From cc5fc4d0dbb9f882a508e40bc884265c50b95a15 Mon Sep 17 00:00:00 2001 From: ishanray Date: Tue, 3 Feb 2026 21:48:41 -0500 Subject: [PATCH 1/7] feat: add branch switcher with cmd+b shortcut Add a quick branch switcher modal accessible via cmd+b (configurable): - New BranchSwitcherPrompt component for branch selection UI - useBranchSwitcher hook for managing switcher state and logic - useBranchSwitcherShortcut hook for keyboard shortcut handling - Settings integration for customizing the shortcut - Support switching to branches in linked worktrees - Fix cmd+n shortcut to ignore shift key combinations - Fix notification compilation on release builds --- src-tauri/src/notifications.rs | 2 +- src/App.tsx | 28 ++++ src/features/app/components/AppModals.tsx | 30 ++++ src/features/app/hooks/useNewAgentShortcut.ts | 2 +- .../git/components/BranchSwitcherPrompt.tsx | 152 ++++++++++++++++++ src/features/git/hooks/useBranchSwitcher.ts | 59 +++++++ .../git/hooks/useBranchSwitcherShortcut.ts | 28 ++++ .../settings/components/SettingsView.test.tsx | 1 + .../settings/components/SettingsView.tsx | 30 ++++ src/features/settings/hooks/useAppSettings.ts | 1 + src/styles/branch-switcher-modal.css | 112 +++++++++++++ src/types.ts | 1 + 12 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 src/features/git/components/BranchSwitcherPrompt.tsx create mode 100644 src/features/git/hooks/useBranchSwitcher.ts create mode 100644 src/features/git/hooks/useBranchSwitcherShortcut.ts create mode 100644 src/styles/branch-switcher-modal.css diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index dc714aabb..a7a5c0447 100644 --- a/src-tauri/src/notifications.rs +++ b/src-tauri/src/notifications.rs @@ -1,4 +1,4 @@ -#[cfg(target_os = "macos")] +#[cfg(all(target_os = "macos", debug_assertions))] use std::process::Command; #[tauri::command] diff --git a/src/App.tsx b/src/App.tsx index aa0f806e2..df65eae6a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ import "./styles/about.css"; import "./styles/tabbar.css"; import "./styles/worktree-modal.css"; import "./styles/clone-modal.css"; +import "./styles/branch-switcher-modal.css"; import "./styles/settings.css"; import "./styles/compact-base.css"; import "./styles/compact-phone.css"; @@ -51,6 +52,8 @@ import { useApps } from "./features/apps/hooks/useApps"; import { useCustomPrompts } from "./features/prompts/hooks/useCustomPrompts"; import { useWorkspaceFileListing } from "./features/app/hooks/useWorkspaceFileListing"; import { useGitBranches } from "./features/git/hooks/useGitBranches"; +import { useBranchSwitcher } from "./features/git/hooks/useBranchSwitcher"; +import { useBranchSwitcherShortcut } from "./features/git/hooks/useBranchSwitcherShortcut"; import { useDebugLog } from "./features/debug/hooks/useDebugLog"; import { useWorkspaceRefreshOnFocus } from "./features/workspaces/hooks/useWorkspaceRefreshOnFocus"; import { useWorkspaceRestore } from "./features/workspaces/hooks/useWorkspaceRestore"; @@ -474,6 +477,25 @@ function MainApp() { await createBranch(name); refreshGitStatus(); }; + const currentBranch = gitStatus.branchName ?? null; + const { + branchSwitcher, + openBranchSwitcher, + closeBranchSwitcher, + handleBranchSelect, + } = useBranchSwitcher({ + activeWorkspace, + workspaces, + branches, + currentBranch, + checkoutBranch: handleCheckoutBranch, + setActiveWorkspaceId, + }); + useBranchSwitcherShortcut({ + shortcut: appSettings.branchSwitcherShortcut, + isEnabled: Boolean(activeWorkspace?.connected), + onTrigger: openBranchSwitcher, + }); const alertError = useCallback((error: unknown) => { alert(error instanceof Error ? error.message : String(error)); }, []); @@ -2237,6 +2259,12 @@ function MainApp() { onClonePromptClearCopiesFolder={clearCloneCopiesFolder} onClonePromptCancel={cancelClonePrompt} onClonePromptConfirm={confirmClonePrompt} + branchSwitcher={branchSwitcher} + branches={branches} + workspaces={workspaces} + currentBranch={currentBranch} + onBranchSwitcherSelect={handleBranchSelect} + onBranchSwitcherCancel={closeBranchSwitcher} settingsOpen={settingsOpen} settingsSection={settingsSection ?? undefined} onCloseSettings={closeSettings} diff --git a/src/features/app/components/AppModals.tsx b/src/features/app/components/AppModals.tsx index 80174f97e..620480554 100644 --- a/src/features/app/components/AppModals.tsx +++ b/src/features/app/components/AppModals.tsx @@ -1,9 +1,11 @@ import { lazy, memo, Suspense } from "react"; import type { ComponentType } from "react"; +import type { BranchInfo, WorkspaceInfo } from "../../../types"; import type { SettingsViewProps } from "../../settings/components/SettingsView"; import { useRenameThreadPrompt } from "../../threads/hooks/useRenameThreadPrompt"; import { useClonePrompt } from "../../workspaces/hooks/useClonePrompt"; import { useWorktreePrompt } from "../../workspaces/hooks/useWorktreePrompt"; +import type { BranchSwitcherState } from "../../git/hooks/useBranchSwitcher"; const RenameThreadPrompt = lazy(() => import("../../threads/components/RenameThreadPrompt").then((module) => ({ @@ -20,6 +22,11 @@ const ClonePrompt = lazy(() => default: module.ClonePrompt, })), ); +const BranchSwitcherPrompt = lazy(() => + import("../../git/components/BranchSwitcherPrompt").then((module) => ({ + default: module.BranchSwitcherPrompt, + })), +); type RenamePromptState = ReturnType["renamePrompt"]; @@ -44,6 +51,12 @@ type AppModalsProps = { onClonePromptClearCopiesFolder: () => void; onClonePromptCancel: () => void; onClonePromptConfirm: () => void; + branchSwitcher: BranchSwitcherState; + branches: BranchInfo[]; + workspaces: WorkspaceInfo[]; + currentBranch: string | null; + onBranchSwitcherSelect: (branch: string, worktree: WorkspaceInfo | null) => void; + onBranchSwitcherCancel: () => void; settingsOpen: boolean; settingsSection: SettingsViewProps["initialSection"] | null; onCloseSettings: () => void; @@ -68,6 +81,12 @@ export const AppModals = memo(function AppModals({ onClonePromptClearCopiesFolder, onClonePromptCancel, onClonePromptConfirm, + branchSwitcher, + branches, + workspaces, + currentBranch, + onBranchSwitcherSelect, + onBranchSwitcherCancel, settingsOpen, settingsSection, onCloseSettings, @@ -122,6 +141,17 @@ export const AppModals = memo(function AppModals({ /> )} + {branchSwitcher && ( + + + + )} {settingsOpen && ( void; + onCancel: () => void; +}; + +function fuzzyMatch(query: string, target: string): boolean { + const q = query.toLowerCase(); + const t = target.toLowerCase(); + let qi = 0; + for (let ti = 0; ti < t.length && qi < q.length; ti++) { + if (t[ti] === q[qi]) { + qi++; + } + } + return qi === q.length; +} + +function getWorktreeByBranch( + workspaces: WorkspaceInfo[], + branch: string, +): WorkspaceInfo | null { + return ( + workspaces.find( + (ws) => ws.kind === "worktree" && ws.worktree?.branch === branch, + ) ?? null + ); +} + +export function BranchSwitcherPrompt({ + branches, + workspaces, + currentBranch, + onSelect, + onCancel, +}: BranchSwitcherPromptProps) { + const inputRef = useRef(null); + const listRef = useRef(null); + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const filteredBranches = useMemo(() => { + if (!query.trim()) { + return branches; + } + return branches.filter((branch) => fuzzyMatch(query.trim(), branch.name)); + }, [branches, query]); + + useEffect(() => { + setSelectedIndex(0); + }, [filteredBranches.length]); + + useEffect(() => { + const itemEl = listRef.current?.children[selectedIndex] as + | HTMLElement + | undefined; + itemEl?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + const handleSelect = (branch: BranchInfo) => { + const worktree = getWorktreeByBranch(workspaces, branch.name); + onSelect(branch.name, worktree); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onCancel(); + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex((prev) => + prev < filteredBranches.length - 1 ? prev + 1 : prev, + ); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + const branch = filteredBranches[selectedIndex]; + if (branch) { + handleSelect(branch); + } + return; + } + }; + + return ( +
+
+
+ setQuery(event.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search branches..." + /> +
+ {filteredBranches.length === 0 && ( +
No branches found
+ )} + {filteredBranches.map((branch, index) => { + const isSelected = index === selectedIndex; + const isCurrent = branch.name === currentBranch; + const worktree = getWorktreeByBranch(workspaces, branch.name); + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/features/git/hooks/useBranchSwitcher.ts b/src/features/git/hooks/useBranchSwitcher.ts new file mode 100644 index 000000000..0bb10df97 --- /dev/null +++ b/src/features/git/hooks/useBranchSwitcher.ts @@ -0,0 +1,59 @@ +import { useCallback, useState } from "react"; +import type { BranchInfo, WorkspaceInfo } from "../../../types"; + +type UseBranchSwitcherOptions = { + activeWorkspace: WorkspaceInfo | null; + workspaces: WorkspaceInfo[]; + branches: BranchInfo[]; + currentBranch: string | null; + checkoutBranch: (name: string) => Promise; + setActiveWorkspaceId: (id: string) => void; +}; + +export type BranchSwitcherState = { + isOpen: boolean; +} | null; + +export function useBranchSwitcher({ + activeWorkspace, + workspaces, + branches, + currentBranch, + checkoutBranch, + setActiveWorkspaceId, +}: UseBranchSwitcherOptions) { + const [branchSwitcher, setBranchSwitcher] = useState(null); + + const openBranchSwitcher = useCallback(() => { + if (!activeWorkspace) { + return; + } + setBranchSwitcher({ isOpen: true }); + }, [activeWorkspace]); + + const closeBranchSwitcher = useCallback(() => { + setBranchSwitcher(null); + }, []); + + const handleBranchSelect = useCallback( + async (branchName: string, worktreeWorkspace: WorkspaceInfo | null) => { + closeBranchSwitcher(); + if (worktreeWorkspace) { + setActiveWorkspaceId(worktreeWorkspace.id); + } else { + await checkoutBranch(branchName); + } + }, + [checkoutBranch, closeBranchSwitcher, setActiveWorkspaceId], + ); + + return { + branchSwitcher, + branches, + workspaces, + currentBranch, + openBranchSwitcher, + closeBranchSwitcher, + handleBranchSelect, + }; +} diff --git a/src/features/git/hooks/useBranchSwitcherShortcut.ts b/src/features/git/hooks/useBranchSwitcherShortcut.ts new file mode 100644 index 000000000..396f59c1a --- /dev/null +++ b/src/features/git/hooks/useBranchSwitcherShortcut.ts @@ -0,0 +1,28 @@ +import { useEffect } from "react"; +import { matchesShortcut } from "../../../utils/shortcuts"; + +type UseBranchSwitcherShortcutOptions = { + shortcut: string | null; + isEnabled: boolean; + onTrigger: () => void; +}; + +export function useBranchSwitcherShortcut({ + shortcut, + isEnabled, + onTrigger, +}: UseBranchSwitcherShortcutOptions) { + useEffect(() => { + if (!isEnabled || !shortcut) { + return; + } + function handleKeyDown(event: KeyboardEvent) { + if (matchesShortcut(event, shortcut)) { + event.preventDefault(); + onTrigger(); + } + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isEnabled, onTrigger, shortcut]); +} diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 4d83f37c7..16a0a4a15 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -36,6 +36,7 @@ const baseSettings: AppSettings = { archiveThreadShortcut: null, toggleProjectsSidebarShortcut: null, toggleGitSidebarShortcut: null, + branchSwitcherShortcut: null, toggleDebugPanelShortcut: null, toggleTerminalShortcut: null, cycleAgentNextShortcut: null, diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index 2761cdcf5..ef697ebc9 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -191,6 +191,7 @@ type ShortcutSettingKey = | "archiveThreadShortcut" | "toggleProjectsSidebarShortcut" | "toggleGitSidebarShortcut" + | "branchSwitcherShortcut" | "toggleDebugPanelShortcut" | "toggleTerminalShortcut" | "cycleAgentNextShortcut" @@ -209,6 +210,7 @@ type ShortcutDraftKey = | "archiveThread" | "projectsSidebar" | "gitSidebar" + | "branchSwitcher" | "debugPanel" | "terminal" | "cycleAgentNext" @@ -230,6 +232,7 @@ const shortcutDraftKeyBySetting: Record = archiveThreadShortcut: "archiveThread", toggleProjectsSidebarShortcut: "projectsSidebar", toggleGitSidebarShortcut: "gitSidebar", + branchSwitcherShortcut: "branchSwitcher", toggleDebugPanelShortcut: "debugPanel", toggleTerminalShortcut: "terminal", cycleAgentNextShortcut: "cycleAgentNext", @@ -380,6 +383,7 @@ export function SettingsView({ archiveThread: appSettings.archiveThreadShortcut ?? "", projectsSidebar: appSettings.toggleProjectsSidebarShortcut ?? "", gitSidebar: appSettings.toggleGitSidebarShortcut ?? "", + branchSwitcher: appSettings.branchSwitcherShortcut ?? "", debugPanel: appSettings.toggleDebugPanelShortcut ?? "", terminal: appSettings.toggleTerminalShortcut ?? "", cycleAgentNext: appSettings.cycleAgentNextShortcut ?? "", @@ -519,6 +523,7 @@ export function SettingsView({ archiveThread: appSettings.archiveThreadShortcut ?? "", projectsSidebar: appSettings.toggleProjectsSidebarShortcut ?? "", gitSidebar: appSettings.toggleGitSidebarShortcut ?? "", + branchSwitcher: appSettings.branchSwitcherShortcut ?? "", debugPanel: appSettings.toggleDebugPanelShortcut ?? "", terminal: appSettings.toggleTerminalShortcut ?? "", cycleAgentNext: appSettings.cycleAgentNextShortcut ?? "", @@ -538,6 +543,7 @@ export function SettingsView({ appSettings.archiveThreadShortcut, appSettings.toggleProjectsSidebarShortcut, appSettings.toggleGitSidebarShortcut, + appSettings.branchSwitcherShortcut, appSettings.toggleDebugPanelShortcut, appSettings.toggleTerminalShortcut, appSettings.cycleAgentNextShortcut, @@ -2305,6 +2311,30 @@ export function SettingsView({ Default: {formatShortcut("cmd+shift+g")}
+
+
Branch switcher
+
+ + handleShortcutKeyDown(event, "branchSwitcherShortcut") + } + placeholder="Type shortcut" + readOnly + /> + +
+
+ Default: {formatShortcut("cmd+b")} +
+
Toggle debug panel
diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index 741fb7681..3c764b00c 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -39,6 +39,7 @@ const defaultSettings: AppSettings = { archiveThreadShortcut: "cmd+ctrl+a", toggleProjectsSidebarShortcut: "cmd+shift+p", toggleGitSidebarShortcut: "cmd+shift+g", + branchSwitcherShortcut: "cmd+b", toggleDebugPanelShortcut: "cmd+shift+d", toggleTerminalShortcut: "cmd+shift+t", cycleAgentNextShortcut: "cmd+ctrl+down", diff --git a/src/styles/branch-switcher-modal.css b/src/styles/branch-switcher-modal.css new file mode 100644 index 000000000..8354c8a90 --- /dev/null +++ b/src/styles/branch-switcher-modal.css @@ -0,0 +1,112 @@ +.branch-switcher-modal { + position: fixed; + inset: 0; + z-index: 40; +} + +.branch-switcher-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(6, 8, 12, 0.55); + backdrop-filter: blur(8px); +} + +.app.reduced-transparency .branch-switcher-modal-backdrop { + backdrop-filter: none; +} + +.branch-switcher-modal-card { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: min(480px, calc(100vw - 48px)); + max-height: calc(100vh - 120px); + background: var(--surface-card-strong); + border: 1px solid var(--border-stronger); + border-radius: 12px; + display: flex; + flex-direction: column; + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35); + overflow: hidden; +} + +.branch-switcher-modal-input { + border: none; + border-bottom: 1px solid var(--border-subtle); + background: transparent; + color: var(--text-strong); + padding: 14px 16px; + font-size: 14px; + outline: none; +} + +.branch-switcher-modal-input::placeholder { + color: var(--text-faint); +} + +.branch-switcher-modal-list { + flex: 1; + overflow-y: auto; + padding: 6px; + max-height: 320px; +} + +.branch-switcher-modal-empty { + padding: 16px; + text-align: center; + color: var(--text-faint); + font-size: 13px; +} + +.branch-switcher-modal-item { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 8px 10px; + border: none; + background: transparent; + color: var(--text-strong); + font-size: 13px; + text-align: left; + border-radius: 6px; + cursor: pointer; + transition: background 0.1s ease; +} + +.branch-switcher-modal-item:hover, +.branch-switcher-modal-item.selected { + background: var(--surface-card-muted); +} + +.branch-switcher-modal-item-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--code-font-family, Menlo, Monaco, "Courier New", monospace); +} + +.branch-switcher-modal-item-meta { + display: flex; + gap: 6px; + margin-left: 8px; + flex-shrink: 0; +} + +.branch-switcher-modal-item-current { + font-size: 11px; + color: var(--text-faint); + background: var(--surface-secondary); + padding: 2px 6px; + border-radius: 4px; +} + +.branch-switcher-modal-item-worktree { + font-size: 11px; + color: #7dd3fc; + background: rgba(125, 211, 252, 0.15); + padding: 2px 6px; + border-radius: 4px; +} diff --git a/src/types.ts b/src/types.ts index c54cbad9c..e2d65d56f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -158,6 +158,7 @@ export type AppSettings = { archiveThreadShortcut: string | null; toggleProjectsSidebarShortcut: string | null; toggleGitSidebarShortcut: string | null; + branchSwitcherShortcut: string | null; toggleDebugPanelShortcut: string | null; toggleTerminalShortcut: string | null; cycleAgentNextShortcut: string | null; From d907a4b214cd58ce23a88b1f4a1b31c295b4f7c0 Mon Sep 17 00:00:00 2001 From: ishanray Date: Tue, 3 Feb 2026 22:59:07 -0500 Subject: [PATCH 2/7] Fix libgit2 branch checkout to avoid half-switched HEAD --- src-tauri/src/git/mod.rs | 5 +++-- src-tauri/src/git_utils.rs | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index aad93508f..f4a9cf0a3 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -8,8 +8,8 @@ use tauri::State; use crate::shared::process_core::tokio_command; use crate::git_utils::{ - checkout_branch, commit_to_entry, diff_patch_to_string, diff_stats_for_path, - image_mime_type, list_git_roots as scan_git_roots, parse_github_repo, resolve_git_root, + checkout_branch, commit_to_entry, diff_patch_to_string, diff_stats_for_path, image_mime_type, + list_git_roots as scan_git_roots, parse_github_repo, resolve_git_root, }; use crate::state::AppState; use crate::types::{ @@ -1497,6 +1497,7 @@ pub(crate) async fn create_git_branch( mod tests { use super::*; use std::fs; + use std::path::Path; fn create_temp_repo() -> (PathBuf, Repository) { let root = std::env::temp_dir().join(format!( diff --git a/src-tauri/src/git_utils.rs b/src-tauri/src/git_utils.rs index 69d431726..6a7a79e1f 100644 --- a/src-tauri/src/git_utils.rs +++ b/src-tauri/src/git_utils.rs @@ -38,10 +38,12 @@ pub(crate) fn commit_to_entry(commit: git2::Commit) -> GitLogEntry { pub(crate) fn checkout_branch(repo: &Repository, name: &str) -> Result<(), git2::Error> { let refname = format!("refs/heads/{name}"); - repo.set_head(&refname)?; + let target = repo.revparse_single(&refname)?; + let mut options = git2::build::CheckoutBuilder::new(); options.safe(); - repo.checkout_head(Some(&mut options))?; + repo.checkout_tree(&target, Some(&mut options))?; + repo.set_head(&refname)?; Ok(()) } @@ -90,7 +92,10 @@ pub(crate) fn diff_patch_to_string(patch: &mut git2::Patch) -> Result Option { From eb587ceb2b0c348cc346a1f366998c11891c9659 Mon Sep 17 00:00:00 2001 From: ishanray Date: Tue, 3 Feb 2026 23:11:16 -0500 Subject: [PATCH 3/7] remove sentry --- package-lock.json | 125 +++--------------- package.json | 1 - src/features/app/components/OpenAppMenu.tsx | 13 -- .../messages/hooks/useFileLinkOpener.ts | 10 -- .../threads/hooks/useThreadActions.test.tsx | 6 - .../threads/hooks/useThreadMessaging.ts | 12 -- src/features/threads/hooks/useThreads.ts | 13 +- .../workspaces/hooks/useWorkspaceSelection.ts | 13 -- .../workspaces/hooks/useWorkspaces.ts | 19 --- src/main.tsx | 18 --- 10 files changed, 23 insertions(+), 207 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9cca12ad1..c41947956 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,9 +7,9 @@ "": { "name": "codex-monitor", "version": "0.7.37", + "hasInstallScript": true, "dependencies": { "@pierre/diffs": "^1.0.6", - "@sentry/react": "^10.36.0", "@tanstack/react-virtual": "^3.13.18", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", @@ -132,6 +132,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -481,6 +482,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -524,6 +526,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1583,97 +1586,6 @@ "win32" ] }, - "node_modules/@sentry-internal/browser-utils": { - "version": "10.36.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.36.0.tgz", - "integrity": "sha512-WILVR8HQBWOxbqLRuTxjzRCMIACGsDTo6jXvzA8rz6ezElElLmIrn3CFAswrESLqEEUa4CQHl5bLgSVJCRNweA==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.36.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/feedback": { - "version": "10.36.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.36.0.tgz", - "integrity": "sha512-zPjz7AbcxEyx8AHj8xvp28fYtPTPWU1XcNtymhAHJLS9CXOblqSC7W02Jxz6eo3eR1/pLyOo6kJBUjvLe9EoFA==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.36.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/replay": { - "version": "10.36.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.36.0.tgz", - "integrity": "sha512-nLMkJgvHq+uCCrQKV2KgSdVHxTsmDk0r2hsAoTcKCbzUpXyW5UhCziMRS6ULjBlzt5sbxoIIplE25ZpmIEeNgg==", - "license": "MIT", - "dependencies": { - "@sentry-internal/browser-utils": "10.36.0", - "@sentry/core": "10.36.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/replay-canvas": { - "version": "10.36.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.36.0.tgz", - "integrity": "sha512-DLGIwmT2LX+O6TyYPtOQL5GiTm2rN0taJPDJ/Lzg2KEJZrdd5sKkzTckhh2x+vr4JQyeaLmnb8M40Ch1hvG/vQ==", - "license": "MIT", - "dependencies": { - "@sentry-internal/replay": "10.36.0", - "@sentry/core": "10.36.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/browser": { - "version": "10.36.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.36.0.tgz", - "integrity": "sha512-yHhXbgdGY1s+m8CdILC9U/II7gb6+s99S2Eh8VneEn/JG9wHc+UOzrQCeFN0phFP51QbLkjkiQbbanjT1HP8UQ==", - "license": "MIT", - "dependencies": { - "@sentry-internal/browser-utils": "10.36.0", - "@sentry-internal/feedback": "10.36.0", - "@sentry-internal/replay": "10.36.0", - "@sentry-internal/replay-canvas": "10.36.0", - "@sentry/core": "10.36.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/core": { - "version": "10.36.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.36.0.tgz", - "integrity": "sha512-EYJjZvofI+D93eUsPLDIUV0zQocYqiBRyXS6CCV6dHz64P/Hob5NJQOwPa8/v6nD+UvJXvwsFfvXOHhYZhZJOQ==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/react": { - "version": "10.36.0", - "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.36.0.tgz", - "integrity": "sha512-k2GwMKgepJLXvEQffQymQyxsTVjsLiY6YXG0bcceM3vulii9Sy29uqGhpqwaPOfM4bPQzUXJzAxS/c9S7n5hTw==", - "license": "MIT", - "dependencies": { - "@sentry/browser": "10.36.0", - "@sentry/core": "10.36.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": "^16.14.0 || 17.x || 18.x || 19.x" - } - }, "node_modules/@shikijs/core": { "version": "3.21.0", "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.21.0.tgz", @@ -1783,6 +1695,7 @@ "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz", "integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==", "license": "Apache-2.0 OR MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/tauri" @@ -2104,8 +2017,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2230,6 +2142,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2240,6 +2153,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2290,6 +2204,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -2611,7 +2526,8 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/acorn": { "version": "8.15.0", @@ -2619,6 +2535,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2702,7 +2619,6 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -2971,6 +2887,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3495,8 +3412,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -3788,6 +3704,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5352,6 +5269,7 @@ "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.7.2", "cssstyle": "^5.3.1", @@ -5557,7 +5475,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6855,6 +6772,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6917,7 +6835,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6933,7 +6850,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -6946,8 +6862,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/prismjs": { "version": "1.30.0", @@ -7016,6 +6931,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7025,6 +6941,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8160,6 +8077,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8349,6 +8267,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index 08e1ff64f..205784d1b 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ }, "dependencies": { "@pierre/diffs": "^1.0.6", - "@sentry/react": "^10.36.0", "@tanstack/react-virtual": "^3.13.18", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", diff --git a/src/features/app/components/OpenAppMenu.tsx b/src/features/app/components/OpenAppMenu.tsx index 06d54f021..616b14628 100644 --- a/src/features/app/components/OpenAppMenu.tsx +++ b/src/features/app/components/OpenAppMenu.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import ChevronDown from "lucide-react/dist/esm/icons/chevron-down"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; -import * as Sentry from "@sentry/react"; import { openWorkspaceIn } from "../../../services/tauri"; import { pushErrorToast } from "../../../services/toasts"; import type { OpenAppTarget } from "../../../types"; @@ -81,18 +80,6 @@ export function OpenAppMenu({ const reportOpenError = (error: unknown, target: OpenTarget) => { const message = error instanceof Error ? error.message : String(error); - Sentry.captureException(error instanceof Error ? error : new Error(message), { - tags: { - feature: "open-app-menu", - }, - extra: { - path, - targetId: target.id, - targetKind: target.target.kind, - targetAppName: target.target.appName ?? null, - targetCommand: target.target.command ?? null, - }, - }); pushErrorToast({ title: "Couldn’t open workspace", message, diff --git a/src/features/messages/hooks/useFileLinkOpener.ts b/src/features/messages/hooks/useFileLinkOpener.ts index 94d3e31ed..a4da2c256 100644 --- a/src/features/messages/hooks/useFileLinkOpener.ts +++ b/src/features/messages/hooks/useFileLinkOpener.ts @@ -4,7 +4,6 @@ import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu"; import { LogicalPosition } from "@tauri-apps/api/dpi"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; -import * as Sentry from "@sentry/react"; import { openWorkspaceIn } from "../../../services/tauri"; import { pushErrorToast } from "../../../services/toasts"; import type { OpenAppTarget } from "../../../types"; @@ -78,15 +77,6 @@ export function useFileLinkOpener( const reportOpenError = useCallback( (error: unknown, context: Record) => { const message = error instanceof Error ? error.message : String(error); - Sentry.captureException( - error instanceof Error ? error : new Error(message), - { - tags: { - feature: "file-link-open", - }, - extra: context, - }, - ); pushErrorToast({ title: "Couldn’t open file", message, diff --git a/src/features/threads/hooks/useThreadActions.test.tsx b/src/features/threads/hooks/useThreadActions.test.tsx index 3f0af7986..03452a903 100644 --- a/src/features/threads/hooks/useThreadActions.test.tsx +++ b/src/features/threads/hooks/useThreadActions.test.tsx @@ -19,12 +19,6 @@ import { import { saveThreadActivity } from "../utils/threadStorage"; import { useThreadActions } from "./useThreadActions"; -vi.mock("@sentry/react", () => ({ - metrics: { - count: vi.fn(), - }, -})); - vi.mock("../../../services/tauri", () => ({ startThread: vi.fn(), forkThread: vi.fn(), diff --git a/src/features/threads/hooks/useThreadMessaging.ts b/src/features/threads/hooks/useThreadMessaging.ts index d01d7f4e5..18e56d514 100644 --- a/src/features/threads/hooks/useThreadMessaging.ts +++ b/src/features/threads/hooks/useThreadMessaging.ts @@ -1,6 +1,5 @@ import { useCallback } from "react"; import type { Dispatch, MutableRefObject } from "react"; -import * as Sentry from "@sentry/react"; import type { AccessMode, RateLimitSnapshot, @@ -159,17 +158,6 @@ export function useThreadMessaging({ }); } } - Sentry.metrics.count("prompt_sent", 1, { - attributes: { - workspace_id: workspace.id, - thread_id: threadId, - has_images: images.length > 0 ? "true" : "false", - text_length: String(finalText.length), - model: resolvedModel ?? "unknown", - effort: resolvedEffort ?? "unknown", - collaboration_mode: sanitizedCollaborationMode ?? "unknown", - }, - }); const timestamp = Date.now(); recordThreadActivity(workspace.id, threadId, timestamp); dispatch({ diff --git a/src/features/threads/hooks/useThreads.ts b/src/features/threads/hooks/useThreads.ts index 2d562789d..308b1a465 100644 --- a/src/features/threads/hooks/useThreads.ts +++ b/src/features/threads/hooks/useThreads.ts @@ -1,5 +1,4 @@ import { useCallback, useMemo, useReducer, useRef } from "react"; -import * as Sentry from "@sentry/react"; import type { CustomPromptOption, DebugEntry, WorkspaceInfo } from "../../../types"; import { useAppServerEvents } from "../../app/hooks/useAppServerEvents"; import { initialState, threadReducer } from "./useThreadsReducer"; @@ -373,22 +372,12 @@ export function useThreads({ if (!targetId) { return; } - const currentThreadId = state.activeThreadIdByWorkspace[targetId] ?? null; dispatch({ type: "setActiveThreadId", workspaceId: targetId, threadId }); - if (threadId && currentThreadId !== threadId) { - Sentry.metrics.count("thread_switched", 1, { - attributes: { - workspace_id: targetId, - thread_id: threadId, - reason: "select", - }, - }); - } if (threadId) { void resumeThreadForWorkspace(targetId, threadId); } }, - [activeWorkspaceId, resumeThreadForWorkspace, state.activeThreadIdByWorkspace], + [activeWorkspaceId, resumeThreadForWorkspace], ); const removeThread = useCallback( diff --git a/src/features/workspaces/hooks/useWorkspaceSelection.ts b/src/features/workspaces/hooks/useWorkspaceSelection.ts index 1d9ef095e..5c6298ede 100644 --- a/src/features/workspaces/hooks/useWorkspaceSelection.ts +++ b/src/features/workspaces/hooks/useWorkspaceSelection.ts @@ -1,5 +1,4 @@ import { useCallback } from "react"; -import * as Sentry from "@sentry/react"; import type { WorkspaceInfo, WorkspaceSettings } from "../../../types"; type UseWorkspaceSelectionOptions = { @@ -25,7 +24,6 @@ type UseWorkspaceSelectionResult = { export function useWorkspaceSelection({ workspaces, isCompact, - activeWorkspaceId, setActiveTab, setActiveWorkspaceId, updateWorkspaceSettings, @@ -41,28 +39,17 @@ export function useWorkspaceSelection({ (workspaceId: string) => { setSelectedDiffPath(null); const target = workspaces.find((entry) => entry.id === workspaceId); - const didSwitch = activeWorkspaceId !== workspaceId; if (target?.settings.sidebarCollapsed) { void updateWorkspaceSettings(workspaceId, { sidebarCollapsed: false, }); } setActiveWorkspaceId(workspaceId); - if (didSwitch) { - Sentry.metrics.count("workspace_switched", 1, { - attributes: { - workspace_id: workspaceId, - workspace_kind: target?.kind ?? "main", - reason: "select", - }, - }); - } if (isCompact) { setActiveTab("codex"); } }, [ - activeWorkspaceId, isCompact, setActiveTab, setActiveWorkspaceId, diff --git a/src/features/workspaces/hooks/useWorkspaces.ts b/src/features/workspaces/hooks/useWorkspaces.ts index 29f1c5e72..19f1ce767 100644 --- a/src/features/workspaces/hooks/useWorkspaces.ts +++ b/src/features/workspaces/hooks/useWorkspaces.ts @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import * as Sentry from "@sentry/react"; import type { AppSettings, DebugEntry, @@ -236,12 +235,6 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { const workspace = await addWorkspaceService(selection, defaultCodexBin ?? null); setWorkspaces((prev) => [...prev, workspace]); setActiveWorkspaceId(workspace.id); - Sentry.metrics.count("workspace_added", 1, { - attributes: { - workspace_id: workspace.id, - workspace_kind: workspace.kind ?? "main", - }, - }); return workspace; } catch (error) { onDebug?.({ @@ -301,12 +294,6 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { if (options?.activate !== false) { setActiveWorkspaceId(workspace.id); } - Sentry.metrics.count("worktree_agent_created", 1, { - attributes: { - workspace_id: workspace.id, - parent_id: parent.id, - }, - }); return workspace; } catch (error) { onDebug?.({ @@ -348,12 +335,6 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { const workspace = await addCloneService(source.id, trimmedFolder, trimmedName); setWorkspaces((prev) => [...prev, workspace]); setActiveWorkspaceId(workspace.id); - Sentry.metrics.count("clone_agent_created", 1, { - attributes: { - workspace_id: workspace.id, - parent_id: source.id, - }, - }); return workspace; } catch (error) { onDebug?.({ diff --git a/src/main.tsx b/src/main.tsx index 21b7dc696..2be325ed2 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,25 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import * as Sentry from "@sentry/react"; import App from "./App"; -const sentryDsn = - import.meta.env.VITE_SENTRY_DSN ?? - "https://8ab67175daed999e8c432a93d8f98e49@o4510750015094784.ingest.us.sentry.io/4510750016012288"; - -Sentry.init({ - dsn: sentryDsn, - enabled: Boolean(sentryDsn), - release: __APP_VERSION__, -}); - -Sentry.metrics.count("app_open", 1, { - attributes: { - env: import.meta.env.MODE, - platform: "macos", - }, -}); - ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( From 597e32e9f98a2b2edd0a97040a1f5360e2296ee0 Mon Sep 17 00:00:00 2001 From: ishanray Date: Wed, 4 Feb 2026 00:51:10 -0500 Subject: [PATCH 4/7] git diff --- src-tauri/src/git/mod.rs | 13 +++++++++ src-tauri/src/types.rs | 11 ++++++++ src/App.tsx | 3 +++ .../app/hooks/useGitPanelController.test.tsx | 1 + .../app/hooks/useGitPanelController.ts | 10 ++++++- src/features/git/components/GitDiffViewer.tsx | 20 +++++++++++++- src/features/git/hooks/useGitCommitDiffs.ts | 18 ++++++++++--- src/features/git/hooks/useGitDiffs.ts | 27 +++++++++++-------- src/features/layout/hooks/useLayoutNodes.tsx | 2 ++ .../settings/components/SettingsView.test.tsx | 1 + .../settings/components/SettingsView.tsx | 21 +++++++++++++++ src/features/settings/hooks/useAppSettings.ts | 1 + src/types.ts | 1 + 13 files changed, 113 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index f4a9cf0a3..4f368942d 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -823,8 +823,13 @@ pub(crate) async fn get_git_diffs( .get(&workspace_id) .ok_or("workspace not found")? .clone(); + drop(workspaces); let repo_root = resolve_git_root(&entry)?; + let ignore_whitespace_changes = { + let settings = state.app_settings.lock().await; + settings.git_diff_ignore_whitespace_changes + }; tokio::task::spawn_blocking(move || { let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; let head_tree = repo @@ -837,6 +842,7 @@ pub(crate) async fn get_git_diffs( .include_untracked(true) .recurse_untracked_dirs(true) .show_untracked_content(true); + options.ignore_whitespace_change(ignore_whitespace_changes); let diff = match head_tree.as_ref() { Some(tree) => repo @@ -1054,6 +1060,12 @@ pub(crate) async fn get_git_commit_diff( .get(&workspace_id) .ok_or("workspace not found")? .clone(); + drop(workspaces); + + let ignore_whitespace_changes = { + let settings = state.app_settings.lock().await; + settings.git_diff_ignore_whitespace_changes + }; let repo_root = resolve_git_root(&entry)?; let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; @@ -1066,6 +1078,7 @@ pub(crate) async fn get_git_commit_diff( .and_then(|parent| parent.tree().ok()); let mut options = DiffOptions::new(); + options.ignore_whitespace_change(ignore_whitespace_changes); let diff = repo .diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut options)) .map_err(|e| e.to_string())?; diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 6e729622f..791450ce6 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -421,6 +421,11 @@ pub(crate) struct AppSettings { pub(crate) notification_sounds_enabled: bool, #[serde(default = "default_preload_git_diffs", rename = "preloadGitDiffs")] pub(crate) preload_git_diffs: bool, + #[serde( + default = "default_git_diff_ignore_whitespace_changes", + rename = "gitDiffIgnoreWhitespaceChanges" + )] + pub(crate) git_diff_ignore_whitespace_changes: bool, #[serde( default = "default_system_notifications_enabled", rename = "systemNotificationsEnabled" @@ -634,6 +639,10 @@ fn default_preload_git_diffs() -> bool { true } +fn default_git_diff_ignore_whitespace_changes() -> bool { + false +} + fn default_experimental_collab_enabled() -> bool { false } @@ -805,6 +814,7 @@ impl Default for AppSettings { notification_sounds_enabled: true, system_notifications_enabled: true, preload_git_diffs: default_preload_git_diffs(), + git_diff_ignore_whitespace_changes: default_git_diff_ignore_whitespace_changes(), experimental_collab_enabled: false, collaboration_modes_enabled: true, experimental_steer_enabled: false, @@ -907,6 +917,7 @@ mod tests { assert!(settings.notification_sounds_enabled); assert!(settings.system_notifications_enabled); assert!(settings.preload_git_diffs); + assert!(!settings.git_diff_ignore_whitespace_changes); assert!(settings.collaboration_modes_enabled); assert!(!settings.experimental_steer_enabled); assert!(!settings.experimental_apps_enabled); diff --git a/src/App.tsx b/src/App.tsx index df65eae6a..0561cea48 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -112,6 +112,7 @@ import { useOpenAppIcons } from "./features/app/hooks/useOpenAppIcons"; import { useCodeCssVars } from "./features/app/hooks/useCodeCssVars"; import { useAccountSwitching } from "./features/app/hooks/useAccountSwitching"; import { useNewAgentDraft } from "./features/app/hooks/useNewAgentDraft"; +import { useSystemNotificationThreadLinks } from "./features/app/hooks/useSystemNotificationThreadLinks"; const AboutView = lazy(() => import("./features/about/components/AboutView").then((module) => ({ @@ -266,6 +267,7 @@ function MainApp() { [workspacesById], ); + const recordPendingThreadLinkRef = useRef< const { updaterState, startUpdate, @@ -726,6 +728,7 @@ function MainApp() { activeThreadIdRef.current = activeThreadId ?? null; }, [activeThreadId]); + const { recordPendingThreadLink } = useSystemNotificationThreadLinks({ useAutoExitEmptyDiff({ centerMode, autoExitEnabled: diffSource === "local", diff --git a/src/features/app/hooks/useGitPanelController.test.tsx b/src/features/app/hooks/useGitPanelController.test.tsx index 8b95c75ed..9f2ee9081 100644 --- a/src/features/app/hooks/useGitPanelController.test.tsx +++ b/src/features/app/hooks/useGitPanelController.test.tsx @@ -37,6 +37,7 @@ function makeProps(overrides?: Partial[ return { activeWorkspace: workspace, gitDiffPreloadEnabled: false, + gitDiffIgnoreWhitespaceChanges: false, isCompact: false, isTablet: false, activeTab: "codex" as const, diff --git a/src/features/app/hooks/useGitPanelController.ts b/src/features/app/hooks/useGitPanelController.ts index f0df462c1..414d749b8 100644 --- a/src/features/app/hooks/useGitPanelController.ts +++ b/src/features/app/hooks/useGitPanelController.ts @@ -8,6 +8,7 @@ import { useGitCommitDiffs } from "../../git/hooks/useGitCommitDiffs"; export function useGitPanelController({ activeWorkspace, gitDiffPreloadEnabled, + gitDiffIgnoreWhitespaceChanges, isCompact, isTablet, activeTab, @@ -19,6 +20,7 @@ export function useGitPanelController({ }: { activeWorkspace: WorkspaceInfo | null; gitDiffPreloadEnabled: boolean; + gitDiffIgnoreWhitespaceChanges: boolean; isCompact: boolean; isTablet: boolean; activeTab: "projects" | "codex" | "git" | "log"; @@ -116,7 +118,12 @@ export function useGitPanelController({ isLoading: isDiffLoading, error: diffError, refresh: refreshGitDiffs, - } = useGitDiffs(activeWorkspace, gitStatus.files, shouldLoadLocalDiffs); + } = useGitDiffs( + activeWorkspace, + gitStatus.files, + shouldLoadLocalDiffs, + gitDiffIgnoreWhitespaceChanges, + ); useEffect(() => { if (!activeWorkspace || !shouldPreloadDiffs) { @@ -155,6 +162,7 @@ export function useGitPanelController({ activeWorkspace, selectedCommitSha, shouldLoadDiffs && diffSource === "commit", + gitDiffIgnoreWhitespaceChanges, ); const activeDiffs = diff --git a/src/features/git/components/GitDiffViewer.tsx b/src/features/git/components/GitDiffViewer.tsx index 72562f17e..a92fa7e35 100644 --- a/src/features/git/components/GitDiffViewer.tsx +++ b/src/features/git/components/GitDiffViewer.tsx @@ -27,6 +27,7 @@ type GitDiffViewerProps = { isLoading: boolean; error: string | null; diffStyle?: "split" | "unified"; + ignoreWhitespaceChanges?: boolean; pullRequest?: GitHubPullRequest | null; pullRequestComments?: GitHubPullRequestComment[]; pullRequestCommentsLoading?: boolean; @@ -102,12 +103,16 @@ type DiffCardProps = { entry: GitDiffViewerItem; isSelected: boolean; diffStyle: "split" | "unified"; + isLoading: boolean; + ignoreWhitespaceChanges: boolean; }; const DiffCard = memo(function DiffCard({ entry, isSelected, diffStyle, + isLoading, + ignoreWhitespaceChanges, }: DiffCardProps) { const diffOptions = useMemo( () => ({ @@ -140,6 +145,16 @@ const DiffCard = memo(function DiffCard({ } satisfies FileDiffMetadata; }, [entry.diff, entry.path]); + const placeholder = useMemo(() => { + if (isLoading) { + return "Loading diff..."; + } + if (ignoreWhitespaceChanges && !entry.diff.trim()) { + return "No non-whitespace changes."; + } + return "Diff unavailable."; + }, [entry.diff, ignoreWhitespaceChanges, isLoading]); + return (
) : ( -
Diff unavailable.
+
{placeholder}
)}
); @@ -362,6 +377,7 @@ export function GitDiffViewer({ isLoading, error, diffStyle = "split", + ignoreWhitespaceChanges = false, pullRequest, pullRequestComments, pullRequestCommentsLoading = false, @@ -646,6 +662,8 @@ export function GitDiffViewer({ entry={entry} isSelected={entry.path === selectedPath} diffStyle={diffStyle} + isLoading={isLoading} + ignoreWhitespaceChanges={ignoreWhitespaceChanges} /> )}
diff --git a/src/features/git/hooks/useGitCommitDiffs.ts b/src/features/git/hooks/useGitCommitDiffs.ts index 5c4ca5623..05a13c34f 100644 --- a/src/features/git/hooks/useGitCommitDiffs.ts +++ b/src/features/git/hooks/useGitCommitDiffs.ts @@ -18,11 +18,13 @@ export function useGitCommitDiffs( activeWorkspace: WorkspaceInfo | null, sha: string | null, enabled: boolean, + ignoreWhitespaceChanges: boolean, ) { const [state, setState] = useState(emptyState); const requestIdRef = useRef(0); const workspaceIdRef = useRef(activeWorkspace?.id ?? null); const shaRef = useRef(sha ?? null); + const ignoreWhitespaceChangesRef = useRef(ignoreWhitespaceChanges); const refresh = useCallback(async () => { if (!activeWorkspace || !sha) { @@ -38,7 +40,8 @@ export function useGitCommitDiffs( if ( requestIdRef.current !== requestId || workspaceIdRef.current !== workspaceId || - shaRef.current !== sha + shaRef.current !== sha || + ignoreWhitespaceChangesRef.current !== ignoreWhitespaceChanges ) { return; } @@ -48,7 +51,8 @@ export function useGitCommitDiffs( if ( requestIdRef.current !== requestId || workspaceIdRef.current !== workspaceId || - shaRef.current !== sha + shaRef.current !== sha || + ignoreWhitespaceChangesRef.current !== ignoreWhitespaceChanges ) { return; } @@ -58,7 +62,7 @@ export function useGitCommitDiffs( error: error instanceof Error ? error.message : String(error), }); } - }, [activeWorkspace, sha]); + }, [activeWorkspace, ignoreWhitespaceChanges, sha]); useEffect(() => { const workspaceId = activeWorkspace?.id ?? null; @@ -77,6 +81,14 @@ export function useGitCommitDiffs( } }, [sha]); + useEffect(() => { + if (ignoreWhitespaceChangesRef.current !== ignoreWhitespaceChanges) { + ignoreWhitespaceChangesRef.current = ignoreWhitespaceChanges; + requestIdRef.current += 1; + setState(emptyState); + } + }, [ignoreWhitespaceChanges]); + useEffect(() => { if (!enabled) { return; diff --git a/src/features/git/hooks/useGitDiffs.ts b/src/features/git/hooks/useGitDiffs.ts index 8bcfd8236..abf1e30ad 100644 --- a/src/features/git/hooks/useGitDiffs.ts +++ b/src/features/git/hooks/useGitDiffs.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { GitFileDiff, GitFileStatus, WorkspaceInfo } from "../../../types"; import { getGitDiffs } from "../../../services/tauri"; +import type { GitFileDiff, GitFileStatus, WorkspaceInfo } from "../../../types"; type GitDiffState = { diffs: GitFileDiff[]; @@ -18,10 +18,11 @@ export function useGitDiffs( activeWorkspace: WorkspaceInfo | null, files: GitFileStatus[], enabled: boolean, + ignoreWhitespaceChanges: boolean, ) { const [state, setState] = useState(emptyState); const requestIdRef = useRef(0); - const workspaceIdRef = useRef(activeWorkspace?.id ?? null); + const cacheKeyRef = useRef(null); const cachedDiffsRef = useRef>(new Map()); const fileKey = useMemo( @@ -42,6 +43,7 @@ export function useGitDiffs( return; } const workspaceId = activeWorkspace.id; + const cacheKey = `${workspaceId}|ignoreWhitespaceChanges:${ignoreWhitespaceChanges ? "1" : "0"}`; const requestId = requestIdRef.current + 1; requestIdRef.current = requestId; setState((prev) => ({ ...prev, isLoading: true, error: null })); @@ -49,17 +51,17 @@ export function useGitDiffs( const diffs = await getGitDiffs(workspaceId); if ( requestIdRef.current !== requestId || - workspaceIdRef.current !== workspaceId + cacheKeyRef.current !== cacheKey ) { return; } setState({ diffs, isLoading: false, error: null }); - cachedDiffsRef.current.set(workspaceId, diffs); + cachedDiffsRef.current.set(cacheKey, diffs); } catch (error) { console.error("Failed to load git diffs", error); if ( requestIdRef.current !== requestId || - workspaceIdRef.current !== workspaceId + cacheKeyRef.current !== cacheKey ) { return; } @@ -69,25 +71,28 @@ export function useGitDiffs( error: error instanceof Error ? error.message : String(error), }); } - }, [activeWorkspace]); + }, [activeWorkspace, ignoreWhitespaceChanges]); useEffect(() => { const workspaceId = activeWorkspace?.id ?? null; - if (workspaceIdRef.current !== workspaceId) { - workspaceIdRef.current = workspaceId; + const nextCacheKey = workspaceId + ? `${workspaceId}|ignoreWhitespaceChanges:${ignoreWhitespaceChanges ? "1" : "0"}` + : null; + if (cacheKeyRef.current !== nextCacheKey) { + cacheKeyRef.current = nextCacheKey; requestIdRef.current += 1; - if (!workspaceId) { + if (!nextCacheKey) { setState(emptyState); return; } - const cached = cachedDiffsRef.current.get(workspaceId); + const cached = cachedDiffsRef.current.get(nextCacheKey); setState({ diffs: cached ?? [], isLoading: false, error: null, }); } - }, [activeWorkspace?.id]); + }, [activeWorkspace?.id, ignoreWhitespaceChanges]); useEffect(() => { if (!enabled) { diff --git a/src/features/layout/hooks/useLayoutNodes.tsx b/src/features/layout/hooks/useLayoutNodes.tsx index 2ce8065e7..4eed44394 100644 --- a/src/features/layout/hooks/useLayoutNodes.tsx +++ b/src/features/layout/hooks/useLayoutNodes.tsx @@ -229,6 +229,7 @@ type LayoutNodesOptions = { gitPanelMode: "diff" | "log" | "issues" | "prs"; onGitPanelModeChange: (mode: "diff" | "log" | "issues" | "prs") => void; gitDiffViewStyle: "split" | "unified"; + gitDiffIgnoreWhitespaceChanges: boolean; worktreeApplyLabel: string; worktreeApplyTitle: string | null; worktreeApplyLoading: boolean; @@ -871,6 +872,7 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { isLoading={options.gitDiffLoading} error={options.gitDiffError} diffStyle={options.gitDiffViewStyle} + ignoreWhitespaceChanges={options.gitDiffIgnoreWhitespaceChanges} pullRequest={options.selectedPullRequest} pullRequestComments={options.selectedPullRequestComments} pullRequestCommentsLoading={options.selectedPullRequestCommentsLoading} diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 16a0a4a15..ba21683c5 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -56,6 +56,7 @@ const baseSettings: AppSettings = { notificationSoundsEnabled: true, systemNotificationsEnabled: true, preloadGitDiffs: true, + gitDiffIgnoreWhitespaceChanges: false, experimentalCollabEnabled: false, collaborationModesEnabled: true, experimentalSteerEnabled: false, diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index ef697ebc9..762f9ae36 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -2721,6 +2721,27 @@ export function SettingsView({ +
+
+
Ignore whitespace changes
+
+ Hides whitespace-only changes in local and commit diffs. +
+
+ +
)} {activeSection === "codex" && ( diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index 3c764b00c..5fc483f25 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -57,6 +57,7 @@ const defaultSettings: AppSettings = { notificationSoundsEnabled: true, systemNotificationsEnabled: true, preloadGitDiffs: true, + gitDiffIgnoreWhitespaceChanges: false, experimentalCollabEnabled: false, collaborationModesEnabled: true, experimentalSteerEnabled: false, diff --git a/src/types.ts b/src/types.ts index e2d65d56f..3eb3d800f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -176,6 +176,7 @@ export type AppSettings = { notificationSoundsEnabled: boolean; systemNotificationsEnabled: boolean; preloadGitDiffs: boolean; + gitDiffIgnoreWhitespaceChanges: boolean; experimentalCollabEnabled: boolean; collaborationModesEnabled: boolean; experimentalSteerEnabled: boolean; From e1f6f3225948c1fd882a7fccf2ab44123ee3b1d7 Mon Sep 17 00:00:00 2001 From: ishanray Date: Wed, 4 Feb 2026 01:00:25 -0500 Subject: [PATCH 5/7] feat(notifications): deep-link threads via system notification metadata --- src/App.tsx | 26 ++++ .../useSystemNotificationThreadLinks.test.tsx | 101 +++++++++++++++ .../hooks/useSystemNotificationThreadLinks.ts | 120 ++++++++++++++++++ .../app/hooks/useUpdaterController.ts | 3 + .../hooks/useAgentSystemNotifications.ts | 41 ++++-- src/hooks/useDebouncedValue.ts | 11 +- src/services/tauri.test.ts | 16 +++ src/services/tauri.ts | 30 ++++- 8 files changed, 337 insertions(+), 11 deletions(-) create mode 100644 src/features/app/hooks/useSystemNotificationThreadLinks.test.tsx create mode 100644 src/features/app/hooks/useSystemNotificationThreadLinks.ts diff --git a/src/App.tsx b/src/App.tsx index 0561cea48..de6bd82d6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -268,6 +268,9 @@ function MainApp() { ); const recordPendingThreadLinkRef = useRef< + (workspaceId: string, threadId: string) => void + >(() => {}); + const { updaterState, startUpdate, @@ -278,6 +281,8 @@ function MainApp() { notificationSoundsEnabled: appSettings.notificationSoundsEnabled, systemNotificationsEnabled: appSettings.systemNotificationsEnabled, getWorkspaceName, + onThreadNotificationSent: (workspaceId, threadId) => + recordPendingThreadLinkRef.current(workspaceId, threadId), onDebug: addDebugEntry, successSoundUrl, errorSoundUrl, @@ -359,6 +364,7 @@ function MainApp() { } = useGitPanelController({ activeWorkspace, gitDiffPreloadEnabled: appSettings.preloadGitDiffs, + gitDiffIgnoreWhitespaceChanges: appSettings.gitDiffIgnoreWhitespaceChanges, isCompact, isTablet, activeTab, @@ -729,6 +735,24 @@ function MainApp() { }, [activeThreadId]); const { recordPendingThreadLink } = useSystemNotificationThreadLinks({ + hasLoadedWorkspaces: hasLoaded, + workspacesById, + refreshWorkspaces, + connectWorkspace, + setActiveTab, + setCenterMode, + setSelectedDiffPath, + setActiveWorkspaceId, + setActiveThreadId, + }); + + useEffect(() => { + recordPendingThreadLinkRef.current = recordPendingThreadLink; + return () => { + recordPendingThreadLinkRef.current = () => {}; + }; + }, [recordPendingThreadLink]); + useAutoExitEmptyDiff({ centerMode, autoExitEnabled: diffSource === "local", @@ -1882,6 +1906,8 @@ function MainApp() { gitPanelMode, onGitPanelModeChange: handleGitPanelModeChange, gitDiffViewStyle, + gitDiffIgnoreWhitespaceChanges: + appSettings.gitDiffIgnoreWhitespaceChanges && diffSource !== "pr", worktreeApplyLabel: "apply", worktreeApplyTitle: activeParentWorkspace?.name ? `Apply changes to ${activeParentWorkspace.name}` diff --git a/src/features/app/hooks/useSystemNotificationThreadLinks.test.tsx b/src/features/app/hooks/useSystemNotificationThreadLinks.test.tsx new file mode 100644 index 000000000..c1bf9d96d --- /dev/null +++ b/src/features/app/hooks/useSystemNotificationThreadLinks.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceInfo } from "../../../types"; +import { useSystemNotificationThreadLinks } from "./useSystemNotificationThreadLinks"; + +function makeWorkspace(overrides: Partial = {}): WorkspaceInfo { + return { + id: "ws-1", + name: "Workspace", + path: "/tmp/workspace", + connected: true, + settings: { sidebarCollapsed: false }, + ...overrides, + }; +} + +describe("useSystemNotificationThreadLinks", () => { + it("navigates to the thread when the app regains focus", async () => { + const workspace = makeWorkspace({ connected: true }); + const workspacesById = new Map([[workspace.id, workspace]]); + + const refreshWorkspaces = vi.fn(async () => [workspace]); + const connectWorkspace = vi.fn(async () => {}); + const setActiveTab = vi.fn(); + const setCenterMode = vi.fn(); + const setSelectedDiffPath = vi.fn(); + const setActiveWorkspaceId = vi.fn(); + const setActiveThreadId = vi.fn(); + + const { result } = renderHook(() => + useSystemNotificationThreadLinks({ + hasLoadedWorkspaces: true, + workspacesById, + refreshWorkspaces, + connectWorkspace, + setActiveTab, + setCenterMode, + setSelectedDiffPath, + setActiveWorkspaceId, + setActiveThreadId, + }), + ); + + act(() => { + result.current.recordPendingThreadLink("ws-1", "t-1"); + }); + + await act(async () => { + window.dispatchEvent(new Event("focus")); + await Promise.resolve(); + }); + + expect(setCenterMode).toHaveBeenCalledWith("chat"); + expect(setSelectedDiffPath).toHaveBeenCalledWith(null); + expect(setActiveTab).toHaveBeenCalledWith("codex"); + expect(setActiveWorkspaceId).toHaveBeenCalledWith("ws-1"); + expect(setActiveThreadId).toHaveBeenCalledWith("t-1", "ws-1"); + expect(connectWorkspace).not.toHaveBeenCalled(); + expect(refreshWorkspaces).not.toHaveBeenCalled(); + }); + + it("connects the workspace before selecting the thread when needed", async () => { + const workspace = makeWorkspace({ connected: false }); + const workspacesById = new Map([[workspace.id, workspace]]); + + const refreshWorkspaces = vi.fn(async () => [workspace]); + const connectWorkspace = vi.fn(async () => {}); + const setActiveTab = vi.fn(); + const setCenterMode = vi.fn(); + const setSelectedDiffPath = vi.fn(); + const setActiveWorkspaceId = vi.fn(); + const setActiveThreadId = vi.fn(); + + const { result } = renderHook(() => + useSystemNotificationThreadLinks({ + hasLoadedWorkspaces: true, + workspacesById, + refreshWorkspaces, + connectWorkspace, + setActiveTab, + setCenterMode, + setSelectedDiffPath, + setActiveWorkspaceId, + setActiveThreadId, + }), + ); + + act(() => { + result.current.recordPendingThreadLink("ws-1", "t-1"); + }); + + await act(async () => { + window.dispatchEvent(new Event("focus")); + await Promise.resolve(); + }); + + expect(connectWorkspace).toHaveBeenCalledTimes(1); + expect(setActiveThreadId).toHaveBeenCalledWith("t-1", "ws-1"); + }); +}); diff --git a/src/features/app/hooks/useSystemNotificationThreadLinks.ts b/src/features/app/hooks/useSystemNotificationThreadLinks.ts new file mode 100644 index 000000000..7f6edc6e4 --- /dev/null +++ b/src/features/app/hooks/useSystemNotificationThreadLinks.ts @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import type { WorkspaceInfo } from "../../../types"; + +type ThreadDeepLink = { + workspaceId: string; + threadId: string; + notifiedAt: number; +}; + +type Params = { + hasLoadedWorkspaces: boolean; + workspacesById: Map; + refreshWorkspaces: () => Promise; + connectWorkspace: (workspace: WorkspaceInfo) => Promise; + setActiveTab: (tab: "projects" | "codex" | "git" | "log") => void; + setCenterMode: (mode: "chat" | "diff") => void; + setSelectedDiffPath: (path: string | null) => void; + setActiveWorkspaceId: (workspaceId: string | null) => void; + setActiveThreadId: (threadId: string | null, workspaceId?: string) => void; + maxAgeMs?: number; +}; + +type Result = { + recordPendingThreadLink: (workspaceId: string, threadId: string) => void; +}; + +export function useSystemNotificationThreadLinks({ + hasLoadedWorkspaces, + workspacesById, + refreshWorkspaces, + connectWorkspace, + setActiveTab, + setCenterMode, + setSelectedDiffPath, + setActiveWorkspaceId, + setActiveThreadId, + maxAgeMs = 120_000, +}: Params): Result { + const pendingLinkRef = useRef(null); + const refreshInFlightRef = useRef(false); + + const recordPendingThreadLink = useCallback((workspaceId: string, threadId: string) => { + pendingLinkRef.current = { workspaceId, threadId, notifiedAt: Date.now() }; + }, []); + + const tryNavigateToLink = useCallback(async () => { + const link = pendingLinkRef.current; + if (!link) { + return; + } + if (Date.now() - link.notifiedAt > maxAgeMs) { + pendingLinkRef.current = null; + return; + } + + setCenterMode("chat"); + setSelectedDiffPath(null); + setActiveTab("codex"); + + let workspace = workspacesById.get(link.workspaceId) ?? null; + if (!workspace && hasLoadedWorkspaces && !refreshInFlightRef.current) { + refreshInFlightRef.current = true; + try { + const refreshed = await refreshWorkspaces(); + workspace = + refreshed?.find((entry) => entry.id === link.workspaceId) ?? null; + } finally { + refreshInFlightRef.current = false; + } + } + + if (!workspace) { + pendingLinkRef.current = null; + return; + } + + if (!workspace.connected) { + try { + await connectWorkspace(workspace); + } catch { + // Ignore connect failures; user can retry manually. + } + } + + setActiveWorkspaceId(link.workspaceId); + setActiveThreadId(link.threadId, link.workspaceId); + pendingLinkRef.current = null; + }, [ + connectWorkspace, + hasLoadedWorkspaces, + maxAgeMs, + refreshWorkspaces, + setActiveTab, + setActiveThreadId, + setActiveWorkspaceId, + setCenterMode, + setSelectedDiffPath, + workspacesById, + ]); + + const focusHandler = useMemo(() => () => void tryNavigateToLink(), [tryNavigateToLink]); + + useEffect(() => { + window.addEventListener("focus", focusHandler); + return () => window.removeEventListener("focus", focusHandler); + }, [focusHandler]); + + useEffect(() => { + if (!pendingLinkRef.current) { + return; + } + if (!hasLoadedWorkspaces) { + return; + } + void tryNavigateToLink(); + }, [hasLoadedWorkspaces, tryNavigateToLink]); + + return { recordPendingThreadLink }; +} + diff --git a/src/features/app/hooks/useUpdaterController.ts b/src/features/app/hooks/useUpdaterController.ts index acb0927a3..324f2f2e4 100644 --- a/src/features/app/hooks/useUpdaterController.ts +++ b/src/features/app/hooks/useUpdaterController.ts @@ -13,6 +13,7 @@ type Params = { notificationSoundsEnabled: boolean; systemNotificationsEnabled: boolean; getWorkspaceName?: (workspaceId: string) => string | undefined; + onThreadNotificationSent?: (workspaceId: string, threadId: string) => void; onDebug: (entry: DebugEntry) => void; successSoundUrl: string; errorSoundUrl: string; @@ -22,6 +23,7 @@ export function useUpdaterController({ notificationSoundsEnabled, systemNotificationsEnabled, getWorkspaceName, + onThreadNotificationSent, onDebug, successSoundUrl, errorSoundUrl, @@ -62,6 +64,7 @@ export function useUpdaterController({ enabled: systemNotificationsEnabled, isWindowFocused, getWorkspaceName, + onThreadNotificationSent, onDebug, }); diff --git a/src/features/notifications/hooks/useAgentSystemNotifications.ts b/src/features/notifications/hooks/useAgentSystemNotifications.ts index 544e43ba2..3b1335871 100644 --- a/src/features/notifications/hooks/useAgentSystemNotifications.ts +++ b/src/features/notifications/hooks/useAgentSystemNotifications.ts @@ -11,6 +11,7 @@ type SystemNotificationOptions = { isWindowFocused: boolean; minDurationMs?: number; getWorkspaceName?: (workspaceId: string) => string | undefined; + onThreadNotificationSent?: (workspaceId: string, threadId: string) => void; onDebug?: (entry: DebugEntry) => void; }; @@ -34,6 +35,7 @@ export function useAgentSystemNotifications({ isWindowFocused, minDurationMs = DEFAULT_MIN_DURATION_MS, getWorkspaceName, + onThreadNotificationSent, onDebug, }: SystemNotificationOptions) { const turnStartById = useRef(new Map()); @@ -42,9 +44,17 @@ export function useAgentSystemNotifications({ const lastMessageByThread = useRef(new Map()); const notify = useCallback( - async (title: string, body: string, label: "success" | "error") => { + async ( + title: string, + body: string, + label: "success" | "error", + extra?: Record, + ) => { try { - await sendNotification(title, body); + await sendNotification(title, body, { + autoCancel: true, + extra, + }); onDebug?.({ id: `${Date.now()}-client-notification-${label}`, timestamp: Date.now(), @@ -162,10 +172,15 @@ export function useAgentSystemNotifications({ threadId, "Your agent has finished its task.", ); - void notify(title, body, "success"); + onThreadNotificationSent?.(workspaceId, threadId); + void notify(title, body, "success", { + kind: "thread", + workspaceId, + threadId, + }); lastMessageByThread.current.delete(threadKey); }, - [consumeDuration, getNotificationContent, notify, shouldNotify], + [consumeDuration, getNotificationContent, notify, onThreadNotificationSent, shouldNotify], ); const handleTurnError = useCallback( @@ -185,10 +200,15 @@ export function useAgentSystemNotifications({ } const title = getWorkspaceName?.(workspaceId) ?? "Agent Error"; const body = payload.message || "An error occurred."; - void notify(title, truncateText(body, MAX_BODY_LENGTH), "error"); + onThreadNotificationSent?.(workspaceId, threadId); + void notify(title, truncateText(body, MAX_BODY_LENGTH), "error", { + kind: "thread", + workspaceId, + threadId, + }); lastMessageByThread.current.delete(threadKey); }, - [consumeDuration, getWorkspaceName, notify, shouldNotify], + [consumeDuration, getWorkspaceName, notify, onThreadNotificationSent, shouldNotify], ); const handleItemStarted = useCallback( @@ -221,10 +241,15 @@ export function useAgentSystemNotifications({ event.threadId, "Your agent has finished its task.", ); - void notify(title, body, "success"); + onThreadNotificationSent?.(event.workspaceId, event.threadId); + void notify(title, body, "success", { + kind: "thread", + workspaceId: event.workspaceId, + threadId: event.threadId, + }); lastMessageByThread.current.delete(threadKey); }, - [consumeDuration, getNotificationContent, notify, shouldNotify], + [consumeDuration, getNotificationContent, notify, onThreadNotificationSent, shouldNotify], ); const handlers = useMemo( diff --git a/src/hooks/useDebouncedValue.ts b/src/hooks/useDebouncedValue.ts index 8b93f4e1c..b47896b09 100644 --- a/src/hooks/useDebouncedValue.ts +++ b/src/hooks/useDebouncedValue.ts @@ -8,10 +8,17 @@ export function useDebouncedValue(value: T, delayMs = 150): T { setDebounced(value); return; } - const handle = window.setTimeout(() => { + let cancelled = false; + const handle = globalThis.setTimeout(() => { + if (cancelled) { + return; + } setDebounced(value); }, delayMs); - return () => window.clearTimeout(handle); + return () => { + cancelled = true; + globalThis.clearTimeout(handle); + }; }, [delayMs, value]); return debounced; diff --git a/src/services/tauri.test.ts b/src/services/tauri.test.ts index 841c74769..e8de425cd 100644 --- a/src/services/tauri.test.ts +++ b/src/services/tauri.test.ts @@ -390,6 +390,22 @@ describe("tauri invoke wrappers", () => { }); }); + it("passes extra metadata when provided", async () => { + const isPermissionGrantedMock = vi.mocked(notification.isPermissionGranted); + const sendNotificationMock = vi.mocked(notification.sendNotification); + isPermissionGrantedMock.mockResolvedValueOnce(true); + + await sendNotification("Hello", "World", { + extra: { kind: "thread", workspaceId: "ws-1", threadId: "t-1" }, + }); + + expect(sendNotificationMock).toHaveBeenCalledWith({ + title: "Hello", + body: "World", + extra: { kind: "thread", workspaceId: "ws-1", threadId: "t-1" }, + }); + }); + it("requests permission once when needed and sends on grant", async () => { const isPermissionGrantedMock = vi.mocked(notification.isPermissionGranted); const requestPermissionMock = vi.mocked(notification.requestPermission); diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 9b527b4c1..49936e254 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -1,5 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; +import type { Options as NotificationOptions } from "@tauri-apps/plugin-notification"; import type { AppSettings, CodexDoctorResult, @@ -767,6 +768,14 @@ export async function generateCommitMessage( export async function sendNotification( title: string, body: string, + options?: { + id?: number; + group?: string; + actionTypeId?: string; + sound?: string; + autoCancel?: boolean; + extra?: Record; + }, ): Promise { const macosDebugBuild = await invoke("is_macos_debug_build").catch( () => false, @@ -801,7 +810,26 @@ export async function sendNotification( } } if (permissionGranted) { - await notification.sendNotification({ title, body }); + const payload: NotificationOptions = { title, body }; + if (options?.id !== undefined) { + payload.id = options.id; + } + if (options?.group !== undefined) { + payload.group = options.group; + } + if (options?.actionTypeId !== undefined) { + payload.actionTypeId = options.actionTypeId; + } + if (options?.sound !== undefined) { + payload.sound = options.sound; + } + if (options?.autoCancel !== undefined) { + payload.autoCancel = options.autoCancel; + } + if (options?.extra !== undefined) { + payload.extra = options.extra; + } + await notification.sendNotification(payload); return; } } catch (error) { From cfa33ac0adb11a35f3a0d6b9b515f2ece4233f1a Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Wed, 4 Feb 2026 07:10:45 +0100 Subject: [PATCH 6/7] fix(git): scope branch switcher worktrees to active repo --- src/App.tsx | 1 + src/features/app/components/AppModals.tsx | 3 + src/features/app/components/MainHeader.tsx | 111 ++++++------------ src/features/git/components/BranchList.tsx | 78 ++++++++++++ .../components/BranchSwitcherPrompt.test.tsx | 74 ++++++++++++ .../git/components/BranchSwitcherPrompt.tsx | 102 ++++++++-------- src/features/git/utils/branchSearch.test.ts | 32 +++++ src/features/git/utils/branchSearch.ts | 46 ++++++++ .../git/utils/branchValidation.test.ts | 16 +++ src/features/git/utils/branchValidation.ts | 32 +++++ 10 files changed, 369 insertions(+), 126 deletions(-) create mode 100644 src/features/git/components/BranchList.tsx create mode 100644 src/features/git/components/BranchSwitcherPrompt.test.tsx create mode 100644 src/features/git/utils/branchSearch.test.ts create mode 100644 src/features/git/utils/branchSearch.ts create mode 100644 src/features/git/utils/branchValidation.test.ts create mode 100644 src/features/git/utils/branchValidation.ts diff --git a/src/App.tsx b/src/App.tsx index de6bd82d6..6bab4ecd3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2291,6 +2291,7 @@ function MainApp() { branchSwitcher={branchSwitcher} branches={branches} workspaces={workspaces} + activeWorkspace={activeWorkspace} currentBranch={currentBranch} onBranchSwitcherSelect={handleBranchSelect} onBranchSwitcherCancel={closeBranchSwitcher} diff --git a/src/features/app/components/AppModals.tsx b/src/features/app/components/AppModals.tsx index 620480554..750d8566c 100644 --- a/src/features/app/components/AppModals.tsx +++ b/src/features/app/components/AppModals.tsx @@ -54,6 +54,7 @@ type AppModalsProps = { branchSwitcher: BranchSwitcherState; branches: BranchInfo[]; workspaces: WorkspaceInfo[]; + activeWorkspace: WorkspaceInfo | null; currentBranch: string | null; onBranchSwitcherSelect: (branch: string, worktree: WorkspaceInfo | null) => void; onBranchSwitcherCancel: () => void; @@ -84,6 +85,7 @@ export const AppModals = memo(function AppModals({ branchSwitcher, branches, workspaces, + activeWorkspace, currentBranch, onBranchSwitcherSelect, onBranchSwitcherCancel, @@ -146,6 +148,7 @@ export const AppModals = memo(function AppModals({ - trimmedQuery.length > 0 - ? branches.filter((branch) => - branch.name.toLowerCase().includes(lowercaseQuery), - ) - : branches.slice(0, 12), - [branches, lowercaseQuery, trimmedQuery], + () => filterBranches(branches, branchQuery, { mode: "includes", whenEmptyLimit: 12 }), + [branches, branchQuery], ); const exactMatch = useMemo( - () => - trimmedQuery - ? branches.find((branch) => branch.name === trimmedQuery) ?? null - : null, + () => findExactBranch(branches, trimmedQuery), [branches, trimmedQuery], ); const canCreate = trimmedQuery.length > 0 && !exactMatch; - const branchValidationMessage = useMemo(() => { - if (trimmedQuery.length === 0) { - return null; - } - if (trimmedQuery === "." || trimmedQuery === "..") { - return "Branch name cannot be '.' or '..'."; - } - if (/\s/.test(trimmedQuery)) { - return "Branch name cannot contain spaces."; - } - if (trimmedQuery.startsWith("/") || trimmedQuery.endsWith("/")) { - return "Branch name cannot start or end with '/'."; - } - if (trimmedQuery.endsWith(".lock")) { - return "Branch name cannot end with '.lock'."; - } - if (trimmedQuery.includes("..")) { - return "Branch name cannot contain '..'."; - } - if (trimmedQuery.includes("@{")) { - return "Branch name cannot contain '@{'."; - } - const invalidChars = ["~", "^", ":", "?", "*", "[", "\\"]; - if (invalidChars.some((char) => trimmedQuery.includes(char))) { - return "Branch name contains invalid characters."; - } - if (trimmedQuery.endsWith(".")) { - return "Branch name cannot end with '.'."; - } - return null; - }, [trimmedQuery]); + const branchValidationMessage = useMemo( + () => validateBranchName(trimmedQuery), + [trimmedQuery], + ); const resolvedWorktreePath = worktreePath ?? workspace.path; const relativeWorktreePath = useMemo(() => { if (!parentPath) { @@ -485,39 +452,31 @@ export function MainHeader({ )} -
- {filteredBranches.map((branch) => ( - - ))} - {filteredBranches.length === 0 && ( -
No branches found
- )} -
+ { + if (branch.name === branchName) { + return; + } + try { + await onCheckoutBranch(branch.name); + setMenuOpen(false); + setBranchQuery(""); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }} + /> {error &&
{error}
} )} diff --git a/src/features/git/components/BranchList.tsx b/src/features/git/components/BranchList.tsx new file mode 100644 index 000000000..768c622a0 --- /dev/null +++ b/src/features/git/components/BranchList.tsx @@ -0,0 +1,78 @@ +import type { ReactNode, Ref } from "react"; +import type { BranchInfo } from "../../../types"; + +type BranchListProps = { + branches: BranchInfo[]; + currentBranch: string | null; + selectedIndex?: number; + listClassName: string; + listRef?: Ref; + itemClassName: string; + itemLabelClassName?: string; + selectedItemClassName?: string; + currentItemClassName?: string; + emptyClassName: string; + emptyText: string; + listRole?: string; + itemRole?: string; + itemDataTauriDragRegion?: string; + renderMeta?: (branch: BranchInfo) => ReactNode; + onMouseEnter?: (index: number) => void; + onSelect: (branch: BranchInfo) => void; +}; + +export function BranchList({ + branches, + currentBranch, + selectedIndex, + listClassName, + listRef, + itemClassName, + itemLabelClassName, + selectedItemClassName, + currentItemClassName, + emptyClassName, + emptyText, + listRole, + itemRole, + itemDataTauriDragRegion, + renderMeta, + onMouseEnter, + onSelect, +}: BranchListProps) { + return ( +
+ {branches.length === 0 &&
{emptyText}
} + {branches.map((branch, index) => { + const isCurrent = branch.name === currentBranch; + const isSelected = selectedIndex === index; + const className = [ + itemClassName, + isSelected && selectedItemClassName ? selectedItemClassName : "", + isCurrent && currentItemClassName ? currentItemClassName : "", + ] + .filter(Boolean) + .join(" "); + + return ( + + ); + })} +
+ ); +} diff --git a/src/features/git/components/BranchSwitcherPrompt.test.tsx b/src/features/git/components/BranchSwitcherPrompt.test.tsx new file mode 100644 index 000000000..3580c08a2 --- /dev/null +++ b/src/features/git/components/BranchSwitcherPrompt.test.tsx @@ -0,0 +1,74 @@ +/** @vitest-environment jsdom */ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { BranchInfo, WorkspaceInfo } from "../../../types"; +import { BranchSwitcherPrompt } from "./BranchSwitcherPrompt"; + +const baseSettings: WorkspaceInfo["settings"] = { + sidebarCollapsed: false, +}; + +Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + value: vi.fn(), + writable: true, +}); + +function createWorkspace( + overrides: Partial & Pick, +): WorkspaceInfo { + return { + id: overrides.id, + name: overrides.name, + path: overrides.path, + connected: overrides.connected ?? true, + kind: overrides.kind ?? "main", + parentId: overrides.parentId ?? null, + worktree: overrides.worktree ?? null, + settings: overrides.settings ?? baseSettings, + }; +} + +const branches: BranchInfo[] = [{ name: "develop", lastCommit: 0 }]; + +describe("BranchSwitcherPrompt", () => { + it("prefers worktrees that belong to the active workspace repo", () => { + const activeMain = createWorkspace({ + id: "main-a", + name: "Repo A", + path: "/tmp/repo-a", + kind: "main", + }); + const matchingWorktree = createWorkspace({ + id: "wt-a-develop", + name: "A develop", + path: "/tmp/repo-a-develop", + kind: "worktree", + parentId: "main-a", + worktree: { branch: "develop" }, + }); + const unrelatedWorktree = createWorkspace({ + id: "wt-b-develop", + name: "B develop", + path: "/tmp/repo-b-develop", + kind: "worktree", + parentId: "main-b", + worktree: { branch: "develop" }, + }); + const onSelect = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /develop/i })); + + expect(onSelect).toHaveBeenCalledWith("develop", matchingWorktree); + }); +}); diff --git a/src/features/git/components/BranchSwitcherPrompt.tsx b/src/features/git/components/BranchSwitcherPrompt.tsx index a625848c4..ccd0914e0 100644 --- a/src/features/git/components/BranchSwitcherPrompt.tsx +++ b/src/features/git/components/BranchSwitcherPrompt.tsx @@ -1,33 +1,36 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { BranchInfo, WorkspaceInfo } from "../../../types"; +import { BranchList } from "./BranchList"; +import { filterBranches } from "../utils/branchSearch"; type BranchSwitcherPromptProps = { branches: BranchInfo[]; workspaces: WorkspaceInfo[]; + activeWorkspace: WorkspaceInfo | null; currentBranch: string | null; onSelect: (branch: string, worktreeWorkspace: WorkspaceInfo | null) => void; onCancel: () => void; }; -function fuzzyMatch(query: string, target: string): boolean { - const q = query.toLowerCase(); - const t = target.toLowerCase(); - let qi = 0; - for (let ti = 0; ti < t.length && qi < q.length; ti++) { - if (t[ti] === q[qi]) { - qi++; - } - } - return qi === q.length; -} - function getWorktreeByBranch( workspaces: WorkspaceInfo[], + activeWorkspace: WorkspaceInfo | null, branch: string, ): WorkspaceInfo | null { + const activeRepoWorkspaceId = activeWorkspace + ? activeWorkspace.kind === "worktree" + ? activeWorkspace.parentId ?? null + : activeWorkspace.id + : null; + if (!activeRepoWorkspaceId) { + return null; + } return ( workspaces.find( - (ws) => ws.kind === "worktree" && ws.worktree?.branch === branch, + (ws) => + ws.kind === "worktree" && + ws.parentId === activeRepoWorkspaceId && + ws.worktree?.branch === branch, ) ?? null ); } @@ -35,6 +38,7 @@ function getWorktreeByBranch( export function BranchSwitcherPrompt({ branches, workspaces, + activeWorkspace, currentBranch, onSelect, onCancel, @@ -49,10 +53,7 @@ export function BranchSwitcherPrompt({ }, []); const filteredBranches = useMemo(() => { - if (!query.trim()) { - return branches; - } - return branches.filter((branch) => fuzzyMatch(query.trim(), branch.name)); + return filterBranches(branches, query, { mode: "fuzzy" }); }, [branches, query]); useEffect(() => { @@ -67,7 +68,7 @@ export function BranchSwitcherPrompt({ }, [selectedIndex]); const handleSelect = (branch: BranchInfo) => { - const worktree = getWorktreeByBranch(workspaces, branch.name); + const worktree = getWorktreeByBranch(workspaces, activeWorkspace, branch.name); onSelect(branch.name, worktree); }; @@ -111,41 +112,42 @@ export function BranchSwitcherPrompt({ onKeyDown={handleKeyDown} placeholder="Search branches..." /> -
- {filteredBranches.length === 0 && ( -
No branches found
- )} - {filteredBranches.map((branch, index) => { - const isSelected = index === selectedIndex; + { const isCurrent = branch.name === currentBranch; - const worktree = getWorktreeByBranch(workspaces, branch.name); + const worktree = getWorktreeByBranch( + workspaces, + activeWorkspace, + branch.name, + ); return ( - + + {isCurrent && ( + + current + + )} + {worktree && ( + + worktree + + )} + ); - })} -
+ }} + /> ); diff --git a/src/features/git/utils/branchSearch.test.ts b/src/features/git/utils/branchSearch.test.ts new file mode 100644 index 000000000..e777157d7 --- /dev/null +++ b/src/features/git/utils/branchSearch.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import type { BranchInfo } from "../../../types"; +import { filterBranches, findExactBranch, fuzzyMatch } from "./branchSearch"; + +const branches: BranchInfo[] = [ + { name: "main", lastCommit: 1 }, + { name: "develop", lastCommit: 2 }, + { name: "feature/add-login", lastCommit: 3 }, +]; + +describe("branchSearch", () => { + it("supports fuzzy matching", () => { + expect(fuzzyMatch("fal", "feature/add-login")).toBe(true); + expect(fuzzyMatch("fzl", "feature/add-login")).toBe(false); + }); + + it("filters with includes mode and empty limit", () => { + expect( + filterBranches(branches, "dev", { mode: "includes" }).map((branch) => branch.name), + ).toEqual(["develop"]); + expect( + filterBranches(branches, "", { mode: "includes", whenEmptyLimit: 2 }).map( + (branch) => branch.name, + ), + ).toEqual(["main", "develop"]); + }); + + it("finds exact branch by trimmed query", () => { + expect(findExactBranch(branches, " develop ")?.name).toBe("develop"); + expect(findExactBranch(branches, "missing")).toBeNull(); + }); +}); diff --git a/src/features/git/utils/branchSearch.ts b/src/features/git/utils/branchSearch.ts new file mode 100644 index 000000000..097cb427d --- /dev/null +++ b/src/features/git/utils/branchSearch.ts @@ -0,0 +1,46 @@ +import type { BranchInfo } from "../../../types"; + +export type BranchMatchMode = "fuzzy" | "includes"; + +export function fuzzyMatch(query: string, target: string): boolean { + const q = query.toLowerCase(); + const t = target.toLowerCase(); + let qi = 0; + for (let ti = 0; ti < t.length && qi < q.length; ti++) { + if (t[ti] === q[qi]) { + qi++; + } + } + return qi === q.length; +} + +export function includesMatch(query: string, target: string): boolean { + return target.toLowerCase().includes(query.toLowerCase()); +} + +export function filterBranches( + branches: BranchInfo[], + query: string, + options?: { mode?: BranchMatchMode; whenEmptyLimit?: number }, +): BranchInfo[] { + const trimmed = query.trim(); + const mode = options?.mode ?? "includes"; + if (trimmed.length === 0) { + const limit = options?.whenEmptyLimit; + return typeof limit === "number" ? branches.slice(0, limit) : branches; + } + + const matcher = mode === "fuzzy" ? fuzzyMatch : includesMatch; + return branches.filter((branch) => matcher(trimmed, branch.name)); +} + +export function findExactBranch( + branches: BranchInfo[], + query: string, +): BranchInfo | null { + const trimmed = query.trim(); + if (!trimmed) { + return null; + } + return branches.find((branch) => branch.name === trimmed) ?? null; +} diff --git a/src/features/git/utils/branchValidation.test.ts b/src/features/git/utils/branchValidation.test.ts new file mode 100644 index 000000000..981b21d64 --- /dev/null +++ b/src/features/git/utils/branchValidation.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { validateBranchName } from "./branchValidation"; + +describe("validateBranchName", () => { + it("returns null for valid names", () => { + expect(validateBranchName("feature/add-login")).toBeNull(); + expect(validateBranchName(" release/v1 ")).toBeNull(); + }); + + it("rejects invalid names", () => { + expect(validateBranchName(".")).toContain("cannot be '.' or '..'"); + expect(validateBranchName("hello world")).toContain("cannot contain spaces"); + expect(validateBranchName("feature..oops")).toContain("cannot contain '..'"); + expect(validateBranchName("topic@{x")).toContain("cannot contain '@{'"); + }); +}); diff --git a/src/features/git/utils/branchValidation.ts b/src/features/git/utils/branchValidation.ts new file mode 100644 index 000000000..642481caa --- /dev/null +++ b/src/features/git/utils/branchValidation.ts @@ -0,0 +1,32 @@ +export function validateBranchName(name: string): string | null { + const trimmed = name.trim(); + if (trimmed.length === 0) { + return null; + } + if (trimmed === "." || trimmed === "..") { + return "Branch name cannot be '.' or '..'."; + } + if (/\s/.test(trimmed)) { + return "Branch name cannot contain spaces."; + } + if (trimmed.startsWith("/") || trimmed.endsWith("/")) { + return "Branch name cannot start or end with '/'."; + } + if (trimmed.endsWith(".lock")) { + return "Branch name cannot end with '.lock'."; + } + if (trimmed.includes("..")) { + return "Branch name cannot contain '..'."; + } + if (trimmed.includes("@{")) { + return "Branch name cannot contain '@{'."; + } + const invalidChars = ["~", "^", ":", "?", "*", "[", "\\"]; + if (invalidChars.some((char) => trimmed.includes(char))) { + return "Branch name contains invalid characters."; + } + if (trimmed.endsWith(".")) { + return "Branch name cannot end with '.'."; + } + return null; +} From 600ae7e6065a8adfc6511965322fc8795944d0b0 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Wed, 4 Feb 2026 07:16:26 +0100 Subject: [PATCH 7/7] fix: disable branch switcher for worktree workspaces --- src/App.tsx | 7 +++---- src/features/git/hooks/useBranchSwitcher.ts | 17 ++++++----------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6bab4ecd3..985d7e4b9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -493,15 +493,14 @@ function MainApp() { handleBranchSelect, } = useBranchSwitcher({ activeWorkspace, - workspaces, - branches, - currentBranch, checkoutBranch: handleCheckoutBranch, setActiveWorkspaceId, }); + const isBranchSwitcherEnabled = + Boolean(activeWorkspace?.connected) && activeWorkspace?.kind !== "worktree"; useBranchSwitcherShortcut({ shortcut: appSettings.branchSwitcherShortcut, - isEnabled: Boolean(activeWorkspace?.connected), + isEnabled: isBranchSwitcherEnabled, onTrigger: openBranchSwitcher, }); const alertError = useCallback((error: unknown) => { diff --git a/src/features/git/hooks/useBranchSwitcher.ts b/src/features/git/hooks/useBranchSwitcher.ts index 0bb10df97..b6de74be3 100644 --- a/src/features/git/hooks/useBranchSwitcher.ts +++ b/src/features/git/hooks/useBranchSwitcher.ts @@ -1,11 +1,8 @@ import { useCallback, useState } from "react"; -import type { BranchInfo, WorkspaceInfo } from "../../../types"; +import type { WorkspaceInfo } from "../../../types"; type UseBranchSwitcherOptions = { activeWorkspace: WorkspaceInfo | null; - workspaces: WorkspaceInfo[]; - branches: BranchInfo[]; - currentBranch: string | null; checkoutBranch: (name: string) => Promise; setActiveWorkspaceId: (id: string) => void; }; @@ -16,16 +13,17 @@ export type BranchSwitcherState = { export function useBranchSwitcher({ activeWorkspace, - workspaces, - branches, - currentBranch, checkoutBranch, setActiveWorkspaceId, }: UseBranchSwitcherOptions) { const [branchSwitcher, setBranchSwitcher] = useState(null); const openBranchSwitcher = useCallback(() => { - if (!activeWorkspace) { + if ( + !activeWorkspace || + !activeWorkspace.connected || + activeWorkspace.kind === "worktree" + ) { return; } setBranchSwitcher({ isOpen: true }); @@ -49,9 +47,6 @@ export function useBranchSwitcher({ return { branchSwitcher, - branches, - workspaces, - currentBranch, openBranchSwitcher, closeBranchSwitcher, handleBranchSelect,