fix(editor): address review feedback on the split editor slice (#14850)

* fix(editor): address review feedback on the split editor slice

- Localize the conflict-placeholder guidance string (en/es/ja/ko/zh).
- Filter target-worktree tabs by migrated tab id so an owner transition
  cannot leave two tabs sharing one id.
- Resolve a pending editor reveal by fileId first; the oldFilePath scan
  could pick another worktree's rekey.
- Return before opening a workspace editor item when conflict metadata
  is missing, so no tab is created for a file that never entered openFiles.
- Skip a persisted open file whose resolved id is already used; the
  session schema allows repeated (path, worktree, runtime) tuples.

* fix(editor): remove migrated tab ids from sibling groups

When tabs migrate to a target group during editor owner transition, the
same tab IDs can be left in sibling groups, causing state corruption.
Strip these IDs from all sibling groups to ensure each tab ID exists
only once across the editor layout.
This commit is contained in:
Jinjing
2026-08-16 00:27:01 -07:00
committed by GitHub
parent 6cf6a7faff
commit 470ef65bd7
15 changed files with 220 additions and 7 deletions
+1
View File
@@ -262,6 +262,7 @@
},
"editor": {
"dcb521ed29": "This file is in a conflict state, but no working-tree file is available to edit.",
"conflictPlaceholderGuidance": "Resolve the conflict in Git or restore one side before reopening it.",
"51f15c37d3": "Cannot open directory: {{value0}}",
"f2e00db373": "File not found: {{value0}}",
"checkRunDetailsUnavailable": "No details are available for this check.",
+1
View File
@@ -196,6 +196,7 @@
},
"editor": {
"dcb521ed29": "Este archivo está en conflicto, pero no hay una copia editable en el worktree.",
"conflictPlaceholderGuidance": "Resuelve el conflicto en Git o restaura uno de los lados antes de volver a abrirlo.",
"51f15c37d3": "No se puede abrir la carpeta: {{value0}}",
"f2e00db373": "No se encontró el archivo: {{value0}}",
"checkRunDetailsUnavailable": "No hay detalles disponibles para este check.",
+1
View File
@@ -196,6 +196,7 @@
},
"editor": {
"dcb521ed29": "このファイルは競合状態にありますが、編集できる作業ツリーファイルがありません。",
"conflictPlaceholderGuidance": "Git で競合を解決するか、いずれかの側を復元してから開き直してください。",
"51f15c37d3": "ディレクトリを開けません: {{value0}}",
"f2e00db373": "ファイルが見つかりません: {{value0}}",
"checkRunDetailsUnavailable": "このチェックの詳細はありません。",
+1
View File
@@ -196,6 +196,7 @@
},
"editor": {
"dcb521ed29": "이 파일은 충돌 상태에 있지만 편집할 수 있는 작업 트리 파일이 없습니다.",
"conflictPlaceholderGuidance": "Git에서 충돌을 해결하거나 한쪽을 복원한 후 다시 여세요.",
"51f15c37d3": "디렉터리를 열 수 없습니다: {{value0}}",
"f2e00db373": "파일을 찾을 수 없습니다: {{value0}}",
"checkRunDetailsUnavailable": "이 체크에 사용할 수 있는 세부 정보가 없습니다.",
+1
View File
@@ -196,6 +196,7 @@
},
"editor": {
"dcb521ed29": "该文件处于冲突状态,但没有可编辑的工作树文件。",
"conflictPlaceholderGuidance": "请在 Git 中解决冲突,或恢复其中一侧后再重新打开。",
"51f15c37d3": "无法打开目录:{{value0}}",
"f2e00db373": "未找到文件:{{value0}}",
"checkRunDetailsUnavailable": "此检查没有可用的详细信息。",
@@ -269,6 +269,40 @@ describe('rekeyOpenFilesForPathChange', () => {
expect(useAppStore.getState().openFiles[0]!.mirroredFromRuntimeSession).toBeUndefined()
})
it('leaves a reveal keyed by another worktree file id untouched when only the path matches', () => {
seedEditTab()
// A same-path tab in a second worktree: the reveal belongs to it, not to the rekeyed tab.
useAppStore.getState().openFile(
{
filePath: '/repo/a.md',
relativePath: 'a.md',
worktreeId: 'wt-2',
runtimeEnvironmentId: null,
language: 'markdown',
mode: 'edit'
},
{ suppressActiveRuntimeFallback: true }
)
const rekeyedId = useAppStore.getState().openFiles[0]!.id
const otherWorktreeId = useAppStore.getState().openFiles[1]!.id
expect(otherWorktreeId).not.toBe(rekeyedId)
useAppStore.setState({
pendingEditorReveal: { fileId: otherWorktreeId, filePath: '/repo/a.md', line: 40 }
} as never)
const result = useAppStore.getState().rekeyOpenFilesForPathChange({
rekeys: [rekeyFor(rekeyedId, '/repo/sub/a.md', 'sub/a.md')]
})
expect(result).toEqual({ ok: true })
expect(
useAppStore.getState().openFiles.find((file) => file.id === '/repo/sub/a.md')
).toMatchObject({ filePath: '/repo/sub/a.md' })
const reveal = useAppStore.getState().pendingEditorReveal!
expect(reveal.fileId).toBe(otherWorktreeId)
expect(reveal.filePath).toBe('/repo/a.md')
})
it('migrates a pending editor reveal to the new path', () => {
seedEditTab()
const oldId = useAppStore.getState().openFiles[0]!.id
@@ -61,6 +61,10 @@ export function createHydrateEditorSession(
usedOpenFileIds.has(pf.filePath)
? ownedId
: pf.filePath
// Why: the persisted schema allows repeated (path, worktree, runtime) tuples, and an owned id repeats verbatim — restoring both would put two files under one id.
if (usedOpenFileIds.has(id)) {
continue
}
usedOpenFileIds.add(id)
// Why: map from the collision-derived legacy id; keying by filePath would collapse same-path local/runtime tabs onto the last owner to hydrate.
addEditorFileIdMigration(editorFileIdMigrationsByWorktree, worktreeId, legacyId, id)
@@ -19,6 +19,7 @@ export function createOpenConflictFile(
const absolutePath = joinPath(worktreePath, entry.path)
const isPreview = options?.preview ?? false
let editorItemTargetGroupId = options?.targetGroupId
let openedConflictFile = true
set((s) => {
const id = absolutePath
const conflict = toOpenConflictMetadata(entry)
@@ -35,6 +36,7 @@ export function createOpenConflictFile(
: s.trackedConflictPathsByWorktree[worktreeId]
if (!conflict) {
openedConflictFile = false
return s
}
@@ -115,6 +117,10 @@ export function createOpenConflictFile(
: { ...s.trackedConflictPathsByWorktree, [worktreeId]: nextTracked }
}
})
// Why: no conflict metadata means no OpenFile was added, so a workspace tab would point at nothing.
if (!openedConflictFile) {
return
}
void openWorkspaceEditorItem(
get(),
absolutePath,
@@ -15,6 +15,7 @@ export function createOpenConflictReview(
const reviewTab = (get().unifiedTabsByWorktree?.[worktreeId] ?? []).find(
(tab) => tab.entityId === reviewFileId && tab.contentType === 'conflict-review'
)
let openedConflictFile = true
set((s) => {
const conflict = toOpenConflictMetadata(entry)
const existing = s.openFiles.find((f) => f.id === absolutePath)
@@ -27,6 +28,7 @@ export function createOpenConflictReview(
: s.trackedConflictPathsByWorktree[worktreeId]
if (!conflict) {
openedConflictFile = false
return s
}
@@ -91,6 +93,10 @@ export function createOpenConflictReview(
}
})
// Why: no conflict metadata means no OpenFile was added, so a workspace tab would point at nothing.
if (!openedConflictFile) {
return
}
// Why: the conflict file needs a normal editor backing tab for save/close, but selecting from Conflict Review must keep the review tab visible; restore focus after.
void openWorkspaceEditorItem(
get(),
@@ -105,9 +105,12 @@ export function createRekeyOpenFilesAction(
}
const reveal = s.pendingEditorReveal
const rekeyForReveal = reveal
? rekeys.find((r) => r.oldFilePath === reveal.filePath)
: undefined
// Why: two worktrees can rekey the same oldFilePath, so an id-keyed reveal must match its own file, not the first path match.
const rekeyForReveal = !reveal
? undefined
: reveal.fileId
? rekeyByOldId.get(reveal.fileId)
: rekeys.find((r) => r.oldFilePath === reveal.filePath)
return {
openFiles: nextOpenFiles,
@@ -6,6 +6,7 @@ import { sanitizeRecentTabIds } from '../../tab-group-state'
import {
nextActiveIdAfterRemoval,
removeEmptyEditorGroups,
removeTabIdsFromGroup,
rekeyFileIdRecord
} from '../file-ids/open-file-path-rekey'
import type {
@@ -44,6 +45,7 @@ export function buildRestoredEditorOwnerTransition(
movedTabs.map((tab) => [tab.id, migrations.get(tab.id) ?? tab.id])
)
const mappedMovedTabIds = movedTabs.map((tab) => tabIdMigration.get(tab.id) ?? tab.id)
const mappedMovedTabIdSet = new Set(mappedMovedTabIds)
const mappedMovedTabBarIds = movedTabs.map(
(tab) => migrations.get(tab.entityId) ?? tab.entityId
)
@@ -93,16 +95,27 @@ export function buildRestoredEditorOwnerTransition(
destinationOrder
)
}
// Why: the migrated ids land in targetGroup only, so any sibling group holding the same id is left dangling.
const nextTargetGroups = targetGroups.some((group) => group.id === targetGroupId)
? targetGroups.map((group) => (group.id === targetGroupId ? updatedTargetGroup : group))
: [...targetGroups, updatedTargetGroup]
? targetGroups.map((group) =>
group.id === targetGroupId
? updatedTargetGroup
: removeTabIdsFromGroup(group, mappedMovedTabIdSet)
)
: [
...targetGroups.map((group) => removeTabIdsFromGroup(group, mappedMovedTabIdSet)),
updatedTargetGroup
]
const nextUnifiedTabsByWorktree = { ...s.unifiedTabsByWorktree }
nextUnifiedTabsByWorktree[sourceWorktreeId] = (
nextUnifiedTabsByWorktree[sourceWorktreeId] ?? []
).filter((tab) => !movedTabIds.has(tab.id))
nextUnifiedTabsByWorktree[targetWorktreeId] = [
...(nextUnifiedTabsByWorktree[targetWorktreeId] ?? []),
// Why: a leftover target tab carrying a migrated id would duplicate the id that destinationOrder keeps only once.
...(nextUnifiedTabsByWorktree[targetWorktreeId] ?? []).filter(
(tab) => !mappedMovedTabIdSet.has(tab.id)
),
...movedTabs.map((tab) => ({
...tab,
id: tabIdMigration.get(tab.id) ?? tab.id,
@@ -1,6 +1,7 @@
import type { AppState } from '../../../types'
import type { TabGroup } from '../../../../../../shared/tab-types'
import { pruneTabGroupLayoutForGroups } from '../../tabs-hydration'
import { sanitizeRecentTabIds } from '../../tab-group-state'
export function rekeyFileIdRecord<T>(
record: Record<string, T>,
@@ -29,6 +30,29 @@ export function nextActiveIdAfterRemoval(
return recent ?? ids.find((id) => !removedIds.has(id)) ?? null
}
/** Strip `removedIds` from a group's order, MRU stack and active id; returns the
* same object when the group never referenced them. */
export function removeTabIdsFromGroup(group: TabGroup, removedIds: ReadonlySet<string>): TabGroup {
const recentTabIds = group.recentTabIds ?? []
const references =
group.tabOrder.some((id) => removedIds.has(id)) ||
recentTabIds.some((id) => removedIds.has(id)) ||
(group.activeTabId !== null && removedIds.has(group.activeTabId))
if (!references) {
return group
}
const tabOrder = group.tabOrder.filter((id) => !removedIds.has(id))
return {
...group,
activeTabId:
group.activeTabId !== null && removedIds.has(group.activeTabId)
? nextActiveIdAfterRemoval(group.tabOrder, recentTabIds, removedIds)
: group.activeTabId,
tabOrder,
recentTabIds: sanitizeRecentTabIds(recentTabIds, tabOrder)
}
}
export function removeEmptyEditorGroups(
previousGroups: TabGroup[],
groups: TabGroup[],
@@ -76,7 +76,10 @@ export function toOpenConflictMetadata(entry: GitStatusEntry): OpenConflictMetad
'auto.store.slices.editor.dcb521ed29',
'This file is in a conflict state, but no working-tree file is available to edit.'
),
guidance: 'Resolve the conflict in Git or restore one side before reopening it.'
guidance: translate(
'auto.store.slices.editor.conflictPlaceholderGuidance',
'Resolve the conflict in Git or restore one side before reopening it.'
)
}
}
@@ -315,6 +315,80 @@ describe('restored editor owner reparent', () => {
expect(refreshGitHubForWorktreeIfStale).toHaveBeenCalledWith(TARGET)
})
it('repairs a duplicate migrated tab id held by a non-selected destination group', () => {
const oldId = openRestoredSource()
const movedTabId = useAppStore.getState().unifiedTabsByWorktree[SOURCE]![0]!.id
const keptFileId = useAppStore.getState().openFile(
{
filePath: '/repo-b/docs/other.md',
relativePath: 'docs/other.md',
worktreeId: TARGET,
runtimeEnvironmentId: null,
language: 'markdown',
mode: 'edit'
},
{ suppressActiveRuntimeFallback: true }
)
const keptTab = useAppStore
.getState()
.unifiedTabsByWorktree[TARGET]!.find((tab) => tab.entityId === keptFileId)!
useAppStore.setState((state) => ({
unifiedTabsByWorktree: {
...state.unifiedTabsByWorktree,
[TARGET]: [
{ ...keptTab, groupId: 'other-group' },
// A stale duplicate carrying the id the source tab keeps through the move.
{ ...keptTab, id: movedTabId, groupId: 'other-group' }
]
},
groupsByWorktree: {
...state.groupsByWorktree,
[TARGET]: [
{
id: 'target-group',
worktreeId: TARGET,
activeTabId: null,
tabOrder: [],
recentTabIds: []
},
{
id: 'other-group',
worktreeId: TARGET,
activeTabId: movedTabId,
tabOrder: [keptTab.id, movedTabId],
recentTabIds: [keptTab.id, movedTabId]
}
]
},
layoutByWorktree: {
...state.layoutByWorktree,
[TARGET]: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'target-group' },
second: { type: 'leaf', groupId: 'other-group' }
}
},
activeGroupIdByWorktree: { ...state.activeGroupIdByWorktree, [TARGET]: 'target-group' }
}))
expect(reparent(oldId).ok).toBe(true)
const next = useAppStore.getState()
const groups = next.groupsByWorktree[TARGET]!
const otherGroup = groups.find((group) => group.id === 'other-group')!
expect(otherGroup).toMatchObject({
activeTabId: keptTab.id,
tabOrder: [keptTab.id],
recentTabIds: [keptTab.id]
})
expect(groups.find((group) => group.id === 'target-group')?.tabOrder).toContain(movedTabId)
// The migrated id survives exactly once, in the group that now owns the tab.
expect(next.unifiedTabsByWorktree[TARGET]!.filter((tab) => tab.id === movedTabId)).toHaveLength(
1
)
})
it('persists and restores the destination owner and dirty hot-exit draft', () => {
const oldId = openRestoredSource()
useAppStore.getState().setEditorDraft(oldId, 'hot exit')
@@ -383,6 +383,47 @@ describe('hydrateEditorSession', () => {
)
})
it('drops a duplicate persisted file that would restore under an already used id', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const filePath = '/path/wt1/src/app.ts'
const runtimeEnvironmentId = 'runtime-1'
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
activeWorktreeId: wt
})
const persistedFile = {
filePath,
relativePath: 'src/app.ts',
worktreeId: wt,
language: 'typescript',
runtimeEnvironmentId
}
store.getState().hydrateEditorSession({
activeRepoId: 'repo1',
activeWorktreeId: wt,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
// The schema allows a repeated (path, worktree, runtime) tuple; both entries resolve to one owned id.
openFilesByWorktree: { [wt]: [persistedFile, { ...persistedFile }] },
activeFileIdByWorktree: {},
activeTabTypeByWorktree: { [wt]: 'editor' as const }
})
const s = store.getState()
expect(s.openFiles.map((file) => file.id)).toEqual([
ownedEditorFileId(filePath, wt, runtimeEnvironmentId)
])
})
it('keeps floating owner-qualified editor ids aligned with restored unified tabs', () => {
const store = createTestStore()
const sharedPath = '/path/wt1/README.md'