mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
fix: scope editor files and tabs per worktree (#68)
This commit is contained in:
@@ -44,6 +44,11 @@ export default function Terminal(): React.JSX.Element | null {
|
||||
const tabs = activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []
|
||||
const allWorktrees = Object.values(worktreesByRepo).flat()
|
||||
|
||||
// Filter editor files to only show those belonging to the active worktree
|
||||
const worktreeFiles = activeWorktreeId
|
||||
? openFiles.filter((f) => f.worktreeId === activeWorktreeId)
|
||||
: []
|
||||
|
||||
// Save confirmation dialog state
|
||||
const [saveDialogFileId, setSaveDialogFileId] = useState<string | null>(null)
|
||||
const saveDialogFile = saveDialogFileId ? openFiles.find((f) => f.id === saveDialogFileId) : null
|
||||
@@ -140,7 +145,7 @@ export default function Terminal(): React.JSX.Element | null {
|
||||
createTab(activeWorktreeId)
|
||||
}, [workspaceSessionReady, activeWorktreeId, tabs.length, createTab])
|
||||
|
||||
const totalTabs = tabs.length + openFiles.length
|
||||
const totalTabs = tabs.length + worktreeFiles.length
|
||||
|
||||
const handleNewTab = useCallback(() => {
|
||||
if (!activeWorktreeId) {
|
||||
@@ -281,7 +286,9 @@ export default function Terminal(): React.JSX.Element | null {
|
||||
if (e.metaKey && e.shiftKey && (e.key === ']' || e.key === '[') && !e.repeat) {
|
||||
const state = useAppStore.getState()
|
||||
const currentTerminalTabs = state.tabsByWorktree[activeWorktreeId] ?? []
|
||||
const currentEditorFiles = state.openFiles
|
||||
const currentEditorFiles = activeWorktreeId
|
||||
? state.openFiles.filter((f) => f.worktreeId === activeWorktreeId)
|
||||
: []
|
||||
|
||||
// Build unified tab list: terminal tabs then editor tabs
|
||||
const allTabIds: { type: 'terminal' | 'editor'; id: string }[] = [
|
||||
@@ -347,7 +354,7 @@ export default function Terminal(): React.JSX.Element | null {
|
||||
onSetTabColor={setTabColor}
|
||||
expandedPaneByTabId={expandedPaneByTabId}
|
||||
onTogglePaneExpand={handleTogglePaneExpand}
|
||||
editorFiles={openFiles}
|
||||
editorFiles={worktreeFiles}
|
||||
activeFileId={activeFileId}
|
||||
activeTabType={activeTabType}
|
||||
onActivateFile={(fileId) => {
|
||||
@@ -363,7 +370,7 @@ export default function Terminal(): React.JSX.Element | null {
|
||||
|
||||
{/* Terminal panes container - hidden when editor tab active */}
|
||||
<div
|
||||
className={`relative flex-1 min-h-0 overflow-hidden ${activeTabType === 'editor' && openFiles.length > 0 ? 'hidden' : ''}`}
|
||||
className={`relative flex-1 min-h-0 overflow-hidden ${activeTabType === 'editor' && worktreeFiles.length > 0 ? 'hidden' : ''}`}
|
||||
>
|
||||
{allWorktrees
|
||||
.filter((wt) => mountedWorktreeIdsRef.current.has(wt.id))
|
||||
@@ -393,7 +400,7 @@ export default function Terminal(): React.JSX.Element | null {
|
||||
</div>
|
||||
|
||||
{/* Editor panel - shown when editor tab is active */}
|
||||
{activeWorktreeId && activeTabType === 'editor' && openFiles.length > 0 && (
|
||||
{activeWorktreeId && activeTabType === 'editor' && worktreeFiles.length > 0 && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type { GitStatusEntry } from '../../../../shared/types'
|
||||
|
||||
export interface OpenFile {
|
||||
export type OpenFile = {
|
||||
id: string // use filePath as unique key
|
||||
filePath: string // absolute path
|
||||
relativePath: string // relative to worktree root
|
||||
@@ -15,7 +15,7 @@ export interface OpenFile {
|
||||
|
||||
export type RightSidebarTab = 'explorer' | 'source-control'
|
||||
|
||||
export interface EditorSlice {
|
||||
export type EditorSlice = {
|
||||
// Right sidebar
|
||||
rightSidebarOpen: boolean
|
||||
rightSidebarWidth: number
|
||||
@@ -32,6 +32,8 @@ export interface EditorSlice {
|
||||
// Open files / editor tabs
|
||||
openFiles: OpenFile[]
|
||||
activeFileId: string | null
|
||||
activeFileIdByWorktree: Record<string, string | null> // worktreeId -> last active file
|
||||
activeTabTypeByWorktree: Record<string, 'terminal' | 'editor'> // worktreeId -> last active tab type
|
||||
activeTabType: 'terminal' | 'editor'
|
||||
setActiveTabType: (type: 'terminal' | 'editor') => void
|
||||
openFile: (file: Omit<OpenFile, 'id' | 'isDirty'>) => void
|
||||
@@ -80,57 +82,138 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
// Open files
|
||||
openFiles: [],
|
||||
activeFileId: null,
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
activeTabType: 'terminal',
|
||||
setActiveTabType: (type) => set({ activeTabType: type }),
|
||||
setActiveTabType: (type) =>
|
||||
set((s) => {
|
||||
const worktreeId = s.activeWorktreeId
|
||||
return {
|
||||
activeTabType: type,
|
||||
activeTabTypeByWorktree: worktreeId
|
||||
? { ...s.activeTabTypeByWorktree, [worktreeId]: type }
|
||||
: s.activeTabTypeByWorktree
|
||||
}
|
||||
}),
|
||||
|
||||
openFile: (file) =>
|
||||
set((s) => {
|
||||
const id = file.filePath
|
||||
const existing = s.openFiles.find((f) => f.id === id)
|
||||
const worktreeId = file.worktreeId
|
||||
if (existing) {
|
||||
// If it's already open, just activate it (and update mode if needed)
|
||||
if (existing.mode === file.mode && existing.diffStaged === file.diffStaged) {
|
||||
return { activeFileId: id, activeTabType: 'editor' }
|
||||
return {
|
||||
activeFileId: id,
|
||||
activeTabType: 'editor',
|
||||
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
|
||||
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
|
||||
}
|
||||
}
|
||||
// Update the existing file entry
|
||||
return {
|
||||
openFiles: s.openFiles.map((f) =>
|
||||
f.id === id ? { ...f, mode: file.mode, diffStaged: file.diffStaged } : f
|
||||
),
|
||||
activeFileId: id,
|
||||
activeTabType: 'editor'
|
||||
activeTabType: 'editor',
|
||||
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
|
||||
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
|
||||
}
|
||||
}
|
||||
return {
|
||||
openFiles: [...s.openFiles, { ...file, id, isDirty: false }],
|
||||
activeFileId: id,
|
||||
activeTabType: 'editor'
|
||||
activeTabType: 'editor',
|
||||
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
|
||||
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
|
||||
}
|
||||
}),
|
||||
|
||||
closeFile: (fileId) =>
|
||||
set((s) => {
|
||||
const closedFile = s.openFiles.find((f) => f.id === fileId)
|
||||
const idx = s.openFiles.findIndex((f) => f.id === fileId)
|
||||
const newFiles = s.openFiles.filter((f) => f.id !== fileId)
|
||||
let newActiveId = s.activeFileId
|
||||
const newActiveFileIdByWorktree = { ...s.activeFileIdByWorktree }
|
||||
|
||||
if (s.activeFileId === fileId) {
|
||||
// Activate adjacent tab
|
||||
if (newFiles.length === 0) {
|
||||
// Find next file within the same worktree
|
||||
const worktreeId = closedFile?.worktreeId
|
||||
const worktreeFiles = worktreeId
|
||||
? newFiles.filter((f) => f.worktreeId === worktreeId)
|
||||
: newFiles
|
||||
if (worktreeFiles.length === 0) {
|
||||
newActiveId = null
|
||||
} else if (idx >= newFiles.length) {
|
||||
newActiveId = newFiles[newFiles.length - 1].id
|
||||
} else {
|
||||
newActiveId = newFiles[idx].id
|
||||
// Pick adjacent file from same worktree
|
||||
const closedWorktreeIdx = worktreeId
|
||||
? s.openFiles
|
||||
.filter((f) => f.worktreeId === worktreeId)
|
||||
.findIndex((f) => f.id === fileId)
|
||||
: idx
|
||||
newActiveId =
|
||||
closedWorktreeIdx >= worktreeFiles.length
|
||||
? worktreeFiles.at(-1)!.id
|
||||
: worktreeFiles[closedWorktreeIdx].id
|
||||
}
|
||||
if (worktreeId) {
|
||||
newActiveFileIdByWorktree[worktreeId] = newActiveId
|
||||
}
|
||||
}
|
||||
// When last editor file is closed, switch back to terminal
|
||||
const newActiveTabType = newFiles.length === 0 ? 'terminal' : s.activeTabType
|
||||
return { openFiles: newFiles, activeFileId: newActiveId, activeTabType: newActiveTabType }
|
||||
|
||||
// When last editor file for current worktree is closed, switch back to terminal
|
||||
const activeWorktreeId = s.activeWorktreeId
|
||||
const remainingForWorktree = activeWorktreeId
|
||||
? newFiles.filter((f) => f.worktreeId === activeWorktreeId)
|
||||
: newFiles
|
||||
const newActiveTabType = remainingForWorktree.length === 0 ? 'terminal' : s.activeTabType
|
||||
const newActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
|
||||
if (activeWorktreeId && remainingForWorktree.length === 0) {
|
||||
newActiveTabTypeByWorktree[activeWorktreeId] = 'terminal'
|
||||
}
|
||||
|
||||
return {
|
||||
openFiles: newFiles,
|
||||
activeFileId: newActiveId,
|
||||
activeTabType: newActiveTabType,
|
||||
activeFileIdByWorktree: newActiveFileIdByWorktree,
|
||||
activeTabTypeByWorktree: newActiveTabTypeByWorktree
|
||||
}
|
||||
}),
|
||||
|
||||
closeAllFiles: () => set({ openFiles: [], activeFileId: null, activeTabType: 'terminal' }),
|
||||
closeAllFiles: () =>
|
||||
set((s) => {
|
||||
const activeWorktreeId = s.activeWorktreeId
|
||||
if (!activeWorktreeId) {
|
||||
return { openFiles: [], activeFileId: null, activeTabType: 'terminal' }
|
||||
}
|
||||
// Only close files for the current worktree
|
||||
const newFiles = s.openFiles.filter((f) => f.worktreeId !== activeWorktreeId)
|
||||
const newActiveFileIdByWorktree = { ...s.activeFileIdByWorktree }
|
||||
delete newActiveFileIdByWorktree[activeWorktreeId]
|
||||
const newActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
|
||||
newActiveTabTypeByWorktree[activeWorktreeId] = 'terminal'
|
||||
return {
|
||||
openFiles: newFiles,
|
||||
activeFileId: null,
|
||||
activeTabType: 'terminal',
|
||||
activeFileIdByWorktree: newActiveFileIdByWorktree,
|
||||
activeTabTypeByWorktree: newActiveTabTypeByWorktree
|
||||
}
|
||||
}),
|
||||
|
||||
setActiveFile: (fileId) => set({ activeFileId: fileId }),
|
||||
setActiveFile: (fileId) =>
|
||||
set((s) => {
|
||||
const file = s.openFiles.find((f) => f.id === fileId)
|
||||
const worktreeId = file?.worktreeId
|
||||
return {
|
||||
activeFileId: fileId,
|
||||
activeFileIdByWorktree: worktreeId
|
||||
? { ...s.activeFileIdByWorktree, [worktreeId]: fileId }
|
||||
: s.activeFileIdByWorktree
|
||||
}
|
||||
}),
|
||||
|
||||
markFileDirty: (fileId, dirty) =>
|
||||
set((s) => ({
|
||||
@@ -139,11 +222,15 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
|
||||
openDiff: (worktreeId, filePath, relativePath, language, staged) =>
|
||||
set((s) => {
|
||||
// Use a unique ID that includes staging state to allow both staged and unstaged diffs
|
||||
const id = `${filePath}${staged ? '::staged' : ''}`
|
||||
const existing = s.openFiles.find((f) => f.id === id)
|
||||
if (existing) {
|
||||
return { activeFileId: id, activeTabType: 'editor' }
|
||||
return {
|
||||
activeFileId: id,
|
||||
activeTabType: 'editor',
|
||||
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
|
||||
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
|
||||
}
|
||||
}
|
||||
const newFile: OpenFile = {
|
||||
id,
|
||||
@@ -158,7 +245,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
return {
|
||||
openFiles: [...s.openFiles, newFile],
|
||||
activeFileId: id,
|
||||
activeTabType: 'editor'
|
||||
activeTabType: 'editor',
|
||||
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
|
||||
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -167,7 +256,12 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
const id = `${worktreeId}::all-diffs`
|
||||
const existing = s.openFiles.find((f) => f.id === id)
|
||||
if (existing) {
|
||||
return { activeFileId: id, activeTabType: 'editor' }
|
||||
return {
|
||||
activeFileId: id,
|
||||
activeTabType: 'editor',
|
||||
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
|
||||
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
|
||||
}
|
||||
}
|
||||
const newFile: OpenFile = {
|
||||
id,
|
||||
@@ -182,7 +276,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
return {
|
||||
openFiles: [...s.openFiles, newFile],
|
||||
activeFileId: id,
|
||||
activeTabType: 'editor'
|
||||
activeTabType: 'editor',
|
||||
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
|
||||
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
@@ -96,6 +96,16 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
}
|
||||
const nextDeleteState = { ...s.deleteStateByWorktreeId }
|
||||
delete nextDeleteState[worktreeId]
|
||||
// Clean up editor files belonging to this worktree
|
||||
const newOpenFiles = s.openFiles.filter((f) => f.worktreeId !== worktreeId)
|
||||
const nextActiveFileIdByWorktree = { ...s.activeFileIdByWorktree }
|
||||
delete nextActiveFileIdByWorktree[worktreeId]
|
||||
const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
|
||||
delete nextActiveTabTypeByWorktree[worktreeId]
|
||||
// If the active file belonged to the removed worktree, clear it
|
||||
const activeFileCleared = s.activeFileId
|
||||
? s.openFiles.some((f) => f.id === s.activeFileId && f.worktreeId === worktreeId)
|
||||
: false
|
||||
return {
|
||||
worktreesByRepo: next,
|
||||
tabsByWorktree: nextTabs,
|
||||
@@ -103,7 +113,12 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
terminalLayoutsByTabId: nextLayouts,
|
||||
deleteStateByWorktreeId: nextDeleteState,
|
||||
activeWorktreeId: s.activeWorktreeId === worktreeId ? null : s.activeWorktreeId,
|
||||
activeTabId: s.activeTabId && tabIds.has(s.activeTabId) ? null : s.activeTabId
|
||||
activeTabId: s.activeTabId && tabIds.has(s.activeTabId) ? null : s.activeTabId,
|
||||
openFiles: newOpenFiles,
|
||||
activeFileIdByWorktree: nextActiveFileIdByWorktree,
|
||||
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
|
||||
activeFileId: activeFileCleared ? null : s.activeFileId,
|
||||
activeTabType: activeFileCleared ? 'terminal' : s.activeTabType
|
||||
}
|
||||
})
|
||||
return { ok: true as const }
|
||||
@@ -189,8 +204,31 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
|
||||
const worktree = findWorktreeById(s.worktreesByRepo, worktreeId)
|
||||
shouldClearUnread = Boolean(worktree?.isUnread)
|
||||
|
||||
// Restore per-worktree editor state
|
||||
const restoredFileId = s.activeFileIdByWorktree[worktreeId] ?? null
|
||||
const restoredTabType = s.activeTabTypeByWorktree[worktreeId] ?? 'terminal'
|
||||
// Verify the restored file still exists in openFiles
|
||||
const fileStillOpen = restoredFileId
|
||||
? s.openFiles.some((f) => f.id === restoredFileId)
|
||||
: false
|
||||
|
||||
// If restored file is gone, fall back to another open file for this worktree
|
||||
let activeFileId: string | null
|
||||
let activeTabType: 'terminal' | 'editor'
|
||||
if (fileStillOpen) {
|
||||
activeFileId = restoredFileId
|
||||
activeTabType = restoredTabType
|
||||
} else {
|
||||
const fallbackFile = s.openFiles.find((f) => f.worktreeId === worktreeId)
|
||||
activeFileId = fallbackFile?.id ?? null
|
||||
activeTabType = fallbackFile ? 'editor' : 'terminal'
|
||||
}
|
||||
|
||||
return {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeFileId,
|
||||
activeTabType,
|
||||
worktreesByRepo: shouldClearUnread
|
||||
? applyWorktreeUpdates(s.worktreesByRepo, worktreeId, { isUnread: false })
|
||||
: s.worktreesByRepo
|
||||
|
||||
Reference in New Issue
Block a user