tabby-tabbyspaces 0.0.1 → 0.1.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.
Files changed (56) hide show
  1. package/.claude/settings.local.json +28 -2
  2. package/CHANGELOG.md +46 -20
  3. package/CLAUDE.md +163 -15
  4. package/README.md +71 -61
  5. package/RELEASE.md +91 -0
  6. package/TEST_MCP.md +176 -0
  7. package/TODO.md +72 -0
  8. package/cdp-click.js +22 -0
  9. package/cdp-test.js +28 -0
  10. package/dist/components/paneEditor.component.d.ts +6 -1
  11. package/dist/components/paneEditor.component.d.ts.map +1 -1
  12. package/dist/components/splitPreview.component.d.ts +22 -7
  13. package/dist/components/splitPreview.component.d.ts.map +1 -1
  14. package/dist/components/workspaceEditor.component.d.ts +30 -4
  15. package/dist/components/workspaceEditor.component.d.ts.map +1 -1
  16. package/dist/components/workspaceList.component.d.ts +21 -9
  17. package/dist/components/workspaceList.component.d.ts.map +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/index.js.LICENSE.txt +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/models/workspace.model.d.ts +4 -2
  23. package/dist/models/workspace.model.d.ts.map +1 -1
  24. package/dist/package.json +26 -0
  25. package/dist/providers/settings.provider.d.ts.map +1 -1
  26. package/dist/providers/toolbar.provider.d.ts +4 -1
  27. package/dist/providers/toolbar.provider.d.ts.map +1 -1
  28. package/dist/services/startupCommand.service.d.ts +20 -0
  29. package/dist/services/startupCommand.service.d.ts.map +1 -0
  30. package/dist/services/workspaceEditor.service.d.ts +11 -3
  31. package/dist/services/workspaceEditor.service.d.ts.map +1 -1
  32. package/docs/marketing_status.md +92 -0
  33. package/package.json +2 -7
  34. package/screenshots/editor.png +0 -0
  35. package/screenshots/pane-edit.png +0 -0
  36. package/scripts/build-prod.js +39 -0
  37. package/src/components/paneEditor.component.pug +2 -2
  38. package/src/components/paneEditor.component.ts +19 -1
  39. package/src/components/splitPreview.component.pug +45 -5
  40. package/src/components/splitPreview.component.scss +79 -22
  41. package/src/components/splitPreview.component.ts +91 -16
  42. package/src/components/workspaceEditor.component.pug +130 -70
  43. package/src/components/workspaceEditor.component.scss +205 -120
  44. package/src/components/workspaceEditor.component.ts +193 -6
  45. package/src/components/workspaceList.component.pug +31 -20
  46. package/src/components/workspaceList.component.scss +12 -6
  47. package/src/components/workspaceList.component.ts +116 -34
  48. package/src/index.ts +2 -0
  49. package/src/models/workspace.model.ts +33 -6
  50. package/src/providers/settings.provider.ts +2 -2
  51. package/src/providers/toolbar.provider.ts +41 -10
  52. package/src/services/startupCommand.service.ts +142 -0
  53. package/src/services/workspaceEditor.service.ts +70 -38
  54. package/test_cdp.py +50 -0
  55. package/RELEASE_PLAN.md +0 -161
  56. package/screenshots/workspace-edit.png +0 -0
@@ -5,8 +5,23 @@
5
5
  i.fas.fa-plus
6
6
  | New Workspace
7
7
 
8
+ //- Editor (above list)
9
+ workspace-editor(
10
+ *ngIf='editingWorkspace',
11
+ [workspace]='editingWorkspace',
12
+ [autoFocus]='isCreatingNew',
13
+ [hasUnsavedChanges]='hasUnsavedChanges',
14
+ (save)='onEditorSave($event)',
15
+ (cancel)='onEditorCancel()'
16
+ )
17
+
18
+ //- Workspace list
8
19
  .workspace-list(*ngIf='workspaces.length > 0')
