feat: add VS Code-style file search and icon activity bar (#78)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Neil
2026-03-23 23:28:05 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 20cfa1f279
commit 1dff41c6d7
11 changed files with 979 additions and 68 deletions
+130 -6
View File
@@ -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<SearchResult> => {
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<string, SearchFileResult>()
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',
+4 -1
View File
@@ -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<SearchResult>
}
type GitApi = {
+19 -1
View File
@@ -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: {
+8 -2
View File
@@ -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 (
<div className="flex flex-col h-screen w-screen overflow-hidden">
<div className="titlebar">
@@ -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}
/>
)
})()
@@ -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<editor.IStandaloneCodeEditor | null>(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 (
<Editor
height="100%"
@@ -109,3 +133,35 @@ export default function MonacoEditor({
/>
)
}
/** 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()
}
@@ -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<HTMLInputElement>(null)
const [showFilters, setShowFilters] = useState(false)
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLInputElement>) => {
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 (
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
Select a worktree to search
</div>
)
}
return (
<div className="flex flex-col h-full">
{/* Search input area */}
<div className="flex flex-col gap-1.5 p-2 border-b border-border">
{/* Main search row */}
<div className="flex items-center gap-1 bg-input/50 border border-border rounded-sm px-1.5 focus-within:border-ring">
<SearchIcon size={14} className="text-muted-foreground flex-shrink-0" />
<input
ref={inputRef}
type="text"
className="flex-1 bg-transparent text-xs py-1.5 outline-none text-foreground placeholder:text-muted-foreground min-w-0"
placeholder="Search"
value={fileSearchQuery}
onChange={handleQueryChange}
onKeyDown={handleKeyDown}
spellCheck={false}
/>
{fileSearchLoading && (
<Loader2 size={12} className="text-muted-foreground animate-spin flex-shrink-0" />
)}
{fileSearchQuery && (
<button
className="p-0.5 rounded-sm hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={clearFileSearch}
>
<X size={12} />
</button>
)}
{/* Toggle buttons */}
<ToggleButton
active={fileSearchCaseSensitive}
onClick={() => {
setFileSearchCaseSensitive(!fileSearchCaseSensitive)
rerunSearch()
}}
title="Match Case"
>
<CaseSensitive size={14} />
</ToggleButton>
<ToggleButton
active={fileSearchWholeWord}
onClick={() => {
setFileSearchWholeWord(!fileSearchWholeWord)
rerunSearch()
}}
title="Match Whole Word"
>
<WholeWord size={14} />
</ToggleButton>
<ToggleButton
active={fileSearchUseRegex}
onClick={() => {
setFileSearchUseRegex(!fileSearchUseRegex)
rerunSearch()
}}
title="Use Regular Expression"
>
<Regex size={14} />
</ToggleButton>
</div>
{/* Files to include/exclude toggle */}
<button
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground"
onClick={() => setShowFilters(!showFilters)}
>
{showFilters ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
<span>files to include/exclude</span>
</button>
{showFilters && (
<div className="flex flex-col gap-1">
<input
type="text"
className="bg-input/50 border border-border rounded-sm px-2 py-1 text-xs outline-none focus:border-ring text-foreground placeholder:text-muted-foreground"
placeholder="files to include (e.g. *.ts, src/**)"
value={fileSearchIncludePattern}
onChange={(e) => {
setFileSearchIncludePattern(e.target.value)
rerunSearch()
}}
spellCheck={false}
/>
<input
type="text"
className="bg-input/50 border border-border rounded-sm px-2 py-1 text-xs outline-none focus:border-ring text-foreground placeholder:text-muted-foreground"
placeholder="files to exclude (e.g. *.min.js, dist/**)"
value={fileSearchExcludePattern}
onChange={(e) => {
setFileSearchExcludePattern(e.target.value)
rerunSearch()
}}
spellCheck={false}
/>
</div>
)}
</div>
{/* Results area */}
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-sleek">
{fileSearchResults && (
<>
{/* Results summary */}
<div className="px-2 py-1 text-[10px] text-muted-foreground border-b border-border">
{fileSearchResults.totalMatches} result
{fileSearchResults.totalMatches !== 1 ? 's' : ''} in {fileSearchResults.files.length}{' '}
file{fileSearchResults.files.length !== 1 ? 's' : ''}
{fileSearchResults.truncated && ' (results truncated)'}
</div>
{/* File results */}
{fileSearchResults.files.map((fileResult) => (
<FileResultItem
key={fileResult.filePath}
fileResult={fileResult}
collapsed={fileSearchCollapsedFiles.has(fileResult.filePath)}
onToggleCollapse={() => toggleFileSearchCollapsedFile(fileResult.filePath)}
onMatchClick={(match) => handleMatchClick(fileResult, match)}
/>
))}
</>
)}
{!fileSearchResults && fileSearchQuery && !fileSearchLoading && (
<div className="flex items-center justify-center h-32 text-muted-foreground text-xs">
Press Enter to search
</div>
)}
{!fileSearchQuery && (
<div className="flex items-center justify-center h-32 text-muted-foreground text-xs">
Type to search in files
</div>
)}
</div>
</div>
)
}
@@ -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 (
<button
className={cn(
'p-0.5 rounded-sm flex-shrink-0 transition-colors',
active
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
onClick={onClick}
title={title}
>
{children}
</button>
)
}
// ─── 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 (
<div>
{/* File header with context menu */}
<ContextMenu>
<ContextMenuTrigger asChild>
<button
className="flex items-center gap-1 w-full px-2 py-0.5 hover:bg-muted/50 text-left group"
onClick={onToggleCollapse}
>
<ChevronRight
size={12}
className={cn(
'flex-shrink-0 text-muted-foreground transition-transform',
!collapsed && 'rotate-90'
)}
/>
<File size={12} className="flex-shrink-0 text-muted-foreground" />
<span className="text-xs text-foreground truncate">{fileName}</span>
{dirPath && (
<span className="text-[10px] text-muted-foreground truncate ml-1">{dirPath}</span>
)}
<span className="ml-auto text-[10px] text-muted-foreground flex-shrink-0 bg-muted/80 rounded-full px-1.5">
{fileResult.matches.length}
</span>
</button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => navigator.clipboard.writeText(fileResult.relativePath)}>
<Copy className="size-3.5" />
Copy Path
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{/* Matches */}
{!collapsed &&
fileResult.matches.map((match, i) => (
<MatchItem
key={`${match.line}:${match.column}:${i}`}
match={match}
relativePath={fileResult.relativePath}
onClick={() => onMatchClick(match)}
/>
))}
</div>
)
}
// ─── 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 (
<ContextMenu>
<ContextMenuTrigger asChild>
<button
className="flex items-start gap-1 w-full pl-7 pr-2 py-px hover:bg-muted/50 text-left min-h-[18px]"
onClick={onClick}
>
<span className="text-[10px] text-muted-foreground flex-shrink-0 w-8 text-right tabular-nums mt-px">
{match.line}
</span>
<span className="text-xs truncate">
<span className="text-muted-foreground">{parts.before.trimStart()}</span>
{parts.match && (
<span className="bg-amber-500/30 text-foreground rounded-sm">{parts.match}</span>
)}
<span className="text-muted-foreground">{parts.after}</span>
</span>
</button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => navigator.clipboard.writeText(`${relativePath}#L${match.line}`)}
>
<Copy className="size-3.5" />
Copy Line Path
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
@@ -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 = (
<div className="flex-1 min-h-0 overflow-hidden scrollbar-sleek-parent">
{rightSidebarTab === 'explorer' && <FileExplorer key={activeWorktreeId ?? 'none'} />}
{rightSidebarTab === 'search' && <SearchPanel key={activeWorktreeId ?? 'none'} />}
{rightSidebarTab === 'source-control' && <SourceControl key={activeWorktreeId ?? 'none'} />}
</div>
)
const activityBarIcons = ACTIVITY_ITEMS.map((item) => (
<ActivityBarButton
key={item.id}
item={item}
active={rightSidebarTab === item.id}
onClick={() => setRightSidebarTab(item.id)}
layout={activityBarPosition}
/>
))
return (
<div
className="relative flex-shrink-0 bg-sidebar flex flex-col overflow-hidden transition-[width] duration-200"
style={{
width: rightSidebarOpen ? rightSidebarWidth : 0,
borderLeft: rightSidebarOpen ? '1px solid var(--sidebar-border)' : 'none'
}}
className="relative flex-shrink-0 flex flex-row overflow-visible transition-[width] duration-200"
style={{ width: totalWidth }}
>
{/* Tab switcher header */}
<div className="flex items-center border-b border-border h-[33px] min-h-[33px]">
<TabButton
label="Explorer"
active={rightSidebarTab === 'explorer'}
onClick={() => setRightSidebarTab('explorer')}
/>
<TabButton
label="Source Control"
active={rightSidebarTab === 'source-control'}
onClick={() => setRightSidebarTab('source-control')}
/>
</div>
{/* Tab content key on worktreeId forces remount so stale file trees don't persist */}
<div className="flex-1 min-h-0 overflow-hidden scrollbar-sleek-parent">
{rightSidebarTab === 'explorer' ? (
<FileExplorer key={activeWorktreeId ?? 'none'} />
) : (
<SourceControl key={activeWorktreeId ?? 'none'} />
)}
</div>
{/* Resize handle on LEFT side */}
{/* Panel content area */}
<div
className="absolute top-0 left-0 w-1 h-full cursor-col-resize hover:bg-ring/20 active:bg-ring/30 transition-colors z-10"
onMouseDown={onResizeStart}
/>
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 ── */
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex items-center border-b border-border h-[33px] min-h-[33px] px-1">
<TooltipProvider delayDuration={400}>{activityBarIcons}</TooltipProvider>
</div>
</ContextMenuTrigger>
<ActivityBarPositionMenu
currentPosition={activityBarPosition}
onChangePosition={setActivityBarPosition}
/>
</ContextMenu>
) : (
/* ── Side layout: static title header ── */
<div className="flex items-center h-[33px] min-h-[33px] px-3 border-b border-border">
<span className="text-[11px] font-semibold uppercase tracking-wider text-foreground">
{ACTIVITY_ITEMS.find((item) => item.id === rightSidebarTab)?.title ?? ''}
</span>
</div>
)}
{panelContent}
{/* Resize handle on LEFT side */}
<div
className="absolute top-0 left-0 w-1 h-full cursor-col-resize hover:bg-ring/20 active:bg-ring/30 transition-colors z-10"
onMouseDown={onResizeStart}
/>
</div>
{/* Side Activity Bar (icon strip on right edge) — only for 'side' position */}
{activityBarPosition === 'side' && (
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex flex-col items-center w-10 min-w-[40px] bg-sidebar border-l border-border">
<TooltipProvider delayDuration={400}>{activityBarIcons}</TooltipProvider>
</div>
</ContextMenuTrigger>
<ActivityBarPositionMenu
currentPosition={activityBarPosition}
onChangePosition={setActivityBarPosition}
/>
</ContextMenu>
)}
</div>
)
}
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 (
<button
className={`flex-1 text-[11px] font-semibold uppercase tracking-wider py-2 px-3 transition-colors ${
active
? 'text-foreground border-b-2 border-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
onClick={onClick}
>
{label}
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
className={cn(
'relative flex items-center justify-center transition-colors',
isTop ? 'h-[33px] w-9' : 'w-10 h-10',
active ? 'text-foreground' : 'text-muted-foreground/60 hover:text-muted-foreground'
)}
onClick={onClick}
aria-label={`${item.title} (${item.shortcut})`}
>
<Icon size={isTop ? 16 : 18} />
{/* Active indicator */}
{active && isTop && (
<div className="absolute bottom-0 left-[25%] right-[25%] h-[2px] bg-foreground rounded-t" />
)}
{active && !isTop && (
<div className="absolute right-0 top-[25%] bottom-[25%] w-[2px] bg-foreground rounded-l" />
)}
</button>
</TooltipTrigger>
<TooltipContent side={isTop ? 'bottom' : 'left'} sideOffset={6}>
{item.title} ({item.shortcut})
</TooltipContent>
</Tooltip>
)
}
// ─── Context Menu for Activity Bar Position ───────────
function ActivityBarPositionMenu({
currentPosition,
onChangePosition
}: {
currentPosition: ActivityBarPosition
onChangePosition: (pos: ActivityBarPosition) => void
}): React.JSX.Element {
return (
<ContextMenuContent>
<ContextMenuLabel>Activity Bar Position</ContextMenuLabel>
<ContextMenuRadioGroup
value={currentPosition}
onValueChange={(v) => onChangePosition(v as ActivityBarPosition)}
>
<ContextMenuRadioItem value="top">Top</ContextMenuRadioItem>
<ContextMenuRadioItem value="side">Side</ContextMenuRadioItem>
</ContextMenuRadioGroup>
</ContextMenuContent>
)
}
+77 -4
View File
@@ -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<string, Set<string>> // worktreeId -> set of expanded dir paths
@@ -53,6 +56,33 @@ export type EditorSlice = {
// Git status cache
gitStatusByWorktree: Record<string, GitStatusEntry[]>
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<string>
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<AppState, [], [], EditorSlice> = (set) => ({
@@ -60,10 +90,12 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
activeFileId: newActiveId,
activeTabType: newActiveTabType,
activeFileIdByWorktree: newActiveFileIdByWorktree,
activeTabTypeByWorktree: newActiveTabTypeByWorktree
activeTabTypeByWorktree: newActiveTabTypeByWorktree,
pendingEditorReveal: null
}
}),
@@ -287,5 +320,45 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<string>(),
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<string>()
}),
// Editor navigation
pendingEditorReveal: null,
setPendingEditorReveal: (reveal) => set({ pendingEditorReveal: reveal })
})
+31
View File
@@ -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
}