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.
- package/.claude/settings.local.json +2 -1
- package/.github/workflows/ci.yml +26 -0
- package/.github/workflows/claude-code-review.yml +44 -0
- package/.github/workflows/claude.yml +81 -0
- package/.github/workflows/release.yml +30 -0
- package/CHANGELOG.md +46 -0
- package/CLAUDE.md +33 -0
- package/CONTRIBUTING.md +3 -1
- package/README.md +21 -18
- package/TODO.md +5 -0
- package/dist/build-config.d.ts +3 -3
- package/dist/components/deleteConfirmModal.component.d.ts +7 -0
- package/dist/components/deleteConfirmModal.component.d.ts.map +1 -0
- package/dist/components/paneEditor.component.d.ts +9 -18
- package/dist/components/paneEditor.component.d.ts.map +1 -1
- package/dist/components/splitPreview.component.d.ts +50 -50
- package/dist/components/splitPreview.component.d.ts.map +1 -1
- package/dist/components/workspaceEditor.component.d.ts +61 -54
- package/dist/components/workspaceEditor.component.d.ts.map +1 -1
- package/dist/components/workspaceList.component.d.ts +56 -39
- package/dist/components/workspaceList.component.d.ts.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.LICENSE.txt +1 -1
- package/dist/index.js.map +1 -1
- package/dist/models/workspace.model.d.ts +118 -78
- package/dist/models/workspace.model.d.ts.map +1 -1
- package/dist/package.json +1 -1
- package/dist/providers/config.provider.d.ts +8 -8
- package/dist/providers/settings.provider.d.ts +7 -7
- package/dist/providers/toolbar.provider.d.ts +23 -15
- package/dist/providers/toolbar.provider.d.ts.map +1 -1
- package/dist/services/startupCommand.service.d.ts +27 -19
- package/dist/services/startupCommand.service.d.ts.map +1 -1
- package/dist/services/workspaceBackground.service.d.ts +38 -0
- package/dist/services/workspaceBackground.service.d.ts.map +1 -0
- package/dist/services/workspaceEditor.service.d.ts +46 -32
- package/dist/services/workspaceEditor.service.d.ts.map +1 -1
- package/docs/DESIGN.md +57 -0
- package/docs/SESSION-2026-01-14-S1-DESIGN.md +134 -0
- package/mockups/index.html +162 -0
- package/mockups/s1-tight-sharp.html +522 -0
- package/mockups/shared/base.css +216 -0
- package/mockups/v06-tabbed.html +643 -0
- package/package.json +2 -1
- package/screenshots/editor.png +0 -0
- package/scripts/build-dev.js +2 -1
- package/scripts/build-prod.js +2 -1
- package/src/components/deleteConfirmModal.component.ts +23 -0
- package/src/components/paneEditor.component.pug +27 -43
- package/src/components/paneEditor.component.scss +37 -85
- package/src/components/paneEditor.component.ts +4 -32
- package/src/components/splitPreview.component.pug +0 -9
- package/src/components/splitPreview.component.scss +46 -70
- package/src/components/splitPreview.component.ts +15 -25
- package/src/components/workspaceEditor.component.pug +140 -112
- package/src/components/workspaceEditor.component.scss +270 -202
- package/src/components/workspaceEditor.component.ts +161 -85
- package/src/components/workspaceList.component.pug +31 -51
- package/src/components/workspaceList.component.scss +86 -77
- package/src/components/workspaceList.component.ts +89 -34
- package/src/index.ts +4 -0
- package/src/models/workspace.model.ts +80 -2
- package/src/providers/toolbar.provider.ts +78 -9
- package/src/services/startupCommand.service.ts +30 -32
- package/src/services/workspaceBackground.service.ts +167 -0
- package/src/services/workspaceEditor.service.ts +77 -40
- package/src/styles/_index.scss +3 -0
- package/src/styles/_mixins.scss +180 -0
- package/src/styles/_variables.scss +67 -0
- package/TEST_MCP.md +0 -176
- package/cdp-click.js +0 -22
- package/cdp-test.js +0 -28
- package/screenshots/pane-edit.png +0 -0
- 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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
const
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
this.
|
|
152
|
-
|
|
153
|
-
|
|
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 =
|
|
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
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
}
|
|
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:
|
|
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 ===
|
|
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 {
|
|
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.
|
|
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(),
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
}
|