tabby-tabbyspaces 0.1.0 → 0.2.1

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.
Files changed (76) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/.github/workflows/ci.yml +26 -0
  3. package/.github/workflows/claude-code-review.yml +44 -0
  4. package/.github/workflows/claude.yml +81 -0
  5. package/.github/workflows/release.yml +30 -0
  6. package/CHANGELOG.md +46 -0
  7. package/CLAUDE.md +33 -0
  8. package/CONTRIBUTING.md +3 -1
  9. package/README.md +21 -18
  10. package/TODO.md +5 -0
  11. package/dist/build-config.d.ts +3 -3
  12. package/dist/components/deleteConfirmModal.component.d.ts +7 -0
  13. package/dist/components/deleteConfirmModal.component.d.ts.map +1 -0
  14. package/dist/components/paneEditor.component.d.ts +9 -18
  15. package/dist/components/paneEditor.component.d.ts.map +1 -1
  16. package/dist/components/splitPreview.component.d.ts +50 -50
  17. package/dist/components/splitPreview.component.d.ts.map +1 -1
  18. package/dist/components/workspaceEditor.component.d.ts +61 -54
  19. package/dist/components/workspaceEditor.component.d.ts.map +1 -1
  20. package/dist/components/workspaceList.component.d.ts +56 -39
  21. package/dist/components/workspaceList.component.d.ts.map +1 -1
  22. package/dist/index.d.ts +6 -6
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -1
  25. package/dist/index.js.LICENSE.txt +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/models/workspace.model.d.ts +118 -78
  28. package/dist/models/workspace.model.d.ts.map +1 -1
  29. package/dist/package.json +1 -1
  30. package/dist/providers/config.provider.d.ts +8 -8
  31. package/dist/providers/settings.provider.d.ts +7 -7
  32. package/dist/providers/toolbar.provider.d.ts +23 -15
  33. package/dist/providers/toolbar.provider.d.ts.map +1 -1
  34. package/dist/services/startupCommand.service.d.ts +27 -19
  35. package/dist/services/startupCommand.service.d.ts.map +1 -1
  36. package/dist/services/workspaceBackground.service.d.ts +38 -0
  37. package/dist/services/workspaceBackground.service.d.ts.map +1 -0
  38. package/dist/services/workspaceEditor.service.d.ts +46 -32
  39. package/dist/services/workspaceEditor.service.d.ts.map +1 -1
  40. package/docs/DESIGN.md +57 -0
  41. package/docs/SESSION-2026-01-14-S1-DESIGN.md +134 -0
  42. package/mockups/index.html +162 -0
  43. package/mockups/s1-tight-sharp.html +522 -0
  44. package/mockups/shared/base.css +216 -0
  45. package/mockups/v06-tabbed.html +643 -0
  46. package/package.json +2 -1
  47. package/screenshots/editor.png +0 -0
  48. package/scripts/build-dev.js +2 -1
  49. package/scripts/build-prod.js +2 -1
  50. package/src/components/deleteConfirmModal.component.ts +23 -0
  51. package/src/components/paneEditor.component.pug +27 -43
  52. package/src/components/paneEditor.component.scss +37 -85
  53. package/src/components/paneEditor.component.ts +4 -32
  54. package/src/components/splitPreview.component.pug +0 -9
  55. package/src/components/splitPreview.component.scss +46 -70
  56. package/src/components/splitPreview.component.ts +15 -25
  57. package/src/components/workspaceEditor.component.pug +140 -112
  58. package/src/components/workspaceEditor.component.scss +270 -202
  59. package/src/components/workspaceEditor.component.ts +161 -85
  60. package/src/components/workspaceList.component.pug +31 -51
  61. package/src/components/workspaceList.component.scss +86 -77
  62. package/src/components/workspaceList.component.ts +89 -34
  63. package/src/index.ts +4 -0
  64. package/src/models/workspace.model.ts +80 -2
  65. package/src/providers/toolbar.provider.ts +78 -9
  66. package/src/services/startupCommand.service.ts +30 -32
  67. package/src/services/workspaceBackground.service.ts +167 -0
  68. package/src/services/workspaceEditor.service.ts +77 -40
  69. package/src/styles/_index.scss +3 -0
  70. package/src/styles/_mixins.scss +180 -0
  71. package/src/styles/_variables.scss +67 -0
  72. package/TEST_MCP.md +0 -176
  73. package/cdp-click.js +0 -22
  74. package/cdp-test.js +0 -28
  75. package/screenshots/pane-edit.png +0 -0
  76. package/test_cdp.py +0 -50
