-
- {resolvedModelName(entry.next ?? entry.prev)} ({entry.status})
+
+
+ {resolvedModelName(entry.next ?? entry.prev)} ({entry.status})
+
+
-
+
);
}
diff --git a/apps/yaak-client/components/git/GitDropdown.tsx b/apps/yaak-client/components/git/GitDropdown.tsx
index 49a53203..a88661dc 100644
--- a/apps/yaak-client/components/git/GitDropdown.tsx
+++ b/apps/yaak-client/components/git/GitDropdown.tsx
@@ -1,9 +1,9 @@
-import { useGit } from "@yaakapp-internal/git";
+import { useGitBranchInfo, useGitMutations } from "@yaakapp-internal/git";
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { HTMLAttributes } from "react";
-import { forwardRef } from "react";
+import { forwardRef, useCallback, useMemo } from "react";
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace";
import { useKeyValue } from "../../hooks/useKeyValue";
@@ -12,17 +12,20 @@ import { sync } from "../../init/sync";
import { showConfirm, showConfirmDelete } from "../../lib/confirm";
import { fireAndForget } from "../../lib/fireAndForget";
import { showDialog } from "../../lib/dialog";
+import { gitWorktreeStatusAtom } from "../../lib/gitWorktreeStatus";
import { showPrompt } from "../../lib/prompt";
import { showErrorToast, showToast } from "../../lib/toast";
import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown";
import { Banner, Icon, InlineCode } from "@yaakapp-internal/ui";
-import { gitCallbacks } from "./callbacks";
+import { useGitCallbacks } from "./callbacks";
import { GitCommitDialog } from "./GitCommitDialog";
import { GitRemotesDialog } from "./GitRemotesDialog";
import { handlePullResult, handlePushResult } from "./git-util";
import { HistoryDialog } from "./HistoryDialog";
+const EMPTY_BRANCHES: string[] = [];
+
export function GitDropdown() {
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
if (workspaceMeta == null) return null;
@@ -36,469 +39,493 @@ export function GitDropdown() {
function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const workspace = useAtomValue(activeWorkspaceAtom);
+ const worktreeStatus = useAtomValue(gitWorktreeStatusAtom);
const [refreshKey, regenerateKey] = useRandomKey();
- const [
- { status, log },
- {
- createBranch,
- deleteBranch,
- deleteRemoteBranch,
- renameBranch,
- mergeBranch,
- push,
- pull,
- checkout,
- resetChanges,
- init,
- },
- ] = useGit(syncDir, gitCallbacks(syncDir), refreshKey);
+ const branchInfo = useGitBranchInfo(syncDir, refreshKey);
+ const callbacks = useGitCallbacks(syncDir);
+ const {
+ createBranch,
+ deleteBranch,
+ deleteRemoteBranch,
+ renameBranch,
+ mergeBranch,
+ push,
+ pull,
+ checkout,
+ resetChanges,
+ init,
+ } = useGitMutations(syncDir, callbacks);
- const localBranches = status.data?.localBranches ?? [];
- const remoteBranches = status.data?.remoteBranches ?? [];
- const remoteOnlyBranches = remoteBranches.filter(
- (b) => !localBranches.includes(b.replace(/^origin\//, "")),
+ const localBranches = branchInfo.data?.localBranches ?? EMPTY_BRANCHES;
+ const remoteBranches = branchInfo.data?.remoteBranches ?? EMPTY_BRANCHES;
+ const remoteOnlyBranches = useMemo(
+ () => remoteBranches.filter((b) => !localBranches.includes(b.replace(/^origin\//, ""))),
+ [localBranches, remoteBranches],
);
+ const currentBranch = branchInfo.data?.headRefShorthand;
+ const hasChanges = worktreeStatus?.entries.some((e) => e.status !== "current") ?? false;
+ const ahead = branchInfo.data?.ahead ?? 0;
+ const behind = branchInfo.data?.behind ?? 0;
+ const initRepo = useCallback(() => {
+ init.mutate();
+ }, [init]);
+
+ const items: DropdownItem[] = useMemo(() => {
+ if (workspace == null || branchInfo.data == null) return [];
+
+ const tryCheckout = (branch: string, force: boolean) => {
+ checkout.mutate(
+ { branch, force },
+ {
+ disableToastError: true,
+ async onError(err) {
+ if (!force) {
+ // Checkout failed so ask user if they want to force it
+ const forceCheckout = await showConfirm({
+ id: "git-force-checkout",
+ title: "Conflicts Detected",
+ description:
+ "Your branch has conflicts. Either make a commit or force checkout to discard changes.",
+ confirmText: "Force Checkout",
+ color: "warning",
+ });
+ if (forceCheckout) {
+ tryCheckout(branch, true);
+ }
+ } else {
+ // Checkout failed
+ showErrorToast({
+ id: "git-checkout-error",
+ title: "Error checking out branch",
+ message: String(err),
+ });
+ }
+ },
+ async onSuccess(branchName) {
+ showToast({
+ id: "git-checkout-success",
+ message: (
+ <>
+ Switched branch
{branchName}
+ >
+ ),
+ color: "success",
+ });
+ await sync({ force: true });
+ },
+ },
+ );
+ };
+
+ return [
+ {
+ label: "View History...",
+ leftSlot:
,
+ onSelect: async () => {
+ showDialog({
+ id: "git-history",
+ size: "md",
+ title: "Commit History",
+ noPadding: true,
+ render: () =>
,
+ });
+ },
+ },
+ {
+ label: "Manage Remotes...",
+ leftSlot:
,
+ onSelect: () => GitRemotesDialog.show(syncDir),
+ },
+ { type: "separator" },
+ {
+ label: "New Branch...",
+ leftSlot:
,
+ async onSelect() {
+ const name = await showPrompt({
+ id: "git-branch-name",
+ title: "Create Branch",
+ label: "Branch Name",
+ });
+ if (!name) return;
+
+ await createBranch.mutateAsync(
+ { branch: name },
+ {
+ disableToastError: true,
+ onError: (err) => {
+ showErrorToast({
+ id: "git-branch-error",
+ title: "Error creating branch",
+ message: String(err),
+ });
+ },
+ },
+ );
+ tryCheckout(name, false);
+ },
+ },
+ { type: "separator" },
+ {
+ label: "Push",
+ leftSlot:
,
+ waitForOnSelect: true,
+ async onSelect() {
+ await push.mutateAsync(undefined, {
+ disableToastError: true,
+ onSuccess: handlePushResult,
+ onError(err) {
+ showErrorToast({
+ id: "git-push-error",
+ title: "Error pushing changes",
+ message: String(err),
+ });
+ },
+ });
+ },
+ },
+ {
+ label: "Pull",
+ leftSlot:
,
+ waitForOnSelect: true,
+ async onSelect() {
+ await pull.mutateAsync(undefined, {
+ disableToastError: true,
+ onSuccess: handlePullResult,
+ onError(err) {
+ showErrorToast({
+ id: "git-pull-error",
+ title: "Error pulling changes",
+ message: String(err),
+ });
+ },
+ });
+ },
+ },
+ {
+ label: "Commit...",
+
+ leftSlot:
,
+ onSelect() {
+ showDialog({
+ id: "commit",
+ title: "Commit Changes",
+ size: "full",
+ noPadding: true,
+ render: ({ hide }) => (
+
+ ),
+ });
+ },
+ },
+ {
+ label: "Reset Changes",
+ hidden: !hasChanges,
+ leftSlot:
,
+ color: "danger",
+ async onSelect() {
+ const confirmed = await showConfirm({
+ id: "git-reset-changes",
+ title: "Reset Changes",
+ description: "This will discard all uncommitted changes. This cannot be undone.",
+ confirmText: "Reset",
+ color: "danger",
+ });
+ if (!confirmed) return;
+
+ await resetChanges.mutateAsync(undefined, {
+ disableToastError: true,
+ onSuccess() {
+ showToast({
+ id: "git-reset-success",
+ message: "Changes have been reset",
+ color: "success",
+ });
+ fireAndForget(sync({ force: true }));
+ },
+ onError(err) {
+ showErrorToast({
+ id: "git-reset-error",
+ title: "Error resetting changes",
+ message: String(err),
+ });
+ },
+ });
+ },
+ },
+ { type: "separator", label: "Branches", hidden: localBranches.length < 1 },
+ ...localBranches.map((branch) => {
+ const isCurrent = currentBranch === branch;
+ return {
+ label: branch,
+ leftSlot:
,
+ submenuOpenOnClick: true,
+ submenu: [
+ {
+ label: "Checkout",
+ hidden: isCurrent,
+ onSelect: () => tryCheckout(branch, false),
+ },
+ {
+ label: (
+ <>
+ Merge into
{currentBranch}
+ >
+ ),
+ hidden: isCurrent,
+ async onSelect() {
+ await mergeBranch.mutateAsync(
+ { branch },
+ {
+ disableToastError: true,
+ onSuccess() {
+ showToast({
+ id: "git-merged-branch",
+ message: (
+ <>
+ Merged
{branch} into{" "}
+
{currentBranch}
+ >
+ ),
+ });
+ fireAndForget(sync({ force: true }));
+ },
+ onError(err) {
+ showErrorToast({
+ id: "git-merged-branch-error",
+ title: "Error merging branch",
+ message: String(err),
+ });
+ },
+ },
+ );
+ },
+ },
+ {
+ label: "New Branch...",
+ async onSelect() {
+ const name = await showPrompt({
+ id: "git-new-branch-from",
+ title: "New Branch",
+ description: (
+ <>
+ Create a new branch from
{branch}
+ >
+ ),
+ label: "Branch Name",
+ });
+ if (!name) return;
+
+ await createBranch.mutateAsync(
+ { branch: name, base: branch },
+ {
+ disableToastError: true,
+ onError: (err) => {
+ showErrorToast({
+ id: "git-branch-error",
+ title: "Error creating branch",
+ message: String(err),
+ });
+ },
+ },
+ );
+ tryCheckout(name, false);
+ },
+ },
+ {
+ label: "Rename...",
+ async onSelect() {
+ const newName = await showPrompt({
+ id: "git-rename-branch",
+ title: "Rename Branch",
+ label: "New Branch Name",
+ defaultValue: branch,
+ });
+ if (!newName || newName === branch) return;
+
+ await renameBranch.mutateAsync(
+ { oldName: branch, newName },
+ {
+ disableToastError: true,
+ onSuccess() {
+ showToast({
+ id: "git-rename-branch-success",
+ message: (
+ <>
+ Renamed
{branch} to{" "}
+
{newName}
+ >
+ ),
+ color: "success",
+ });
+ },
+ onError(err) {
+ showErrorToast({
+ id: "git-rename-branch-error",
+ title: "Error renaming branch",
+ message: String(err),
+ });
+ },
+ },
+ );
+ },
+ },
+ { type: "separator", hidden: isCurrent },
+ {
+ label: "Delete",
+ color: "danger",
+ hidden: isCurrent,
+ onSelect: async () => {
+ const confirmed = await showConfirmDelete({
+ id: "git-delete-branch",
+ title: "Delete Branch",
+ description: (
+ <>
+ Permanently delete
{branch}?
+ >
+ ),
+ });
+ if (!confirmed) {
+ return;
+ }
+
+ const result = await deleteBranch.mutateAsync(
+ { branch },
+ {
+ disableToastError: true,
+ onError(err) {
+ showErrorToast({
+ id: "git-delete-branch-error",
+ title: "Error deleting branch",
+ message: String(err),
+ });
+ },
+ },
+ );
+
+ if (result.type === "not_fully_merged") {
+ const confirmed = await showConfirm({
+ id: "force-branch-delete",
+ title: "Branch not fully merged",
+ description: (
+ <>
+
+ Branch {branch} is not fully merged.
+
+
Do you want to delete it anyway?
+ >
+ ),
+ });
+ if (confirmed) {
+ await deleteBranch.mutateAsync(
+ { branch, force: true },
+ {
+ disableToastError: true,
+ onError(err) {
+ showErrorToast({
+ id: "git-force-delete-branch-error",
+ title: "Error force deleting branch",
+ message: String(err),
+ });
+ },
+ },
+ );
+ }
+ }
+ },
+ },
+ ],
+ } satisfies DropdownItem;
+ }),
+ ...remoteOnlyBranches.map((branch) => {
+ const isCurrent = currentBranch === branch;
+ return {
+ label: branch,
+ leftSlot:
,
+ submenuOpenOnClick: true,
+ submenu: [
+ {
+ label: "Checkout",
+ hidden: isCurrent,
+ onSelect: () => tryCheckout(branch, false),
+ },
+ {
+ label: "Delete",
+ color: "danger",
+ async onSelect() {
+ const confirmed = await showConfirmDelete({
+ id: "git-delete-remote-branch",
+ title: "Delete Remote Branch",
+ description: (
+ <>
+ Permanently delete
{branch} from the remote?
+ >
+ ),
+ });
+ if (!confirmed) return;
+
+ await deleteRemoteBranch.mutateAsync(
+ { branch },
+ {
+ disableToastError: true,
+ onSuccess() {
+ showToast({
+ id: "git-delete-remote-branch-success",
+ message: (
+ <>
+ Deleted remote branch
{branch}
+ >
+ ),
+ color: "success",
+ });
+ },
+ onError(err) {
+ showErrorToast({
+ id: "git-delete-remote-branch-error",
+ title: "Error deleting remote branch",
+ message: String(err),
+ });
+ },
+ },
+ );
+ },
+ },
+ ],
+ } satisfies DropdownItem;
+ }),
+ ];
+ }, [
+ branchInfo.data,
+ checkout,
+ createBranch,
+ currentBranch,
+ deleteBranch,
+ deleteRemoteBranch,
+ hasChanges,
+ localBranches,
+ mergeBranch,
+ pull,
+ push,
+ remoteOnlyBranches,
+ renameBranch,
+ resetChanges,
+ syncDir,
+ workspace,
+ ]);
+
if (workspace == null) {
return null;
}
- const noRepo = status.error?.includes("not found");
+ const noRepo = branchInfo.error?.includes("not found");
if (noRepo) {
- return
;
+ return
;
}
// Still loading
- if (status.data == null) {
+ if (branchInfo.data == null) {
return null;
}
- const currentBranch = status.data.headRefShorthand;
- const hasChanges = status.data.entries.some((e) => e.status !== "current");
- const _hasRemotes = (status.data.origins ?? []).length > 0;
- const { ahead, behind } = status.data;
-
- const tryCheckout = (branch: string, force: boolean) => {
- checkout.mutate(
- { branch, force },
- {
- disableToastError: true,
- async onError(err) {
- if (!force) {
- // Checkout failed so ask user if they want to force it
- const forceCheckout = await showConfirm({
- id: "git-force-checkout",
- title: "Conflicts Detected",
- description:
- "Your branch has conflicts. Either make a commit or force checkout to discard changes.",
- confirmText: "Force Checkout",
- color: "warning",
- });
- if (forceCheckout) {
- tryCheckout(branch, true);
- }
- } else {
- // Checkout failed
- showErrorToast({
- id: "git-checkout-error",
- title: "Error checking out branch",
- message: String(err),
- });
- }
- },
- async onSuccess(branchName) {
- showToast({
- id: "git-checkout-success",
- message: (
- <>
- Switched branch
{branchName}
- >
- ),
- color: "success",
- });
- await sync({ force: true });
- },
- },
- );
- };
-
- const items: DropdownItem[] = [
- {
- label: "View History...",
- hidden: (log.data ?? []).length === 0,
- leftSlot:
,
- onSelect: async () => {
- showDialog({
- id: "git-history",
- size: "md",
- title: "Commit History",
- noPadding: true,
- render: () =>
,
- });
- },
- },
- {
- label: "Manage Remotes...",
- leftSlot:
,
- onSelect: () => GitRemotesDialog.show(syncDir),
- },
- { type: "separator" },
- {
- label: "New Branch...",
- leftSlot:
,
- async onSelect() {
- const name = await showPrompt({
- id: "git-branch-name",
- title: "Create Branch",
- label: "Branch Name",
- });
- if (!name) return;
-
- await createBranch.mutateAsync(
- { branch: name },
- {
- disableToastError: true,
- onError: (err) => {
- showErrorToast({
- id: "git-branch-error",
- title: "Error creating branch",
- message: String(err),
- });
- },
- },
- );
- tryCheckout(name, false);
- },
- },
- { type: "separator" },
- {
- label: "Push",
- leftSlot:
,
- waitForOnSelect: true,
- async onSelect() {
- await push.mutateAsync(undefined, {
- disableToastError: true,
- onSuccess: handlePushResult,
- onError(err) {
- showErrorToast({
- id: "git-push-error",
- title: "Error pushing changes",
- message: String(err),
- });
- },
- });
- },
- },
- {
- label: "Pull",
- leftSlot:
,
- waitForOnSelect: true,
- async onSelect() {
- await pull.mutateAsync(undefined, {
- disableToastError: true,
- onSuccess: handlePullResult,
- onError(err) {
- showErrorToast({
- id: "git-pull-error",
- title: "Error pulling changes",
- message: String(err),
- });
- },
- });
- },
- },
- {
- label: "Commit...",
-
- leftSlot:
,
- onSelect() {
- showDialog({
- id: "commit",
- title: "Commit Changes",
- size: "full",
- noPadding: true,
- render: ({ hide }) => (
-
- ),
- });
- },
- },
- {
- label: "Reset Changes",
- hidden: !hasChanges,
- leftSlot:
,
- color: "danger",
- async onSelect() {
- const confirmed = await showConfirm({
- id: "git-reset-changes",
- title: "Reset Changes",
- description: "This will discard all uncommitted changes. This cannot be undone.",
- confirmText: "Reset",
- color: "danger",
- });
- if (!confirmed) return;
-
- await resetChanges.mutateAsync(undefined, {
- disableToastError: true,
- onSuccess() {
- showToast({
- id: "git-reset-success",
- message: "Changes have been reset",
- color: "success",
- });
- fireAndForget(sync({ force: true }));
- },
- onError(err) {
- showErrorToast({
- id: "git-reset-error",
- title: "Error resetting changes",
- message: String(err),
- });
- },
- });
- },
- },
- { type: "separator", label: "Branches", hidden: localBranches.length < 1 },
- ...localBranches.map((branch) => {
- const isCurrent = currentBranch === branch;
- return {
- label: branch,
- leftSlot:
,
- submenuOpenOnClick: true,
- submenu: [
- {
- label: "Checkout",
- hidden: isCurrent,
- onSelect: () => tryCheckout(branch, false),
- },
- {
- label: (
- <>
- Merge into
{currentBranch}
- >
- ),
- hidden: isCurrent,
- async onSelect() {
- await mergeBranch.mutateAsync(
- { branch },
- {
- disableToastError: true,
- onSuccess() {
- showToast({
- id: "git-merged-branch",
- message: (
- <>
- Merged
{branch} into{" "}
-
{currentBranch}
- >
- ),
- });
- fireAndForget(sync({ force: true }));
- },
- onError(err) {
- showErrorToast({
- id: "git-merged-branch-error",
- title: "Error merging branch",
- message: String(err),
- });
- },
- },
- );
- },
- },
- {
- label: "New Branch...",
- async onSelect() {
- const name = await showPrompt({
- id: "git-new-branch-from",
- title: "New Branch",
- description: (
- <>
- Create a new branch from
{branch}
- >
- ),
- label: "Branch Name",
- });
- if (!name) return;
-
- await createBranch.mutateAsync(
- { branch: name, base: branch },
- {
- disableToastError: true,
- onError: (err) => {
- showErrorToast({
- id: "git-branch-error",
- title: "Error creating branch",
- message: String(err),
- });
- },
- },
- );
- tryCheckout(name, false);
- },
- },
- {
- label: "Rename...",
- async onSelect() {
- const newName = await showPrompt({
- id: "git-rename-branch",
- title: "Rename Branch",
- label: "New Branch Name",
- defaultValue: branch,
- });
- if (!newName || newName === branch) return;
-
- await renameBranch.mutateAsync(
- { oldName: branch, newName },
- {
- disableToastError: true,
- onSuccess() {
- showToast({
- id: "git-rename-branch-success",
- message: (
- <>
- Renamed
{branch} to{" "}
-
{newName}
- >
- ),
- color: "success",
- });
- },
- onError(err) {
- showErrorToast({
- id: "git-rename-branch-error",
- title: "Error renaming branch",
- message: String(err),
- });
- },
- },
- );
- },
- },
- { type: "separator", hidden: isCurrent },
- {
- label: "Delete",
- color: "danger",
- hidden: isCurrent,
- onSelect: async () => {
- const confirmed = await showConfirmDelete({
- id: "git-delete-branch",
- title: "Delete Branch",
- description: (
- <>
- Permanently delete
{branch}?
- >
- ),
- });
- if (!confirmed) {
- return;
- }
-
- const result = await deleteBranch.mutateAsync(
- { branch },
- {
- disableToastError: true,
- onError(err) {
- showErrorToast({
- id: "git-delete-branch-error",
- title: "Error deleting branch",
- message: String(err),
- });
- },
- },
- );
-
- if (result.type === "not_fully_merged") {
- const confirmed = await showConfirm({
- id: "force-branch-delete",
- title: "Branch not fully merged",
- description: (
- <>
-
- Branch {branch} is not fully merged.
-
-
Do you want to delete it anyway?
- >
- ),
- });
- if (confirmed) {
- await deleteBranch.mutateAsync(
- { branch, force: true },
- {
- disableToastError: true,
- onError(err) {
- showErrorToast({
- id: "git-force-delete-branch-error",
- title: "Error force deleting branch",
- message: String(err),
- });
- },
- },
- );
- }
- }
- },
- },
- ],
- } satisfies DropdownItem;
- }),
- ...remoteOnlyBranches.map((branch) => {
- const isCurrent = currentBranch === branch;
- return {
- label: branch,
- leftSlot:
,
- submenuOpenOnClick: true,
- submenu: [
- {
- label: "Checkout",
- hidden: isCurrent,
- onSelect: () => tryCheckout(branch, false),
- },
- {
- label: "Delete",
- color: "danger",
- async onSelect() {
- const confirmed = await showConfirmDelete({
- id: "git-delete-remote-branch",
- title: "Delete Remote Branch",
- description: (
- <>
- Permanently delete
{branch} from the remote?
- >
- ),
- });
- if (!confirmed) return;
-
- await deleteRemoteBranch.mutateAsync(
- { branch },
- {
- disableToastError: true,
- onSuccess() {
- showToast({
- id: "git-delete-remote-branch-success",
- message: (
- <>
- Deleted remote branch
{branch}
- >
- ),
- color: "success",
- });
- },
- onError(err) {
- showErrorToast({
- id: "git-delete-remote-branch-error",
- title: "Error deleting remote branch",
- message: String(err),
- });
- },
- },
- );
- },
- },
- ],
- } satisfies DropdownItem;
- }),
- ];
-
return (
diff --git a/apps/yaak-client/components/git/GitRemotesDialog.tsx b/apps/yaak-client/components/git/GitRemotesDialog.tsx
index f72b546a..8a9be720 100644
--- a/apps/yaak-client/components/git/GitRemotesDialog.tsx
+++ b/apps/yaak-client/components/git/GitRemotesDialog.tsx
@@ -10,7 +10,7 @@ import {
TableHeaderCell,
TableRow,
} from "@yaakapp-internal/ui";
-import { gitCallbacks } from "./callbacks";
+import { useGitCallbacks } from "./callbacks";
import { addGitRemote } from "./showAddRemoteDialog";
interface Props {
@@ -19,7 +19,8 @@ interface Props {
}
export function GitRemotesDialog({ dir }: Props) {
- const [{ remotes }, { rmRemote }] = useGit(dir, gitCallbacks(dir));
+ const callbacks = useGitCallbacks(dir);
+ const [{ remotes }, { rmRemote }] = useGit(dir, callbacks);
return (
diff --git a/apps/yaak-client/components/git/HistoryDialog.tsx b/apps/yaak-client/components/git/HistoryDialog.tsx
index b1f78c0f..90324022 100644
--- a/apps/yaak-client/components/git/HistoryDialog.tsx
+++ b/apps/yaak-client/components/git/HistoryDialog.tsx
@@ -1,4 +1,4 @@
-import type { GitCommit } from "@yaakapp-internal/git";
+import { useGitLog } from "@yaakapp-internal/git";
import { formatDistanceToNowStrict } from "date-fns";
import {
Table,
@@ -10,11 +10,9 @@ import {
TruncatedWideTableCell,
} from "@yaakapp-internal/ui";
-interface Props {
- log: GitCommit[];
-}
+export function HistoryDialog({ dir }: { dir: string }) {
+ const log = useGitLog(dir);
-export function HistoryDialog({ log }: Props) {
return (
@@ -26,8 +24,8 @@ export function HistoryDialog({ log }: Props) {
- {log.map((l) => (
-
+ {(log.data ?? []).map((l) => (
+
{l.message || No message}
diff --git a/apps/yaak-client/components/git/callbacks.tsx b/apps/yaak-client/components/git/callbacks.tsx
index 3629f33f..b788c67c 100644
--- a/apps/yaak-client/components/git/callbacks.tsx
+++ b/apps/yaak-client/components/git/callbacks.tsx
@@ -1,4 +1,5 @@
import type { GitCallbacks } from "@yaakapp-internal/git";
+import { useMemo } from "react";
import { sync } from "../../init/sync";
import { promptCredentials } from "./credentials";
import { promptDivergedStrategy } from "./diverged";
@@ -24,3 +25,7 @@ export function gitCallbacks(dir: string): GitCallbacks {
forceSync: () => sync({ force: true }),
};
}
+
+export function useGitCallbacks(dir: string): GitCallbacks {
+ return useMemo(() => gitCallbacks(dir), [dir]);
+}
diff --git a/apps/yaak-client/init/git.ts b/apps/yaak-client/init/git.ts
new file mode 100644
index 00000000..7267691f
--- /dev/null
+++ b/apps/yaak-client/init/git.ts
@@ -0,0 +1,38 @@
+import { watchGitWorktreeStatus, type GitWorktreeStatusEntry } from "@yaakapp-internal/git";
+import { activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
+import { gitWorktreeStatusAtom, gitWorktreeStatusByModelIdAtom } from "../lib/gitWorktreeStatus";
+import { jotaiStore } from "../lib/jotai";
+
+export function initGit() {
+ let watchedDir: string | null = null;
+ let unwatch: null | ReturnType = null;
+
+ const watchActiveWorkspace = () => {
+ const syncDir = jotaiStore.get(activeWorkspaceMetaAtom)?.settingSyncDir ?? null;
+ if (syncDir === watchedDir) return;
+
+ void unwatch?.();
+ unwatch = null;
+ watchedDir = syncDir;
+ jotaiStore.set(gitWorktreeStatusAtom, null);
+ jotaiStore.set(gitWorktreeStatusByModelIdAtom, {});
+
+ if (syncDir == null) return;
+
+ unwatch = watchGitWorktreeStatus(syncDir, (status) => {
+ if (syncDir !== watchedDir) return;
+
+ jotaiStore.set(gitWorktreeStatusAtom, status);
+
+ const statusByModelId: Record = {};
+ for (const entry of status.entries) {
+ if (entry.modelId == null) continue;
+ statusByModelId[entry.modelId] = entry;
+ }
+ jotaiStore.set(gitWorktreeStatusByModelIdAtom, statusByModelId);
+ });
+ };
+
+ watchActiveWorkspace();
+ jotaiStore.sub(activeWorkspaceMetaAtom, watchActiveWorkspace);
+}
diff --git a/apps/yaak-client/init/sync.ts b/apps/yaak-client/init/sync.ts
index bc856a2d..61412711 100644
--- a/apps/yaak-client/init/sync.ts
+++ b/apps/yaak-client/init/sync.ts
@@ -1,4 +1,4 @@
-import { debounce } from "@yaakapp-internal/lib";
+import { debounce, eagerDebounceAsync } from "@yaakapp-internal/lib";
import type { AnyModel, ModelPayload } from "@yaakapp-internal/models";
import { watchWorkspaceFiles } from "@yaakapp-internal/sync";
import { syncWorkspace } from "../commands/commands";
@@ -25,9 +25,8 @@ export async function sync({ force }: { force?: boolean } = {}) {
});
}
-const debouncedSync = debounce(async () => {
- await sync();
-}, 1000);
+const syncAfterFileChange = debounce(sync, 1000);
+const syncAfterModelWrite = eagerDebounceAsync(sync, 1000);
/**
* Subscribe to model change events. Since we check the workspace ID on sync, we can
@@ -35,7 +34,7 @@ const debouncedSync = debounce(async () => {
*/
function initModelListeners() {
listenToTauriEvent("model_write", (p) => {
- if (isModelRelevant(p.payload.model)) debouncedSync();
+ if (isModelRelevant(p.payload.model)) syncAfterModelWrite();
});
}
@@ -50,11 +49,11 @@ function initFileChangeListeners() {
await unsub?.(); // Unsub to previous
const workspaceMeta = jotaiStore.get(activeWorkspaceMetaAtom);
if (workspaceMeta == null || workspaceMeta.settingSyncDir == null) return;
- debouncedSync(); // Perform an initial sync when switching workspace
+ syncAfterFileChange(); // Perform an initial sync when switching workspace
unsub = watchWorkspaceFiles(
workspaceMeta.workspaceId,
workspaceMeta.settingSyncDir,
- debouncedSync,
+ syncAfterFileChange,
);
});
}
diff --git a/apps/yaak-client/lib/diffYaml.ts b/apps/yaak-client/lib/diffYaml.ts
index 248a9cf4..42a12023 100644
--- a/apps/yaak-client/lib/diffYaml.ts
+++ b/apps/yaak-client/lib/diffYaml.ts
@@ -2,8 +2,7 @@ import type { SyncModel } from "@yaakapp-internal/git";
import { stringify } from "yaml";
/**
- * Convert a SyncModel to a clean YAML string for diffing.
- * Removes noisy fields like updatedAt that change on every edit.
+ * Convert a SyncModel to a YAML string for diffing.
*/
export function modelToYaml(model: SyncModel | null): string {
if (!model) return "";
diff --git a/apps/yaak-client/lib/gitWorktreeStatus.ts b/apps/yaak-client/lib/gitWorktreeStatus.ts
new file mode 100644
index 00000000..0829e750
--- /dev/null
+++ b/apps/yaak-client/lib/gitWorktreeStatus.ts
@@ -0,0 +1,22 @@
+import type { GitWorktreeStatus, GitWorktreeStatusEntry } from "@yaakapp-internal/git";
+import { atom } from "jotai";
+import { atomFamily } from "jotai-family";
+import { selectAtom } from "jotai/utils";
+
+export const gitWorktreeStatusAtom = atom(null);
+
+export const gitWorktreeStatusByModelIdAtom = atom>({});
+
+export const gitWorktreeStatusFamily = atomFamily(
+ (modelId: string) =>
+ selectAtom(
+ gitWorktreeStatusByModelIdAtom,
+ (statusByModelId) => statusByModelId[modelId] ?? null,
+ (a, b) =>
+ a?.relaPath === b?.relaPath &&
+ a?.status === b?.status &&
+ a?.staged === b?.staged &&
+ a?.modelId === b?.modelId,
+ ),
+ Object.is,
+);
diff --git a/apps/yaak-client/main.tsx b/apps/yaak-client/main.tsx
index 80233dbd..5c4e073c 100644
--- a/apps/yaak-client/main.tsx
+++ b/apps/yaak-client/main.tsx
@@ -5,6 +5,7 @@ import { changeModelStoreWorkspace, initModelStore } from "@yaakapp-internal/mod
import { setPlatformOnDocument } from "@yaakapp-internal/theme";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
+import { initGit } from "./init/git";
import { initSync } from "./init/sync";
import { initGlobalListeners } from "./lib/initGlobalListeners";
import { jotaiStore } from "./lib/jotai";
@@ -31,6 +32,7 @@ window.addEventListener("keydown", (e) => {
});
// Initialize a bunch of watchers
+initGit();
initSync();
initModelStore(jotaiStore);
initGlobalListeners();
diff --git a/apps/yaak-client/package.json b/apps/yaak-client/package.json
index 0f22b67e..38cd1d7f 100644
--- a/apps/yaak-client/package.json
+++ b/apps/yaak-client/package.json
@@ -31,14 +31,14 @@
"@tanstack/react-query": "^5.90.5",
"@tanstack/react-router": "^1.133.13",
"@tanstack/react-virtual": "^3.13.12",
- "@tauri-apps/api": "^2.9.1",
+ "@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
- "@tauri-apps/plugin-dialog": "^2.4.2",
- "@tauri-apps/plugin-fs": "^2.4.4",
- "@tauri-apps/plugin-log": "^2.7.1",
- "@tauri-apps/plugin-opener": "^2.5.2",
+ "@tauri-apps/plugin-dialog": "^2.7.1",
+ "@tauri-apps/plugin-fs": "^2.5.1",
+ "@tauri-apps/plugin-log": "^2.8.0",
+ "@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-os": "^2.3.2",
- "@tauri-apps/plugin-shell": "^2.3.3",
+ "@tauri-apps/plugin-shell": "^2.3.5",
"buffer": "^6.0.3",
"classnames": "^2.5.1",
"cm6-graphql": "^0.2.1",
@@ -52,6 +52,7 @@
"hexy": "^0.3.5",
"history": "^5.3.0",
"jotai": "^2.18.0",
+ "jotai-family": "^1.0.1",
"js-md5": "^0.8.3",
"lucide-react": "^0.525.0",
"mime": "^4.0.4",
diff --git a/apps/yaak-client/vite.config.ts b/apps/yaak-client/vite.config.ts
index 1e72e4c6..00756360 100644
--- a/apps/yaak-client/vite.config.ts
+++ b/apps/yaak-client/vite.config.ts
@@ -3,7 +3,7 @@ import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import { createRequire } from "node:module";
import path from "node:path";
-import { defineConfig, normalizePath } from "vite";
+import { defineConfig, normalizePath } from "vite-plus";
import { viteStaticCopy } from "vite-plugin-static-copy";
import svgr from "vite-plugin-svgr";
import topLevelAwait from "vite-plugin-top-level-await";
@@ -42,12 +42,15 @@ export default defineConfig(async () => {
sourcemap: true,
outDir: "../../dist/apps/yaak-client",
emptyOutDir: true,
- rollupOptions: {
+ rolldownOptions: {
output: {
// Make chunk names readable
chunkFileNames: "assets/chunk-[name]-[hash].js",
entryFileNames: "assets/entry-[name]-[hash].js",
assetFileNames: "assets/asset-[name]-[hash][extname]",
+ // Vite-Plus/Rolldown 0.1.20 can emit a stale style-mod export when
+ // top-level var rewriting combines with OXC minification.
+ topLevelVar: false,
},
},
},
diff --git a/apps/yaak-proxy/components/Sidebar.tsx b/apps/yaak-proxy/components/Sidebar.tsx
index f001dc6a..74c19cfc 100644
--- a/apps/yaak-proxy/components/Sidebar.tsx
+++ b/apps/yaak-proxy/components/Sidebar.tsx
@@ -2,7 +2,7 @@ import type { HttpExchange } from "@yaakapp-internal/proxy-lib";
import type { TreeNode } from "@yaakapp-internal/ui";
import { selectedIdsFamily, Tree } from "@yaakapp-internal/ui";
import { atom, useAtomValue } from "jotai";
-import { atomFamily } from "jotai/utils";
+import { atomFamily } from "jotai-family";
import { useCallback } from "react";
import { httpExchangesAtom } from "../lib/store";
diff --git a/apps/yaak-proxy/package.json b/apps/yaak-proxy/package.json
index 4b2f6323..0386c049 100644
--- a/apps/yaak-proxy/package.json
+++ b/apps/yaak-proxy/package.json
@@ -10,7 +10,7 @@
},
"dependencies": {
"@tanstack/react-query": "^5.90.5",
- "@tauri-apps/api": "^2.9.1",
+ "@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-os": "^2.3.2",
"@yaakapp-internal/model-store": "^1.0.0",
"@yaakapp-internal/proxy-lib": "^1.0.0",
@@ -18,6 +18,7 @@
"@yaakapp-internal/ui": "^1.0.0",
"classnames": "^2.5.1",
"jotai": "^2.18.0",
+ "jotai-family": "^1.0.1",
"motion": "^12.4.7",
"react": "^19.2.0",
"react-dom": "^19.2.0"
diff --git a/crates-proxy/yaak-proxy-lib/src/actions.rs b/crates-proxy/yaak-proxy-lib/src/actions.rs
index 278e97a6..cf2084eb 100644
--- a/crates-proxy/yaak-proxy-lib/src/actions.rs
+++ b/crates-proxy/yaak-proxy-lib/src/actions.rs
@@ -25,11 +25,7 @@ pub struct ActionMetadata {
}
fn default_hotkey(mac: &str, other: &str) -> Option {
- if cfg!(target_os = "macos") {
- Some(mac.into())
- } else {
- Some(other.into())
- }
+ if cfg!(target_os = "macos") { Some(mac.into()) } else { Some(other.into()) }
}
/// All global actions with their metadata, used by `list_actions` RPC.
diff --git a/crates-proxy/yaak-proxy-lib/src/db.rs b/crates-proxy/yaak-proxy-lib/src/db.rs
index d0332738..e5bba29d 100644
--- a/crates-proxy/yaak-proxy-lib/src/db.rs
+++ b/crates-proxy/yaak-proxy-lib/src/db.rs
@@ -14,10 +14,8 @@ pub struct ProxyQueryManager {
impl ProxyQueryManager {
pub fn new(db_path: &Path) -> Self {
let manager = SqliteConnectionManager::file(db_path);
- let pool = Pool::builder()
- .max_size(5)
- .build(manager)
- .expect("Failed to create proxy DB pool");
+ let pool =
+ Pool::builder().max_size(5).build(manager).expect("Failed to create proxy DB pool");
run_migrations(&pool, &MIGRATIONS).expect("Failed to run proxy DB migrations");
Self { pool }
}
diff --git a/crates-proxy/yaak-proxy-lib/src/lib.rs b/crates-proxy/yaak-proxy-lib/src/lib.rs
index 36ddaf25..af72a6b6 100644
--- a/crates-proxy/yaak-proxy-lib/src/lib.rs
+++ b/crates-proxy/yaak-proxy-lib/src/lib.rs
@@ -2,18 +2,18 @@ pub mod actions;
pub mod db;
pub mod models;
+use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction};
+use crate::db::ProxyQueryManager;
+use crate::models::{HttpExchange, ModelPayload, ProxyHeader};
+use log::warn;
+use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
-use log::warn;
-use serde::{Deserialize, Serialize};
use ts_rs::TS;
use yaak_database::{ModelChangeEvent, UpdateSource};
use yaak_proxy::{CapturedRequest, ProxyEvent, ProxyHandle, RequestState};
use yaak_rpc::{RpcError, RpcEventEmitter, define_rpc};
-use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction};
-use crate::db::ProxyQueryManager;
-use crate::models::{HttpExchange, ModelPayload, ProxyHeader};
// -- Context --
@@ -25,11 +25,7 @@ pub struct ProxyCtx {
impl ProxyCtx {
pub fn new(db_path: &Path, events: RpcEventEmitter) -> Self {
- Self {
- handle: Mutex::new(None),
- db: ProxyQueryManager::new(db_path),
- events,
- }
+ Self { handle: Mutex::new(None), db: ProxyQueryManager::new(db_path), events }
}
}
@@ -88,17 +84,15 @@ fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result match action {
GlobalAction::ProxyStart => {
- let mut handle = ctx
- .handle
- .lock()
- .map_err(|_| RpcError { message: "lock poisoned".into() })?;
+ let mut handle =
+ ctx.handle.lock().map_err(|_| RpcError { message: "lock poisoned".into() })?;
if handle.is_some() {
return Ok(true); // already running
}
- let mut proxy_handle = yaak_proxy::start_proxy(9090)
- .map_err(|e| RpcError { message: e })?;
+ let mut proxy_handle =
+ yaak_proxy::start_proxy(9090).map_err(|e| RpcError { message: e })?;
if let Some(event_rx) = proxy_handle.take_event_rx() {
let db = ctx.db.clone();
@@ -107,49 +101,43 @@ fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result {
- let mut handle = ctx
- .handle
- .lock()
- .map_err(|_| RpcError { message: "lock poisoned".into() })?;
+ let mut handle =
+ ctx.handle.lock().map_err(|_| RpcError { message: "lock poisoned".into() })?;
handle.take();
- ctx.events.emit("proxy_state_changed", &ProxyStatePayload {
- state: ProxyState::Stopped,
- });
+ ctx.events
+ .emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Stopped });
Ok(true)
}
},
}
}
-fn get_proxy_state(ctx: &ProxyCtx, _req: GetProxyStateRequest) -> Result {
- let handle = ctx
- .handle
- .lock()
- .map_err(|_| RpcError { message: "lock poisoned".into() })?;
- let state = if handle.is_some() {
- ProxyState::Running
- } else {
- ProxyState::Stopped
- };
+fn get_proxy_state(
+ ctx: &ProxyCtx,
+ _req: GetProxyStateRequest,
+) -> Result {
+ let handle = ctx.handle.lock().map_err(|_| RpcError { message: "lock poisoned".into() })?;
+ let state = if handle.is_some() { ProxyState::Running } else { ProxyState::Stopped };
Ok(GetProxyStateResponse { state })
}
-fn list_actions(_ctx: &ProxyCtx, _req: ListActionsRequest) -> Result {
- Ok(ListActionsResponse {
- actions: crate::actions::all_global_actions(),
- })
+fn list_actions(
+ _ctx: &ProxyCtx,
+ _req: ListActionsRequest,
+) -> Result {
+ Ok(ListActionsResponse { actions: crate::actions::all_global_actions() })
}
fn list_models(ctx: &ProxyCtx, _req: ListModelsRequest) -> Result {
ctx.db.with_conn(|db| {
Ok(ListModelsResponse {
- http_exchanges: db.find_all::()
+ http_exchanges: db
+ .find_all::()
.map_err(|e| RpcError { message: e.to_string() })?,
})
})
@@ -157,28 +145,35 @@ fn list_models(ctx: &ProxyCtx, _req: ListModelsRequest) -> Result, db: ProxyQueryManager, events: RpcEventEmitter) {
+fn run_event_loop(
+ rx: std::sync::mpsc::Receiver,
+ db: ProxyQueryManager,
+ events: RpcEventEmitter,
+) {
let mut in_flight: HashMap = HashMap::new();
while let Ok(event) = rx.recv() {
match event {
ProxyEvent::RequestStart { id, method, url, http_version } => {
- in_flight.insert(id, CapturedRequest {
+ in_flight.insert(
id,
- method,
- url,
- http_version,
- status: None,
- elapsed_ms: None,
- remote_http_version: None,
- request_headers: vec![],
- request_body: None,
- response_headers: vec![],
- response_body: None,
- response_body_size: 0,
- state: RequestState::Sending,
- error: None,
- });
+ CapturedRequest {
+ id,
+ method,
+ url,
+ http_version,
+ status: None,
+ elapsed_ms: None,
+ remote_http_version: None,
+ request_headers: vec![],
+ request_body: None,
+ response_headers: vec![],
+ response_body: None,
+ response_body_size: 0,
+ state: RequestState::Sending,
+ error: None,
+ },
+ );
}
ProxyEvent::RequestHeader { id, name, value } => {
if let Some(r) = in_flight.get_mut(&id) {
@@ -230,28 +225,30 @@ fn write_entry(db: &ProxyQueryManager, events: &RpcEventEmitter, r: &CapturedReq
let entry = HttpExchange {
url: r.url.clone(),
method: r.method.clone(),
- req_headers: r.request_headers.iter()
+ req_headers: r
+ .request_headers
+ .iter()
.map(|(n, v)| ProxyHeader { name: n.clone(), value: v.clone() })
.collect(),
req_body: r.request_body.clone(),
res_status: r.status.map(|s| s as i32),
- res_headers: r.response_headers.iter()
+ res_headers: r
+ .response_headers
+ .iter()
.map(|(n, v)| ProxyHeader { name: n.clone(), value: v.clone() })
.collect(),
res_body: r.response_body.clone(),
error: r.error.clone(),
..Default::default()
};
- db.with_conn(|ctx| {
- match ctx.upsert(&entry, &UpdateSource::Background) {
- Ok((saved, created)) => {
- events.emit("model_write", &ModelPayload {
- model: saved,
- change: ModelChangeEvent::Upsert { created },
- });
- }
- Err(e) => warn!("Failed to write proxy entry: {e}"),
+ db.with_conn(|ctx| match ctx.upsert(&entry, &UpdateSource::Background) {
+ Ok((saved, created)) => {
+ events.emit(
+ "model_write",
+ &ModelPayload { model: saved, change: ModelChangeEvent::Upsert { created } },
+ );
}
+ Err(e) => warn!("Failed to write proxy entry: {e}"),
});
}
diff --git a/crates-proxy/yaak-proxy-lib/src/models.rs b/crates-proxy/yaak-proxy-lib/src/models.rs
index 3c1d5624..0b6249ae 100644
--- a/crates-proxy/yaak-proxy-lib/src/models.rs
+++ b/crates-proxy/yaak-proxy-lib/src/models.rs
@@ -3,7 +3,10 @@ use rusqlite::Row;
use sea_query::{IntoColumnRef, IntoIden, IntoTableRef, Order, SimpleExpr, enum_def};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
-use yaak_database::{ModelChangeEvent, Result as DbResult, UpdateSource, UpsertModelInfo, generate_prefixed_id, upsert_date};
+use yaak_database::{
+ ModelChangeEvent, Result as DbResult, UpdateSource, UpsertModelInfo, generate_prefixed_id,
+ upsert_date,
+};
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
diff --git a/crates-tauri/yaak-app-client/Cargo.toml b/crates-tauri/yaak-app-client/Cargo.toml
index be185b7d..177fff84 100644
--- a/crates-tauri/yaak-app-client/Cargo.toml
+++ b/crates-tauri/yaak-app-client/Cargo.toml
@@ -17,7 +17,7 @@ updater = []
license = ["yaak-license"]
[build-dependencies]
-tauri-build = { version = "2.5.3", features = [] }
+tauri-build = { version = "2.6.1", features = [] }
[target.'cfg(target_os = "linux")'.dependencies]
openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work
@@ -30,6 +30,7 @@ eventsource-client = { git = "https://github.com/yaakapp/rust-eventsource-client
http = { version = "1.2.0", default-features = false }
log = { workspace = true }
md5 = "0.8.0"
+notify = "8.0.0"
pretty_graphql = "0.2"
r2d2 = "0.8.10"
r2d2_sqlite = "0.25.0"
@@ -49,15 +50,15 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["raw_value"] }
tauri = { workspace = true, features = ["devtools", "protocol-asset"] }
tauri-plugin-clipboard-manager = "2.3.2"
-tauri-plugin-deep-link = "2.4.5"
+tauri-plugin-deep-link = "2.4.9"
tauri-plugin-dialog = { workspace = true }
-tauri-plugin-fs = "2.4.4"
-tauri-plugin-log = { version = "2.7.1", features = ["colored"] }
-tauri-plugin-opener = "2.5.2"
+tauri-plugin-fs = "2.5.1"
+tauri-plugin-log = { version = "2.8.0", features = ["colored"] }
+tauri-plugin-opener = "2.5.4"
tauri-plugin-os = "2.3.2"
tauri-plugin-shell = { workspace = true }
-tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] }
-tauri-plugin-updater = "2.9.0"
+tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
+tauri-plugin-updater = "2.10.1"
tauri-plugin-window-state = "2.4.1"
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
diff --git a/crates-tauri/yaak-app-client/bindings/index.ts b/crates-tauri/yaak-app-client/bindings/index.ts
index 8f19e775..28d15eac 100644
--- a/crates-tauri/yaak-app-client/bindings/index.ts
+++ b/crates-tauri/yaak-app-client/bindings/index.ts
@@ -12,6 +12,8 @@ export type UpdateResponseAction = "install" | "skip";
export type WatchResult = { unlistenEvent: string, };
+export type GitWatchResult = { unlistenEvent: string, };
+
export type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, };
export type YaakNotificationAction = { label: string, url: string, };
diff --git a/crates-tauri/yaak-app-client/src/git_ext.rs b/crates-tauri/yaak-app-client/src/git_ext.rs
index c4ae297a..004d65e0 100644
--- a/crates-tauri/yaak-app-client/src/git_ext.rs
+++ b/crates-tauri/yaak-app-client/src/git_ext.rs
@@ -3,14 +3,18 @@
//! This module provides the Tauri commands for git functionality.
use crate::error::Result;
+use crate::git_watcher::{GitWatchResult, watch_git_worktree_status};
use std::path::{Path, PathBuf};
-use tauri::command;
+use tauri::ipc::Channel;
+use tauri::{AppHandle, Runtime, command};
use yaak_git::{
- BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult,
- PushResult, git_add, git_add_credential, git_add_remote, git_checkout_branch, git_clone,
- git_commit, git_create_branch, git_delete_branch, git_delete_remote_branch, git_fetch_all,
- git_init, git_log, git_merge_branch, git_pull, git_pull_force_reset, git_pull_merge, git_push,
- git_remotes, git_rename_branch, git_reset_changes, git_rm_remote, git_status, git_unstage,
+ BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote,
+ GitStatusSummary, GitWorktreeStatus, PullResult, PushResult, git_add, git_add_credential,
+ git_add_remote, git_branch_info, git_checkout_branch, git_clone, git_commit, git_create_branch,
+ git_delete_branch, git_delete_remote_branch, git_fetch_all, git_file_diff_for_commit, git_init,
+ git_log, git_log_for_file, git_merge_branch, git_pull, git_pull_force_reset, git_pull_merge,
+ git_push, git_remotes, git_rename_branch, git_reset_changes, git_restore,
+ git_restore_file_from_commit, git_rm_remote, git_status, git_unstage, git_worktree_status,
};
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
@@ -54,11 +58,44 @@ pub async fn cmd_git_status(dir: &Path) -> Result {
Ok(git_status(dir)?)
}
+#[command]
+pub async fn cmd_git_branch_info(dir: &Path) -> Result {
+ Ok(git_branch_info(dir)?)
+}
+
+#[command]
+pub async fn cmd_git_worktree_status(dir: &Path) -> Result {
+ Ok(git_worktree_status(dir)?)
+}
+
+#[command]
+pub async fn cmd_git_watch_worktree_status(
+ app_handle: AppHandle,
+ dir: &Path,
+ channel: Channel,
+) -> Result {
+ watch_git_worktree_status(app_handle, dir, channel).await
+}
+
#[command]
pub async fn cmd_git_log(dir: &Path) -> Result> {
Ok(git_log(dir)?)
}
+#[command]
+pub async fn cmd_git_log_for_file(dir: &Path, rela_path: PathBuf) -> Result> {
+ Ok(git_log_for_file(dir, &rela_path)?)
+}
+
+#[command]
+pub async fn cmd_git_file_diff_for_commit(
+ dir: &Path,
+ commit_oid: &str,
+ rela_path: PathBuf,
+) -> Result {
+ Ok(git_file_diff_for_commit(dir, commit_oid, &rela_path)?)
+}
+
#[command]
pub async fn cmd_git_initialize(dir: &Path) -> Result<()> {
Ok(git_init(dir)?)
@@ -124,6 +161,23 @@ pub async fn cmd_git_reset_changes(dir: &Path) -> Result<()> {
Ok(git_reset_changes(dir).await?)
}
+#[command]
+pub async fn cmd_git_restore_files(dir: &Path, rela_paths: Vec) -> Result<()> {
+ for path in rela_paths {
+ git_restore(dir, &path)?;
+ }
+ Ok(())
+}
+
+#[command]
+pub async fn cmd_git_restore_file_from_commit(
+ dir: &Path,
+ commit_oid: &str,
+ rela_path: PathBuf,
+) -> Result<()> {
+ Ok(git_restore_file_from_commit(dir, commit_oid, &rela_path)?)
+}
+
#[command]
pub async fn cmd_git_add_credential(
remote_url: &str,
diff --git a/crates-tauri/yaak-app-client/src/git_watcher.rs b/crates-tauri/yaak-app-client/src/git_watcher.rs
new file mode 100644
index 00000000..b82faba8
--- /dev/null
+++ b/crates-tauri/yaak-app-client/src/git_watcher.rs
@@ -0,0 +1,172 @@
+use crate::error::{Error, Result};
+use chrono::Utc;
+use log::{debug, error, warn};
+use notify::Watcher;
+use serde::{Deserialize, Serialize};
+use std::path::Path;
+use std::sync::mpsc;
+use std::time::Duration;
+use tauri::ipc::Channel;
+use tauri::{AppHandle, Listener, Runtime};
+use tokio::select;
+use tokio::sync::watch;
+use tokio::time::sleep;
+use ts_rs::TS;
+use yaak_git::{GitWorktreeStatus, git_path_is_ignored, git_repository_paths, git_worktree_status};
+
+const GIT_STATUS_COALESCE_WINDOW: Duration = Duration::from_millis(250);
+
+#[derive(Debug, Clone, Serialize, Deserialize, TS)]
+#[serde(rename_all = "camelCase")]
+#[ts(export, export_to = "index.ts")]
+pub(crate) struct GitWatchResult {
+ unlisten_event: String,
+}
+
+pub(crate) async fn watch_git_worktree_status(
+ app_handle: AppHandle,
+ dir: &Path,
+ channel: Channel,
+) -> Result {
+ let paths = git_repository_paths(dir)?;
+ let repo_dir = dir.to_path_buf();
+ let workdir = paths.workdir;
+ let gitdir = paths.gitdir;
+
+ let (tx, rx) = mpsc::channel::>();
+ let mut watcher = notify::recommended_watcher(tx)
+ .map_err(|e| Error::GenericError(format!("Failed to watch Git repository: {e}")))?;
+
+ watcher
+ .watch(&workdir, notify::RecursiveMode::Recursive)
+ .map_err(|e| Error::GenericError(format!("Failed to watch Git worktree: {e}")))?;
+ if gitdir != workdir {
+ watcher
+ .watch(&gitdir, notify::RecursiveMode::Recursive)
+ .map_err(|e| Error::GenericError(format!("Failed to watch Git metadata: {e}")))?;
+ }
+
+ let (async_tx, mut async_rx) = tokio::sync::mpsc::channel::>(100);
+ std::thread::spawn(move || {
+ for res in rx {
+ if async_tx.blocking_send(res).is_err() {
+ break;
+ }
+ }
+ });
+
+ let (cancel_tx, cancel_rx) = watch::channel(());
+ let mut cancel_rx = cancel_rx;
+ send_worktree_status(&repo_dir, &channel);
+
+ tauri::async_runtime::spawn(async move {
+ let _watcher = watcher;
+ loop {
+ select! {
+ Some(event_res) = async_rx.recv() => {
+ handle_git_watch_event(
+ event_res,
+ &mut async_rx,
+ &repo_dir,
+ &workdir,
+ &gitdir,
+ &channel,
+ ).await;
+ }
+ _ = cancel_rx.changed() => {
+ break;
+ }
+ }
+ }
+ });
+
+ let app_handle_inner = app_handle.clone();
+ let unlisten_event = format!("git-watch-unlisten-{}", Utc::now().timestamp_millis());
+ app_handle.listen_any(unlisten_event.clone(), move |event| {
+ app_handle_inner.unlisten(event.id());
+ if let Err(e) = cancel_tx.send(()) {
+ warn!("Failed to send git watch cancel signal {e:?}");
+ }
+ });
+
+ Ok(GitWatchResult { unlisten_event })
+}
+
+async fn handle_git_watch_event(
+ event_res: notify::Result,
+ async_rx: &mut tokio::sync::mpsc::Receiver>,
+ repo_dir: &Path,
+ workdir: &Path,
+ gitdir: &Path,
+ channel: &Channel,
+) {
+ if !is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir) {
+ return;
+ }
+
+ send_worktree_status(repo_dir, channel);
+
+ let settle_window = sleep(GIT_STATUS_COALESCE_WINDOW);
+ tokio::pin!(settle_window);
+ loop {
+ select! {
+ Some(event_res) = async_rx.recv() => {
+ let _ = is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir);
+ }
+ _ = &mut settle_window => {
+ break;
+ }
+ }
+ }
+
+ send_worktree_status(repo_dir, channel);
+}
+
+fn is_relevant_git_watch_event(
+ event_res: notify::Result,
+ repo_dir: &Path,
+ workdir: &Path,
+ gitdir: &Path,
+) -> bool {
+ let event = match event_res {
+ Ok(event) => event,
+ Err(e) => {
+ error!("Git watch error: {:?}", e);
+ return false;
+ }
+ };
+
+ for path in event.paths {
+ if path.strip_prefix(gitdir).is_ok() {
+ return true;
+ }
+
+ let Ok(rela_path) = path.strip_prefix(workdir) else {
+ continue;
+ };
+
+ match git_path_is_ignored(repo_dir, rela_path) {
+ Ok(true) => {}
+ Ok(false) => return true,
+ Err(e) => {
+ debug!("Failed to check Git ignore status for {:?}: {e}", rela_path);
+ return true;
+ }
+ }
+ }
+
+ false
+}
+
+fn send_worktree_status(repo_dir: &Path, channel: &Channel) {
+ match git_worktree_status(repo_dir) {
+ Ok(status) => {
+ if let Err(e) = channel.send(status) {
+ warn!("Failed to send git worktree status: {:?}", e);
+ }
+ }
+ Err(e) => {
+ warn!("Failed to get git worktree status: {e}");
+ }
+ }
+}
diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs
index aea200c5..817d05d0 100644
--- a/crates-tauri/yaak-app-client/src/lib.rs
+++ b/crates-tauri/yaak-app-client/src/lib.rs
@@ -67,6 +67,7 @@ mod commands;
mod encoding;
mod error;
mod git_ext;
+mod git_watcher;
mod grpc;
mod history;
mod http_request;
@@ -121,9 +122,7 @@ fn setup_window_menu(win: &WebviewWindow) -> Result<()> {
}
// Commands for development
- "dev.reset_size" => webview_window
- .set_size(LogicalSize::new(1100.0, 600.0))
- .unwrap(),
+ "dev.reset_size" => webview_window.set_size(LogicalSize::new(1100.0, 600.0)).unwrap(),
"dev.reset_size_16x9" => {
let width = webview_window.outer_size().unwrap().width;
let height = width * 9 / 16;
@@ -1506,7 +1505,6 @@ async fn cmd_reload_plugins(
Ok(errors)
}
-
#[tauri::command]
async fn cmd_plugin_info(
id: &str,
@@ -1579,7 +1577,14 @@ async fn cmd_new_child_window(
inner_size: (f64, f64),
) -> YaakResult<()> {
let use_native_titlebar = parent_window.app_handle().db().get_settings().use_native_titlebar;
- let win = yaak_window::window::create_child_window(&parent_window, url, label, title, inner_size, use_native_titlebar)?;
+ let win = yaak_window::window::create_child_window(
+ &parent_window,
+ url,
+ label,
+ title,
+ inner_size,
+ use_native_titlebar,
+ )?;
setup_window_menu(&win)?;
Ok(())
}
@@ -1831,8 +1836,13 @@ pub fn run() {
git_ext::cmd_git_delete_remote_branch,
git_ext::cmd_git_merge_branch,
git_ext::cmd_git_rename_branch,
+ git_ext::cmd_git_branch_info,
git_ext::cmd_git_status,
+ git_ext::cmd_git_worktree_status,
+ git_ext::cmd_git_watch_worktree_status,
git_ext::cmd_git_log,
+ git_ext::cmd_git_log_for_file,
+ git_ext::cmd_git_file_diff_for_commit,
git_ext::cmd_git_initialize,
git_ext::cmd_git_clone,
git_ext::cmd_git_commit,
@@ -1844,6 +1854,8 @@ pub fn run() {
git_ext::cmd_git_add,
git_ext::cmd_git_unstage,
git_ext::cmd_git_reset_changes,
+ git_ext::cmd_git_restore_files,
+ git_ext::cmd_git_restore_file_from_commit,
git_ext::cmd_git_add_credential,
git_ext::cmd_git_remotes,
git_ext::cmd_git_add_remote,
@@ -1870,7 +1882,11 @@ pub fn run() {
match event {
RunEvent::Ready => {
let use_native_titlebar = app_handle.db().get_settings().use_native_titlebar;
- if let Ok(win) = yaak_window::window::create_main_window(app_handle, "/", use_native_titlebar) {
+ if let Ok(win) = yaak_window::window::create_main_window(
+ app_handle,
+ "/",
+ use_native_titlebar,
+ ) {
let _ = setup_window_menu(&win);
}
let h = app_handle.clone();
diff --git a/crates-tauri/yaak-app-client/src/plugin_events.rs b/crates-tauri/yaak-app-client/src/plugin_events.rs
index 6afe6e4a..4b14dd90 100644
--- a/crates-tauri/yaak-app-client/src/plugin_events.rs
+++ b/crates-tauri/yaak-app-client/src/plugin_events.rs
@@ -3,7 +3,6 @@ use crate::http_request::send_http_request_with_context;
use crate::models_ext::BlobManagerExt;
use crate::models_ext::QueryManagerExt;
use crate::render::{render_grpc_request, render_http_request, render_json_value};
-use yaak_window::window::{CreateWindowConfig, create_window};
use crate::{
call_frontend, cookie_jar_from_window, environment_from_window, get_window_from_plugin_context,
workspace_from_window,
@@ -36,6 +35,7 @@ use yaak_plugins::plugin_handle::PluginHandle;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
use yaak_templates::{RenderErrorBehavior, RenderOptions};
+use yaak_window::window::{CreateWindowConfig, create_window};
pub(crate) async fn handle_plugin_event(
app_handle: &AppHandle,
diff --git a/crates-tauri/yaak-app-client/src/updates.rs b/crates-tauri/yaak-app-client/src/updates.rs
index 6fd7fe74..d55465ba 100644
--- a/crates-tauri/yaak-app-client/src/updates.rs
+++ b/crates-tauri/yaak-app-client/src/updates.rs
@@ -234,7 +234,7 @@ async fn start_integrated_update(
window: &WebviewWindow,
update: &Update,
) -> Result {
- let download_path = ensure_download_path(window, update)?;
+ let download_path = ensure_download_dir(window)?.join(download_file_name(update));
debug!("Download path: {}", download_path.display());
let downloaded = download_path.exists();
let ack_wait = Duration::from_secs(3);
@@ -345,7 +345,7 @@ pub async fn download_update_idempotent(
window: &WebviewWindow,
update: &Update,
) -> Result {
- let dl_path = ensure_download_path(window, update)?;
+ let dl_path = ensure_download_dir(window)?.join(download_file_name(update));
if dl_path.exists() {
info!("{} already downloaded to {}", update.version, dl_path.display());
@@ -385,21 +385,36 @@ pub async fn install_update_maybe_download(
let dl_path = download_update_idempotent(window, update).await?;
let update_bytes = std::fs::read(&dl_path)?;
update.install(update_bytes.as_slice())?;
+ delete_download_dir(window);
Ok(())
}
-pub fn ensure_download_path(
- window: &WebviewWindow,
- update: &Update,
-) -> Result {
- // Ensure dir exists
- let base_dir = window.path().app_cache_dir()?.join("updates");
- std::fs::create_dir_all(&base_dir)?;
-
- // Generate name based on signature
- let sig_digest = md5::compute(&update.signature);
- let name = format!("yaak-{}-{:x}", update.version, sig_digest);
- let dl_path = base_dir.join(name);
-
- Ok(dl_path)
+pub fn download_dir(window: &WebviewWindow) -> Result {
+ Ok(window.path().app_cache_dir()?.join("updates"))
+}
+
+pub fn ensure_download_dir(window: &WebviewWindow) -> Result {
+ let base_dir = download_dir(window)?;
+ std::fs::create_dir_all(&base_dir)?;
+ Ok(base_dir)
+}
+
+pub fn download_file_name(update: &Update) -> String {
+ let sig_digest = md5::compute(&update.signature);
+ format!("yaak-{}-{:x}", update.version, sig_digest)
+}
+
+pub fn delete_download_dir(window: &WebviewWindow) {
+ let base_dir = match download_dir(window) {
+ Ok(dir) => dir,
+ Err(e) => {
+ warn!("Failed to locate update downloads dir: {}", e);
+ return;
+ }
+ };
+ match std::fs::remove_dir_all(&base_dir) {
+ Ok(()) => info!("Removed update downloads dir {}", base_dir.display()),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
+ Err(e) => warn!("Failed to remove update downloads dir {}: {}", base_dir.display(), e),
+ }
}
diff --git a/crates-tauri/yaak-app-proxy/Cargo.toml b/crates-tauri/yaak-app-proxy/Cargo.toml
index 0adda828..74581bbd 100644
--- a/crates-tauri/yaak-app-proxy/Cargo.toml
+++ b/crates-tauri/yaak-app-proxy/Cargo.toml
@@ -10,7 +10,7 @@ name = "tauri_app_proxy_lib"
crate-type = ["staticlib", "cdylib", "lib"]
[build-dependencies]
-tauri-build = { version = "2.5.3", features = [] }
+tauri-build = { version = "2.6.1", features = [] }
[dependencies]
log = { workspace = true }
diff --git a/crates-tauri/yaak-app-proxy/src/lib.rs b/crates-tauri/yaak-app-proxy/src/lib.rs
index 6cd23350..b66a78a4 100644
--- a/crates-tauri/yaak-app-proxy/src/lib.rs
+++ b/crates-tauri/yaak-app-proxy/src/lib.rs
@@ -1,6 +1,6 @@
use log::{error, info, warn};
-use tauri::{Emitter, Manager, RunEvent, State, WebviewWindow};
use tauri::Runtime;
+use tauri::{Emitter, Manager, RunEvent, State, WebviewWindow};
use yaak_proxy_lib::ProxyCtx;
use yaak_rpc::{RpcEventEmitter, RpcRouter};
use yaak_window::window::CreateWindowConfig;
diff --git a/crates-tauri/yaak-mac-window/src/mac.rs b/crates-tauri/yaak-mac-window/src/mac.rs
index c5b5cdba..e45d0bd4 100644
--- a/crates-tauri/yaak-mac-window/src/mac.rs
+++ b/crates-tauri/yaak-mac-window/src/mac.rs
@@ -109,19 +109,16 @@ fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, x: f64, y: f64,
// we've modified it. This avoids the height growing on repeated calls.
use std::sync::OnceLock;
static DEFAULT_TITLEBAR_HEIGHT: OnceLock = OnceLock::new();
- let default_height =
- *DEFAULT_TITLEBAR_HEIGHT.get_or_init(|| NSView::frame(title_bar_container_view).size.height);
+ let default_height = *DEFAULT_TITLEBAR_HEIGHT
+ .get_or_init(|| NSView::frame(title_bar_container_view).size.height);
// On pre-Tahoe, button_height + y is larger than the default title bar
// height, so the resize works as before. On Tahoe (26+), the default is
// already 32px and button_height + y = 32, so nothing changes. In that
// case, add TITLEBAR_EXTRA_HEIGHT extra pixels to push the buttons down.
let desired = button_height + y;
- let title_bar_frame_height = if desired > default_height {
- desired
- } else {
- default_height + TITLEBAR_EXTRA_HEIGHT
- };
+ let title_bar_frame_height =
+ if desired > default_height { desired } else { default_height + TITLEBAR_EXTRA_HEIGHT };
let mut title_bar_rect = NSView::frame(title_bar_container_view);
title_bar_rect.size.height = title_bar_frame_height;
diff --git a/crates/common/yaak-database/src/db_context.rs b/crates/common/yaak-database/src/db_context.rs
index 83e56711..8303e89d 100644
--- a/crates/common/yaak-database/src/db_context.rs
+++ b/crates/common/yaak-database/src/db_context.rs
@@ -65,8 +65,7 @@ impl<'a> DbContext<'a> {
.cond_where(Expr::col(col).eq(value))
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn.prepare(sql.as_str()).expect("Failed to prepare query");
- stmt.query_row(&*params.as_params(), M::from_row)
- .ok()
+ stmt.query_row(&*params.as_params(), M::from_row).ok()
}
pub fn find_all(&self) -> Result>
@@ -126,9 +125,8 @@ impl<'a> DbContext<'a> {
let other_values = model.clone().insert_values(source)?;
let mut column_vec = vec![id_iden.clone()];
- let mut value_vec = vec![
- if id_val.is_empty() { M::generate_id().into() } else { id_val.into() },
- ];
+ let mut value_vec =
+ vec![if id_val.is_empty() { M::generate_id().into() } else { id_val.into() }];
for (col, val) in other_values {
value_vec.push(val.into());
diff --git a/crates/common/yaak-database/src/migrate.rs b/crates/common/yaak-database/src/migrate.rs
index c81b0c21..30c53dcd 100644
--- a/crates/common/yaak-database/src/migrate.rs
+++ b/crates/common/yaak-database/src/migrate.rs
@@ -55,8 +55,7 @@ pub fn run_migrations(pool: &Pool, dir: &Dir<'_>) -> Re
continue;
}
- let sql =
- entry.as_file().unwrap().contents_utf8().expect("Failed to read migration file");
+ let sql = entry.as_file().unwrap().contents_utf8().expect("Failed to read migration file");
info!("Applying migration: {}", filename);
let conn = pool.get()?;
diff --git a/crates/common/yaak-database/src/util.rs b/crates/common/yaak-database/src/util.rs
index a9c84468..64af7c80 100644
--- a/crates/common/yaak-database/src/util.rs
+++ b/crates/common/yaak-database/src/util.rs
@@ -10,10 +10,10 @@ pub fn generate_id() -> String {
pub fn generate_id_of_length(n: usize) -> String {
let alphabet: [char; 57] = [
- '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i',
- 'j', 'k', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A',
- 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T',
- 'U', 'V', 'W', 'X', 'Y', 'Z',
+ '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',
+ 'k', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C',
+ 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W',
+ 'X', 'Y', 'Z',
];
nanoid!(n, &alphabet)
diff --git a/crates/common/yaak-rpc/src/lib.rs b/crates/common/yaak-rpc/src/lib.rs
index 8895c610..80a462bf 100644
--- a/crates/common/yaak-rpc/src/lib.rs
+++ b/crates/common/yaak-rpc/src/lib.rs
@@ -3,7 +3,8 @@ use std::collections::HashMap;
use std::sync::mpsc;
/// Type-erased handler function: takes context + JSON payload, returns JSON or error.
-type HandlerFn = Box Result + Send + Sync>;
+type HandlerFn =
+ Box Result + Send + Sync>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcError {
@@ -57,9 +58,7 @@ pub struct RpcRouter {
impl RpcRouter {
pub fn new() -> Self {
- Self {
- handlers: HashMap::new(),
- }
+ Self { handlers: HashMap::new() }
}
/// Register a handler for a command name.
@@ -77,23 +76,15 @@ impl RpcRouter {
) -> Result {
match self.handlers.get(cmd) {
Some(handler) => handler(ctx, payload),
- None => Err(RpcError {
- message: format!("unknown command: {cmd}"),
- }),
+ None => Err(RpcError { message: format!("unknown command: {cmd}") }),
}
}
/// Handle a full `RpcRequest`, returning an `RpcResponse`.
pub fn handle(&self, req: RpcRequest, ctx: &Ctx) -> RpcResponse {
match self.dispatch(&req.cmd, req.payload, ctx) {
- Ok(payload) => RpcResponse::Success {
- id: req.id,
- payload,
- },
- Err(e) => RpcResponse::Error {
- id: req.id,
- error: e.message,
- },
+ Ok(payload) => RpcResponse::Success { id: req.id, payload },
+ Err(e) => RpcResponse::Error { id: req.id, error: e.message },
}
}
diff --git a/crates/yaak-git/bindings/gen_git.ts b/crates/yaak-git/bindings/gen_git.ts
index 1ec69d86..3222a770 100644
--- a/crates/yaak-git/bindings/gen_git.ts
+++ b/crates/yaak-git/bindings/gen_git.ts
@@ -7,7 +7,11 @@ export type CloneResult = { "type": "success" } | { "type": "cancelled" } | { "t
export type GitAuthor = { name: string | null, email: string | null, };
-export type GitCommit = { author: GitAuthor, when: string, message: string | null, };
+export type GitBranchInfo = { path: string, headRef: string | null, headRefShorthand: string | null, origins: Array, localBranches: Array, remoteBranches: Array, ahead: number, behind: number, };
+
+export type GitCommit = { oid: string, author: GitAuthor, when: string, message: string | null, };
+
+export type GitFileDiff = { original: string, modified: string, };
export type GitRemote = { name: string, url: string | null, };
@@ -17,6 +21,10 @@ export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: bool
export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array, origins: Array, localBranches: Array, remoteBranches: Array, ahead: number, behind: number, };
+export type GitWorktreeStatus = { entries: Array, };
+
+export type GitWorktreeStatusEntry = { relaPath: string, modelId: string | null, status: GitStatus, staged: boolean, };
+
export type PullResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, } | { "type": "diverged", remote: string, branch: string, } | { "type": "uncommitted_changes" };
export type PushResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, };
diff --git a/crates/yaak-git/index.ts b/crates/yaak-git/index.ts
index 418def3e..ea7ef76a 100644
--- a/crates/yaak-git/index.ts
+++ b/crates/yaak-git/index.ts
@@ -1,14 +1,18 @@
import { useQuery } from "@tanstack/react-query";
-import { invoke } from "@tauri-apps/api/core";
+import { Channel, invoke } from "@tauri-apps/api/core";
+import { emit } from "@tauri-apps/api/event";
import { createFastMutation } from "@yaakapp/yaak-client/hooks/useFastMutation";
import { queryClient } from "@yaakapp/yaak-client/lib/queryClient";
import { useMemo } from "react";
import {
BranchDeleteResult,
CloneResult,
+ GitBranchInfo,
GitCommit,
+ GitFileDiff,
GitRemote,
GitStatusSummary,
+ GitWorktreeStatus,
PullResult,
PushResult,
} from "./bindings/gen_git";
@@ -26,6 +30,10 @@ export type DivergedStrategy = "force_reset" | "merge" | "cancel";
export type UncommittedChangesStrategy = "reset" | "cancel";
+interface GitWatchResult {
+ unlistenEvent: string;
+}
+
export interface GitCallbacks {
addRemote: () => Promise;
promptCredentials: (
@@ -38,13 +46,98 @@ export interface GitCallbacks {
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ["git"] });
-export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string) {
- const mutations = useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]);
- const fetchAll = useQuery({
+function gitWorktreeStatusQueryKey(dir?: string, refreshKey?: string) {
+ return refreshKey == null
+ ? (["git", "worktree_status", dir] as const)
+ : (["git", "worktree_status", dir, refreshKey] as const);
+}
+
+export function invalidateGitWorktreeStatus(dir?: string) {
+ return queryClient.invalidateQueries({ queryKey: gitWorktreeStatusQueryKey(dir) });
+}
+
+export function useGitWorktreeStatus(dir: string, refreshKey?: string) {
+ return useQuery({
+ queryKey: gitWorktreeStatusQueryKey(dir, refreshKey),
+ queryFn: () => invoke("cmd_git_worktree_status", { dir }),
+ placeholderData: (prev) => prev,
+ });
+}
+
+export function watchGitWorktreeStatus(dir: string, callback: (status: GitWorktreeStatus) => void) {
+ const channel = new Channel();
+ channel.onmessage = callback;
+ const unlistenPromise = invoke("cmd_git_watch_worktree_status", {
+ dir,
+ channel,
+ });
+
+ void unlistenPromise
+ .then(({ unlistenEvent }) => {
+ addGitWatchKey(unlistenEvent);
+ })
+ .catch(console.debug);
+
+ return () =>
+ unlistenPromise
+ .then(async ({ unlistenEvent }) => {
+ unlistenGitWatcher(unlistenEvent);
+ })
+ .catch(console.error);
+}
+
+function useGitFetchAll(dir: string, refreshKey?: string) {
+ return useQuery({
queryKey: ["git", "fetch_all", dir, refreshKey],
queryFn: () => invoke("cmd_git_fetch_all", { dir }),
refetchInterval: 10 * 60_000,
});
+}
+
+function useGitBranchInfoQuery(dir: string, refreshKey?: string, fetchAllUpdatedAt?: number) {
+ return useQuery({
+ refetchOnMount: true,
+ queryKey: ["git", "branch_info", dir, refreshKey, fetchAllUpdatedAt],
+ queryFn: () => invoke("cmd_git_branch_info", { dir }),
+ placeholderData: (prev) => prev,
+ });
+}
+
+export function useGitBranchInfo(dir: string, refreshKey?: string) {
+ const fetchAll = useGitFetchAll(dir, refreshKey);
+ return useGitBranchInfoQuery(dir, refreshKey, fetchAll.dataUpdatedAt);
+}
+
+export function useGitLog(dir: string, refreshKey?: string, relaPath?: string) {
+ return useQuery({
+ queryKey: ["git", "log", dir, refreshKey, relaPath],
+ queryFn: () =>
+ relaPath == null
+ ? invoke("cmd_git_log", { dir })
+ : invoke("cmd_git_log_for_file", { dir, relaPath }),
+ placeholderData: (prev) => prev,
+ });
+}
+
+export function useGitFileDiffForCommit(
+ dir: string,
+ relaPath: string,
+ commitOid: string | null | undefined,
+) {
+ return useQuery({
+ enabled: commitOid != null,
+ queryKey: ["git", "file_diff_for_commit", dir, relaPath, commitOid],
+ queryFn: () => {
+ if (commitOid == null) throw new Error("Missing commit oid");
+ return invoke("cmd_git_file_diff_for_commit", { dir, relaPath, commitOid });
+ },
+ });
+}
+
+export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string) {
+ const mutations = useGitMutations(dir, callbacks);
+ const fetchAll = useGitFetchAll(dir, refreshKey);
+
return [
{
remotes: useQuery({
@@ -52,11 +145,7 @@ export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string
queryFn: () => getRemotes(dir),
placeholderData: (prev) => prev,
}),
- log: useQuery({
- queryKey: ["git", "log", dir, refreshKey],
- queryFn: () => invoke("cmd_git_log", { dir }),
- placeholderData: (prev) => prev,
- }),
+ log: useGitLog(dir, refreshKey),
status: useQuery({
refetchOnMount: true,
queryKey: ["git", "status", dir, refreshKey, fetchAll.dataUpdatedAt],
@@ -68,6 +157,10 @@ export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string
] as const;
}
+export function useGitMutations(dir: string, callbacks: GitCallbacks) {
+ return useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]);
+}
+
export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
const push = async () => {
const remotes = await getRemotes(dir);
@@ -250,6 +343,20 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
mutationFn: () => invoke("cmd_git_reset_changes", { dir }),
onSuccess,
}),
+ restore: createFastMutation({
+ mutationKey: ["git", "restore", dir],
+ mutationFn: (args) => invoke("cmd_git_restore_files", { dir, ...args }),
+ onSuccess,
+ }),
+ restoreFileFromCommit: createFastMutation<
+ void,
+ string,
+ { commitOid: string; relaPath: string }
+ >({
+ mutationKey: ["git", "restore-file-from-commit", dir],
+ mutationFn: (args) => invoke("cmd_git_restore_file_from_commit", { dir, ...args }),
+ onSuccess,
+ }),
} as const;
};
@@ -257,6 +364,35 @@ async function getRemotes(dir: string) {
return invoke("cmd_git_remotes", { dir });
}
+function unlistenGitWatcher(unlistenEvent: string) {
+ void emit(unlistenEvent).then(() => {
+ removeGitWatchKey(unlistenEvent);
+ });
+}
+
+function getGitWatchKeys() {
+ return sessionStorage.getItem("git-worktree-watchers")?.split(",").filter(Boolean) ?? [];
+}
+
+function setGitWatchKeys(keys: string[]) {
+ sessionStorage.setItem("git-worktree-watchers", keys.join(","));
+}
+
+function addGitWatchKey(key: string) {
+ const keys = getGitWatchKeys();
+ setGitWatchKeys([...keys, key]);
+}
+
+function removeGitWatchKey(key: string) {
+ const keys = getGitWatchKeys();
+ setGitWatchKeys(keys.filter((k) => k !== key));
+}
+
+const gitWatchKeys = getGitWatchKeys();
+if (gitWatchKeys.length > 0) {
+ gitWatchKeys.forEach(unlistenGitWatcher);
+}
+
/**
* Clone a git repository, prompting for credentials if needed.
*/
diff --git a/crates/yaak-git/src/lib.rs b/crates/yaak-git/src/lib.rs
index 2a0b6406..9500fa69 100644
--- a/crates/yaak-git/src/lib.rs
+++ b/crates/yaak-git/src/lib.rs
@@ -14,6 +14,7 @@ mod push;
mod remotes;
mod repository;
mod reset;
+mod restore;
mod status;
mod unstage;
mod util;
@@ -29,10 +30,15 @@ pub use commit::git_commit;
pub use credential::git_add_credential;
pub use fetch::git_fetch_all;
pub use init::git_init;
-pub use log::{GitCommit, git_log};
+pub use log::{GitCommit, GitFileDiff, git_file_diff_for_commit, git_log, git_log_for_file};
pub use pull::{PullResult, git_pull, git_pull_force_reset, git_pull_merge};
pub use push::{PushResult, git_push};
pub use remotes::{GitRemote, git_add_remote, git_remotes, git_rm_remote};
+pub use repository::{GitRepositoryPaths, git_path_is_ignored, git_repository_paths};
pub use reset::git_reset_changes;
-pub use status::{GitStatusSummary, git_status};
+pub use restore::{git_restore, git_restore_file_from_commit};
+pub use status::{
+ GitBranchInfo, GitStatusSummary, GitWorktreeStatus, git_branch_info, git_status,
+ git_worktree_status,
+};
pub use unstage::git_unstage;
diff --git a/crates/yaak-git/src/log.rs b/crates/yaak-git/src/log.rs
index 108c0fbd..8b31f80b 100644
--- a/crates/yaak-git/src/log.rs
+++ b/crates/yaak-git/src/log.rs
@@ -8,6 +8,7 @@ use ts_rs::TS;
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub struct GitCommit {
+ pub oid: String,
pub author: GitAuthor,
pub when: DateTime,
pub message: Option,
@@ -21,7 +22,23 @@ pub struct GitAuthor {
pub email: Option,
}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
+#[serde(rename_all = "camelCase")]
+#[ts(export, export_to = "gen_git.ts")]
+pub struct GitFileDiff {
+ pub original: String,
+ pub modified: String,
+}
+
pub fn git_log(dir: &Path) -> crate::error::Result> {
+ git_log_inner(dir, None)
+}
+
+pub fn git_log_for_file(dir: &Path, rela_path: &Path) -> crate::error::Result> {
+ git_log_inner(dir, Some(rela_path))
+}
+
+fn git_log_inner(dir: &Path, rela_path: Option<&Path>) -> crate::error::Result> {
let repo = open_repo(dir)?;
// Return empty if empty repo or no head (new repo)
@@ -46,8 +63,16 @@ pub fn git_log(dir: &Path) -> crate::error::Result> {
.filter_map(|oid| {
let oid = filter_try!(oid);
let commit = filter_try!(repo.find_commit(oid));
+ if let Some(rela_path) = rela_path {
+ let touches_path = filter_try!(commit_touches_path(&repo, &commit, rela_path));
+ if !touches_path {
+ return None;
+ }
+ }
+
let author = commit.author();
Some(GitCommit {
+ oid: oid.to_string(),
author: GitAuthor {
name: author.name().map(|s| s.to_string()),
email: author.email().map(|s| s.to_string()),
@@ -61,6 +86,53 @@ pub fn git_log(dir: &Path) -> crate::error::Result> {
Ok(log)
}
+pub fn git_file_diff_for_commit(
+ dir: &Path,
+ commit_oid: &str,
+ rela_path: &Path,
+) -> crate::error::Result {
+ let repo = open_repo(dir)?;
+ let oid = git2::Oid::from_str(commit_oid)?;
+ let commit = repo.find_commit(oid)?;
+ let new_tree = commit.tree()?;
+ let old_tree = if commit.parent_count() > 0 { Some(commit.parent(0)?.tree()?) } else { None };
+
+ Ok(GitFileDiff {
+ original: blob_text_at_path(&repo, old_tree.as_ref(), rela_path)?,
+ modified: blob_text_at_path(&repo, Some(&new_tree), rela_path)?,
+ })
+}
+
+fn commit_touches_path(
+ repo: &git2::Repository,
+ commit: &git2::Commit,
+ rela_path: &Path,
+) -> crate::error::Result {
+ let new_tree = commit.tree()?;
+ let old_tree = if commit.parent_count() > 0 { Some(commit.parent(0)?.tree()?) } else { None };
+
+ let mut opts = git2::DiffOptions::new();
+ opts.pathspec(rela_path);
+
+ let diff = repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))?;
+ Ok(diff.deltas().len() > 0)
+}
+
+fn blob_text_at_path(
+ repo: &git2::Repository,
+ tree: Option<&git2::Tree>,
+ rela_path: &Path,
+) -> crate::error::Result {
+ let Some(tree) = tree else {
+ return Ok(String::new());
+ };
+ let Ok(entry) = tree.get_path(rela_path) else {
+ return Ok(String::new());
+ };
+ let blob = entry.to_object(repo)?.peel_to_blob()?;
+ Ok(String::from_utf8(blob.content().to_vec())?)
+}
+
#[cfg(test)]
fn convert_git_time_to_date(_git_time: git2::Time) -> DateTime {
DateTime::from_timestamp(0, 0).unwrap()
diff --git a/crates/yaak-git/src/repository.rs b/crates/yaak-git/src/repository.rs
index c148f07c..6081abf3 100644
--- a/crates/yaak-git/src/repository.rs
+++ b/crates/yaak-git/src/repository.rs
@@ -1,5 +1,12 @@
use crate::error::Error::{GitRepoNotFound, GitUnknown};
-use std::path::Path;
+use crate::error::{Error, Result};
+use std::path::{Path, PathBuf};
+
+#[derive(Debug, Clone)]
+pub struct GitRepositoryPaths {
+ pub workdir: PathBuf,
+ pub gitdir: PathBuf,
+}
pub(crate) fn open_repo(dir: &Path) -> crate::error::Result {
match git2::Repository::discover(dir) {
@@ -8,3 +15,17 @@ pub(crate) fn open_repo(dir: &Path) -> crate::error::Result {
Err(e) => Err(GitUnknown(e)),
}
}
+
+pub fn git_repository_paths(dir: &Path) -> Result {
+ let repo = open_repo(dir)?;
+ let workdir = repo
+ .workdir()
+ .ok_or_else(|| Error::GenericError("Git repository does not have a worktree".into()))?
+ .to_path_buf();
+ Ok(GitRepositoryPaths { workdir, gitdir: repo.path().to_path_buf() })
+}
+
+pub fn git_path_is_ignored(dir: &Path, rela_path: &Path) -> Result {
+ let repo = open_repo(dir)?;
+ Ok(repo.status_should_ignore(rela_path)?)
+}
diff --git a/crates/yaak-git/src/restore.rs b/crates/yaak-git/src/restore.rs
new file mode 100644
index 00000000..3d7484f1
--- /dev/null
+++ b/crates/yaak-git/src/restore.rs
@@ -0,0 +1,76 @@
+use crate::error::Result;
+use crate::repository::open_repo;
+use log::info;
+use std::fs;
+use std::path::{Component, Path};
+
+pub fn git_restore(dir: &Path, rela_path: &Path) -> Result<()> {
+ let repo = open_repo(dir)?;
+ validate_relative_path(rela_path)?;
+
+ let status = repo.status_file(rela_path).ok();
+ let is_untracked = status
+ .is_some_and(|s| s.contains(git2::Status::WT_NEW) || s.contains(git2::Status::INDEX_NEW));
+
+ info!("Restoring file {rela_path:?} in {dir:?}");
+ if is_untracked {
+ let mut index = repo.index()?;
+ let _ = index.remove_path(rela_path);
+ index.write()?;
+
+ let path = repo.workdir().unwrap_or(dir).join(rela_path);
+ if path.is_dir() {
+ fs::remove_dir_all(path)?;
+ } else if path.exists() {
+ fs::remove_file(path)?;
+ }
+ return Ok(());
+ }
+
+ let head = repo.head()?;
+ let commit = head.peel_to_commit()?;
+ repo.reset_default(Some(commit.as_object()), &[rela_path])?;
+
+ let mut checkout = git2::build::CheckoutBuilder::new();
+ checkout.force().path(rela_path);
+ repo.checkout_head(Some(&mut checkout))?;
+
+ Ok(())
+}
+
+pub fn git_restore_file_from_commit(dir: &Path, commit_oid: &str, rela_path: &Path) -> Result<()> {
+ let repo = open_repo(dir)?;
+ validate_relative_path(rela_path)?;
+
+ let oid = git2::Oid::from_str(commit_oid)?;
+ let commit = repo.find_commit(oid)?;
+ let tree = commit.tree()?;
+ let path = repo.workdir().unwrap_or(dir).join(rela_path);
+
+ info!("Restoring file {rela_path:?} from commit {commit_oid} in {dir:?}");
+ if tree.get_path(rela_path).is_err() {
+ if path.is_dir() {
+ fs::remove_dir_all(path)?;
+ } else if path.exists() {
+ fs::remove_file(path)?;
+ }
+ return Ok(());
+ }
+
+ let mut checkout = git2::build::CheckoutBuilder::new();
+ checkout.force().path(rela_path);
+ repo.checkout_tree(commit.as_object(), Some(&mut checkout))?;
+
+ Ok(())
+}
+
+fn validate_relative_path(path: &Path) -> Result<()> {
+ let is_safe = !path.as_os_str().is_empty()
+ && !path.is_absolute()
+ && path.components().all(|c| matches!(c, Component::Normal(_)));
+ if is_safe {
+ Ok(())
+ } else {
+ Err(crate::error::Error::GenericError(format!("Invalid restore path {}", path.display())))
+ }
+}
diff --git a/crates/yaak-git/src/status.rs b/crates/yaak-git/src/status.rs
index b2dad85b..c07c218e 100644
--- a/crates/yaak-git/src/status.rs
+++ b/crates/yaak-git/src/status.rs
@@ -22,6 +22,20 @@ pub struct GitStatusSummary {
pub behind: u32,
}
+#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
+#[serde(rename_all = "camelCase")]
+#[ts(export, export_to = "gen_git.ts")]
+pub struct GitBranchInfo {
+ pub path: String,
+ pub head_ref: Option,
+ pub head_ref_shorthand: Option,
+ pub origins: Vec,
+ pub local_branches: Vec,
+ pub remote_branches: Vec,
+ pub ahead: u32,
+ pub behind: u32,
+}
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
@@ -33,6 +47,23 @@ pub struct GitStatusEntry {
pub next: Option,
}
+#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
+#[serde(rename_all = "camelCase")]
+#[ts(export, export_to = "gen_git.ts")]
+pub struct GitWorktreeStatus {
+ pub entries: Vec,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
+#[serde(rename_all = "camelCase")]
+#[ts(export, export_to = "gen_git.ts")]
+pub struct GitWorktreeStatusEntry {
+ pub rela_path: String,
+ pub model_id: Option,
+ pub status: GitStatus,
+ pub staged: bool,
+}
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_git.ts")]
@@ -46,31 +77,43 @@ pub enum GitStatus {
TypeChange,
}
+pub fn git_worktree_status(dir: &Path) -> crate::error::Result {
+ let repo = open_repo(dir)?;
+ let mut opts = git2::StatusOptions::new();
+ opts.include_ignored(false)
+ .include_untracked(true)
+ .recurse_untracked_dirs(true)
+ .include_unmodified(false);
+
+ let mut entries = Vec::new();
+ for entry in repo.statuses(Some(&mut opts))?.into_iter() {
+ let Some(rela_path) = entry.path() else {
+ continue;
+ };
+ let Some((status, staged)) = git_status_from_raw(entry.status()) else {
+ continue;
+ };
+
+ entries.push(GitWorktreeStatusEntry {
+ rela_path: rela_path.to_string(),
+ model_id: model_id_from_rela_path(Path::new(rela_path)),
+ status,
+ staged,
+ });
+ }
+
+ Ok(GitWorktreeStatus { entries })
+}
+
+pub fn git_branch_info(dir: &Path) -> crate::error::Result {
+ let repo = open_repo(dir)?;
+ git_branch_info_for_repo(&repo, dir)
+}
+
pub fn git_status(dir: &Path) -> crate::error::Result {
let repo = open_repo(dir)?;
- let (head_tree, head_ref, head_ref_shorthand) = match repo.head() {
- Ok(head) => {
- let tree = head.peel_to_tree().ok();
- let head_ref_shorthand = head.shorthand().map(|s| s.to_string());
- let head_ref = head.name().map(|s| s.to_string());
-
- (tree, head_ref, head_ref_shorthand)
- }
- Err(_) => {
- // For "unborn" repos, reading from HEAD is the only way to get the branch name
- // See https://github.com/starship/starship/pull/1336
- let head_path = repo.path().join("HEAD");
- let head_ref = fs::read_to_string(&head_path)
- .ok()
- .unwrap_or_default()
- .lines()
- .next()
- .map(|s| s.trim_start_matches("ref:").trim().to_string());
- let head_ref_shorthand =
- head_ref.clone().map(|r| r.split('/').last().unwrap_or("unknown").to_string());
- (None, head_ref, head_ref_shorthand)
- }
- };
+ let branch_info = git_branch_info_for_repo(&repo, dir)?;
+ let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok());
let mut opts = git2::StatusOptions::new();
opts.include_ignored(false)
@@ -83,51 +126,8 @@ pub fn git_status(dir: &Path) -> crate::error::Result {
let mut entries: Vec = Vec::new();
for entry in repo.statuses(Some(&mut opts))?.into_iter() {
let rela_path = entry.path().unwrap().to_string();
- let status = entry.status();
- let index_status = match status {
- // Note: order matters here, since we're checking a bitmap!
- s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
- s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Untracked,
- s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified,
- s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed,
- s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed,
- s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::TypeChange,
- s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
- s => {
- warn!("Unknown index status {s:?}");
- continue;
- }
- };
-
- let worktree_status = match status {
- // Note: order matters here, since we're checking a bitmap!
- s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
- s if s.contains(git2::Status::WT_NEW) => GitStatus::Untracked,
- s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified,
- s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed,
- s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed,
- s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::TypeChange,
- s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
- s => {
- warn!("Unknown worktree status {s:?}");
- continue;
- }
- };
-
- let status = if index_status == GitStatus::Current {
- worktree_status.clone()
- } else {
- index_status.clone()
- };
-
- let staged = if index_status == GitStatus::Current && worktree_status == GitStatus::Current
- {
- // No change, so can't be added
- false
- } else if index_status != GitStatus::Current {
- true
- } else {
- false
+ let Some((status, staged)) = git_status_from_raw(entry.status()) else {
+ continue;
};
// Get previous content from Git, if it's in there
@@ -158,9 +158,27 @@ pub fn git_status(dir: &Path) -> crate::error::Result {
})
}
+ Ok(GitStatusSummary {
+ entries,
+ path: branch_info.path,
+ head_ref: branch_info.head_ref,
+ head_ref_shorthand: branch_info.head_ref_shorthand,
+ origins: branch_info.origins,
+ local_branches: branch_info.local_branches,
+ remote_branches: branch_info.remote_branches,
+ ahead: branch_info.ahead,
+ behind: branch_info.behind,
+ })
+}
+
+fn git_branch_info_for_repo(
+ repo: &git2::Repository,
+ dir: &Path,
+) -> crate::error::Result {
+ let (head_ref, head_ref_shorthand) = git_head_refs(repo);
let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o?.to_string())).collect();
- let local_branches = local_branch_names(&repo)?;
- let remote_branches = remote_branch_names(&repo)?;
+ let local_branches = local_branch_names(repo)?;
+ let remote_branches = remote_branch_names(repo)?;
// Compute ahead/behind relative to remote tracking branch
let (ahead, behind) = (|| -> Option<(usize, usize)> {
@@ -174,15 +192,85 @@ pub fn git_status(dir: &Path) -> crate::error::Result {
})()
.unwrap_or((0, 0));
- Ok(GitStatusSummary {
- entries,
- origins,
+ Ok(GitBranchInfo {
path: dir.to_string_lossy().to_string(),
head_ref,
head_ref_shorthand,
+ origins,
local_branches,
remote_branches,
ahead: ahead as u32,
behind: behind as u32,
})
}
+
+fn git_head_refs(repo: &git2::Repository) -> (Option, Option) {
+ match repo.head() {
+ Ok(head) => {
+ let head_ref = head.name().map(|s| s.to_string());
+ let head_ref_shorthand = head.shorthand().map(|s| s.to_string());
+ (head_ref, head_ref_shorthand)
+ }
+ Err(_) => {
+ // For "unborn" repos, reading from HEAD is the only way to get the branch name
+ // See https://github.com/starship/starship/pull/1336
+ let head_path = repo.path().join("HEAD");
+ let head_ref = fs::read_to_string(&head_path)
+ .ok()
+ .unwrap_or_default()
+ .lines()
+ .next()
+ .map(|s| s.trim_start_matches("ref:").trim().to_string());
+ let head_ref_shorthand =
+ head_ref.clone().map(|r| r.split('/').last().unwrap_or("unknown").to_string());
+ (head_ref, head_ref_shorthand)
+ }
+ }
+}
+
+fn git_status_from_raw(status: git2::Status) -> Option<(GitStatus, bool)> {
+ let index_status = match status {
+ // Note: order matters here, since we're checking a bitmap!
+ s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
+ s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Untracked,
+ s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified,
+ s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed,
+ s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed,
+ s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::TypeChange,
+ s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
+ s => {
+ warn!("Unknown index status {s:?}");
+ return None;
+ }
+ };
+
+ let worktree_status = match status {
+ // Note: order matters here, since we're checking a bitmap!
+ s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
+ s if s.contains(git2::Status::WT_NEW) => GitStatus::Untracked,
+ s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified,
+ s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed,
+ s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed,
+ s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::TypeChange,
+ s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
+ s => {
+ warn!("Unknown worktree status {s:?}");
+ return None;
+ }
+ };
+
+ let status =
+ if index_status == GitStatus::Current { worktree_status } else { index_status.clone() };
+ let staged = index_status != GitStatus::Current;
+
+ Some((status, staged))
+}
+
+fn model_id_from_rela_path(path: &Path) -> Option {
+ let ext = path.extension()?.to_str()?;
+ if ext != "yaml" && ext != "yml" && ext != "json" {
+ return None;
+ }
+
+ path.file_stem()?.to_str()?.strip_prefix("yaak.").map(String::from)
+}
diff --git a/crates/yaak-http/src/types.rs b/crates/yaak-http/src/types.rs
index ed113752..0794df21 100644
--- a/crates/yaak-http/src/types.rs
+++ b/crates/yaak-http/src/types.rs
@@ -304,7 +304,10 @@ async fn build_binary_body(
}))
}
-fn build_text_body(body: &BTreeMap, body_type: &str) -> Option {
+fn build_text_body(
+ body: &BTreeMap,
+ body_type: &str,
+) -> Option {
let text = get_str_map(body, "text");
if text.is_empty() {
return None;
diff --git a/crates/yaak-models/src/models.rs b/crates/yaak-models/src/models.rs
index d7efe1e5..1b47ba79 100644
--- a/crates/yaak-models/src/models.rs
+++ b/crates/yaak-models/src/models.rs
@@ -16,8 +16,8 @@ use std::collections::HashMap;
use std::fmt::{Debug, Display};
use std::str::FromStr;
use ts_rs::TS;
+use yaak_database::{Result as DbResult, UpdateSource};
pub use yaak_database::{UpsertModelInfo, upsert_date};
-use yaak_database::{UpdateSource, Result as DbResult};
#[macro_export]
macro_rules! impl_model {
@@ -2526,4 +2526,3 @@ impl AnyModel {
}
}
}
-
diff --git a/crates/yaak-models/src/queries/folders.rs b/crates/yaak-models/src/queries/folders.rs
index 20d60ce3..702cae21 100644
--- a/crates/yaak-models/src/queries/folders.rs
+++ b/crates/yaak-models/src/queries/folders.rs
@@ -1,5 +1,5 @@
-use crate::connection_or_tx::ConnectionOrTx;
use crate::client_db::ClientDb;
+use crate::connection_or_tx::ConnectionOrTx;
use crate::error::Result;
use crate::models::{
Environment, EnvironmentIden, Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequest,
diff --git a/crates/yaak-models/src/queries/plugin_key_values.rs b/crates/yaak-models/src/queries/plugin_key_values.rs
index 9d5c664a..e01e471c 100644
--- a/crates/yaak-models/src/queries/plugin_key_values.rs
+++ b/crates/yaak-models/src/queries/plugin_key_values.rs
@@ -16,7 +16,10 @@ impl<'a> ClientDb<'a> {
.add(Expr::col(PluginKeyValueIden::Key).eq(key)),
)
.build_rusqlite(SqliteQueryBuilder);
- self.conn().resolve().query_row(sql.as_str(), &*params.as_params(), |row| row.try_into()).ok()
+ self.conn()
+ .resolve()
+ .query_row(sql.as_str(), &*params.as_params(), |row| row.try_into())
+ .ok()
}
pub fn set_plugin_key_value(
diff --git a/crates/yaak-models/src/util.rs b/crates/yaak-models/src/util.rs
index a05a4ff1..62cb7bd7 100644
--- a/crates/yaak-models/src/util.rs
+++ b/crates/yaak-models/src/util.rs
@@ -10,7 +10,9 @@ use std::collections::BTreeMap;
use ts_rs::TS;
use yaak_core::WorkspaceContext;
-pub use yaak_database::{ModelChangeEvent, generate_id, generate_id_of_length, generate_prefixed_id};
+pub use yaak_database::{
+ ModelChangeEvent, generate_id, generate_id_of_length, generate_prefixed_id,
+};
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
diff --git a/crates/yaak-proxy/src/body.rs b/crates/yaak-proxy/src/body.rs
index 216206de..5eef207a 100644
--- a/crates/yaak-proxy/src/body.rs
+++ b/crates/yaak-proxy/src/body.rs
@@ -79,10 +79,9 @@ where
let len = data.len();
self.bytes_count += len as u64;
self.chunks.push(data.clone());
- let _ = self.event_tx.send(ProxyEvent::ResponseBodyChunk {
- id: self.request_id,
- bytes: len,
- });
+ let _ = self
+ .event_tx
+ .send(ProxyEvent::ResponseBodyChunk { id: self.request_id, bytes: len });
}
Poll::Ready(Some(Ok(frame)))
}
diff --git a/crates/yaak-proxy/src/cert.rs b/crates/yaak-proxy/src/cert.rs
index 7636057c..e9105e02 100644
--- a/crates/yaak-proxy/src/cert.rs
+++ b/crates/yaak-proxy/src/cert.rs
@@ -18,23 +18,14 @@ impl CertificateAuthority {
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages.push(KeyUsagePurpose::KeyCertSign);
params.key_usages.push(KeyUsagePurpose::CrlSign);
- params
- .distinguished_name
- .push(rcgen::DnType::CommonName, "Debug Proxy CA");
- params
- .distinguished_name
- .push(rcgen::DnType::OrganizationName, "Debug Proxy");
+ params.distinguished_name.push(rcgen::DnType::CommonName, "Debug Proxy CA");
+ params.distinguished_name.push(rcgen::DnType::OrganizationName, "Debug Proxy");
let key = KeyPair::generate()?;
let ca_cert = params.self_signed(&key)?;
let ca_cert_der = ca_cert.der().clone();
- Ok(Self {
- ca_cert,
- ca_cert_der,
- ca_key: key,
- cache: Mutex::new(HashMap::new()),
- })
+ Ok(Self { ca_cert, ca_cert_der, ca_key: key, cache: Mutex::new(HashMap::new()) })
}
pub fn ca_pem(&self) -> String {
@@ -53,9 +44,7 @@ impl CertificateAuthority {
}
let mut params = CertificateParams::new(vec![domain.to_string()])?;
- params
- .distinguished_name
- .push(rcgen::DnType::CommonName, domain);
+ params.distinguished_name.push(rcgen::DnType::CommonName, domain);
let leaf_key = KeyPair::generate()?;
let leaf_cert = params.signed_by(&leaf_key, &self.ca_cert, &self.ca_key)?;
@@ -63,20 +52,18 @@ impl CertificateAuthority {
let cert_der = leaf_cert.der().clone();
let key_der = leaf_key.serialize_der();
- let mut config = ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
- .with_safe_default_protocol_versions()?
- .with_no_client_auth()
- .with_single_cert(
- vec![cert_der, self.ca_cert_der.clone()],
- PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)),
- )?;
+ let mut config =
+ ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
+ .with_safe_default_protocol_versions()?
+ .with_no_client_auth()
+ .with_single_cert(
+ vec![cert_der, self.ca_cert_der.clone()],
+ PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)),
+ )?;
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
let config = Arc::new(config);
- self.cache
- .lock()
- .unwrap()
- .insert(domain.to_string(), config.clone());
+ self.cache.lock().unwrap().insert(domain.to_string(), config.clone());
Ok(config)
}
}
diff --git a/crates/yaak-proxy/src/connection.rs b/crates/yaak-proxy/src/connection.rs
index 77e6ee6d..9719876e 100644
--- a/crates/yaak-proxy/src/connection.rs
+++ b/crates/yaak-proxy/src/connection.rs
@@ -1,5 +1,5 @@
-use std::sync::mpsc as std_mpsc;
use std::sync::Arc;
+use std::sync::mpsc as std_mpsc;
use hyper::server::conn::http1;
use hyper::service::service_fn;
diff --git a/crates/yaak-proxy/src/lib.rs b/crates/yaak-proxy/src/lib.rs
index 80f2b82a..a83a2d7c 100644
--- a/crates/yaak-proxy/src/lib.rs
+++ b/crates/yaak-proxy/src/lib.rs
@@ -4,9 +4,9 @@ mod connection;
mod request;
use std::net::SocketAddr;
+use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use std::sync::mpsc as std_mpsc;
-use std::sync::Arc;
use cert::CertificateAuthority;
use tokio::net::TcpListener;
@@ -27,7 +27,11 @@ pub enum ProxyEvent {
http_version: String,
},
/// A request header sent to the upstream server.
- RequestHeader { id: u64, name: String, value: String },
+ RequestHeader {
+ id: u64,
+ name: String,
+ value: String,
+ },
/// The full request body (buffered before forwarding).
RequestBody { id: u64, body: Vec },
/// Response headers received from upstream.
@@ -38,7 +42,11 @@ pub enum ProxyEvent {
elapsed_ms: u64,
},
/// A response header received from the upstream server.
- ResponseHeader { id: u64, name: String, value: String },
+ ResponseHeader {
+ id: u64,
+ name: String,
+ value: String,
+ },
/// A chunk of the response body was received (emitted per-frame).
ResponseBodyChunk { id: u64, bytes: usize },
/// The response body stream has completed.
diff --git a/crates/yaak-proxy/src/request.rs b/crates/yaak-proxy/src/request.rs
index 7285af5c..1de6bb63 100644
--- a/crates/yaak-proxy/src/request.rs
+++ b/crates/yaak-proxy/src/request.rs
@@ -63,10 +63,7 @@ fn emit_request_events(
});
}
if let Some(body) = body {
- let _ = tx.send(ProxyEvent::RequestBody {
- id,
- body: body.clone(),
- });
+ let _ = tx.send(ProxyEvent::RequestBody { id, body: body.clone() });
}
}
@@ -123,22 +120,13 @@ async fn handle_http(
let http_version = version_str(req.version());
let start = Instant::now();
- let _ = event_tx.send(ProxyEvent::RequestStart {
- id,
- method,
- url: uri.clone(),
- http_version,
- });
+ let _ = event_tx.send(ProxyEvent::RequestStart { id, method, url: uri.clone(), http_version });
let client: Client<_, Full> = Client::builder(TokioExecutor::new()).build_http();
let (parts, body) = req.into_parts();
let body_bytes = body.collect().await?.to_bytes();
- let request_body = if body_bytes.is_empty() {
- None
- } else {
- Some(body_bytes.to_vec())
- };
+ let request_body = if body_bytes.is_empty() { None } else { Some(body_bytes.to_vec()) };
emit_request_events(&event_tx, id, &parts.headers, &request_body);
let outgoing_req = Request::from_parts(parts, Full::new(body_bytes));
@@ -148,16 +136,10 @@ async fn handle_http(
emit_response_events(&event_tx, id, &resp, &start);
let (parts, body) = resp.into_parts();
- Ok(Response::from_parts(
- parts,
- measured_incoming(body, id, start, event_tx),
- ))
+ Ok(Response::from_parts(parts, measured_incoming(body, id, start, event_tx)))
}
Err(e) => {
- let _ = event_tx.send(ProxyEvent::Error {
- id,
- error: e.to_string(),
- });
+ let _ = event_tx.send(ProxyEvent::Error { id, error: e.to_string() });
Err(Box::new(e) as Box)
}
}
@@ -168,11 +150,7 @@ async fn handle_connect(
event_tx: std_mpsc::Sender,
ca: Arc,
) -> Result, Box> {
- let authority = req
- .uri()
- .authority()
- .map(|a| a.to_string())
- .unwrap_or_default();
+ let authority = req.uri().authority().map(|a| a.to_string()).unwrap_or_default();
let (host, port) = parse_host_port(&authority);
let server_config = ca.server_config(&host)?;
@@ -189,10 +167,7 @@ async fn handle_connect(
}
};
- let tls_stream = match acceptor
- .accept(hyper_util::rt::TokioIo::new(upgraded))
- .await
- {
+ let tls_stream = match acceptor.accept(hyper_util::rt::TokioIo::new(upgraded)).await {
Ok(s) => s,
Err(e) => {
eprintln!("TLS accept failed for {host}: {e}");
@@ -203,10 +178,7 @@ async fn handle_connect(
let tx = event_tx.clone();
let host_for_requests = host.clone();
let mut builder = auto::Builder::new(TokioExecutor::new());
- builder
- .http1()
- .preserve_header_case(true)
- .title_case_headers(true);
+ builder.http1().preserve_header_case(true).title_case_headers(true);
if let Err(e) = builder
.serve_connection_with_upgrades(
hyper_util::rt::TokioIo::new(tls_stream),
@@ -271,20 +243,12 @@ async fn forward_https(
let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed);
let method = req.method().to_string();
let http_version = version_str(req.version());
- let path = req
- .uri()
- .path_and_query()
- .map(|pq| pq.to_string())
- .unwrap_or_else(|| "/".into());
+ let path = req.uri().path_and_query().map(|pq| pq.to_string()).unwrap_or_else(|| "/".into());
let uri_str = format!("https://{host}{path}");
let start = Instant::now();
- let _ = event_tx.send(ProxyEvent::RequestStart {
- id,
- method,
- url: uri_str.clone(),
- http_version,
- });
+ let _ =
+ event_tx.send(ProxyEvent::RequestStart { id, method, url: uri_str.clone(), http_version });
// Connect to upstream with TLS
let tcp_stream = TcpStream::connect(target_addr).await?;
@@ -305,18 +269,13 @@ async fn forward_https(
let server_name = ServerName::try_from(host.to_string())?;
let tls_stream = connector.connect(server_name, tcp_stream).await?;
- let negotiated_h2 = tls_stream
- .get_ref()
- .1
- .alpn_protocol()
- .map_or(false, |p| p == b"h2");
+ let negotiated_h2 = tls_stream.get_ref().1.alpn_protocol().map_or(false, |p| p == b"h2");
let io = hyper_util::rt::TokioIo::new(tls_stream);
let mut sender = if negotiated_h2 {
- let (sender, conn) = hyper::client::conn::http2::Builder::new(TokioExecutor::new())
- .handshake(io)
- .await?;
+ let (sender, conn) =
+ hyper::client::conn::http2::Builder::new(TokioExecutor::new()).handshake(io).await?;
tokio::spawn(async move {
if let Err(e) = conn.await {
eprintln!("Upstream h2 connection error: {e}");
@@ -340,11 +299,7 @@ async fn forward_https(
// Capture request metadata
let (mut parts, body) = req.into_parts();
let body_bytes = body.collect().await?.to_bytes();
- let request_body = if body_bytes.is_empty() {
- None
- } else {
- Some(body_bytes.to_vec())
- };
+ let request_body = if body_bytes.is_empty() { None } else { Some(body_bytes.to_vec()) };
emit_request_events(&event_tx, id, &parts.headers, &request_body);
if negotiated_h2 {
@@ -365,16 +320,10 @@ async fn forward_https(
emit_response_events(&event_tx, id, &resp, &start);
let (parts, body) = resp.into_parts();
- Ok(Response::from_parts(
- parts,
- measured_incoming(body, id, start, event_tx),
- ))
+ Ok(Response::from_parts(parts, measured_incoming(body, id, start, event_tx)))
}
Err(e) => {
- let _ = event_tx.send(ProxyEvent::Error {
- id,
- error: e.to_string(),
- });
+ let _ = event_tx.send(ProxyEvent::Error { id, error: e.to_string() });
Err(Box::new(e) as Box)
}
}
diff --git a/crates/yaak-templates/src/lib.rs b/crates/yaak-templates/src/lib.rs
index db5c4e14..1fc5a6ea 100644
--- a/crates/yaak-templates/src/lib.rs
+++ b/crates/yaak-templates/src/lib.rs
@@ -1,9 +1,9 @@
pub mod error;
pub mod escape;
pub mod format_json;
-pub mod strip_json_comments;
pub mod parser;
pub mod renderer;
+pub mod strip_json_comments;
pub mod wasm;
pub use parser::*;
diff --git a/crates/yaak-templates/src/strip_json_comments.rs b/crates/yaak-templates/src/strip_json_comments.rs
index ade19c8c..5941f10e 100644
--- a/crates/yaak-templates/src/strip_json_comments.rs
+++ b/crates/yaak-templates/src/strip_json_comments.rs
@@ -113,11 +113,8 @@ pub fn strip_json_comments(text: &str) -> String {
}
// Remove lines that are now empty (were comment-only lines)
- let result = result
- .lines()
- .filter(|line| !line.trim().is_empty())
- .collect::>()
- .join("\n");
+ let result =
+ result.lines().filter(|line| !line.trim().is_empty()).collect::>().join("\n");
// Remove trailing commas before } or ]
strip_trailing_commas(&result)
@@ -192,10 +189,12 @@ mod tests {
#[test]
fn test_trailing_line_comment() {
assert_eq!(
- strip_json_comments(r#"{
+ strip_json_comments(
+ r#"{
"foo": "bar", // this is a comment
"baz": 123
-}"#),
+}"#
+ ),
r#"{
"foo": "bar",
"baz": 123
@@ -206,10 +205,12 @@ mod tests {
#[test]
fn test_whole_line_comment() {
assert_eq!(
- strip_json_comments(r#"{
+ strip_json_comments(
+ r#"{
// this is a comment
"foo": "bar"
-}"#),
+}"#
+ ),
r#"{
"foo": "bar"
}"#
@@ -219,9 +220,11 @@ mod tests {
#[test]
fn test_inline_block_comment() {
assert_eq!(
- strip_json_comments(r#"{
+ strip_json_comments(
+ r#"{
"foo": /* a comment */ "bar"
-}"#),
+}"#
+ ),
r#"{
"foo": "bar"
}"#
@@ -231,10 +234,12 @@ mod tests {
#[test]
fn test_whole_line_block_comment() {
assert_eq!(
- strip_json_comments(r#"{
+ strip_json_comments(
+ r#"{
/* a comment */
"foo": "bar"
-}"#),
+}"#
+ ),
r#"{
"foo": "bar"
}"#
@@ -244,12 +249,14 @@ mod tests {
#[test]
fn test_multiline_block_comment() {
assert_eq!(
- strip_json_comments(r#"{
+ strip_json_comments(
+ r#"{
/**
* Hello World!
*/
"foo": "bar"
-}"#),
+}"#
+ ),
r#"{
"foo": "bar"
}"#
@@ -276,12 +283,14 @@ mod tests {
#[test]
fn test_multiple_comments() {
assert_eq!(
- strip_json_comments(r#"{
+ strip_json_comments(
+ r#"{
// first comment
"foo": "bar", // trailing
/* block */
"baz": 123
-}"#),
+}"#
+ ),
r#"{
"foo": "bar",
"baz": 123
@@ -292,10 +301,12 @@ mod tests {
#[test]
fn test_trailing_comma_after_comment_removed() {
assert_eq!(
- strip_json_comments(r#"{
+ strip_json_comments(
+ r#"{
"a": "aaa",
// "b": "bbb"
-}"#),
+}"#
+ ),
r#"{
"a": "aaa"
}"#
@@ -304,10 +315,7 @@ mod tests {
#[test]
fn test_trailing_comma_in_array() {
- assert_eq!(
- strip_json_comments(r#"[1, 2, /* 3 */]"#),
- r#"[1, 2]"#
- );
+ assert_eq!(strip_json_comments(r#"[1, 2, /* 3 */]"#), r#"[1, 2]"#);
}
#[test]
diff --git a/crates/yaak/src/render.rs b/crates/yaak/src/render.rs
index 75015ae7..5523c9ce 100644
--- a/crates/yaak/src/render.rs
+++ b/crates/yaak/src/render.rs
@@ -2,7 +2,9 @@ use log::info;
use serde_json::Value;
use std::collections::BTreeMap;
use yaak_http::path_placeholders::apply_path_placeholders;
-use yaak_models::models::{Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter};
+use yaak_models::models::{
+ Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
+};
use yaak_models::render::make_vars_hashmap;
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
diff --git a/package-lock.json b/package-lock.json
index eeef35d3..67f7adc6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -79,7 +79,7 @@
},
"devDependencies": {
"@rolldown/plugin-babel": "^0.2.3",
- "@tauri-apps/cli": "^2.9.6",
+ "@tauri-apps/cli": "^2.11.1",
"@types/babel__core": "^7.20.5",
"@vitejs/plugin-react": "^6.0.1",
"@yaakapp/cli": "^0.5.1",
@@ -119,14 +119,14 @@
"@tanstack/react-query": "^5.90.5",
"@tanstack/react-router": "^1.133.13",
"@tanstack/react-virtual": "^3.13.12",
- "@tauri-apps/api": "^2.9.1",
+ "@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
- "@tauri-apps/plugin-dialog": "^2.4.2",
- "@tauri-apps/plugin-fs": "^2.4.4",
- "@tauri-apps/plugin-log": "^2.7.1",
- "@tauri-apps/plugin-opener": "^2.5.2",
+ "@tauri-apps/plugin-dialog": "^2.7.1",
+ "@tauri-apps/plugin-fs": "^2.5.1",
+ "@tauri-apps/plugin-log": "^2.8.0",
+ "@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-os": "^2.3.2",
- "@tauri-apps/plugin-shell": "^2.3.3",
+ "@tauri-apps/plugin-shell": "^2.3.5",
"buffer": "^6.0.3",
"classnames": "^2.5.1",
"cm6-graphql": "^0.2.1",
@@ -140,6 +140,7 @@
"hexy": "^0.3.5",
"history": "^5.3.0",
"jotai": "^2.18.0",
+ "jotai-family": "^1.0.1",
"js-md5": "^0.8.3",
"lucide-react": "^0.525.0",
"mime": "^4.0.4",
@@ -240,7 +241,7 @@
"version": "1.0.0",
"dependencies": {
"@tanstack/react-query": "^5.90.5",
- "@tauri-apps/api": "^2.9.1",
+ "@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-os": "^2.3.2",
"@yaakapp-internal/model-store": "^1.0.0",
"@yaakapp-internal/proxy-lib": "^1.0.0",
@@ -248,6 +249,7 @@
"@yaakapp-internal/ui": "^1.0.0",
"classnames": "^2.5.1",
"jotai": "^2.18.0",
+ "jotai-family": "^1.0.1",
"motion": "^12.4.7",
"react": "^19.2.0",
"react-dom": "^19.2.0"
@@ -4173,9 +4175,9 @@
}
},
"node_modules/@tauri-apps/api": {
- "version": "2.9.1",
- "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
- "integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
+ "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
@@ -4183,9 +4185,9 @@
}
},
"node_modules/@tauri-apps/cli": {
- "version": "2.9.6",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.6.tgz",
- "integrity": "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==",
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.1.tgz",
+ "integrity": "sha512-rpEbaJ/HzNb6fwsquwoAbq29/Vt4gADhS423A8fdkwL4edJ0wZmoB8ar7O6JPDL834MUKOCm/rrJ7c9oAaEaYQ==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
@@ -4199,23 +4201,23 @@
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
- "@tauri-apps/cli-darwin-arm64": "2.9.6",
- "@tauri-apps/cli-darwin-x64": "2.9.6",
- "@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6",
- "@tauri-apps/cli-linux-arm64-gnu": "2.9.6",
- "@tauri-apps/cli-linux-arm64-musl": "2.9.6",
- "@tauri-apps/cli-linux-riscv64-gnu": "2.9.6",
- "@tauri-apps/cli-linux-x64-gnu": "2.9.6",
- "@tauri-apps/cli-linux-x64-musl": "2.9.6",
- "@tauri-apps/cli-win32-arm64-msvc": "2.9.6",
- "@tauri-apps/cli-win32-ia32-msvc": "2.9.6",
- "@tauri-apps/cli-win32-x64-msvc": "2.9.6"
+ "@tauri-apps/cli-darwin-arm64": "2.11.1",
+ "@tauri-apps/cli-darwin-x64": "2.11.1",
+ "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.1",
+ "@tauri-apps/cli-linux-arm64-gnu": "2.11.1",
+ "@tauri-apps/cli-linux-arm64-musl": "2.11.1",
+ "@tauri-apps/cli-linux-riscv64-gnu": "2.11.1",
+ "@tauri-apps/cli-linux-x64-gnu": "2.11.1",
+ "@tauri-apps/cli-linux-x64-musl": "2.11.1",
+ "@tauri-apps/cli-win32-arm64-msvc": "2.11.1",
+ "@tauri-apps/cli-win32-ia32-msvc": "2.11.1",
+ "@tauri-apps/cli-win32-x64-msvc": "2.11.1"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
- "version": "2.9.6",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.6.tgz",
- "integrity": "sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==",
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.1.tgz",
+ "integrity": "sha512-6eEKMBXsQPCuM1EmvrjT2+aBuxWQuFdKdW8pzNuNQtpq45nEEpBlD5gr8pUeAyOU1DQKlkFaEc/MPBxb/Pfjtg==",
"cpu": [
"arm64"
],
@@ -4230,9 +4232,9 @@
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
- "version": "2.9.6",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.9.6.tgz",
- "integrity": "sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw==",
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.1.tgz",
+ "integrity": "sha512-LQUO7exfRWjWALNhetph5guWpMeHphRpokOLk0OIbTTExaNwJNFu3I4vb+CCM/4G/QGoZe/5XikZOJdNEFP1ig==",
"cpu": [
"x64"
],
@@ -4247,9 +4249,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
- "version": "2.9.6",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.6.tgz",
- "integrity": "sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg==",
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.1.tgz",
+ "integrity": "sha512-5i/awiBCRRhOUG8yjn0fMHXIWD5Ez8eEk5LtvOxyQrKuJkRaZDvnbIjZbE183blAwkoA4xN3aO/prJiqscl02Q==",
"cpu": [
"arm"
],
@@ -4264,9 +4266,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
- "version": "2.9.6",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.6.tgz",
- "integrity": "sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg==",
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.1.tgz",
+ "integrity": "sha512-9LrwDw3S9Fygtw/Q6WDhOP+3svJRGAsejeE+GKrc0eO1ThMVhwi2LL6hw4dlKw93IfS7VY1G19sWGxJ/NcU4nA==",
"cpu": [
"arm64"
],
@@ -4281,9 +4283,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
- "version": "2.9.6",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.6.tgz",
- "integrity": "sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==",
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.1.tgz",
+ "integrity": "sha512-mNA5dbbqPqDUdTIwdUYYuhO2GvIe9UnB2r0VU2njxBOS3Opbx4gKNC5yP0Iu4rYmEmqdlwry9VzGZQ3wq9dyFg==",
"cpu": [
"arm64"
],
@@ -4298,9 +4300,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
- "version": "2.9.6",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.6.tgz",
- "integrity": "sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==",
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.1.tgz",
+ "integrity": "sha512-fZj3Gwq+6fUs305T5WQiD5iSGJw+j/4w/HGmk4sHDAcy+rp9zU5eaxB7nOyz5/I/nkNAuKPqfp6uIbiUBXkBCw==",
"cpu": [
"riscv64"
],
@@ -4315,9 +4317,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
- "version": "2.9.6",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.6.tgz",
- "integrity": "sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==",
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.1.tgz",
+ "integrity": "sha512-XFxGxOvHM7jjeD6ozCKdGfhzJ7lERYDGZl1/Kb4fsvchaJsfLJ981TlyTG8Qy/gFq+f5GitH3bfrX9JAkjPEyw==",
"cpu": [
"x64"
],
@@ -4332,9 +4334,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
- "version": "2.9.6",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.6.tgz",
- "integrity": "sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==",
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.1.tgz",
+ "integrity": "sha512-d5C2/Zm+68v7R9wTuTCjRQEVrWjcdMkJBZ1+rXse+QdMMlTB9+u9PDNDLw9PQflWxYLaYZ7tjxxL9Nb9II6PbA==",
"cpu": [
"x64"
],
@@ -4349,9 +4351,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
- "version": "2.9.6",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.6.tgz",
- "integrity": "sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==",
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.1.tgz",
+ "integrity": "sha512-YdeVWFAR1pTXzUU6NLstPq4G6OLxuDrXCXEBdmBH+5EZIDXUx0D2kJlz3+YjpazkKvAzYpgziTsyRagls0OfRQ==",
"cpu": [
"arm64"
],
@@ -4366,9 +4368,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
- "version": "2.9.6",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.6.tgz",
- "integrity": "sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg==",
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.1.tgz",
+ "integrity": "sha512-VBGkuH0eB9K9LLSMv361Gzr5Ou72sCS4+ztpmkWEQ+wd/amhcYOsf3X6qn1RJZDzIhiOYHJEOysZUC3baD01rA==",
"cpu": [
"ia32"
],
@@ -4383,9 +4385,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
- "version": "2.9.6",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.6.tgz",
- "integrity": "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw==",
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.1.tgz",
+ "integrity": "sha512-b3ORhIAKgp9ZYY+zBt7b7r0kLU2kjvyGF0+MS2SBym3emsweGPybEqocJcmtMuxyBhkOKHP4CiuEJEDuAlTx6A==",
"cpu": [
"x64"
],
@@ -4409,21 +4411,21 @@
}
},
"node_modules/@tauri-apps/plugin-dialog": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.5.0.tgz",
- "integrity": "sha512-I0R0ygwRd9AN8Wj5GnzCogOlqu2+OWAtBd0zEC4+kQCI32fRowIyuhPCBoUv4h/lQt2bM39kHlxPHD5vDcFjiA==",
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
+ "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
- "@tauri-apps/api": "^2.8.0"
+ "@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-fs": {
- "version": "2.4.5",
- "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz",
- "integrity": "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz",
+ "integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
- "@tauri-apps/api": "^2.8.0"
+ "@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-log": {
@@ -4436,12 +4438,12 @@
}
},
"node_modules/@tauri-apps/plugin-opener": {
- "version": "2.5.3",
- "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
- "integrity": "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==",
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
+ "integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
- "@tauri-apps/api": "^2.8.0"
+ "@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-os": {
@@ -4454,12 +4456,12 @@
}
},
"node_modules/@tauri-apps/plugin-shell": {
- "version": "2.3.4",
- "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.4.tgz",
- "integrity": "sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA==",
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
+ "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
- "@tauri-apps/api": "^2.8.0"
+ "@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@types/aws4": {
@@ -7927,9 +7929,9 @@
"integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw=="
},
"node_modules/fast-uri": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
- "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
+ "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"funding": [
{
"type": "github",
@@ -9733,6 +9735,18 @@
}
}
},
+ "node_modules/jotai-family": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/jotai-family/-/jotai-family-1.0.1.tgz",
+ "integrity": "sha512-Zb/79GNDhC/z82R+6qTTpeKW4l4H6ZCApfF5W8G4SH37E4mhbysU7r8DkP0KX94hWvjB/6lt/97nSr3wB+64Zg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "jotai": ">=2.9.0"
+ }
+ },
"node_modules/js-cookie": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
@@ -16941,14 +16955,17 @@
"name": "@yaakapp-internal/theme",
"version": "1.0.0",
"dependencies": {
- "@tauri-apps/api": "^2.9.1",
+ "@tauri-apps/api": "^2.11.0",
"@yaakapp-internal/plugins": "^1.0.0",
"parse-color": "^1.0.0"
}
},
"packages/ui": {
"name": "@yaakapp-internal/ui",
- "version": "1.0.0"
+ "version": "1.0.0",
+ "dependencies": {
+ "jotai-family": "^1.0.1"
+ }
},
"plugins-external/faker": {
"name": "@yaak/faker",
diff --git a/package.json b/package.json
index 29fadeee..8f2265b4 100644
--- a/package.json
+++ b/package.json
@@ -111,7 +111,7 @@
},
"devDependencies": {
"@rolldown/plugin-babel": "^0.2.3",
- "@tauri-apps/cli": "^2.9.6",
+ "@tauri-apps/cli": "^2.11.1",
"@types/babel__core": "^7.20.5",
"@vitejs/plugin-react": "^6.0.1",
"@yaakapp/cli": "^0.5.1",
diff --git a/packages/common-lib/eagerDebounceAsync.ts b/packages/common-lib/eagerDebounceAsync.ts
new file mode 100644
index 00000000..09e2c2b1
--- /dev/null
+++ b/packages/common-lib/eagerDebounceAsync.ts
@@ -0,0 +1,36 @@
+export function eagerDebounceAsync(fn: () => Promise, delay: number) {
+ let timer: ReturnType | null = null;
+ let inFlight: Promise | null = null;
+ let runAfterInFlight = false;
+
+ const run = async () => {
+ if (inFlight != null) {
+ runAfterInFlight = true;
+ return;
+ }
+
+ runAfterInFlight = false;
+ inFlight = fn()
+ .catch(console.error)
+ .finally(() => {
+ inFlight = null;
+ if (runAfterInFlight && timer == null) {
+ void run();
+ }
+ });
+ await inFlight;
+ };
+
+ return () => {
+ if (timer == null) {
+ void run();
+ } else {
+ clearTimeout(timer);
+ }
+
+ timer = setTimeout(() => {
+ timer = null;
+ void run();
+ }, delay);
+ };
+}
diff --git a/packages/common-lib/index.ts b/packages/common-lib/index.ts
index 01983b42..245e43a4 100644
--- a/packages/common-lib/index.ts
+++ b/packages/common-lib/index.ts
@@ -1,3 +1,4 @@
export * from "./debounce";
+export * from "./eagerDebounceAsync";
export * from "./formatSize";
export * from "./templateFunction";
diff --git a/packages/theme/package.json b/packages/theme/package.json
index bb125320..8ece0029 100644
--- a/packages/theme/package.json
+++ b/packages/theme/package.json
@@ -6,7 +6,7 @@
"main": "src/index.ts",
"types": "src/index.ts",
"dependencies": {
- "@tauri-apps/api": "^2.9.1",
+ "@tauri-apps/api": "^2.11.0",
"@yaakapp-internal/plugins": "^1.0.0",
"parse-color": "^1.0.0"
}
diff --git a/packages/ui/package.json b/packages/ui/package.json
index c0b8a1a5..283be298 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -4,5 +4,8 @@
"private": true,
"type": "module",
"main": "src/index.ts",
- "types": "src/index.ts"
+ "types": "src/index.ts",
+ "dependencies": {
+ "jotai-family": "^1.0.1"
+ }
}
diff --git a/packages/ui/src/components/tree/TreeItem.tsx b/packages/ui/src/components/tree/TreeItem.tsx
index 6a64195c..61375662 100644
--- a/packages/ui/src/components/tree/TreeItem.tsx
+++ b/packages/ui/src/components/tree/TreeItem.tsx
@@ -81,7 +81,7 @@ function TreeItem_({
const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id }));
const [editing, setEditing] = useState(false);
const [dropHover, setDropHover] = useState(null);
- const startedHoverTimeout = useRef(undefined);
+ const startedHoverTimeout = useRef>(undefined);
const handle = useMemo(
() => ({
focus: () => {
@@ -141,7 +141,13 @@ function TreeItem_({
const handleSubmitNameEdit = useCallback(
async (el: HTMLInputElement) => {
- getEditOptions?.(node.item).onChange(node.item, el.value);
+ const editOptions = getEditOptions?.(node.item);
+ if (editOptions == null || el.value === editOptions.defaultValue) {
+ setEditing(false);
+ return;
+ }
+
+ editOptions.onChange(node.item, el.value);
onClick?.(node.item, { shiftKey: false, ctrlKey: false, metaKey: false });
// Slight delay for the model to propagate to the local store
setTimeout(() => setEditing(false), 200);
diff --git a/packages/ui/src/components/tree/atoms.ts b/packages/ui/src/components/tree/atoms.ts
index 8a6a64b5..d7070d11 100644
--- a/packages/ui/src/components/tree/atoms.ts
+++ b/packages/ui/src/components/tree/atoms.ts
@@ -1,5 +1,6 @@
import { atom } from "jotai";
-import { atomFamily, selectAtom } from "jotai/utils";
+import { atomFamily } from "jotai-family";
+import { selectAtom } from "jotai/utils";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const selectedIdsFamily = atomFamily((_treeId: string) => {
diff --git a/vite.config.ts b/vite.config.ts
index 2b8e5e29..421e45f6 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,6 +1,9 @@
import { defineConfig } from "vite-plus";
export default defineConfig({
+ staged: {
+ "*": "vp check --fix",
+ },
lint: {
ignorePatterns: ["npm/**", "crates/yaak-templates/pkg/**", "**/bindings/gen_*.ts"],
options: {