From 764fbf979c6a977f99ac4ee4f93024bbbf995107 Mon Sep 17 00:00:00 2001 From: cst8t <1810150+cst8t@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:38:02 +0100 Subject: [PATCH 01/11] refactor: optimise search and tab switching with React features --- src/components/ProjectView.tsx | 30 +++++++++-------- src/components/centre/CentrePanel.test.tsx | 38 ++++++++++++++++++++++ src/components/centre/CentrePanel.tsx | 12 +++---- src/components/centre/LogView.tsx | 16 +++++++++ 4 files changed, 77 insertions(+), 19 deletions(-) diff --git a/src/components/ProjectView.tsx b/src/components/ProjectView.tsx index 2164a24..a35ed24 100644 --- a/src/components/ProjectView.tsx +++ b/src/components/ProjectView.tsx @@ -7,7 +7,7 @@ * in-flight async result from a previous project can ever survive into the * new one. */ -import React, { useState, useCallback, useEffect, useRef } from "react"; +import React, { useState, useCallback, useEffect, useRef, useDeferredValue, useMemo } from "react"; import { ask, open, save } from "@tauri-apps/plugin-dialog"; import { listen } from "@tauri-apps/api/event"; import type { TFunction } from "i18next"; @@ -374,6 +374,7 @@ export function ProjectView({ const [showCommitGraph, setShowCommitGraph] = useState(readShowCommitGraphPreference); const [showAiWriting, setShowAiWriting] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const deferredSearchQuery = useDeferredValue(searchQuery); const [windowFocused, setWindowFocused] = useState(() => ( typeof document === "undefined" ? true : document.hasFocus() )); @@ -432,6 +433,16 @@ export function ProjectView({ pageSize: logPageSize, refresh: refreshLog, } = useGitLog(repoPath, logScope, windowFocused, showCommitGraphButton && showCommitGraph); + const searching = deferredSearchQuery.length > 0; + const visibleCommits = useMemo(() => { + if (!searching) return commits; + const q = deferredSearchQuery.toLowerCase(); + return commits.filter(c => + c.message.toLowerCase().includes(q) + || c.author.toLowerCase().includes(q) + || c.shortHash.toLowerCase().includes(q), + ); + }, [commits, deferredSearchQuery, searching]); const stagedFiles = status?.stagedFiles ?? []; const unstagedFiles = status?.changedFiles ?? []; const unversionedFiles = status?.unversionedFiles ?? []; @@ -2279,18 +2290,11 @@ export function ProjectView({ cherryPickHead={cherryPickHead} revertInProgress={revertInProgress} revertHead={revertHead} - commits={searchQuery - ? commits.filter(c => { - const q = searchQuery.toLowerCase(); - return c.message.toLowerCase().includes(q) - || c.author.toLowerCase().includes(q) - || c.shortHash.toLowerCase().includes(q); - }) - : commits} - loadMore={searchQuery ? () => {} : loadMore} - hasMore={searchQuery ? false : hasMore} - loadingMore={searchQuery ? false : logLoadingMore} - loadMoreError={searchQuery ? null : logLoadMoreError} + commits={visibleCommits} + loadMore={searching ? () => {} : loadMore} + hasMore={searching ? false : hasMore} + loadingMore={searching ? false : logLoadingMore} + loadMoreError={searching ? null : logLoadMoreError} pageSize={logPageSize} logLoading={logLoading} logError={logError} diff --git a/src/components/centre/CentrePanel.test.tsx b/src/components/centre/CentrePanel.test.tsx index 184f866..f054c2a 100644 --- a/src/components/centre/CentrePanel.test.tsx +++ b/src/components/centre/CentrePanel.test.tsx @@ -324,6 +324,44 @@ describe("CentrePanel operation feedback", () => { }); }); +describe("CentrePanel tab persistence", () => { + it("keeps both views mounted and hides the log when Changes is active", () => { + renderCentrePanel({ activeTab: "changes" }); + + expect(screen.getByTestId("staging-view")).toBeInTheDocument(); + const log = screen.getByTestId("log-view"); + expect(log).toBeInTheDocument(); + expect(log.style.display).toBe("none"); + }); + + it("keeps both views mounted and shows the log when Log is active", () => { + renderCentrePanel({ activeTab: "log" }); + + expect(screen.getByTestId("staging-view")).toBeInTheDocument(); + const log = screen.getByTestId("log-view"); + expect(log).toBeInTheDocument(); + expect(log.style.display).not.toBe("none"); + }); + + it("does not remount either view when switching tabs", () => { + const { props, rerender } = renderCentrePanel({ activeTab: "changes" }); + const staging = screen.getByTestId("staging-view"); + const log = screen.getByTestId("log-view"); + + rerender(); + + expect(screen.getByTestId("staging-view")).toBe(staging); + expect(screen.getByTestId("log-view")).toBe(log); + expect(screen.getByTestId("log-view").style.display).not.toBe("none"); + + rerender(); + + expect(screen.getByTestId("staging-view")).toBe(staging); + expect(screen.getByTestId("log-view")).toBe(log); + expect(screen.getByTestId("log-view").style.display).toBe("none"); + }); +}); + describe("CentrePanel AI conflict lock", () => { it("disables merge workflow actions while AI conflict resolution is active", () => { renderCentrePanel({ diff --git a/src/components/centre/CentrePanel.tsx b/src/components/centre/CentrePanel.tsx index 4796017..76385f1 100644 --- a/src/components/centre/CentrePanel.tsx +++ b/src/components/centre/CentrePanel.tsx @@ -349,10 +349,10 @@ export function CentrePanel(props: CentrePanelProps) { {/* - Both panels are always in the DOM. Mounting LogView on first click is - expensive (DOM creation + IntersectionObserver + avatar fetches). By - keeping both rendered and toggling CSS display, switching tabs is a - zero-cost CSS property change instead of a full React mount. + Both panels stay mounted so tab switches keep Log scroll, selection, + and graph state. Changes stays CSS-hidden so CommitBox drafts and + in-progress AI conflict UI keep their Effects. Log uses Activity so + its Effects pause while hidden, without dropping DOM or React state. */}
{})} />
-
+ -
+ {popupOperationContent && ( <>
diff --git a/src/components/centre/LogView.tsx b/src/components/centre/LogView.tsx index 5069c14..c7c0485 100644 --- a/src/components/centre/LogView.tsx +++ b/src/components/centre/LogView.tsx @@ -18,6 +18,7 @@ import type { } from "../../types"; import { addSshSigningKeyToAllowedSigners, + getSettings, getSshAllowedSignerStatus, verifyCommits, } from "../../api/commands"; @@ -665,6 +666,7 @@ export function LogView({ const verificationPumpQueuedRef = useRef(false); const verificationRequestIdRef = useRef(0); const lastSettingsRef = useRef(null); + const signatureSettingsEffectMountedRef = useRef(false); const visibleRangeRef = useRef({ startIndex: 0, endIndex: 19 }); const pendingRevealIndexRef = useRef(null); const commitHashes = useMemo(() => new Set(commits.map(c => c.hash)), [commits]); @@ -1096,6 +1098,16 @@ export function LogView({ let cancelled = false; let unlisten: (() => void) | null = null; (async () => { + try { + const next = await getSettings(); + if (!cancelled && signatureSettingsChanged(lastSettingsRef.current, next)) { + verifyVisibleSignedCommits(visibleRangeRef.current.startIndex, visibleRangeRef.current.endIndex, true); + } + if (!cancelled) lastSettingsRef.current = next; + } catch { + // Keep the last known snapshot if settings cannot be read. + } + if (cancelled) return; const fn = await listen("settings-updated", (event) => { if (signatureSettingsChanged(lastSettingsRef.current, event.payload)) { verifyVisibleSignedCommits(visibleRangeRef.current.startIndex, visibleRangeRef.current.endIndex, true); @@ -1117,6 +1129,10 @@ export function LogView({ useEffect(() => { let cancelled = false; let unlisten: (() => void) | null = null; + if (signatureSettingsEffectMountedRef.current) { + verifyVisibleSignedCommits(visibleRangeRef.current.startIndex, visibleRangeRef.current.endIndex, true); + } + signatureSettingsEffectMountedRef.current = true; (async () => { const fn = await listen("signature-settings-updated", () => { verifyVisibleSignedCommits(visibleRangeRef.current.startIndex, visibleRangeRef.current.endIndex, true); From 47570266b8ddf91811f7fdda91836fbeb7240d4b Mon Sep 17 00:00:00 2001 From: cst8t <1810150+cst8t@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:02:58 +0100 Subject: [PATCH 02/11] feat(ui): disable commit graph while searching Disable the commit graph toggle and hide the graph during search without losing the user's saved preference. Show appropriate empty state messaging when search returns no results. --- src/components/ProjectView.tsx | 1 + src/components/centre/CentrePanel.css | 7 ++++- src/components/centre/CentrePanel.test.tsx | 33 ++++++++++++++++++++++ src/components/centre/CentrePanel.tsx | 10 +++++-- src/components/centre/LogView.test.tsx | 27 ++++++++++++++++++ src/components/centre/LogView.tsx | 5 ++++ src/i18n/locales/en/centre.json | 1 + 7 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/components/ProjectView.tsx b/src/components/ProjectView.tsx index a35ed24..e99f3d9 100644 --- a/src/components/ProjectView.tsx +++ b/src/components/ProjectView.tsx @@ -2298,6 +2298,7 @@ export function ProjectView({ pageSize={logPageSize} logLoading={logLoading} logError={logError} + searching={searching} commitMarkers={commitMarkers} logScope={logScope} rowStriping={rowStriping} diff --git a/src/components/centre/CentrePanel.css b/src/components/centre/CentrePanel.css index 74712ba..a1ac5e7 100644 --- a/src/components/centre/CentrePanel.css +++ b/src/components/centre/CentrePanel.css @@ -800,11 +800,16 @@ cursor: pointer; } -.log-view__toolbar-toggle:hover { +.log-view__toolbar-toggle:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-hover); } +.log-view__toolbar-toggle:disabled { + opacity: 0.45; + cursor: default; +} + .log-view__toolbar-toggle--active { color: var(--text-on-accent); background: var(--accent); diff --git a/src/components/centre/CentrePanel.test.tsx b/src/components/centre/CentrePanel.test.tsx index f054c2a..9ab0356 100644 --- a/src/components/centre/CentrePanel.test.tsx +++ b/src/components/centre/CentrePanel.test.tsx @@ -204,6 +204,39 @@ describe("CentrePanel commit graph toggle", () => { expect(container.querySelector(".log-view__graph")).toBeNull(); expect(localStorage.getItem("gitmun.showCommitGraph")).toBe("true"); }); + + it("disables the commit graph while searching without changing the saved preference", () => { + localStorage.setItem("gitmun.showCommitGraph", "true"); + const onCommitGraphVisibilityChange = vi.fn(); + + const { container } = renderCentrePanel({ + searching: true, + onCommitGraphVisibilityChange, + }); + + expect(container.querySelector(".log-view__graph")).toBeNull(); + expect(screen.getByLabelText("Hide commit graph")).toBeDisabled(); + expect(localStorage.getItem("gitmun.showCommitGraph")).toBe("true"); + expect(onCommitGraphVisibilityChange).toHaveBeenLastCalledWith(true); + }); + + it("restores the commit graph when search is cleared", () => { + localStorage.setItem("gitmun.showCommitGraph", "true"); + const onCommitGraphVisibilityChange = vi.fn(); + const { container, props, rerender } = renderCentrePanel({ + searching: true, + onCommitGraphVisibilityChange, + }); + + expect(container.querySelector(".log-view__graph")).toBeNull(); + + rerender(); + + expect(container.querySelector(".log-view__graph")).not.toBeNull(); + expect(screen.getByLabelText("Hide commit graph")).toBeEnabled(); + expect(localStorage.getItem("gitmun.showCommitGraph")).toBe("true"); + expect(onCommitGraphVisibilityChange).toHaveBeenLastCalledWith(true); + }); }); describe("CentrePanel operation feedback", () => { diff --git a/src/components/centre/CentrePanel.tsx b/src/components/centre/CentrePanel.tsx index 76385f1..567d50b 100644 --- a/src/components/centre/CentrePanel.tsx +++ b/src/components/centre/CentrePanel.tsx @@ -64,6 +64,7 @@ type CentrePanelProps = { pageSize: number; logLoading: boolean; logError: string | null; + searching?: boolean; commitMarkers: CommitMarkers; logScope: CommitLogScope; rowStriping: RowStriping; @@ -221,7 +222,8 @@ function getOperationContent( export function CentrePanel(props: CentrePanelProps) { const { t } = useTranslation("centre"); const [showCommitGraph, setShowCommitGraph] = React.useState(readShowCommitGraphPreference); - const effectiveShowCommitGraph = props.showCommitGraphButton && showCommitGraph; + const preferredShowCommitGraph = props.showCommitGraphButton && showCommitGraph; + const effectiveShowCommitGraph = preferredShowCommitGraph && !props.searching; const tab = props.activeTab; const operationContent = getOperationContent(props.operationLock, t); const operationFeedback = useDelayedOperationFeedback(props.operationLock); @@ -233,8 +235,8 @@ export function CentrePanel(props: CentrePanelProps) { const totalChanges = props.stagedFiles.length + props.unstagedFiles.length + props.unversionedFiles.length + submoduleChanges; React.useEffect(() => { - props.onCommitGraphVisibilityChange?.(effectiveShowCommitGraph); - }, [effectiveShowCommitGraph, props.onCommitGraphVisibilityChange]); + props.onCommitGraphVisibilityChange?.(preferredShowCommitGraph); + }, [preferredShowCommitGraph, props.onCommitGraphVisibilityChange]); const handleToggleCommitGraph = () => { setShowCommitGraph(previous => { @@ -323,6 +325,7 @@ export function CentrePanel(props: CentrePanelProps) { title={showCommitGraph ? t("log.hideCommitGraph") : t("log.showCommitGraph")} aria-label={showCommitGraph ? t("log.hideCommitGraph") : t("log.showCommitGraph")} aria-pressed={showCommitGraph} + disabled={props.searching} onClick={handleToggleCommitGraph} > @@ -431,6 +434,7 @@ export function CentrePanel(props: CentrePanelProps) { pageSize={props.pageSize} logLoading={props.logLoading} logError={props.logError} + searching={props.searching} commitMarkers={props.commitMarkers} logScope={props.logScope} rowStriping={props.rowStriping} diff --git a/src/components/centre/LogView.test.tsx b/src/components/centre/LogView.test.tsx index 1bd5a38..3e3fade 100644 --- a/src/components/centre/LogView.test.tsx +++ b/src/components/centre/LogView.test.tsx @@ -819,6 +819,33 @@ describe("LogView commit selection", () => { expect(screen.queryByRole("button", { name: "View next 100 commits" })).not.toBeInTheDocument(); }); + it("shows no commits yet when the history is empty", () => { + renderLog({ commits: [] }); + + expect(screen.getByText("No commits yet")).toBeInTheDocument(); + }); + + it("shows no commits found when a search matches nothing", () => { + renderLog({ commits: [], searching: true }); + + expect(screen.getByText("No commits found")).toBeInTheDocument(); + expect(screen.queryByText("No commits yet")).not.toBeInTheDocument(); + }); + + it("shows no commits found for an empty all-refs search", () => { + renderLog({ commits: [], searching: true, logScope: "allRefs" }); + + expect(screen.getByText("No commits found")).toBeInTheDocument(); + expect(screen.queryByText("No commits were returned for any refs.")).not.toBeInTheDocument(); + }); + + it("keeps no commits found when a search refresh sets loading", () => { + renderLog({ commits: [], searching: true, logLoading: true }); + + expect(screen.getByText("No commits found")).toBeInTheDocument(); + expect(screen.queryByText("Loading commit history...")).not.toBeInTheDocument(); + }); + it("hides the load more footer while the first page is loading", () => { renderLog({ commits: [], hasMore: true, logLoading: true }); diff --git a/src/components/centre/LogView.tsx b/src/components/centre/LogView.tsx index c7c0485..810829e 100644 --- a/src/components/centre/LogView.tsx +++ b/src/components/centre/LogView.tsx @@ -516,6 +516,7 @@ type LogViewProps = { pageSize: number; logLoading: boolean; logError: string | null; + searching?: boolean; commitMarkers: CommitMarkers; logScope: CommitLogScope; rowStriping: RowStriping; @@ -623,6 +624,7 @@ export function LogView({ pageSize, logLoading, logError, + searching = false, commitMarkers, logScope, rowStriping, @@ -1344,6 +1346,9 @@ export function LogView({ if (logError) { return
{t("log.loadFailed", { message: logError })}
; } + if (searching) { + return
{t("log.noCommitsFound")}
; + } if (logLoading) { return
{t("log.loading")}
; } diff --git a/src/i18n/locales/en/centre.json b/src/i18n/locales/en/centre.json index 4a07369..e731605 100644 --- a/src/i18n/locales/en/centre.json +++ b/src/i18n/locales/en/centre.json @@ -130,6 +130,7 @@ "moreRefsLabel": "+{{count}}", "noCommits": "No commits yet", "noCommitsAllRefs": "No commits were returned for any refs.", + "noCommitsFound": "No commits found", "revertCommit": "Revert Commit...", "remoteBranchRef": "Remote branch {{name}}", "showCommitGraph": "Show commit graph", From 035e5da24d68c0cacd2e7bb35064b1a285675756 Mon Sep 17 00:00:00 2001 From: cst8t <1810150+cst8t@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:24:12 +0100 Subject: [PATCH 03/11] feat(diff): show commit files as a searchable folder tree Replace the flat commit-file list with compact folder rows, basename files, expand/collapse, and a path search, reusing the staging tree walk helpers. --- src/components/centre/StagingView.tsx | 56 +---- src/components/diff/DiffPanel.css | 83 +++++++ src/components/diff/DiffPanel.test.tsx | 286 ++++++++++++++++++++++++- src/components/diff/DiffPanel.tsx | 97 ++++++++- src/i18n/locales/en/diffPanel.json | 4 + src/utils/fileTree.test.ts | 92 +++++++- src/utils/fileTree.ts | 42 ++++ 7 files changed, 602 insertions(+), 58 deletions(-) diff --git a/src/components/centre/StagingView.tsx b/src/components/centre/StagingView.tsx index bf416bc..0354183 100644 --- a/src/components/centre/StagingView.tsx +++ b/src/components/centre/StagingView.tsx @@ -15,7 +15,7 @@ import type { UnversionedItem, } from "../../types"; import { getNumstat, openSettingsWindow } from "../../api/commands"; -import { buildFileTree, descendantFilePaths, type FileTreeDirectoryNode, type FileTreeNode } from "../../utils/fileTree"; +import { buildFileTree, descendantFilePaths, type FileTreeDirectoryNode, type VisibleFileTreeRow, visibleFileTreeRows } from "../../utils/fileTree"; import { ChevDownIcon, ChevRightIcon, FolderIcon } from "../icons"; type StagingViewProps = { @@ -91,22 +91,16 @@ type CachedNumstat = { type TreeSection = "staged" | "unstaged"; -type VisibleTreeRow = - | { type: "directory"; node: FileTreeDirectoryNode; depth: number; expanded: boolean } - | { type: "file"; node: Extract; depth: number; fileIndex: number }; - type StagingListRow = | { type: "section"; key: string; section: "submodules" | "conflicts" | TreeSection } | { type: "submodule"; key: string; submodule: SubmoduleStatus; index: number } | { type: "conflict"; key: string; file: ConflictFileItem; index: number } - | { type: "tree"; key: string; section: TreeSection; row: VisibleTreeRow } + | { type: "tree"; key: string; section: TreeSection; row: VisibleFileTreeRow } | { type: "empty"; key: string; section: TreeSection }; const NUMSTAT_REFRESH_MS = 7000; const NUMSTAT_BATCH_SIZE = 6; const NUMSTAT_FAILURE_BACKOFF_MS = 3000; -const AUTO_COLLAPSE_SECTION_THRESHOLD = 500; -const AUTO_COLLAPSE_DIRECTORY_THRESHOLD = 100; const SUBMODULE_STATE_LABELS: Record = { clean: "Clean", @@ -126,46 +120,6 @@ function folderStateKey(section: TreeSection, path: string): string { return `${section}:${path}`; } -function defaultDirectoryExpanded(node: FileTreeDirectoryNode, depth: number, totalFiles: number): boolean { - if (totalFiles <= AUTO_COLLAPSE_SECTION_THRESHOLD) return true; - return depth > 0 && node.fileCount < AUTO_COLLAPSE_DIRECTORY_THRESHOLD; -} - -function isDirectoryExpanded( - section: TreeSection, - node: FileTreeDirectoryNode, - depth: number, - totalFiles: number, - expandedFolders: Record, -): boolean { - const key = folderStateKey(section, node.path); - return expandedFolders[key] ?? defaultDirectoryExpanded(node, depth, totalFiles); -} - -function visibleTreeRows( - nodes: FileTreeNode[], - section: TreeSection, - expandedFolders: Record, - totalFiles: number, -): VisibleTreeRow[] { - let fileIndex = 0; - - const visit = (currentNodes: FileTreeNode[], depth: number): VisibleTreeRow[] => - currentNodes.flatMap((node): VisibleTreeRow[] => { - if (node.type === "file") { - const row = { type: "file" as const, node, depth, fileIndex }; - fileIndex += 1; - return [row]; - } - - const expanded = node.children.length > 0 && isDirectoryExpanded(section, node, depth, totalFiles, expandedFolders); - const children = expanded ? visit(node.children, depth + 1) : []; - return [{ type: "directory", node, depth, expanded }, ...children]; - }); - - return visit(nodes, 0); -} - function shortHash(hash: string | null): string { return hash ? hash.slice(0, 7) : "-"; } @@ -602,11 +556,11 @@ export function StagingView({ const stagedTree = useMemo(() => buildFileTree(mergedStaged), [mergedStaged]); const unstagedTree = useMemo(() => buildFileTree(allUnstaged), [allUnstaged]); const stagedTreeRows = useMemo( - () => visibleTreeRows(stagedTree, "staged", expandedFolders, mergedStaged.length), + () => visibleFileTreeRows(stagedTree, expandedFolders, mergedStaged.length, (path) => folderStateKey("staged", path)), [stagedTree, expandedFolders, mergedStaged.length], ); const unstagedTreeRows = useMemo( - () => visibleTreeRows(unstagedTree, "unstaged", expandedFolders, allUnstaged.length), + () => visibleFileTreeRows(unstagedTree, expandedFolders, allUnstaged.length, (path) => folderStateKey("unstaged", path)), [unstagedTree, expandedFolders, allUnstaged.length], ); const stagingBusy = stagingOperation != null || aiResolvingPath !== null; @@ -648,7 +602,7 @@ export function StagingView({ if (rowStriping === "Off" || index % 2 === 0) return undefined; return rowStriping; }; - const renderTreeRow = (row: VisibleTreeRow, section: TreeSection) => { + const renderTreeRow = (row: VisibleFileTreeRow, section: TreeSection) => { const isStaged = section === "staged"; const selectedMap = isStaged ? selectedStaged : selectedUnstaged; const onSelectedChange = isStaged ? onSelectedStagedChange : onSelectedUnstagedChange; diff --git a/src/components/diff/DiffPanel.css b/src/components/diff/DiffPanel.css index 362d388..99afad3 100644 --- a/src/components/diff/DiffPanel.css +++ b/src/components/diff/DiffPanel.css @@ -170,6 +170,89 @@ padding: 8px 0; } +.diff-panel__commit-file-search { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + gap: 8px; + margin: 0 10px 8px; + padding: 6px 10px; + min-height: 32px; + box-sizing: border-box; + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + color: var(--text-muted); +} + +.diff-panel__commit-file-search:focus-within { + border-color: var(--accent); + outline: 2px solid var(--focus-ring); + outline-offset: 1px; +} + +.diff-panel__commit-file-search-input { + flex: 1; + min-width: 0; + border: none; + background: none; + outline: none; + color: var(--text-primary); + font-family: var(--font-ui); + font-size: var(--font-size-sm); +} + +.diff-panel__commit-file-search-input::placeholder { + color: var(--text-secondary); +} + +.diff-panel__commit-folder-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + min-height: 32px; + color: var(--text-secondary-strong); +} + +.diff-panel__commit-folder-toggle { + width: 16px; + height: 16px; + padding: 0; + border: none; + background: none; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.diff-panel__commit-folder-icon { + display: flex; + color: var(--text-muted); + flex-shrink: 0; +} + +.diff-panel__commit-folder-name { + flex: 1; + font-family: var(--font-ui); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.diff-panel__commit-folder-count { + font-size: var(--font-size-xs); + color: var(--text-muted); + flex-shrink: 0; +} + .diff-panel__commit-file-row { display: flex; align-items: center; diff --git a/src/components/diff/DiffPanel.test.tsx b/src/components/diff/DiffPanel.test.tsx index 921dc7d..8dfe569 100644 --- a/src/components/diff/DiffPanel.test.tsx +++ b/src/components/diff/DiffPanel.test.tsx @@ -4,7 +4,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { getCommitDetails } from "../../api/commands"; import "../../i18n"; -import type { CommitDetails } from "../../types"; +import type { CommitDetails, CommitFileItem, RowStriping } from "../../types"; import { DiffPanel } from "./DiffPanel"; vi.mock("../../api/commands", () => ({ @@ -102,3 +102,287 @@ describe("DiffPanel commit details", () => { } }); }); + +function commitFile(path: string, status = "Modified"): CommitFileItem { + return { path, status }; +} + +function renderLog(options?: { + commitFiles?: CommitFileItem[]; + commitFilesLoading?: boolean; + selectedCommitHash?: string | null; + rowStriping?: RowStriping; +}) { + const onOpenCommitFileDiff = vi.fn(); + const view = render( + , + ); + + return { ...view, onOpenCommitFileDiff }; +} + +describe("DiffPanel commit file tree", () => { + it("groups nested files under a compact folder row", () => { + renderLog({ + commitFiles: [ + commitFile("src/components/Button.tsx"), + commitFile("src/components/Icon.tsx"), + ], + }); + + expect(screen.getByText("src/components")).toBeInTheDocument(); + expect(screen.getByText("2 files")).toBeInTheDocument(); + expect(screen.getByText("Button.tsx")).toBeInTheDocument(); + expect(screen.getByText("Icon.tsx")).toBeInTheDocument(); + expect(screen.queryByText("src/components/Button.tsx")).not.toBeInTheDocument(); + }); + + it("selects on click and opens the external diff on double-click using the full path", () => { + const { onOpenCommitFileDiff } = renderLog({ + commitFiles: [ + commitFile("src/components/Button.tsx"), + commitFile("src/components/Icon.tsx"), + ], + }); + + const button = screen.getByRole("button", { name: /Button\.tsx/ }); + fireEvent.click(button); + + expect(button).toHaveClass("diff-panel__commit-file-row--selected"); + expect(onOpenCommitFileDiff).not.toHaveBeenCalled(); + + fireEvent.doubleClick(button); + + expect(onOpenCommitFileDiff).toHaveBeenCalledWith("src/components/Button.tsx"); + expect(button).toHaveAttribute("title", "src/components/Button.tsx"); + }); + + it("hides nested files when a folder is collapsed and shows them again when expanded", () => { + renderLog({ + commitFiles: [ + commitFile("src/components/Button.tsx"), + commitFile("src/components/Icon.tsx"), + ], + }); + + fireEvent.click(screen.getByLabelText("Collapse src/components")); + + expect(screen.queryByText("Button.tsx")).not.toBeInTheDocument(); + expect(screen.queryByText("Icon.tsx")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText("Expand src/components")); + + expect(screen.getByText("Button.tsx")).toBeInTheDocument(); + expect(screen.getByText("Icon.tsx")).toBeInTheDocument(); + }); + + it("keeps status letters on file rows", () => { + renderLog({ + commitFiles: [ + commitFile("src/components/Button.tsx", "Added"), + commitFile("README.md", "Deleted"), + ], + }); + + expect(screen.getByText("A")).toBeInTheDocument(); + expect(screen.getByText("D")).toBeInTheDocument(); + }); + + it("stripes only visible file rows and recalculates after collapse", () => { + renderLog({ + commitFiles: [ + commitFile("lib/A.ts"), + commitFile("B.ts"), + commitFile("C.ts"), + ], + rowStriping: "Subtle", + }); + + expect(screen.getByRole("button", { name: /A\.ts/ })).not.toHaveClass("diff-panel__commit-file-row--striped-subtle"); + expect(screen.getByRole("button", { name: /B\.ts/ })).toHaveClass("diff-panel__commit-file-row--striped-subtle"); + expect(screen.getByRole("button", { name: /C\.ts/ })).not.toHaveClass("diff-panel__commit-file-row--striped-subtle"); + expect(screen.getByText("lib").closest(".diff-panel__commit-folder-row")).not.toHaveClass( + "diff-panel__commit-file-row--striped-subtle", + ); + + fireEvent.click(screen.getByLabelText("Collapse lib")); + + expect(screen.queryByText("A.ts")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /B\.ts/ })).not.toHaveClass("diff-panel__commit-file-row--striped-subtle"); + expect(screen.getByRole("button", { name: /C\.ts/ })).toHaveClass("diff-panel__commit-file-row--striped-subtle"); + }); + + it("shows root-level files without a folder row", () => { + renderLog({ + commitFiles: [commitFile("README.md")], + }); + + expect(screen.getByText("README.md")).toBeInTheDocument(); + expect(screen.queryByLabelText(/Collapse /)).not.toBeInTheDocument(); + }); + + it("clears folder expansion and file selection when the commit hash changes", () => { + const { rerender, onOpenCommitFileDiff } = renderLog({ + commitFiles: [ + commitFile("src/components/Button.tsx"), + commitFile("src/components/Icon.tsx"), + ], + }); + + fireEvent.click(screen.getByRole("button", { name: /Button\.tsx/ })); + fireEvent.click(screen.getByLabelText("Collapse src/components")); + expect(screen.queryByText("Button.tsx")).not.toBeInTheDocument(); + + rerender( + , + ); + + const button = screen.getByRole("button", { name: /Button\.tsx/ }); + expect(button).not.toHaveClass("diff-panel__commit-file-row--selected"); + expect(screen.getByText("Icon.tsx")).toBeInTheDocument(); + }); + + it("keeps loading and empty commit states unchanged", () => { + const { rerender } = renderLog({ commitFilesLoading: true }); + + expect(screen.getByText("Loading commit files...")).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByText("Select a commit to view changed files")).toBeInTheDocument(); + }); + + it("filters commit files by path and keeps matching folders", () => { + renderLog({ + commitFiles: [ + commitFile("src/components/Button.tsx"), + commitFile("src/components/Icon.tsx"), + commitFile("README.md"), + ], + }); + + fireEvent.change(screen.getByLabelText("Search changed files..."), { + target: { value: "button" }, + }); + + expect(screen.getByText("Button.tsx")).toBeInTheDocument(); + expect(screen.getByText("src/components")).toBeInTheDocument(); + expect(screen.queryByText("Icon.tsx")).not.toBeInTheDocument(); + expect(screen.queryByText("README.md")).not.toBeInTheDocument(); + }); + + it("shows an empty state when no commit files match the search", () => { + renderLog({ + commitFiles: [commitFile("src/components/Button.tsx")], + }); + + fireEvent.change(screen.getByLabelText("Search changed files..."), { + target: { value: "missing" }, + }); + + expect(screen.getByText("No files match this search")).toBeInTheDocument(); + expect(screen.queryByText("Button.tsx")).not.toBeInTheDocument(); + }); + + it("clears the file search when the commit hash changes", () => { + const { rerender, onOpenCommitFileDiff } = renderLog({ + commitFiles: [ + commitFile("src/components/Button.tsx"), + commitFile("README.md"), + ], + }); + + fireEvent.change(screen.getByLabelText("Search changed files..."), { + target: { value: "README" }, + }); + expect(screen.queryByText("Button.tsx")).not.toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByLabelText("Search changed files...")).toHaveValue(""); + expect(screen.getByText("Button.tsx")).toBeInTheDocument(); + expect(screen.getByText("README.md")).toBeInTheDocument(); + }); +}); diff --git a/src/components/diff/DiffPanel.tsx b/src/components/diff/DiffPanel.tsx index 3c88e0c..4acadaa 100644 --- a/src/components/diff/DiffPanel.tsx +++ b/src/components/diff/DiffPanel.tsx @@ -1,9 +1,10 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { Decoration, Diff, Hunk, type ChangeData, type DiffType, type HunkData, type ViewType } from "react-diff-view"; -import { CloseIcon, FileIcon } from "../icons"; +import { ChevDownIcon, ChevRightIcon, CloseIcon, FileIcon, FolderIcon, SearchIcon } from "../icons"; import { StageHunkIcon } from "../icons"; import type { CommitDetails, CommitFileItem, FileDiff, RowStriping, SubmoduleStatus } from "../../types"; +import { buildFileTree, visibleFileTreeRows } from "../../utils/fileTree"; import { getCommitDetails } from "../../api/commands"; import type { CentreTab } from "../centre/CentrePanel"; import "react-diff-view/style/index.css"; @@ -216,6 +217,8 @@ export function DiffPanel({ const { t } = useTranslation("diffPanel"); const [viewType, setViewType] = React.useState("unified"); const [selectedCommitFile, setSelectedCommitFile] = React.useState(null); + const [expandedFolders, setExpandedFolders] = React.useState>({}); + const [commitFileQuery, setCommitFileQuery] = React.useState(""); const [detailsPopover, setDetailsPopover] = React.useState<{ rect: DOMRect; data: CommitDetails } | null>(null); const [detailsLoading, setDetailsLoading] = React.useState(false); @@ -225,9 +228,42 @@ export function DiffPanel({ React.useEffect(() => { setSelectedCommitFile(null); + setExpandedFolders({}); + setCommitFileQuery(""); setDetailsPopover(null); }, [selectedCommitHash]); + const filteredCommitFiles = React.useMemo(() => { + const query = commitFileQuery.trim().toLowerCase(); + if (!query) return commitFiles; + return commitFiles.filter((file) => file.path.toLowerCase().includes(query)); + }, [commitFiles, commitFileQuery]); + + const commitTreeRows = React.useMemo(() => { + const treeItems = filteredCommitFiles.map((file) => ({ + path: file.path, + status: file.status, + additions: null, + deletions: null, + })); + return visibleFileTreeRows( + buildFileTree(treeItems), + commitFileQuery.trim() ? {} : expandedFolders, + filteredCommitFiles.length, + (path) => path, + ); + }, [commitFileQuery, filteredCommitFiles, expandedFolders]); + + const toggleFolderExpanded = (path: string) => { + setExpandedFolders((prev) => { + const currentRow = commitTreeRows.find((row) => row.type === "directory" && row.node.path === path); + const currentExpanded = currentRow?.type === "directory" + ? currentRow.expanded + : prev[path] ?? true; + return { ...prev, [path]: !currentExpanded }; + }); + }; + const hasSelectedFile = mode === "changes" && !!selectedFile; const hasSelectedSubmodule = mode === "changes" && !!selectedSubmodule; const currentDiff = @@ -397,20 +433,71 @@ export function DiffPanel({
{t("placeholders.loadingCommitFiles")}
) : commitFiles.length > 0 ? (
- {commitFiles.map((file, index) => { - const rowStripe = striped(index); + + {filteredCommitFiles.length === 0 ? ( +
{t("placeholders.noMatchingCommitFiles")}
+ ) : commitTreeRows.map((row) => { + const indent = row.depth > 0 ? { paddingLeft: 8 + row.depth * 18 } : undefined; + + if (row.type === "directory") { + return ( +
+ {row.node.children.length > 0 && ( + + )} + + + + {row.node.name} + + {t("fileCount", { ns: "common", count: row.node.fileCount })} + +
+ ); + } + + const file = row.node.file; + const rowStripe = striped(row.fileIndex); return ( ); })} diff --git a/src/i18n/locales/en/diffPanel.json b/src/i18n/locales/en/diffPanel.json index 4a55fd2..f45fec8 100644 --- a/src/i18n/locales/en/diffPanel.json +++ b/src/i18n/locales/en/diffPanel.json @@ -11,10 +11,12 @@ "parents": "Parents", "tags": "Tags" }, + "collapseFolder": "Collapse {{path}}", "eol": { "mixed": "Mixed EOL", "unknown": "Unknown EOL" }, + "expandFolder": "Expand {{path}}", "header": { "clickFile": "Click a file to show changes", "commit": "Commit {{hash}}", @@ -27,8 +29,10 @@ "emptyFile": "Empty file - no changes to display", "loadingCommitFiles": "Loading commit files...", "loadingDiff": "Loading diff...", + "noMatchingCommitFiles": "No files match this search", "selectCommit": "Select a commit to view changed files" }, + "searchCommitFiles": "Search changed files...", "submodule": { "checkedOutCommit": "Checked-out commit", "configuredBranch": "Configured branch", diff --git a/src/utils/fileTree.test.ts b/src/utils/fileTree.test.ts index 1e93ad3..ec0da10 100644 --- a/src/utils/fileTree.test.ts +++ b/src/utils/fileTree.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import type { FileStatusItem } from "../types"; -import { buildFileTree, descendantFilePaths } from "./fileTree"; +import { + AUTO_COLLAPSE_SECTION_THRESHOLD, + buildFileTree, + descendantFilePaths, + visibleFileTreeRows, +} from "./fileTree"; function file(path: string, additions: number | null = null, deletions: number | null = null): FileStatusItem { return { @@ -216,3 +221,88 @@ describe("buildFileTree", () => { expect(descendantFilePaths(node)).toEqual(["drafts"]); }); }); + +describe("visibleFileTreeRows", () => { + const folderKey = (path: string) => path; + + function rowSummary(rows: ReturnType) { + return rows.map((row) => + row.type === "directory" + ? { type: row.type, name: row.node.name, depth: row.depth, expanded: row.expanded } + : { type: row.type, name: row.node.name, depth: row.depth, fileIndex: row.fileIndex }, + ); + } + + it("shows compact folders and nested basenames", () => { + const files = [ + file("src/components/Button.tsx"), + file("src/components/Icon.tsx"), + ]; + const rows = visibleFileTreeRows(buildFileTree(files), {}, files.length, folderKey); + + expect(rowSummary(rows)).toEqual([ + { type: "directory", name: "src/components", depth: 0, expanded: true }, + { type: "file", name: "Button.tsx", depth: 1, fileIndex: 0 }, + { type: "file", name: "Icon.tsx", depth: 1, fileIndex: 1 }, + ]); + }); + + it("hides descendants when a folder is collapsed", () => { + const files = [ + file("src/components/Button.tsx"), + file("src/components/Icon.tsx"), + file("README.md"), + ]; + const rows = visibleFileTreeRows( + buildFileTree(files), + { "src/components": false }, + files.length, + folderKey, + ); + + expect(rowSummary(rows)).toEqual([ + { type: "directory", name: "src/components", depth: 0, expanded: false }, + { type: "file", name: "README.md", depth: 0, fileIndex: 0 }, + ]); + }); + + it("auto-collapses top-level folders when the tree has more than 500 files", () => { + const files = Array.from({ length: AUTO_COLLAPSE_SECTION_THRESHOLD + 1 }, (_, index) => + file(`marine-lab/samples/sample-${String(index).padStart(4, "0")}.csv`), + ); + const rows = visibleFileTreeRows(buildFileTree(files), {}, files.length, folderKey); + + expect(rowSummary(rows)).toEqual([ + { type: "directory", name: "marine-lab/samples", depth: 0, expanded: false }, + ]); + }); + + it("keeps small nested folders expanded in large trees", () => { + const nested = [ + file("marine-lab/reports/current/plankton.json"), + file("marine-lab/reports/current/salinity.json"), + ]; + const topLevel = Array.from({ length: AUTO_COLLAPSE_SECTION_THRESHOLD }, (_, index) => + file(`marine-lab/samples/sample-${String(index).padStart(4, "0")}.csv`), + ); + const files = [...topLevel, ...nested]; + const rows = visibleFileTreeRows( + buildFileTree(files), + { "marine-lab": true }, + files.length, + folderKey, + ); + + expect(rows).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: "directory", + expanded: true, + node: expect.objectContaining({ name: "reports/current" }), + }), + expect.objectContaining({ + type: "file", + node: expect.objectContaining({ name: "plankton.json" }), + }), + ])); + }); +}); diff --git a/src/utils/fileTree.ts b/src/utils/fileTree.ts index e97afa0..97742a5 100644 --- a/src/utils/fileTree.ts +++ b/src/utils/fileTree.ts @@ -147,3 +147,45 @@ export function descendantFilePaths(node: FileTreeDirectoryNode): string[] { ), ]; } + +export const AUTO_COLLAPSE_SECTION_THRESHOLD = 500; +export const AUTO_COLLAPSE_DIRECTORY_THRESHOLD = 100; + +export type VisibleFileTreeRow = + | { type: "file"; node: FileTreeFileNode; depth: number; fileIndex: number } + | { type: "directory"; node: FileTreeDirectoryNode; depth: number; expanded: boolean }; + +export function defaultDirectoryExpanded( + node: FileTreeDirectoryNode, + depth: number, + totalFiles: number, +): boolean { + if (totalFiles <= AUTO_COLLAPSE_SECTION_THRESHOLD) return true; + return depth > 0 && node.fileCount < AUTO_COLLAPSE_DIRECTORY_THRESHOLD; +} + +export function visibleFileTreeRows( + nodes: FileTreeNode[], + expandedFolders: Record, + totalFiles: number, + folderKey: (path: string) => string, +): VisibleFileTreeRow[] { + let fileIndex = 0; + + const visit = (currentNodes: FileTreeNode[], depth: number): VisibleFileTreeRow[] => + currentNodes.flatMap((node): VisibleFileTreeRow[] => { + if (node.type === "file") { + const row = { type: "file" as const, node, depth, fileIndex }; + fileIndex += 1; + return [row]; + } + + const expanded = + node.children.length > 0 && + (expandedFolders[folderKey(node.path)] ?? defaultDirectoryExpanded(node, depth, totalFiles)); + const children = expanded ? visit(node.children, depth + 1) : []; + return [{ type: "directory", node, depth, expanded }, ...children]; + }); + + return visit(nodes, 0); +} From 8f5a2acb94930808586c9c24b819516cdd369e84 Mon Sep 17 00:00:00 2001 From: cst8t <1810150+cst8t@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:20:22 +0100 Subject: [PATCH 04/11] feat(git): add configurable automatic remote fetching Fetch focused repositories when their configured interval elapses, with per-repository throttling, timeout handling, result logging, and no terminal authentication prompts. --- src-tauri/config.example.toml | 4 + src-tauri/src/commands/settings.rs | 10 ++ src-tauri/src/config_file.rs | 19 +++ src-tauri/src/git/handler.rs | 18 +++ src-tauri/src/git/types.rs | 13 ++ src-tauri/src/lib.rs | 8 ++ src/components/App.tsx | 12 ++ src/components/ProjectView.tsx | 26 ++++ src/components/Titlebar.tsx | 6 +- src/components/settings/SettingsWindow.tsx | 19 +++ src/hooks/useAutoFetch.test.ts | 113 +++++++++++++++++ src/hooks/useAutoFetch.ts | 41 ++++++ src/hooks/useRemoteOperations.test.tsx | 139 ++++++++++++++++++++- src/hooks/useRemoteOperations.ts | 39 +++++- src/i18n/locales/en/projectView.json | 3 + src/i18n/locales/en/settings.json | 4 + src/types.ts | 1 + 17 files changed, 465 insertions(+), 10 deletions(-) create mode 100644 src/hooks/useAutoFetch.test.ts create mode 100644 src/hooks/useAutoFetch.ts diff --git a/src-tauri/config.example.toml b/src-tauri/config.example.toml index 56468b6..42e5d3d 100644 --- a/src-tauri/config.example.toml +++ b/src-tauri/config.example.toml @@ -70,6 +70,10 @@ commitMessageRecommendedLength = 72 # Whether to automatically push tags when pushing commits. pushFollowTags = false +# Fetch remotes periodically while a repository window is focused. +# Use 0 to disable, or 5, 10, 30, or 60 minutes. +autoFetchIntervalMinutes = 0 + # Whether to check for application updates on launch. autoCheckForUpdatesOnLaunch = true diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 8b1a747..982b752 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -1141,6 +1141,16 @@ pub fn set_push_follow_tags(push_follow_tags: bool, state: tauri::State<'_, AppS state.git_service.set_push_follow_tags(push_follow_tags) } +#[tauri::command] +pub fn set_auto_fetch_interval_minutes( + auto_fetch_interval_minutes: u32, + state: tauri::State<'_, AppState>, +) -> Settings { + state + .git_service + .set_auto_fetch_interval_minutes(auto_fetch_interval_minutes) +} + #[tauri::command] pub fn set_commit_primary_action( commit_primary_action: CommitPrimaryAction, diff --git a/src-tauri/src/config_file.rs b/src-tauri/src/config_file.rs index 8ff2efa..165e209 100644 --- a/src-tauri/src/config_file.rs +++ b/src-tauri/src/config_file.rs @@ -13,6 +13,10 @@ pub fn load_or_migrate(toml_path: &Path, json_path: &Path) -> (Settings, bool) { match std::fs::read_to_string(toml_path) { Ok(text) => match toml::from_str::(&text) { Ok(mut settings) => { + settings.auto_fetch_interval_minutes = + Settings::normalised_auto_fetch_interval_minutes( + settings.auto_fetch_interval_minutes, + ); let migrated = settings.migrate_legacy_ai(contains_legacy_ai_keys(&text)); archive_migrated_json_config(json_path); return (settings, migrated); @@ -29,6 +33,8 @@ pub fn load_or_migrate(toml_path: &Path, json_path: &Path) -> (Settings, bool) { if json_path.exists() { let text = std::fs::read_to_string(json_path).unwrap_or_default(); let mut settings = serde_json::from_str::(&text).unwrap_or_default(); + settings.auto_fetch_interval_minutes = + Settings::normalised_auto_fetch_interval_minutes(settings.auto_fetch_interval_minutes); settings.migrate_legacy_ai(contains_legacy_ai_keys(&text)); let created = create_from_template(toml_path, &settings).is_ok(); @@ -278,6 +284,19 @@ mod tests { ); } + #[test] + fn load_toml_disables_unsupported_auto_fetch_interval() { + let dir = TempDir::new().unwrap(); + let toml_path = dir.path().join("config.toml"); + let json_path = dir.path().join("config.json"); + + write_file(&toml_path, "autoFetchIntervalMinutes = 1\n"); + + let (settings, should_persist) = load_or_migrate(&toml_path, &json_path); + assert!(!should_persist); + assert_eq!(settings.auto_fetch_interval_minutes, 0); + } + #[test] fn load_toml_normalises_ai_context_limits() { let dir = TempDir::new().unwrap(); diff --git a/src-tauri/src/git/handler.rs b/src-tauri/src/git/handler.rs index cbb95cb..0a69ea5 100644 --- a/src-tauri/src/git/handler.rs +++ b/src-tauri/src/git/handler.rs @@ -335,6 +335,13 @@ impl GitService { }) } + pub fn set_auto_fetch_interval_minutes(&self, minutes: u32) -> Settings { + self.update_settings(|settings| { + settings.auto_fetch_interval_minutes = + Settings::normalised_auto_fetch_interval_minutes(minutes); + }) + } + pub fn set_commit_primary_action( &self, commit_primary_action: CommitPrimaryAction, @@ -765,6 +772,17 @@ mod tests { assert!(service.get_settings().enable_local_copy); } + #[test] + fn auto_fetch_interval_setter_disables_unsupported_values() { + let service = GitService::new(); + + let settings = service.set_auto_fetch_interval_minutes(5); + assert_eq!(settings.auto_fetch_interval_minutes, 5); + + let settings = service.set_auto_fetch_interval_minutes(1); + assert_eq!(settings.auto_fetch_interval_minutes, 0); + } + #[test] fn ai_context_limit_setters_normalise_values() { let service = GitService::new(); diff --git a/src-tauri/src/git/types.rs b/src-tauri/src/git/types.rs index 085b699..79f1738 100644 --- a/src-tauri/src/git/types.rs +++ b/src-tauri/src/git/types.rs @@ -316,6 +316,10 @@ pub struct Settings { pub commit_message_recommended_length: u32, #[serde(default)] pub push_follow_tags: bool, + /// How often an open, focused repository is fetched automatically. + /// Zero disables automatic fetching. + #[serde(default)] + pub auto_fetch_interval_minutes: u32, #[serde(default = "Settings::default_auto_check_for_updates_on_launch")] pub auto_check_for_updates_on_launch: bool, #[serde(default)] @@ -421,6 +425,7 @@ impl Default for Settings { commit_primary_action: CommitPrimaryAction::Commit, commit_message_recommended_length: 72, push_follow_tags: false, + auto_fetch_interval_minutes: 0, auto_check_for_updates_on_launch: true, auto_install_updates: false, update_endpoint: Self::default_update_endpoint(), @@ -447,6 +452,14 @@ impl Default for Settings { } impl Settings { + pub fn normalised_auto_fetch_interval_minutes(value: u32) -> u32 { + if [0, 5, 10, 30, 60].contains(&value) { + value + } else { + 0 + } + } + pub fn normalised_ui_text_scale(value: f64) -> f64 { normalise_ui_text_scale(value) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 11926cf..4a3eec4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -218,6 +218,7 @@ pub(crate) fn git_command() -> std::process::Command { command.env("LC_ALL", "C"); command.env("LANG", "C"); + command.env("GIT_TERMINAL_PROMPT", "0"); command } @@ -236,6 +237,12 @@ mod git_command_tests { assert!(command_env_is(&command, "LC_ALL", "C")); assert!(command_env_is(&command, "LANG", "C")); } + + #[test] + fn git_command_disables_terminal_prompt() { + let command = crate::git_command(); + assert!(command_env_is(&command, "GIT_TERMINAL_PROMPT", "0")); + } } pub(crate) fn normalise_display_path(path: &str) -> String { @@ -1597,6 +1604,7 @@ pub fn run() { commands::settings::set_commit_message_recommended_length, commands::settings::set_auto_check_for_updates_on_launch, commands::settings::set_auto_install_updates, + commands::settings::set_auto_fetch_interval_minutes, commands::settings::set_update_endpoint, commands::settings::set_linux_graphics_mode, commands::settings::get_linux_terminal_options, diff --git a/src/components/App.tsx b/src/components/App.tsx index 50908a7..bc96b4c 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -83,6 +83,16 @@ export function App() { const [identityOpen, setIdentityOpen] = useState(false); const [confirmRevert, setConfirmRevert] = useState(true); const [settingsRevision, setSettingsRevision] = useState(0); + const [lastFetchAttemptAtByRepo, setLastFetchAttemptAtByRepo] = useState( + () => new Map(), + ); + const recordFetchAttempt = useCallback((path: string) => { + setLastFetchAttemptAtByRepo(previous => { + const next = new Map(previous); + next.set(path, Date.now()); + return next; + }); + }, []); const activeRepoDisplayName = repoPath && repoDisplayName?.repoPath === repoPath ? repoDisplayName.name : null; @@ -681,6 +691,8 @@ export function App() { repoPath={repoPath} repoDisplayName={activeRepoDisplayName} settingsRevision={settingsRevision} + lastFetchAttemptAt={repoPath ? lastFetchAttemptAtByRepo.get(repoPath) ?? null : null} + onFetchAttemptComplete={recordFetchAttempt} platform={platform} showToast={showToast} recentRepos={recentRepos} diff --git a/src/components/ProjectView.tsx b/src/components/ProjectView.tsx index e99f3d9..5152165 100644 --- a/src/components/ProjectView.tsx +++ b/src/components/ProjectView.tsx @@ -43,6 +43,7 @@ import { useGitStashes } from "../hooks/useGitStashes"; import { useStagingOperations } from "../hooks/useStagingOperations"; import { useProjectKeyboardShortcuts } from "../hooks/useProjectKeyboardShortcuts"; import { useRemoteOperations } from "../hooks/useRemoteOperations"; +import { useAutoFetch } from "../hooks/useAutoFetch"; export { buildPushRequestForCurrentBranch } from "../hooks/useRemoteOperations"; import * as api from "../api/commands"; import type { ResetMode } from "../api/commands"; @@ -255,6 +256,8 @@ export type ProjectViewProps = { repoDisplayName: string | null; /** Increments each time settings are saved - triggers a full data refresh. */ settingsRevision: number; + lastFetchAttemptAt: number | null; + onFetchAttemptComplete: (repoPath: string) => void; platform: PlatformType; showToast: (message: string, type?: ToastType) => void; recentRepos: string[]; @@ -286,6 +289,8 @@ export function ProjectView({ repoPath, repoDisplayName, settingsRevision, + lastFetchAttemptAt, + onFetchAttemptComplete, platform, showToast, recentRepos, @@ -325,6 +330,7 @@ export function ProjectView({ const { remotes, refresh: refreshRemotes } = useGitRemotes(repoPath); const { stashes, refresh: refreshStashes } = useGitStashes(repoPath); const [logScope, setLogScope] = useState("currentCheckout"); + const [autoFetchIntervalMinutes, setAutoFetchIntervalMinutes] = useState(0); const [selectedFile, setSelectedFile] = useState(null); const [selectedFileStaged, setSelectedFileStaged] = useState(false); @@ -661,6 +667,7 @@ export function ProjectView({ pushRejectionAnalysis, upstreamDialogMode, fetch: handleFetch, + autoFetch: handleAutoFetch, fetchSingleRemote: handleFetchSingleRemote, pull: handlePull, push: handlePush, @@ -686,8 +693,27 @@ export function ProjectView({ refreshAll, showToast, onForcePushComplete: handleForcePushComplete, + onFetchAttemptComplete, }); + useEffect(() => { + let cancelled = false; + api.getSettings().then(settings => { + if (!cancelled) setAutoFetchIntervalMinutes(settings.autoFetchIntervalMinutes ?? 0); + }).catch(() => { + if (!cancelled) setAutoFetchIntervalMinutes(0); + }); + return () => { cancelled = true; }; + }, [settingsRevision]); + + useAutoFetch( + autoFetchIntervalMinutes, + Boolean(repoPath && windowFocused && !operationLock && !remoteOp), + repoPath, + lastFetchAttemptAt, + handleAutoFetch, + ); + const handleSaveLocalIdentity = useCallback(async (payload: Partial) => { await saveLocalIdentity(payload); await refreshAll(); diff --git a/src/components/Titlebar.tsx b/src/components/Titlebar.tsx index cedd697..1cb0ace 100644 --- a/src/components/Titlebar.tsx +++ b/src/components/Titlebar.tsx @@ -197,14 +197,14 @@ export function Titlebar({ {/* Action buttons */}
- } label={t("actions.fetch")} onClick={onFetch} disabled={!repoPath} loading={remoteOp === "fetch"} /> - } label={t("actions.pull")} badge={behind > 0 ? String(behind) : undefined} onClick={onPull} disabled={!repoPath} loading={remoteOp === "pull"} /> + } label={t("actions.fetch")} onClick={onFetch} disabled={!repoPath || !!remoteOp} loading={remoteOp === "fetch"} /> + } label={t("actions.pull")} badge={behind > 0 ? String(behind) : undefined} onClick={onPull} disabled={!repoPath || !!remoteOp} loading={remoteOp === "pull"} /> } label={pushActionLabel} badge={pushActionLabel === t("actions.push") && ahead > 0 ? String(ahead) : undefined} onClick={onPush} - disabled={!repoPath || pushDisabled} + disabled={!repoPath || pushDisabled || !!remoteOp} loading={remoteOp === "push"} title={pushTitle} /> diff --git a/src/components/settings/SettingsWindow.tsx b/src/components/settings/SettingsWindow.tsx index 1a026cc..14ae2cc 100644 --- a/src/components/settings/SettingsWindow.tsx +++ b/src/components/settings/SettingsWindow.tsx @@ -357,6 +357,7 @@ export function SettingsWindow() { const [commitDateMode, setCommitDateMode] = useState("AuthorDate"); const [commitMessageRecommendedLength, setCommitMessageRecommendedLength] = useState(String(DEFAULT_COMMIT_MESSAGE_RECOMMENDED_LENGTH)); const [pushFollowTags, setPushFollowTags] = useState(false); + const [autoFetchIntervalMinutes, setAutoFetchIntervalMinutes] = useState(0); const [autoCheckForUpdatesOnLaunch, setAutoCheckForUpdatesOnLaunch] = useState(true); const [autoInstallUpdates, setAutoInstallUpdates] = useState(false); const [updateEndpoint, setUpdateEndpointState] = useState(DEFAULT_UPDATE_ENDPOINT); @@ -614,6 +615,7 @@ export function SettingsWindow() { setCommitDateMode(settings.commitDateMode ?? "AuthorDate"); setCommitMessageRecommendedLength(String(settings.commitMessageRecommendedLength ?? DEFAULT_COMMIT_MESSAGE_RECOMMENDED_LENGTH)); setPushFollowTags(settings.pushFollowTags ?? false); + setAutoFetchIntervalMinutes(settings.autoFetchIntervalMinutes ?? 0); setAutoCheckForUpdatesOnLaunch(settings.autoCheckForUpdatesOnLaunch ?? true); setAutoInstallUpdates(settings.autoInstallUpdates ?? false); setUpdateEndpointState(settings.updateEndpoint ?? DEFAULT_UPDATE_ENDPOINT); @@ -1161,6 +1163,7 @@ export function SettingsWindow() { await invoke("set_commit_date_mode", {commitDateMode}); await invoke("set_commit_message_recommended_length", {commitMessageRecommendedLength: savedCommitMessageRecommendedLength}); await invoke("set_push_follow_tags", {pushFollowTags}); + await invoke("set_auto_fetch_interval_minutes", {autoFetchIntervalMinutes}); await invoke("set_auto_check_for_updates_on_launch", {autoCheckForUpdatesOnLaunch}); await invoke("set_auto_install_updates", {autoInstallUpdates}); await setUpdateEndpoint(updateEndpoint); @@ -2796,6 +2799,22 @@ export function SettingsWindow() {
{t("labels.gitGroupGitmunBehaviour")}
+
+ + +
{t("notes.autoFetch")}
+
+