- {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")}
+
+
- {inlineOperation && inlineOperationIsCommit && }
+ {commitProgress
+ ?
+ : inlineOperation && inlineOperationIsCommit && }
+ {hookRejection && (
+ {})}
+ onBypass={onHookRejectionBypass ?? (() => {})}
+ />
+ )}
);
}
diff --git a/src/i18n/locales/en/centre.json b/src/i18n/locales/en/centre.json
index e731605..e3d343c 100644
--- a/src/i18n/locales/en/centre.json
+++ b/src/i18n/locales/en/centre.json
@@ -94,6 +94,8 @@
"cherryPickCommit": "Cherry-pick Commit",
"close": "Close",
"commitLogScope": "Commit log scope",
+ "commitHookRejected": "Commit rejected by {{hook}} (exit status {{exitStatus}}).",
+ "commitHooksSkipped": "Committed without verification hooks.",
"commitRefs": "Commit refs",
"copyCommitHash": "Copy Commit Hash",
"copyDetails": "Copy Details",
@@ -169,6 +171,22 @@
"unstageMessage_other": "Gitmun is unstaging {{count}} files.",
"unstageTitle": "Unstaging changes"
},
+ "commitHooks": {
+ "awaitingDecision": "Commit hook failed",
+ "bypassWarning": "Committing without hooks skips repository checks.",
+ "close": "Close",
+ "committing": "Committing",
+ "commitWithoutHooks": "Commit without hooks",
+ "elapsed": "Running for {{seconds}}s",
+ "failedDescription": "The {{hook}} hook exited with status {{exitStatus}}.",
+ "failedTitle": "Commit hook failed",
+ "hideOutput": "Hide output",
+ "outputTruncated": "Output was truncated.",
+ "reviewFailure": "Choose how to continue.",
+ "runningHook": "Running {{hook}} hook",
+ "unknownExitStatus": "an unknown status",
+ "viewOutput": "View output"
+ },
"pushRejected": {
"cancel": "Cancel",
"currentBranch": "Current branch",
diff --git a/src/types.ts b/src/types.ts
index 509c19e..707337b 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -242,8 +242,35 @@ export type ExportCommitPatchRequest = RepoRequest & {
export type CommitRequest = RepoRequest & {
message: string;
amend?: boolean;
+ skipHooks?: boolean;
};
+export type CommitProgressEvent =
+ | { event: "output"; stream: "stdout" | "stderr"; text: string; truncated: boolean }
+ | { event: "hookStarted"; hookName: string }
+ | { event: "hookFinished"; hookName: string; exitStatus: number | null };
+
+export type CommitProgressState = {
+ startedAt: number;
+ phase: "running" | "awaitingDecision";
+ hookName: string | null;
+ output: string;
+ outputTruncated: boolean;
+ expanded: boolean;
+};
+
+export type CommitHookRejection = {
+ hookName: string;
+ exitStatus: number | null;
+ output: string | null;
+ outputTruncated: boolean;
+ bypassSupported: boolean;
+};
+
+export type CommitAttemptResult =
+ | { status: "committed"; result: OperationResult; outputTruncated: boolean }
+ | ({ status: "hookRejected" } & CommitHookRejection);
+
export type CommitMessageRecovery = {
message: string;
updatedAt: number;
From a1654490f9af8d18da2c2abdb11d02285d6f2a95 Mon Sep 17 00:00:00 2001
From: cst8t <1810150+cst8t@users.noreply.github.com>
Date: Fri, 28 Aug 2026 01:41:25 +0100
Subject: [PATCH 06/11] fix(ui): anchor commit file search toolbar
Keep the search control inside persistent panel chrome so it remains visually separated from the file tree while scrolling.
---
src/components/diff/DiffPanel.css | 11 ++++++++---
src/components/diff/DiffPanel.tsx | 30 ++++++++++++++++--------------
2 files changed, 24 insertions(+), 17 deletions(-)
diff --git a/src/components/diff/DiffPanel.css b/src/components/diff/DiffPanel.css
index 99afad3..7784d84 100644
--- a/src/components/diff/DiffPanel.css
+++ b/src/components/diff/DiffPanel.css
@@ -167,17 +167,22 @@
.diff-panel__submodule-state--conflict { color: var(--red); }
.diff-panel__commit-files {
- padding: 8px 0;
+ padding-bottom: 8px;
}
-.diff-panel__commit-file-search {
+.diff-panel__commit-file-toolbar {
position: sticky;
top: 0;
z-index: 1;
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--border-subtle);
+ background: var(--bg-surface);
+}
+
+.diff-panel__commit-file-search {
display: flex;
align-items: center;
gap: 8px;
- margin: 0 10px 8px;
padding: 6px 10px;
min-height: 32px;
box-sizing: border-box;
diff --git a/src/components/diff/DiffPanel.tsx b/src/components/diff/DiffPanel.tsx
index 4acadaa..013defc 100644
--- a/src/components/diff/DiffPanel.tsx
+++ b/src/components/diff/DiffPanel.tsx
@@ -433,20 +433,22 @@ export function DiffPanel({
{t("placeholders.loadingCommitFiles")}
) : commitFiles.length > 0 ? (
-
+
+
+
{filteredCommitFiles.length === 0 ? (
{t("placeholders.noMatchingCommitFiles")}
) : commitTreeRows.map((row) => {
From 6e16fc20508de550e969eb6ad78c7d048a7742dd Mon Sep 17 00:00:00 2001
From: cst8t <1810150+cst8t@users.noreply.github.com>
Date: Thu, 3 Sep 2026 23:41:55 +0100
Subject: [PATCH 07/11] feat(ui): sync recent repositories with system history
Add Windows Jump List and Linux recent-manager integration, and allow
recent repositories to be removed from the empty state and open menu.
---
src-tauri/Cargo.toml | 5 +
src-tauri/src/commands/mod.rs | 1 +
src-tauri/src/commands/recent_repositories.rs | 511 ++++++++++++++++++
src-tauri/src/lib.rs | 5 +-
src/api/commands.ts | 11 +
src/components/App.css | 59 +-
src/components/App.tsx | 93 +++-
src/components/ProjectView.test.ts | 32 ++
src/components/ProjectView.tsx | 90 ++-
src/components/Titlebar.css | 39 ++
src/components/Titlebar.test.tsx | 30 +-
src/components/Titlebar.tsx | 40 +-
src/i18n/locales/en/app.json | 4 +
src/utils/recentRepositories.test.ts | 60 ++
src/utils/recentRepositories.ts | 38 ++
15 files changed, 959 insertions(+), 59 deletions(-)
create mode 100644 src-tauri/src/commands/recent_repositories.rs
create mode 100644 src/utils/recentRepositories.test.ts
create mode 100644 src/utils/recentRepositories.ts
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 3a9e171..26e9406 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -57,8 +57,13 @@ windows = { version = "0.61.3", features = [
"Foundation_Collections",
"Services_Store",
"Win32_Foundation",
+ "Win32_System_Com",
+ "Win32_System_Com_StructuredStorage",
"Win32_System_Recovery",
+ "Win32_System_Variant",
"Win32_UI_Shell",
+ "Win32_UI_Shell_Common",
+ "Win32_UI_Shell_PropertiesSystem",
"Win32_UI_WindowsAndMessaging",
] }
windows-future = "0.2.1"
diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs
index 81f3e1c..a74587d 100644
--- a/src-tauri/src/commands/mod.rs
+++ b/src-tauri/src/commands/mod.rs
@@ -1,5 +1,6 @@
pub mod branches;
pub mod history;
+pub mod recent_repositories;
pub mod repo;
pub mod settings;
pub mod store_update;
diff --git a/src-tauri/src/commands/recent_repositories.rs b/src-tauri/src/commands/recent_repositories.rs
new file mode 100644
index 0000000..c173678
--- /dev/null
+++ b/src-tauri/src/commands/recent_repositories.rs
@@ -0,0 +1,511 @@
+use serde::Deserialize;
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct RecentRepositoriesSyncRequest {
+ pub paths: Vec
,
+ pub category_label: String,
+ pub accessed_path: Option,
+ pub linux_seed_paths: Vec,
+}
+
+#[cfg(any(target_os = "windows", test))]
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct JumpListDestination {
+ path: String,
+ title: String,
+ arguments: String,
+}
+
+#[cfg(any(target_os = "windows", test))]
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum WindowsAppIdentity {
+ Packaged(String),
+ RunningProcess,
+}
+
+#[cfg(any(target_os = "windows", test))]
+fn windows_app_identity(is_msix_build: bool, has_package_identity: bool) -> WindowsAppIdentity {
+ if is_msix_build && has_package_identity {
+ WindowsAppIdentity::Packaged(format!("{}!Gitmun", crate::MSIX_PACKAGE_FAMILY_NAME))
+ } else {
+ WindowsAppIdentity::RunningProcess
+ }
+}
+
+fn repository_title(path: &str) -> String {
+ path.trim_end_matches(['/', '\\'])
+ .rsplit(['/', '\\'])
+ .next()
+ .filter(|name| !name.is_empty())
+ .unwrap_or(path)
+ .to_string()
+}
+
+#[cfg(any(target_os = "windows", test))]
+fn quote_windows_argument(argument: &str) -> String {
+ let mut quoted = String::from("\"");
+ let mut backslashes = 0;
+ for character in argument.chars() {
+ match character {
+ '\\' => backslashes += 1,
+ '"' => {
+ quoted.extend(std::iter::repeat_n('\\', backslashes * 2 + 1));
+ quoted.push('"');
+ backslashes = 0;
+ }
+ _ => {
+ quoted.extend(std::iter::repeat_n('\\', backslashes));
+ quoted.push(character);
+ backslashes = 0;
+ }
+ }
+ }
+ quoted.extend(std::iter::repeat_n('\\', backslashes * 2));
+ quoted.push('"');
+ quoted
+}
+
+#[cfg(any(target_os = "windows", test))]
+fn jump_list_destinations(
+ paths: &[String],
+ removed_paths: &[String],
+ capacity: usize,
+) -> Vec {
+ paths
+ .iter()
+ .filter(|path| !removed_paths.contains(path))
+ .take(capacity)
+ .map(|path| JumpListDestination {
+ path: path.clone(),
+ title: repository_title(path),
+ arguments: format!("--new-window open {}", quote_windows_argument(path)),
+ })
+ .collect()
+}
+
+#[cfg(any(target_os = "windows", test))]
+trait JumpListWriter {
+ fn begin(&mut self) -> Result<(usize, Vec), String>;
+ fn append_category(
+ &mut self,
+ category_label: &str,
+ destinations: &[JumpListDestination],
+ ) -> Result<(), String>;
+ fn commit(&mut self) -> Result<(), String>;
+ fn abort(&mut self);
+}
+
+#[cfg(any(target_os = "windows", test))]
+fn rebuild_jump_list(
+ writer: &mut impl JumpListWriter,
+ paths: &[String],
+ category_label: &str,
+) -> Result, String> {
+ let (capacity, removed_paths) = writer.begin()?;
+ let destinations = jump_list_destinations(paths, &removed_paths, capacity);
+ let result = writer
+ .append_category(category_label, &destinations)
+ .and_then(|()| writer.commit());
+ if let Err(error) = result {
+ writer.abort();
+ return Err(error);
+ }
+ Ok(removed_paths)
+}
+
+#[tauri::command]
+pub async fn sync_recent_repositories(
+ app: tauri::AppHandle,
+ request: RecentRepositoriesSyncRequest,
+) -> Result, String> {
+ platform::sync(app, request).await
+}
+
+#[cfg(target_os = "linux")]
+mod platform {
+ use super::RecentRepositoriesSyncRequest;
+ use gtk::prelude::RecentManagerExt;
+ use std::path::Path;
+
+ pub async fn sync(
+ app: tauri::AppHandle,
+ request: RecentRepositoriesSyncRequest,
+ ) -> Result, String> {
+ let mut accessed_paths = request.linux_seed_paths;
+ if let Some(accessed_path) = request.accessed_path {
+ accessed_paths.push(accessed_path);
+ }
+ if accessed_paths.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let (sender, receiver) = tokio::sync::oneshot::channel();
+ app.run_on_main_thread(move || {
+ drop(sender.send(record_accesses(&accessed_paths)));
+ })
+ .map_err(|error| error.to_string())?;
+ receiver.await.map_err(|error| error.to_string())??;
+ Ok(Vec::new())
+ }
+
+ fn record_accesses(paths: &[String]) -> Result<(), String> {
+ let manager = gtk::RecentManager::default()
+ .ok_or_else(|| "GTK recent manager is unavailable".to_string())?;
+ let executable = std::env::current_exe().map_err(|error| error.to_string())?;
+ let app_exec = format!(
+ "{} --new-window open %f",
+ gtk::glib::shell_quote(&executable).to_string_lossy()
+ );
+
+ for path in paths {
+ let uri = url::Url::from_directory_path(Path::new(path))
+ .map_err(|()| format!("Cannot convert repository path to URI: {path}"))?;
+ let recent_data = gtk::RecentData {
+ display_name: Some(super::repository_title(path)),
+ description: Some(path.clone()),
+ mime_type: "inode/directory".to_string(),
+ app_name: "Gitmun".to_string(),
+ app_exec: app_exec.clone(),
+ groups: vec!["gitmun".to_string()],
+ is_private: false,
+ };
+ if !manager.add_full(uri.as_str(), &recent_data) {
+ return Err(format!("GTK could not record recent repository: {path}"));
+ }
+ }
+ Ok(())
+ }
+}
+
+#[cfg(target_os = "windows")]
+mod platform {
+ use super::{
+ JumpListDestination, JumpListWriter, RecentRepositoriesSyncRequest, WindowsAppIdentity,
+ rebuild_jump_list, windows_app_identity,
+ };
+ use windows::{
+ Win32::{
+ Foundation::PROPERTYKEY,
+ System::Com::{
+ CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx,
+ CoTaskMemFree, CoUninitialize, StructuredStorage::PROPVARIANT,
+ },
+ UI::Shell::{
+ Common::{IObjectArray, IObjectCollection},
+ DestinationList, EnumerableObjectCollection,
+ GetCurrentProcessExplicitAppUserModelID, ICustomDestinationList, IShellLinkW,
+ PropertiesSystem::IPropertyStore,
+ ShellLink,
+ },
+ },
+ core::{GUID, Interface, PCWSTR},
+ };
+
+ const PKEY_TITLE: PROPERTYKEY = PROPERTYKEY {
+ fmtid: GUID::from_u128(0xf29f85e0_4ff9_1068_ab91_08002b27b3d9),
+ pid: 2,
+ };
+ const SHELL_STRING_CAPACITY: usize = 32_768;
+
+ pub async fn sync(
+ _app: tauri::AppHandle,
+ request: RecentRepositoriesSyncRequest,
+ ) -> Result, String> {
+ tauri::async_runtime::spawn_blocking(move || sync_blocking(request))
+ .await
+ .map_err(|error| error.to_string())?
+ }
+
+ fn sync_blocking(request: RecentRepositoriesSyncRequest) -> Result, String> {
+ let _com = ComInitialisation::new()?;
+ let executable = std::env::current_exe().map_err(|error| error.to_string())?;
+ let identity = windows_app_identity(crate::is_msix_build(), crate::has_package_identity());
+ let mut writer = WindowsJumpListWriter {
+ destination_list: None,
+ executable: executable.to_string_lossy().into_owned(),
+ identity,
+ };
+ rebuild_jump_list(&mut writer, &request.paths, &request.category_label)
+ }
+
+ struct ComInitialisation;
+
+ impl ComInitialisation {
+ fn new() -> Result {
+ unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }
+ .ok()
+ .map_err(|error| error.to_string())?;
+ Ok(Self)
+ }
+ }
+
+ impl Drop for ComInitialisation {
+ fn drop(&mut self) {
+ unsafe { CoUninitialize() };
+ }
+ }
+
+ struct WindowsJumpListWriter {
+ destination_list: Option,
+ executable: String,
+ identity: WindowsAppIdentity,
+ }
+
+ impl JumpListWriter for WindowsJumpListWriter {
+ fn begin(&mut self) -> Result<(usize, Vec), String> {
+ let destination_list: ICustomDestinationList =
+ unsafe { CoCreateInstance(&DestinationList, None, CLSCTX_INPROC_SERVER) }
+ .map_err(|error| error.to_string())?;
+ if let WindowsAppIdentity::Packaged(app_id) = &self.identity {
+ let app_id = wide_string(app_id);
+ unsafe { destination_list.SetAppID(PCWSTR::from_raw(app_id.as_ptr())) }
+ .map_err(|error| error.to_string())?;
+ } else if let Ok(app_id) = unsafe { GetCurrentProcessExplicitAppUserModelID() } {
+ let result =
+ unsafe { destination_list.SetAppID(PCWSTR::from_raw(app_id.as_ptr())) };
+ unsafe { CoTaskMemFree(Some(app_id.as_ptr().cast())) };
+ result.map_err(|error| error.to_string())?;
+ }
+
+ let mut capacity = 0;
+ let removed: IObjectArray = unsafe { destination_list.BeginList(&mut capacity) }
+ .map_err(|error| error.to_string())?;
+ self.destination_list = Some(destination_list);
+ let removed_paths = match removed_paths(&removed) {
+ Ok(paths) => paths,
+ Err(error) => {
+ self.abort();
+ return Err(error);
+ }
+ };
+ Ok((capacity as usize, removed_paths))
+ }
+
+ fn append_category(
+ &mut self,
+ category_label: &str,
+ destinations: &[JumpListDestination],
+ ) -> Result<(), String> {
+ if destinations.is_empty() {
+ return Ok(());
+ }
+ let collection: IObjectCollection = unsafe {
+ CoCreateInstance(&EnumerableObjectCollection, None, CLSCTX_INPROC_SERVER)
+ }
+ .map_err(|error| error.to_string())?;
+ for destination in destinations {
+ let link = self.create_link(destination)?;
+ unsafe { collection.AddObject(&link) }.map_err(|error| error.to_string())?;
+ }
+ let objects: IObjectArray = collection.cast().map_err(|error| error.to_string())?;
+ let category_label = wide_string(category_label);
+ unsafe {
+ self.destination_list()
+ .AppendCategory(PCWSTR::from_raw(category_label.as_ptr()), &objects)
+ }
+ .map_err(|error| error.to_string())
+ }
+
+ fn commit(&mut self) -> Result<(), String> {
+ unsafe { self.destination_list().CommitList() }.map_err(|error| error.to_string())
+ }
+
+ fn abort(&mut self) {
+ if let Some(destination_list) = &self.destination_list {
+ drop(unsafe { destination_list.AbortList() });
+ }
+ }
+ }
+
+ impl WindowsJumpListWriter {
+ fn destination_list(&self) -> &ICustomDestinationList {
+ self.destination_list
+ .as_ref()
+ .expect("destination list must be begun before it is updated")
+ }
+
+ fn create_link(&self, destination: &JumpListDestination) -> Result {
+ let link: IShellLinkW =
+ unsafe { CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER) }
+ .map_err(|error| error.to_string())?;
+ let executable = wide_string(&self.executable);
+ let arguments = wide_string(&destination.arguments);
+ let description = wide_string(&destination.path);
+ let result = (|| -> windows::core::Result<()> {
+ unsafe {
+ link.SetPath(PCWSTR::from_raw(executable.as_ptr()))?;
+ link.SetArguments(PCWSTR::from_raw(arguments.as_ptr()))?;
+ link.SetDescription(PCWSTR::from_raw(description.as_ptr()))?;
+ link.SetIconLocation(PCWSTR::from_raw(executable.as_ptr()), 0)?;
+ let properties: IPropertyStore = link.cast()?;
+ let title = PROPVARIANT::from(destination.title.as_str());
+ properties.SetValue(&PKEY_TITLE, &title)?;
+ properties.Commit()?;
+ }
+ Ok(())
+ })();
+ result.map_err(|error| error.to_string())?;
+ Ok(link)
+ }
+ }
+
+ fn removed_paths(objects: &IObjectArray) -> Result, String> {
+ let count = unsafe { objects.GetCount() }.map_err(|error| error.to_string())?;
+ let mut paths = Vec::with_capacity(count as usize);
+ for index in 0..count {
+ let link: IShellLinkW =
+ unsafe { objects.GetAt(index) }.map_err(|error| error.to_string())?;
+ let mut description = vec![0_u16; SHELL_STRING_CAPACITY];
+ unsafe { link.GetDescription(&mut description) }.map_err(|error| error.to_string())?;
+ let length = description
+ .iter()
+ .position(|character| *character == 0)
+ .unwrap_or(description.len());
+ if length > 0 {
+ paths.push(String::from_utf16_lossy(&description[..length]));
+ }
+ }
+ Ok(paths)
+ }
+
+ fn wide_string(value: &str) -> Vec {
+ value.encode_utf16().chain(std::iter::once(0)).collect()
+ }
+}
+
+#[cfg(not(any(target_os = "linux", target_os = "windows")))]
+mod platform {
+ use super::RecentRepositoriesSyncRequest;
+
+ pub async fn sync(
+ _app: tauri::AppHandle,
+ _request: RecentRepositoriesSyncRequest,
+ ) -> Result, String> {
+ Ok(Vec::new())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn limits_destinations_and_preserves_order() {
+ let paths = vec![
+ r"C:\Repos\one".to_string(),
+ r"C:\Repos\two".to_string(),
+ r"C:\Repos\three".to_string(),
+ ];
+
+ let destinations = jump_list_destinations(&paths, &[], 2);
+
+ assert_eq!(
+ destinations
+ .iter()
+ .map(|item| item.path.as_str())
+ .collect::>(),
+ vec![r"C:\Repos\one", r"C:\Repos\two"]
+ );
+ }
+
+ #[test]
+ fn omits_removed_destinations_without_reordering_the_rest() {
+ let paths = vec![
+ r"C:\Repos\one".to_string(),
+ r"C:\Repos\two".to_string(),
+ r"C:\Repos\three".to_string(),
+ ];
+
+ let destinations = jump_list_destinations(&paths, &[r"C:\Repos\two".to_string()], 10);
+
+ assert_eq!(
+ destinations
+ .iter()
+ .map(|item| item.path.as_str())
+ .collect::>(),
+ vec![r"C:\Repos\one", r"C:\Repos\three"]
+ );
+ }
+
+ #[test]
+ fn builds_unicode_titles_and_correctly_quoted_arguments() {
+ let paths = vec![r#"C:\Repos\quoted name\résumé"#.to_string()];
+
+ let destinations = jump_list_destinations(&paths, &[], 10);
+
+ assert_eq!(destinations[0].title, "résumé");
+ assert_eq!(
+ destinations[0].arguments,
+ r#"--new-window open "C:\Repos\quoted name\résumé""#
+ );
+ assert_eq!(
+ quote_windows_argument(r#"C:\Repos\name"with quote\"#),
+ r#""C:\Repos\name\"with quote\\""#
+ );
+ }
+
+ #[test]
+ fn selects_packaged_and_running_process_identities() {
+ assert_eq!(
+ windows_app_identity(true, true),
+ WindowsAppIdentity::Packaged("cst8t.Gitmun_yqm0gq6me4wme!Gitmun".to_string())
+ );
+ assert_eq!(
+ windows_app_identity(false, false),
+ WindowsAppIdentity::RunningProcess
+ );
+ assert_eq!(
+ windows_app_identity(true, false),
+ WindowsAppIdentity::RunningProcess
+ );
+ }
+
+ #[derive(Default)]
+ struct TestWriter {
+ fail_append: bool,
+ committed: bool,
+ aborted: bool,
+ }
+
+ impl JumpListWriter for TestWriter {
+ fn begin(&mut self) -> Result<(usize, Vec), String> {
+ Ok((10, Vec::new()))
+ }
+
+ fn append_category(
+ &mut self,
+ _category_label: &str,
+ _destinations: &[JumpListDestination],
+ ) -> Result<(), String> {
+ if self.fail_append {
+ Err("append failed".to_string())
+ } else {
+ Ok(())
+ }
+ }
+
+ fn commit(&mut self) -> Result<(), String> {
+ self.committed = true;
+ Ok(())
+ }
+
+ fn abort(&mut self) {
+ self.aborted = true;
+ }
+ }
+
+ #[test]
+ fn aborts_without_committing_after_a_build_error() {
+ let mut writer = TestWriter {
+ fail_append: true,
+ ..TestWriter::default()
+ };
+
+ let result = rebuild_jump_list(&mut writer, &[r"C:\Repos\one".to_string()], "Recent");
+
+ assert_eq!(result, Err("append failed".to_string()));
+ assert!(writer.aborted);
+ assert!(!writer.committed);
+ }
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index c2e5ad7..51d83be 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -69,8 +69,8 @@ static BUNDLED_GIT_EXE: OnceLock
- {emptyStateRecentRepos.length > 0 && (
-
-
-
{t("emptyState.recentRepositories")}
-
- {emptyStateRecentRepos.map(path => {
- const name = displayNameForRepoPath(path, recentRepoDisplayNames[path]);
- return (
-
- );
- })}
-
-
- )}
+
)}
diff --git a/src/components/Titlebar.css b/src/components/Titlebar.css
index eae2db8..35340f5 100644
--- a/src/components/Titlebar.css
+++ b/src/components/Titlebar.css
@@ -442,10 +442,49 @@
}
.titlebar__open-menu-item--recent {
+ gap: 2px;
+ padding: 0;
+}
+
+.titlebar__open-menu-recent-select {
+ min-width: 0;
+ flex: 1;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ padding: 6px 8px 6px 10px;
font-family: var(--font-mono);
+ font-size: inherit;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+ text-align: left;
+ cursor: pointer;
+}
+
+.titlebar__open-menu-recent-remove {
+ width: 26px;
+ height: 26px;
+ flex: 0 0 26px;
+ border: 0;
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: var(--text-muted);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+
+.titlebar__open-menu-recent-remove:hover {
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+}
+
+.titlebar__open-menu-recent-select:focus-visible,
+.titlebar__open-menu-recent-remove:focus-visible {
+ outline: 2px solid var(--focus-ring);
+ outline-offset: -2px;
}
.titlebar__open-menu-sep {
diff --git a/src/components/Titlebar.test.tsx b/src/components/Titlebar.test.tsx
index 0dbcd45..695e56c 100644
--- a/src/components/Titlebar.test.tsx
+++ b/src/components/Titlebar.test.tsx
@@ -47,6 +47,9 @@ function renderTitlebar(
aiConfigured?: boolean;
onAiWriting?: () => void;
onSettingsClick?: () => void;
+ recentRepos?: string[];
+ onRepoSelect?: (path: string) => void;
+ onRemoveRecentRepo?: (path: string) => void;
} = {},
) {
const onImportPatch = patchHandlers.onImportPatch ?? vi.fn();
@@ -61,7 +64,7 @@ function renderTitlebar(
branches={branches}
identityName="Gitmun Maintainer"
identityAvatarUrl={null}
- recentRepos={[]}
+ recentRepos={patchHandlers.recentRepos ?? []}
searchQuery=""
searchInputRef={{ current: null }}
onSearchChange={vi.fn()}
@@ -71,7 +74,8 @@ function renderTitlebar(
onCloneClick={vi.fn()}
onInitRepoClick={vi.fn()}
onOpenExistingClick={vi.fn()}
- onRepoSelect={vi.fn()}
+ onRepoSelect={patchHandlers.onRepoSelect ?? vi.fn()}
+ onRemoveRecentRepo={patchHandlers.onRemoveRecentRepo ?? vi.fn()}
onOpenRepoLocation={onOpenRepoLocation}
onFetch={vi.fn()}
onPull={vi.fn()}
@@ -126,6 +130,28 @@ describe("Titlebar", () => {
expect(screen.getByText("Push")).toBeInTheDocument();
});
+ it("removes a recent repository without opening it or closing the menu", () => {
+ const onRepoSelect = vi.fn();
+ const onRemoveRecentRepo = vi.fn();
+ renderTitlebar([makeBranch()], "Push", "/current", vi.fn(), {
+ recentRepos: ["/repos/one", "/repos/two"],
+ onRepoSelect,
+ onRemoveRecentRepo,
+ });
+ fireEvent.click(screen.getByTitle("Open a repository"));
+
+ const removeButton = screen.getByRole("button", {
+ name: "Remove one from recent repositories",
+ });
+ expect(removeButton).toHaveAttribute("title", "Remove one from recent repositories");
+ fireEvent.click(removeButton);
+
+ expect(onRemoveRecentRepo).toHaveBeenCalledWith("/repos/one");
+ expect(onRepoSelect).not.toHaveBeenCalled();
+ expect(screen.getByRole("button", {name: "Remove two from recent repositories"}))
+ .toBeInTheDocument();
+ });
+
it("shows a disclosure with the full branch name when the branch label is truncated", () => {
const longBranch = "feature/this-is-a-very-long-branch-name-that-should-not-crowd-toolbar-actions";
renderTitlebar([makeBranch({ name: longBranch })], "Push", "/repo", vi.fn(), { currentBranch: longBranch });
diff --git a/src/components/Titlebar.tsx b/src/components/Titlebar.tsx
index 1cb0ace..527d1fb 100644
--- a/src/components/Titlebar.tsx
+++ b/src/components/Titlebar.tsx
@@ -4,6 +4,7 @@ import {
GitIcon, BranchIcon, FetchIcon, PullIcon, PushIcon,
StashIcon, SearchIcon, SettingsIcon, FolderIcon, CopyIcon, ChevDownIcon, InfoIcon, TerminalIcon, OpenExternalIcon,
MoreIcon,
+ CloseIcon,
} from "./icons";
import * as api from "../api/commands";
import type { ResetMode } from "../api/commands";
@@ -37,6 +38,7 @@ type TitlebarProps = {
onInitRepoClick: () => void;
onOpenExistingClick: () => void;
onRepoSelect: (path: string) => void;
+ onRemoveRecentRepo: (path: string) => void;
onOpenRepoLocation: (kind: RepoOpenLocationKind) => void;
onFetch: () => void;
onPull: () => void;
@@ -58,7 +60,7 @@ export function Titlebar({
repoDisplayName,
identityName, identityAvatarUrl, recentRepos, searchQuery, searchInputRef,
onSearchChange, onAboutClick, onSettingsClick, onIdentityClick, onCloneClick, onInitRepoClick, onOpenExistingClick,
- onRepoSelect, onOpenRepoLocation, onFetch, onPull, onPush, pushLabel, pushDisabled = false, pushTitle, onStash,
+ onRepoSelect, onRemoveRecentRepo, onOpenRepoLocation, onFetch, onPull, onPush, pushLabel, pushDisabled = false, pushTitle, onStash,
onReset, onImportPatch, onExportPatch, selectedPatchExportEnabled,
identityOpen, remoteOp, aiEnabled = false, aiConfigured = false, onAiWriting,
}: TitlebarProps) {
@@ -235,6 +237,7 @@ export function Titlebar({
recentRepos={recentRepos}
onOpenExistingClick={onOpenExistingClick}
onRepoSelect={onRepoSelect}
+ onRemoveRecentRepo={onRemoveRecentRepo}
/>