@@ -1,17 +1,23 @@
1
1
  import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef, ElementRef, NgZone } from '@angular/core'
2
+ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
2
3
  import { ConfigService, ProfilesService } from 'tabby-core'
3
4
  import { Subscription } from 'rxjs'
4
5
  import { StartupCommandService } from '../services/startupCommand.service'
5
6
  import { WorkspaceEditorService } from '../services/workspaceEditor.service'
7
+ import { DeleteConfirmModalComponent } from './deleteConfirmModal.component'
6
8
  import {
7
9
  Workspace,
8
10
  WorkspacePane,
9
11
  WorkspaceSplit,
12
+ TabbyProfile,
10
13
  countPanes,
11
14
  createDefaultWorkspace,
15
+ deepClone,
12
16
  isWorkspaceSplit,
13
17
  } from '../models/workspace.model'
14
18
 
19
+ const SETTINGS_MAX_WIDTH = '876px'
20
+
15
21
  @Component({
16
22
  selector: 'workspace-list',
17
23
  template: require('./workspaceList.component.pug'),
@@ -23,6 +29,8 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
23
29
  editingWorkspace: Workspace | null = null
24
30
  isCreatingNew = false
25
31
  openingWorkspaceId: string | null = null
32
+ displayTabs: Array<{ workspace: Workspace; isNew: boolean }> = []
33
+ private cachedProfiles: TabbyProfile[] = []
26
34
  private configSubscription: Subscription | null = null
27
35
 
28
36
  constructor(
@@ -30,16 +38,18 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
30
38
  private workspaceService: WorkspaceEditorService,
31
39
  private profilesService: ProfilesService,
32
40
  private startupService: StartupCommandService,
41
+ private modalService: NgbModal,
33
42
  private cdr: ChangeDetectorRef,
34
43
  private elementRef: ElementRef,
35
44
  private zone: NgZone
36
45
  ) {}
37
46
 
38
- ngOnInit(): void {
47
+ async ngOnInit(): Promise<void> {
39
48
  this.loadWorkspaces()
40
49
  this.autoSelectFirst()
50
+ this.cachedProfiles = await this.workspaceService.getAvailableProfiles()
41
51
  this.configSubscription = this.config.changed$.subscribe(() => {
42
- this.loadWorkspaces()
52
+ this.zone.run(() => this.loadWorkspaces())
43
53
  })
44
54
  }
45
55
 
@@ -48,7 +58,7 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
48
58
  setTimeout(() => {
49
59
  const parent = this.elementRef.nativeElement.closest('settings-tab-body') as HTMLElement
50
60
  if (parent) {
51
- parent.style.maxWidth = '876px'
61
+ parent.style.maxWidth = SETTINGS_MAX_WIDTH
52
62
  }
53
63
  }, 0)
54
64
  }
@@ -62,7 +72,8 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
62
72
  selectWorkspace(workspace: Workspace): void {
63
73
  this.isCreatingNew = false
64
74
  this.selectedWorkspace = workspace
65
- this.editingWorkspace = JSON.parse(JSON.stringify(workspace))
75
+ this.editingWorkspace = deepClone(workspace)
76
+ this.updateDisplayTabs()
66
77
  }
67
78
 
68
79
  isSelected(workspace: Workspace): boolean {
@@ -74,18 +85,26 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
74
85
  }
75
86
 
76
87
  loadWorkspaces(): void {
88
+ const previousSelectedId = this.selectedWorkspace?.id
77
89
  this.workspaces = this.workspaceService.getWorkspaces()
78
- this.cdr.detectChanges()
90
+
91
+ // Re-sync selectedWorkspace to point to object in new array
92
+ // This prevents stale reference after delete/reload operations
93
+ if (previousSelectedId) {
94
+ this.selectedWorkspace = this.workspaces.find(w => w.id === previousSelectedId) || null
95
+ }
96
+
97
+ this.updateDisplayTabs()
79
98
  }
80
99
 
81
- async createWorkspace(): Promise<void> {
82
- const profiles = await this.workspaceService.getAvailableProfiles()
83
- const defaultProfileId = profiles[0]?.id || ''
100
+ createWorkspace(): void {
101
+ const defaultProfileId = this.cachedProfiles[0]?.id || ''
84
102
  const workspace = createDefaultWorkspace()
85
103
  this.setProfileForAllPanes(workspace.root, defaultProfileId)
86
104
  this.selectedWorkspace = null
87
105
  this.editingWorkspace = workspace
88
106
  this.isCreatingNew = true
107
+ this.updateDisplayTabs()
89
108
  this.cdr.detectChanges()
90
109
  }
91
110
 
@@ -105,33 +124,48 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
105
124
  event.stopPropagation()
106
125
  const clone = this.workspaceService.duplicateWorkspace(workspace)
107
126
  await this.workspaceService.addWorkspace(clone)
108
- this.loadWorkspaces()
109
127
 
110
- // Select the duplicated workspace
111
- const duplicated = this.workspaces.find((w) => w.id === clone.id)
112
- if (duplicated) {
113
- this.selectWorkspace(duplicated)
114
- }
115
- this.cdr.detectChanges()
128
+ this.zone.run(() => {
129
+ this.loadWorkspaces()
130
+ const duplicated = this.workspaces.find((w) => w.id === clone.id)
131
+ if (duplicated) {
132
+ this.selectWorkspace(duplicated)
133
+ }
134
+ })
116
135
  }
117
136
 
118
137
  async deleteWorkspace(event: MouseEvent, workspace: Workspace): Promise<void> {
119
138
  event.stopPropagation()
120
- if (confirm(`Delete workspace "${workspace.name}"?`)) {
121
- const currentIndex = this.workspaces.findIndex((w) => w.id === workspace.id)
122
- await this.workspaceService.deleteWorkspace(workspace.id)
123
- this.loadWorkspaces()
124
139
 
125
- // Select next workspace after deletion
126
- if (this.workspaces.length > 0) {
127
- const nextIndex = Math.min(currentIndex, this.workspaces.length - 1)
128
- this.selectWorkspace(this.workspaces[nextIndex])
129
- } else {
140
+ const confirmed = await this.confirmDelete(workspace.name)
141
+ if (!confirmed) return
142
+
143
+ const wasSelected = this.selectedWorkspace?.id === workspace.id
144
+ const deletedIndex = this.workspaces.findIndex((w) => w.id === workspace.id)
145
+
146
+ await this.workspaceService.deleteWorkspace(workspace.id)
147
+
148
+ this.zone.run(() => {
149
+ this.loadWorkspaces()
150
+ if (this.workspaces.length === 0) {
130
151
  this.selectedWorkspace = null
131
152
  this.editingWorkspace = null
132
153
  this.isCreatingNew = false
154
+ } else if (wasSelected) {
155
+ const nextIndex = Math.min(deletedIndex, this.workspaces.length - 1)
156
+ this.selectWorkspace(this.workspaces[nextIndex])
133
157
  }
134
- this.cdr.detectChanges()
158
+ })
159
+ }
160
+
161
+ private async confirmDelete(name: string): Promise<boolean> {
162
+ const modalRef = this.modalService.open(DeleteConfirmModalComponent)
163
+ modalRef.componentInstance.workspaceName = name
164
+ try {
165
+ await modalRef.result
166
+ return true
167
+ } catch {
168
+ return false
135
169
  }
136
170
  }
137
171
 
@@ -142,15 +176,16 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
142
176
  } else {
143
177
  await this.workspaceService.updateWorkspace(workspace)
144
178
  }
145
- this.loadWorkspaces()
146
- this.isCreatingNew = false
147
179
 
148
- // Select the saved workspace
149
- const saved = this.workspaces.find((w) => w.id === workspace.id)
150
- if (saved) {
151
- this.selectWorkspace(saved)
152
- }
153
- this.cdr.detectChanges()
180
+ // Wrap state changes in zone.run to ensure proper change detection
181
+ this.zone.run(() => {
182
+ this.loadWorkspaces()
183
+ this.isCreatingNew = false
184
+ const saved = this.workspaces.find((w) => w.id === workspace.id)
185
+ if (saved) {
186
+ this.selectWorkspace(saved)
187
+ }
188
+ })
154
189
  }
155
190
 
156
191
  onEditorCancel(): void {
@@ -162,10 +197,11 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
162
197
  } else {
163
198
  this.selectedWorkspace = null
164
199
  this.editingWorkspace = null
200
+ this.updateDisplayTabs()
165
201
  }
166
202
  } else if (this.selectedWorkspace) {
167
203
  // Reset to original workspace data
168
- this.editingWorkspace = JSON.parse(JSON.stringify(this.selectedWorkspace))
204
+ this.editingWorkspace = deepClone(this.selectedWorkspace)
169
205
  }
170
206
  this.cdr.detectChanges()
171
207
  }
