diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index b1c29f27ea1..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 @@ -17,7 +25,7 @@ function isPathAllowed(targetPath: string, store: Store): boolean { for (const repo of repos) { // Allow paths within the repo itself if ( - resolvedTarget.startsWith(resolve(repo.path) + '/') || + resolvedTarget.startsWith(`${resolve(repo.path)}/`) || resolvedTarget === resolve(repo.path) ) { return true @@ -29,7 +37,7 @@ function isPathAllowed(targetPath: string, store: Store): boolean { if (settings.workspaceDir) { const resolvedWorkspace = resolve(settings.workspaceDir) if ( - resolvedTarget.startsWith(resolvedWorkspace + '/') || + resolvedTarget.startsWith(`${resolvedWorkspace}/`) || resolvedTarget === resolvedWorkspace ) { return true @@ -45,7 +53,9 @@ function isPathAllowed(targetPath: string, store: Store): boolean { function isBinaryBuffer(buffer: Buffer): boolean { const len = Math.min(buffer.length, 8192) for (let i = 0; i < len; i++) { - if (buffer[i] === 0) return true + if (buffer[i] === 0) { + return true + } } return false } @@ -66,7 +76,9 @@ export function registerFilesystemHandlers(store: Store): void { })) .sort((a, b) => { // Directories first, then alphabetical - if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } return a.name.localeCompare(b.name) }) }) @@ -124,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/preload/index.d.ts b/src/preload/index.d.ts index 022ae0ebd55..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 = { @@ -116,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 ca62af01c43..cd62ea70755 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -198,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 607e2479698..7b02b921f76 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -43,7 +43,6 @@ function App(): React.JSX.Element { const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen) const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab) - // Subscribe to IPC push events useIpcEvents() @@ -234,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() @@ -246,7 +253,6 @@ function App(): React.JSX.Element { return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) }, [openModal, repos.length, setRightSidebarTab, setRightSidebarOpen]) - return (
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 95d2c60c494..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] ) @@ -83,6 +97,16 @@ export default function MonacoEditor({ 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 18f1947e01a..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) @@ -21,8 +51,9 @@ export default function RightSidebar(): React.JSX.Element { const handleMouseMove = useCallback( (e: MouseEvent) => { - if (!isResizing.current) return - // Dragging left = larger width (opposite of left sidebar) + if (!isResizing.current) { + return + } const delta = startX.current - e.clientX const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth.current + delta)) setRightSidebarWidth(next) @@ -57,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/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 016e518ef9f..58f2bb75d5e 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -1,6 +1,6 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import type { GitStatusEntry } from '../../../../shared/types' +import type { GitStatusEntry, SearchResult } from '../../../../shared/types' export type OpenFile = { id: string // use filePath as unique key @@ -13,17 +13,20 @@ export type OpenFile = { diffStaged?: boolean } -export type RightSidebarTab = 'explorer' | 'source-control' +export type RightSidebarTab = 'explorer' | 'search' | 'source-control' +export type ActivityBarPosition = 'top' | 'side' export type EditorSlice = { // Right sidebar rightSidebarOpen: boolean rightSidebarWidth: number rightSidebarTab: RightSidebarTab + activityBarPosition: ActivityBarPosition toggleRightSidebar: () => void setRightSidebarOpen: (open: boolean) => void setRightSidebarWidth: (width: number) => void setRightSidebarTab: (tab: RightSidebarTab) => void + setActivityBarPosition: (position: ActivityBarPosition) => void // File explorer state expandedDirs: Record> // worktreeId -> set of expanded dir paths @@ -53,6 +56,33 @@ export type EditorSlice = { // Git status cache gitStatusByWorktree: Record setGitStatus: (worktreeId: string, entries: GitStatusEntry[]) => void + + // File search state + fileSearchQuery: string + fileSearchCaseSensitive: boolean + fileSearchWholeWord: boolean + fileSearchUseRegex: boolean + fileSearchIncludePattern: string + fileSearchExcludePattern: string + fileSearchResults: SearchResult | null + fileSearchLoading: boolean + fileSearchCollapsedFiles: Set + setFileSearchQuery: (query: string) => void + setFileSearchCaseSensitive: (v: boolean) => void + setFileSearchWholeWord: (v: boolean) => void + setFileSearchUseRegex: (v: boolean) => void + setFileSearchIncludePattern: (v: string) => void + setFileSearchExcludePattern: (v: string) => void + setFileSearchResults: (results: SearchResult | null) => void + setFileSearchLoading: (loading: boolean) => void + toggleFileSearchCollapsedFile: (filePath: string) => void + clearFileSearch: () => void + + // Editor navigation (for search result → go-to-line) + pendingEditorReveal: { line: number; column: number; matchLength: number } | null + setPendingEditorReveal: ( + reveal: { line: number; column: number; matchLength: number } | null + ) => void } export const createEditorSlice: StateCreator = (set) => ({ @@ -60,10 +90,12 @@ export const createEditorSlice: StateCreator = (s rightSidebarOpen: false, rightSidebarWidth: 280, rightSidebarTab: 'explorer', + activityBarPosition: 'top', toggleRightSidebar: () => set((s) => ({ rightSidebarOpen: !s.rightSidebarOpen })), setRightSidebarOpen: (open) => set({ rightSidebarOpen: open }), setRightSidebarWidth: (width) => set({ rightSidebarWidth: width }), setRightSidebarTab: (tab) => set({ rightSidebarTab: tab }), + setActivityBarPosition: (position) => set({ activityBarPosition: position }), // File explorer expandedDirs: {}, @@ -178,7 +210,8 @@ export const createEditorSlice: StateCreator = (s activeFileId: newActiveId, activeTabType: newActiveTabType, activeFileIdByWorktree: newActiveFileIdByWorktree, - activeTabTypeByWorktree: newActiveTabTypeByWorktree + activeTabTypeByWorktree: newActiveTabTypeByWorktree, + pendingEditorReveal: null } }), @@ -287,5 +320,45 @@ export const createEditorSlice: StateCreator = (s setGitStatus: (worktreeId, entries) => set((s) => ({ gitStatusByWorktree: { ...s.gitStatusByWorktree, [worktreeId]: entries } - })) + })), + + // File search + fileSearchQuery: '', + fileSearchCaseSensitive: false, + fileSearchWholeWord: false, + fileSearchUseRegex: false, + fileSearchIncludePattern: '', + fileSearchExcludePattern: '', + fileSearchResults: null, + fileSearchLoading: false, + fileSearchCollapsedFiles: new Set(), + setFileSearchQuery: (query) => set({ fileSearchQuery: query }), + setFileSearchCaseSensitive: (v) => set({ fileSearchCaseSensitive: v }), + setFileSearchWholeWord: (v) => set({ fileSearchWholeWord: v }), + setFileSearchUseRegex: (v) => set({ fileSearchUseRegex: v }), + setFileSearchIncludePattern: (v) => set({ fileSearchIncludePattern: v }), + setFileSearchExcludePattern: (v) => set({ fileSearchExcludePattern: v }), + setFileSearchResults: (results) => set({ fileSearchResults: results }), + setFileSearchLoading: (loading) => set({ fileSearchLoading: loading }), + toggleFileSearchCollapsedFile: (filePath) => + set((s) => { + const next = new Set(s.fileSearchCollapsedFiles) + if (next.has(filePath)) { + next.delete(filePath) + } else { + next.add(filePath) + } + return { fileSearchCollapsedFiles: next } + }), + clearFileSearch: () => + set({ + fileSearchQuery: '', + fileSearchResults: null, + fileSearchLoading: false, + fileSearchCollapsedFiles: new Set() + }), + + // Editor navigation + pendingEditorReveal: null, + setPendingEditorReveal: (reveal) => set({ pendingEditorReveal: reveal }) }) diff --git a/src/shared/types.ts b/src/shared/types.ts index aae2959011b..55168c71740 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -203,3 +203,34 @@ export type GitDiffResult = { originalContent: string modifiedContent: string } + +// ─── Search ───────────────────────────────────────────── +export type SearchMatch = { + line: number + column: number + matchLength: number + lineContent: string +} + +export type SearchFileResult = { + filePath: string + relativePath: string + matches: SearchMatch[] +} + +export type SearchResult = { + files: SearchFileResult[] + totalMatches: number + truncated: boolean +} + +export type SearchOptions = { + query: string + rootPath: string + caseSensitive?: boolean + wholeWord?: boolean + useRegex?: boolean + includePattern?: string + excludePattern?: string + maxResults?: number +}