@brimveyn/aimux 1.20.4 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -25
- package/package.json +2 -2
- package/src/app-runtime/auto-commit-driver.ts +22 -27
- package/src/app-runtime/backend-attach-runtime.ts +43 -36
- package/src/app-runtime/backend-runtime-events.ts +107 -88
- package/src/app-runtime/editor-actions.ts +201 -0
- package/src/app-runtime/git-actions.ts +197 -0
- package/src/app-runtime/measure-ref.ts +19 -0
- package/src/app-runtime/navigation-actions.ts +213 -0
- package/src/app-runtime/project-actions.ts +188 -0
- package/src/app-runtime/prompt-injection.ts +123 -0
- package/src/app-runtime/selection.ts +29 -0
- package/src/app-runtime/settings-actions.ts +156 -0
- package/src/app-runtime/setup-actions.ts +237 -0
- package/src/app-runtime/side-effect-context.ts +33 -0
- package/src/app-runtime/side-effects.ts +305 -1589
- package/src/app-runtime/snippet-actions.ts +3 -2
- package/src/app-runtime/split-drag-controller.ts +4 -8
- package/src/app-runtime/tab-actions.ts +218 -0
- package/src/app-runtime/tab-runtime-timeouts.ts +1 -1
- package/src/app-runtime/use-auto-commit-driver.ts +14 -13
- package/src/app-runtime/use-backend-runtime.ts +12 -11
- package/src/app-runtime/use-directory-search.ts +5 -4
- package/src/app-runtime/use-mouse-handlers.ts +28 -42
- package/src/app-runtime/{use-workspace-autosave.ts → use-project-autosave.ts} +4 -4
- package/src/app-runtime/use-renderer-bindings.ts +2 -1
- package/src/app-runtime/use-setup-runner.ts +96 -0
- package/src/app-runtime/use-terminal-resize.ts +28 -33
- package/src/app-runtime/workspace-actions.ts +366 -0
- package/src/app-runtime/workspace-activity.ts +138 -0
- package/src/app-runtime/workspace-naming.ts +90 -0
- package/src/app.tsx +102 -85
- package/src/assets/sounds/bell.wav +0 -0
- package/src/assets/sounds/ding.wav +0 -0
- package/src/assets/sounds/plane.wav +0 -0
- package/src/cli/client/daemon-client.ts +2 -2
- package/src/cli/client/project-resolver.ts +112 -0
- package/src/cli/commands/project/close.ts +32 -0
- package/src/cli/commands/project/create.ts +93 -0
- package/src/cli/commands/project/list.ts +25 -0
- package/src/cli/commands/project/show.ts +32 -0
- package/src/cli/commands/{workspace → project}/switch.ts +19 -19
- package/src/cli/commands/tab/await.ts +2 -2
- package/src/cli/commands/tab/close.ts +3 -3
- package/src/cli/commands/tab/create.ts +74 -73
- package/src/cli/commands/tab/focus.ts +3 -3
- package/src/cli/commands/tab/list.ts +6 -6
- package/src/cli/commands/tab/run.ts +2 -2
- package/src/cli/commands/tab/send.ts +2 -2
- package/src/cli/commands/tab/snapshot.ts +2 -2
- package/src/cli/commands/tab/tail.ts +2 -2
- package/src/cli/commands/tab/wait.ts +2 -2
- package/src/cli/commands/worker/await.ts +3 -3
- package/src/cli/commands/worker/doctor.ts +30 -30
- package/src/cli/commands/worker/list.ts +15 -15
- package/src/cli/commands/worker/prompt.ts +3 -3
- package/src/cli/commands/worker/run.ts +23 -23
- package/src/cli/commands/worker/shared.ts +53 -59
- package/src/cli/commands/worker/stop.ts +29 -29
- package/src/cli/commands/worker/submit.ts +3 -3
- package/src/cli/commands/{worktree → workspace}/create-core.ts +26 -26
- package/src/cli/commands/workspace/create.ts +30 -68
- package/src/cli/commands/workspace/list.ts +52 -9
- package/src/cli/commands/workspace/remove.ts +81 -0
- package/src/cli/completion/plan.ts +2 -2
- package/src/cli/completion/sources.ts +18 -18
- package/src/cli/context.ts +13 -13
- package/src/cli/flags.ts +30 -10
- package/src/cli/index.ts +26 -16
- package/src/cli/output.ts +1 -1
- package/src/cli/registry.ts +12 -12
- package/src/config/loader.ts +16 -5
- package/src/config.ts +149 -101
- package/src/daemon/catalog-writer.ts +67 -67
- package/src/daemon/daemon.ts +205 -201
- package/src/daemon/session-manager.ts +39 -39
- package/src/daemon/session-registry.ts +14 -14
- package/src/git/divergence.ts +33 -2
- package/src/git/git-poller.ts +1 -1
- package/src/git/git-status.ts +2 -2
- package/src/git/{move-worktree.ts → move-workspace.ts} +5 -5
- package/src/git/pr-merge.ts +64 -0
- package/src/git/pr-status-poller.ts +57 -0
- package/src/git/pr-status.ts +227 -0
- package/src/git/repo-discovery.ts +1 -1
- package/src/git/use-repo-discovery.ts +1 -1
- package/src/git/workspace-branch-poller.ts +54 -0
- package/src/git/workspace-divergence-poller.ts +65 -0
- package/src/git/worktree.ts +29 -0
- package/src/index.tsx +11 -6
- package/src/input/keymap/help-entries.ts +7 -5
- package/src/input/modes/bridge.ts +13 -12
- package/src/input/modes/handlers/shared.ts +1 -1
- package/src/input/modes/transitions.ts +34 -21
- package/src/input/modes/types.ts +48 -31
- package/src/ipc/manager-protocol.ts +44 -44
- package/src/ipc/protocol.ts +192 -166
- package/src/platform/open-url.ts +39 -0
- package/src/platform/play-sound.ts +184 -0
- package/src/platform/project-search.ts +8 -8
- package/src/platform/worktree-paths.ts +8 -6
- package/src/pty/assistant-question-extractor.ts +1 -1
- package/src/pty/assistant-status-detection-loop.ts +79 -55
- package/src/pty/assistant-status-detector.ts +40 -15
- package/src/pty/command-registry.ts +81 -1
- package/src/restart-terminal-manager.ts +1 -1
- package/src/services/ai-usage/provider.ts +9 -2
- package/src/services/ai-usage/spawn.ts +3 -1
- package/src/session-backend/bootstrap.ts +4 -4
- package/src/session-backend/local-session-backend.ts +78 -75
- package/src/session-backend/remote-session-backend.ts +64 -52
- package/src/session-backend/types.ts +43 -32
- package/src/settings/live.ts +90 -0
- package/src/settings/search.ts +43 -0
- package/src/settings/sections/about.ts +49 -0
- package/src/settings/sections/appearance.ts +54 -0
- package/src/settings/sections/automation.ts +105 -0
- package/src/settings/sections/commands.ts +72 -0
- package/src/settings/sections/editor.ts +56 -0
- package/src/settings/sections/experimental.ts +57 -0
- package/src/settings/sections/git.ts +112 -0
- package/src/settings/sections/index.ts +95 -0
- package/src/settings/sections/integrations.ts +23 -0
- package/src/settings/sections/layout.ts +72 -0
- package/src/settings/sections/notifications.ts +99 -0
- package/src/settings/sections/setup.ts +54 -0
- package/src/settings/sections/status-bar.ts +63 -0
- package/src/settings/settings-store.ts +207 -0
- package/src/settings/types.ts +108 -0
- package/src/snippets/run-shell-var.ts +48 -22
- package/src/state/actions.ts +342 -0
- package/src/state/app-store.ts +2 -1
- package/src/state/bars.ts +75 -0
- package/src/state/dispatch-ref.ts +1 -1
- package/src/state/git-pane-sizing.ts +0 -9
- package/src/state/layout-resize.ts +2 -2
- package/src/state/pr-status-store.ts +39 -0
- package/src/state/project-catalog.ts +275 -0
- package/src/state/project-data.ts +143 -0
- package/src/state/{session-persistence.ts → project-persistence.ts} +77 -73
- package/src/state/project-save.ts +46 -0
- package/src/state/project-workspaces.ts +392 -0
- package/src/state/reducers/auto-commit-state.ts +11 -10
- package/src/state/reducers/git-commit-modal-state.ts +122 -0
- package/src/state/reducers/git-mode-state.ts +1 -1
- package/src/state/reducers/git-panel-state.ts +10 -53
- package/src/state/reducers/modal-state.ts +290 -681
- package/src/state/reducers/multi-repo-state.ts +3 -6
- package/src/state/reducers/project-state.ts +312 -0
- package/src/state/reducers/settings-state.ts +80 -0
- package/src/state/reducers/tab-state.ts +107 -60
- package/src/state/reducers/ui-state.ts +86 -15
- package/src/state/selectors.ts +49 -37
- package/src/state/store.ts +83 -57
- package/src/state/types.ts +261 -417
- package/src/state/validation.ts +14 -12
- package/src/terminal-manager/manager-client.ts +32 -32
- package/src/terminal-manager/terminal-manager.ts +24 -24
- package/src/ui/components/git/diff-renderer/pierre-diff.tsx +2 -1
- package/src/ui/components/git/git-panel.tsx +2 -2
- package/src/ui/components/git/git-view.tsx +12 -12
- package/src/ui/components/git/image-diff/image-diff-view.tsx +1 -1
- package/src/ui/components/git/pane/git-pane-header.tsx +105 -39
- package/src/ui/components/git/pane/git-pane-widget.tsx +37 -23
- package/src/ui/components/git/pane/pr-checks-panel.tsx +197 -0
- package/src/ui/components/git/pane/pr-state-row.tsx +103 -0
- package/src/ui/components/layout/bar.tsx +191 -0
- package/src/ui/components/layout/sidebar/project-list.tsx +444 -0
- package/src/ui/components/layout/sidebar/tab-item.tsx +27 -12
- package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +1 -1
- package/src/ui/components/layout/sidebar/workspace-row.tsx +253 -0
- package/src/ui/components/layout/status-bar.tsx +6 -2
- package/src/ui/components/layout/terminal-pane.tsx +34 -29
- package/src/ui/components/layout/top-tab-bar.tsx +48 -37
- package/src/ui/components/modals/git/git-commit-modal.tsx +8 -8
- package/src/ui/components/modals/{sessions/create-session-modal.tsx → projects/create-project-modal.tsx} +12 -12
- package/src/ui/components/modals/{sessions/session-name-modal.tsx → projects/project-name-modal.tsx} +2 -2
- package/src/ui/components/modals/{sessions/session-picker-modal.tsx → projects/project-picker-modal.tsx} +33 -35
- package/src/ui/components/modals/settings/settings-search-modal.tsx +70 -0
- package/src/ui/components/modals/shared/form.tsx +34 -22
- package/src/ui/components/modals/shared/picker.tsx +10 -10
- package/src/ui/components/modals/shared/{worktree-delete-confirm.tsx → workspace-delete-confirm.tsx} +9 -9
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +30 -324
- package/src/ui/components/modals/workspace/create-workspace-modal.tsx +101 -0
- package/src/ui/components/modals/{worktree/worktree-move-confirm-modal.tsx → workspace/workspace-move-confirm-modal.tsx} +5 -5
- package/src/ui/components/modals/{worktree/worktree-move-modal.tsx → workspace/workspace-move-modal.tsx} +29 -27
- package/src/ui/components/primitives/input-field.tsx +7 -3
- package/src/ui/components/primitives/surface.tsx +3 -0
- package/src/ui/components/settings/row-value.tsx +74 -0
- package/src/ui/components/settings/settings-row.tsx +78 -0
- package/src/ui/components/settings/settings-view.tsx +169 -0
- package/src/ui/components/setup/setup-widget.tsx +152 -0
- package/src/ui/flash/build-labels.ts +27 -27
- package/src/ui/hooks/use-activity-sprite.ts +73 -0
- package/src/ui/hooks/use-scroll-active-into-view.ts +26 -0
- package/src/ui/{session-ordering.ts → project-ordering.ts} +4 -4
- package/src/ui/root.tsx +164 -230
- package/src/ui/status-bar-model.ts +48 -34
- package/src/ui/terminal-graphics/kitty.ts +28 -2
- package/src/ui/terminal-graphics/sprites/done@330/0.png +0 -0
- package/src/ui/terminal-graphics/sprites/done@330/1.png +0 -0
- package/src/ui/terminal-graphics/sprites/idle@1000/0.png +0 -0
- package/src/ui/terminal-graphics/sprites/idle@1000/1.png +0 -0
- package/src/ui/terminal-graphics/sprites/waiting@170/0.png +0 -0
- package/src/ui/terminal-graphics/sprites/waiting@170/1.png +0 -0
- package/src/ui/terminal-graphics/sprites/working@150/0.png +0 -0
- package/src/ui/terminal-graphics/sprites/working@150/1.png +0 -0
- package/src/ui/terminal-graphics/sprites/working@150/2.png +0 -0
- package/src/ui/terminal-graphics/sprites/working@150/3.png +0 -0
- package/src/ui/terminal-graphics/sprites.ts +246 -0
- package/src/ui/truncate.ts +7 -0
- package/src/ui/widgets/registry.tsx +21 -0
- package/src/ui/widgets/widget-context-menu.ts +85 -0
- package/src/update.ts +2 -2
- package/src/app-runtime/session-actions.ts +0 -187
- package/src/cli/client/workspace-resolver.ts +0 -110
- package/src/cli/commands/workspace/close.ts +0 -32
- package/src/cli/commands/workspace/show.ts +0 -32
- package/src/cli/commands/worktree/create.ts +0 -55
- package/src/cli/commands/worktree/list.ts +0 -68
- package/src/cli/commands/worktree/remove.ts +0 -81
- package/src/git/worktree-branch-poller.ts +0 -54
- package/src/git/worktree-divergence-poller.ts +0 -59
- package/src/state/reducers/session-state.ts +0 -262
- package/src/state/session-catalog.ts +0 -143
- package/src/state/session-worktrees.ts +0 -260
- package/src/state/workspace-save.ts +0 -45
- package/src/ui/components/git/pane/git-pane-context-menu.ts +0 -26
- package/src/ui/components/layout/sidebar/sidebar.tsx +0 -163
- package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +0 -30
- package/src/ui/components/layout/sidebar/workspace-list.tsx +0 -418
- package/src/ui/components/layout/sidebar/worktree-row.tsx +0 -149
- /package/src/ui/{components/git/image-diff → terminal-graphics}/dimensions.ts +0 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { ProjectRecord } from './types'
|
|
5
|
+
|
|
6
|
+
import { loadConfig, saveConfig } from '../config'
|
|
7
|
+
import { logDebug } from '../debug/input-log'
|
|
8
|
+
import { getProfileConfigDir } from '../profile-paths'
|
|
9
|
+
import { ensureProjectWorkspaces } from './project-workspaces'
|
|
10
|
+
import { toast } from './toast-store'
|
|
11
|
+
import { isProjectRecord } from './validation'
|
|
12
|
+
|
|
13
|
+
const CATALOG_VERSION = 2
|
|
14
|
+
|
|
15
|
+
interface ProjectCatalogFile {
|
|
16
|
+
version: typeof CATALOG_VERSION
|
|
17
|
+
projects: ProjectRecord[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Resolved per call, never module-cached: $HOME is read at call time so tests
|
|
21
|
+
// that redirect it cannot clobber the real catalog.
|
|
22
|
+
function projectsPath(): string {
|
|
23
|
+
return join(getProfileConfigDir(), 'aimux-projects.json')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ponytail: the v1 file is left on disk so a downgrade still works. Drop this
|
|
27
|
+
// helper, migrateV1 and the fallback branch a release or two out.
|
|
28
|
+
function legacySessionsPath(): string {
|
|
29
|
+
return join(getProfileConfigDir(), 'aimux-sessions.json')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The v1 shape, before project/workspace naming. Only what migrateV1 reads. */
|
|
33
|
+
interface LegacyProjectRecordV1 {
|
|
34
|
+
projectPath?: string
|
|
35
|
+
workspaceSnapshot?: {
|
|
36
|
+
tabs?: { worktreeId?: string }[]
|
|
37
|
+
lastActiveTabByWorktree?: Record<string, string>
|
|
38
|
+
}
|
|
39
|
+
worktrees?: { source?: string; repoRoot?: string }[]
|
|
40
|
+
activeWorktreeId?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* v1 -> v2: `worktrees` became `workspaces`, `workspaceSnapshot` became
|
|
45
|
+
* `projectSnapshot`, and each tab's `worktreeId` became `workspaceId`. Record
|
|
46
|
+
* ids are deliberately left alone — they are opaque and may be scripted.
|
|
47
|
+
*
|
|
48
|
+
* `projectPath` is also healed here: v1 rewrote it with the *active worktree's*
|
|
49
|
+
* path, so it usually pointed inside the worktree root rather than at the repo.
|
|
50
|
+
*/
|
|
51
|
+
function migrateV1(records: LegacyProjectRecordV1[]): unknown[] {
|
|
52
|
+
return records.map((record) => {
|
|
53
|
+
const { activeWorktreeId, workspaceSnapshot, worktrees, ...rest } = record
|
|
54
|
+
const primaryRepoRoot = worktrees?.find((w) => w.source === 'primary')?.repoRoot
|
|
55
|
+
return {
|
|
56
|
+
...rest,
|
|
57
|
+
...(primaryRepoRoot != null && primaryRepoRoot !== ''
|
|
58
|
+
? { projectPath: primaryRepoRoot }
|
|
59
|
+
: null),
|
|
60
|
+
...(worktrees ? { workspaces: worktrees } : null),
|
|
61
|
+
...(activeWorktreeId != null ? { activeWorkspaceId: activeWorktreeId } : null),
|
|
62
|
+
...(workspaceSnapshot
|
|
63
|
+
? {
|
|
64
|
+
projectSnapshot: (() => {
|
|
65
|
+
const { lastActiveTabByWorktree, tabs, ...snapshot } = workspaceSnapshot
|
|
66
|
+
return {
|
|
67
|
+
...snapshot,
|
|
68
|
+
...(lastActiveTabByWorktree
|
|
69
|
+
? { lastActiveTabByWorkspace: lastActiveTabByWorktree }
|
|
70
|
+
: null),
|
|
71
|
+
...(tabs
|
|
72
|
+
? {
|
|
73
|
+
tabs: tabs.map(({ worktreeId, ...tab }) => ({
|
|
74
|
+
...tab,
|
|
75
|
+
...(worktreeId != null ? { workspaceId: worktreeId } : null),
|
|
76
|
+
})),
|
|
77
|
+
}
|
|
78
|
+
: null),
|
|
79
|
+
}
|
|
80
|
+
})(),
|
|
81
|
+
}
|
|
82
|
+
: null),
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Copy the catalog aside before the load path writes over it. Called whenever
|
|
89
|
+
* we could not read the file whole: the app is about to persist its own
|
|
90
|
+
* reduced view, and without this that write is the point of no return.
|
|
91
|
+
*/
|
|
92
|
+
function backupUnreadableCatalog(): void {
|
|
93
|
+
const source = projectsPath()
|
|
94
|
+
if (!existsSync(source)) return
|
|
95
|
+
const target = `${source}.unreadable`
|
|
96
|
+
try {
|
|
97
|
+
copyFileSync(source, target)
|
|
98
|
+
logDebug('projects.catalog.backup', { target })
|
|
99
|
+
} catch (error) {
|
|
100
|
+
logDebug('projects.catalog.backupFailed', {
|
|
101
|
+
error: error instanceof Error ? error.message : String(error),
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseCatalog(path: string, version: number): { projects: unknown[] } | { issue: string } {
|
|
107
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as {
|
|
108
|
+
version?: unknown
|
|
109
|
+
projects?: unknown
|
|
110
|
+
sessions?: unknown
|
|
111
|
+
}
|
|
112
|
+
if (parsed.version !== version) {
|
|
113
|
+
return { issue: `invalid project catalog header in ${path}` }
|
|
114
|
+
}
|
|
115
|
+
// v1 called the array `sessions`.
|
|
116
|
+
const records = parsed.projects ?? parsed.sessions
|
|
117
|
+
if (!Array.isArray(records)) {
|
|
118
|
+
return { issue: `invalid project catalog header in ${path}` }
|
|
119
|
+
}
|
|
120
|
+
return { projects: records }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readCatalogFile(): { file: ProjectCatalogFile | null; issue?: string } {
|
|
124
|
+
try {
|
|
125
|
+
let records: unknown[]
|
|
126
|
+
if (existsSync(projectsPath())) {
|
|
127
|
+
const parsed = parseCatalog(projectsPath(), CATALOG_VERSION)
|
|
128
|
+
if ('issue' in parsed) {
|
|
129
|
+
backupUnreadableCatalog()
|
|
130
|
+
return { file: null, issue: parsed.issue }
|
|
131
|
+
}
|
|
132
|
+
records = parsed.projects
|
|
133
|
+
} else if (existsSync(legacySessionsPath())) {
|
|
134
|
+
const parsed = parseCatalog(legacySessionsPath(), 1)
|
|
135
|
+
if ('issue' in parsed) return { file: null, issue: parsed.issue }
|
|
136
|
+
records = migrateV1(parsed.projects as LegacyProjectRecordV1[])
|
|
137
|
+
logDebug('projects.catalog.migrateV1', { projectCount: records.length })
|
|
138
|
+
} else {
|
|
139
|
+
return { file: null }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Keep the records that survive validation instead of failing the whole
|
|
143
|
+
// file: one malformed entry used to cost the user every project, and since
|
|
144
|
+
// the load path writes back what it loaded, that loss became permanent on
|
|
145
|
+
// the next save. Whatever we could not read is preserved by the backup
|
|
146
|
+
// below, so nothing is destroyed either way.
|
|
147
|
+
const valid = records.filter(isProjectRecord)
|
|
148
|
+
if (valid.length !== records.length) {
|
|
149
|
+
backupUnreadableCatalog()
|
|
150
|
+
return {
|
|
151
|
+
file: { projects: valid, version: CATALOG_VERSION },
|
|
152
|
+
issue: `${records.length - valid.length} unreadable project record(s) skipped`,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { file: { projects: valid, version: CATALOG_VERSION } }
|
|
157
|
+
} catch (error) {
|
|
158
|
+
backupUnreadableCatalog()
|
|
159
|
+
return {
|
|
160
|
+
file: null,
|
|
161
|
+
issue: `failed to load project catalog: ${error instanceof Error ? error.message : String(error)}`,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function loadProjectCatalog(): ProjectRecord[] {
|
|
167
|
+
const { file, issue } = readCatalogFile()
|
|
168
|
+
if (file) {
|
|
169
|
+
logDebug('projects.catalog.load', { projectCount: file.projects.length })
|
|
170
|
+
if (issue != null && issue !== '') {
|
|
171
|
+
// Same reasoning as the save path below: losing projects is not something
|
|
172
|
+
// the user should have to read a debug log to discover.
|
|
173
|
+
toast.error(`${issue} — original kept at ${projectsPath()}.unreadable`)
|
|
174
|
+
}
|
|
175
|
+
const normalized = normalizeProjects(file.projects)
|
|
176
|
+
// A fresh migration has no v2 file yet, so write even when normalizing
|
|
177
|
+
// changed nothing.
|
|
178
|
+
if (
|
|
179
|
+
!existsSync(projectsPath()) ||
|
|
180
|
+
JSON.stringify(normalized) !== JSON.stringify(file.projects)
|
|
181
|
+
) {
|
|
182
|
+
saveProjectCatalog(normalized)
|
|
183
|
+
}
|
|
184
|
+
return normalized
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (issue != null && issue !== '') {
|
|
188
|
+
logDebug('projects.catalog.loadIssue', { issue, path: projectsPath() })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const config = loadConfig()
|
|
192
|
+
if (!config.projectSnapshot) {
|
|
193
|
+
logDebug('projects.catalog.load', { projectCount: 0 })
|
|
194
|
+
return []
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const now = new Date().toISOString()
|
|
198
|
+
const migrated: ProjectRecord = {
|
|
199
|
+
createdAt: now,
|
|
200
|
+
id: `project-${Date.now()}`,
|
|
201
|
+
lastOpenedAt: now,
|
|
202
|
+
name: 'Last project',
|
|
203
|
+
projectSnapshot: config.projectSnapshot,
|
|
204
|
+
updatedAt: now,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const normalized = normalizeProjects([migrated])
|
|
208
|
+
saveProjectCatalog(normalized)
|
|
209
|
+
saveConfig({ ...config, projectSnapshot: undefined })
|
|
210
|
+
logDebug('projects.catalog.migrateLegacyProject', {
|
|
211
|
+
migratedProjectId: migrated.id,
|
|
212
|
+
tabCount: migrated.projectSnapshot?.tabs.length ?? 0,
|
|
213
|
+
})
|
|
214
|
+
return normalized
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function saveProjectCatalog(projects: ProjectRecord[]): void {
|
|
218
|
+
try {
|
|
219
|
+
mkdirSync(getProfileConfigDir(), { recursive: true })
|
|
220
|
+
writeFileSync(
|
|
221
|
+
projectsPath(),
|
|
222
|
+
`${JSON.stringify({ projects, version: CATALOG_VERSION }, null, 2)}\n`
|
|
223
|
+
)
|
|
224
|
+
logDebug('projects.catalog.save', { projectCount: projects.length })
|
|
225
|
+
} catch (error) {
|
|
226
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
227
|
+
logDebug('projects.catalog.saveError', {
|
|
228
|
+
error: message,
|
|
229
|
+
path: projectsPath(),
|
|
230
|
+
projectCount: projects.length,
|
|
231
|
+
})
|
|
232
|
+
// Persisting the catalog underpins project/workspace state — a silent
|
|
233
|
+
// failure means the user loses projects on restart. Surface it.
|
|
234
|
+
toast.error(`Failed to save projects: ${message}`)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function getProjectCatalogPath(): string {
|
|
239
|
+
return projectsPath()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function findMostRecentProject(projects: ProjectRecord[]): ProjectRecord | undefined {
|
|
243
|
+
let best: ProjectRecord | undefined
|
|
244
|
+
for (const candidate of projects) {
|
|
245
|
+
if (!best || candidate.lastOpenedAt.localeCompare(best.lastOpenedAt) > 0) {
|
|
246
|
+
best = candidate
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return best
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Assign a stable `order` to every project. Records with an existing numeric
|
|
254
|
+
* `order` keep their slot (sorted ascending); the rest are appended by
|
|
255
|
+
* createdAt ascending so older projects come first.
|
|
256
|
+
*/
|
|
257
|
+
function normalizeOrder(projects: ProjectRecord[]): ProjectRecord[] {
|
|
258
|
+
const withOrder: ProjectRecord[] = []
|
|
259
|
+
const withoutOrder: ProjectRecord[] = []
|
|
260
|
+
for (const s of projects) {
|
|
261
|
+
if (typeof s.order === 'number' && Number.isFinite(s.order)) {
|
|
262
|
+
withOrder.push(s)
|
|
263
|
+
} else {
|
|
264
|
+
withoutOrder.push(s)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
withOrder.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
268
|
+
withoutOrder.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
269
|
+
const merged = [...withOrder, ...withoutOrder]
|
|
270
|
+
return merged.map((s, i) => (s.order === i ? s : { ...s, order: i }))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function normalizeProjects(projects: ProjectRecord[]): ProjectRecord[] {
|
|
274
|
+
return normalizeOrder(projects).map((project) => ensureProjectWorkspaces(project))
|
|
275
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { getProfileConfigDir } from '../profile-paths'
|
|
5
|
+
|
|
6
|
+
const SETUP_HEADER = `#!/usr/bin/env bash
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
`
|
|
10
|
+
|
|
11
|
+
const SETUP_STUB = `#!/usr/bin/env bash
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
|
|
14
|
+
# aimux setup script.
|
|
15
|
+
#
|
|
16
|
+
# Runs once per newly created workspace, with the working directory set to that
|
|
17
|
+
# workspace's root. A workspace is a fresh \`git worktree\`, so it contains only
|
|
18
|
+
# files tracked by git — no .env, no node_modules, no .venv, no build cache.
|
|
19
|
+
#
|
|
20
|
+
# Keep it idempotent and non-interactive. A non-zero exit is surfaced in the
|
|
21
|
+
# setup widget and recorded on the workspace.
|
|
22
|
+
|
|
23
|
+
echo "nothing to do yet — edit this script"
|
|
24
|
+
`
|
|
25
|
+
|
|
26
|
+
// Resolved per call, never module-cached: --profile is applied after module
|
|
27
|
+
// import, so a cached path would point at the wrong profile.
|
|
28
|
+
export function getProjectDataDir(projectId: string): string {
|
|
29
|
+
return join(getProfileConfigDir(), 'projects', projectId)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getSetupScriptPath(projectId: string): string {
|
|
33
|
+
return join(getProjectDataDir(projectId), 'setup.sh')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function hasSetupScript(projectId: string): boolean {
|
|
37
|
+
return existsSync(getSetupScriptPath(projectId))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Returns the script path, creating an executable stub if there is none. */
|
|
41
|
+
export function ensureSetupScriptStub(projectId: string): string {
|
|
42
|
+
const path = getSetupScriptPath(projectId)
|
|
43
|
+
if (existsSync(path)) return path
|
|
44
|
+
mkdirSync(getProjectDataDir(projectId), { recursive: true })
|
|
45
|
+
writeFileSync(path, SETUP_STUB)
|
|
46
|
+
chmodSync(path, 0o755)
|
|
47
|
+
return path
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const EMPTY_LINES: readonly string[] = []
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Keyed by path, not project id: the path depends on the active profile, which is
|
|
54
|
+
* resolved per call. Holds one small array per project that has a setup script.
|
|
55
|
+
*/
|
|
56
|
+
const setupLinesCache = new Map<string, { mtimeMs: number; lines: string[] }>()
|
|
57
|
+
|
|
58
|
+
/** A line that does something, as opposed to a shebang, an option or a comment. */
|
|
59
|
+
function isWorkLine(line: string): boolean {
|
|
60
|
+
const trimmed = line.trim()
|
|
61
|
+
return (
|
|
62
|
+
trimmed !== '' &&
|
|
63
|
+
!trimmed.startsWith('#') &&
|
|
64
|
+
!/^set\s+-[a-zA-Z]/.test(trimmed) &&
|
|
65
|
+
!/^set\s+-o\s/.test(trimmed)
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseSetupScript(source: string): string[] {
|
|
70
|
+
return source
|
|
71
|
+
.split('\n')
|
|
72
|
+
.filter((line) => isWorkLine(line))
|
|
73
|
+
.map((line) => line.trim())
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The script's actual work, with the boilerplate stripped: the shebang, the
|
|
78
|
+
* `set -e…` line, comments and blank lines. One line left means the script is a
|
|
79
|
+
* command, and a single text field can hold all of it; more than one means it is
|
|
80
|
+
* a script, and a text field would silently truncate it.
|
|
81
|
+
*
|
|
82
|
+
* Cached against the file's mtime, because the settings screen builds its Setup
|
|
83
|
+
* rows from this: the search filter runs it for every project on every keystroke,
|
|
84
|
+
* and `getModalOptionCount` runs it from inside the reducer. A `statSync` per
|
|
85
|
+
* project is a syscall; a read-and-parse per project per keystroke is a habit
|
|
86
|
+
* that gets worse with every project the user adds. An edit made outside aimux
|
|
87
|
+
* still lands — that is what the mtime is for.
|
|
88
|
+
*
|
|
89
|
+
* `readonly` because the array it hands back is the cached one, not a copy: a
|
|
90
|
+
* caller that sorted or spliced it would be editing every later caller's answer.
|
|
91
|
+
*/
|
|
92
|
+
export function readSetupScriptLines(projectId: string): readonly string[] {
|
|
93
|
+
const path = getSetupScriptPath(projectId)
|
|
94
|
+
try {
|
|
95
|
+
const { mtimeMs } = statSync(path)
|
|
96
|
+
const cached = setupLinesCache.get(path)
|
|
97
|
+
if (cached?.mtimeMs === mtimeMs) return cached.lines
|
|
98
|
+
const lines = parseSetupScript(readFileSync(path, 'utf8'))
|
|
99
|
+
setupLinesCache.set(path, { lines, mtimeMs })
|
|
100
|
+
return lines
|
|
101
|
+
} catch {
|
|
102
|
+
// Missing or unreadable. Not the same thing, but the caller has nothing
|
|
103
|
+
// better to show for either, and the editor path still works.
|
|
104
|
+
setupLinesCache.delete(path)
|
|
105
|
+
return EMPTY_LINES
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Put this one command in the script, replacing the work line that was there.
|
|
111
|
+
* Round-trips with `readSetupScriptLines`.
|
|
112
|
+
*
|
|
113
|
+
* Everything that is not that line survives: the shebang, the `set -e…`, and any
|
|
114
|
+
* comments. Rewriting the file from a fixed header was simpler and quietly threw
|
|
115
|
+
* away whatever the user had written around their one command — a field that
|
|
116
|
+
* deletes the notes next to what it edits is not an editor.
|
|
117
|
+
*/
|
|
118
|
+
export function writeSetupCommand(projectId: string, command: string): void {
|
|
119
|
+
mkdirSync(getProjectDataDir(projectId), { recursive: true })
|
|
120
|
+
const path = getSetupScriptPath(projectId)
|
|
121
|
+
writeFileSync(path, nextSetupSource(path, command.trim()))
|
|
122
|
+
chmodSync(path, 0o755)
|
|
123
|
+
// Not left to the mtime: a write and the read that follows it can land in the
|
|
124
|
+
// same millisecond, and the cache would hand back what was there before.
|
|
125
|
+
setupLinesCache.delete(path)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function nextSetupSource(path: string, command: string): string {
|
|
129
|
+
let existing: string
|
|
130
|
+
try {
|
|
131
|
+
existing = readFileSync(path, 'utf8')
|
|
132
|
+
} catch {
|
|
133
|
+
// No script yet (or none we can read): the template is all there is to keep.
|
|
134
|
+
return `${SETUP_HEADER}${command}\n`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const lines = existing.split('\n')
|
|
138
|
+
const first = lines.findIndex((line) => isWorkLine(line))
|
|
139
|
+
// Nothing to replace — the stub, or comments only. Append rather than drop them.
|
|
140
|
+
if (first === -1) return `${existing.replace(/\n*$/, '')}\n\n${command}\n`
|
|
141
|
+
lines[first] = command
|
|
142
|
+
return lines.join('\n')
|
|
143
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { AppState, TabSession, TabStatus
|
|
1
|
+
import type { AppState, ProjectSnapshotV1, TabSession, TabStatus } from './types'
|
|
2
2
|
|
|
3
3
|
import { allLeafIds, createGroupId, type LayoutNode, pruneLayoutTree } from './layout-tree'
|
|
4
4
|
|
|
5
|
-
export function
|
|
5
|
+
export function createEmptyProjectSnapshot(): ProjectSnapshotV1 {
|
|
6
6
|
return {
|
|
7
7
|
activeTabId: null,
|
|
8
8
|
savedAt: new Date().toISOString(),
|
|
@@ -16,13 +16,13 @@ export function createEmptyWorkspaceSnapshot(): WorkspaceSnapshotV1 {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* The per-
|
|
20
|
-
* data accrues across
|
|
19
|
+
* The per-workspace last-tab memory is serialized into every snapshot now so the
|
|
20
|
+
* data accrues across projects, but restoring it at startup stays dormant until
|
|
21
21
|
* a future (non-breaking) change is ready to consume it. Flipping this to true
|
|
22
|
-
* makes restart restore the last-viewed tab per
|
|
22
|
+
* makes restart restore the last-viewed tab per workspace; until then restart
|
|
23
23
|
* behavior is unchanged and the live in-memory map is never clobbered on switch.
|
|
24
24
|
*/
|
|
25
|
-
const
|
|
25
|
+
const RESTORE_LAST_ACTIVE_TAB_BY_WORKSPACE: boolean = false
|
|
26
26
|
|
|
27
27
|
function getDisconnectedStatus(status: TabStatus): TabStatus {
|
|
28
28
|
if (status === 'running' || status === 'starting') {
|
|
@@ -32,73 +32,81 @@ function getDisconnectedStatus(status: TabStatus): TabStatus {
|
|
|
32
32
|
return status
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export function
|
|
35
|
+
export function serializeProject(state: AppState): ProjectSnapshotV1 {
|
|
36
36
|
return {
|
|
37
37
|
activeTabId: state.activeTabId,
|
|
38
|
-
|
|
39
|
-
Object.keys(state.
|
|
40
|
-
? state.
|
|
38
|
+
lastActiveTabByWorkspace:
|
|
39
|
+
Object.keys(state.lastActiveTabByWorkspace).length > 0
|
|
40
|
+
? state.lastActiveTabByWorkspace
|
|
41
41
|
: undefined,
|
|
42
42
|
layoutTree: Object.values(state.layoutTrees)[0] ?? undefined,
|
|
43
43
|
layoutTrees: Object.keys(state.layoutTrees).length > 0 ? state.layoutTrees : undefined,
|
|
44
44
|
savedAt: new Date().toISOString(),
|
|
45
|
+
// Mirror of the left bar. `isProjectSnapshotV1` requires this key —
|
|
46
|
+
// dropping it invalidates every entry in the project catalog.
|
|
45
47
|
sidebar: {
|
|
46
|
-
visible: state.
|
|
47
|
-
width: state.
|
|
48
|
+
visible: state.bars.left.visible,
|
|
49
|
+
width: state.bars.left.width,
|
|
48
50
|
},
|
|
49
51
|
tabGroupMap: Object.keys(state.tabGroupMap).length > 0 ? state.tabGroupMap : undefined,
|
|
50
|
-
tabs
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
52
|
+
// Hidden tabs (the setup runner's PTY) are session-scoped on purpose: the
|
|
53
|
+
// durable record of a setup run is `WorkspaceRecord.setupRanAt`, and keeping
|
|
54
|
+
// them out here is what lets `hidden` stay off the ipc/daemon wire.
|
|
55
|
+
tabs: state.tabs
|
|
56
|
+
.filter((tab) => tab.hidden !== true)
|
|
57
|
+
.map((tab) => ({
|
|
58
|
+
assistant: tab.assistant,
|
|
59
|
+
autoRenameStatus: tab.autoRenameStatus,
|
|
60
|
+
buffer: tab.buffer,
|
|
61
|
+
command: tab.command,
|
|
62
|
+
errorMessage: tab.errorMessage,
|
|
63
|
+
exitCode: tab.exitCode,
|
|
64
|
+
id: tab.id,
|
|
65
|
+
status: tab.status === 'disconnected' ? 'running' : tab.status,
|
|
66
|
+
terminalModes: tab.terminalModes,
|
|
67
|
+
title: tab.title,
|
|
68
|
+
viewport: tab.viewport,
|
|
69
|
+
workerName: tab.workerName,
|
|
70
|
+
workspaceId: tab.workspaceId,
|
|
71
|
+
})),
|
|
65
72
|
version: 1,
|
|
66
73
|
}
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
export interface RestoreOptions {
|
|
70
77
|
// Force any running/starting tab to 'disconnected' on restore. True for
|
|
71
|
-
// cold-start (daemon may not own the
|
|
72
|
-
// catalog hydration. False for live in-app
|
|
73
|
-
// attach() is guaranteed to follow within a frame and hydrate-
|
|
78
|
+
// cold-start (daemon may not own the projects yet) and the daemon's own
|
|
79
|
+
// catalog hydration. False for live in-app project switches where an
|
|
80
|
+
// attach() is guaranteed to follow within a frame and hydrate-project
|
|
74
81
|
// will overwrite the status with daemon truth — leaving the flag on would
|
|
75
82
|
// briefly flash the "Restored snapshot" hint on every j/k cycle.
|
|
76
83
|
forceDisconnected?: boolean
|
|
77
|
-
// When provided, drop tabs pinned to a
|
|
78
|
-
// longer owns. Some delete paths (notably the sidebar's "Remove
|
|
79
|
-
// historically removed the
|
|
84
|
+
// When provided, drop tabs pinned to a workspace id that the project no
|
|
85
|
+
// longer owns. Some delete paths (notably the sidebar's "Remove workspace")
|
|
86
|
+
// historically removed the workspace record without closing its tabs, leaving
|
|
80
87
|
// orphans bound to a vanished id. Those orphans are invisible (filtered out
|
|
81
|
-
// by the active-
|
|
82
|
-
// delete can collide with — exactly what closes "another
|
|
88
|
+
// by the active-workspace filter) yet keep a workspace id that a *future*
|
|
89
|
+
// delete can collide with — exactly what closes "another workspace's" tabs.
|
|
83
90
|
// Pruning them on restore both repairs corrupted catalogs and prevents the
|
|
84
|
-
// collision. Tabs with no
|
|
85
|
-
|
|
91
|
+
// collision. Tabs with no workspace id (legacy/unbound) are always kept.
|
|
92
|
+
validWorkspaceIds?: ReadonlySet<string>
|
|
86
93
|
}
|
|
87
94
|
|
|
88
|
-
// Drop tabs bound to a
|
|
89
|
-
// (no
|
|
95
|
+
// Drop tabs bound to a workspace id the project no longer owns. Unbound tabs
|
|
96
|
+
// (no workspaceId) are kept — they surface under the primary workspace.
|
|
90
97
|
function pruneOrphanedTabs(
|
|
91
98
|
tabs: TabSession[],
|
|
92
|
-
|
|
99
|
+
validWorkspaceIds: ReadonlySet<string> | undefined
|
|
93
100
|
): TabSession[] {
|
|
94
|
-
if (!
|
|
101
|
+
if (!validWorkspaceIds) return tabs
|
|
95
102
|
return tabs.filter(
|
|
96
|
-
(tab) =>
|
|
103
|
+
(tab) =>
|
|
104
|
+
tab.workspaceId == null || tab.workspaceId === '' || validWorkspaceIds.has(tab.workspaceId)
|
|
97
105
|
)
|
|
98
106
|
}
|
|
99
107
|
|
|
100
|
-
export function
|
|
101
|
-
snapshot:
|
|
108
|
+
export function restoreTabsFromProject(
|
|
109
|
+
snapshot: ProjectSnapshotV1 | undefined,
|
|
102
110
|
options: RestoreOptions = {}
|
|
103
111
|
): TabSession[] {
|
|
104
112
|
if (!snapshot || snapshot.version !== 1) {
|
|
@@ -126,13 +134,13 @@ export function restoreTabsFromWorkspace(
|
|
|
126
134
|
title: tab.title,
|
|
127
135
|
viewport: tab.viewport,
|
|
128
136
|
workerName: tab.workerName,
|
|
129
|
-
|
|
137
|
+
workspaceId: tab.workspaceId,
|
|
130
138
|
}))
|
|
131
|
-
return pruneOrphanedTabs(restored, options.
|
|
139
|
+
return pruneOrphanedTabs(restored, options.validWorkspaceIds)
|
|
132
140
|
}
|
|
133
141
|
|
|
134
142
|
export function restoreLayoutTrees(
|
|
135
|
-
snapshot:
|
|
143
|
+
snapshot: ProjectSnapshotV1 | undefined,
|
|
136
144
|
tabs: TabSession[]
|
|
137
145
|
): { layoutTrees: Record<string, LayoutNode>; tabGroupMap: Record<string, string> } {
|
|
138
146
|
const validTabIds = new Set(tabs.map((t) => t.id))
|
|
@@ -165,16 +173,16 @@ export function restoreLayoutTrees(
|
|
|
165
173
|
return { layoutTrees, tabGroupMap }
|
|
166
174
|
}
|
|
167
175
|
|
|
168
|
-
// When a
|
|
169
|
-
// — but the
|
|
170
|
-
//
|
|
176
|
+
// When a workspace is removed, its tabs are killed live via disposeWorkspaceTabs
|
|
177
|
+
// — but the project's persisted projectSnapshot still references them by
|
|
178
|
+
// workspaceId. Without this pruning, a subsequent load-project (project switch
|
|
171
179
|
// or restart) resurrects the dead tabs and they reappear in h-l navigation.
|
|
172
|
-
export function
|
|
173
|
-
snapshot:
|
|
174
|
-
|
|
175
|
-
):
|
|
180
|
+
export function pruneSnapshotOfWorkspace(
|
|
181
|
+
snapshot: ProjectSnapshotV1 | undefined,
|
|
182
|
+
workspaceId: string
|
|
183
|
+
): ProjectSnapshotV1 | undefined {
|
|
176
184
|
if (!snapshot) return snapshot
|
|
177
|
-
const keptTabs = snapshot.tabs.filter((tab) => tab.
|
|
185
|
+
const keptTabs = snapshot.tabs.filter((tab) => tab.workspaceId !== workspaceId)
|
|
178
186
|
if (keptTabs.length === snapshot.tabs.length) return snapshot
|
|
179
187
|
const validTabIds = new Set(keptTabs.map((tab) => tab.id))
|
|
180
188
|
|
|
@@ -273,41 +281,37 @@ export function normalizeGroupedTabOrder(
|
|
|
273
281
|
return orderedTabs
|
|
274
282
|
}
|
|
275
283
|
|
|
276
|
-
export function
|
|
284
|
+
export function restoreProjectState(
|
|
277
285
|
state: AppState,
|
|
278
|
-
|
|
286
|
+
projectSnapshot: ProjectSnapshotV1 | undefined,
|
|
279
287
|
options: RestoreOptions = {}
|
|
280
|
-
): Pick<
|
|
281
|
-
AppState,
|
|
282
|
-
|
|
283
|
-
> &
|
|
284
|
-
Partial<Pick<AppState, 'lastActiveTabByWorktree'>> {
|
|
285
|
-
const tabs = restoreTabsFromWorkspace(workspaceSnapshot, options)
|
|
288
|
+
): Pick<AppState, 'tabs' | 'activeTabId' | 'focusMode' | 'layoutTrees' | 'tabGroupMap'> &
|
|
289
|
+
Partial<Pick<AppState, 'lastActiveTabByWorkspace'>> {
|
|
290
|
+
const tabs = restoreTabsFromProject(projectSnapshot, options)
|
|
286
291
|
const activeTabId =
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
tabs.some((tab) => tab.id ===
|
|
290
|
-
?
|
|
292
|
+
projectSnapshot?.activeTabId != null &&
|
|
293
|
+
projectSnapshot?.activeTabId !== '' &&
|
|
294
|
+
tabs.some((tab) => tab.id === projectSnapshot.activeTabId)
|
|
295
|
+
? projectSnapshot.activeTabId
|
|
291
296
|
: (tabs[0]?.id ?? null)
|
|
292
297
|
|
|
293
|
-
const { layoutTrees, tabGroupMap } = restoreLayoutTrees(
|
|
298
|
+
const { layoutTrees, tabGroupMap } = restoreLayoutTrees(projectSnapshot, tabs)
|
|
294
299
|
const orderedTabs = normalizeGroupedTabOrder(tabs, layoutTrees, tabGroupMap)
|
|
295
300
|
|
|
296
|
-
// Dormant until the gate flips: merge the persisted per-
|
|
301
|
+
// Dormant until the gate flips: merge the persisted per-workspace memory over
|
|
297
302
|
// the live in-memory map. While off, the key is omitted entirely so callers
|
|
298
303
|
// that spread the result never overwrite their existing map on a switch.
|
|
299
304
|
const persistedLastActiveTab =
|
|
300
|
-
|
|
301
|
-
? { ...state.
|
|
305
|
+
RESTORE_LAST_ACTIVE_TAB_BY_WORKSPACE && projectSnapshot?.lastActiveTabByWorkspace
|
|
306
|
+
? { ...state.lastActiveTabByWorkspace, ...projectSnapshot.lastActiveTabByWorkspace }
|
|
302
307
|
: undefined
|
|
303
308
|
|
|
304
309
|
return {
|
|
305
310
|
activeTabId,
|
|
306
311
|
focusMode: 'navigation',
|
|
307
312
|
layoutTrees,
|
|
308
|
-
sidebar: state.sidebar,
|
|
309
313
|
tabGroupMap,
|
|
310
314
|
tabs: orderedTabs,
|
|
311
|
-
...(persistedLastActiveTab ? {
|
|
315
|
+
...(persistedLastActiveTab ? { lastActiveTabByWorkspace: persistedLastActiveTab } : {}),
|
|
312
316
|
}
|
|
313
317
|
}
|