fix(mobile): sync non-markdown editor files to mobile session tabs (#5582)
* fix(mobile): sync non-markdown editor files to mobile session tabs A regression stopped non-markdown files opened on desktop from reaching the mobile session-tab list: the group projection order surfaced markdown tabs but dropped plain file tabs. Append any open editor file for the worktree that the projection didn't emit, keyed by relativePath, so plain files show up on mobile again (markdown still syncs via the projection). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mobile): preserve recovered editor session tab identity Co-authored-by: Orca <help@stably.ai> * fix(mobile): tolerate legacy active tab metadata Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
f534cd5bc3
commit
b8f2f95100
|
|
@ -21,6 +21,8 @@ function makeState(overrides: Partial<AppState> = {}): AppState {
|
|||
tabBarOrderByWorktree: {},
|
||||
activeFileId: null,
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabType: 'terminal',
|
||||
activeTabTypeByWorktree: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
|
|
@ -53,6 +55,8 @@ function makeSharedOverrides(): Partial<AppState> {
|
|||
unifiedTabsByWorktree: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabType: 'terminal',
|
||||
activeTabTypeByWorktree: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
|
|
@ -606,6 +610,448 @@ describe('buildMobileSessionTabSnapshots', () => {
|
|||
expect(tab).not.toHaveProperty('diffSource')
|
||||
})
|
||||
|
||||
it('publishes a missing non-markdown editor with its unified tab id and split group', () => {
|
||||
const fileId = '/repo/src/app.ts'
|
||||
const state = makeState({
|
||||
activeGroupIdByWorktree: { 'wt-1': 'group-left' },
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-left',
|
||||
activeTabId: 'browser-tab-1',
|
||||
tabOrder: ['browser-tab-1'],
|
||||
recentTabIds: ['browser-tab-1']
|
||||
},
|
||||
{
|
||||
id: 'group-right',
|
||||
activeTabId: 'editor-tab-1',
|
||||
tabOrder: [],
|
||||
recentTabIds: []
|
||||
}
|
||||
]
|
||||
} as unknown as AppState['groupsByWorktree'],
|
||||
layoutByWorktree: {
|
||||
'wt-1': {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'group-left' },
|
||||
second: { type: 'leaf', groupId: 'group-right' }
|
||||
}
|
||||
} as unknown as AppState['layoutByWorktree'],
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'browser-tab-1',
|
||||
groupId: 'group-left',
|
||||
contentType: 'browser',
|
||||
entityId: 'browser-1',
|
||||
title: 'Docs'
|
||||
},
|
||||
{
|
||||
id: 'editor-tab-1',
|
||||
groupId: 'group-right',
|
||||
contentType: 'editor',
|
||||
entityId: fileId,
|
||||
title: 'app.ts'
|
||||
}
|
||||
]
|
||||
} as unknown as AppState['unifiedTabsByWorktree'],
|
||||
browserTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'browser-1',
|
||||
worktreeId: 'wt-1',
|
||||
activePageId: 'page-1',
|
||||
pageIds: ['page-1'],
|
||||
url: 'https://example.test',
|
||||
title: 'Docs',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
} as unknown as AppState['browserTabsByWorktree'],
|
||||
browserPagesByWorkspace: {
|
||||
'browser-1': [
|
||||
{
|
||||
id: 'page-1',
|
||||
workspaceId: 'browser-1',
|
||||
worktreeId: 'wt-1',
|
||||
url: 'https://example.test',
|
||||
title: 'Docs',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
} as unknown as AppState['browserPagesByWorkspace'],
|
||||
openFiles: [
|
||||
{
|
||||
id: fileId,
|
||||
filePath: fileId,
|
||||
relativePath: 'src/app.ts',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
mode: 'edit',
|
||||
isDirty: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const snapshot = buildMobileSessionTabSnapshots(state)[0]
|
||||
|
||||
expect(snapshot?.tabs.map((tab) => tab.id)).toEqual(['browser-tab-1', 'editor-tab-1'])
|
||||
expect(snapshot?.tabs.at(-1)).toMatchObject({
|
||||
type: 'file',
|
||||
id: 'editor-tab-1',
|
||||
relativePath: 'src/app.ts',
|
||||
isActive: false
|
||||
})
|
||||
expect(snapshot?.activeTabId).toBe('browser-tab-1')
|
||||
expect(snapshot?.tabGroups).toEqual([
|
||||
{
|
||||
id: 'group-left',
|
||||
activeTabId: 'browser-tab-1',
|
||||
tabOrder: ['browser-tab-1'],
|
||||
recentTabIds: ['browser-tab-1']
|
||||
},
|
||||
{
|
||||
id: 'group-right',
|
||||
activeTabId: 'editor-tab-1',
|
||||
tabOrder: ['editor-tab-1'],
|
||||
recentTabIds: []
|
||||
}
|
||||
])
|
||||
expect(snapshot?.tabGroupLayout).toEqual({
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'group-left' },
|
||||
second: { type: 'leaf', groupId: 'group-right' }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not conflate same-path edit and diff editor tabs in the fallback', () => {
|
||||
const fileId = '/repo/src/app.ts'
|
||||
const diffId = 'wt-1::diff::unstaged::src/app.ts'
|
||||
const state = makeState({
|
||||
activeGroupIdByWorktree: { 'wt-1': 'group-1' },
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-1',
|
||||
activeTabId: 'editor-tab-1',
|
||||
tabOrder: ['editor-tab-1'],
|
||||
recentTabIds: ['editor-tab-1']
|
||||
}
|
||||
]
|
||||
} as unknown as AppState['groupsByWorktree'],
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'editor-tab-1',
|
||||
groupId: 'group-1',
|
||||
contentType: 'editor',
|
||||
entityId: fileId,
|
||||
title: 'app.ts'
|
||||
}
|
||||
]
|
||||
} as unknown as AppState['unifiedTabsByWorktree'],
|
||||
openFiles: [
|
||||
{
|
||||
id: fileId,
|
||||
filePath: fileId,
|
||||
relativePath: 'src/app.ts',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
mode: 'edit',
|
||||
isDirty: false
|
||||
},
|
||||
{
|
||||
id: diffId,
|
||||
filePath: fileId,
|
||||
relativePath: 'src/app.ts',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
mode: 'diff',
|
||||
diffSource: 'unstaged',
|
||||
isDirty: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const snapshot = buildMobileSessionTabSnapshots(state)[0]
|
||||
|
||||
expect(snapshot?.tabs).toMatchObject([
|
||||
{ type: 'file', id: 'editor-tab-1', mode: 'edit', relativePath: 'src/app.ts' },
|
||||
{ type: 'file', id: diffId, mode: 'diff', diffSource: 'unstaged', relativePath: 'src/app.ts' }
|
||||
])
|
||||
expect(snapshot?.tabGroups).toEqual([
|
||||
{
|
||||
id: 'group-1',
|
||||
activeTabId: 'editor-tab-1',
|
||||
tabOrder: ['editor-tab-1', diffId],
|
||||
recentTabIds: ['editor-tab-1']
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('recovers a duplicate split editor tab for an already-emitted file id', () => {
|
||||
const fileId = '/repo/src/app.ts'
|
||||
const state = makeState({
|
||||
activeGroupIdByWorktree: { 'wt-1': 'group-left' },
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-left',
|
||||
activeTabId: 'editor-left',
|
||||
tabOrder: ['editor-left'],
|
||||
recentTabIds: ['editor-left']
|
||||
},
|
||||
{
|
||||
id: 'group-right',
|
||||
activeTabId: 'editor-right',
|
||||
tabOrder: [],
|
||||
recentTabIds: []
|
||||
}
|
||||
]
|
||||
} as unknown as AppState['groupsByWorktree'],
|
||||
layoutByWorktree: {
|
||||
'wt-1': {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'group-left' },
|
||||
second: { type: 'leaf', groupId: 'group-right' }
|
||||
}
|
||||
} as unknown as AppState['layoutByWorktree'],
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'editor-left',
|
||||
groupId: 'group-left',
|
||||
contentType: 'editor',
|
||||
entityId: fileId,
|
||||
title: 'app.ts'
|
||||
},
|
||||
{
|
||||
id: 'editor-right',
|
||||
groupId: 'group-right',
|
||||
contentType: 'editor',
|
||||
entityId: fileId,
|
||||
title: 'app.ts'
|
||||
}
|
||||
]
|
||||
} as unknown as AppState['unifiedTabsByWorktree'],
|
||||
openFiles: [
|
||||
{
|
||||
id: fileId,
|
||||
filePath: fileId,
|
||||
relativePath: 'src/app.ts',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
mode: 'edit',
|
||||
isDirty: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const snapshot = buildMobileSessionTabSnapshots(state)[0]
|
||||
|
||||
expect(snapshot?.tabs).toMatchObject([
|
||||
{ type: 'file', id: 'editor-left', relativePath: 'src/app.ts' },
|
||||
{ type: 'file', id: 'editor-right', relativePath: 'src/app.ts' }
|
||||
])
|
||||
expect(snapshot?.tabGroups).toEqual([
|
||||
{
|
||||
id: 'group-left',
|
||||
activeTabId: 'editor-left',
|
||||
tabOrder: ['editor-left'],
|
||||
recentTabIds: ['editor-left']
|
||||
},
|
||||
{
|
||||
id: 'group-right',
|
||||
activeTabId: 'editor-right',
|
||||
tabOrder: ['editor-right'],
|
||||
recentTabIds: []
|
||||
}
|
||||
])
|
||||
expect(snapshot?.tabGroupLayout).toEqual({
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'group-left' },
|
||||
second: { type: 'leaf', groupId: 'group-right' }
|
||||
})
|
||||
})
|
||||
|
||||
it('uses unified editor ids in legacy no-group order without duplicating file ids', () => {
|
||||
const fileId = '/repo/src/app.ts'
|
||||
const state = makeState({
|
||||
tabBarOrderByWorktree: { 'wt-1': [fileId] },
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'editor-tab-1',
|
||||
groupId: 'group-1',
|
||||
contentType: 'editor',
|
||||
entityId: fileId,
|
||||
title: 'app.ts'
|
||||
}
|
||||
]
|
||||
} as unknown as AppState['unifiedTabsByWorktree'],
|
||||
openFiles: [
|
||||
{
|
||||
id: fileId,
|
||||
filePath: fileId,
|
||||
relativePath: 'src/app.ts',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
mode: 'edit',
|
||||
isDirty: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const snapshot = buildMobileSessionTabSnapshots(state)[0]
|
||||
|
||||
expect(snapshot?.tabs).toMatchObject([
|
||||
{ type: 'file', id: 'editor-tab-1', relativePath: 'src/app.ts' }
|
||||
])
|
||||
expect(snapshot?.tabs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('recovers a missing diff unified tab in its split group', () => {
|
||||
const diffId = 'wt-1::diff::unstaged::src/app.ts'
|
||||
const state = makeState({
|
||||
activeGroupIdByWorktree: { 'wt-1': 'group-left' },
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-left',
|
||||
activeTabId: 'terminal-left',
|
||||
tabOrder: [],
|
||||
recentTabIds: []
|
||||
},
|
||||
{
|
||||
id: 'group-right',
|
||||
activeTabId: 'diff-tab-right',
|
||||
tabOrder: [],
|
||||
recentTabIds: []
|
||||
}
|
||||
]
|
||||
} as unknown as AppState['groupsByWorktree'],
|
||||
layoutByWorktree: {
|
||||
'wt-1': {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'group-left' },
|
||||
second: { type: 'leaf', groupId: 'group-right' }
|
||||
}
|
||||
} as unknown as AppState['layoutByWorktree'],
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'diff-tab-right',
|
||||
groupId: 'group-right',
|
||||
contentType: 'diff',
|
||||
entityId: diffId,
|
||||
title: 'app.ts'
|
||||
}
|
||||
]
|
||||
} as unknown as AppState['unifiedTabsByWorktree'],
|
||||
openFiles: [
|
||||
{
|
||||
id: diffId,
|
||||
filePath: '/repo/src/app.ts',
|
||||
relativePath: 'src/app.ts',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
mode: 'diff',
|
||||
diffSource: 'unstaged',
|
||||
isDirty: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const snapshot = buildMobileSessionTabSnapshots(state)[0]
|
||||
|
||||
expect(snapshot?.tabs).toMatchObject([
|
||||
{
|
||||
type: 'file',
|
||||
id: 'diff-tab-right',
|
||||
mode: 'diff',
|
||||
diffSource: 'unstaged',
|
||||
relativePath: 'src/app.ts'
|
||||
}
|
||||
])
|
||||
expect(snapshot?.tabGroups).toEqual([
|
||||
{
|
||||
id: 'group-right',
|
||||
activeTabId: 'diff-tab-right',
|
||||
tabOrder: ['diff-tab-right'],
|
||||
recentTabIds: []
|
||||
}
|
||||
])
|
||||
expect(snapshot?.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-right' })
|
||||
})
|
||||
|
||||
it('gates fallback editor active state on the worktree active tab type', () => {
|
||||
const fileId = '/repo/src/app.ts'
|
||||
const state = {
|
||||
activeFileId: '/repo/other-worktree.ts',
|
||||
activeFileIdByWorktree: { 'wt-1': fileId },
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-1',
|
||||
activeTabId: fileId,
|
||||
tabOrder: [],
|
||||
recentTabIds: []
|
||||
}
|
||||
]
|
||||
} as unknown as AppState['groupsByWorktree'],
|
||||
openFiles: [
|
||||
{
|
||||
id: fileId,
|
||||
filePath: fileId,
|
||||
relativePath: 'src/app.ts',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
mode: 'edit',
|
||||
isDirty: false
|
||||
}
|
||||
]
|
||||
} satisfies Partial<AppState>
|
||||
|
||||
const terminalSnapshot = buildMobileSessionTabSnapshots(
|
||||
makeState({
|
||||
...state,
|
||||
activeTabTypeByWorktree: { 'wt-1': 'terminal' }
|
||||
})
|
||||
)[0]
|
||||
const editorSnapshot = buildMobileSessionTabSnapshots(
|
||||
makeState({
|
||||
...state,
|
||||
activeTabTypeByWorktree: { 'wt-1': 'editor' }
|
||||
})
|
||||
)[0]
|
||||
|
||||
expect(terminalSnapshot?.tabs).toMatchObject([
|
||||
{ type: 'file', id: fileId, relativePath: 'src/app.ts', isActive: false }
|
||||
])
|
||||
expect(terminalSnapshot?.activeTabId).toBeNull()
|
||||
expect(terminalSnapshot?.activeTabType).toBeNull()
|
||||
expect(editorSnapshot?.tabs).toMatchObject([
|
||||
{ type: 'file', id: fileId, relativePath: 'src/app.ts', isActive: true }
|
||||
])
|
||||
expect(editorSnapshot?.activeTabId).toBe(fileId)
|
||||
expect(editorSnapshot?.activeTabType).toBe('file')
|
||||
})
|
||||
|
||||
it('keeps duplicate file ids scoped to their worktree', () => {
|
||||
const sharedRemotePath = '/home/dev/project/README.md'
|
||||
const previewId = `markdown-preview::${sharedRemotePath}`
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
|
|||
import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
|
||||
import { isClaudeManagementTitle } from '../../../shared/agent-detection'
|
||||
import type {
|
||||
Tab,
|
||||
TabGroup,
|
||||
TabGroupLayoutNode,
|
||||
TerminalLayoutSnapshot,
|
||||
|
|
@ -53,6 +54,10 @@ type OpenFileIndexes = {
|
|||
byWorktreeAndId: OpenFileByWorktreeAndId
|
||||
idsByWorktree: Map<string, string[]>
|
||||
}
|
||||
type FallbackEditorTabTarget = {
|
||||
tabId: string
|
||||
groupId: string | null
|
||||
}
|
||||
type TabsProjectionCacheEntry = {
|
||||
tabs: NonNullable<AppState['tabsByWorktree'][string]>
|
||||
worktreeIdJson: string
|
||||
|
|
@ -184,6 +189,8 @@ export type RuntimeMobileSessionSyncKey = {
|
|||
tabBarOrderByWorktree: AppState['tabBarOrderByWorktree']
|
||||
activeFileId: AppState['activeFileId']
|
||||
activeFileIdByWorktree: AppState['activeFileIdByWorktree']
|
||||
activeTabType: AppState['activeTabType']
|
||||
activeTabTypeByWorktree: AppState['activeTabTypeByWorktree']
|
||||
activeTabId: AppState['activeTabId']
|
||||
activeBrowserTabIdByWorktree: AppState['activeBrowserTabIdByWorktree']
|
||||
agentStatusEpoch: number
|
||||
|
|
@ -222,6 +229,8 @@ export function canSkipRuntimeMobileSessionSyncKeyBuild(
|
|||
state.tabBarOrderByWorktree === previousState.tabBarOrderByWorktree &&
|
||||
state.activeFileId === previousState.activeFileId &&
|
||||
state.activeFileIdByWorktree === previousState.activeFileIdByWorktree &&
|
||||
state.activeTabType === previousState.activeTabType &&
|
||||
state.activeTabTypeByWorktree === previousState.activeTabTypeByWorktree &&
|
||||
state.browserTabsByWorktree === previousState.browserTabsByWorktree &&
|
||||
state.browserPagesByWorkspace === previousState.browserPagesByWorkspace &&
|
||||
state.activeBrowserTabIdByWorktree === previousState.activeBrowserTabIdByWorktree &&
|
||||
|
|
@ -274,6 +283,8 @@ export function getRuntimeMobileSessionSyncKey(
|
|||
tabBarOrderByWorktree: state.tabBarOrderByWorktree,
|
||||
activeFileId: state.activeFileId,
|
||||
activeFileIdByWorktree: state.activeFileIdByWorktree,
|
||||
activeTabType: state.activeTabType,
|
||||
activeTabTypeByWorktree: state.activeTabTypeByWorktree,
|
||||
activeTabId: state.activeTabId,
|
||||
activeBrowserTabIdByWorktree:
|
||||
state.activeBrowserTabIdByWorktree ?? EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE,
|
||||
|
|
@ -478,6 +489,8 @@ export function runtimeMobileSessionSyncKeysEqual(
|
|||
a.tabBarOrderByWorktree === b.tabBarOrderByWorktree &&
|
||||
a.activeFileId === b.activeFileId &&
|
||||
a.activeFileIdByWorktree === b.activeFileIdByWorktree &&
|
||||
a.activeTabType === b.activeTabType &&
|
||||
a.activeTabTypeByWorktree === b.activeTabTypeByWorktree &&
|
||||
a.activeTabId === b.activeTabId &&
|
||||
a.activeBrowserTabIdByWorktree === b.activeBrowserTabIdByWorktree &&
|
||||
a.agentStatusEpoch === b.agentStatusEpoch &&
|
||||
|
|
@ -670,6 +683,8 @@ export function buildMobileSessionTabSnapshots(
|
|||
browserIds: [...browserWorkspaceByIdForWorktree.keys()]
|
||||
})
|
||||
const tabs: RuntimeMobileSessionSnapshotTab[] = []
|
||||
const emittedEditorFileIds = new Set<string>()
|
||||
const emittedEditorTabIds = new Set<string>()
|
||||
|
||||
for (const item of groupProjection.order) {
|
||||
if (item.type === 'terminal') {
|
||||
|
|
@ -706,6 +721,8 @@ export function buildMobileSessionTabSnapshots(
|
|||
} else {
|
||||
tabs.push(buildMobileFileTab(state, file, item.tabId))
|
||||
}
|
||||
emittedEditorFileIds.add(file.id)
|
||||
emittedEditorTabIds.add(item.tabId ?? item.id)
|
||||
} else if (item.type === 'browser') {
|
||||
const workspace = browserWorkspaceByIdForWorktree.get(item.id)
|
||||
if (!workspace) {
|
||||
|
|
@ -715,7 +732,75 @@ export function buildMobileSessionTabSnapshots(
|
|||
}
|
||||
}
|
||||
|
||||
// Why: split-group projection can miss plain editor files during hydration.
|
||||
// Publish the missing file so paired mobile/web clients still mirror it.
|
||||
const fallbackEditorTabs: FallbackEditorTabTarget[] = []
|
||||
const openFilesForWorktree = openFileIndexes.byWorktreeAndId.get(worktreeId)
|
||||
if (openFilesForWorktree) {
|
||||
const unifiedEditorTabs = getEditorUnifiedTabsForWorktree(state, worktreeId)
|
||||
const unifiedEditorFileIds = new Set(unifiedEditorTabs.map((tab) => tab.entityId))
|
||||
for (const unifiedTab of unifiedEditorTabs) {
|
||||
if (emittedEditorTabIds.has(unifiedTab.id)) {
|
||||
continue
|
||||
}
|
||||
const file = openFilesForWorktree.get(unifiedTab.entityId)
|
||||
if (!file) {
|
||||
continue
|
||||
}
|
||||
const markdown = buildMobileMarkdownTab(
|
||||
state,
|
||||
openFileIndexes.byWorktreeAndId,
|
||||
editorDraftVersionByFileId,
|
||||
file,
|
||||
unifiedTab.id
|
||||
)
|
||||
const fallbackTab = markdown ?? buildMobileFileTab(state, file, unifiedTab.id)
|
||||
tabs.push(fallbackTab)
|
||||
fallbackEditorTabs.push({
|
||||
tabId: fallbackTab.id,
|
||||
groupId: unifiedTab.groupId
|
||||
})
|
||||
emittedEditorTabIds.add(unifiedTab.id)
|
||||
}
|
||||
for (const file of openFilesForWorktree.values()) {
|
||||
if (emittedEditorFileIds.has(file.id)) {
|
||||
continue
|
||||
}
|
||||
if (unifiedEditorFileIds.has(file.id)) {
|
||||
emittedEditorFileIds.add(file.id)
|
||||
continue
|
||||
}
|
||||
const markdown = buildMobileMarkdownTab(
|
||||
state,
|
||||
openFileIndexes.byWorktreeAndId,
|
||||
editorDraftVersionByFileId,
|
||||
file
|
||||
)
|
||||
const fallbackTab = markdown ?? buildMobileFileTab(state, file)
|
||||
tabs.push(fallbackTab)
|
||||
fallbackEditorTabs.push({
|
||||
tabId: fallbackTab.id,
|
||||
groupId: null
|
||||
})
|
||||
emittedEditorFileIds.add(file.id)
|
||||
}
|
||||
}
|
||||
|
||||
const active = tabs.find((tab) => tab.isActive) ?? null
|
||||
const tabGroups = appendFallbackEditorTabsToGroups(
|
||||
groupProjection.tabGroups,
|
||||
state.groupsByWorktree[worktreeId] ?? [],
|
||||
activeGroupId,
|
||||
fallbackEditorTabs,
|
||||
active?.id ?? null
|
||||
)
|
||||
const tabGroupLayout =
|
||||
tabGroups && tabGroups.length > 0
|
||||
? pruneTabGroupLayout(
|
||||
(state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE)[worktreeId],
|
||||
new Set(tabGroups.map((group) => group.id))
|
||||
)
|
||||
: groupProjection.tabGroupLayout
|
||||
snapshots.push({
|
||||
worktree: worktreeId,
|
||||
publicationEpoch: mobileSessionPublicationEpoch,
|
||||
|
|
@ -723,10 +808,8 @@ export function buildMobileSessionTabSnapshots(
|
|||
activeGroupId,
|
||||
activeTabId: active?.id ?? null,
|
||||
activeTabType: active?.type ?? null,
|
||||
...(groupProjection.tabGroups && groupProjection.tabGroups.length > 0
|
||||
? { tabGroups: groupProjection.tabGroups }
|
||||
: {}),
|
||||
...(groupProjection.tabGroupLayout ? { tabGroupLayout: groupProjection.tabGroupLayout } : {}),
|
||||
...(tabGroups && tabGroups.length > 0 ? { tabGroups } : {}),
|
||||
...(tabGroupLayout ? { tabGroupLayout } : {}),
|
||||
tabs
|
||||
})
|
||||
}
|
||||
|
|
@ -734,6 +817,125 @@ export function buildMobileSessionTabSnapshots(
|
|||
return snapshots
|
||||
}
|
||||
|
||||
function isEditorSurfaceTab(tab: Pick<Tab, 'contentType'>): boolean {
|
||||
// Why: mobile file snapshots can faithfully mirror ordinary edit/diff files;
|
||||
// conflict review and check-details tabs require metadata this contract lacks.
|
||||
return tab.contentType === 'editor' || tab.contentType === 'diff'
|
||||
}
|
||||
|
||||
function getEditorUnifiedTabsForWorktree(
|
||||
state: Pick<AppState, 'unifiedTabsByWorktree'>,
|
||||
worktreeId: string
|
||||
): Tab[] {
|
||||
return (state.unifiedTabsByWorktree[worktreeId] ?? []).filter(isEditorSurfaceTab)
|
||||
}
|
||||
|
||||
function applyUnifiedEditorTabIdsToLegacyOrder(
|
||||
order: readonly VisibleTabRef[],
|
||||
state: Pick<AppState, 'unifiedTabsByWorktree'>,
|
||||
worktreeId: string
|
||||
): VisibleTabRef[] {
|
||||
const unifiedEditorTabs = getEditorUnifiedTabsForWorktree(state, worktreeId)
|
||||
if (unifiedEditorTabs.length === 0) {
|
||||
return [...order]
|
||||
}
|
||||
const firstUnifiedTabByFileId = new Map<string, string>()
|
||||
for (const tab of unifiedEditorTabs) {
|
||||
if (!firstUnifiedTabByFileId.has(tab.entityId)) {
|
||||
firstUnifiedTabByFileId.set(tab.entityId, tab.id)
|
||||
}
|
||||
}
|
||||
return order.map((item) => {
|
||||
if (item.type !== 'editor' || item.tabId) {
|
||||
return item
|
||||
}
|
||||
const tabId = firstUnifiedTabByFileId.get(item.id)
|
||||
return tabId ? { ...item, tabId } : item
|
||||
})
|
||||
}
|
||||
|
||||
function appendFallbackEditorTabsToGroups(
|
||||
tabGroups: RuntimeMobileSessionTabGroup[] | undefined,
|
||||
sourceGroups: readonly TabGroup[],
|
||||
activeGroupId: string | null,
|
||||
fallbackTabs: readonly FallbackEditorTabTarget[],
|
||||
activeTabId: string | null
|
||||
): RuntimeMobileSessionTabGroup[] | undefined {
|
||||
if (fallbackTabs.length === 0) {
|
||||
return tabGroups
|
||||
}
|
||||
const result = [...(tabGroups ?? [])]
|
||||
const sourceGroupsById = new Map(sourceGroups.map((group) => [group.id, group]))
|
||||
const groupIndexById = new Map(result.map((group, index) => [group.id, index]))
|
||||
const firstTargetGroupId =
|
||||
result[0]?.id ??
|
||||
(activeGroupId && sourceGroupsById.has(activeGroupId) ? activeGroupId : null) ??
|
||||
sourceGroups[0]?.id ??
|
||||
null
|
||||
const fallbackTabIdSet = new Set(fallbackTabs.map((tab) => tab.tabId))
|
||||
|
||||
for (const fallback of fallbackTabs) {
|
||||
const targetGroupId =
|
||||
fallback.groupId ??
|
||||
(activeGroupId && (groupIndexById.has(activeGroupId) || sourceGroupsById.has(activeGroupId))
|
||||
? activeGroupId
|
||||
: firstTargetGroupId)
|
||||
if (!targetGroupId) {
|
||||
continue
|
||||
}
|
||||
let targetIndex = groupIndexById.get(targetGroupId)
|
||||
if (targetIndex === undefined) {
|
||||
const sourceGroup = sourceGroupsById.get(targetGroupId)
|
||||
const group: RuntimeMobileSessionTabGroup = {
|
||||
id: targetGroupId,
|
||||
activeTabId: sourceGroup?.activeTabId ?? null,
|
||||
tabOrder: [],
|
||||
recentTabIds: sourceGroup?.recentTabIds ?? []
|
||||
}
|
||||
targetIndex = result.length
|
||||
groupIndexById.set(targetGroupId, targetIndex)
|
||||
result.push(group)
|
||||
}
|
||||
const group = result[targetIndex]!
|
||||
if (!group.tabOrder.includes(fallback.tabId)) {
|
||||
result[targetIndex] = {
|
||||
...group,
|
||||
tabOrder: [...group.tabOrder, fallback.tabId]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.length === 0) {
|
||||
return tabGroups
|
||||
}
|
||||
|
||||
const activeFallbackTabId = activeTabId && fallbackTabIdSet.has(activeTabId) ? activeTabId : null
|
||||
|
||||
return result.map((group) => {
|
||||
const tabOrder = [...group.tabOrder]
|
||||
const tabOrderSet = new Set(tabOrder)
|
||||
const activeFallbackTabIdForGroup =
|
||||
activeFallbackTabId && tabOrderSet.has(activeFallbackTabId) ? activeFallbackTabId : null
|
||||
const activeTabIdForGroup =
|
||||
activeFallbackTabIdForGroup ??
|
||||
(group.activeTabId && tabOrderSet.has(group.activeTabId) ? group.activeTabId : null)
|
||||
const recentTabIds = (group.recentTabIds ?? []).filter((tabId) => tabOrderSet.has(tabId))
|
||||
if (
|
||||
activeFallbackTabId &&
|
||||
tabOrderSet.has(activeFallbackTabId) &&
|
||||
!recentTabIds.includes(activeFallbackTabId)
|
||||
) {
|
||||
recentTabIds.push(activeFallbackTabId)
|
||||
}
|
||||
return {
|
||||
...group,
|
||||
activeTabId: activeTabIdForGroup,
|
||||
tabOrder,
|
||||
recentTabIds
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean {
|
||||
return typeof ptyId === 'string' && parseRemoteRuntimePtyId(ptyId) !== null
|
||||
}
|
||||
|
|
@ -859,9 +1061,13 @@ function buildMobileSessionGroupProjection(
|
|||
const groups = state.groupsByWorktree[worktreeId] ?? []
|
||||
if (groups.length === 0) {
|
||||
return {
|
||||
order: getActiveTabNavOrder(state, worktreeId, {
|
||||
editorIds: ids.editorIds
|
||||
})
|
||||
order: applyUnifiedEditorTabIdsToLegacyOrder(
|
||||
getActiveTabNavOrder(state, worktreeId, {
|
||||
editorIds: ids.editorIds
|
||||
}),
|
||||
state,
|
||||
worktreeId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1137,7 +1343,7 @@ function buildMobileMarkdownTab(
|
|||
isDirty: file.isDirty || sourceFile.isDirty,
|
||||
isActive: unifiedTabId
|
||||
? isUnifiedTabActiveInActiveGroup(state, file.worktreeId, unifiedTabId)
|
||||
: state.activeFileId === file.id,
|
||||
: isFileActiveEditorSurface(state, file),
|
||||
sourceFileId: sourceFile.id,
|
||||
sourceFilePath: sourceFile.filePath,
|
||||
sourceRelativePath: sourceFile.relativePath,
|
||||
|
|
@ -1165,10 +1371,24 @@ function buildMobileFileTab(
|
|||
isDirty: file.isDirty,
|
||||
isActive: unifiedTabId
|
||||
? isUnifiedTabActiveInActiveGroup(state, file.worktreeId, unifiedTabId)
|
||||
: state.activeFileId === file.id
|
||||
: isFileActiveEditorSurface(state, file)
|
||||
}
|
||||
}
|
||||
|
||||
function isFileActiveEditorSurface(
|
||||
state: Pick<
|
||||
AppState,
|
||||
'activeFileId' | 'activeFileIdByWorktree' | 'activeTabType' | 'activeTabTypeByWorktree'
|
||||
>,
|
||||
file: Pick<AppState['openFiles'][number], 'id' | 'worktreeId'>
|
||||
): boolean {
|
||||
const activeType = state.activeTabTypeByWorktree?.[file.worktreeId] ?? state.activeTabType
|
||||
return (
|
||||
activeType === 'editor' &&
|
||||
(state.activeFileIdByWorktree?.[file.worktreeId] ?? state.activeFileId) === file.id
|
||||
)
|
||||
}
|
||||
|
||||
function isMobileFileDiffSource(
|
||||
diffSource: AppState['openFiles'][number]['diffSource']
|
||||
): diffSource is 'staged' | 'unstaged' {
|
||||
|
|
|
|||
Loading…
Reference in New Issue