mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
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:
+130
-6
@@ -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',
|
||||
|
||||
Vendored
+4
-1
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user