From 9e21423a6e90e211ba9a153d6c42251e6acf76b4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:09:57 -0400 Subject: [PATCH 1/7] fix(ssh): support Cmd+P file search for non-git folders without ripgrep (#1065) --- src/main/ipc/filesystem.ts | 6 +- src/main/ipc/worktrees.ts | 11 +-- src/relay/fs-handler-readdir-fallback.ts | 82 +++++++++++++++++++++++ src/relay/fs-handler.ts | 19 +++++- src/renderer/src/components/QuickOpen.tsx | 14 +++- 5 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 src/relay/fs-handler-readdir-fallback.ts diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 475fcc988b4..e59f9126141 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -440,8 +440,12 @@ export function registerFilesystemHandlers(store: Store): void { ): Promise => { if (args.connectionId) { const provider = getSshFilesystemProvider(args.connectionId) + // Why: when the SSH connection is not yet established (cold start) or + // temporarily disconnected, return [] so quick-open shows "No matching + // files" instead of an error banner. The file list will repopulate when + // the user re-opens quick-open after the connection is restored. if (!provider) { - throw new Error(`No filesystem provider for connection "${args.connectionId}"`) + return [] } return provider.listFiles(args.rootPath) } diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index a96fb70f3d7..134fc4a3e3c 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -99,11 +99,14 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store gitWorktrees = [createFolderWorktree(repo)] } else if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) - // Why: when SSH is disconnected the provider is null. Throwing here - // makes the renderer's fetchWorktrees catch block preserve its cached - // worktree list instead of replacing it with an empty array. + // Why: when SSH is disconnected the provider is null. Return [] so the + // renderer's fetchWorktrees guard (`worktrees.length === 0 && current.length > 0`) + // preserves its cached worktree list. This avoids a console error on every + // fetchAllWorktrees cycle while the connection is being (re-)established — + // worktrees will be properly populated when the SSH `connected` event fires + // and triggers a re-fetch. if (!provider) { - throw new Error(`SSH connection "${repo.connectionId}" is not active`) + return [] } gitWorktrees = await provider.listWorktrees(repo.path) } else { diff --git a/src/relay/fs-handler-readdir-fallback.ts b/src/relay/fs-handler-readdir-fallback.ts new file mode 100644 index 00000000000..2d07a01f1bb --- /dev/null +++ b/src/relay/fs-handler-readdir-fallback.ts @@ -0,0 +1,82 @@ +/** + * Plain readdir-based file listing fallback. + * + * Why: when neither ripgrep nor git is available (e.g. a non-git folder on a + * remote machine without rg), we still need to list files for quick-open. + * This walks the directory tree using Node's fs.readdir, respecting the same + * blocklist and timeout constraints as the git-based fallback. + */ +import { readdir } from 'fs/promises' +import { join, relative } from 'path' + +const MAX_FILES = 10_000 +const TIMEOUT_MS = 10_000 + +// Why: mirrors the HIDDEN_DIR_BLOCKLIST in fs-handler-git-fallback.ts — +// tool-generated dirs that clutter quick-open. User-authored dotdirs like +// .github/ and .devcontainer/ are intentionally kept discoverable. +const HIDDEN_DIR_BLOCKLIST = new Set([ + '.git', + '.next', + '.nuxt', + '.cache', + '.stably', + '.vscode', + '.idea', + '.yarn', + '.pnpm-store', + '.terraform', + '.docker', + '.husky' +]) + +function shouldDescend(name: string): boolean { + if (name === 'node_modules' || HIDDEN_DIR_BLOCKLIST.has(name)) { + return false + } + return true +} + +/** + * Recursively list files under `rootPath` using fs.readdir. + * Returns relative POSIX paths, capped at MAX_FILES with a timeout. + */ +export async function listFilesWithReaddir(rootPath: string): Promise { + const files: string[] = [] + const deadline = Date.now() + TIMEOUT_MS + + async function walk(dir: string): Promise { + if (files.length >= MAX_FILES || Date.now() > deadline) { + return + } + + let entries + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch { + // Permission denied, symlink loop, etc. — skip silently. + return + } + + for (const entry of entries) { + if (files.length >= MAX_FILES || Date.now() > deadline) { + return + } + + const name = entry.name + if (entry.isDirectory()) { + if (shouldDescend(name)) { + await walk(join(dir, name)) + } + } else if (entry.isFile()) { + // Why: path.relative() returns backslashes on Windows. The quick-open + // UI assumes POSIX separators for display and fuzzy matching. + const relPath = relative(rootPath, join(dir, name)).replace(/\\/g, '/') + files.push(relPath) + } + } + } + + await walk(rootPath) + return files +} diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index fd65c5e0c3c..bb2ca22974c 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -11,6 +11,7 @@ import { realpath } from 'fs/promises' import { extname } from 'path' +import { execFile } from 'child_process' import type { RelayDispatcher } from './dispatcher' import type { RelayContext } from './context' import { expandTilde } from './context' @@ -24,6 +25,7 @@ import { checkRgAvailable } from './fs-handler-utils' import { listFilesWithGit, searchWithGitGrep } from './fs-handler-git-fallback' +import { listFilesWithReaddir } from './fs-handler-readdir-fallback' type WatchState = { rootPath: string @@ -228,10 +230,23 @@ export class FsHandler { const rootPath = expandTilde(params.rootPath as string) await this.context.validatePathResolved(rootPath) const rgAvailable = await checkRgAvailable() - if (!rgAvailable) { + if (rgAvailable) { + return listFilesWithRg(rootPath) + } + // Why: git ls-files only works inside git repos. Use rev-parse to detect + // git ancestry — unlike checking for a local .git entry, this works from + // subdirectories of a checkout (e.g. /repo/packages/app added as a folder). + // Without this, a git subdirectory would fall through to readdir and + // surface .gitignore'd build artifacts. + const isGitRepo = await new Promise((resolve) => { + execFile('git', ['rev-parse', '--is-inside-work-tree'], { cwd: rootPath }, (err) => + resolve(!err) + ) + }) + if (isGitRepo) { return listFilesWithGit(rootPath) } - return listFilesWithRg(rootPath) + return listFilesWithReaddir(rootPath) } private async watch(params: Record) { diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index ce249e03996..9d2e74b95b9 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -92,9 +92,19 @@ export default function QuickOpen(): React.JSX.Element | null { () => getConnectionId(activeWorktreeId ?? null) ?? undefined, [activeWorktreeId] ) + + // Why: when quick-open opens before the SSH connection is established, + // fs:listFiles returns [] (no provider yet). Watching the active target's + // connection status lets the file-load effect re-fire automatically once + // that specific connection comes up, without being affected by unrelated + // SSH targets reconnecting. + const activeTargetStatus = useAppStore((s) => + connectionId ? s.sshConnectionStates.get(connectionId)?.status : undefined + ) const filesRequestKey = useMemo( - () => `${worktreePath ?? ''}\n${connectionId ?? ''}\n${excludePathsKey}`, - [connectionId, excludePathsKey, worktreePath] + () => + `${worktreePath ?? ''}\n${connectionId ?? ''}\n${excludePathsKey}\n${activeTargetStatus ?? ''}`, + [connectionId, excludePathsKey, worktreePath, activeTargetStatus] ) // Why: reset input only on open. Keeping this out of the file-load effect From 5766defd878e23d93b1f332c2fa3ed11ecd59e7e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:14:06 -0700 Subject: [PATCH 2/7] feat(ssh): surface SSH status in worktree UI and streamline add-repo flow (#1067) - Show SSH icon (Globe/WifiOff) beside worktree titles in jump palette and WorktreeCard so remote state is visible at a glance. - Replace inline add-repo flow in NewWorkspaceComposerCard with the existing add-repo modal for a single entry point. - When no SSH targets are configured, let users jump straight to SSH settings from the add-repo dialog, and disable remote path until a target is selected. --- .../components/NewWorkspaceComposerCard.tsx | 34 ++++--------------- .../src/components/WorktreeJumpPalette.tsx | 23 ++++++++++++- .../src/components/sidebar/AddRepoDialog.tsx | 5 +++ .../src/components/sidebar/AddRepoSteps.tsx | 23 +++++++++---- .../src/components/sidebar/WorktreeCard.tsx | 34 +++++++++---------- src/renderer/src/store/slices/ui.ts | 1 + 6 files changed, 68 insertions(+), 52 deletions(-) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index ccc31aa4f72..3b27aab1996 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -19,7 +19,6 @@ import { AGENT_CATALOG } from '@/lib/agent-catalog' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import type { TuiAgent } from '../../../shared/types' -import { isGitRepoKind } from '../../../shared/repo-kind' const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') @@ -190,11 +189,9 @@ export default function NewWorkspaceComposerCard({ createError }: NewWorkspaceComposerCardProps): React.JSX.Element { const { isFileDragOver, dragHandlers } = useComposerFileDragOver() - const addRepo = useAppStore((s) => s.addRepo) - const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) + const openModal = useAppStore((s) => s.openModal) const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) const updateSettings = useAppStore((s) => s.updateSettings) - const [isAddingRepo, setIsAddingRepo] = React.useState(false) const handleSetDefaultAgent = React.useCallback( (next: TuiAgent | 'blank' | null) => { @@ -218,25 +215,9 @@ export default function NewWorkspaceComposerCard({ [detectedAgentIds] ) - const handleAddRepo = React.useCallback(async (): Promise => { - if (isAddingRepo) { - return - } - setIsAddingRepo(true) - try { - const repo = await addRepo() - if (!repo) { - return - } - if (isGitRepoKind(repo)) { - await fetchWorktrees(repo.id) - } - onRepoChange(repo.id) - focusNameInput() - } finally { - setIsAddingRepo(false) - } - }, [addRepo, fetchWorktrees, focusNameInput, isAddingRepo, onRepoChange]) + const handleAddRepo = React.useCallback((): void => { + openModal('add-repo') + }, [openModal]) return (
void handleAddRepo()} + onClick={handleAddRepo} className="size-5 shrink-0 rounded-sm text-muted-foreground hover:text-foreground" - aria-label={ - isAddingRepo ? 'Adding folder or repository' : 'Add folder or repository' - } + aria-label="Add folder or repository" > diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index d9db09828c4..4c8b14920a5 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -1,7 +1,7 @@ /* oxlint-disable max-lines */ import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' -import { Globe, Plus } from 'lucide-react' +import { Globe, Plus, WifiOff } from 'lucide-react' import { useAppStore } from '@/store' import { getRepoMapFromState, useAllWorktrees } from '@/store/selectors' import { @@ -145,6 +145,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId) const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree) const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const [query, setQuery] = useState('') const deferredQuery = useDeferredValue(query) @@ -705,6 +706,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ) const statusLabel = getWorktreeStatusLabel(status) const isCurrentWorktree = activeWorktreeId === worktree.id + const sshConnectionId = repo?.connectionId ?? null + const sshStatus = sshConnectionId + ? (sshConnectionStates.get(sshConnectionId)?.status ?? 'disconnected') + : null + const isSshDisconnected = sshStatus != null && sshStatus !== 'connected' return (
+ {sshConnectionId && ( + + {isSshDisconnected ? ( + + )} {entry.match.displayNameRange ? ( { + closeModal() + openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' }) + openSettingsPage() + }} /> ) : step === 'clone' ? ( void onRemotePathChange: (value: string) => void onAdd: () => void + onOpenSshSettings: () => void } export function RemoteStep({ @@ -158,7 +159,8 @@ export function RemoteStep({ isAddingRemote, onSelectTarget, onRemotePathChange, - onAdd + onAdd, + onOpenSshSettings }: RemoteStepProps): React.JSX.Element { const [browsing, setBrowsing] = useState(false) @@ -197,9 +199,18 @@ export function RemoteStep({
{sshTargets.length === 0 ? ( -

- No SSH targets configured. Add one in Settings first. -

+
+

No SSH targets configured.

+ +
) : (
{sshTargets.map((target) => { @@ -252,7 +263,7 @@ export function RemoteStep({ }} placeholder="/home/user/project" className="h-8 text-xs flex-1" - disabled={isAddingRemote} + disabled={isAddingRemote || !selectedTargetId} /> - - - {`Toggle sidebar (${isMac ? '⌘B' : 'Ctrl+B'})`} - - - )} - {settings?.showTitlebarAgentActivity !== false ? ( - - - - - -
0 ? ' titlebar-agent-hovercard-header-with-list' : ''}`} - > - {activeAgentCount === 0 - ? 'No agents active' - : `${activeAgentCount} ${activeAgentCount === 1 ? 'agent' : 'agents'} active`} -
- {activeAgentCount > 0 && ( -
- {Object.entries(workingAgentsPerWorktree).map(([worktreeId, { agents }]) => { - const wt = findWorktreeById(worktreesByRepo, worktreeId) - // Why: when a transient git error causes worktreesByRepo to - // lose a worktree, the raw worktreeId (uuid::path) is not - // useful. Extract a cross-platform path basename as a - // readable fallback. - const sepIdx = worktreeId.indexOf('::') - const pathPart = sepIdx !== -1 ? worktreeId.slice(sepIdx + 2) : worktreeId - const fallbackName = pathPart.split(/[\\/]/).pop() || pathPart - return ( -
- - {agents.map((agent) => ( - - ))} -
- ) - })} -
- )} - -
-
- ) : null} - {/* Why: Back/Forward navigate worktree-activation history. Only - meaningful while viewing a worktree (terminal view); hidden in - Settings/Tasks/Landing to keep the titlebar compact and the - semantics unambiguous. */} - {activeView === 'terminal' && ( - <> +
+
+
+ {showSidebar && ( + + + {`Toggle sidebar (${isMac ? '⌘B' : 'Ctrl+B'})`} + + + )} + {settings?.showTitlebarAgentActivity !== false ? ( + + + + + +
0 ? ' titlebar-agent-hovercard-header-with-list' : ''}`} + > + {activeAgentCount === 0 + ? 'No agents active' + : `${activeAgentCount} ${activeAgentCount === 1 ? 'agent' : 'agents'} active`} +
+ {activeAgentCount > 0 && ( +
+ {Object.entries(workingAgentsPerWorktree).map(([worktreeId, { agents }]) => { + const wt = findWorktreeById(worktreesByRepo, worktreeId) + // Why: when a transient git error causes worktreesByRepo to + // lose a worktree, the raw worktreeId (uuid::path) is not + // useful. Extract a cross-platform path basename as a + // readable fallback. + const sepIdx = worktreeId.indexOf('::') + const pathPart = sepIdx !== -1 ? worktreeId.slice(sepIdx + 2) : worktreeId + const fallbackName = pathPart.split(/[\\/]/).pop() || pathPart + return ( +
+ + {agents.map((agent) => ( + + ))} +
+ ) + })} +
+ )} + +
+
+ ) : null} +
+ {/* Why: Back/Forward navigate worktree-activation history. Only + meaningful while viewing a worktree (terminal view); hidden in + Settings/Tasks/Landing to keep the titlebar compact and the + semantics unambiguous. The group sits outside the measured inner + container so the sidebar-collapse spacer stays sized to the left + controls only. */} + {activeView === 'terminal' && ( +
+ + + @@ -797,19 +801,19 @@ function App(): React.JSX.Element { {`Go forward (${isMac ? '⌘⌥→' : 'Ctrl+Alt+→'})`} - +
)}
) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index b49f9eba549..756b1b2c55c 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -369,6 +369,13 @@ color: var(--foreground); } +/* Why: tighter padding for the back/forward pair so the arrows sit + close together as a single navigation group, while the standard + .sidebar-toggle spacing stays unchanged for other titlebar icons. */ +.sidebar-toggle-compact { + padding: 4px 5px; +} + .sidebar-toggle:disabled { cursor: default; opacity: 0.45; From 68ceae531de39a2bda980e651810c3a490f0260d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:39:16 -0700 Subject: [PATCH 7/7] feat(composer): default blank workspace name to a unique marine-creature (#1069) Use the existing getSuggestedCreatureName helper as the fallback seed in getWorkspaceSeedName so blank submissions get a distinct, readable name scoped to the selected repo (respecting nestWorkspaces) instead of colliding on the "workspace" literal. --- src/renderer/src/hooks/useComposerState.ts | 19 ++++++++++++++--- src/renderer/src/lib/new-workspace.test.ts | 24 ++++++++++++++++++++++ src/renderer/src/lib/new-workspace.ts | 10 ++++++++- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 49a4ba0e983..4d6c1501e7c 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -33,6 +33,7 @@ import { renderIssueCommandTemplate, type LinkedWorkItemSummary } from '@/lib/new-workspace' +import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions' export type UseComposerStateOptions = { initialRepoId?: string @@ -169,6 +170,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const activeRepoId = useAppStore((s) => s.activeRepoId) const settings = useAppStore((s) => s.settings) const newWorkspaceDraft = useAppStore((s) => s.newWorkspaceDraft) + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos]) const draftRepoId = persistDraft ? (newWorkspaceDraft?.repoId ?? null) : null @@ -320,15 +322,24 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const isSetupCheckPending = Boolean(repoId) && checkedHooksRepoId !== repoId const shouldWaitForSetupCheck = Boolean(selectedRepo) && isSetupCheckPending + // Why: when the user leaves the workspace name blank and provides no other + // seed source (prompt, linked issue/PR), pick a repo-scoped unique marine + // creature name so the workspace gets a distinct, readable identifier + // instead of colliding on a literal "workspace" default. + const fallbackCreatureName = useMemo( + () => getSuggestedCreatureName(repoId, worktreesByRepo, settings?.nestWorkspaces ?? true), + [repoId, worktreesByRepo, settings?.nestWorkspaces] + ) const workspaceSeedName = useMemo( () => getWorkspaceSeedName({ explicitName: name, prompt: agentPrompt, linkedIssueNumber: parsedLinkedIssueNumber, - linkedPR + linkedPR, + fallbackName: fallbackCreatureName }), - [agentPrompt, linkedPR, name, parsedLinkedIssueNumber] + [agentPrompt, fallbackCreatureName, linkedPR, name, parsedLinkedIssueNumber] ) // Why: when the user links an issue/PR but has not typed any prompt text // (attachments don't count), swap the generic "Linked work items:" context @@ -989,7 +1000,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS explicitName: name, prompt: '', linkedIssueNumber: null, - linkedPR: null + linkedPR: null, + fallbackName: fallbackCreatureName }) if ( !repoId || @@ -1057,6 +1069,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS applyWorktreeMeta, clearNewWorkspaceDraft, createWorktree, + fallbackCreatureName, name, note, onCreated, diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index 2eecf0c34e3..b9ddee7ff3d 100644 --- a/src/renderer/src/lib/new-workspace.test.ts +++ b/src/renderer/src/lib/new-workspace.test.ts @@ -88,4 +88,28 @@ describe('getWorkspaceSeedName', () => { }) ).toBe('workspace') }) + + it('uses the fallback name when no other seed source is available', () => { + expect( + getWorkspaceSeedName({ + explicitName: '', + prompt: '', + linkedIssueNumber: null, + linkedPR: null, + fallbackName: 'Nautilus' + }) + ).toBe('Nautilus') + }) + + it('prefers an explicit name over the fallback name', () => { + expect( + getWorkspaceSeedName({ + explicitName: 'my-workspace', + prompt: '', + linkedIssueNumber: null, + linkedPR: null, + fallbackName: 'Nautilus' + }) + ).toBe('my-workspace') + }) }) diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index e845cbdc430..e9d99b9d8b8 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -153,8 +153,13 @@ export function getWorkspaceSeedName(args: { prompt: string linkedIssueNumber: number | null linkedPR: number | null + /** Why: when none of the other seed sources produce a name, the composer + * supplies a repo-scoped unique marine-creature name so blank submissions + * still get a distinct, readable workspace rather than a collision-prone + * "workspace" literal that git would append numeric suffixes to. */ + fallbackName?: string }): string { - const { explicitName, prompt, linkedIssueNumber, linkedPR } = args + const { explicitName, prompt, linkedIssueNumber, linkedPR, fallbackName } = args if (explicitName.trim()) { return explicitName.trim() } @@ -175,6 +180,9 @@ export function getWorkspaceSeedName(args: { return slug } } + if (fallbackName && fallbackName.trim()) { + return fallbackName.trim() + } // Why: the prompt is optional in this flow. Fall back to a stable default // branch/workspace seed so users can launch an empty draft without first // writing a brief or naming the workspace manually.