merge: resolve conflicts with origin/main

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jinjing
2026-03-23 23:44:02 -07:00
co-authored by Claude Opus 4.6
20 changed files with 1525 additions and 115 deletions
+1 -1
View File
@@ -141,7 +141,7 @@ function mapPRState(state: string): PRInfo['state'] {
function deriveCheckStatus(rollup: unknown[] | null | undefined): CheckStatus {
if (!rollup || !Array.isArray(rollup) || rollup.length === 0) {
return 'pending'
return 'neutral'
}
let hasFailure = false
+122 -2
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
@@ -128,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',
+73 -3
View File
@@ -1,14 +1,84 @@
import { ipcMain, shell } from 'electron'
import { stat } from 'node:fs/promises'
import { isAbsolute, normalize } from 'node:path'
import { fileURLToPath } from 'node:url'
async function pathExists(pathValue: string): Promise<boolean> {
try {
await stat(pathValue)
return true
} catch {
return false
}
}
export function registerShellHandlers(): void {
ipcMain.handle('shell:openPath', (_event, path: string) => {
shell.showItemInFolder(path)
})
ipcMain.handle('shell:openExternal', (_event, url: string) => {
if (!url.startsWith('https://') && !url.startsWith('http://')) {
ipcMain.handle('shell:openUrl', (_event, rawUrl: string) => {
let parsed: URL
try {
parsed = new URL(rawUrl)
} catch {
return
}
return shell.openExternal(url)
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return
}
return shell.openExternal(parsed.toString())
})
ipcMain.handle('shell:openFilePath', async (_event, filePath: string) => {
if (!isAbsolute(filePath)) {
return
}
const normalizedPath = normalize(filePath)
if (!(await pathExists(normalizedPath))) {
return
}
await shell.openPath(normalizedPath)
})
ipcMain.handle('shell:openFileUri', async (_event, rawUri: string) => {
let parsed: URL
try {
parsed = new URL(rawUri)
} catch {
return
}
if (parsed.protocol !== 'file:') {
return
}
// Only local files are supported. Remote hosts are intentionally rejected.
if (parsed.hostname && parsed.hostname !== 'localhost') {
return
}
let filePath: string
try {
filePath = fileURLToPath(parsed)
} catch {
return
}
const normalizedPath = normalize(filePath)
if (!isAbsolute(normalizedPath)) {
return
}
if (!(await pathExists(normalizedPath))) {
return
}
await shell.openPath(normalizedPath)
})
ipcMain.handle('shell:pathExists', async (_event, filePath: string): Promise<boolean> => {
return pathExists(filePath)
})
}
+8 -2
View File
@@ -12,7 +12,9 @@ import type {
UpdateStatus,
DirEntry,
GitStatusEntry,
GitDiffResult
GitDiffResult,
SearchOptions,
SearchResult
} from '../../shared/types'
type ReposApi = {
@@ -62,7 +64,10 @@ type SettingsApi = {
type ShellApi = {
openPath: (path: string) => Promise<void>
openExternal: (url: string) => Promise<void>
openUrl: (url: string) => Promise<void>
openFilePath: (path: string) => Promise<void>
openFileUri: (uri: string) => Promise<void>
pathExists: (path: string) => Promise<boolean>
}
type HooksApi = {
@@ -113,6 +118,7 @@ type FsApi = {
stat: (args: {
filePath: string
}) => Promise<{ size: number; isDirectory: boolean; mtime: number }>
search: (args: SearchOptions) => Promise<SearchResult>
}
type GitApi = {
+26 -2
View File
@@ -150,7 +150,13 @@ const api = {
shell: {
openPath: (path: string): Promise<void> => ipcRenderer.invoke('shell:openPath', path),
openExternal: (url: string): Promise<void> => ipcRenderer.invoke('shell:openExternal', url)
openUrl: (url: string): Promise<void> => ipcRenderer.invoke('shell:openUrl', url),
openFilePath: (path: string): Promise<void> => ipcRenderer.invoke('shell:openFilePath', path),
openFileUri: (uri: string): Promise<void> => ipcRenderer.invoke('shell:openFileUri', uri),
pathExists: (path: string): Promise<boolean> => ipcRenderer.invoke('shell:pathExists', path)
},
hooks: {
@@ -192,7 +198,25 @@ const api = {
stat: (args: {
filePath: string
}): Promise<{ size: number; isDirectory: boolean; mtime: number }> =>
ipcRenderer.invoke('fs:stat', args)
ipcRenderer.invoke('fs:stat', args),
search: (args: {
query: string
rootPath: string
caseSensitive?: boolean
wholeWord?: boolean
useRegex?: boolean
includePattern?: string
excludePattern?: string
maxResults?: number
}): Promise<{
files: {
filePath: string
relativePath: string
matches: { line: number; column: number; matchLength: number; lineContent: string }[]
}[]
totalMatches: number
truncated: boolean
}> => ipcRenderer.invoke('fs:search', args)
},
git: {
+8
View File
@@ -233,6 +233,14 @@ function App(): React.JSX.Element {
return
}
// Cmd+Shift+F — toggle right sidebar / search tab
if (e.shiftKey && !e.ctrlKey && !e.altKey && e.key.toLowerCase() === 'f') {
e.preventDefault()
setRightSidebarTab('search')
setRightSidebarOpen(true)
return
}
// Cmd+Shift+G — toggle right sidebar / source control tab
if (e.shiftKey && !e.ctrlKey && !e.altKey && e.key.toLowerCase() === 'g') {
e.preventDefault()
@@ -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]
)
@@ -60,6 +74,39 @@ export default function MonacoEditor({
})
}, [settings])
useEffect(() => {
const handler = (event: Event): void => {
const detail = (event as CustomEvent).detail as
| { filePath?: string; line?: number; column?: number | null }
| undefined
if (!detail || detail.filePath !== filePath || !detail.line) {
return
}
const editor = editorRef.current
if (!editor) {
return
}
const targetColumn = Math.max(1, detail.column ?? 1)
const targetLine = Math.max(1, detail.line)
editor.revealPositionInCenter({ lineNumber: targetLine, column: targetColumn })
editor.setPosition({ lineNumber: targetLine, column: targetColumn })
editor.focus()
}
window.addEventListener('orca:editor-reveal-location', handler as EventListener)
return () => window.removeEventListener('orca:editor-reveal-location', handler as EventListener)
}, [filePath])
// Navigate to line and highlight match when requested (for already-mounted editor)
useEffect(() => {
if (!revealLine || !editorRef.current) {
return
}
performReveal(editorRef.current, revealLine, revealColumn ?? 1, revealMatchLength ?? 0)
// Clear after consuming so it doesn't re-fire
setPendingEditorReveal(null)
}, [revealLine, revealColumn, revealMatchLength, setPendingEditorReveal])
return (
<Editor
height="100%"
@@ -86,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)
@@ -24,7 +54,6 @@ export default function RightSidebar(): React.JSX.Element {
if (!isResizing.current) {
return
}
// Dragging left = larger width (opposite of left sidebar)
const delta = startX.current - e.clientX
const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth.current + delta))
setRightSidebarWidth(next)
@@ -59,65 +88,152 @@ export default function RightSidebar(): React.JSX.Element {
[rightSidebarWidth]
)
const totalWidth = rightSidebarOpen
? rightSidebarWidth + (activityBarPosition === 'side' ? ACTIVITY_BAR_SIDE_WIDTH : 0)
: 0
const panelContent = (
<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>
)
}
@@ -3,7 +3,7 @@ import { useAppStore } from '@/store'
import { Badge } from '@/components/ui/badge'
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { Bell, LoaderCircle, CircleDot } from 'lucide-react'
import { Bell, LoaderCircle, CircleDot, CircleCheck, CircleX } from 'lucide-react'
import RepoDotLabel from '@/components/repo/RepoDotLabel'
import StatusIndicator from './StatusIndicator'
import WorktreeContextMenu from './WorktreeContextMenu'
@@ -75,6 +75,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
isActive
}: WorktreeCardProps) {
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const openModal = useAppStore((s) => s.openModal)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const fetchIssue = useAppStore((s) => s.fetchIssue)
@@ -150,6 +151,14 @@ const WorktreeCard = React.memo(function WorktreeCard({
setActiveWorktree(worktree.id)
}, [worktree.id, setActiveWorktree])
const handleDoubleClick = useCallback(() => {
openModal('edit-meta', {
worktreeId: worktree.id,
currentIssue: worktree.linkedIssue,
currentComment: worktree.comment
})
}, [worktree.id, worktree.linkedIssue, worktree.comment, openModal])
const handleToggleUnreadQuick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
@@ -172,6 +181,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
isDeleting && 'opacity-70'
)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
aria-busy={isDeleting}
>
{isDeleting && (
@@ -218,7 +228,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
{worktree.displayName}
</div>
{/* Line 2: Repo badge + branch + primary badge */}
{/* Line 2: Repo badge + branch + primary badge + CI status */}
<div className="flex items-center gap-1 min-w-0">
{repo && (
<Badge
@@ -239,6 +249,24 @@ const WorktreeCard = React.memo(function WorktreeCard({
main
</Badge>
)}
{pr && pr.checksStatus !== 'neutral' && (
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-auto shrink-0 inline-flex items-center">
{pr.checksStatus === 'success' && (
<CircleCheck className="size-3 text-emerald-400" />
)}
{pr.checksStatus === 'failure' && <CircleX className="size-3 text-red-400" />}
{pr.checksStatus === 'pending' && (
<LoaderCircle className="size-3 text-yellow-400 animate-spin" />
)}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<span>CI checks {checksLabel(pr.checksStatus).toLowerCase()}</span>
</TooltipContent>
</Tooltip>
)}
</div>
{/* Meta section: Issue, Comment, PR */}
@@ -48,12 +48,22 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree,
}, [worktree.id, worktree.isUnread, updateWorktreeMeta])
const handleLinkIssue = useCallback(() => {
openModal('link-issue', { worktreeId: worktree.id, currentIssue: worktree.linkedIssue })
}, [worktree.id, worktree.linkedIssue, openModal])
openModal('edit-meta', {
worktreeId: worktree.id,
currentIssue: worktree.linkedIssue,
currentComment: worktree.comment,
focus: 'issue'
})
}, [worktree.id, worktree.linkedIssue, worktree.comment, openModal])
const handleComment = useCallback(() => {
openModal('edit-comment', { worktreeId: worktree.id, currentComment: worktree.comment })
}, [worktree.id, worktree.comment, openModal])
openModal('edit-meta', {
worktreeId: worktree.id,
currentIssue: worktree.linkedIssue,
currentComment: worktree.comment,
focus: 'comment'
})
}, [worktree.id, worktree.linkedIssue, worktree.comment, openModal])
const handleCloseTerminals = useCallback(async () => {
await shutdownWorktreeTerminals(worktree.id)
@@ -11,6 +11,7 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { parseGitHubIssueOrPRNumber } from '@/lib/github-links'
import type { WorktreeMeta } from '../../../../shared/types'
const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
const activeModal = useAppStore((s) => s.activeModal)
@@ -18,20 +19,21 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
const closeModal = useAppStore((s) => s.closeModal)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const isLinkIssue = activeModal === 'link-issue'
const isEditComment = activeModal === 'edit-comment'
const isOpen = isLinkIssue || isEditComment
const isEditMeta = activeModal === 'edit-meta'
const isOpen = isEditMeta
const worktreeId = typeof modalData.worktreeId === 'string' ? modalData.worktreeId : ''
const currentIssue =
typeof modalData.currentIssue === 'number' ? String(modalData.currentIssue) : ''
const currentComment =
typeof modalData.currentComment === 'string' ? modalData.currentComment : ''
const focusField = typeof modalData.focus === 'string' ? modalData.focus : 'comment'
const [issueInput, setIssueInput] = useState('')
const [commentInput, setCommentInput] = useState('')
const [saving, setSaving] = useState(false)
const issueInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const prevIsOpenRef = useRef(false)
if (isOpen && !prevIsOpenRef.current) {
@@ -50,20 +52,17 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
}, [])
useEffect(() => {
if (isEditComment) {
if (isEditMeta) {
autoResize()
}
}, [isEditComment, commentInput, autoResize])
}, [isEditMeta, commentInput, autoResize])
const canSave = useMemo(() => {
if (!worktreeId) {
return false
}
if (isLinkIssue) {
return issueInput.trim() === '' || parseGitHubIssueOrPRNumber(issueInput) !== null
}
return true
}, [worktreeId, isLinkIssue, issueInput])
return issueInput.trim() === '' || parseGitHubIssueOrPRNumber(issueInput) !== null
}, [worktreeId, issueInput])
const handleOpenChange = useCallback(
(open: boolean) => {
@@ -80,28 +79,24 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
}
setSaving(true)
try {
if (isLinkIssue) {
const trimmed = issueInput.trim()
const linkedIssueNumber = parseGitHubIssueOrPRNumber(trimmed)
if (trimmed === '' || linkedIssueNumber !== null) {
await updateWorktreeMeta(worktreeId, { linkedIssue: linkedIssueNumber })
}
} else if (isEditComment) {
await updateWorktreeMeta(worktreeId, { comment: commentInput.trim() })
const trimmedIssue = issueInput.trim()
const linkedIssueNumber = parseGitHubIssueOrPRNumber(trimmedIssue)
const finalLinkedIssue =
trimmedIssue === '' ? null : linkedIssueNumber !== null ? linkedIssueNumber : undefined
const updates: Partial<WorktreeMeta> = {
comment: commentInput.trim()
}
if (finalLinkedIssue !== undefined) {
updates.linkedIssue = finalLinkedIssue
}
await updateWorktreeMeta(worktreeId, updates)
closeModal()
} finally {
setSaving(false)
}
}, [
worktreeId,
isLinkIssue,
isEditComment,
issueInput,
commentInput,
updateWorktreeMeta,
closeModal
])
}, [worktreeId, issueInput, commentInput, updateWorktreeMeta, closeModal])
const handleCommentKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -113,35 +108,52 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
[handleSave]
)
const handleIssueKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSave()
}
},
[handleSave]
)
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md" onOpenAutoFocus={(e) => e.preventDefault()}>
<DialogContent
className="max-w-md"
onOpenAutoFocus={(e) => {
e.preventDefault()
if (focusField === 'issue') {
issueInputRef.current?.focus()
} else {
textareaRef.current?.focus()
}
}}
>
<DialogHeader>
<DialogTitle className="text-sm">
{isLinkIssue ? 'Link GH Issue/PR' : 'Edit Comment'}
</DialogTitle>
<DialogTitle className="text-sm">Edit Worktree Details</DialogTitle>
<DialogDescription className="text-xs">
{isLinkIssue
? 'Add an issue/PR number or URL to link this worktree. Leave blank to remove the link.'
: 'Add or edit notes for this worktree.'}
Edit the GitHub issue link and notes for this worktree.
</DialogDescription>
</DialogHeader>
{isLinkIssue ? (
<div className="space-y-4">
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">GH Issue / PR</label>
<Input
ref={issueInputRef}
value={issueInput}
onChange={(e) => setIssueInput(e.target.value)}
onKeyDown={handleIssueKeyDown}
placeholder="Issue/PR # or GitHub URL"
className="h-8 text-xs"
autoFocus
/>
<p className="text-[10px] text-muted-foreground">
Paste an issue or PR URL, or enter a number.
Paste an issue or PR URL, or enter a number. Leave blank to remove the link.
</p>
</div>
) : (
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">Comment</label>
<textarea
@@ -151,14 +163,13 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
onKeyDown={handleCommentKeyDown}
placeholder="Notes about this worktree..."
rows={3}
autoFocus
className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-xs shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 resize-none max-h-60 overflow-y-auto"
/>
<p className="text-[10px] text-muted-foreground">
Press Enter to save, Shift+Enter for a new line.
</p>
</div>
)}
</div>
<DialogFooter>
<Button
@@ -0,0 +1,144 @@
import type { IDisposable, ILink, ILinkProvider } from '@xterm/xterm'
import { detectLanguage } from '@/lib/language-detect'
import {
extractTerminalFileLinks,
isPathInsideWorktree,
resolveTerminalFileLink,
toWorktreeRelativePath
} from '@/lib/terminal-links'
import { useAppStore } from '@/store'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
export type LinkHandlerDeps = {
worktreeId: string
worktreePath: string
startupCwd: string
managerRef: React.RefObject<PaneManager | null>
linkProviderDisposablesRef: React.RefObject<Map<number, IDisposable>>
pathExistsCache: Map<string, boolean>
}
export function openDetectedFilePath(
filePath: string,
line: number | null,
column: number | null,
deps: Pick<LinkHandlerDeps, 'worktreeId' | 'worktreePath'>
): void {
const { worktreeId, worktreePath } = deps
void (async () => {
const pathExists = await window.api.shell.pathExists(filePath)
if (!pathExists) {
return
}
if (worktreePath && isPathInsideWorktree(filePath, worktreePath)) {
const relativePath = toWorktreeRelativePath(filePath, worktreePath)
if (relativePath === null || relativePath.length === 0) {
return
}
const store = useAppStore.getState()
store.setActiveWorktree(worktreeId)
store.openFile({
filePath,
relativePath,
worktreeId,
language: detectLanguage(filePath),
mode: 'edit'
})
if (line !== null) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
window.dispatchEvent(
new CustomEvent('orca:editor-reveal-location', {
detail: { filePath, line, column }
})
)
})
})
}
return
}
await window.api.shell.openFilePath(filePath)
})()
}
export function createFilePathLinkProvider(paneId: number, deps: LinkHandlerDeps): ILinkProvider {
const { startupCwd, managerRef, pathExistsCache, worktreeId, worktreePath } = deps
return {
provideLinks: (bufferLineNumber, callback) => {
const pane = managerRef.current?.getPanes().find((candidate) => candidate.id === paneId)
if (!pane) {
callback(undefined)
return
}
const bufferLine = pane.terminal.buffer.active.getLine(bufferLineNumber - 1)
const lineText = bufferLine?.translateToString(true)
if (!lineText) {
callback(undefined)
return
}
const fileLinks = extractTerminalFileLinks(lineText)
if (fileLinks.length === 0) {
callback(undefined)
return
}
void Promise.all(
fileLinks.map(async (parsed): Promise<ILink | null> => {
const resolved = startupCwd ? resolveTerminalFileLink(parsed, startupCwd) : null
if (!resolved) {
return null
}
const cachedExists = pathExistsCache.get(resolved.absolutePath)
const exists = cachedExists ?? (await window.api.shell.pathExists(resolved.absolutePath))
pathExistsCache.set(resolved.absolutePath, exists)
if (!exists) {
return null
}
return {
range: {
start: { x: parsed.startIndex + 1, y: bufferLineNumber },
end: { x: parsed.endIndex + 1, y: bufferLineNumber }
},
text: parsed.displayText,
activate: () => {
openDetectedFilePath(resolved.absolutePath, resolved.line, resolved.column, {
worktreeId,
worktreePath
})
}
}
})
).then((resolvedLinks) => {
const links = resolvedLinks.filter((link): link is ILink => link !== null)
callback(links.length > 0 ? links : undefined)
})
}
}
}
export function handleOscLink(rawText: string): void {
let parsed: URL
try {
parsed = new URL(rawText)
} catch {
return
}
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
void window.api.shell.openUrl(parsed.toString())
return
}
if (parsed.protocol === 'file:') {
void window.api.shell.openFileUri(parsed.toString())
}
}
@@ -1,5 +1,9 @@
import { useEffect, useRef } from 'react'
import type { IDisposable } from '@xterm/xterm'
import { PaneManager } from '@/lib/pane-manager/pane-manager'
import { useAppStore } from '@/store'
import { createFilePathLinkProvider, handleOscLink } from './terminal-link-handlers'
import type { LinkHandlerDeps } from './terminal-link-handlers'
import type { GlobalSettings, TerminalLayoutSnapshot } from '../../../../shared/types'
import { buildFontFamily, replayTerminalLayout } from './layout-serialization'
import { applyExpandedLayoutTo, restoreExpandedLayoutFrom } from './expand-collapse'
@@ -67,6 +71,7 @@ export function useTerminalPaneLifecycle({
}: UseTerminalPaneLifecycleDeps): void {
const systemPrefersDarkRef = useRef(systemPrefersDark)
systemPrefersDarkRef.current = systemPrefersDark
const linkProviderDisposablesRef = useRef(new Map<number, IDisposable>())
const applyAppearance = (manager: PaneManager): void => {
const currentSettings = settingsRef.current
@@ -91,6 +96,24 @@ export function useTerminalPaneLifecycle({
const expandedStyleSnapshots = expandedStyleSnapshotRef.current
const paneTransports = paneTransportsRef.current
const pendingWrites = pendingWritesRef.current
const linkDisposables = linkProviderDisposablesRef.current
const worktreePath =
useAppStore
.getState()
.allWorktrees()
.find((candidate) => candidate.id === worktreeId)?.path ??
cwd ??
''
const startupCwd = cwd ?? worktreePath
const pathExistsCache = new Map<string, boolean>()
const linkDeps: LinkHandlerDeps = {
worktreeId,
worktreePath,
startupCwd,
managerRef,
linkProviderDisposablesRef,
pathExistsCache
}
let resizeRaf: number | null = null
const queueResizeAll = (focusActive: boolean): void => {
@@ -133,11 +156,24 @@ export function useTerminalPaneLifecycle({
const manager = new PaneManager(container, {
onPaneCreated: (pane) => {
const linkProviderDisposable = pane.terminal.registerLinkProvider(
createFilePathLinkProvider(pane.id, linkDeps)
)
linkProviderDisposablesRef.current.set(pane.id, linkProviderDisposable)
pane.terminal.options.linkHandler = {
allowNonHttpProtocols: true,
activate: (_event, text) => handleOscLink(text)
}
applyAppearance(manager)
connectPanePty(pane, manager, ptyDeps)
queueResizeAll(true)
},
onPaneClosed: (paneId) => {
const linkProviderDisposable = linkProviderDisposablesRef.current.get(paneId)
if (linkProviderDisposable) {
linkProviderDisposable.dispose()
linkProviderDisposablesRef.current.delete(paneId)
}
const transport = paneTransportsRef.current.get(paneId)
if (transport) {
transport.destroy?.()
@@ -176,7 +212,7 @@ export function useTerminalPaneLifecycle({
}
},
onLinkClick: (url) => {
window.api.shell.openExternal(url)
void window.api.shell.openUrl(url)
}
})
@@ -217,6 +253,10 @@ export function useTerminalPaneLifecycle({
cancelAnimationFrame(resizeRaf)
}
restoreExpandedLayoutFrom(expandedStyleSnapshots)
for (const disposable of linkDisposables.values()) {
disposable.dispose()
}
linkDisposables.clear()
for (const transport of paneTransports.values()) {
transport.destroy?.()
}
+168
View File
@@ -0,0 +1,168 @@
export type ParsedTerminalFileLink = {
pathText: string
line: number | null
column: number | null
startIndex: number
endIndex: number
displayText: string
}
export type ResolvedTerminalFileLink = {
absolutePath: string
line: number | null
column: number | null
}
const FILE_LINK_CANDIDATE_REGEX =
/(?:\/|\.{1,2}\/|[A-Za-z0-9._-]+\/)[A-Za-z0-9._~\-/]*(?::\d+)?(?::\d+)?/g
const LEADING_TRIM_CHARS = new Set(['(', '[', '{', '"', "'"])
const TRAILING_TRIM_CHARS = new Set([')', ']', '}', '"', "'", ',', ';', '.'])
function trimBoundaryPunctuation(
value: string,
startIndex: number
): { text: string; startIndex: number; endIndex: number } | null {
let start = 0
let end = value.length
while (start < end && LEADING_TRIM_CHARS.has(value[start])) {
start += 1
}
while (end > start && TRAILING_TRIM_CHARS.has(value[end - 1])) {
end -= 1
}
if (start >= end) {
return null
}
return {
text: value.slice(start, end),
startIndex: startIndex + start,
endIndex: startIndex + end
}
}
function parsePathWithOptionalLineColumn(value: string): {
pathText: string
line: number | null
column: number | null
} | null {
const match = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value)
if (!match) {
return null
}
const pathText = match[1]
if (!pathText || pathText.endsWith('/')) {
return null
}
const line = match[2] ? Number.parseInt(match[2], 10) : null
const column = match[3] ? Number.parseInt(match[3], 10) : null
if ((line !== null && line < 1) || (column !== null && column < 1)) {
return null
}
return { pathText, line, column }
}
function normalizeAbsolutePosixPath(pathValue: string): string {
const segments = pathValue.split('/')
const stack: string[] = []
for (const segment of segments) {
if (!segment || segment === '.') {
continue
}
if (segment === '..') {
if (stack.length > 0) {
stack.pop()
}
continue
}
stack.push(segment)
}
return `/${stack.join('/')}`
}
export function extractTerminalFileLinks(lineText: string): ParsedTerminalFileLink[] {
const results: ParsedTerminalFileLink[] = []
const matches = lineText.matchAll(FILE_LINK_CANDIDATE_REGEX)
for (const match of matches) {
const rawText = match[0]
const rawStart = match.index ?? 0
const trimmed = trimBoundaryPunctuation(rawText, rawStart)
if (!trimmed) {
continue
}
const candidateText = trimmed.text
if (candidateText.includes('://')) {
continue
}
const prefix = lineText.slice(0, trimmed.startIndex)
if (/[A-Za-z][A-Za-z0-9+.-]*:\/\/$/.test(prefix)) {
continue
}
if (!candidateText.includes('/')) {
continue
}
const parsed = parsePathWithOptionalLineColumn(candidateText)
if (!parsed) {
continue
}
results.push({
pathText: parsed.pathText,
line: parsed.line,
column: parsed.column,
startIndex: trimmed.startIndex,
endIndex: trimmed.endIndex,
displayText: candidateText
})
}
return results
}
export function resolveTerminalFileLink(
parsed: ParsedTerminalFileLink,
cwd: string
): ResolvedTerminalFileLink | null {
if (!cwd.startsWith('/')) {
return null
}
const absolutePath = parsed.pathText.startsWith('/')
? normalizeAbsolutePosixPath(parsed.pathText)
: normalizeAbsolutePosixPath(`${cwd.replace(/\/+$/, '')}/${parsed.pathText}`)
return {
absolutePath,
line: parsed.line,
column: parsed.column
}
}
export function isPathInsideWorktree(filePath: string, worktreePath: string): boolean {
const normalizedFile = normalizeAbsolutePosixPath(filePath)
const normalizedWorktree = normalizeAbsolutePosixPath(worktreePath)
if (normalizedFile === normalizedWorktree) {
return true
}
return normalizedFile.startsWith(`${normalizedWorktree}/`)
}
export function toWorktreeRelativePath(filePath: string, worktreePath: string): string | null {
const normalizedFile = normalizeAbsolutePosixPath(filePath)
const normalizedWorktree = normalizeAbsolutePosixPath(worktreePath)
if (normalizedFile === normalizedWorktree) {
return ''
}
if (!normalizedFile.startsWith(`${normalizedWorktree}/`)) {
return null
}
return normalizedFile.slice(normalizedWorktree.length + 1)
}
+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 })
})
+1 -1
View File
@@ -10,7 +10,7 @@ export type UISlice = {
setSidebarWidth: (width: number) => void
activeView: 'terminal' | 'settings'
setActiveView: (view: UISlice['activeView']) => void
activeModal: 'none' | 'create-worktree' | 'link-issue' | 'edit-comment' | 'delete-worktree'
activeModal: 'none' | 'create-worktree' | 'edit-meta' | 'delete-worktree'
modalData: Record<string, unknown>
openModal: (modal: UISlice['activeModal'], data?: Record<string, unknown>) => void
closeModal: () => void
+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
}