@@ -183,6 +219,24 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
183
219
  return JSON.stringify(this.editingWorkspace) !== JSON.stringify(this.selectedWorkspace)
184
220
  }
185
221
 
222
+ // Update display tabs array (call after state changes)
223
+ private updateDisplayTabs(): void {
224
+ const tabs = this.workspaces.map(w => ({ workspace: w, isNew: false }))
225
+ if (this.isCreatingNew && this.editingWorkspace) {
226
+ tabs.push({ workspace: this.editingWorkspace, isNew: true })
227
+ }
228
+ this.displayTabs = tabs
229
+ }
230
+
231
+ isTabSelected(tab: { workspace: Workspace; isNew: boolean }): boolean {
232
+ if (tab.isNew) return true
233
+ return this.selectedWorkspace?.id === tab.workspace.id
234
+ }
235
+
236
+ trackByTab(index: number, tab: { workspace: Workspace; isNew: boolean }): string {
237
+ return tab.isNew ? '__new__' : tab.workspace.id
238
+ }
239
+
186
240
  async openWorkspace(event: MouseEvent, workspace: Workspace): Promise<void> {
187
241
  event.stopPropagation()
188
242
  if (this.openingWorkspaceId) return
@@ -203,4 +257,5 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
203
257
  this.cdr.detectChanges()
204
258
  }
