diff --git a/src/main/github/client.ts b/src/main/github/client.ts index d5621252ed8..f696ba8ec18 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -141,7 +141,7 @@ function mapPRState(state: string): PRInfo['state'] { function deriveCheckStatus(rollup: unknown[] | null | undefined): CheckStatus { if (!rollup || !Array.isArray(rollup) || rollup.length === 0) { - return 'pending' + return 'neutral' } let hasFailure = false diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 376650627a9..429f2217922 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -1,8 +1,16 @@ import { ipcMain } from 'electron' import { readdir, readFile, writeFile, stat } from 'fs/promises' -import { resolve } from 'path' +import { resolve, relative } from 'path' +import { execFile } from 'child_process' import type { Store } from '../persistence' -import type { DirEntry, GitStatusEntry, GitDiffResult } from '../../shared/types' +import type { + DirEntry, + GitStatusEntry, + GitDiffResult, + SearchOptions, + SearchResult, + SearchFileResult +} from '../../shared/types' import { getStatus, getDiff, stageFile, unstageFile, discardChanges } from '../git/status' const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB @@ -128,6 +136,118 @@ export function registerFilesystemHandlers(store: Store): void { } ) + // ─── Search ──────────────────────────────────────────── + ipcMain.handle('fs:search', async (_event, args: SearchOptions): Promise => { + if (!isPathAllowed(args.rootPath, store)) { + throw new Error('Access denied: path is outside allowed directories') + } + + const maxResults = args.maxResults ?? 10000 + + return new Promise((resolvePromise) => { + const rgArgs: string[] = [ + '--json', + '--max-count', + '200', // max matches per file + '--max-filesize', + '1M' + ] + + if (!args.caseSensitive) { + rgArgs.push('--ignore-case') + } + if (args.wholeWord) { + rgArgs.push('--word-regexp') + } + if (!args.useRegex) { + rgArgs.push('--fixed-strings') + } + if (args.includePattern) { + for (const pat of args.includePattern + .split(',') + .map((s) => s.trim()) + .filter(Boolean)) { + rgArgs.push('--glob', pat) + } + } + if (args.excludePattern) { + for (const pat of args.excludePattern + .split(',') + .map((s) => s.trim()) + .filter(Boolean)) { + rgArgs.push('--glob', `!${pat}`) + } + } + + rgArgs.push('--', args.query, args.rootPath) + + const fileMap = new Map() + let totalMatches = 0 + let truncated = false + + const child = execFile('rg', rgArgs, { maxBuffer: 50 * 1024 * 1024 }, (error, stdout) => { + clearTimeout(killTimeout) + + // rg exit code 1 = no matches, exit code 2 = error. + // If there's no stdout and a real error, return empty. + if (error && !stdout) { + resolvePromise({ files: [], totalMatches: 0, truncated: false }) + return + } + + const lines = stdout.split('\n').filter(Boolean) + for (const line of lines) { + if (totalMatches >= maxResults) { + truncated = true + break + } + + try { + const msg = JSON.parse(line) + if (msg.type !== 'match') { + continue + } + + const data = msg.data + const absPath: string = data.path.text + const relPath = relative(args.rootPath, absPath) + + let fileResult = fileMap.get(absPath) + if (!fileResult) { + fileResult = { filePath: absPath, relativePath: relPath, matches: [] } + fileMap.set(absPath, fileResult) + } + + for (const sub of data.submatches) { + fileResult.matches.push({ + line: data.line_number, + column: sub.start + 1, + matchLength: sub.end - sub.start, + lineContent: data.lines.text.replace(/\n$/, '') + }) + totalMatches++ + if (totalMatches >= maxResults) { + truncated = true + break + } + } + } catch { + // skip malformed JSON lines + } + } + + resolvePromise({ + files: Array.from(fileMap.values()), + totalMatches, + truncated + }) + }) + + // Kill after 30s if still running + const killTimeout = setTimeout(() => child.kill(), 30000) + }) + }) + // ─── Git operations ───────────────────────────────────── ipcMain.handle( 'git:status', diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 0ed6bb019f6..67e98c8b772 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -1,14 +1,84 @@ import { ipcMain, shell } from 'electron' +import { stat } from 'node:fs/promises' +import { isAbsolute, normalize } from 'node:path' +import { fileURLToPath } from 'node:url' + +async function pathExists(pathValue: string): Promise { + try { + await stat(pathValue) + return true + } catch { + return false + } +} export function registerShellHandlers(): void { ipcMain.handle('shell:openPath', (_event, path: string) => { shell.showItemInFolder(path) }) - ipcMain.handle('shell:openExternal', (_event, url: string) => { - if (!url.startsWith('https://') && !url.startsWith('http://')) { + ipcMain.handle('shell:openUrl', (_event, rawUrl: string) => { + let parsed: URL + try { + parsed = new URL(rawUrl) + } catch { return } - return shell.openExternal(url) + + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return + } + + return shell.openExternal(parsed.toString()) + }) + + ipcMain.handle('shell:openFilePath', async (_event, filePath: string) => { + if (!isAbsolute(filePath)) { + return + } + const normalizedPath = normalize(filePath) + if (!(await pathExists(normalizedPath))) { + return + } + await shell.openPath(normalizedPath) + }) + + ipcMain.handle('shell:openFileUri', async (_event, rawUri: string) => { + let parsed: URL + try { + parsed = new URL(rawUri) + } catch { + return + } + + if (parsed.protocol !== 'file:') { + return + } + + // Only local files are supported. Remote hosts are intentionally rejected. + if (parsed.hostname && parsed.hostname !== 'localhost') { + return + } + + let filePath: string + try { + filePath = fileURLToPath(parsed) + } catch { + return + } + + const normalizedPath = normalize(filePath) + if (!isAbsolute(normalizedPath)) { + return + } + if (!(await pathExists(normalizedPath))) { + return + } + + await shell.openPath(normalizedPath) + }) + + ipcMain.handle('shell:pathExists', async (_event, filePath: string): Promise => { + return pathExists(filePath) }) } diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 0650e75adb7..f496385a7b3 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -12,7 +12,9 @@ import type { UpdateStatus, DirEntry, GitStatusEntry, - GitDiffResult + GitDiffResult, + SearchOptions, + SearchResult } from '../../shared/types' type ReposApi = { @@ -62,7 +64,10 @@ type SettingsApi = { type ShellApi = { openPath: (path: string) => Promise - openExternal: (url: string) => Promise + openUrl: (url: string) => Promise + openFilePath: (path: string) => Promise + openFileUri: (uri: string) => Promise + pathExists: (path: string) => Promise } type HooksApi = { @@ -113,6 +118,7 @@ type FsApi = { stat: (args: { filePath: string }) => Promise<{ size: number; isDirectory: boolean; mtime: number }> + search: (args: SearchOptions) => Promise } type GitApi = { diff --git a/src/preload/index.ts b/src/preload/index.ts index 6e91befc31a..cd62ea70755 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -150,7 +150,13 @@ const api = { shell: { openPath: (path: string): Promise => ipcRenderer.invoke('shell:openPath', path), - openExternal: (url: string): Promise => ipcRenderer.invoke('shell:openExternal', url) + openUrl: (url: string): Promise => ipcRenderer.invoke('shell:openUrl', url), + + openFilePath: (path: string): Promise => ipcRenderer.invoke('shell:openFilePath', path), + + openFileUri: (uri: string): Promise => ipcRenderer.invoke('shell:openFileUri', uri), + + pathExists: (path: string): Promise => ipcRenderer.invoke('shell:pathExists', path) }, hooks: { @@ -192,7 +198,25 @@ const api = { stat: (args: { filePath: string }): Promise<{ size: number; isDirectory: boolean; mtime: number }> => - ipcRenderer.invoke('fs:stat', args) + ipcRenderer.invoke('fs:stat', args), + search: (args: { + query: string + rootPath: string + caseSensitive?: boolean + wholeWord?: boolean + useRegex?: boolean + includePattern?: string + excludePattern?: string + maxResults?: number + }): Promise<{ + files: { + filePath: string + relativePath: string + matches: { line: number; column: number; matchLength: number; lineContent: string }[] + }[] + totalMatches: number + truncated: boolean + }> => ipcRenderer.invoke('fs:search', args) }, git: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1ee3b0c07d2..d0a4febf190 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -233,6 +233,14 @@ function App(): React.JSX.Element { return } + // Cmd+Shift+F — toggle right sidebar / search tab + if (e.shiftKey && !e.ctrlKey && !e.altKey && e.key.toLowerCase() === 'f') { + e.preventDefault() + setRightSidebarTab('search') + setRightSidebarOpen(true) + return + } + // Cmd+Shift+G — toggle right sidebar / source control tab if (e.shiftKey && !e.ctrlKey && !e.altKey && e.key.toLowerCase() === 'g') { e.preventDefault() diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index e3d1e7b4626..9b9f0ebd814 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -20,6 +20,7 @@ export default function EditorPanel(): React.JSX.Element | null { const openFiles = useAppStore((s) => s.openFiles) const activeFileId = useAppStore((s) => s.activeFileId) const markFileDirty = useAppStore((s) => s.markFileDirty) + const pendingEditorReveal = useAppStore((s) => s.pendingEditorReveal) const activeFile = openFiles.find((f) => f.id === activeFileId) ?? null @@ -218,6 +219,9 @@ export default function EditorPanel(): React.JSX.Element | null { language={resolvedLanguage} onContentChange={handleContentChange} onSave={handleSave} + revealLine={pendingEditorReveal?.line} + revealColumn={pendingEditorReveal?.column} + revealMatchLength={pendingEditorReveal?.matchLength} /> ) })() diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index eab15acaa91..1a440869429 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -10,6 +10,9 @@ type MonacoEditorProps = { language: string onContentChange: (content: string) => void onSave: (content: string) => void + revealLine?: number + revealColumn?: number + revealMatchLength?: number } export default function MonacoEditor({ @@ -17,25 +20,36 @@ export default function MonacoEditor({ content, language, onContentChange, - onSave + onSave, + revealLine, + revealColumn, + revealMatchLength }: MonacoEditorProps): React.JSX.Element { const editorRef = useRef(null) const settings = useAppStore((s) => s.settings) + const setPendingEditorReveal = useAppStore((s) => s.setPendingEditorReveal) const isDark = settings?.theme === 'dark' || (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) const handleMount: OnMount = useCallback( - (editor, monaco) => { - editorRef.current = editor + (editorInstance, monaco) => { + editorRef.current = editorInstance // Add Cmd+S save keybinding - editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { - const value = editor.getValue() + editorInstance.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { + const value = editorInstance.getValue() onSave(value) }) - editor.focus() + // If there's a pending reveal at mount time, execute it now + const reveal = useAppStore.getState().pendingEditorReveal + if (reveal) { + performReveal(editorInstance, reveal.line, reveal.column, reveal.matchLength) + useAppStore.getState().setPendingEditorReveal(null) + } else { + editorInstance.focus() + } }, [onSave] ) @@ -60,6 +74,39 @@ export default function MonacoEditor({ }) }, [settings]) + useEffect(() => { + const handler = (event: Event): void => { + const detail = (event as CustomEvent).detail as + | { filePath?: string; line?: number; column?: number | null } + | undefined + if (!detail || detail.filePath !== filePath || !detail.line) { + return + } + const editor = editorRef.current + if (!editor) { + return + } + const targetColumn = Math.max(1, detail.column ?? 1) + const targetLine = Math.max(1, detail.line) + editor.revealPositionInCenter({ lineNumber: targetLine, column: targetColumn }) + editor.setPosition({ lineNumber: targetLine, column: targetColumn }) + editor.focus() + } + + window.addEventListener('orca:editor-reveal-location', handler as EventListener) + return () => window.removeEventListener('orca:editor-reveal-location', handler as EventListener) + }, [filePath]) + + // Navigate to line and highlight match when requested (for already-mounted editor) + useEffect(() => { + if (!revealLine || !editorRef.current) { + return + } + performReveal(editorRef.current, revealLine, revealColumn ?? 1, revealMatchLength ?? 0) + // Clear after consuming so it doesn't re-fire + setPendingEditorReveal(null) + }, [revealLine, revealColumn, revealMatchLength, setPendingEditorReveal]) + return ( ) } + +/** Shared reveal logic used by both onMount and useEffect */ +function performReveal( + ed: editor.IStandaloneCodeEditor, + line: number, + column: number, + matchLength: number +): void { + const model = ed.getModel() + const maxLine = model?.getLineCount() ?? Infinity + + // Clamp line to valid range + const safeLine = Math.min(Math.max(1, line), maxLine) + const lineLength = model?.getLineMaxColumn(safeLine) ?? Infinity + const safeCol = Math.min(Math.max(1, column), lineLength) + + ed.setPosition({ lineNumber: safeLine, column: safeCol }) + ed.revealLineInCenter(safeLine) + + // Highlight the match if we have length info + if (matchLength > 0) { + const endCol = Math.min(safeCol + matchLength, lineLength) + ed.setSelection({ + startLineNumber: safeLine, + startColumn: safeCol, + endLineNumber: safeLine, + endColumn: endCol + }) + } + + ed.focus() +} diff --git a/src/renderer/src/components/right-sidebar/Search.tsx b/src/renderer/src/components/right-sidebar/Search.tsx new file mode 100644 index 00000000000..16b029dd15f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/Search.tsx @@ -0,0 +1,316 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + Search as SearchIcon, + CaseSensitive, + WholeWord, + Regex, + ChevronRight, + X, + ChevronDown, + Loader2 +} from 'lucide-react' +import { useAppStore } from '@/store' +import { detectLanguage } from '@/lib/language-detect' +import type { SearchFileResult, SearchMatch } from '../../../../shared/types' +import { ToggleButton, FileResultItem } from './SearchResultItems' + +export default function Search(): React.JSX.Element { + const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const openFile = useAppStore((s) => s.openFile) + const setPendingEditorReveal = useAppStore((s) => s.setPendingEditorReveal) + + const fileSearchQuery = useAppStore((s) => s.fileSearchQuery) + const fileSearchCaseSensitive = useAppStore((s) => s.fileSearchCaseSensitive) + const fileSearchWholeWord = useAppStore((s) => s.fileSearchWholeWord) + const fileSearchUseRegex = useAppStore((s) => s.fileSearchUseRegex) + const fileSearchIncludePattern = useAppStore((s) => s.fileSearchIncludePattern) + const fileSearchExcludePattern = useAppStore((s) => s.fileSearchExcludePattern) + const fileSearchResults = useAppStore((s) => s.fileSearchResults) + const fileSearchLoading = useAppStore((s) => s.fileSearchLoading) + const fileSearchCollapsedFiles = useAppStore((s) => s.fileSearchCollapsedFiles) + + const setFileSearchQuery = useAppStore((s) => s.setFileSearchQuery) + const setFileSearchCaseSensitive = useAppStore((s) => s.setFileSearchCaseSensitive) + const setFileSearchWholeWord = useAppStore((s) => s.setFileSearchWholeWord) + const setFileSearchUseRegex = useAppStore((s) => s.setFileSearchUseRegex) + const setFileSearchIncludePattern = useAppStore((s) => s.setFileSearchIncludePattern) + const setFileSearchExcludePattern = useAppStore((s) => s.setFileSearchExcludePattern) + const setFileSearchResults = useAppStore((s) => s.setFileSearchResults) + const setFileSearchLoading = useAppStore((s) => s.setFileSearchLoading) + const toggleFileSearchCollapsedFile = useAppStore((s) => s.toggleFileSearchCollapsedFile) + const clearFileSearch = useAppStore((s) => s.clearFileSearch) + + const inputRef = useRef(null) + const [showFilters, setShowFilters] = useState(false) + const searchTimerRef = useRef | null>(null) + + // Find active worktree path + const worktreePath = useMemo(() => { + if (!activeWorktreeId) { + return null + } + for (const worktrees of Object.values(worktreesByRepo)) { + const wt = worktrees.find((w) => w.id === activeWorktreeId) + if (wt) { + return wt.path + } + } + return null + }, [activeWorktreeId, worktreesByRepo]) + + // Focus input on mount + useEffect(() => { + inputRef.current?.focus() + }, []) + + // Cleanup debounce timer on unmount + useEffect(() => { + return () => { + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current) + } + } + }, []) + + // Execute search with debounce — reads fresh state inside setTimeout + // to avoid stale closures when options change during debounce + const executeSearch = useCallback( + (query: string) => { + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current) + } + + if (!query.trim() || !worktreePath) { + setFileSearchResults(null) + setFileSearchLoading(false) + return + } + + setFileSearchLoading(true) + searchTimerRef.current = setTimeout(async () => { + try { + const state = useAppStore.getState() + const results = await window.api.fs.search({ + query: query.trim(), + rootPath: worktreePath, + caseSensitive: state.fileSearchCaseSensitive, + wholeWord: state.fileSearchWholeWord, + useRegex: state.fileSearchUseRegex, + includePattern: state.fileSearchIncludePattern || undefined, + excludePattern: state.fileSearchExcludePattern || undefined, + maxResults: 10000 + }) + setFileSearchResults(results) + } catch (err) { + console.error('Search failed:', err) + setFileSearchResults({ files: [], totalMatches: 0, truncated: false }) + } finally { + setFileSearchLoading(false) + } + }, 300) + }, + [worktreePath, setFileSearchResults, setFileSearchLoading] + ) + + // Re-execute search from event handlers when options change + const rerunSearch = useCallback(() => { + const q = useAppStore.getState().fileSearchQuery + if (q.trim()) { + executeSearch(q) + } + }, [executeSearch]) + + const handleQueryChange = useCallback( + (e: React.ChangeEvent) => { + const val = e.target.value + setFileSearchQuery(val) + executeSearch(val) + }, + [setFileSearchQuery, executeSearch] + ) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + if (fileSearchQuery) { + clearFileSearch() + } + } + if (e.key === 'Enter') { + executeSearch(fileSearchQuery) + } + }, + [fileSearchQuery, clearFileSearch, executeSearch] + ) + + const handleMatchClick = useCallback( + (fileResult: SearchFileResult, match: SearchMatch) => { + if (!activeWorktreeId) { + return + } + + // Set pending navigation so editor scrolls to the match + setPendingEditorReveal({ + line: match.line, + column: match.column, + matchLength: match.matchLength + }) + + openFile({ + filePath: fileResult.filePath, + relativePath: fileResult.relativePath, + worktreeId: activeWorktreeId, + language: detectLanguage(fileResult.relativePath), + mode: 'edit' + }) + }, + [activeWorktreeId, openFile, setPendingEditorReveal] + ) + + if (!activeWorktreeId) { + return ( +
+ Select a worktree to search +
+ ) + } + + return ( +
+ {/* Search input area */} +
+ {/* Main search row */} +
+ + + {fileSearchLoading && ( + + )} + {fileSearchQuery && ( + + )} + {/* Toggle buttons */} + { + setFileSearchCaseSensitive(!fileSearchCaseSensitive) + rerunSearch() + }} + title="Match Case" + > + + + { + setFileSearchWholeWord(!fileSearchWholeWord) + rerunSearch() + }} + title="Match Whole Word" + > + + + { + setFileSearchUseRegex(!fileSearchUseRegex) + rerunSearch() + }} + title="Use Regular Expression" + > + + +
+ + {/* Files to include/exclude toggle */} + + + {showFilters && ( +
+ { + setFileSearchIncludePattern(e.target.value) + rerunSearch() + }} + spellCheck={false} + /> + { + setFileSearchExcludePattern(e.target.value) + rerunSearch() + }} + spellCheck={false} + /> +
+ )} +
+ + {/* Results area */} +
+ {fileSearchResults && ( + <> + {/* Results summary */} +
+ {fileSearchResults.totalMatches} result + {fileSearchResults.totalMatches !== 1 ? 's' : ''} in {fileSearchResults.files.length}{' '} + file{fileSearchResults.files.length !== 1 ? 's' : ''} + {fileSearchResults.truncated && ' (results truncated)'} +
+ + {/* File results */} + {fileSearchResults.files.map((fileResult) => ( + toggleFileSearchCollapsedFile(fileResult.filePath)} + onMatchClick={(match) => handleMatchClick(fileResult, match)} + /> + ))} + + )} + + {!fileSearchResults && fileSearchQuery && !fileSearchLoading && ( +
+ Press Enter to search +
+ )} + + {!fileSearchQuery && ( +
+ Type to search in files +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/SearchResultItems.tsx b/src/renderer/src/components/right-sidebar/SearchResultItems.tsx new file mode 100644 index 00000000000..cc7aa432209 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/SearchResultItems.tsx @@ -0,0 +1,162 @@ +import React, { useMemo } from 'react' +import { ChevronRight, File, Copy } from 'lucide-react' +import { cn } from '@/lib/utils' +import { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem +} from '@/components/ui/context-menu' +import type { SearchFileResult, SearchMatch } from '../../../../shared/types' + +// ─── Toggle Button ──────────────────────────────────────── +export function ToggleButton({ + active, + onClick, + title, + children +}: { + active: boolean + onClick: () => void + title: string + children: React.ReactNode +}): React.JSX.Element { + return ( + + ) +} + +// ─── File Result ────────────────────────────────────────── +export function FileResultItem({ + fileResult, + collapsed, + onToggleCollapse, + onMatchClick +}: { + fileResult: SearchFileResult + collapsed: boolean + onToggleCollapse: () => void + onMatchClick: (match: SearchMatch) => void +}): React.JSX.Element { + const fileName = fileResult.relativePath.split('/').pop() ?? fileResult.relativePath + const dirPath = fileResult.relativePath.includes('/') + ? fileResult.relativePath.slice(0, fileResult.relativePath.lastIndexOf('/')) + : '' + + return ( +
+ {/* File header with context menu */} + + + + + + navigator.clipboard.writeText(fileResult.relativePath)}> + + Copy Path + + + + + {/* Matches */} + {!collapsed && + fileResult.matches.map((match, i) => ( + onMatchClick(match)} + /> + ))} +
+ ) +} + +// ─── Match Item ─────────────────────────────────────────── +export function MatchItem({ + match, + relativePath, + onClick +}: { + match: SearchMatch + relativePath: string + onClick: () => void +}): React.JSX.Element { + // Highlight the matched text within the line + const parts = useMemo(() => { + const content = match.lineContent + const col = match.column - 1 // convert to 0-indexed + const len = match.matchLength + + if (col >= 0 && col + len <= content.length) { + return { + before: content.slice(0, col), + match: content.slice(col, col + len), + after: content.slice(col + len) + } + } + + // Fallback + return { before: content, match: '', after: '' } + }, [match.lineContent, match.column, match.matchLength]) + + return ( + + + + + + navigator.clipboard.writeText(`${relativePath}#L${match.line}`)} + > + + Copy Line Path + + + + ) +} diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx index 77086874b68..3fc920e89fc 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -1,11 +1,39 @@ import React, { useCallback, useRef, useEffect } from 'react' +import { Files, Search, GitBranch } from 'lucide-react' import { useAppStore } from '@/store' +import { cn } from '@/lib/utils' +import type { RightSidebarTab, ActivityBarPosition } from '@/store/slices/editor' +import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip' +import { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuLabel, + ContextMenuRadioGroup, + ContextMenuRadioItem +} from '@/components/ui/context-menu' import FileExplorer from './FileExplorer' import SourceControl from './SourceControl' +import SearchPanel from './Search' const MIN_WIDTH = 220 const MAX_WIDTH = 500 +const ACTIVITY_BAR_SIDE_WIDTH = 40 + +type ActivityBarItem = { + id: RightSidebarTab + icon: React.ComponentType<{ size?: number; className?: string }> + title: string + shortcut: string +} + +const ACTIVITY_ITEMS: ActivityBarItem[] = [ + { id: 'explorer', icon: Files, title: 'Explorer', shortcut: '\u21E7\u2318E' }, + { id: 'source-control', icon: GitBranch, title: 'Source Control', shortcut: '\u21E7\u2318G' }, + { id: 'search', icon: Search, title: 'Search', shortcut: '\u21E7\u2318F' } +] + export default function RightSidebar(): React.JSX.Element { const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth) @@ -13,6 +41,8 @@ export default function RightSidebar(): React.JSX.Element { const rightSidebarTab = useAppStore((s) => s.rightSidebarTab) const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const activityBarPosition = useAppStore((s) => s.activityBarPosition) + const setActivityBarPosition = useAppStore((s) => s.setActivityBarPosition) // ─── Resize logic (handle on LEFT edge) ──────────── const isResizing = useRef(false) @@ -24,7 +54,6 @@ export default function RightSidebar(): React.JSX.Element { if (!isResizing.current) { return } - // Dragging left = larger width (opposite of left sidebar) const delta = startX.current - e.clientX const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth.current + delta)) setRightSidebarWidth(next) @@ -59,65 +88,152 @@ export default function RightSidebar(): React.JSX.Element { [rightSidebarWidth] ) + const totalWidth = rightSidebarOpen + ? rightSidebarWidth + (activityBarPosition === 'side' ? ACTIVITY_BAR_SIDE_WIDTH : 0) + : 0 + + const panelContent = ( +
+ {rightSidebarTab === 'explorer' && } + {rightSidebarTab === 'search' && } + {rightSidebarTab === 'source-control' && } +
+ ) + + const activityBarIcons = ACTIVITY_ITEMS.map((item) => ( + setRightSidebarTab(item.id)} + layout={activityBarPosition} + /> + )) + return (
- {/* Tab switcher header */} -
- setRightSidebarTab('explorer')} - /> - setRightSidebarTab('source-control')} - /> -
- - {/* Tab content – key on worktreeId forces remount so stale file trees don't persist */} -
- {rightSidebarTab === 'explorer' ? ( - - ) : ( - - )} -
- - {/* Resize handle on LEFT side */} + {/* Panel content area */}
+ className="flex flex-col flex-1 min-w-0 bg-sidebar overflow-hidden" + style={{ + borderLeft: rightSidebarOpen ? '1px solid var(--sidebar-border)' : 'none' + }} + > + {activityBarPosition === 'top' ? ( + /* ── Top activity bar: horizontal icon row ── */ + + +
+ {activityBarIcons} +
+
+ +
+ ) : ( + /* ── Side layout: static title header ── */ +
+ + {ACTIVITY_ITEMS.find((item) => item.id === rightSidebarTab)?.title ?? ''} + +
+ )} + + {panelContent} + + {/* Resize handle on LEFT side */} +
+
+ + {/* Side Activity Bar (icon strip on right edge) — only for 'side' position */} + {activityBarPosition === 'side' && ( + + +
+ {activityBarIcons} +
+
+ +
+ )}
) } -function TabButton({ - label, +// ─── Activity Bar Button (shared for top + side) ────── +function ActivityBarButton({ + item, active, - onClick + onClick, + layout }: { - label: string + item: ActivityBarItem active: boolean onClick: () => void + layout: 'top' | 'side' }): React.JSX.Element { + const Icon = item.icon + const isTop = layout === 'top' + return ( - + + + + + + {item.title} ({item.shortcut}) + + + ) +} + +// ─── Context Menu for Activity Bar Position ─────────── +function ActivityBarPositionMenu({ + currentPosition, + onChangePosition +}: { + currentPosition: ActivityBarPosition + onChangePosition: (pos: ActivityBarPosition) => void +}): React.JSX.Element { + return ( + + Activity Bar Position + onChangePosition(v as ActivityBarPosition)} + > + Top + Side + + ) } diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 06aceba32e8..018def96548 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -3,7 +3,7 @@ import { useAppStore } from '@/store' import { Badge } from '@/components/ui/badge' import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' -import { Bell, LoaderCircle, CircleDot } from 'lucide-react' +import { Bell, LoaderCircle, CircleDot, CircleCheck, CircleX } from 'lucide-react' import RepoDotLabel from '@/components/repo/RepoDotLabel' import StatusIndicator from './StatusIndicator' import WorktreeContextMenu from './WorktreeContextMenu' @@ -75,6 +75,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ isActive }: WorktreeCardProps) { const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) + const openModal = useAppStore((s) => s.openModal) const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch) const fetchIssue = useAppStore((s) => s.fetchIssue) @@ -150,6 +151,14 @@ const WorktreeCard = React.memo(function WorktreeCard({ setActiveWorktree(worktree.id) }, [worktree.id, setActiveWorktree]) + const handleDoubleClick = useCallback(() => { + openModal('edit-meta', { + worktreeId: worktree.id, + currentIssue: worktree.linkedIssue, + currentComment: worktree.comment + }) + }, [worktree.id, worktree.linkedIssue, worktree.comment, openModal]) + const handleToggleUnreadQuick = useCallback( (event: React.MouseEvent) => { event.preventDefault() @@ -172,6 +181,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ isDeleting && 'opacity-70' )} onClick={handleClick} + onDoubleClick={handleDoubleClick} aria-busy={isDeleting} > {isDeleting && ( @@ -218,7 +228,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ {worktree.displayName}
- {/* Line 2: Repo badge + branch + primary badge */} + {/* Line 2: Repo badge + branch + primary badge + CI status */}
{repo && ( )} + {pr && pr.checksStatus !== 'neutral' && ( + + + + {pr.checksStatus === 'success' && ( + + )} + {pr.checksStatus === 'failure' && } + {pr.checksStatus === 'pending' && ( + + )} + + + + CI checks {checksLabel(pr.checksStatus).toLowerCase()} + + + )}
{/* Meta section: Issue, Comment, PR */} diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index 165705e5066..e319d04225e 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -48,12 +48,22 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree, }, [worktree.id, worktree.isUnread, updateWorktreeMeta]) const handleLinkIssue = useCallback(() => { - openModal('link-issue', { worktreeId: worktree.id, currentIssue: worktree.linkedIssue }) - }, [worktree.id, worktree.linkedIssue, openModal]) + openModal('edit-meta', { + worktreeId: worktree.id, + currentIssue: worktree.linkedIssue, + currentComment: worktree.comment, + focus: 'issue' + }) + }, [worktree.id, worktree.linkedIssue, worktree.comment, openModal]) const handleComment = useCallback(() => { - openModal('edit-comment', { worktreeId: worktree.id, currentComment: worktree.comment }) - }, [worktree.id, worktree.comment, openModal]) + openModal('edit-meta', { + worktreeId: worktree.id, + currentIssue: worktree.linkedIssue, + currentComment: worktree.comment, + focus: 'comment' + }) + }, [worktree.id, worktree.linkedIssue, worktree.comment, openModal]) const handleCloseTerminals = useCallback(async () => { await shutdownWorktreeTerminals(worktree.id) diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx index 8b4889f89e4..27db62c14d1 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx @@ -11,6 +11,7 @@ import { import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { parseGitHubIssueOrPRNumber } from '@/lib/github-links' +import type { WorktreeMeta } from '../../../../shared/types' const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const activeModal = useAppStore((s) => s.activeModal) @@ -18,20 +19,21 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const closeModal = useAppStore((s) => s.closeModal) const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) - const isLinkIssue = activeModal === 'link-issue' - const isEditComment = activeModal === 'edit-comment' - const isOpen = isLinkIssue || isEditComment + const isEditMeta = activeModal === 'edit-meta' + const isOpen = isEditMeta const worktreeId = typeof modalData.worktreeId === 'string' ? modalData.worktreeId : '' const currentIssue = typeof modalData.currentIssue === 'number' ? String(modalData.currentIssue) : '' const currentComment = typeof modalData.currentComment === 'string' ? modalData.currentComment : '' + const focusField = typeof modalData.focus === 'string' ? modalData.focus : 'comment' const [issueInput, setIssueInput] = useState('') const [commentInput, setCommentInput] = useState('') const [saving, setSaving] = useState(false) + const issueInputRef = useRef(null) const textareaRef = useRef(null) const prevIsOpenRef = useRef(false) if (isOpen && !prevIsOpenRef.current) { @@ -50,20 +52,17 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { }, []) useEffect(() => { - if (isEditComment) { + if (isEditMeta) { autoResize() } - }, [isEditComment, commentInput, autoResize]) + }, [isEditMeta, commentInput, autoResize]) const canSave = useMemo(() => { if (!worktreeId) { return false } - if (isLinkIssue) { - return issueInput.trim() === '' || parseGitHubIssueOrPRNumber(issueInput) !== null - } - return true - }, [worktreeId, isLinkIssue, issueInput]) + return issueInput.trim() === '' || parseGitHubIssueOrPRNumber(issueInput) !== null + }, [worktreeId, issueInput]) const handleOpenChange = useCallback( (open: boolean) => { @@ -80,28 +79,24 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { } setSaving(true) try { - if (isLinkIssue) { - const trimmed = issueInput.trim() - const linkedIssueNumber = parseGitHubIssueOrPRNumber(trimmed) - if (trimmed === '' || linkedIssueNumber !== null) { - await updateWorktreeMeta(worktreeId, { linkedIssue: linkedIssueNumber }) - } - } else if (isEditComment) { - await updateWorktreeMeta(worktreeId, { comment: commentInput.trim() }) + const trimmedIssue = issueInput.trim() + const linkedIssueNumber = parseGitHubIssueOrPRNumber(trimmedIssue) + const finalLinkedIssue = + trimmedIssue === '' ? null : linkedIssueNumber !== null ? linkedIssueNumber : undefined + + const updates: Partial = { + comment: commentInput.trim() } + if (finalLinkedIssue !== undefined) { + updates.linkedIssue = finalLinkedIssue + } + + await updateWorktreeMeta(worktreeId, updates) closeModal() } finally { setSaving(false) } - }, [ - worktreeId, - isLinkIssue, - isEditComment, - issueInput, - commentInput, - updateWorktreeMeta, - closeModal - ]) + }, [worktreeId, issueInput, commentInput, updateWorktreeMeta, closeModal]) const handleCommentKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -113,35 +108,52 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { [handleSave] ) + const handleIssueKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSave() + } + }, + [handleSave] + ) + return ( - e.preventDefault()}> + { + e.preventDefault() + if (focusField === 'issue') { + issueInputRef.current?.focus() + } else { + textareaRef.current?.focus() + } + }} + > - - {isLinkIssue ? 'Link GH Issue/PR' : 'Edit Comment'} - + Edit Worktree Details - {isLinkIssue - ? 'Add an issue/PR number or URL to link this worktree. Leave blank to remove the link.' - : 'Add or edit notes for this worktree.'} + Edit the GitHub issue link and notes for this worktree. - {isLinkIssue ? ( +
setIssueInput(e.target.value)} + onKeyDown={handleIssueKeyDown} placeholder="Issue/PR # or GitHub URL" className="h-8 text-xs" - autoFocus />

- Paste an issue or PR URL, or enter a number. + Paste an issue or PR URL, or enter a number. Leave blank to remove the link.

- ) : ( +