diff --git a/package.json b/package.json index 6a8e8bb0a61..45e1513e65f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.3.17-rc.4", + "version": "1.3.17", "description": "An Electron application with React and TypeScript", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", 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/App.tsx b/src/renderer/src/App.tsx index bd2e1a17a78..086f7e49fe8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2,7 +2,7 @@ import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../shared/constants' -import { ChevronLeft, ChevronRight, Minimize2, PanelLeft, PanelRight } from 'lucide-react' +import { ArrowLeft, ArrowRight, Minimize2, PanelLeft, PanelRight } from 'lucide-react' import { FOCUS_TERMINAL_PANE_EVENT, TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal' import { syncZoomCSSVar } from '@/lib/ui-zoom' import { toast } from 'sonner' @@ -645,149 +645,153 @@ function App(): React.JSX.Element { // the sidebar-width left header (workspace view) can share the same // controls without duplicating the agent badge popover. const titlebarLeftControls = ( -
-
- {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. */} - {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; 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/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 diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index d9db09828c4..bd354d5aa92 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 { @@ -138,6 +138,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const allWorktrees = useAllWorktrees() const repos = useAppStore((s) => s.repos) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) + // Why: getWorktreeStatus needs per-pane titles so split-pane tabs with a + // working agent in a non-focused pane still surface as 'working' in the + // jump palette. Without this, clicking between panes would desync the + // palette's spinner from the sidebar's spinner. + const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) const prCache = useAppStore((s) => s.prCache) const issueCache = useAppStore((s) => s.issueCache) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) @@ -145,6 +150,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) @@ -701,10 +707,16 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const branch = branchName(worktree.branch) const status = getWorktreeStatus( tabsByWorktree[worktree.id] ?? [], - browserTabsByWorktree[worktree.id] ?? [] + browserTabsByWorktree[worktree.id] ?? [], + runtimePaneTitlesByTabId ) 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} />