205
259
  }
260
+
206
261
  }
package/src/index.ts CHANGED
@@ -9,11 +9,13 @@ import { WorkspaceEditorSettingsProvider } from './providers/settings.provider'
9
9
  import { WorkspaceToolbarProvider } from './providers/toolbar.provider'
10
10
  import { WorkspaceEditorService } from './services/workspaceEditor.service'
11
11
  import { StartupCommandService } from './services/startupCommand.service'
12
+ import { WorkspaceBackgroundService } from './services/workspaceBackground.service'
12
13
 
13
14
  import { WorkspaceListComponent } from './components/workspaceList.component'
14
15
  import { WorkspaceEditorComponent } from './components/workspaceEditor.component'
15
16
  import { PaneEditorComponent } from './components/paneEditor.component'
16
17
  import { SplitPreviewComponent } from './components/splitPreview.component'
18
+ import { DeleteConfirmModalComponent } from './components/deleteConfirmModal.component'
17
19
 
18
20
  @NgModule({
19
21
  imports: [CommonModule, FormsModule],
@@ -23,12 +25,14 @@ import { SplitPreviewComponent } from './components/splitPreview.component'
23
25
  { provide: ToolbarButtonProvider, useClass: WorkspaceToolbarProvider, multi: true },
24
26
  WorkspaceEditorService,
25
27
  StartupCommandService,
28
+ WorkspaceBackgroundService,
26
29
  ],
27
30
  declarations: [
28
31
  WorkspaceListComponent,
29
32
  WorkspaceEditorComponent,
30
33
  PaneEditorComponent,
31
34
  SplitPreviewComponent,
35
+ DeleteConfirmModalComponent,
32
36
  ],
33
37
  })
34
38
  export default class WorkspaceEditorModule {}
@@ -38,6 +38,8 @@ export interface TabbyRecoveryToken {
38
38
  tabCustomTitle?: string
39
39
  disableDynamicTitle?: boolean
40
40
  cwd?: string
41
+ // Allow custom properties (matches Tabby's RecoveryToken interface)
42
+ [key: string]: any
41
43
  }
42
44
 
