From d7e67cf13c95db693033c6d6edca3dd8e7615591 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 8 May 2026 11:25:39 -0700 Subject: [PATCH] Add live git status indicators (#458) --- Cargo.lock | 1 + .../components/EnvironmentEditDialog.tsx | 2 +- apps/yaak-client/components/Sidebar.tsx | 163 ++- .../core/HttpResponseDurationTag.tsx | 2 +- apps/yaak-client/components/core/Tooltip.tsx | 2 +- .../components/git/FileHistoryDialog.tsx | 131 +++ .../components/git/GitCommitDialog.tsx | 58 +- .../components/git/GitDropdown.tsx | 931 +++++++++--------- .../components/git/GitRemotesDialog.tsx | 5 +- .../components/git/HistoryDialog.tsx | 12 +- apps/yaak-client/components/git/callbacks.tsx | 5 + apps/yaak-client/init/git.ts | 38 + apps/yaak-client/init/sync.ts | 34 +- apps/yaak-client/lib/diffYaml.ts | 3 +- apps/yaak-client/lib/gitWorktreeStatus.ts | 22 + apps/yaak-client/main.tsx | 2 + apps/yaak-client/package.json | 1 + apps/yaak-proxy/components/Sidebar.tsx | 2 +- apps/yaak-proxy/package.json | 1 + crates-tauri/yaak-app-client/Cargo.toml | 1 + .../yaak-app-client/bindings/index.ts | 2 + crates-tauri/yaak-app-client/src/git_ext.rs | 66 +- .../yaak-app-client/src/git_watcher.rs | 172 ++++ crates-tauri/yaak-app-client/src/lib.rs | 8 + crates/yaak-git/bindings/gen_git.ts | 10 +- crates/yaak-git/index.ts | 154 ++- crates/yaak-git/src/lib.rs | 10 +- crates/yaak-git/src/log.rs | 72 ++ crates/yaak-git/src/repository.rs | 23 +- crates/yaak-git/src/restore.rs | 76 ++ crates/yaak-git/src/status.rs | 234 +++-- package-lock.json | 19 +- packages/ui/package.json | 5 +- packages/ui/src/components/tree/TreeItem.tsx | 10 +- packages/ui/src/components/tree/atoms.ts | 3 +- 35 files changed, 1702 insertions(+), 578 deletions(-) create mode 100644 apps/yaak-client/components/git/FileHistoryDialog.tsx create mode 100644 apps/yaak-client/init/git.ts create mode 100644 apps/yaak-client/lib/gitWorktreeStatus.ts create mode 100644 crates-tauri/yaak-app-client/src/git_watcher.rs create mode 100644 crates/yaak-git/src/restore.rs diff --git a/Cargo.lock b/Cargo.lock index 477efc5a..ea781cc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10310,6 +10310,7 @@ dependencies = [ "log 0.4.29", "md5 0.8.0", "mime_guess", + "notify", "openssl-sys", "pretty_graphql", "r2d2", diff --git a/apps/yaak-client/components/EnvironmentEditDialog.tsx b/apps/yaak-client/components/EnvironmentEditDialog.tsx index effc819d..1b6c6b49 100644 --- a/apps/yaak-client/components/EnvironmentEditDialog.tsx +++ b/apps/yaak-client/components/EnvironmentEditDialog.tsx @@ -3,7 +3,7 @@ import { duplicateModel, patchModel } from "@yaakapp-internal/models"; import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui"; import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui"; import { atom, useAtomValue } from "jotai"; -import { atomFamily } from "jotai/utils"; +import { atomFamily } from "jotai-family"; import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { createSubEnvironmentAndActivate } from "../commands/createEnvironment"; import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace"; diff --git a/apps/yaak-client/components/Sidebar.tsx b/apps/yaak-client/components/Sidebar.tsx index dae1347c..985dc078 100644 --- a/apps/yaak-client/components/Sidebar.tsx +++ b/apps/yaak-client/components/Sidebar.tsx @@ -1,6 +1,8 @@ import type { Extension } from "@codemirror/state"; import { Compartment } from "@codemirror/state"; import { debounce } from "@yaakapp-internal/lib"; +import { gitMutations } from "@yaakapp-internal/git"; +import type { GitStatus } from "@yaakapp-internal/git"; import type { AnyModel, Folder, @@ -23,13 +25,18 @@ import { } from "@yaakapp-internal/models"; import classNames from "classnames"; import { atom, useAtomValue } from "jotai"; -import { atomFamily, selectAtom } from "jotai/utils"; +import { atomFamily } from "jotai-family"; +import { selectAtom } from "jotai/utils"; import { memo, useCallback, useEffect, useMemo, useRef } from "react"; import { moveToWorkspace } from "../commands/moveToWorkspace"; import { openFolderSettings } from "../commands/openFolderSettings"; import { activeFolderIdAtom } from "../hooks/useActiveFolderId"; import { activeRequestIdAtom } from "../hooks/useActiveRequestId"; -import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace"; +import { + activeWorkspaceAtom, + activeWorkspaceIdAtom, + activeWorkspaceMetaAtom, +} from "../hooks/useActiveWorkspace"; import { allRequestsAtom } from "../hooks/useAllRequests"; import { getCreateDropdownItems } from "../hooks/useCreateDropdownItems"; import { getFolderActions } from "../hooks/useFolderActions"; @@ -42,7 +49,13 @@ import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest"; import { useSidebarHidden } from "../hooks/useSidebarHidden"; import { getWebsocketRequestActions } from "../hooks/useWebsocketRequestActions"; import { deepEqualAtom } from "../lib/atoms"; +import { showConfirm } from "../lib/confirm"; import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm"; +import { showDialog } from "../lib/dialog"; +import { + gitWorktreeStatusByModelIdAtom, + gitWorktreeStatusFamily, +} from "../lib/gitWorktreeStatus"; import { jotaiStore } from "../lib/jotai"; import { resolvedModelName } from "../lib/resolvedModelName"; import { isSidebarFocused } from "../lib/scopes"; @@ -68,6 +81,9 @@ import type { InputHandle } from "./core/Input"; import { Input } from "./core/Input"; import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage"; import { GitDropdown } from "./git/GitDropdown"; +import { gitCallbacks } from "./git/callbacks"; +import { FileHistoryDialog } from "./git/FileHistoryDialog"; +import { sync } from "../init/sync"; const collapsedFamily = atomFamily((treeId: string) => { const key = ["sidebar_collapsed", treeId ?? "n/a"]; @@ -375,6 +391,8 @@ function Sidebar({ className }: { className?: string }) { } const workspaces = jotaiStore.get(workspacesAtom); + const syncDir = jotaiStore.get(activeWorkspaceMetaAtom)?.settingSyncDir; + const gitItems = getGitContextMenuItems({ items, syncDir }); const onlyHttpRequests = items.every((i) => i.model === "http_request"); const requestItems = items.filter( (i) => @@ -458,8 +476,10 @@ function Sidebar({ className }: { className?: string }) { ...initialItems, { type: "separator", - hidden: initialItems.filter((v) => !v.hidden).length === 0, + hidden: initialItems.filter((v) => !v.hidden).length === 0 || gitItems.length === 0, }, + ...gitItems, + { type: "separator", hidden: gitItems.length === 0 }, { label: "Rename", leftSlot: , @@ -661,6 +681,73 @@ function Sidebar({ className }: { className?: string }) { export default Sidebar; +function getGitContextMenuItems({ + items, + syncDir, +}: { + items: SidebarModel[]; + syncDir: string | null | undefined; +}): DropdownItem[] { + if (syncDir == null) return []; + + const gitStatusEntries = items.flatMap((item) => { + const status = jotaiStore.get(gitWorktreeStatusFamily(item.id)); + return status == null || status.status === "current" ? [] : [status]; + }); + const historyItem = items.length === 1 ? items[0] : null; + const historyPath = + historyItem == null + ? null + : (jotaiStore.get(gitWorktreeStatusFamily(historyItem.id))?.relaPath ?? + syncPathForModel(historyItem)); + + return [ + { + label: "View History", + leftSlot: , + hidden: historyPath == null, + onSelect: () => { + if (historyPath == null) return; + showDialog({ + id: "git-history", + size: "lg", + title: "File History", + noPadding: true, + noScroll: true, + render: () => , + }); + }, + }, + { + label: "Restore Changes", + leftSlot: , + hidden: gitStatusEntries.length === 0, + async onSelect() { + const confirmed = await showConfirm({ + id: "git-restore-sidebar-items", + title: "Restore Changes", + description: + gitStatusEntries.length === 1 + ? "This will discard uncommitted changes for the selected item." + : `This will discard uncommitted changes for ${gitStatusEntries.length} selected items.`, + confirmText: "Restore", + color: "danger", + }); + if (!confirmed) return; + + await gitMutations(syncDir, gitCallbacks(syncDir)).restore.mutateAsync({ + relaPaths: gitStatusEntries.map((entry) => entry.relaPath), + }); + await sync({ force: true }); + }, + }, + ]; +} + +function syncPathForModel(item: SidebarModel) { + return `yaak.${item.id}.yaml`; +} + const activeIdAtom = atom((get) => { return get(activeRequestIdAtom) || get(activeFolderIdAtom); }); @@ -790,6 +877,64 @@ const sidebarTreeAtom = atom<[TreeNode, FieldDef[]] | null>((get) return [root, fields] as const; }); +const sidebarGitStatusByModelIdAtom = atom>((get) => { + const allModels = get(memoAllPotentialChildrenAtom); + const activeWorkspace = get(activeWorkspaceAtom); + const gitStatusByModelId = get(gitWorktreeStatusByModelIdAtom); + const childrenMap: Record[]> = {}; + const statusByModelId: Record = {}; + + for (const item of allModels) { + if ("folderId" in item && item.folderId == null) { + childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? []; + childrenMap[item.workspaceId]?.push(item); + } else if ("folderId" in item && item.folderId != null) { + childrenMap[item.folderId] = childrenMap[item.folderId] ?? []; + childrenMap[item.folderId]?.push(item); + } + } + + const visit = (item: SidebarModel): GitStatus | null => { + const statuses: GitStatus[] = []; + const directStatus = gitStatusByModelId[item.id]?.status; + if (directStatus != null && directStatus !== "current") { + statuses.push(directStatus); + } + + for (const child of childrenMap[item.id] ?? []) { + const childStatus = visit(child); + if (childStatus != null) statuses.push(childStatus); + } + + const status = summarizeGitStatuses(statuses); + if (status != null) { + statusByModelId[item.id] = status; + } + return status; + }; + + if (activeWorkspace != null) { + visit(activeWorkspace); + } + + return statusByModelId; +}); + +const sidebarGitStatusFamily = atomFamily( + (modelId: string) => + selectAtom(sidebarGitStatusByModelIdAtom, (statusByModelId) => statusByModelId[modelId] ?? null), + Object.is, +); + +function summarizeGitStatuses(statuses: GitStatus[]): GitStatus | null { + if (statuses.length === 0) return null; + const firstStatus = statuses[0]; + if (firstStatus != null && statuses.every((status) => status === firstStatus)) { + return firstStatus; + } + return "modified"; +} + function getItemKey(item: SidebarModel) { const responses = jotaiStore.get(httpResponsesAtom); const latestResponse = responses.find((r) => r.requestId === item.id) ?? null; @@ -836,6 +981,7 @@ const SidebarInnerItem = memo(function SidebarInnerItem({ treeId: string; item: SidebarModel; }) { + const gitStatus = useAtomValue(sidebarGitStatusFamily(item.id)); const response = useAtomValue( useMemo( () => @@ -854,7 +1000,16 @@ const SidebarInnerItem = memo(function SidebarInnerItem({ return (
-
{resolvedModelName(item)}
+
+ {resolvedModelName(item)} +
{response != null && (
{response.state !== "closed" ? ( diff --git a/apps/yaak-client/components/core/HttpResponseDurationTag.tsx b/apps/yaak-client/components/core/HttpResponseDurationTag.tsx index 6c7c2ad2..93a7dd9c 100644 --- a/apps/yaak-client/components/core/HttpResponseDurationTag.tsx +++ b/apps/yaak-client/components/core/HttpResponseDurationTag.tsx @@ -7,7 +7,7 @@ interface Props { export function HttpResponseDurationTag({ response }: Props) { const [fallbackElapsed, setFallbackElapsed] = useState(0); - const timeout = useRef(undefined); + const timeout = useRef>(undefined); // Calculate the duration of the response for use when the response hasn't finished yet useEffect(() => { diff --git a/apps/yaak-client/components/core/Tooltip.tsx b/apps/yaak-client/components/core/Tooltip.tsx index 94d71a50..beccd289 100644 --- a/apps/yaak-client/components/core/Tooltip.tsx +++ b/apps/yaak-client/components/core/Tooltip.tsx @@ -31,7 +31,7 @@ export function Tooltip({ children, className, content, tabIndex, size = "md" }: const [openState, setOpenState] = useState(null); const triggerRef = useRef(null); const tooltipRef = useRef(null); - const showTimeout = useRef(undefined); + const showTimeout = useRef>(undefined); const handleOpenImmediate = () => { if (triggerRef.current == null || tooltipRef.current == null) return; diff --git a/apps/yaak-client/components/git/FileHistoryDialog.tsx b/apps/yaak-client/components/git/FileHistoryDialog.tsx new file mode 100644 index 00000000..90c416e0 --- /dev/null +++ b/apps/yaak-client/components/git/FileHistoryDialog.tsx @@ -0,0 +1,131 @@ +import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git"; +import type { GitCommit } from "@yaakapp-internal/git"; +import { InlineCode, SplitLayout } from "@yaakapp-internal/ui"; +import classNames from "classnames"; +import { formatDistanceToNowStrict } from "date-fns"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { sync } from "../../init/sync"; +import { showConfirm } from "../../lib/confirm"; +import { EmptyStateText } from "../EmptyStateText"; +import { Button } from "../core/Button"; +import { DiffViewer } from "../core/Editor/DiffViewer"; +import { useGitCallbacks } from "./callbacks"; + +export function FileHistoryDialog({ dir, relaPath }: { dir: string; relaPath: string }) { + const callbacks = useGitCallbacks(dir); + const { restoreFileFromCommit } = useGitMutations(dir, callbacks); + const log = useGitLog(dir, undefined, relaPath); + const commits = log.data ?? []; + const [selectedOid, setSelectedOid] = useState(null); + const selectedCommit = useMemo( + () => commits.find((commit) => commit.oid === selectedOid) ?? null, + [commits, selectedOid], + ); + const diff = useGitFileDiffForCommit(dir, relaPath, selectedCommit?.oid); + + useEffect(() => { + if (commits.length === 0) { + setSelectedOid(null); + } else if (selectedOid == null || !commits.some((commit) => commit.oid === selectedOid)) { + setSelectedOid(commits[0]?.oid ?? null); + } + }, [commits, selectedOid]); + + const handleRestoreCommit = useCallback( + async (commit: GitCommit) => { + const confirmed = await showConfirm({ + id: "git-restore-file-history-entry", + title: "Restore File", + description: "This will restore the file to the selected commit.", + confirmText: "Restore", + color: "warning", + }); + if (!confirmed) return; + + await restoreFileFromCommit.mutateAsync({ commitOid: commit.oid, relaPath }); + await sync({ force: true }); + }, + [relaPath, restoreFileFromCommit], + ); + + if (commits.length === 0 && !log.isLoading) { + return No history for this file; + } + + return ( +
+ ( +
+
+ {commits.map((commit) => ( + setSelectedOid(commit.oid)} + /> + ))} +
+
+ )} + secondSlot={({ style }) => ( +
+ {selectedCommit == null ? ( + Select a commit to view diff + ) : ( +
+
+
{selectedCommit.message || "No message"}
+ +
+ +
+ )} +
+ )} + /> +
+ ); +} + +function CommitListItem({ + commit, + selected, + onSelect, +}: { + commit: GitCommit; + selected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} diff --git a/apps/yaak-client/components/git/GitCommitDialog.tsx b/apps/yaak-client/components/git/GitCommitDialog.tsx index 79765257..fd1b65cf 100644 --- a/apps/yaak-client/components/git/GitCommitDialog.tsx +++ b/apps/yaak-client/components/git/GitCommitDialog.tsx @@ -8,12 +8,14 @@ import type { WebsocketRequest, Workspace, } from "@yaakapp-internal/models"; -import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal/ui"; +import { Banner, HStack, Icon, IconButton, InlineCode, SplitLayout } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useCallback, useMemo, useState } from "react"; import { modelToYaml } from "../../lib/diffYaml"; import { resolvedModelName } from "../../lib/resolvedModelName"; +import { showConfirm } from "../../lib/confirm"; import { showErrorToast } from "../../lib/toast"; +import { sync } from "../../init/sync"; import { Button } from "../core/Button"; import type { CheckboxProps } from "../core/Checkbox"; import { Checkbox } from "../core/Checkbox"; @@ -21,7 +23,7 @@ import { DiffViewer } from "../core/Editor/DiffViewer"; import { Input } from "../core/Input"; import { Separator } from "../core/Separator"; import { EmptyStateText } from "../EmptyStateText"; -import { gitCallbacks } from "./callbacks"; +import { useGitCallbacks } from "./callbacks"; import { handlePushResult } from "./git-util"; interface Props { @@ -38,9 +40,10 @@ interface CommitTreeNode { } export function GitCommitDialog({ syncDir, onDone, workspace }: Props) { - const [{ status }, { commit, commitAndPush, add, unstage }] = useGit( + const callbacks = useGitCallbacks(syncDir); + const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit( syncDir, - gitCallbacks(syncDir), + callbacks, ); const [isPushing, setIsPushing] = useState(false); const [commitError, setCommitError] = useState(null); @@ -165,6 +168,24 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) { [selectedEntry], ); + const handleDiscardChanges = useCallback( + async (entry: GitStatusEntry) => { + const confirmed = await showConfirm({ + id: "git-restore-commit-entry", + title: "Discard Changes", + description: "Do you really want to discard uncommitted changes for the selected item?", + confirmText: "Discard", + color: "danger", + }); + if (!confirmed) return; + + await restore.mutateAsync({ relaPaths: [entry.relaPath] }); + await sync({ force: true }); + setSelectedEntry(null); + }, + [restore], + ); + if (tree == null) { return null; } @@ -259,7 +280,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) { secondSlot={({ style }) => (
{selectedEntry ? ( - + ) : ( Select a change to view diff )} @@ -466,16 +487,35 @@ function isNodeRelevant(node: CommitTreeNode): boolean { return node.children.some((c) => isNodeRelevant(c)); } -function DiffPanel({ entry }: { entry: GitStatusEntry }) { +function DiffPanel({ + entry, + onDiscardChanges, +}: { + entry: GitStatusEntry; + onDiscardChanges: (entry: GitStatusEntry) => void | Promise; +}) { const prevYaml = modelToYaml(entry.prev); const nextYaml = modelToYaml(entry.next); return (
-
- {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..92685084 100644 --- a/apps/yaak-client/init/sync.ts +++ b/apps/yaak-client/init/sync.ts @@ -29,13 +29,45 @@ const debouncedSync = debounce(async () => { await sync(); }, 1000); +let modelSyncTimer: ReturnType | null = null; +let modelSyncInFlight = false; + +function scheduleModelSync() { + if (modelSyncTimer == null) { + // No timer means this is the first model change in a burst, so sync immediately. + void syncModelChanges(); + } else { + // Keep pushing the trailing sync out until model writes have been quiet for a bit. + clearTimeout(modelSyncTimer); + } + + modelSyncTimer = setTimeout(async () => { + modelSyncTimer = null; + // Catch any final state that was written while the immediate sync was running. + await syncModelChanges(); + }, 1000); +} + +async function syncModelChanges() { + if (modelSyncInFlight) return; + + modelSyncInFlight = true; + try { + await sync(); + } catch (e) { + console.error(e); + } finally { + modelSyncInFlight = false; + } +} + /** * Subscribe to model change events. Since we check the workspace ID on sync, we can * simply add long-lived subscribers for the lifetime of the app. */ function initModelListeners() { listenToTauriEvent("model_write", (p) => { - if (isModelRelevant(p.payload.model)) debouncedSync(); + if (isModelRelevant(p.payload.model)) scheduleModelSync(); }); } 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..f65b59bb 100644 --- a/apps/yaak-client/package.json +++ b/apps/yaak-client/package.json @@ -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-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..01e52ff4 100644 --- a/apps/yaak-proxy/package.json +++ b/apps/yaak-proxy/package.json @@ -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-tauri/yaak-app-client/Cargo.toml b/crates-tauri/yaak-app-client/Cargo.toml index be185b7d..47a99942 100644 --- a/crates-tauri/yaak-app-client/Cargo.toml +++ b/crates-tauri/yaak-app-client/Cargo.toml @@ -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" 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..63908fa4 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; @@ -1831,8 +1832,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 +1850,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, 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/package-lock.json b/package-lock.json index eeef35d3..57f75fad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -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" @@ -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", @@ -16948,7 +16962,10 @@ }, "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/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) => {