fix: scope editor files and tabs per worktree (#68)

This commit is contained in:
Jinjing
2026-03-23 16:45:00 -07:00
committed by GitHub
parent 90d1da84fb
commit 2163c67035
3 changed files with 170 additions and 29 deletions
+12 -5
View File
@@ -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">
+119 -23
View File
@@ -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' }
}
}),
+39 -1
View File
@@ -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