43
45
  export interface TabbySplitLayoutProfile {
@@ -59,7 +61,6 @@ export interface WorkspacePane {
59
61
  profileId: string
60
62
  cwd?: string
61
63
  startupCommand?: string
62
- title?: string
63
64
  }
64
65
 
65
66
  export interface WorkspaceSplit {
@@ -68,29 +69,71 @@ export interface WorkspaceSplit {
68
69
  children: (WorkspacePane | WorkspaceSplit)[]
69
70
  }
70
71
 
72
+ export interface WorkspaceBackground {
73
+ type: 'none' | 'solid' | 'gradient' | 'image'
74
+ value: string // CSS value: hex, gradient string, or URL
75
+ }
76
+
71
77
  export interface Workspace {
72
78
  id: string
73
79
  name: string
74
80
  icon?: string
75
81
  color?: string
82
+ background?: WorkspaceBackground
76
83
  root: WorkspaceSplit
77
84
  launchOnStartup?: boolean
78
85
  }
79
86
 
87
+ // Preset backgrounds for quick selection
88
+ export const BACKGROUND_PRESETS: WorkspaceBackground[] = [
89
+ { type: 'none', value: '' },
90
+ // Existing presets
91
+ { type: 'gradient', value: 'linear-gradient(132deg, transparent 83%, rgba(6, 220, 249, 0.18) 100%), linear-gradient(210deg, transparent 85%, rgba(139, 92, 246, 0.2) 100%)' },
92
+ { type: 'gradient', value: 'linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, transparent 50%)' },
93
+ { type: 'gradient', value: 'linear-gradient(45deg, rgba(239, 68, 68, 0.1) 0%, transparent 50%)' },
94
+ { type: 'gradient', value: 'linear-gradient(135deg, rgba(59, 130, 246, 0.15) 0%, transparent 50%)' },
95
+ { type: 'gradient', value: 'linear-gradient(225deg, transparent 70%, rgba(249, 115, 22, 0.15) 100%)' },
96
+ { type: 'gradient', value: 'linear-gradient(180deg, rgba(139, 92, 246, 0.1) 0%, transparent 40%)' },
97
+ // New presets
98
+ { type: 'gradient', value: 'linear-gradient(315deg, transparent 80%, rgba(236, 72, 153, 0.15) 100%)' }, // Pink bottom-right
99
+ { type: 'gradient', value: 'linear-gradient(0deg, rgba(6, 182, 212, 0.12) 0%, transparent 35%)' }, // Cyan bottom
100
+ { type: 'gradient', value: 'linear-gradient(45deg, transparent 85%, rgba(234, 179, 8, 0.18) 100%), linear-gradient(225deg, transparent 85%, rgba(249, 115, 22, 0.15) 100%)' }, // Gold corners
101
+ { type: 'gradient', value: 'linear-gradient(160deg, rgba(34, 197, 94, 0.12) 0%, transparent 40%)' }, // Green top-left
102
+ { type: 'gradient', value: 'linear-gradient(200deg, transparent 75%, rgba(99, 102, 241, 0.18) 100%)' }, // Indigo bottom-left
103
+ { type: 'gradient', value: 'linear-gradient(135deg, rgba(20, 184, 166, 0.1) 0%, transparent 50%), linear-gradient(315deg, rgba(139, 92, 246, 0.1) 0%, transparent 50%)' }, // Teal + Violet diagonal
104
+ { type: 'gradient', value: 'linear-gradient(90deg, rgba(239, 68, 68, 0.08) 0%, transparent 30%, transparent 70%, rgba(59, 130, 246, 0.08) 100%)' }, // Red-Blue sides
105
+ { type: 'gradient', value: 'linear-gradient(180deg, transparent 60%, rgba(16, 185, 129, 0.12) 100%)' }, // Emerald bottom fade
106
+ { type: 'gradient', value: 'linear-gradient(45deg, rgba(168, 85, 247, 0.1) 0%, transparent 40%), linear-gradient(225deg, rgba(6, 182, 212, 0.1) 0%, transparent 40%)' }, // Purple + Cyan corners
107
+ { type: 'gradient', value: 'linear-gradient(150deg, transparent 70%, rgba(251, 146, 60, 0.15) 100%), linear-gradient(30deg, transparent 70%, rgba(251, 146, 60, 0.1) 100%)' }, // Warm orange accents
108
+ ]
109
+
110
+ /**
111
+ * Type guard to check if a node is a WorkspaceSplit.
112
+ * @param node - The node to check
113
+ * @returns True if the node is a WorkspaceSplit
114
+ */
80
115
  export function isWorkspaceSplit(node: WorkspacePane | WorkspaceSplit): node is WorkspaceSplit {
81
116
  return 'orientation' in node && 'children' in node
82
117
  }
83
118
 
119
+ /**
120
+ * Creates a new pane with default configuration.
121
+ * @returns A new WorkspacePane with generated UUID and empty settings
122
+ */
84
123
  export function createDefaultPane(): WorkspacePane {
85
124
  return {
86
125
  id: generateUUID(),
87
126
  profileId: '',
88
127
  cwd: '',
89
128
  startupCommand: '',
90
- title: '',
91
129
  }
92
130
  }
93
131
 
132
+ /**
133
+ * Creates a new split with two default panes.
134
+ * @param orientation - Split direction ('horizontal' or 'vertical'), defaults to 'horizontal'
135
+ * @returns A new WorkspaceSplit with two panes at 50/50 ratio
136
+ */
94
137
  export function createDefaultSplit(orientation: 'horizontal' | 'vertical' = 'horizontal'): WorkspaceSplit {
95
138
  return {
96
139
  orientation,
@@ -118,14 +161,21 @@ const WORKSPACE_ICONS = [
118
161
  'bug', 'wrench', 'cube', 'layer-group', 'sitemap', 'project-diagram'
119
162
  ]
120
163
 
164
+ /** Returns a random color from the workspace color palette. */
121
165
  export function getRandomColor(): string {
122
166
  return WORKSPACE_COLORS[Math.floor(Math.random() * WORKSPACE_COLORS.length)]
123
167
  }
124
168
 
169
+ /** Returns a random icon from the workspace icon set. */
125
170
  export function getRandomIcon(): string {
126
171
  return WORKSPACE_ICONS[Math.floor(Math.random() * WORKSPACE_ICONS.length)]
127
172
  }
128
173
 
174
+ /**
175
+ * Creates a new workspace with default configuration.
176
+ * @param name - Display name for the workspace (optional)
177
+ * @returns A new Workspace with generated UUID, random icon/color, and a default split
178
+ */
129
179
  export function createDefaultWorkspace(name: string = ''): Workspace {
130
180
  return {
131
181
  id: generateUUID(),
@@ -137,6 +187,7 @@ export function createDefaultWorkspace(name: string = ''): Workspace {
137
187
  }
138
188
  }
139
189
 
190
+ /** Generates a random UUID v4 string. */
140
191
  export function generateUUID(): string {
141
192
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
142
193
  const r = (Math.random() * 16) | 0
@@ -145,9 +196,36 @@ export function generateUUID(): string {
145
196
  })
146
197
  }
147
198
 
199
+ /**
200
+ * Recursively counts the total number of panes in a split tree.
201
+ * @param node - The root node to count from
202
+ * @returns Total number of panes in the tree
203
+ */
148
204
  export function countPanes(node: WorkspacePane | WorkspaceSplit): number {
149
205
  if (isWorkspaceSplit(node)) {
150
206
  return node.children.reduce((sum, child) => sum + countPanes(child), 0)
151
207
  }
152
208
  return 1
153
209
  }
210
+
211
+ /**
212
+ * Creates a deep clone of an object, preserving type information.
213
+ * More efficient than JSON.parse(JSON.stringify()) for simple objects.
214
+ * @param obj - The object to clone
215
+ * @returns A deep copy of the object
216
+ */
217
+ export function deepClone<T>(obj: T): T {
218
+ if (obj === null || typeof obj !== 'object') {
219
+ return obj
220
+ }
221
+ if (Array.isArray(obj)) {
222
+ return obj.map(item => deepClone(item)) as T
223
+ }
224
+ const cloned = {} as T
225
+ for (const key in obj) {
226
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
227
+ cloned[key] = deepClone(obj[key])
228
+ }
229
+ }
230
+ return cloned
231
+ }
@@ -1,7 +1,9 @@
1
1
  import { Injectable } from '@angular/core'
2
- import { ToolbarButtonProvider, ToolbarButton, ProfilesService, AppService } from 'tabby-core'
2
+ import { ToolbarButtonProvider, ToolbarButton, ProfilesService, AppService, SplitTabComponent } from 'tabby-core'
3
+ import { BaseTerminalTabComponent } from 'tabby-terminal'
3
4
  import { WorkspaceEditorService } from '../services/workspaceEditor.service'
4
5
  import { StartupCommandService } from '../services/startupCommand.service'
6
+ import { WorkspaceBackgroundService } from '../services/workspaceBackground.service'
5
7
  import { SettingsTabComponent } from 'tabby-settings'
6
8
  import { CONFIG_KEY, DISPLAY_NAME, IS_DEV } from '../build-config'
7
9
 
@@ -15,24 +17,51 @@ const ICON_GRID = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" f
15
17
  const ICON_BOLT = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
16
18
  <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
17
19
  </svg>`
20
+
21
+ const SELECTOR_SETTINGS_ID = '__settings__'
22
+
18
23
  import { countPanes } from '../models/workspace.model'
19
24
 
25
+ /** Recovery token structure for workspace tabs */
26
+ interface RecoveryTokenWithWorkspace {
27
+ workspaceId?: string
28
+ }
29
+
20
30
  @Injectable()
21
31
  export class WorkspaceToolbarProvider extends ToolbarButtonProvider {
22
32
  constructor(
23
33
  private workspaceService: WorkspaceEditorService,
24
34
  private profilesService: ProfilesService,
25
35
  private app: AppService,
26
- private startupService: StartupCommandService
36
+ private startupService: StartupCommandService,
37
+ private backgroundService: WorkspaceBackgroundService
27
38
  ) {
28
39
  super()
29
- // Delay startup tasks to ensure Tabby config is loaded
30
- setTimeout(() => {
31
- // Cleanup orphaned profiles from previous plugin versions (one-time migration)
40
+ // Initialize background service to listen for tab events
41
+ this.backgroundService.initialize()
42
+
43
+ // Wait for Tabby to finish recovery before launching startup workspaces
44
+ this.waitForTabbyReady().then(() => {
32
45
  this.workspaceService.cleanupOrphanedProfiles()
33
- // Launch workspaces marked for startup
34
46
  this.launchStartupWorkspaces()
35
- }, 500)
47
+ })
48
+ }
49
+
50
+ private waitForTabbyReady(): Promise<void> {
51
+ return new Promise(resolve => {
52
+ let lastTabCount = -1
53
+ const checkStable = () => {
54
+ const currentCount = this.app.tabs.length
55
+ if (currentCount === lastTabCount && currentCount >= 0) {
56
+ resolve()
57
+ } else {
58
+ lastTabCount = currentCount
59
+ setTimeout(checkStable, 300)
60
+ }
61
+ }
62
+ // Initial delay to let Tabby start loading
63
+ setTimeout(checkStable, 500)
64
+ })
36
65
  }
37
66
 
38
67
  private async launchStartupWorkspaces(): Promise<void> {
@@ -40,10 +69,50 @@ export class WorkspaceToolbarProvider extends ToolbarButtonProvider {
40
69
  const startupWorkspaces = workspaces.filter(w => w.launchOnStartup)
41
70
 
42
71
  for (const workspace of startupWorkspaces) {
72
+ if (this.isWorkspaceAlreadyOpen(workspace.id)) {
73
+ console.log(`[TabbySpaces] Workspace "${workspace.name}" already open, skipping`)
74
+ continue
75
+ }
43
76
  await this.openWorkspace(workspace.id)
44
77
  }
45
78
  }
46
79
 
80
+ /**
81
+ * Type-safe helper to extract workspace ID from tab's recovery token.
82
+ */
83
+ private getRecoveryWorkspaceId(tab: unknown): string | undefined {
84
+ if (tab && typeof tab === 'object' && 'recoveryToken' in tab) {
85
+ const token = (tab as { recoveryToken?: RecoveryTokenWithWorkspace }).recoveryToken
86
+ return token?.workspaceId
87
+ }
88
+ return undefined
89
+ }
90
+
91
+ private isWorkspaceAlreadyOpen(workspaceId: string): boolean {
92
+ const profilePrefix = `split-layout:${CONFIG_KEY}:`
93
+
94
+ for (const tab of this.app.tabs) {
95
+ if (tab instanceof SplitTabComponent) {
96
+ // Strategy 1: Check recoveryToken.workspaceId (for restored tabs)
97
+ if (this.getRecoveryWorkspaceId(tab) === workspaceId) {
98
+ return true
99
+ }
100
+
101
+ // Strategy 2: Check profile ID (for freshly opened tabs)
102
+ for (const child of tab.getAllTabs()) {
103
+ if (child instanceof BaseTerminalTabComponent) {
104
+ const profileId = child.profile?.id ?? ''
105
+ // Strict matching: prefix + workspaceId at the end
106
+ if (profileId.startsWith(profilePrefix) && profileId.endsWith(`:${workspaceId}`)) {
107
+ return true
108
+ }
109
+ }
110
+ }
111
+ }
112
+ }
113
+ return false
114
+ }
115
+
47
116
  provide(): ToolbarButton[] {
48
117
  return [
49
118
  {
@@ -77,12 +146,12 @@ export class WorkspaceToolbarProvider extends ToolbarButtonProvider {
77
146
  description: 'Create and edit workspaces',
78
147
  icon: 'cog',
79
148
  color: undefined,
80
- result: '__settings__'
149
+ result: SELECTOR_SETTINGS_ID
81
150
  })
82
151
 
83
152
  const selectedId = await this.app.showSelector('Select Workspace', options)
84
153
 
85
- if (selectedId === '__settings__') {
154
+ if (selectedId === SELECTOR_SETTINGS_ID) {
86
155
  this.openSettings()
87
156
  } else if (selectedId) {
88
157
  this.openWorkspace(selectedId)
@@ -1,7 +1,8 @@
1
1
  import { Injectable } from '@angular/core'
2
2
  import { AppService, BaseTabComponent, SplitTabComponent } from 'tabby-core'
3
3
  import { BaseTerminalTabComponent } from 'tabby-terminal'
4
- import { Subscription, first, timer, switchMap } from 'rxjs'
4
+ import { first, timeout, of } from 'rxjs'
5
+ import { catchError } from 'rxjs/operators'
5
6
 
6
7
  export interface PendingCommand {
7
8
  paneId: string
@@ -9,13 +10,21 @@ export interface PendingCommand {
9
10
  originalTitle: string
10
11
  }
11
12
 
13
+ /**
14
+ * Handles startup commands for workspace panes.
15
+ *
16
+ * This service listens to tab open events and sends startup commands
17
+ * to terminals that match registered pane IDs.
18
+ *
19
+ * NOTE: This is a module-level singleton that lives for the app lifetime.
20
+ * The tabOpened$ subscription intentionally runs forever - no cleanup needed.
21
+ */
12
22
  @Injectable()
13
23
  export class StartupCommandService {
14
24
  private pendingCommands: Map<string, PendingCommand> = new Map()
15
- private subscription: Subscription
16
25
 
17
26
  constructor(private app: AppService) {
18
- this.subscription = this.app.tabOpened$.subscribe((tab) => this.onTabOpened(tab))
27
+ this.app.tabOpened$.subscribe((tab) => this.onTabOpened(tab))
19
28
  }
20
29
 
21
30
  registerCommands(commands: PendingCommand[]): void {
@@ -84,40 +93,27 @@ export class StartupCommandService {
84
93
 
85
94
  console.log('[TabbySpaces] Command matched, waiting for shell output...:', fullCommand)
86
95
 
96
+ // Unified command sender - reduces duplication
97
+ const sendCommand = () => {
98
+ console.log('[TabbySpaces] Shell ready, sending command:', fullCommand)
99
+ terminalTab.sendInput(fullCommand + '\r')
100
+ this.clearProfileArgs(terminalTab)
101
+ this.setTabTitle(terminalTab, pending.originalTitle)
102
+ }
103
+
87
104
  // Wait for shell to emit first output (prompt), then send command
88
105
  if (terminalTab.session?.output$) {
89
106
  terminalTab.session.output$.pipe(
90
- first(), // Wait for first output (shell prompt)
91
- switchMap(() => timer(100)) // Small buffer after prompt renders
107
+ first(),
108
+ timeout(2000), // Prevent infinite wait if shell doesn't emit
109
+ catchError(() => of(null)) // Fallback on timeout/error
92
110
  ).subscribe(() => {
93
- console.log('[TabbySpaces] Shell ready, sending command:', fullCommand)
94
- terminalTab.sendInput(fullCommand + '\r')
95
-
96
- // Clear profile args to prevent native splits from re-running command
97
- this.clearProfileArgs(terminalTab)
98
-
99
- // Reset title - either to original or clear for dynamic shell title
100
- if (pending.originalTitle) {
101
- terminalTab.setTitle(pending.originalTitle)
102
- } else {
103
- terminalTab.customTitle = ''
104
- }
111
+ // Small delay after prompt renders
112
+ setTimeout(sendCommand, 100)
105
113
  })
106
114
  } else {
107
115
  console.log('[TabbySpaces] No session.output$, falling back to timeout')
108
- // Fallback if session not available yet
109
- setTimeout(() => {
110
- terminalTab.sendInput(fullCommand + '\r')
111
-
112
- // Clear profile args to prevent native splits from re-running command
113
- this.clearProfileArgs(terminalTab)
114
-
115
- if (pending.originalTitle) {
116
- terminalTab.setTitle(pending.originalTitle)
117
- } else {
118
- terminalTab.customTitle = ''
119
- }
120
- }, 500)
116
+ setTimeout(sendCommand, 500)
121
117
  }
122
118
  }
123
119
 
@@ -136,7 +132,9 @@ export class StartupCommandService {
136
132
  }
137
133
  }
138
134
 
139
- ngOnDestroy(): void {
140
- this.subscription?.unsubscribe()
135
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
136
+ private setTabTitle(terminalTab: BaseTerminalTabComponent<any>, title: string): void {
137
+ terminalTab.setTitle(title)
138
+ terminalTab.customTitle = title
141
139
  }
142
140
  }