9
- .workspace-item(*ngFor='let workspace of workspaces')
20
+ .workspace-item(
21
+ *ngFor='let workspace of workspaces',
22
+ [class.selected]='isSelected(workspace)',
23
+ (click)='selectWorkspace(workspace)'
24
+ )
10
25
  .workspace-info
11
26
  .workspace-icon([style.color]='workspace.color')
12
27
  i.fas([class]='"fa-" + (workspace.icon || "columns")')
@@ -16,31 +31,27 @@
16
31
  span {{ getPaneCount(workspace) }} panes
17
32
  span.separator ·
18
33
  span {{ getOrientationLabel(workspace) }}
19
- span.separator(*ngIf='workspace.isDefault') ·
20
- span.badge.badge-primary(*ngIf='workspace.isDefault') default
34
+ span.separator(*ngIf='workspace.launchOnStartup') ·
35
+ span.badge.badge-primary(*ngIf='workspace.launchOnStartup') startup
21
36
 
22
37
  .workspace-actions
23
- button.btn.btn-link(
38
+ button.btn.btn-link.open-btn(
24
39
  type='button',
25
- title='Set as default',
26
- (click)='setAsDefault(workspace)',
27
- [class.active]='workspace.isDefault'
40
+ title='Open',
41
+ [disabled]='openingWorkspaceId === workspace.id',
42
+ (click)='openWorkspace($event, workspace)'
28
43
  )
29
- i.fas.fa-star
30
- button.btn.btn-link(type='button', title='Duplicate', (click)='duplicateWorkspace(workspace)')
44
+ i.fas(
45
+ [class.fa-external-link-alt]='openingWorkspaceId !== workspace.id',
46
+ [class.fa-spinner]='openingWorkspaceId === workspace.id',
47
+ [class.fa-spin]='openingWorkspaceId === workspace.id'
48
+ )
49
+ button.btn.btn-link(type='button', title='Duplicate', (click)='duplicateWorkspace($event, workspace)')
31
50
  i.fas.fa-copy
32
- button.btn.btn-link(type='button', title='Edit', (click)='editWorkspace(workspace)')
33
- i.fas.fa-edit
34
- button.btn.btn-link.text-danger(type='button', title='Delete', (click)='deleteWorkspace(workspace)')
51
+ button.btn.btn-link.text-danger(type='button', title='Delete', (click)='deleteWorkspace($event, workspace)')
35
52
  i.fas.fa-trash
36
53
 
37
- .workspace-empty(*ngIf='workspaces.length === 0')
54
+ //- Empty state
55
+ .workspace-empty(*ngIf='workspaces.length === 0 && !isCreatingNew')
38
56
  p No workspaces configured yet.
39
57
  p Click "New Workspace" to create your first split-layout workspace.
40
-
41
- workspace-editor(
42
- *ngIf='showEditor',
43
- [workspace]='editingWorkspace',
44
- (save)='onEditorSave($event)',
45
- (cancel)='closeEditor()'
46
- )
@@ -1,6 +1,5 @@
1
1
  .workspace-list-container {
2
2
  padding: 20px;
3
- max-width: 800px;
4
3
  }
5
4
 
6
5
  .workspace-list-header {
@@ -29,11 +28,19 @@
29
28
  background: var(--theme-bg-more);
30
29
  border-radius: 8px;
31
30
  border: 1px solid var(--theme-border);
32
- transition: background 0.2s;
31
+ transition: background 0.2s, border-color 0.2s;
32
+ cursor: pointer;
33
33
 
34
34
  &:hover {
35
35
  background: var(--theme-bg-more-more);
36
36
  }
37
+
38
+ &.selected {
39
+ border-color: var(--theme-primary);
40
+ border-left-width: 3px;
41
+ background: var(--theme-bg-more-more);
42
+ box-shadow: 0 0 0 1px var(--theme-primary) inset;
43
+ }
37
44
  }
38
45
 
39
46
  .workspace-info {
@@ -84,15 +91,14 @@
84
91
  padding: 8px;
85
92
  color: var(--theme-fg-more);
86
93
  opacity: 0.7;
87
- transition: opacity 0.2s;
94
+ transition: opacity 0.2s, color 0.2s;
88
95
 
89
96
  &:hover {
90
97
  opacity: 1;
91
98
  }
92
99
 
93
- &.active {
94
- color: gold;
95
- opacity: 1;
100
+ &.open-btn:hover {
101
+ color: var(--theme-success, #10b981);
96
102
  }
97
103
 
98
104
  &.text-danger:hover {
@@ -1,6 +1,7 @@
1
- import { Component, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core'
2
- import { ConfigService } from 'tabby-core'
1
+ import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef, ElementRef, NgZone } from '@angular/core'
2
+ import { ConfigService, ProfilesService } from 'tabby-core'
3
3
  import { Subscription } from 'rxjs'
4
+ import { StartupCommandService } from '../services/startupCommand.service'
4
5
  import { WorkspaceEditorService } from '../services/workspaceEditor.service'
5
6
  import {
6
7
  Workspace,
@@ -16,25 +17,58 @@ import {
16
17
  template: require('./workspaceList.component.pug'),
17
18
  styles: [require('./workspaceList.component.scss')],
18
19
  })
19
- export class WorkspaceListComponent implements OnInit, OnDestroy {
20
+ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit {
20
21
  workspaces: Workspace[] = []
22
+ selectedWorkspace: Workspace | null = null
21
23
  editingWorkspace: Workspace | null = null
22
- showEditor = false
24
+ isCreatingNew = false
25
+ openingWorkspaceId: string | null = null
23
26
  private configSubscription: Subscription | null = null
24
27
 
25
28
  constructor(
26
29
  public config: ConfigService,
27
30
  private workspaceService: WorkspaceEditorService,
28
- private cdr: ChangeDetectorRef
31
+ private profilesService: ProfilesService,
32
+ private startupService: StartupCommandService,
33
+ private cdr: ChangeDetectorRef,
34
+ private elementRef: ElementRef,
35
+ private zone: NgZone
29
36
  ) {}
30
37
 
31
38
  ngOnInit(): void {
32
39
  this.loadWorkspaces()
40
+ this.autoSelectFirst()
33
41
  this.configSubscription = this.config.changed$.subscribe(() => {
34
42
  this.loadWorkspaces()
35
43
  })
36
44
  }
37
45
 
46
+ ngAfterViewInit(): void {
47
+ // Hack: Override Tabby's settings-tab-body max-width restriction
48
+ setTimeout(() => {
49
+ const parent = this.elementRef.nativeElement.closest('settings-tab-body') as HTMLElement
50
+ if (parent) {
51
+ parent.style.maxWidth = '876px'
52
+ }
53
+ }, 0)
54
+ }
55
+
56
+ private autoSelectFirst(): void {
57
+ if (this.workspaces.length > 0 && !this.selectedWorkspace) {
58
+ this.selectWorkspace(this.workspaces[0])
59
+ }
60
+ }
61
+
62
+ selectWorkspace(workspace: Workspace): void {
63
+ this.isCreatingNew = false
64
+ this.selectedWorkspace = workspace
65
+ this.editingWorkspace = JSON.parse(JSON.stringify(workspace))
66
+ }
67
+
68
+ isSelected(workspace: Workspace): boolean {
69
+ return this.selectedWorkspace?.id === workspace.id
70
+ }
71
+
38
72
  ngOnDestroy(): void {
39
73
  this.configSubscription?.unsubscribe()
40
74
  }
@@ -49,8 +83,10 @@ export class WorkspaceListComponent implements OnInit, OnDestroy {
49
83
  const defaultProfileId = profiles[0]?.id || ''
50
84
  const workspace = createDefaultWorkspace()
51
85
  this.setProfileForAllPanes(workspace.root, defaultProfileId)
86
+ this.selectedWorkspace = null
52
87
  this.editingWorkspace = workspace
53
- this.showEditor = true
88
+ this.isCreatingNew = true
89
+ this.cdr.detectChanges()
54
90
  }
55
91
 
56
92
  private setProfileForAllPanes(node: WorkspacePane | WorkspaceSplit, profileId: string): void {
@@ -62,49 +98,76 @@ export class WorkspaceListComponent implements OnInit, OnDestroy {
62
98
  }
63
99
 
64
100
  editWorkspace(workspace: Workspace): void {
65
- this.editingWorkspace = JSON.parse(JSON.stringify(workspace))
66
- this.showEditor = true
101
+ this.selectWorkspace(workspace)
67
102
  }
68
103
 
69
- async duplicateWorkspace(workspace: Workspace): Promise<void> {
104
+ async duplicateWorkspace(event: MouseEvent, workspace: Workspace): Promise<void> {
105
+ event.stopPropagation()
70
106
  const clone = this.workspaceService.duplicateWorkspace(workspace)
71
107
  await this.workspaceService.addWorkspace(clone)
72
108
  this.loadWorkspaces()
109
+
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()
73
116
  }
74
117
 
75
- async deleteWorkspace(workspace: Workspace): Promise<void> {
76
- console.log('[TabbySpaces] deleteWorkspace called', workspace.id)
118
+ async deleteWorkspace(event: MouseEvent, workspace: Workspace): Promise<void> {
119
+ event.stopPropagation()
77
120
  if (confirm(`Delete workspace "${workspace.name}"?`)) {
78
- console.log('[TabbySpaces] confirm = true, calling service.deleteWorkspace')
121
+ const currentIndex = this.workspaces.findIndex((w) => w.id === workspace.id)
79
122
  await this.workspaceService.deleteWorkspace(workspace.id)
80
- console.log('[TabbySpaces] service.deleteWorkspace done, calling loadWorkspaces')
81
123
  this.loadWorkspaces()
82
- console.log('[TabbySpaces] loadWorkspaces done, workspaces:', this.workspaces.length)
124
+
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 {
130
+ this.selectedWorkspace = null
131
+ this.editingWorkspace = null
132
+ this.isCreatingNew = false
133
+ }
134
+ this.cdr.detectChanges()
83
135
  }
84
136
  }
85
137
 
86
138
  async onEditorSave(workspace: Workspace): Promise<void> {
87
- console.log('[TabbySpaces] onEditorSave called', workspace.id, workspace.name)
88
- const existing = this.workspaces.find((w) => w.id === workspace.id)
89
- console.log('[TabbySpaces] existing workspace?', !!existing)
90
- if (existing) {
91
- await this.workspaceService.updateWorkspace(workspace)
92
- } else {
139
+ const isNew = !this.workspaces.find((w) => w.id === workspace.id)
140
+ if (isNew) {
93
141
  await this.workspaceService.addWorkspace(workspace)
142
+ } else {
143
+ await this.workspaceService.updateWorkspace(workspace)
94
144
  }
95
- console.log('[TabbySpaces] save done, calling loadWorkspaces')
96
145
  this.loadWorkspaces()
97
- console.log('[TabbySpaces] calling closeEditor')
98
- this.closeEditor()
99
- console.log('[TabbySpaces] closeEditor done, showEditor:', this.showEditor)
146
+ this.isCreatingNew = false
147
+
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()
100
154
  }
101
155
 
102
- closeEditor(): void {
103
- console.log('[TabbySpaces] closeEditor called, showEditor before:', this.showEditor)
104
- this.showEditor = false
105
- this.editingWorkspace = null
156
+ onEditorCancel(): void {
157
+ if (this.isCreatingNew) {
158
+ // Cancel new workspace creation - go back to first workspace or empty
159
+ this.isCreatingNew = false
160
+ if (this.workspaces.length > 0) {
161
+ this.selectWorkspace(this.workspaces[0])
162
+ } else {
163
+ this.selectedWorkspace = null
164
+ this.editingWorkspace = null
165
+ }
166
+ } else if (this.selectedWorkspace) {
167
+ // Reset to original workspace data
168
+ this.editingWorkspace = JSON.parse(JSON.stringify(this.selectedWorkspace))
169
+ }
106
170
  this.cdr.detectChanges()
107
- console.log('[TabbySpaces] closeEditor done, showEditor after:', this.showEditor)
108
171
  }
109
172
 
110
173
  getPaneCount(workspace: Workspace): number {
@@ -115,10 +178,29 @@ export class WorkspaceListComponent implements OnInit, OnDestroy {
115
178
  return workspace.root.orientation === 'horizontal' ? 'horizontal' : 'vertical'
116
179
  }
117
180
 
118
- async setAsDefault(workspace: Workspace): Promise<void> {
119
- this.workspaces.forEach((w) => (w.isDefault = false))
120
- workspace.isDefault = true
121
- await this.workspaceService.saveWorkspaces(this.workspaces)
122
- this.loadWorkspaces()
181
+ get hasUnsavedChanges(): boolean {
182
+ if (!this.editingWorkspace || !this.selectedWorkspace) return this.isCreatingNew
183
+ return JSON.stringify(this.editingWorkspace) !== JSON.stringify(this.selectedWorkspace)
184
+ }
185
+
186
+ async openWorkspace(event: MouseEvent, workspace: Workspace): Promise<void> {
187
+ event.stopPropagation()
188
+ if (this.openingWorkspaceId) return
189
+ this.openingWorkspaceId = workspace.id
190
+
191
+ try {
192
+ const commands = this.workspaceService.collectStartupCommands(workspace)
193
+ if (commands.length > 0) {
194
+ this.startupService.registerCommands(commands)
195
+ }
196
+
197
+ const profile = await this.workspaceService.generateTabbyProfile(workspace)
198
+ this.zone.run(() => {
199
+ this.profilesService.openNewTabForProfile(profile)
200
+ })
201
+ } finally {
202
+ this.openingWorkspaceId = null
203
+ this.cdr.detectChanges()
204
+ }
123
205
  }
124
206
  }
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import { WorkspaceEditorConfigProvider } from './providers/config.provider'
8
8
  import { WorkspaceEditorSettingsProvider } from './providers/settings.provider'
9
9
  import { WorkspaceToolbarProvider } from './providers/toolbar.provider'
10
10
  import { WorkspaceEditorService } from './services/workspaceEditor.service'
11
+ import { StartupCommandService } from './services/startupCommand.service'
11
12
 
12
13
  import { WorkspaceListComponent } from './components/workspaceList.component'
13
14
  import { WorkspaceEditorComponent } from './components/workspaceEditor.component'
@@ -21,6 +22,7 @@ import { SplitPreviewComponent } from './components/splitPreview.component'
21
22
  { provide: SettingsTabProvider, useClass: WorkspaceEditorSettingsProvider, multi: true },
22
23
  { provide: ToolbarButtonProvider, useClass: WorkspaceToolbarProvider, multi: true },
23
24
  WorkspaceEditorService,
25
+ StartupCommandService,
24
26
  ],
25
27
  declarations: [
26
28
  WorkspaceListComponent,
@@ -37,6 +37,7 @@ export interface TabbyRecoveryToken {
37
37
  tabTitle?: string
38
38
  tabCustomTitle?: string
39
39
  disableDynamicTitle?: boolean
40
+ cwd?: string
40
41
  }
41
42
 
42
43
  export interface TabbySplitLayoutProfile {
@@ -73,8 +74,7 @@ export interface Workspace {
73
74
  icon?: string
74
75
  color?: string
75
76
  root: WorkspaceSplit
76
- isDefault?: boolean
77
- hotkey?: string
77
+ launchOnStartup?: boolean
78
78
  }
79
79
 
80
80
  export function isWorkspaceSplit(node: WorkspacePane | WorkspaceSplit): node is WorkspaceSplit {
@@ -99,14 +99,41 @@ export function createDefaultSplit(orientation: 'horizontal' | 'vertical' = 'hor
99
99
  }
100
100
  }
101
101
 
102
- export function createDefaultWorkspace(name: string = 'New Workspace'): Workspace {
102
+ // Color palette for workspaces
103
+ const WORKSPACE_COLORS = [
104
+ '#3b82f6', // blue
105
+ '#10b981', // emerald
106
+ '#f59e0b', // amber
107
+ '#ef4444', // red
108
+ '#8b5cf6', // violet
109
+ '#ec4899', // pink
110
+ '#06b6d4', // cyan
111
+ '#f97316', // orange
112
+ ]
113
+
114
+ // Icon list for workspaces
115
+ const WORKSPACE_ICONS = [
116
+ 'columns', 'terminal', 'code', 'folder', 'home', 'briefcase',
117
+ 'cog', 'database', 'server', 'cloud', 'rocket', 'flask',
118
+ 'bug', 'wrench', 'cube', 'layer-group', 'sitemap', 'project-diagram'
119
+ ]
120
+
121
+ export function getRandomColor(): string {
122
+ return WORKSPACE_COLORS[Math.floor(Math.random() * WORKSPACE_COLORS.length)]
123
+ }
124
+
125
+ export function getRandomIcon(): string {
126
+ return WORKSPACE_ICONS[Math.floor(Math.random() * WORKSPACE_ICONS.length)]
127
+ }
128
+
129
+ export function createDefaultWorkspace(name: string = ''): Workspace {
103
130
  return {
104
131
  id: generateUUID(),
105
132
  name,
106
- icon: 'columns',
107
- color: '#3b82f6',
133
+ icon: getRandomIcon(),
134
+ color: getRandomColor(),
108
135
  root: createDefaultSplit(),
109
- isDefault: false,
136
+ launchOnStartup: false,
110
137
  }
111
138
  }
112
139
 
@@ -1,12 +1,12 @@
1
1
  import { Injectable } from '@angular/core'
2
2
  import { SettingsTabProvider } from 'tabby-settings'
3
3
  import { WorkspaceListComponent } from '../components/workspaceList.component'
4
- import { CONFIG_KEY, DISPLAY_NAME } from '../build-config'
4
+ import { CONFIG_KEY, DISPLAY_NAME, IS_DEV } from '../build-config'
5
5
 
6
6
  @Injectable()
7
7
  export class WorkspaceEditorSettingsProvider extends SettingsTabProvider {
8
8
  id = CONFIG_KEY
9
- icon = 'columns'
9
+ icon = IS_DEV ? 'bolt' : 'th-large'
10
10
  title = DISPLAY_NAME
11
11
 
12
12
  getComponentType(): any {
@@ -1,8 +1,20 @@
1
1
  import { Injectable } from '@angular/core'
2
2
  import { ToolbarButtonProvider, ToolbarButton, ProfilesService, AppService } from 'tabby-core'
3
3
  import { WorkspaceEditorService } from '../services/workspaceEditor.service'
4
+ import { StartupCommandService } from '../services/startupCommand.service'
4
5
  import { SettingsTabComponent } from 'tabby-settings'
5
- import { CONFIG_KEY, DISPLAY_NAME } from '../build-config'
6
+ import { CONFIG_KEY, DISPLAY_NAME, IS_DEV } from '../build-config'
7
+
8
+ const ICON_GRID = `<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">
9
+ <rect x="3" y="3" width="7" height="7"/>
10
+ <rect x="14" y="3" width="7" height="7"/>
11
+ <rect x="14" y="14" width="7" height="7"/>
12
+ <rect x="3" y="14" width="7" height="7"/>
13
+ </svg>`
14
+
15
+ 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
+ <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
17
+ </svg>`
6
18
  import { countPanes } from '../models/workspace.model'
7
19
 
8
20
  @Injectable()
@@ -10,20 +22,32 @@ export class WorkspaceToolbarProvider extends ToolbarButtonProvider {
10
22
  constructor(
11
23
  private workspaceService: WorkspaceEditorService,
12
24
  private profilesService: ProfilesService,
13
- private app: AppService
25
+ private app: AppService,
26
+ private startupService: StartupCommandService
14
27
  ) {
15
28
  super()
29
+ // Delay startup tasks to ensure Tabby config is loaded
30
+ setTimeout(() => {
31
+ // Cleanup orphaned profiles from previous plugin versions (one-time migration)
32
+ this.workspaceService.cleanupOrphanedProfiles()
33
+ // Launch workspaces marked for startup
34
+ this.launchStartupWorkspaces()
35
+ }, 500)
36
+ }
37
+
38
+ private async launchStartupWorkspaces(): Promise<void> {
39
+ const workspaces = this.workspaceService.getWorkspaces()
40
+ const startupWorkspaces = workspaces.filter(w => w.launchOnStartup)
41
+
42
+ for (const workspace of startupWorkspaces) {
43
+ await this.openWorkspace(workspace.id)
44
+ }
16
45
  }
17
46
 
18
47
  provide(): ToolbarButton[] {
19
48
  return [
20
49
  {
21
- icon: `<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">
22
- <rect x="3" y="3" width="7" height="7"/>
23
- <rect x="14" y="3" width="7" height="7"/>
24
- <rect x="14" y="14" width="7" height="7"/>
25
- <rect x="3" y="14" width="7" height="7"/>
26
- </svg>`,
50
+ icon: IS_DEV ? ICON_BOLT : ICON_GRID,
27
51
  title: DISPLAY_NAME,
28
52
  weight: 5,
29
53
  click: () => this.showWorkspaceSelector()
@@ -69,13 +93,20 @@ export class WorkspaceToolbarProvider extends ToolbarButtonProvider {
69
93
  this.app.openNewTabRaw({ type: SettingsTabComponent, inputs: { activeTab: CONFIG_KEY } })
70
94
  }
71
95
 
72
- private openWorkspace(workspaceId: string): void {
96
+ private async openWorkspace(workspaceId: string): Promise<void> {
73
97
  const workspaces = this.workspaceService.getWorkspaces()
74
98
  const workspace = workspaces.find((w) => w.id === workspaceId)
75
99
 
76
100
  if (!workspace) return
77
101
 
78
- const profile = this.workspaceService.generateTabbyProfile(workspace)
102
+ // Register startup commands BEFORE opening the workspace
103
+ // Commands will be sent via sendInput() when terminals open
104
+ const commands = this.workspaceService.collectStartupCommands(workspace)
105
+ if (commands.length > 0) {
106
+ this.startupService.registerCommands(commands)
107
+ }
108
+
109
+ const profile = await this.workspaceService.generateTabbyProfile(workspace)
79
110
  this.profilesService.openNewTabForProfile(profile)
80
111
  }
81
112
  }
@@ -0,0 +1,142 @@
1
+ import { Injectable } from '@angular/core'
2
+ import { AppService, BaseTabComponent, SplitTabComponent } from 'tabby-core'
3
+ import { BaseTerminalTabComponent } from 'tabby-terminal'
4
+ import { Subscription, first, timer, switchMap } from 'rxjs'
5
+
6
+ export interface PendingCommand {
7
+ paneId: string
8
+ command?: string
9
+ originalTitle: string
10
+ }
11
+
12
+ @Injectable()
13
+ export class StartupCommandService {
14
+ private pendingCommands: Map<string, PendingCommand> = new Map()
15
+ private subscription: Subscription
16
+
17
+ constructor(private app: AppService) {
18
+ this.subscription = this.app.tabOpened$.subscribe((tab) => this.onTabOpened(tab))
19
+ }
20
+
21
+ registerCommands(commands: PendingCommand[]): void {
22
+ console.log('[TabbySpaces] Registering commands:', commands)
23
+ for (const cmd of commands) {
24
+ this.pendingCommands.set(cmd.paneId, cmd)
25
+ }
26
+ }
27
+
28
+ private onTabOpened(tab: BaseTabComponent): void {
29
+ console.log('[TabbySpaces] Tab opened:', {
30
+ type: tab.constructor.name,
31
+ title: tab.title,
32
+ })
33
+
34
+ // Handle SplitTabComponent - get all child terminal tabs
35
+ if (tab instanceof SplitTabComponent) {
36
+ console.log('[TabbySpaces] SplitTabComponent detected, waiting for children...')
37
+ // Wait for split tab to fully initialize its children
38
+ setTimeout(() => this.processChildTabs(tab), 300)
39
+ return
40
+ }
41
+
42
+ // Handle individual terminal tab (shouldn't happen for split-layout, but just in case)
43
+ if (tab instanceof BaseTerminalTabComponent) {
44
+ this.processTerminalTab(tab)
45
+ }
46
+ }
47
+
48
+ private processChildTabs(splitTab: SplitTabComponent): void {
49
+ // Get all nested tabs from the split container
50
+ const allTabs = splitTab.getAllTabs()
51
+ console.log('[TabbySpaces] Found child tabs:', allTabs.length)
52
+
53
+ for (const tab of allTabs) {
54
+ if (tab instanceof BaseTerminalTabComponent) {
55
+ this.processTerminalTab(tab)
56
+ }
57
+ }
58
+ }
59
+
60
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
+ private processTerminalTab(terminalTab: BaseTerminalTabComponent<any>): void {
62
+ const paneId = terminalTab.customTitle || terminalTab.title
63
+ console.log('[TabbySpaces] Processing terminal tab:', {
64
+ title: terminalTab.title,
65
+ customTitle: terminalTab.customTitle,
66
+ paneId,
67
+ pendingKeys: [...this.pendingCommands.keys()],
68
+ })
69
+
70
+ const pending = this.pendingCommands.get(paneId)
71
+ if (!pending) {
72
+ console.log('[TabbySpaces] No matching command for paneId:', paneId)
73
+ return
74
+ }
75
+
76
+ this.pendingCommands.delete(paneId)
77
+
78
+ // Build startup command (cd + command)
79
+ const fullCommand = this.buildFullCommand(pending)
80
+ if (!fullCommand) {
81
+ console.log('[TabbySpaces] No command to send (no cwd or startup command)')
82
+ return
83
+ }
84
+
85
+ console.log('[TabbySpaces] Command matched, waiting for shell output...:', fullCommand)
86
+
87
+ // Wait for shell to emit first output (prompt), then send command
88
+ if (terminalTab.session?.output$) {
89
+ terminalTab.session.output$.pipe(
90
+ first(), // Wait for first output (shell prompt)
91
+ switchMap(() => timer(100)) // Small buffer after prompt renders
92
+ ).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
+ }
105
+ })
106
+ } else {
107
+ 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)
121
+ }
122
+ }
123
+
124
+ private buildFullCommand(pending: PendingCommand): string | null {
125
+ return pending.command || null
126
+ }
127
+
128
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
129
+ private clearProfileArgs(terminalTab: BaseTerminalTabComponent<any>): void {
130
+ // Clear args from profile to prevent native splits from re-running startup command
131
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
132
+ const profile = (terminalTab as any).profile
133
+ if (profile?.options?.args) {
134
+ console.log('[TabbySpaces] Clearing profile args to prevent re-run on split')
135
+ profile.options.args = []
136
+ }
137
+ }
138
+
139
+ ngOnDestroy(): void {
140
+ this.subscription?.unsubscribe()
141
+ }
142
+ }