diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 1ae27db..abc685b 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -31,7 +31,8 @@
"Bash(git log:*)",
"Bash(ls -la \"C:\\\\Users\\\\halil\\\\tabbyspaces\\\\screenshots\"\" 2>nul || echo \"Directory not found \")",
"Bash(dir:*)",
- "Bash(cmd.exe /c start \"\" \"C:\\\\Program Files \\(x86\\)\\\\Tabby\\\\Tabby.exe\" --remote-debugging-port=9222)"
+ "Bash(cmd.exe /c start \"\" \"C:\\\\Program Files \\(x86\\)\\\\Tabby\\\\Tabby.exe\" --remote-debugging-port=9222)",
+ "Bash(ls:*)"
]
},
"enableAllProjectMcpServers": true,
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..815a96f
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,26 @@
+name: CI
+
+on:
+ push:
+ branches: [dev]
+ pull_request:
+ branches: [main]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '18'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci --legacy-peer-deps
+
+ - name: Build
+ run: npm run build
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..5c00795
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,30 @@
+name: Release
+
+on:
+ push:
+ branches: [main]
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '18'
+ cache: 'npm'
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Install dependencies
+ run: npm ci --legacy-peer-deps
+
+ - name: Build
+ run: npm run build
+
+ - name: Publish to npm
+ run: npm publish --access public
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9e8b6bc..87ca675 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,51 @@
# Changelog
+## [0.2.0] - 2026-01-26
+
+### Design
+
+- **S1 "Tight & Sharp" UI redesign**
+ - Tab bar navigation replaces vertical workspace list
+ - Inline pane editor replaces modal overlay
+ - Section-based layout with uppercase titles
+ - Reorganized preview toolbar with icon buttons
+ - 2-column form grid in pane editor
+- Refactor SCSS to modular DRY architecture
+ - Shared variables: spacing scale, border radius, colors, z-index
+ - Reusable mixins: flex-row, form-input, interactive-card, toolbar-btn
+ - All components migrated to use shared styles
+- Add design system documentation (docs/DESIGN.md)
+- Add HTML mockups for design exploration
+
+### Reliability
+
+- Improved duplicate workspace detection on Tabby restart
+ - Add workspaceId to recovery tokens
+ - Two-strategy detection (restored tabs + freshly opened)
+- Better shell initialization with 2s timeout and error handling
+- Wait for Tabby recovery before launching startup workspaces
+- Type-safe workspace detection with proper type guards
+
+### Bug Fixes
+
+- Fix focus lost after workspace delete (NgbModal replaces native confirm)
+- Fix split preview change detection (remove OnPush strategy)
+- Fix race condition in shell initialization
+
+### Infrastructure
+
+- Add CI/CD workflows (GitHub Actions for build + release)
+- Add dev branch workflow documentation
+
+### Technical
+
+- Code review cleanup and fixes
+- Consistent use of deepClone helper
+- Add deleteConfirmModal component
+- Improve singleton service patterns
+
+---
+
## [0.1.0] - 2026-01-13
### Features
diff --git a/CLAUDE.md b/CLAUDE.md
index 61189a0..ca1d4a1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -44,6 +44,9 @@ src/
│ ├── config.provider.ts
│ ├── settings.provider.ts
│ └── toolbar.provider.ts
+├── styles/ # Shared SCSS (modular DRY)
+│ ├── _variables.scss # Spacing, radius, colors, z-index
+│ └── _mixins.scss # Reusable patterns
└── components/ # Angular components (.ts, .pug, .scss)
├── workspaceList # Main settings UI
├── workspaceEditor # Single workspace editor
@@ -51,6 +54,16 @@ src/
└── splitPreview # Visual split preview
```
+## Styles
+
+Modular DRY SCSS architecture. All components load shared styles via `@use '../styles/index' as *;`.
+
+- **Variables**: `$spacing-*`, `$radius-*`, `$color-*`, `$z-*`, `$transition-*`
+- **Mixins**: Layout, form, card, and button patterns. See `src/styles/_mixins.scss` for the available mixins.
+- **Theming**: Uses Tabby's `--theme-*` CSS variables
+
+See `docs/DESIGN.md` for details.
+
## Build
```bash
@@ -291,6 +304,26 @@ await new Promise(r => setTimeout(r, 100));
return document.querySelectorAll('.preview-pane.selected').length;
```
+## Angular Change Detection
+
+**KRITIČNO**: NE koristi `OnPush` strategiju na komponentama koje primaju mutirane objekte.
+
+### Pravilo
+- **NE koristi `OnPush`** ako parent komponenta mutira objekte umesto da kreira nove reference
+- Angular default strategija automatski detektuje sve promene
+- `OnPush` je samo za leaf komponente koje emituju events bez lokalnog state-a
+
+### Zašto
+- `OnPush` osvežava view samo kada se `@Input` referenca promeni
+- Mutacija objekta (npr. `workspace.root.children.push()`) NE menja referencu
+- Bez nove reference, Angular ne zna da treba re-renderovati
+
+### Komponente u ovom projektu
+- `workspaceEditor` - **default CD** (mutira workspace)
+- `workspaceList` - **default CD** (koristi `detectChanges()` za async operacije)
+- `splitPreview` - **default CD** (prima mutirane objekte)
+- `paneEditor` - može biti `OnPush` (samo emituje, nema mutacija)
+
## Known Issues
### YAML escape sequences in config.yaml
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f8e9053..f2d36c4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -49,7 +49,9 @@ No hot reload. Tabby doesn't support it for plugins. Restart after each build.
2. Create a branch (`git checkout -b fix/thing`)
3. Make your changes
4. Test manually in Tabby
-5. Submit PR with a clear description
+5. Submit PR **to the `dev` branch** (not `main`)
+
+CI will check that your code builds. `main` is for releases only.
No strict commit message format. Just be clear about what you changed and why.
diff --git a/README.md b/README.md
index f8355c5..b8cfc57 100644
--- a/README.md
+++ b/README.md
@@ -4,19 +4,26 @@ Visual workspace editor for [Tabby](https://tabby.sh). Create split-layout termi

-## What it does
+## Features
-- Visual editor for split layouts (horizontal/vertical, nested, any depth)
-- Per-pane configuration: profile, working directory, startup command, title
-- One-click workspace launch from toolbar
-- Launch on startup (auto-open workspaces when Tabby starts)
-- Works with any shell (Bash, Zsh, PowerShell, Nushell, etc.)
+- **Visual split editor** - Design layouts inline, not in modal dialogs. Split horizontally, vertically, nest to any depth
+- **Layout toolbar** - Select a pane, then split, add adjacent panes, or delete with toolbar buttons
+- **Per-pane configuration** - Set profile, working directory, and startup command for each pane
+- **One-click launch** - Open workspaces instantly from the toolbar dropdown
+- **Launch on startup** - Auto-open multiple workspaces when Tabby starts
+- **Any shell** - Works with Bash, Zsh, PowerShell, Nushell, cmd, WSL, and any other shell Tabby supports
+
+## Screenshots
+
+| Editor with selected pane | Pane configuration |
+|---------------------------|-------------------|
+|  |  |
## About this project
This plugin was written 100% by [Claude Code](https://claude.ai/code).
-Igor Halilović had the idea and provided product direction. He hates Angular so much (19 years of web dev, wrote his own TypeScript framework) that he didn't look at this code. Not once. He told Claude Code what he wanted, Claude Code built it.
+Igor Halilovic had the idea and provided product direction. He hates Angular so much (19 years of web dev, wrote his own TypeScript framework) that he didn't look at this code. Not once. He told Claude Code what he wanted, Claude Code built it.
Human provides the *what* and *why*. AI handles the *how*.
@@ -25,7 +32,7 @@ Here's the fun part: to test this plugin, we built [tabby-mcp](https://github.co
## Install
**From Tabby Plugin Manager:**
-Settings → Plugins → Search "tabbyspaces" → Install
+Settings > Plugins > Search "tabbyspaces" > Install
**Manual:**
```bash
@@ -35,17 +42,13 @@ npm install tabby-tabbyspaces
Restart Tabby after installation.
-## Usage
-
-1. Open Settings → TabbySpaces
-2. Create a workspace
-3. Design your split layout visually
-4. Configure each pane (profile, cwd, startup command)
-5. Save and launch from the toolbar
-
-### Pane configuration
+## Quick Start
-
+1. **Open settings** - Settings > TabbySpaces
+2. **Create workspace** - Click "New Workspace", name it
+3. **Design layout** - Click a pane to select it, use toolbar to split (horizontal/vertical)
+4. **Configure panes** - Click a pane (or use its context menu) to set profile, cwd, startup command
+5. **Save and launch** - Save changes, then click "Open" or use the toolbar dropdown
## Roadmap
@@ -66,6 +69,12 @@ Restart Tabby after installation.
- [Discussions](https://github.com/halilc4/tabbyspaces/discussions) - Questions, ideas, show your setup
- [Issues](https://github.com/halilc4/tabbyspaces/issues) - Bug reports
+## Contributing
+
+PRs welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions.
+
+All PRs go to the `dev` branch. CI checks the build automatically.
+
## License
MIT
diff --git a/TODO.md b/TODO.md
index 4185312..13a98ca 100644
--- a/TODO.md
+++ b/TODO.md
@@ -13,10 +13,14 @@
- [ ] List: add small layout preview
- [ ] Better input for command
- [ ] Better input for cwd
+- [ ] Editor workspace + pane editor autosave
+- [ ] Undo/redo for editor changes
### Bugs
- [~] Resize panes in Tabby reverts to original values (ratio problem) - WATCH: happens only on one workspace
- [ ] Layout preview responsive - nested splits don't adapt well to smaller sizes
+- [ ] Launch on startup - Tabby remembers open tabs, check if we can detect if workspace is already open; if not, kill the feature
+- [x] Tab titles are a mess - keep only workspace name or default to Tabby behavior (verify no caching/lookup by tab name)
### Other
- [ ] Update screenshots in README
@@ -60,6 +64,7 @@
- [x] Refactoring: Remove profile persistence, shell-aware CWD, dead code cleanup
### Bugs
+- [x] Focus lost after deleting workspace (native confirm() steals focus from Electron) - fix: use NgbModal instead
- [x] Audit async functions - check if `detectChanges()` is missing after async operations that change state
- [x] Split pane runs command (in-memory profiles) - fix: clear profile.options.args after command execution
- [x] Pane editor modal bug - mouseup outside dialog closes modal. Dialog should close only on Esc or close/cancel/save button
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
new file mode 100644
index 0000000..a950771
--- /dev/null
+++ b/docs/DESIGN.md
@@ -0,0 +1,57 @@
+# Design System
+
+TabbySpaces uses a modular DRY SCSS architecture.
+
+## Structure
+
+```
+src/styles/
+├── _index.scss # Entry point (imports all)
+├── _variables.scss # Spacing, radius, colors, z-index, transitions
+└── _mixins.scss # Reusable patterns (flex, inputs, buttons, overlays)
+```
+
+## Usage
+
+All component SCSS files import shared styles:
+
+```scss
+@use '../styles/index' as *;
+
+.my-component {
+ padding: $spacing-md;
+ border-radius: $radius-lg;
+ @include flex-center;
+}
+```
+
+## Variables
+
+@src/styles/_variables.scss
+
+## Mixins
+
+Key mixins available:
+
+| Mixin | Purpose |
+|-------|---------|
+| `flex-center` | Center content with flexbox |
+| `form-input($bg)` | Styled input field with focus state |
+| `form-label` | Uppercase compact label (S1 design) |
+| `toolbar-btn` | Small toolbar button with hover state |
+| `btn-success` | Green success button |
+| `btn-base` | Base button styling with flex layout |
+| `btn-ghost` | Ghost button with border |
+| `btn-primary` | Primary button with theme color |
+| `icon-btn-sm($size)` | Small icon button with border |
+| `full-overlay($z)` | Fixed fullscreen overlay |
+| `dropdown-panel` | Dropdown with border/shadow |
+| `text-ellipsis` | Truncate text with ellipsis |
+
+## Theming
+
+Plugin uses Tabby's CSS custom properties (`--theme-*`) for automatic theme support:
+- `--theme-bg`, `--theme-bg-more`, `--theme-bg-more-more`
+- `--theme-fg`, `--theme-fg-more`
+- `--theme-border`, `--theme-primary`
+- `--theme-success`, `--theme-danger`
diff --git a/docs/SESSION-2026-01-14-S1-DESIGN.md b/docs/SESSION-2026-01-14-S1-DESIGN.md
new file mode 100644
index 0000000..c6dfa80
--- /dev/null
+++ b/docs/SESSION-2026-01-14-S1-DESIGN.md
@@ -0,0 +1,134 @@
+# Session Summary: S1 Design Implementation
+**Date:** 2026-01-14
+**Branch:** design
+
+## Task
+Implement S1 "Tight & Sharp" design from `mockups/s1-tight-sharp.html` into TabbySpaces plugin.
+
+## Status: COMPLETED (Core Structure)
+
+### What Was Done
+
+#### 1. Tab Bar Navigation (workspaceList)
+- **Before:** Vertical card list with workspace items
+- **After:** Horizontal tab bar with colored icons, close buttons, and "+" button
+- **Files:** `workspaceList.component.pug`, `workspaceList.component.scss`
+
+#### 2. Editor Section Structure (workspaceEditor)
+- Added explicit sections with uppercase titles and icons:
+ - "WORKSPACE SETTINGS" (fa-cog)
+ - "SPLIT LAYOUT" (fa-columns)
+- **Files:** `workspaceEditor.component.pug`, `workspaceEditor.component.scss`
+
+#### 3. Dropdown Triggers
+- Icon picker: Styled as dropdown trigger with chevron
+- Color picker: Native input wrapped in dropdown-style trigger with chevron
+- **Files:** `workspaceEditor.component.pug`, `workspaceEditor.component.scss`
+
+#### 4. Inline Pane Editor (MAJOR CHANGE)
+- **Before:** Modal overlay with backdrop
+- **After:** Inline panel below split preview with:
+ - Header with title and close button
+ - 2-column grid form (Profile, CWD, Title, Command)
+ - Cancel/Apply buttons at bottom
+- **Files:** `paneEditor.component.pug`, `paneEditor.component.scss`, `paneEditor.component.ts`
+
+#### 5. Preview Toolbar
+- Reorganized toolbar above split preview:
+ - Edit (fa-pen)
+ - Split H (fa-grip-lines-vertical)
+ - Split V (fa-grip-lines)
+ - Separator
+ - Add Left/Right/Up/Down (fa-arrow-*)
+ - Separator
+ - Delete (fa-trash, danger style)
+- **Files:** `workspaceEditor.component.pug`, `workspaceEditor.component.scss`
+
+#### 6. Split Preview Container
+- Wrapped preview in bordered container with padding
+- **Files:** `workspaceEditor.component.scss`
+
+#### 7. Action Buttons
+- Footer with:
+ - Left: "Launch on startup" checkbox
+ - Right: Cancel (ghost), Save (success) buttons
+- **Files:** `workspaceEditor.component.pug`, `workspaceEditor.component.scss`
+
+### Files Modified
+
+```
+src/components/
+├── workspaceList.component.pug # Tab bar structure
+├── workspaceList.component.scss # Tab bar styles
+├── workspaceEditor.component.pug # Sections, dropdowns, toolbar, inline pane editor
+├── workspaceEditor.component.scss # All new styles
+├── paneEditor.component.pug # Inline panel (removed modal)
+├── paneEditor.component.scss # Inline panel styles
+└── paneEditor.component.ts # Removed modal-specific code
+```
+
+### Previously Done (before this session)
+- Variables (`_variables.scss`): S1 spacing, radius, shadows, fonts
+- Mixins (`_mixins.scss`): form-input, form-label, interactive-card
+- Color/icon picker size reduced to 28x28px
+
+### Build Status
+✅ Build successful (`npm run build:dev`)
+- Only Sass deprecation warnings (expected, @import rules)
+
+## What Might Need Attention
+
+### 1. Visual Testing Required
+- Restart Tabby and visually verify all changes
+- Test tab switching, pane selection, split/add operations
+- Check inline pane editor save/cancel flow
+
+### 2. Potential Issues to Watch
+- Tab close button might need adjustment (currently uses deleteWorkspace with confirm dialog)
+- Icon dropdown positioning (absolute, right-aligned)
+- Color picker trigger click area (hidden input overlay)
+
+### 3. Not Implemented (out of scope for this session)
+- Open/Duplicate buttons removed from UI (were on cards, not in tab bar)
+ - Could add to editor action buttons or tab context menu later
+- No mobile/responsive considerations
+
+## Reference: Mockup vs Implementation
+
+| Mockup Element | Implementation |
+|----------------|----------------|
+| `.tab-bar` | ✅ Implemented |
+| `.tab` with icon, name, close | ✅ Implemented |
+| `.tab-new` (+) | ✅ Implemented |
+| `.tab-content` | ✅ Implemented |
+| `.editor-section` | ✅ Implemented |
+| `.section-title` | ✅ Implemented |
+| `.form-row`, `.form-group` | ✅ Implemented |
+| `.dropdown-trigger` | ✅ Implemented |
+| `.color-trigger` | ✅ Implemented (native input wrapped) |
+| `.split-preview-container` | ✅ Implemented |
+| `.preview-toolbar` | ✅ Implemented |
+| `.preview-btn` | ✅ Implemented |
+| `.toolbar-separator` | ✅ Implemented |
+| `.pane-details` (inline panel) | ✅ Implemented |
+| `.pane-form` (2-col grid) | ✅ Implemented |
+| `.action-buttons` | ✅ Implemented |
+| `.checkbox-group` | ✅ Implemented |
+
+## Quick Start for Next Session
+
+```bash
+# Build and test
+cd C:\Users\halil\tabbyspaces
+npm run build:dev
+
+# Install if needed
+cd "$APPDATA/tabby/plugins" && npm install "C:/Users/halil/tabbyspaces/dist-dev"
+
+# Restart Tabby to see changes
+```
+
+## Key Files to Read
+- `mockups/s1-tight-sharp.html` - Target design
+- `src/styles/_variables.scss` - S1 spacing/radius values
+- `src/styles/_mixins.scss` - Reusable patterns
diff --git a/mockups/index.html b/mockups/index.html
new file mode 100644
index 0000000..546a8af
--- /dev/null
+++ b/mockups/index.html
@@ -0,0 +1,162 @@
+
+
+
+
+
+ TabbySpaces Design Mockups
+
+
+
+
+
+
+
+
diff --git a/mockups/s1-tight-sharp.html b/mockups/s1-tight-sharp.html
new file mode 100644
index 0000000..d15d65f
--- /dev/null
+++ b/mockups/s1-tight-sharp.html
@@ -0,0 +1,522 @@
+
+
+
+
+
+ S1: Tight & Sharp - TabbySpaces
+
+
+
+
+
+
+
+
+ Back to variants
+
+
+
+
+
+
+
+ Development
+
+
+
+
+ DevOps
+
+
+
+
+ Research
+
+
+
+
+
+
+
+
+
+
+
+ Workspace Settings
+
+
+
+
+
+
+
+
+ Split Layout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
PowerShell
+
C:\Projects\app
+
+
+
+
PowerShell
+
C:\Projects\app
+
+
+
+
+
PowerShell
+
C:\Projects\api
+
+
+
+
+
+
+
+
+
+
+
+
+
S1: Tight & Sharp
+
+ - Spacing: Minimal padding, compact layout
+ - Radius: 2-4px, sharp corners
+ - Borders: Thin 1px, subtle
+ - Shadows: None - completely flat
+ - Vibe: IDE/developer tool aesthetic
+
+
+
+
diff --git a/mockups/shared/base.css b/mockups/shared/base.css
new file mode 100644
index 0000000..4f29175
--- /dev/null
+++ b/mockups/shared/base.css
@@ -0,0 +1,216 @@
+/* TabbySpaces Design Mockups - Base Styles */
+/* Simulates Tabby's dark theme environment */
+
+:root {
+ /* Tabby-like dark theme variables */
+ --theme-bg: #1e1e1e;
+ --theme-bg-more: #252526;
+ --theme-bg-more-more: #2d2d30;
+ --theme-fg: #cccccc;
+ --theme-fg-more: #ffffff;
+ --theme-fg-less: #808080;
+ --theme-border: #3c3c3c;
+ --theme-primary: #0078d4;
+ --theme-success: #10b981;
+ --theme-danger: #ef4444;
+ --theme-warning: #f59e0b;
+
+ /* Spacing scale */
+ --space-xs: 2px;
+ --space-sm: 4px;
+ --space-md: 8px;
+ --space-lg: 12px;
+ --space-xl: 16px;
+ --space-2xl: 20px;
+ --space-3xl: 24px;
+ --space-4xl: 32px;
+
+ /* Border radius */
+ --radius-sm: 4px;
+ --radius-md: 6px;
+ --radius-lg: 8px;
+ --radius-xl: 12px;
+
+ /* Typography */
+ --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ --font-mono: "Cascadia Code", "Fira Code", Consolas, monospace;
+ --font-size-xs: 0.75rem;
+ --font-size-sm: 0.85rem;
+ --font-size-md: 1rem;
+ --font-size-lg: 1.25rem;
+ --font-size-xl: 1.5rem;
+
+ /* Shadows */
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
+ --shadow-md: 0 4px 6px rgba(0,0,0,0.4);
+ --shadow-lg: 0 10px 15px rgba(0,0,0,0.5);
+
+ /* Transitions */
+ --transition-fast: 150ms ease;
+ --transition-normal: 250ms ease;
+}
+
+/* Reset */
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: var(--font-family);
+ font-size: var(--font-size-md);
+ color: var(--theme-fg);
+ background: var(--theme-bg);
+ line-height: 1.5;
+ min-height: 100vh;
+}
+
+/* Container - simulates Tabby settings panel */
+.mockup-container {
+ max-width: 876px;
+ margin: 0 auto;
+ padding: var(--space-xl);
+ background: var(--theme-bg);
+ min-height: 100vh;
+}
+
+/* Typography utilities */
+.text-muted { color: var(--theme-fg-less); }
+.text-primary { color: var(--theme-primary); }
+.text-success { color: var(--theme-success); }
+.text-danger { color: var(--theme-danger); }
+.text-sm { font-size: var(--font-size-sm); }
+.text-xs { font-size: var(--font-size-xs); }
+.text-lg { font-size: var(--font-size-lg); }
+.text-xl { font-size: var(--font-size-xl); }
+.font-mono { font-family: var(--font-mono); }
+.font-bold { font-weight: 600; }
+
+/* Common form elements */
+input[type="text"],
+input[type="color"],
+select {
+ background: var(--theme-bg);
+ border: 1px solid var(--theme-border);
+ border-radius: var(--radius-md);
+ color: var(--theme-fg);
+ padding: var(--space-md) var(--space-lg);
+ font-size: var(--font-size-sm);
+ transition: border-color var(--transition-fast);
+}
+
+input[type="text"]:focus,
+select:focus {
+ outline: none;
+ border-color: var(--theme-primary);
+}
+
+/* Button base */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-md);
+ padding: var(--space-md) var(--space-lg);
+ border: none;
+ border-radius: var(--radius-sm);
+ font-size: var(--font-size-sm);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.btn-primary {
+ background: var(--theme-primary);
+ color: white;
+}
+.btn-primary:hover {
+ filter: brightness(1.1);
+}
+
+.btn-success {
+ background: var(--theme-success);
+ color: white;
+}
+.btn-success:hover {
+ filter: brightness(1.1);
+}
+
+.btn-danger {
+ background: var(--theme-danger);
+ color: white;
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--theme-fg);
+}
+.btn-ghost:hover {
+ background: var(--theme-bg-more);
+}
+
+/* Card base */
+.card {
+ background: var(--theme-bg-more);
+ border: 1px solid var(--theme-border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-xl);
+}
+
+.card-hover:hover {
+ border-color: var(--theme-primary);
+}
+
+/* Flex utilities */
+.flex { display: flex; }
+.flex-col { flex-direction: column; }
+.items-center { align-items: center; }
+.justify-between { justify-content: space-between; }
+.gap-xs { gap: var(--space-xs); }
+.gap-sm { gap: var(--space-sm); }
+.gap-md { gap: var(--space-md); }
+.gap-lg { gap: var(--space-lg); }
+.gap-xl { gap: var(--space-xl); }
+
+/* Grid utilities */
+.grid { display: grid; }
+.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
+
+/* Spacing utilities */
+.p-md { padding: var(--space-md); }
+.p-lg { padding: var(--space-lg); }
+.p-xl { padding: var(--space-xl); }
+.mt-md { margin-top: var(--space-md); }
+.mt-lg { margin-top: var(--space-lg); }
+.mt-xl { margin-top: var(--space-xl); }
+.mb-md { margin-bottom: var(--space-md); }
+.mb-lg { margin-bottom: var(--space-lg); }
+
+/* Design notes (visible as overlay) */
+.design-notes {
+ position: fixed;
+ top: var(--space-lg);
+ right: var(--space-lg);
+ background: rgba(0,0,0,0.9);
+ border: 1px solid var(--theme-border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-lg);
+ max-width: 300px;
+ font-size: var(--font-size-xs);
+ color: var(--theme-fg-less);
+ z-index: 1000;
+}
+.design-notes h4 {
+ color: var(--theme-primary);
+ margin-bottom: var(--space-md);
+}
+.design-notes ul {
+ list-style: none;
+ padding-left: 0;
+}
+.design-notes li {
+ margin-bottom: var(--space-sm);
+}
+.design-notes li::before {
+ content: "• ";
+ color: var(--theme-primary);
+}
diff --git a/mockups/v06-tabbed.html b/mockups/v06-tabbed.html
new file mode 100644
index 0000000..6b85391
--- /dev/null
+++ b/mockups/v06-tabbed.html
@@ -0,0 +1,643 @@
+
+
+
+
+
+ V06: Tabbed Interface - TabbySpaces
+
+
+
+
+
+
+
+
+
+ Back to variants
+
+
+
+
+
+
+
+
+
+ Development
+
+
+
+
+ DevOps
+
+
+
+
+ Research
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Workspace Settings
+
+
+
+
+
+
+
+
+
+ Split Layout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
PowerShell
+
C:\Projects\app
+
+
+
+
PowerShell
+
C:\Projects\app
+
+
+
+
+
+
PowerShell
+
C:\Projects\api
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Design Notes
+
+ - Familiar metaphor: Browser tabs are universally understood - instant learnability
+ - Quick switching: All workspaces visible and one-click accessible
+ - Active tab connected: No border between tab and content creates visual unity
+ - Close on hover: Clean tabs, X appears only when needed
+ - New tab button: Plus icon follows convention for adding items
+ - Full editor below: No scrolling needed, everything visible at once
+ - Inline pane config: Click pane to edit, no modal disruption
+ - Consistent with code editors: VSCode, terminals all use tabs
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index d1116d6..eaab0ef 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
- "name": "tabbyspaces",
- "version": "1.0.0",
+ "name": "tabby-tabbyspaces",
+ "version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "tabbyspaces",
- "version": "1.0.0",
+ "name": "tabby-tabbyspaces",
+ "version": "0.1.0",
"license": "MIT",
"devDependencies": {
"@angular/common": "^15.0.0",
@@ -14,6 +14,7 @@
"@angular/compiler-cli": "^15.0.0",
"@angular/core": "^15.0.0",
"@angular/forms": "^15.0.0",
+ "@ng-bootstrap/ng-bootstrap": "^14.2.0",
"@ngtools/webpack": "^15.0.0",
"apply-loader": "^2.0.0",
"css-loader": "^6.8.0",
@@ -483,6 +484,24 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@ng-bootstrap/ng-bootstrap": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-14.2.0.tgz",
+ "integrity": "sha512-nqEKVXauSontGKqC5WSKpch5TiAGDZB3hluvxkINS0r9LUE6sBQRP3qeYOe7Uwu+UbQcj28NG3qFHhpfnG8KHw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "peerDependencies": {
+ "@angular/common": "^15.0.0",
+ "@angular/core": "^15.0.0",
+ "@angular/forms": "^15.0.0",
+ "@angular/localize": "^15.0.0",
+ "@popperjs/core": "^2.11.6",
+ "rxjs": "^6.5.3 || ^7.4.0"
+ }
+ },
"node_modules/@ngtools/webpack": {
"version": "15.2.11",
"resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-15.2.11.tgz",
diff --git a/package.json b/package.json
index 7fee475..24eec8b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "tabby-tabbyspaces",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Workspaces for Tabby - Visual split-layout workspace editor",
"keywords": [
"tabby",
@@ -30,6 +30,7 @@
"@angular/compiler-cli": "^15.0.0",
"@angular/core": "^15.0.0",
"@angular/forms": "^15.0.0",
+ "@ng-bootstrap/ng-bootstrap": "^14.2.0",
"@ngtools/webpack": "^15.0.0",
"apply-loader": "^2.0.0",
"css-loader": "^6.8.0",
diff --git a/screenshots/editor.png b/screenshots/editor.png
index af43a43..368718e 100644
Binary files a/screenshots/editor.png and b/screenshots/editor.png differ
diff --git a/screenshots/pane-edit.png b/screenshots/pane-edit.png
index e13c8a5..534bc72 100644
Binary files a/screenshots/pane-edit.png and b/screenshots/pane-edit.png differ
diff --git a/scripts/build-dev.js b/scripts/build-dev.js
index c06acd6..f42d1d1 100644
--- a/scripts/build-dev.js
+++ b/scripts/build-dev.js
@@ -12,7 +12,8 @@ if (fs.existsSync(distDevDir)) {
// 2. Run webpack with dev env directly to dist-dev
console.log('Building dev version...')
-execSync(`npx webpack --mode production --env dev --output-path "${distDevDir}"`, {
+const webpackCli = path.join(rootDir, 'node_modules', 'webpack-cli', 'bin', 'cli.js')
+execSync(`node "${webpackCli}" --mode production --env dev --output-path "${distDevDir}"`, {
cwd: rootDir,
stdio: 'inherit'
})
diff --git a/scripts/build-prod.js b/scripts/build-prod.js
index 657f19d..6b5c5d7 100644
--- a/scripts/build-prod.js
+++ b/scripts/build-prod.js
@@ -12,7 +12,8 @@ if (fs.existsSync(distDir)) {
// 2. Run webpack
console.log('Building production version...')
-execSync('npx webpack --mode production', {
+const webpackCli = path.join(rootDir, 'node_modules', 'webpack-cli', 'bin', 'cli.js')
+execSync(`node "${webpackCli}" --mode production`, {
cwd: rootDir,
stdio: 'inherit'
})
diff --git a/src/components/deleteConfirmModal.component.ts b/src/components/deleteConfirmModal.component.ts
new file mode 100644
index 0000000..30f6e9c
--- /dev/null
+++ b/src/components/deleteConfirmModal.component.ts
@@ -0,0 +1,23 @@
+import { Component, Input } from '@angular/core'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+
+@Component({
+ selector: 'delete-confirm-modal',
+ template: `
+
+
+
Delete workspace "{{ workspaceName }}"?
+
This action cannot be undone.
+
+
+ `,
+})
+export class DeleteConfirmModalComponent {
+ @Input() workspaceName = ''
+ constructor(public modal: NgbActiveModal) {}
+}
diff --git a/src/components/paneEditor.component.pug b/src/components/paneEditor.component.pug
index 3d8e10d..13896fe 100644
--- a/src/components/paneEditor.component.pug
+++ b/src/components/paneEditor.component.pug
@@ -1,46 +1,30 @@
-.pane-editor-overlay((click)='onOverlayClick($event)')
- .pane-editor-modal(#modal)
- .modal-header
- h4 Edit Pane
- button.btn.btn-link.close-btn(type='button', (click)='onCancel()')
- i.fas.fa-times
+//- Inline pane editor panel (no modal overlay)
+.pane-details
+ .pane-details-header
+ span.pane-details-title
+ i.fas.fa-terminal
+ | Pane Configuration
- .modal-body
- .form-group
- label Base Profile
- select.form-control([(ngModel)]='editedPane.profileId')
- option(value='') -- Select Profile --
- option(*ngFor='let profile of profiles', [value]='profile.id')
- | {{ profile.name }}
+ .pane-form
+ .form-group
+ label Profile
+ select.form-control([(ngModel)]='pane.profileId')
+ option(value='') -- Select Profile --
+ option(*ngFor='let profile of profiles', [value]='profile.id')
+ | {{ profile.name }}
- .form-group
- label Working Directory
- input.form-control(
- type='text',
- [(ngModel)]='editedPane.cwd',
- placeholder='C:\\path\\to\\project'
- )
+ .form-group
+ label Working Directory
+ input.form-control(
+ type='text',
+ [(ngModel)]='pane.cwd',
+ placeholder='C:\\path\\to\\project'
+ )
- .form-group
- label Startup Command
- input.form-control(
- type='text',
- [(ngModel)]='editedPane.startupCommand',
- placeholder='e.g., npm run dev'
- )
- small.help-text
- | Command to execute when the pane opens. For Nushell, use commands like: uv run serve
-
- .form-group
- label Pane Title (optional)
- input.form-control(
- type='text',
- [(ngModel)]='editedPane.title',
- placeholder='Custom tab title'
- )
-
- .modal-footer
- button.btn.btn-secondary(type='button', (click)='onCancel()') Cancel
- button.btn.btn-primary(type='button', (click)='onSave()')
- i.fas.fa-check
- | Apply
+ .form-group
+ label Startup Command
+ input.form-control(
+ type='text',
+ [(ngModel)]='pane.startupCommand',
+ placeholder='e.g., npm run dev'
+ )
diff --git a/src/components/paneEditor.component.scss b/src/components/paneEditor.component.scss
index 43be40d..2ba429e 100644
--- a/src/components/paneEditor.component.scss
+++ b/src/components/paneEditor.component.scss
@@ -1,112 +1,64 @@
-.pane-editor-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.7);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 1100;
+@use '../styles/index' as *;
+
+// Inline pane details panel
+.pane-details {
+ background: var(--theme-bg-more-more);
+ border-radius: $radius-sm;
+ padding: $spacing-lg;
+ padding-top: $spacing-lg;
+ margin-top: $spacing-xl;
+ border-left: 2px solid var(--theme-primary);
+ border-top: 1px solid var(--theme-border, $fallback-border);
}
-.pane-editor-modal {
- background: var(--theme-bg);
- border-radius: 12px;
- width: 90%;
- max-width: 500px;
- max-height: 80vh;
- overflow: hidden;
+.pane-details-header {
display: flex;
- flex-direction: column;
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: $spacing-lg;
}
-.modal-header {
+.pane-details-title {
+ font-size: $font-sm;
+ color: var(--theme-fg);
+ font-weight: 500;
display: flex;
- justify-content: space-between;
align-items: center;
- padding: 16px 20px;
- border-bottom: 1px solid var(--theme-border);
-
- h4 {
- margin: 0;
- font-size: 1.15rem;
- }
-
- .close-btn {
- font-size: 1.15rem;
- color: var(--theme-fg-more);
- padding: 4px 8px;
-
- &:hover {
- color: var(--theme-fg);
- }
- }
+ gap: $spacing-sm;
}
-.modal-body {
- padding: 20px;
- overflow-y: auto;
- flex: 1;
+// 2-column grid for pane form
+.pane-form {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: $spacing-lg;
}
-.form-group {
- margin-bottom: 16px;
-
- &:last-child {
- margin-bottom: 0;
+@media (max-width: 600px) {
+ .pane-form {
+ grid-template-columns: 1fr;
}
+}
+.form-group {
label {
- display: block;
- margin-bottom: 6px;
- font-size: 0.9rem;
- color: var(--theme-fg-more);
+ @include form-label;
}
}
.form-control {
width: 100%;
- padding: 10px 12px;
- border-radius: 6px;
- border: 1px solid var(--theme-border);
- background: var(--theme-bg-more);
- color: var(--theme-fg);
- font-size: 0.95rem;
+ @include form-input(var(--theme-bg));
+ font-size: $font-sm;
+ // Override Tabby's global border reset
+ border: 1px solid var(--theme-border, $fallback-border) !important;
&:focus {
- outline: none;
- border-color: var(--theme-primary);
+ border-color: var(--theme-primary) !important;
}
}
select.form-control {
cursor: pointer;
-}
-
-.input-with-button {
- display: flex;
- gap: 8px;
-
- input {
- flex: 1;
- }
-}
-
-.help-text {
- display: block;
- margin-top: 4px;
- font-size: 0.8rem;
- color: var(--theme-fg-more);
- opacity: 0.8;
-}
-
-.modal-footer {
- display: flex;
- justify-content: flex-end;
- gap: 12px;
- padding: 16px 20px;
- border-top: 1px solid var(--theme-border);
+ appearance: auto; // Show dropdown arrow
}
diff --git a/src/components/paneEditor.component.ts b/src/components/paneEditor.component.ts
index 3f11c5b..7df293a 100644
--- a/src/components/paneEditor.component.ts
+++ b/src/components/paneEditor.component.ts
@@ -1,4 +1,4 @@
-import { Component, Input, Output, EventEmitter, OnInit, HostListener, ElementRef, ViewChild } from '@angular/core'
+import { Component, Input, Output, EventEmitter, HostListener } from '@angular/core'
import { WorkspacePane, TabbyProfile } from '../models/workspace.model'
@Component({
@@ -6,42 +6,14 @@ import { WorkspacePane, TabbyProfile } from '../models/workspace.model'
template: require('./paneEditor.component.pug'),
styles: [require('./paneEditor.component.scss')],
})
-export class PaneEditorComponent implements OnInit {
+export class PaneEditorComponent {
@Input() pane!: WorkspacePane
@Input() profiles: TabbyProfile[] = []
- @Output() save = new EventEmitter()
- @Output() cancel = new EventEmitter()
- @ViewChild('modal', { static: true }) modalRef!: ElementRef
-
- editedPane!: WorkspacePane
- private pointerDownInsideModal = false
-
- ngOnInit(): void {
- this.editedPane = { ...this.pane }
- }
+ @Output() close = new EventEmitter()
@HostListener('document:keydown.escape')
onEscapeKey(): void {
- this.cancel.emit()
- }
-
- @HostListener('document:pointerdown', ['$event'])
- onDocumentPointerDown(event: PointerEvent): void {
- this.pointerDownInsideModal = this.modalRef.nativeElement.contains(event.target as Node)
- }
-
- onOverlayClick(event: MouseEvent): void {
- if (!this.pointerDownInsideModal && event.target === event.currentTarget) {
- this.cancel.emit()
- }
- }
-
- onSave(): void {
- this.save.emit(this.editedPane)
- }
-
- onCancel(): void {
- this.cancel.emit()
+ this.close.emit()
}
getProfileName(profileId: string): string {
diff --git a/src/components/splitPreview.component.pug b/src/components/splitPreview.component.pug
index 692e4b4..e8cd0f6 100644
--- a/src/components/splitPreview.component.pug
+++ b/src/components/splitPreview.component.pug
@@ -6,7 +6,6 @@
[style.flex-basis]='getFlexStyle(i)',
[class.selected]='asPane(child).id === selectedPaneId',
(click)='onPaneClick(asPane(child)); $event.stopPropagation()',
- (dblclick)='onEditClick($event, asPane(child))',
(contextmenu)='onContextMenu($event, asPane(child))'
)
.pane-content
@@ -20,13 +19,6 @@
i.fas.fa-terminal
span {{ truncate(asPane(child).startupCommand, 20) }}
- button.pane-edit-btn(
- type='button',
- (click)='onEditClick($event, asPane(child))',
- title='Edit pane'
- )
- i.fas.fa-pen
-
//- Nested split
split-preview(
*ngIf='isSplit(child)',
@@ -35,7 +27,6 @@
[selectedPaneId]='selectedPaneId',
[profiles]='profiles',
[style.flex-basis]='getFlexStyle(i)',
- (paneSelect)='onNestedPaneSelect($event)',
(paneEdit)='onNestedPaneEdit($event)',
(splitHorizontal)='onNestedSplitH($event)',
(splitVertical)='onNestedSplitV($event)',
diff --git a/src/components/splitPreview.component.scss b/src/components/splitPreview.component.scss
index b10bc4e..d521bed 100644
--- a/src/components/splitPreview.component.scss
+++ b/src/components/splitPreview.component.scss
@@ -1,12 +1,14 @@
+@use '../styles/index' as *;
+
.split-preview {
display: flex;
width: 100%;
- height: 140px;
- gap: 4px;
- border-radius: 6px;
+ height: $preview-height;
+ gap: $spacing-sm;
+ border-radius: $radius-md;
overflow: hidden;
background: var(--theme-bg);
- border: 1px solid var(--theme-border);
+ border: 1px solid var(--theme-border, rgba(255, 255, 255, 0.1));
&.horizontal {
flex-direction: row;
@@ -19,62 +21,68 @@
&.nested {
height: auto;
border: 1px dashed var(--theme-fg-more);
- background: rgba(255, 255, 255, 0.02);
- padding: 4px;
- border-radius: 4px;
+ background: $nested-split-bg;
+ padding: $spacing-sm;
+ border-radius: $radius-sm;
}
}
.preview-pane {
- display: flex;
- align-items: center;
- justify-content: center;
+ @include flex-center;
background: var(--theme-bg-more);
- border-radius: 4px;
- border: 2px solid transparent;
+ border-radius: $radius-sm;
+ border: 2px solid var(--theme-border, rgba(255, 255, 255, 0.1));
cursor: pointer;
- transition: all 0.15s;
+ transition: all $transition-fast;
position: relative;
min-height: 50px;
&:hover {
background: var(--theme-bg-more-more);
+ border-color: var(--theme-fg-more, rgba(255, 255, 255, 0.3));
}
&.selected {
border-color: var(--theme-primary);
- background: var(--theme-bg-more-more);
+ background: linear-gradient(
+ to bottom,
+ $selected-pane-gradient-start,
+ $selected-pane-gradient-end
+ );
}
}
.pane-content {
text-align: center;
- padding: 8px;
+ padding: $spacing-md;
color: var(--theme-fg);
max-width: 100%;
overflow: hidden;
}
+.pane-label {
+ font-size: 0.85rem;
+ font-weight: 600;
+ margin-bottom: $spacing-sm;
+ @include text-ellipsis;
+}
+
.pane-title,
.pane-profile {
font-size: 0.8rem;
font-weight: 500;
- margin-bottom: 2px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
+ margin-bottom: $spacing-xs;
+ @include text-ellipsis;
}
.pane-details {
font-size: 0.7rem;
opacity: 0.7;
- margin-top: 4px;
+ margin-top: $spacing-sm;
.pane-detail {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 4px;
+ @include flex-center;
+ gap: $spacing-sm;
i {
width: 12px;
@@ -83,83 +91,51 @@
}
span {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+ @include text-ellipsis;
max-width: 100px;
}
}
}
-.pane-edit-btn {
- position: absolute;
- top: 4px;
- right: 4px;
- padding: 4px 6px;
- background: var(--theme-bg);
- border: 1px solid var(--theme-border);
- border-radius: 4px;
- color: var(--theme-fg);
- cursor: pointer;
- font-size: 0.7rem;
- transition: all 0.15s;
- opacity: 0;
-
- &:hover {
- background: var(--theme-primary);
- color: white;
- border-color: var(--theme-primary);
- }
-}
-
-.preview-pane:hover .pane-edit-btn {
- opacity: 1;
-}
-
// Context menu
.context-menu-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 1200;
+ @include full-overlay($z-context-menu-overlay);
}
.context-menu {
position: fixed;
background: var(--theme-bg);
- border: 1px solid var(--theme-border);
- border-radius: 8px;
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
+ border: 1px solid var(--theme-border, $fallback-border);
+ border-radius: $radius-lg;
+ box-shadow: $shadow-context-menu;
min-width: 160px;
- padding: 4px;
- z-index: 1201;
+ padding: $spacing-sm;
+ z-index: $z-context-menu;
}
.context-menu-item {
display: flex;
align-items: center;
- gap: 10px;
+ gap: $spacing-lg;
width: 100%;
- padding: 8px 12px;
+ padding: $spacing-md $spacing-lg;
border: none;
background: none;
color: var(--theme-fg);
font-size: 0.9rem;
text-align: left;
cursor: pointer;
- border-radius: 4px;
+ border-radius: $radius-sm;
&:hover {
background: var(--theme-bg-more);
}
&.danger {
- color: var(--theme-danger, #ef4444);
+ color: var(--theme-danger, $color-danger);
&:hover {
- background: rgba(239, 68, 68, 0.1);
+ background: rgba($color-danger, 0.1);
}
}
@@ -172,8 +148,8 @@
.context-menu-divider {
height: 1px;
- background: var(--theme-border);
- margin: 4px 0;
+ background: var(--theme-border, $fallback-border);
+ margin: $spacing-sm 0;
}
// Nested splits styling
diff --git a/src/components/splitPreview.component.ts b/src/components/splitPreview.component.ts
index 791f47d..8c11bee 100644
--- a/src/components/splitPreview.component.ts
+++ b/src/components/splitPreview.component.ts
@@ -1,4 +1,4 @@
-import { Component, Input, Output, EventEmitter } from '@angular/core'
+import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges, ChangeDetectorRef } from '@angular/core'
import {
WorkspaceSplit,
WorkspacePane,
@@ -11,12 +11,11 @@ import {
template: require('./splitPreview.component.pug'),
styles: [require('./splitPreview.component.scss')],
})
-export class SplitPreviewComponent {
+export class SplitPreviewComponent implements OnChanges {
@Input() split!: WorkspaceSplit
@Input() depth = 0
@Input() selectedPaneId: string | null = null
@Input() profiles: TabbyProfile[] = []
- @Output() paneSelect = new EventEmitter()
@Output() paneEdit = new EventEmitter()
@Output() splitHorizontal = new EventEmitter()
@Output() splitVertical = new EventEmitter()
@@ -29,6 +28,15 @@ export class SplitPreviewComponent {
contextMenuPane: WorkspacePane | null = null
contextMenuPosition = { x: 0, y: 0 }
+ constructor(private cdr: ChangeDetectorRef) {}
+
+ ngOnChanges(changes: SimpleChanges): void {
+ // Clear context menu when split input changes to avoid stale state
+ if (changes['split']) {
+ this.closeContextMenu()
+ }
+ }
+
isPane(child: WorkspacePane | WorkspaceSplit): boolean {
return !isWorkspaceSplit(child)
}
@@ -50,11 +58,6 @@ export class SplitPreviewComponent {
}
onPaneClick(pane: WorkspacePane): void {
- this.paneSelect.emit(pane)
- }
-
- onEditClick(event: MouseEvent, pane: WorkspacePane): void {
- event.stopPropagation()
this.paneEdit.emit(pane)
}
@@ -72,6 +75,7 @@ export class SplitPreviewComponent {
closeContextMenu(): void {
this.contextMenuPane = null
+ this.cdr.detectChanges()
}
onEdit(): void {
@@ -131,27 +135,13 @@ export class SplitPreviewComponent {
}
getPaneLabel(pane: WorkspacePane): string {
- // Base label is always the profile name
- let profileName = ''
- if (pane.profileId) {
- const profile = this.profiles.find(p => p.id === pane.profileId)
- if (profile?.name) profileName = profile.name
- }
-
- if (!profileName) return 'Select profile'
+ if (!pane.profileId) return 'Select profile'
- // Format: "Title - Profile" or just "Profile"
- if (pane.title) {
- return `${pane.title} - ${profileName}`
- }
- return profileName
+ const profile = this.profiles.find(p => p.id === pane.profileId)
+ return profile?.name || 'Select profile'
}
// Pass-through events from nested splits
- onNestedPaneSelect(pane: WorkspacePane): void {
- this.paneSelect.emit(pane)
- }
-
onNestedPaneEdit(pane: WorkspacePane): void {
this.paneEdit.emit(pane)
}
diff --git a/src/components/workspaceEditor.component.pug b/src/components/workspaceEditor.component.pug
index c2003ab..925b323 100644
--- a/src/components/workspaceEditor.component.pug
+++ b/src/components/workspaceEditor.component.pug
@@ -1,46 +1,26 @@
-//- Inline workspace editor
-.workspace-editor-inline
- .editor-body
- ng-container(*ngTemplateOutlet='editorContent')
+//- Section 1: Workspace Settings
+.editor-section
+ .section-title
+ i.fas.fa-cog
+ | Workspace Settings
- .editor-footer
- label.startup-checkbox
- input(type='checkbox', [(ngModel)]='workspace.launchOnStartup')
- | Launch on startup
- .editor-actions
- button.btn.btn-secondary.btn-sm(type='button', (click)='onCancel()') Cancel
- button.btn.btn-primary.btn-sm(type='button', (click)='onSave()', [disabled]='!workspace.name?.trim() || !hasUnsavedChanges')
- i.fas.fa-save
- | Save
- span.unsaved-indicator(*ngIf='hasUnsavedChanges') *
-
-//- Shared editor content
-ng-template(#editorContent)
- //- Top row: Color picker, Name input, Icon grid
- .editor-top-row
- .color-picker
- input.color-input(
- type='color',
- [(ngModel)]='workspace.color',
- title='Workspace color'
- )
-
- input.name-input(
- #nameInput,
- type='text',
- [(ngModel)]='workspace.name',
- placeholder='Name your workspace'
- )
-
- .icon-picker
- button.icon-trigger(
- type='button',
- [style.color]='workspace.color',
- (click)='toggleIconDropdown()',
- title='Select icon'
+ .form-row
+ .form-group
+ label Name
+ input.form-control(
+ #nameInput,
+ type='text',
+ [(ngModel)]='workspace.name',
+ placeholder='Workspace name'
)
- i.fas([class]='"fa-" + workspace.icon')
+ .form-group.auto-width
+ label Icon
+ .dropdown-trigger((click)='toggleIconDropdown()')
+ span.dropdown-icon([style.color]='workspace.color')
+ i.fas([class]='"fa-" + workspace.icon')
+ span.dropdown-chevron
+ i.fas.fa-chevron-down
.icon-dropdown(*ngIf='iconDropdownOpen')
button.icon-option(
*ngFor='let icon of availableIcons',
@@ -51,79 +31,110 @@ ng-template(#editorContent)
)
i.fas([class]='"fa-" + icon')
- //- Layout section
- .layout-section
- .layout-header
- span.layout-title Layout
-
- .layout-toolbar
- .toolbar-group.split-group
- span.toolbar-label Split
- button.toolbar-btn(
- type='button',
- [disabled]='!selectedPaneId',
- (click)='splitSelectedPane("horizontal")',
- title='Split Horizontal'
- )
- i.fas.fa-arrows-alt-h
- button.toolbar-btn(
- type='button',
- [disabled]='!selectedPaneId',
- (click)='splitSelectedPane("vertical")',
- title='Split Vertical'
+ .form-group.auto-width
+ label Color
+ .color-trigger
+ span.dropdown-color([style.background]='workspace.color')
+ input.color-input-hidden(
+ type='color',
+ [(ngModel)]='workspace.color'
)
- i.fas.fa-arrows-alt-v
+ span.dropdown-chevron
+ i.fas.fa-chevron-down
- .toolbar-divider
-
- .toolbar-group.add-group
- span.toolbar-label Add
- button.toolbar-btn(
- type='button',
- [disabled]='!selectedPaneId',
- (click)='addPane("left")',
- title='Add Left'
- )
- i.fas.fa-caret-left
- button.toolbar-btn(
- type='button',
- [disabled]='!selectedPaneId',
- (click)='addPane("right")',
- title='Add Right'
- )
- i.fas.fa-caret-right
- button.toolbar-btn(
- type='button',
- [disabled]='!selectedPaneId',
- (click)='addPane("top")',
- title='Add Top'
- )
- i.fas.fa-caret-up
- button.toolbar-btn(
- type='button',
- [disabled]='!selectedPaneId',
- (click)='addPane("bottom")',
- title='Add Bottom'
- )
- i.fas.fa-caret-down
+ .form-group.auto-width
+ label Background
+ .background-picker
+ .background-trigger((click)='toggleBackgroundDropdown()')
+ span.background-preview([style.background]='workspace.background?.value || "transparent"')
+ i.fas.fa-ban(*ngIf='!workspace.background?.value')
+ span.dropdown-chevron
+ i.fas.fa-chevron-down
+ .background-dropdown(*ngIf='backgroundDropdownOpen')
+ .preset-grid
+ button.preset-option(
+ *ngFor='let preset of backgroundPresets',
+ type='button',
+ [class.selected]='isBackgroundSelected(preset)',
+ [style.background]='preset.value || "var(--theme-bg)"',
+ (click)='selectBackgroundPreset(preset)'
+ )
+ i.fas.fa-ban(*ngIf='preset.type === "none"')
+ .custom-input-wrapper
+ input.form-control.custom-bg-input(
+ type='text',
+ [(ngModel)]='customBackgroundValue',
+ placeholder='Custom CSS gradient...',
+ (blur)='applyCustomBackground()'
+ )
- .toolbar-spacer
+//- Section 2: Split Layout
+.editor-section
+ .section-title
+ i.fas.fa-columns
+ | Split Layout
- .toolbar-group
- button.toolbar-btn.danger(
- type='button',
- [disabled]='!selectedPaneId || !canRemovePane()',
- (click)='removeSelectedPane()',
- title='Remove pane'
- )
- i.fas.fa-trash
+ .split-preview-container
+ //- Preview toolbar
+ .preview-toolbar
+ button.preview-btn(
+ type='button',
+ title='Split Horizontal',
+ [disabled]='!selectedPaneId',
+ (click)='splitSelectedPane("horizontal")'
+ )
+ i.fas.fa-grip-lines-vertical
+ button.preview-btn(
+ type='button',
+ title='Split Vertical',
+ [disabled]='!selectedPaneId',
+ (click)='splitSelectedPane("vertical")'
+ )
+ i.fas.fa-grip-lines
+ span.toolbar-separator
+ button.preview-btn(
+ type='button',
+ title='Add Left',
+ [disabled]='!selectedPaneId',
+ (click)='addPane("left")'
+ )
+ i.fas.fa-arrow-left
+ button.preview-btn(
+ type='button',
+ title='Add Right',
+ [disabled]='!selectedPaneId',
+ (click)='addPane("right")'
+ )
+ i.fas.fa-arrow-right
+ button.preview-btn(
+ type='button',
+ title='Add Top',
+ [disabled]='!selectedPaneId',
+ (click)='addPane("top")'
+ )
+ i.fas.fa-arrow-up
+ button.preview-btn(
+ type='button',
+ title='Add Bottom',
+ [disabled]='!selectedPaneId',
+ (click)='addPane("bottom")'
+ )
+ i.fas.fa-arrow-down
+ span.toolbar-separator
+ button.preview-btn.danger(
+ type='button',
+ title='Remove pane',
+ [disabled]='!selectedPaneId || !canRemovePane()',
+ (click)='removeSelectedPane()'
+ )
+ i.fas.fa-trash
- .layout-preview-container((click)='onPreviewBackgroundClick()')
+ //- Split preview
+ .layout-preview((click)='onPreviewBackgroundClick()')
split-preview(
[split]='workspace.root',
[selectedPaneId]='selectedPaneId',
[profiles]='profiles',
- (paneSelect)='selectPane($event)',
(paneEdit)='editPane($event)',
(splitHorizontal)='splitPane($event, "horizontal")',
(splitVertical)='splitPane($event, "vertical")',
@@ -134,11 +145,28 @@ ng-template(#editorContent)
(removePane)='removePane($event)'
)
-//- Pane editor (always modal)
-pane-editor(
- *ngIf='showPaneEditor && editingPane',
- [pane]='editingPane',
- [profiles]='profiles',
- (save)='onPaneSave($event)',
- (cancel)='closePaneEditor()'
-)
+ //- Inline pane editor
+ pane-editor(
+ *ngIf='showPaneEditor && editingPane',
+ [pane]='editingPane',
+ [profiles]='profiles',
+ (close)='closePaneEditor()'
+ )
+
+//- Action buttons
+.action-buttons
+ .checkbox-group
+ input(type='checkbox', [(ngModel)]='workspace.launchOnStartup', id='launchStartup')
+ label(for='launchStartup') Launch on startup
+ .action-buttons-right
+ button.btn.btn-ghost(type='button', (click)='onCancel()')
+ i.fas.fa-xmark
+ | Cancel
+ button.btn.btn-success(
+ type='button',
+ (click)='onSave()',
+ [disabled]='!workspace.name?.trim() || !hasUnsavedChanges'
+ )
+ i.fas.fa-check
+ | Save
+ span.unsaved-indicator(*ngIf='hasUnsavedChanges') *
diff --git a/src/components/workspaceEditor.component.scss b/src/components/workspaceEditor.component.scss
index aa28e2d..41a73eb 100644
--- a/src/components/workspaceEditor.component.scss
+++ b/src/components/workspaceEditor.component.scss
@@ -1,254 +1,322 @@
-// Workspace editor (inline)
-.workspace-editor-inline {
- background: var(--theme-bg-more);
- border-radius: 8px;
- border: 1px solid var(--theme-border);
- margin-bottom: 16px;
+@use '../styles/index' as *;
+
+// Editor section
+.editor-section {
+ margin-bottom: $spacing-xl;
+}
+
+.section-title {
+ font-size: 11px;
+ color: var(--theme-fg-more);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: $spacing-lg;
+ display: flex;
+ align-items: center;
+ gap: $spacing-sm;
+}
+
+// Form layout
+.form-row {
+ display: flex;
+ gap: $spacing-lg;
+ align-items: flex-end;
+}
- .editor-body {
- padding: 16px;
+.form-group {
+ flex: 1;
+ position: relative;
+
+ &.auto-width {
+ flex: none;
}
- .editor-footer {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 10px 16px;
- border-top: 1px solid var(--theme-border);
- background: var(--theme-bg);
- border-radius: 0 0 8px 8px;
-
- .startup-checkbox {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 0.85rem;
- color: var(--theme-fg-more);
- cursor: pointer;
-
- input {
- margin: 0;
- }
- }
+ label {
+ @include form-label;
+ }
+
+ .form-control {
+ @include form-input(var(--theme-bg-more));
+ width: 100%;
+ font-size: $font-sm;
+ // Override Tabby's global border reset
+ border: 1px solid var(--theme-border, $fallback-border) !important;
- .editor-actions {
- display: flex;
- gap: 8px;
-
- .btn-success {
- background: #10b981;
- border-color: #10b981;
- color: white;
-
- &:hover {
- background: #059669;
- border-color: #059669;
- }
- }
-
- .unsaved-indicator {
- color: #f59e0b;
- font-weight: bold;
- margin-left: 2px;
- }
+ &:focus {
+ border-color: var(--theme-primary) !important;
}
}
}
-// Shared editor content
-.editor-top-row {
+// Dropdown trigger (icon picker)
+.dropdown-trigger {
display: flex;
- align-items: flex-start;
- gap: 12px;
- margin-bottom: 16px;
-}
-
-.color-picker {
- .color-input {
- width: 40px;
- height: 40px;
- padding: 2px;
- border: 1px solid var(--theme-border);
- border-radius: 6px;
- cursor: pointer;
- background: var(--theme-bg);
+ align-items: center;
+ gap: $spacing-sm;
+ padding: $spacing-sm $spacing-md;
+ background: var(--theme-bg-more);
+ border: 1px solid var(--theme-border, $fallback-border);
+ border-radius: $radius-sm;
+ cursor: pointer;
+ transition: border-color $transition-fast;
+ height: 32px;
+ box-sizing: border-box;
+
+ &:hover {
+ border-color: var(--theme-fg-more);
+ }
+}
- &::-webkit-color-swatch-wrapper {
- padding: 2px;
- }
+.dropdown-icon {
+ font-size: 14px;
+}
- &::-webkit-color-swatch {
- border-radius: 4px;
- border: none;
- }
- }
+.dropdown-chevron {
+ color: var(--theme-fg-more);
+ font-size: 10px;
}
-.name-input {
- flex: 1;
- min-width: 150px;
- padding: 10px 12px;
- border-radius: 6px;
- border: 1px solid var(--theme-border);
- background: var(--theme-bg);
- color: var(--theme-fg);
- font-size: 1rem;
- font-weight: 500;
+// Icon dropdown
+.icon-dropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ margin-top: $spacing-sm;
+ padding: $spacing-md;
+ @include dropdown-panel;
+ z-index: $z-dropdown;
+ display: grid;
+ grid-template-columns: repeat(6, 28px);
+ gap: $spacing-sm;
+}
- &:focus {
- outline: none;
- border-color: var(--theme-primary);
+.icon-option {
+ width: 28px;
+ height: 28px;
+ @include flex-center;
+ border: 1px solid transparent;
+ border-radius: $radius-sm;
+ background: transparent;
+ color: var(--theme-fg-more);
+ cursor: pointer;
+ transition: all $transition-fast;
+ font-size: 0.85rem;
+
+ &:hover {
+ background: var(--theme-bg-more-more);
+ color: var(--theme-fg);
}
- &::placeholder {
- color: var(--theme-fg-more);
- font-weight: 400;
+ &.selected {
+ border-color: currentColor;
+ background: var(--theme-bg);
}
}
-.icon-picker {
+// Color trigger (wraps native color input)
+.color-trigger {
+ display: flex;
+ align-items: center;
+ gap: $spacing-sm;
+ padding: $spacing-sm $spacing-md;
+ background: var(--theme-bg-more);
+ border: 1px solid var(--theme-border, $fallback-border);
+ border-radius: $radius-sm;
+ cursor: pointer;
position: relative;
+ transition: border-color $transition-fast;
+ height: 32px;
+ box-sizing: border-box;
- .icon-trigger {
- width: 40px;
- height: 40px;
- display: flex;
- align-items: center;
- justify-content: center;
- border: 1px solid var(--theme-border);
- border-radius: 6px;
- background: var(--theme-bg);
- cursor: pointer;
- font-size: 1.1rem;
- transition: background 0.15s;
+ &:hover {
+ border-color: var(--theme-fg-more);
+ }
- &:hover {
- background: var(--theme-bg-more);
- }
+ &:focus-within {
+ border-color: var(--theme-primary);
}
- .icon-dropdown {
+ .color-input-hidden {
position: absolute;
- top: 100%;
- right: 0;
- margin-top: 4px;
- padding: 8px;
- background: var(--theme-bg-more);
- border: 1px solid var(--theme-border);
- border-radius: 6px;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
- z-index: 100;
- display: grid;
- grid-template-columns: repeat(6, 28px);
- gap: 4px;
- }
-
- .icon-option {
- width: 28px;
- height: 28px;
- display: flex;
- align-items: center;
- justify-content: center;
- border: 1px solid transparent;
- border-radius: 4px;
- background: transparent;
- color: var(--theme-fg-more);
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
cursor: pointer;
- transition: all 0.15s;
- font-size: 0.85rem;
+ }
+}
- &:hover {
- background: var(--theme-bg-more-more);
- color: var(--theme-fg);
- }
+.dropdown-color {
+ width: 16px;
+ height: 16px;
+ border-radius: $radius-xs;
+}
- &.selected {
- border-color: currentColor;
- background: var(--theme-bg);
- }
+// Background picker
+.background-picker {
+ position: relative;
+}
+
+.background-trigger {
+ display: flex;
+ align-items: center;
+ gap: $spacing-sm;
+ padding: $spacing-sm $spacing-md;
+ background: var(--theme-bg-more);
+ border: 1px solid var(--theme-border, $fallback-border);
+ border-radius: $radius-sm;
+ cursor: pointer;
+ transition: border-color $transition-fast;
+ height: 32px;
+ box-sizing: border-box;
+
+ &:hover {
+ border-color: var(--theme-fg-more);
}
}
-// Layout section
-.layout-section {
- .layout-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 8px;
+.background-preview {
+ width: 48px;
+ height: 18px;
+ border-radius: $radius-xs;
+ border: 1px solid var(--theme-border, $fallback-border);
+ @include flex-center;
+ color: var(--theme-fg-more, $fallback-fg-more);
+ font-size: 10px;
+}
+
+.background-dropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ margin-top: $spacing-sm;
+ padding: $spacing-md;
+ @include dropdown-panel;
+ z-index: $z-dropdown;
+ min-width: 260px;
+}
+
+.preset-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: $spacing-sm;
+ margin-bottom: $spacing-md;
+}
+
+.preset-option {
+ aspect-ratio: 1.5;
+ height: 32px;
+ @include flex-center;
+ border: 1px solid var(--theme-border, $fallback-border);
+ border-radius: $radius-sm;
+ cursor: pointer;
+ transition: all $transition-fast;
+ color: var(--theme-fg-more, $fallback-fg-more);
+ font-size: 10px;
+
+ &:hover {
+ border-color: var(--theme-fg-more);
}
- .layout-title {
- font-size: 0.8rem;
- font-weight: 500;
- color: var(--theme-fg-more);
- text-transform: uppercase;
- letter-spacing: 0.5px;
+ &.selected {
+ border-color: var(--theme-primary);
+ border-width: 2px;
+ }
+}
+
+.custom-input-wrapper {
+ .custom-bg-input {
+ width: 100%;
+ font-size: $font-xs;
}
}
-// Layout toolbar
-.layout-toolbar {
+// Split preview container
+.split-preview-container {
+ background: var(--theme-bg-more);
+ border: 1px solid var(--theme-border, $fallback-border);
+ border-radius: $radius-md;
+ padding: $spacing-lg;
+}
+
+// Preview toolbar
+.preview-toolbar {
display: flex;
+ justify-content: flex-end;
align-items: center;
- gap: 4px;
- padding: 8px 0;
- margin-bottom: 8px;
+ gap: $spacing-sm;
+ margin-bottom: $spacing-lg;
+ padding-bottom: $spacing-md;
+ border-bottom: 1px solid var(--theme-border, $fallback-border);
+}
- .toolbar-group {
- display: flex;
- align-items: center;
- gap: 2px;
- }
+.toolbar-separator {
+ width: 1px;
+ height: 18px;
+ background: var(--theme-border, $fallback-border);
+ margin: 0 $spacing-sm;
+ align-self: center;
+}
- .toolbar-label {
- font-size: 0.75rem;
- color: var(--theme-fg-more);
- margin-right: 4px;
- text-transform: uppercase;
- letter-spacing: 0.3px;
- }
+.preview-btn {
+ @include icon-btn-sm;
+}
- .toolbar-spacer {
- flex: 1;
- }
+// Layout preview area
+.layout-preview {
+ background: var(--theme-bg);
+ border-radius: $radius-sm;
+ min-height: 150px;
+ max-height: 200px;
+ padding: $spacing-sm;
+}
+
+// Action buttons
+.action-buttons {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-top: $spacing-xl;
+ border-top: 1px solid var(--theme-border, $fallback-border);
+ margin-top: $spacing-xl;
+}
+
+.checkbox-group {
+ display: flex;
+ align-items: center;
+ gap: $spacing-sm;
- .toolbar-divider {
- width: 1px;
- height: 20px;
- background: var(--theme-border);
- margin: 0 4px;
+ input[type="checkbox"] {
+ width: 14px;
+ height: 14px;
+ margin: 0;
}
- .toolbar-btn {
- padding: 4px 8px;
- background: var(--theme-bg);
- border: 1px solid var(--theme-border);
- border-radius: 4px;
+ label {
+ font-size: $font-sm;
color: var(--theme-fg);
cursor: pointer;
- transition: all 0.15s;
- font-size: 0.85rem;
+ }
+}
- &:hover:not(:disabled) {
- background: var(--theme-bg-more-more);
- }
+.action-buttons-right {
+ display: flex;
+ gap: $spacing-md;
- &:disabled {
- opacity: 0.4;
- cursor: not-allowed;
- }
+ .btn-ghost {
+ @include btn-ghost;
+ }
- &.danger {
- color: var(--theme-danger, #ef4444);
- }
+ .btn-success {
+ @include btn-base;
+ @include btn-success;
}
-}
-.layout-preview-container {
- background: var(--theme-bg);
- border-radius: 6px;
- padding: 8px;
- min-height: 120px;
- max-height: 180px;
+ .unsaved-indicator {
+ color: $color-warning;
+ font-weight: bold;
+ margin-left: $spacing-xs;
+ }
}
diff --git a/src/components/workspaceEditor.component.ts b/src/components/workspaceEditor.component.ts
index 0e5eb1c..ef1e3a3 100644
--- a/src/components/workspaceEditor.component.ts
+++ b/src/components/workspaceEditor.component.ts
@@ -1,13 +1,22 @@
-import { Component, Input, Output, EventEmitter, OnInit, OnChanges, AfterViewInit, SimpleChanges, HostListener, ElementRef, ViewChild } from '@angular/core'
+import { Component, Input, Output, EventEmitter, OnInit, OnChanges, AfterViewInit, SimpleChanges, HostListener, ElementRef, ViewChild, ChangeDetectorRef } from '@angular/core'
import {
Workspace,
WorkspacePane,
WorkspaceSplit,
+ WorkspaceBackground,
TabbyProfile,
isWorkspaceSplit,
createDefaultPane,
generateUUID,
+ BACKGROUND_PRESETS,
} from '../models/workspace.model'
+
+interface TreeContext {
+ node: WorkspaceSplit
+ index: number
+ parent: WorkspaceSplit | null
+ child: WorkspacePane | WorkspaceSplit
+}
import { WorkspaceEditorService } from '../services/workspaceEditor.service'
@Component({
@@ -34,18 +43,35 @@ export class WorkspaceEditorComponent implements OnInit, OnChanges, AfterViewIni
'bug', 'wrench', 'cube', 'layer-group', 'sitemap', 'project-diagram'
]
iconDropdownOpen = false
+ backgroundPresets = BACKGROUND_PRESETS
+ backgroundDropdownOpen = false
+ customBackgroundValue = ''
constructor(
private workspaceService: WorkspaceEditorService,
- private elementRef: ElementRef
+ private elementRef: ElementRef,
+ private cdr: ChangeDetectorRef
) {}
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
- const iconPicker = this.elementRef.nativeElement.querySelector('.icon-picker')
- if (iconPicker && !iconPicker.contains(event.target as Node)) {
+ // Check if click is outside the icon dropdown area (trigger + dropdown)
+ const dropdownTrigger = this.elementRef.nativeElement.querySelector('.dropdown-trigger')
+ const iconDropdown = this.elementRef.nativeElement.querySelector('.icon-dropdown')
+ const iconClickedInside = dropdownTrigger?.contains(event.target as Node) ||
+ iconDropdown?.contains(event.target as Node)
+ if (this.iconDropdownOpen && !iconClickedInside) {
this.iconDropdownOpen = false
}
+
+ // Check if click is outside the background dropdown area
+ const bgTrigger = this.elementRef.nativeElement.querySelector('.background-trigger')
+ const bgDropdown = this.elementRef.nativeElement.querySelector('.background-dropdown')
+ const bgClickedInside = bgTrigger?.contains(event.target as Node) ||
+ bgDropdown?.contains(event.target as Node)
+ if (this.backgroundDropdownOpen && !bgClickedInside) {
+ this.backgroundDropdownOpen = false
+ }
}
toggleIconDropdown(): void {
@@ -57,9 +83,49 @@ export class WorkspaceEditorComponent implements OnInit, OnChanges, AfterViewIni
this.iconDropdownOpen = false
}
+ toggleBackgroundDropdown(): void {
+ this.backgroundDropdownOpen = !this.backgroundDropdownOpen
+ }
+
+ selectBackgroundPreset(preset: WorkspaceBackground): void {
+ if (preset.type === 'none') {
+ this.workspace.background = undefined
+ this.customBackgroundValue = ''
+ } else {
+ this.workspace.background = { ...preset }
+ this.customBackgroundValue = preset.value
+ }
+ this.backgroundDropdownOpen = false
+ }
+
+ applyCustomBackground(): void {
+ const value = this.customBackgroundValue.trim()
+ if (value) {
+ this.workspace.background = {
+ type: 'gradient',
+ value
+ }
+ } else {
+ this.workspace.background = undefined
+ }
+ }
+
+ clearBackground(): void {
+ this.workspace.background = undefined
+ this.customBackgroundValue = ''
+ }
+
+ isBackgroundSelected(preset: WorkspaceBackground): boolean {
+ if (preset.type === 'none') {
+ return !this.workspace.background || this.workspace.background.type === 'none'
+ }
+ return this.workspace.background?.value === preset.value
+ }
+
async ngOnInit(): Promise {
this.profiles = await this.workspaceService.getAvailableProfiles()
this.initializeWorkspace()
+ this.cdr.detectChanges()
}
ngAfterViewInit(): void {
@@ -81,11 +147,26 @@ export class WorkspaceEditorComponent implements OnInit, OnChanges, AfterViewIni
ngOnChanges(changes: SimpleChanges): void {
if (changes['workspace'] && !changes['workspace'].firstChange) {
- // Reset component state when workspace input changes
- this.selectedPaneId = null
- this.editingPane = null
- this.showPaneEditor = false
+ const prevId = changes['workspace'].previousValue?.id
+ const currId = this.workspace.id
+
+ if (prevId !== currId) {
+ // Different workspace - reset everything and focus name input
+ this.selectedPaneId = null
+ this.editingPane = null
+ this.showPaneEditor = false
+ this.focusNameInput()
+ } else {
+ // Same workspace ID but different reference (after save/reload)
+ // Re-sync editingPane to point to pane in new object tree
+ if (this.selectedPaneId && this.showPaneEditor) {
+ this.editingPane = this.findPaneById(this.selectedPaneId)
+ }
+ }
+ // Always reset dropdowns and sync background value
this.iconDropdownOpen = false
+ this.backgroundDropdownOpen = false
+ this.customBackgroundValue = this.workspace.background?.value || ''
this.initializeWorkspace()
}
@@ -116,37 +197,26 @@ export class WorkspaceEditorComponent implements OnInit, OnChanges, AfterViewIni
this.cancel.emit()
}
- selectPane(pane: WorkspacePane): void {
- this.selectedPaneId = pane.id
- }
-
deselectPane(): void {
this.selectedPaneId = null
}
onPreviewBackgroundClick(): void {
this.deselectPane()
+ this.closePaneEditor()
}
editPane(pane: WorkspacePane): void {
+ this.selectedPaneId = pane.id
this.editingPane = pane
this.showPaneEditor = true
- }
-
- editSelectedPane(): void {
- if (!this.selectedPaneId) return
- const pane = this.findPaneById(this.selectedPaneId)
- if (pane) this.editPane(pane)
+ this.cdr.detectChanges()
}
closePaneEditor(): void {
this.showPaneEditor = false
this.editingPane = null
- }
-
- onPaneSave(pane: WorkspacePane): void {
- this.updatePaneInTree(this.workspace.root, pane)
- this.closePaneEditor()
+ this.cdr.detectChanges()
}
// Helper functions
@@ -176,23 +246,37 @@ export class WorkspaceEditorComponent implements OnInit, OnChanges, AfterViewIni
}, 0)
}
- private updatePaneInTree(node: WorkspaceSplit, updatedPane: WorkspacePane): boolean {
+ private walkTree(
+ node: WorkspaceSplit,
+ visitor: (ctx: TreeContext) => boolean,
+ parent: WorkspaceSplit | null = null
+ ): boolean {
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i]
+ const ctx: TreeContext = { node, index: i, parent, child }
+
if (isWorkspaceSplit(child)) {
- if (this.updatePaneInTree(child, updatedPane)) {
- return true
- }
- } else if (child.id === updatedPane.id) {
- node.children[i] = updatedPane
+ if (this.walkTree(child, visitor, node)) return true
+ } else if (visitor(ctx)) {
return true
}
}
return false
}
+ private updatePaneInTree(updatedPane: WorkspacePane): boolean {
+ return this.walkTree(this.workspace.root, (ctx) => {
+ if ((ctx.child as WorkspacePane).id === updatedPane.id) {
+ ctx.node.children[ctx.index] = updatedPane
+ return true
+ }
+ return false
+ })
+ }
+
splitPane(pane: WorkspacePane, orientation: 'horizontal' | 'vertical'): void {
- this.splitPaneInTree(this.workspace.root, pane, orientation)
+ this.splitPaneInTree(pane, orientation)
+ this.cdr.detectChanges()
}
splitSelectedPane(orientation: 'horizontal' | 'vertical'): void {
@@ -202,30 +286,24 @@ export class WorkspaceEditorComponent implements OnInit, OnChanges, AfterViewIni
}
private splitPaneInTree(
- node: WorkspaceSplit,
targetPane: WorkspacePane,
orientation: 'horizontal' | 'vertical'
): boolean {
- for (let i = 0; i < node.children.length; i++) {
- const child = node.children[i]
- if (isWorkspaceSplit(child)) {
- if (this.splitPaneInTree(child, targetPane, orientation)) {
- return true
- }
- } else if (child.id === targetPane.id) {
+ return this.walkTree(this.workspace.root, (ctx) => {
+ if ((ctx.child as WorkspacePane).id === targetPane.id) {
const newPane = createDefaultPane()
- newPane.profileId = child.profileId // Copy profile from source pane
+ newPane.profileId = (ctx.child as WorkspacePane).profileId
const newSplit: WorkspaceSplit = {
orientation,
ratios: [0.5, 0.5],
- children: [child, newPane],
+ children: [ctx.child, newPane],
}
- node.children[i] = newSplit
- this.recalculateRatios(node)
+ ctx.node.children[ctx.index] = newSplit
+ this.recalculateRatios(ctx.node)
return true
}
- }
- return false
+ return false
+ })
}
removePane(pane: WorkspacePane): void {
@@ -233,6 +311,7 @@ export class WorkspaceEditorComponent implements OnInit, OnChanges, AfterViewIni
this.selectedPaneId = null
}
this.removePaneFromTree(this.workspace.root, pane)
+ this.cdr.detectChanges()
}
removeSelectedPane(): void {
@@ -279,6 +358,7 @@ export class WorkspaceEditorComponent implements OnInit, OnChanges, AfterViewIni
setOrientation(orientation: 'horizontal' | 'vertical'): void {
this.workspace.root.orientation = orientation
+ this.cdr.detectChanges()
}
updateRatio(index: number, value: number): void {
@@ -299,6 +379,7 @@ export class WorkspaceEditorComponent implements OnInit, OnChanges, AfterViewIni
})
this.workspace.root.ratios = ratios
+ this.cdr.detectChanges()
}
// Add pane operations
@@ -306,63 +387,58 @@ export class WorkspaceEditorComponent implements OnInit, OnChanges, AfterViewIni
if (!this.selectedPaneId) return
const pane = this.findPaneById(this.selectedPaneId)
if (!pane) return
- this.addPaneInTree(this.workspace.root, pane, direction, null)
+ this.addPaneInTree(pane, direction)
+ this.cdr.detectChanges()
}
addPaneFromEvent(pane: WorkspacePane, direction: 'left' | 'right' | 'top' | 'bottom'): void {
- this.addPaneInTree(this.workspace.root, pane, direction, null)
+ this.addPaneInTree(pane, direction)
+ this.cdr.detectChanges()
}
private addPaneInTree(
- node: WorkspaceSplit,
targetPane: WorkspacePane,
- direction: 'left' | 'right' | 'top' | 'bottom',
- parentNode: WorkspaceSplit | null
+ direction: 'left' | 'right' | 'top' | 'bottom'
): boolean {
const isHorizontalAdd = direction === 'left' || direction === 'right'
const isBefore = direction === 'left' || direction === 'top'
const targetOrientation = isHorizontalAdd ? 'horizontal' : 'vertical'
- for (let i = 0; i < node.children.length; i++) {
- const child = node.children[i]
-
- if (isWorkspaceSplit(child)) {
- if (this.addPaneInTree(child, targetPane, direction, node)) return true
- } else if (child.id === targetPane.id) {
- const newPane = createDefaultPane()
- newPane.profileId = child.profileId
-
- if (node.orientation === targetOrientation) {
- // Same orientation: add as sibling
- const insertIndex = isBefore ? i : i + 1
- node.children.splice(insertIndex, 0, newPane)
- this.recalculateRatios(node)
- } else {
- // Different orientation: wrap entire node in new split
- const nodeCopy: WorkspaceSplit = {
- orientation: node.orientation,
- ratios: [...node.ratios],
- children: [...node.children]
- }
- const wrapper: WorkspaceSplit = {
- orientation: targetOrientation,
- ratios: [0.5, 0.5],
- children: isBefore ? [newPane, nodeCopy] : [nodeCopy, newPane]
- }
+ return this.walkTree(this.workspace.root, (ctx) => {
+ if ((ctx.child as WorkspacePane).id !== targetPane.id) return false
+
+ const newPane = createDefaultPane()
+ newPane.profileId = (ctx.child as WorkspacePane).profileId
+
+ if (ctx.node.orientation === targetOrientation) {
+ // Same orientation: add as sibling
+ const insertIndex = isBefore ? ctx.index : ctx.index + 1
+ ctx.node.children.splice(insertIndex, 0, newPane)
+ this.recalculateRatios(ctx.node)
+ } else {
+ // Different orientation: wrap entire node in new split
+ const nodeCopy: WorkspaceSplit = {
+ orientation: ctx.node.orientation,
+ ratios: [...ctx.node.ratios],
+ children: [...ctx.node.children]
+ }
+ const wrapper: WorkspaceSplit = {
+ orientation: targetOrientation,
+ ratios: [0.5, 0.5],
+ children: isBefore ? [newPane, nodeCopy] : [nodeCopy, newPane]
+ }
- if (node === this.workspace.root) {
- this.workspace.root = wrapper
- } else if (parentNode) {
- const nodeIndex = parentNode.children.indexOf(node)
- if (nodeIndex !== -1) {
- parentNode.children[nodeIndex] = wrapper
- }
+ if (ctx.node === this.workspace.root) {
+ this.workspace.root = wrapper
+ } else if (ctx.parent) {
+ const nodeIndex = ctx.parent.children.indexOf(ctx.node)
+ if (nodeIndex !== -1) {
+ ctx.parent.children[nodeIndex] = wrapper
}
}
- return true
}
- }
- return false
+ return true
+ })
}
}
diff --git a/src/components/workspaceList.component.pug b/src/components/workspaceList.component.pug
index 07af348..0cde004 100644
--- a/src/components/workspaceList.component.pug
+++ b/src/components/workspaceList.component.pug
@@ -1,57 +1,37 @@
.workspace-list-container
- .workspace-list-header
- h3 Workspace Editor
- button.btn.btn-primary(type='button', (click)='createWorkspace()')
- i.fas.fa-plus
- | New Workspace
+ //- Tab bar navigation
+ .tab-bar
+ .tab(
+ *ngFor='let tab of displayTabs; trackBy: trackByTab',
+ [class.active]='isTabSelected(tab)',
+ (click)='!tab.isNew && selectWorkspace(tab.workspace)'
+ )
+ span.tab-icon([style.color]='tab.workspace.color')
+ i.fas([class]='"fa-" + (tab.workspace.icon || "columns")')
+ span.tab-name {{ tab.workspace.name || 'New Workspace' }}
+ span.tab-close(
+ *ngIf='!tab.isNew',
+ (click)='deleteWorkspace($event, tab.workspace)',
+ title='Delete workspace'
+ )
+ i.fas.fa-xmark
- //- Editor (above list)
- workspace-editor(
- *ngIf='editingWorkspace',
- [workspace]='editingWorkspace',
- [autoFocus]='isCreatingNew',
- [hasUnsavedChanges]='hasUnsavedChanges',
- (save)='onEditorSave($event)',
- (cancel)='onEditorCancel()'
- )
+ .tab-new((click)='createWorkspace()', title='New workspace')
+ i.fas.fa-plus
- //- Workspace list
- .workspace-list(*ngIf='workspaces.length > 0')
- .workspace-item(
- *ngFor='let workspace of workspaces',
- [class.selected]='isSelected(workspace)',
- (click)='selectWorkspace(workspace)'
+ //- Tab content (editor)
+ .tab-content(*ngIf='editingWorkspace')
+ workspace-editor(
+ [workspace]='editingWorkspace',
+ [autoFocus]='isCreatingNew',
+ [hasUnsavedChanges]='hasUnsavedChanges',
+ (save)='onEditorSave($event)',
+ (cancel)='onEditorCancel()'
)
- .workspace-info
- .workspace-icon([style.color]='workspace.color')
- i.fas([class]='"fa-" + (workspace.icon || "columns")')
- .workspace-details
- .workspace-name {{ workspace.name }}
- .workspace-meta
- span {{ getPaneCount(workspace) }} panes
- span.separator ·
- span {{ getOrientationLabel(workspace) }}
- span.separator(*ngIf='workspace.launchOnStartup') ·
- span.badge.badge-primary(*ngIf='workspace.launchOnStartup') startup
-
- .workspace-actions
- button.btn.btn-link.open-btn(
- type='button',
- title='Open',
- [disabled]='openingWorkspaceId === workspace.id',
- (click)='openWorkspace($event, workspace)'
- )
- i.fas(
- [class.fa-external-link-alt]='openingWorkspaceId !== workspace.id',
- [class.fa-spinner]='openingWorkspaceId === workspace.id',
- [class.fa-spin]='openingWorkspaceId === workspace.id'
- )
- button.btn.btn-link(type='button', title='Duplicate', (click)='duplicateWorkspace($event, workspace)')
- i.fas.fa-copy
- button.btn.btn-link.text-danger(type='button', title='Delete', (click)='deleteWorkspace($event, workspace)')
- i.fas.fa-trash
- //- Empty state
- .workspace-empty(*ngIf='workspaces.length === 0 && !isCreatingNew')
+ //- Empty state (when no workspaces)
+ .tab-content.empty-state(*ngIf='!editingWorkspace && workspaces.length === 0')
p No workspaces configured yet.
- p Click "New Workspace" to create your first split-layout workspace.
+ p Click
+ strong +
+ | to create your first workspace.
diff --git a/src/components/workspaceList.component.scss b/src/components/workspaceList.component.scss
index 612a5c1..e8e91f4 100644
--- a/src/components/workspaceList.component.scss
+++ b/src/components/workspaceList.component.scss
@@ -1,118 +1,127 @@
+@use '../styles/index' as *;
+
.workspace-list-container {
- padding: 20px;
+ padding: $spacing-2xl;
}
-.workspace-list-header {
+// Tab bar
+.tab-bar {
display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20px;
+ background: var(--theme-bg-more);
+ border: 1px solid var(--theme-border, $fallback-border);
+ border-bottom: none;
+ overflow-y: hidden;
+ overflow-x: auto;
+
+ // Thin scrollbar
+ &::-webkit-scrollbar {
+ height: 6px;
+ }
- h3 {
- margin: 0;
- font-size: 1.5rem;
+ &::-webkit-scrollbar-track {
+ background: var(--theme-bg-more);
}
-}
-.workspace-list {
- display: flex;
- flex-direction: column;
- gap: 12px;
+ &::-webkit-scrollbar-thumb {
+ background: var(--theme-border, $fallback-border);
+ border-radius: 3px;
+
+ &:hover {
+ background: var(--theme-fg-more);
+ }
+ }
}
-.workspace-item {
+.tab {
display: flex;
- justify-content: space-between;
align-items: center;
- padding: 16px;
- background: var(--theme-bg-more);
- border-radius: 8px;
- border: 1px solid var(--theme-border);
- transition: background 0.2s, border-color 0.2s;
+ gap: $spacing-md;
+ padding: $spacing-md $spacing-lg;
+ border-right: 1px solid var(--theme-border, $fallback-border);
+ color: var(--theme-fg-more);
cursor: pointer;
+ font-size: $font-sm;
+ min-width: 120px;
+ transition: background $transition-fast;
&:hover {
background: var(--theme-bg-more-more);
+
+ .tab-close {
+ opacity: 1;
+ }
}
- &.selected {
- border-color: var(--theme-primary);
- border-left-width: 3px;
- background: var(--theme-bg-more-more);
- box-shadow: 0 0 0 1px var(--theme-primary) inset;
+ &.active {
+ background: var(--theme-bg);
+ color: var(--theme-fg);
+ border-bottom: 2px solid var(--theme-primary);
+ margin-bottom: -1px;
}
}
-.workspace-info {
- display: flex;
- align-items: center;
- gap: 16px;
+.tab-icon {
+ font-size: 12px;
}
-.workspace-icon {
- font-size: 24px;
- width: 40px;
- text-align: center;
+.tab-name {
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
-.workspace-details {
+.tab-close {
+ width: 16px;
+ height: 16px;
display: flex;
- flex-direction: column;
- gap: 4px;
-}
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ font-size: 9px;
+ border-radius: $radius-xs;
+ transition: opacity $transition-fast, background $transition-fast;
-.workspace-name {
- font-size: 1.1rem;
- font-weight: 500;
+ &:hover {
+ background: var(--theme-danger);
+ color: white;
+ }
}
-.workspace-meta {
- font-size: 0.85rem;
+.tab-new {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
color: var(--theme-fg-more);
+ cursor: pointer;
+ transition: color $transition-fast, background $transition-fast;
- .separator {
- margin: 0 6px;
- }
-
- .badge {
- font-size: 0.75rem;
- padding: 2px 6px;
- border-radius: 4px;
- background: var(--theme-primary);
- color: white;
+ &:hover {
+ background: var(--theme-bg-more-more);
+ color: var(--theme-primary);
}
}
-.workspace-actions {
- display: flex;
- gap: 4px;
+// Tab content
+.tab-content {
+ background: var(--theme-bg);
+ border: 1px solid var(--theme-border, $fallback-border);
+ border-top: none;
+ padding: $spacing-xl;
- .btn-link {
- padding: 8px;
+ &.empty-state {
+ text-align: center;
+ padding: $spacing-2xl;
color: var(--theme-fg-more);
- opacity: 0.7;
- transition: opacity 0.2s, color 0.2s;
-
- &:hover {
- opacity: 1;
- }
- &.open-btn:hover {
- color: var(--theme-success, #10b981);
+ p {
+ margin: $spacing-md 0;
}
- &.text-danger:hover {
- color: var(--theme-danger);
+ strong {
+ color: var(--theme-primary);
+ padding: 0 $spacing-xs;
}
}
}
-
-.workspace-empty {
- text-align: center;
- padding: 40px;
- color: var(--theme-fg-more);
-
- p {
- margin: 8px 0;
- }
-}
diff --git a/src/components/workspaceList.component.ts b/src/components/workspaceList.component.ts
index 36f3325..072a816 100644
--- a/src/components/workspaceList.component.ts
+++ b/src/components/workspaceList.component.ts
@@ -1,17 +1,23 @@
import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef, ElementRef, NgZone } from '@angular/core'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { ConfigService, ProfilesService } from 'tabby-core'
import { Subscription } from 'rxjs'
import { StartupCommandService } from '../services/startupCommand.service'
import { WorkspaceEditorService } from '../services/workspaceEditor.service'
+import { DeleteConfirmModalComponent } from './deleteConfirmModal.component'
import {
Workspace,
WorkspacePane,
WorkspaceSplit,
+ TabbyProfile,
countPanes,
createDefaultWorkspace,
+ deepClone,
isWorkspaceSplit,
} from '../models/workspace.model'
+const SETTINGS_MAX_WIDTH = '876px'
+
@Component({
selector: 'workspace-list',
template: require('./workspaceList.component.pug'),
@@ -23,6 +29,8 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
editingWorkspace: Workspace | null = null
isCreatingNew = false
openingWorkspaceId: string | null = null
+ displayTabs: Array<{ workspace: Workspace; isNew: boolean }> = []
+ private cachedProfiles: TabbyProfile[] = []
private configSubscription: Subscription | null = null
constructor(
@@ -30,16 +38,18 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
private workspaceService: WorkspaceEditorService,
private profilesService: ProfilesService,
private startupService: StartupCommandService,
+ private modalService: NgbModal,
private cdr: ChangeDetectorRef,
private elementRef: ElementRef,
private zone: NgZone
) {}
- ngOnInit(): void {
+ async ngOnInit(): Promise {
this.loadWorkspaces()
this.autoSelectFirst()
+ this.cachedProfiles = await this.workspaceService.getAvailableProfiles()
this.configSubscription = this.config.changed$.subscribe(() => {
- this.loadWorkspaces()
+ this.zone.run(() => this.loadWorkspaces())
})
}
@@ -48,7 +58,7 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
setTimeout(() => {
const parent = this.elementRef.nativeElement.closest('settings-tab-body') as HTMLElement
if (parent) {
- parent.style.maxWidth = '876px'
+ parent.style.maxWidth = SETTINGS_MAX_WIDTH
}
}, 0)
}
@@ -62,7 +72,8 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
selectWorkspace(workspace: Workspace): void {
this.isCreatingNew = false
this.selectedWorkspace = workspace
- this.editingWorkspace = JSON.parse(JSON.stringify(workspace))
+ this.editingWorkspace = deepClone(workspace)
+ this.updateDisplayTabs()
}
isSelected(workspace: Workspace): boolean {
@@ -74,18 +85,26 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
}
loadWorkspaces(): void {
+ const previousSelectedId = this.selectedWorkspace?.id
this.workspaces = this.workspaceService.getWorkspaces()
- this.cdr.detectChanges()
+
+ // Re-sync selectedWorkspace to point to object in new array
+ // This prevents stale reference after delete/reload operations
+ if (previousSelectedId) {
+ this.selectedWorkspace = this.workspaces.find(w => w.id === previousSelectedId) || null
+ }
+
+ this.updateDisplayTabs()
}
- async createWorkspace(): Promise {
- const profiles = await this.workspaceService.getAvailableProfiles()
- const defaultProfileId = profiles[0]?.id || ''
+ createWorkspace(): void {
+ const defaultProfileId = this.cachedProfiles[0]?.id || ''
const workspace = createDefaultWorkspace()
this.setProfileForAllPanes(workspace.root, defaultProfileId)
this.selectedWorkspace = null
this.editingWorkspace = workspace
this.isCreatingNew = true
+ this.updateDisplayTabs()
this.cdr.detectChanges()
}
@@ -105,33 +124,48 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
event.stopPropagation()
const clone = this.workspaceService.duplicateWorkspace(workspace)
await this.workspaceService.addWorkspace(clone)
- this.loadWorkspaces()
- // Select the duplicated workspace
- const duplicated = this.workspaces.find((w) => w.id === clone.id)
- if (duplicated) {
- this.selectWorkspace(duplicated)
- }
- this.cdr.detectChanges()
+ this.zone.run(() => {
+ this.loadWorkspaces()
+ const duplicated = this.workspaces.find((w) => w.id === clone.id)
+ if (duplicated) {
+ this.selectWorkspace(duplicated)
+ }
+ })
}
async deleteWorkspace(event: MouseEvent, workspace: Workspace): Promise {
event.stopPropagation()
- if (confirm(`Delete workspace "${workspace.name}"?`)) {
- const currentIndex = this.workspaces.findIndex((w) => w.id === workspace.id)
- await this.workspaceService.deleteWorkspace(workspace.id)
- this.loadWorkspaces()
- // Select next workspace after deletion
- if (this.workspaces.length > 0) {
- const nextIndex = Math.min(currentIndex, this.workspaces.length - 1)
- this.selectWorkspace(this.workspaces[nextIndex])
- } else {
+ const confirmed = await this.confirmDelete(workspace.name)
+ if (!confirmed) return
+
+ const wasSelected = this.selectedWorkspace?.id === workspace.id
+ const deletedIndex = this.workspaces.findIndex((w) => w.id === workspace.id)
+
+ await this.workspaceService.deleteWorkspace(workspace.id)
+
+ this.zone.run(() => {
+ this.loadWorkspaces()
+ if (this.workspaces.length === 0) {
this.selectedWorkspace = null
this.editingWorkspace = null
this.isCreatingNew = false
+ } else if (wasSelected) {
+ const nextIndex = Math.min(deletedIndex, this.workspaces.length - 1)
+ this.selectWorkspace(this.workspaces[nextIndex])
}
- this.cdr.detectChanges()
+ })
+ }
+
+ private async confirmDelete(name: string): Promise {
+ const modalRef = this.modalService.open(DeleteConfirmModalComponent)
+ modalRef.componentInstance.workspaceName = name
+ try {
+ await modalRef.result
+ return true
+ } catch {
+ return false
}
}
@@ -142,15 +176,16 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
} else {
await this.workspaceService.updateWorkspace(workspace)
}
- this.loadWorkspaces()
- this.isCreatingNew = false
- // Select the saved workspace
- const saved = this.workspaces.find((w) => w.id === workspace.id)
- if (saved) {
- this.selectWorkspace(saved)
- }
- this.cdr.detectChanges()
+ // Wrap state changes in zone.run to ensure proper change detection
+ this.zone.run(() => {
+ this.loadWorkspaces()
+ this.isCreatingNew = false
+ const saved = this.workspaces.find((w) => w.id === workspace.id)
+ if (saved) {
+ this.selectWorkspace(saved)
+ }
+ })
}
onEditorCancel(): void {
@@ -162,10 +197,11 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
} else {
this.selectedWorkspace = null
this.editingWorkspace = null
+ this.updateDisplayTabs()
}
} else if (this.selectedWorkspace) {
// Reset to original workspace data
- this.editingWorkspace = JSON.parse(JSON.stringify(this.selectedWorkspace))
+ this.editingWorkspace = deepClone(this.selectedWorkspace)
}
this.cdr.detectChanges()
}
@@ -183,6 +219,24 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
return JSON.stringify(this.editingWorkspace) !== JSON.stringify(this.selectedWorkspace)
}
+ // Update display tabs array (call after state changes)
+ private updateDisplayTabs(): void {
+ const tabs = this.workspaces.map(w => ({ workspace: w, isNew: false }))
+ if (this.isCreatingNew && this.editingWorkspace) {
+ tabs.push({ workspace: this.editingWorkspace, isNew: true })
+ }
+ this.displayTabs = tabs
+ }
+
+ isTabSelected(tab: { workspace: Workspace; isNew: boolean }): boolean {
+ if (tab.isNew) return true
+ return this.selectedWorkspace?.id === tab.workspace.id
+ }
+
+ trackByTab(index: number, tab: { workspace: Workspace; isNew: boolean }): string {
+ return tab.isNew ? '__new__' : tab.workspace.id
+ }
+
async openWorkspace(event: MouseEvent, workspace: Workspace): Promise {
event.stopPropagation()
if (this.openingWorkspaceId) return
@@ -203,4 +257,5 @@ export class WorkspaceListComponent implements OnInit, OnDestroy, AfterViewInit
this.cdr.detectChanges()
}
}
+
}
diff --git a/src/index.ts b/src/index.ts
index 607062c..3c62e6f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -9,11 +9,13 @@ import { WorkspaceEditorSettingsProvider } from './providers/settings.provider'
import { WorkspaceToolbarProvider } from './providers/toolbar.provider'
import { WorkspaceEditorService } from './services/workspaceEditor.service'
import { StartupCommandService } from './services/startupCommand.service'
+import { WorkspaceBackgroundService } from './services/workspaceBackground.service'
import { WorkspaceListComponent } from './components/workspaceList.component'
import { WorkspaceEditorComponent } from './components/workspaceEditor.component'
import { PaneEditorComponent } from './components/paneEditor.component'
import { SplitPreviewComponent } from './components/splitPreview.component'
+import { DeleteConfirmModalComponent } from './components/deleteConfirmModal.component'
@NgModule({
imports: [CommonModule, FormsModule],
@@ -23,12 +25,14 @@ import { SplitPreviewComponent } from './components/splitPreview.component'
{ provide: ToolbarButtonProvider, useClass: WorkspaceToolbarProvider, multi: true },
WorkspaceEditorService,
StartupCommandService,
+ WorkspaceBackgroundService,
],
declarations: [
WorkspaceListComponent,
WorkspaceEditorComponent,
PaneEditorComponent,
SplitPreviewComponent,
+ DeleteConfirmModalComponent,
],
})
export default class WorkspaceEditorModule {}
diff --git a/src/models/workspace.model.ts b/src/models/workspace.model.ts
index e4c5236..770cd03 100644
--- a/src/models/workspace.model.ts
+++ b/src/models/workspace.model.ts
@@ -38,6 +38,8 @@ export interface TabbyRecoveryToken {
tabCustomTitle?: string
disableDynamicTitle?: boolean
cwd?: string
+ // Allow custom properties (matches Tabby's RecoveryToken interface)
+ [key: string]: any
}
export interface TabbySplitLayoutProfile {
@@ -59,7 +61,6 @@ export interface WorkspacePane {
profileId: string
cwd?: string
startupCommand?: string
- title?: string
}
export interface WorkspaceSplit {
@@ -68,29 +69,71 @@ export interface WorkspaceSplit {
children: (WorkspacePane | WorkspaceSplit)[]
}
+export interface WorkspaceBackground {
+ type: 'none' | 'solid' | 'gradient' | 'image'
+ value: string // CSS value: hex, gradient string, or URL
+}
+
export interface Workspace {
id: string
name: string
icon?: string
color?: string
+ background?: WorkspaceBackground
root: WorkspaceSplit
launchOnStartup?: boolean
}
+// Preset backgrounds for quick selection
+export const BACKGROUND_PRESETS: WorkspaceBackground[] = [
+ { type: 'none', value: '' },
+ // Existing presets
+ { 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%)' },
+ { type: 'gradient', value: 'linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, transparent 50%)' },
+ { type: 'gradient', value: 'linear-gradient(45deg, rgba(239, 68, 68, 0.1) 0%, transparent 50%)' },
+ { type: 'gradient', value: 'linear-gradient(135deg, rgba(59, 130, 246, 0.15) 0%, transparent 50%)' },
+ { type: 'gradient', value: 'linear-gradient(225deg, transparent 70%, rgba(249, 115, 22, 0.15) 100%)' },
+ { type: 'gradient', value: 'linear-gradient(180deg, rgba(139, 92, 246, 0.1) 0%, transparent 40%)' },
+ // New presets
+ { type: 'gradient', value: 'linear-gradient(315deg, transparent 80%, rgba(236, 72, 153, 0.15) 100%)' }, // Pink bottom-right
+ { type: 'gradient', value: 'linear-gradient(0deg, rgba(6, 182, 212, 0.12) 0%, transparent 35%)' }, // Cyan bottom
+ { 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
+ { type: 'gradient', value: 'linear-gradient(160deg, rgba(34, 197, 94, 0.12) 0%, transparent 40%)' }, // Green top-left
+ { type: 'gradient', value: 'linear-gradient(200deg, transparent 75%, rgba(99, 102, 241, 0.18) 100%)' }, // Indigo bottom-left
+ { 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
+ { 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
+ { type: 'gradient', value: 'linear-gradient(180deg, transparent 60%, rgba(16, 185, 129, 0.12) 100%)' }, // Emerald bottom fade
+ { 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
+ { 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
+]
+
+/**
+ * Type guard to check if a node is a WorkspaceSplit.
+ * @param node - The node to check
+ * @returns True if the node is a WorkspaceSplit
+ */
export function isWorkspaceSplit(node: WorkspacePane | WorkspaceSplit): node is WorkspaceSplit {
return 'orientation' in node && 'children' in node
}
+/**
+ * Creates a new pane with default configuration.
+ * @returns A new WorkspacePane with generated UUID and empty settings
+ */
export function createDefaultPane(): WorkspacePane {
return {
id: generateUUID(),
profileId: '',
cwd: '',
startupCommand: '',
- title: '',
}
}
+/**
+ * Creates a new split with two default panes.
+ * @param orientation - Split direction ('horizontal' or 'vertical'), defaults to 'horizontal'
+ * @returns A new WorkspaceSplit with two panes at 50/50 ratio
+ */
export function createDefaultSplit(orientation: 'horizontal' | 'vertical' = 'horizontal'): WorkspaceSplit {
return {
orientation,
@@ -118,14 +161,21 @@ const WORKSPACE_ICONS = [
'bug', 'wrench', 'cube', 'layer-group', 'sitemap', 'project-diagram'
]
+/** Returns a random color from the workspace color palette. */
export function getRandomColor(): string {
return WORKSPACE_COLORS[Math.floor(Math.random() * WORKSPACE_COLORS.length)]
}
+/** Returns a random icon from the workspace icon set. */
export function getRandomIcon(): string {
return WORKSPACE_ICONS[Math.floor(Math.random() * WORKSPACE_ICONS.length)]
}
+/**
+ * Creates a new workspace with default configuration.
+ * @param name - Display name for the workspace (optional)
+ * @returns A new Workspace with generated UUID, random icon/color, and a default split
+ */
export function createDefaultWorkspace(name: string = ''): Workspace {
return {
id: generateUUID(),
@@ -137,6 +187,7 @@ export function createDefaultWorkspace(name: string = ''): Workspace {
}
}
+/** Generates a random UUID v4 string. */
export function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
@@ -145,9 +196,36 @@ export function generateUUID(): string {
})
}
+/**
+ * Recursively counts the total number of panes in a split tree.
+ * @param node - The root node to count from
+ * @returns Total number of panes in the tree
+ */
export function countPanes(node: WorkspacePane | WorkspaceSplit): number {
if (isWorkspaceSplit(node)) {
return node.children.reduce((sum, child) => sum + countPanes(child), 0)
}
return 1
}
+
+/**
+ * Creates a deep clone of an object, preserving type information.
+ * More efficient than JSON.parse(JSON.stringify()) for simple objects.
+ * @param obj - The object to clone
+ * @returns A deep copy of the object
+ */
+export function deepClone(obj: T): T {
+ if (obj === null || typeof obj !== 'object') {
+ return obj
+ }
+ if (Array.isArray(obj)) {
+ return obj.map(item => deepClone(item)) as T
+ }
+ const cloned = {} as T
+ for (const key in obj) {
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
+ cloned[key] = deepClone(obj[key])
+ }
+ }
+ return cloned
+}
diff --git a/src/providers/toolbar.provider.ts b/src/providers/toolbar.provider.ts
index 3c4fb5e..022af9a 100644
--- a/src/providers/toolbar.provider.ts
+++ b/src/providers/toolbar.provider.ts
@@ -1,7 +1,9 @@
import { Injectable } from '@angular/core'
-import { ToolbarButtonProvider, ToolbarButton, ProfilesService, AppService } from 'tabby-core'
+import { ToolbarButtonProvider, ToolbarButton, ProfilesService, AppService, SplitTabComponent } from 'tabby-core'
+import { BaseTerminalTabComponent } from 'tabby-terminal'
import { WorkspaceEditorService } from '../services/workspaceEditor.service'
import { StartupCommandService } from '../services/startupCommand.service'
+import { WorkspaceBackgroundService } from '../services/workspaceBackground.service'
import { SettingsTabComponent } from 'tabby-settings'
import { CONFIG_KEY, DISPLAY_NAME, IS_DEV } from '../build-config'
@@ -15,24 +17,51 @@ const ICON_GRID = ``
+
+const SELECTOR_SETTINGS_ID = '__settings__'
+
import { countPanes } from '../models/workspace.model'
+/** Recovery token structure for workspace tabs */
+interface RecoveryTokenWithWorkspace {
+ workspaceId?: string
+}
+
@Injectable()
export class WorkspaceToolbarProvider extends ToolbarButtonProvider {
constructor(
private workspaceService: WorkspaceEditorService,
private profilesService: ProfilesService,
private app: AppService,
- private startupService: StartupCommandService
+ private startupService: StartupCommandService,
+ private backgroundService: WorkspaceBackgroundService
) {
super()
- // Delay startup tasks to ensure Tabby config is loaded
- setTimeout(() => {
- // Cleanup orphaned profiles from previous plugin versions (one-time migration)
+ // Initialize background service to listen for tab events
+ this.backgroundService.initialize()
+
+ // Wait for Tabby to finish recovery before launching startup workspaces
+ this.waitForTabbyReady().then(() => {
this.workspaceService.cleanupOrphanedProfiles()
- // Launch workspaces marked for startup
this.launchStartupWorkspaces()
- }, 500)
+ })
+ }
+
+ private waitForTabbyReady(): Promise {
+ return new Promise(resolve => {
+ let lastTabCount = -1
+ const checkStable = () => {
+ const currentCount = this.app.tabs.length
+ if (currentCount === lastTabCount && currentCount >= 0) {
+ resolve()
+ } else {
+ lastTabCount = currentCount
+ setTimeout(checkStable, 300)
+ }
+ }
+ // Initial delay to let Tabby start loading
+ setTimeout(checkStable, 500)
+ })
}
private async launchStartupWorkspaces(): Promise {
@@ -40,10 +69,50 @@ export class WorkspaceToolbarProvider extends ToolbarButtonProvider {
const startupWorkspaces = workspaces.filter(w => w.launchOnStartup)
for (const workspace of startupWorkspaces) {
+ if (this.isWorkspaceAlreadyOpen(workspace.id)) {
+ console.log(`[TabbySpaces] Workspace "${workspace.name}" already open, skipping`)
+ continue
+ }
await this.openWorkspace(workspace.id)
}
}
+ /**
+ * Type-safe helper to extract workspace ID from tab's recovery token.
+ */
+ private getRecoveryWorkspaceId(tab: unknown): string | undefined {
+ if (tab && typeof tab === 'object' && 'recoveryToken' in tab) {
+ const token = (tab as { recoveryToken?: RecoveryTokenWithWorkspace }).recoveryToken
+ return token?.workspaceId
+ }
+ return undefined
+ }
+
+ private isWorkspaceAlreadyOpen(workspaceId: string): boolean {
+ const profilePrefix = `split-layout:${CONFIG_KEY}:`
+
+ for (const tab of this.app.tabs) {
+ if (tab instanceof SplitTabComponent) {
+ // Strategy 1: Check recoveryToken.workspaceId (for restored tabs)
+ if (this.getRecoveryWorkspaceId(tab) === workspaceId) {
+ return true
+ }
+
+ // Strategy 2: Check profile ID (for freshly opened tabs)
+ for (const child of tab.getAllTabs()) {
+ if (child instanceof BaseTerminalTabComponent) {
+ const profileId = child.profile?.id ?? ''
+ // Strict matching: prefix + workspaceId at the end
+ if (profileId.startsWith(profilePrefix) && profileId.endsWith(`:${workspaceId}`)) {
+ return true
+ }
+ }
+ }
+ }
+ }
+ return false
+ }
+
provide(): ToolbarButton[] {
return [
{
@@ -77,12 +146,12 @@ export class WorkspaceToolbarProvider extends ToolbarButtonProvider {
description: 'Create and edit workspaces',
icon: 'cog',
color: undefined,
- result: '__settings__'
+ result: SELECTOR_SETTINGS_ID
})
const selectedId = await this.app.showSelector('Select Workspace', options)
- if (selectedId === '__settings__') {
+ if (selectedId === SELECTOR_SETTINGS_ID) {
this.openSettings()
} else if (selectedId) {
this.openWorkspace(selectedId)
diff --git a/src/services/startupCommand.service.ts b/src/services/startupCommand.service.ts
index 9e69a9c..119b012 100644
--- a/src/services/startupCommand.service.ts
+++ b/src/services/startupCommand.service.ts
@@ -1,7 +1,8 @@
import { Injectable } from '@angular/core'
import { AppService, BaseTabComponent, SplitTabComponent } from 'tabby-core'
import { BaseTerminalTabComponent } from 'tabby-terminal'
-import { Subscription, first, timer, switchMap } from 'rxjs'
+import { first, timeout, of } from 'rxjs'
+import { catchError } from 'rxjs/operators'
export interface PendingCommand {
paneId: string
@@ -9,13 +10,21 @@ export interface PendingCommand {
originalTitle: string
}
+/**
+ * Handles startup commands for workspace panes.
+ *
+ * This service listens to tab open events and sends startup commands
+ * to terminals that match registered pane IDs.
+ *
+ * NOTE: This is a module-level singleton that lives for the app lifetime.
+ * The tabOpened$ subscription intentionally runs forever - no cleanup needed.
+ */
@Injectable()
export class StartupCommandService {
private pendingCommands: Map = new Map()
- private subscription: Subscription
constructor(private app: AppService) {
- this.subscription = this.app.tabOpened$.subscribe((tab) => this.onTabOpened(tab))
+ this.app.tabOpened$.subscribe((tab) => this.onTabOpened(tab))
}
registerCommands(commands: PendingCommand[]): void {
@@ -84,40 +93,27 @@ export class StartupCommandService {
console.log('[TabbySpaces] Command matched, waiting for shell output...:', fullCommand)
+ // Unified command sender - reduces duplication
+ const sendCommand = () => {
+ console.log('[TabbySpaces] Shell ready, sending command:', fullCommand)
+ terminalTab.sendInput(fullCommand + '\r')
+ this.clearProfileArgs(terminalTab)
+ this.setTabTitle(terminalTab, pending.originalTitle)
+ }
+
// Wait for shell to emit first output (prompt), then send command
if (terminalTab.session?.output$) {
terminalTab.session.output$.pipe(
- first(), // Wait for first output (shell prompt)
- switchMap(() => timer(100)) // Small buffer after prompt renders
+ first(),
+ timeout(2000), // Prevent infinite wait if shell doesn't emit
+ catchError(() => of(null)) // Fallback on timeout/error
).subscribe(() => {
- console.log('[TabbySpaces] Shell ready, sending command:', fullCommand)
- terminalTab.sendInput(fullCommand + '\r')
-
- // Clear profile args to prevent native splits from re-running command
- this.clearProfileArgs(terminalTab)
-
- // Reset title - either to original or clear for dynamic shell title
- if (pending.originalTitle) {
- terminalTab.setTitle(pending.originalTitle)
- } else {
- terminalTab.customTitle = ''
- }
+ // Small delay after prompt renders
+ setTimeout(sendCommand, 100)
})
} else {
console.log('[TabbySpaces] No session.output$, falling back to timeout')
- // Fallback if session not available yet
- setTimeout(() => {
- terminalTab.sendInput(fullCommand + '\r')
-
- // Clear profile args to prevent native splits from re-running command
- this.clearProfileArgs(terminalTab)
-
- if (pending.originalTitle) {
- terminalTab.setTitle(pending.originalTitle)
- } else {
- terminalTab.customTitle = ''
- }
- }, 500)
+ setTimeout(sendCommand, 500)
}
}
@@ -136,7 +132,9 @@ export class StartupCommandService {
}
}
- ngOnDestroy(): void {
- this.subscription?.unsubscribe()
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ private setTabTitle(terminalTab: BaseTerminalTabComponent, title: string): void {
+ terminalTab.setTitle(title)
+ terminalTab.customTitle = title
}
}
diff --git a/src/services/workspaceBackground.service.ts b/src/services/workspaceBackground.service.ts
new file mode 100644
index 0000000..d085e6c
--- /dev/null
+++ b/src/services/workspaceBackground.service.ts
@@ -0,0 +1,167 @@
+import { Injectable } from '@angular/core'
+import { AppService, SplitTabComponent } from 'tabby-core'
+import { WorkspaceEditorService } from './workspaceEditor.service'
+import { WorkspaceBackground } from '../models/workspace.model'
+import { CONFIG_KEY } from '../build-config'
+
+/**
+ * Service for applying custom backgrounds to workspace tabs.
+ * Injects CSS dynamically based on workspace configuration.
+ */
+@Injectable({ providedIn: 'root' })
+export class WorkspaceBackgroundService {
+ private styleElement: HTMLStyleElement | null = null
+ private appliedWorkspaces = new Map() // workspaceId -> CSS
+
+ constructor(
+ private app: AppService,
+ private workspaceService: WorkspaceEditorService
+ ) {}
+
+ /**
+ * Initialize the service by setting up tab event listeners.
+ * Must be called once during app initialization.
+ */
+ initialize(): void {
+ this.setupTabListeners()
+ }
+
+ private setupTabListeners(): void {
+ // Listen for tab open
+ this.app.tabOpened$.subscribe(tab => this.onTabOpened(tab))
+
+ // Listen for tab close - cleanup
+ this.app.tabClosed$.subscribe(tab => this.onTabClosed(tab))
+ }
+
+ private onTabOpened(tab: unknown): void {
+ if (!(tab instanceof SplitTabComponent)) return
+
+ // Small delay to let Angular finish rendering
+ setTimeout(() => {
+ const workspaceId = this.extractWorkspaceId(tab)
+ if (!workspaceId) return
+
+ const workspace = this.workspaceService.getWorkspaces()
+ .find(w => w.id === workspaceId)
+
+ if (workspace?.background && workspace.background.type !== 'none') {
+ this.applyBackground(workspaceId, workspace.background)
+ }
+ }, 200)
+ }
+
+ private onTabClosed(tab: unknown): void {
+ if (!(tab instanceof SplitTabComponent)) return
+
+ const workspaceId = this.extractWorkspaceId(tab)
+ if (workspaceId) {
+ this.removeBackground(workspaceId)
+ }
+ }
+
+ /**
+ * Extract workspace ID from a SplitTabComponent.
+ * Tries multiple strategies: _recoveredState and child profile ID.
+ */
+ private extractWorkspaceId(tab: SplitTabComponent): string | undefined {
+ const tabAny = tab as any
+
+ // Strategy 1: Check _recoveredState.workspaceId (for restored tabs)
+ if (tabAny._recoveredState?.workspaceId) {
+ return tabAny._recoveredState.workspaceId
+ }
+
+ // Strategy 2: Extract from child profile ID (for freshly opened tabs)
+ const profilePrefix = `split-layout:${CONFIG_KEY}:`
+ for (const child of tab.getAllTabs()) {
+ const profileId = (child as any).profile?.id ?? ''
+ if (profileId.startsWith(profilePrefix)) {
+ // Profile ID format: split-layout:CONFIG_KEY:name:UUID
+ const parts = profileId.split(':')
+ return parts[parts.length - 1]
+ }
+ }
+
+ return undefined
+ }
+
+ private applyBackground(workspaceId: string, bg: WorkspaceBackground): void {
+ // Mark split-tab element with data attribute
+ this.markSplitTabElement(workspaceId)
+
+ // Generate and inject CSS
+ const css = this.generateCSS(workspaceId, bg)
+ this.injectCSS(workspaceId, css)
+ }
+
+ private markSplitTabElement(workspaceId: string): void {
+ // Find split-tab that doesn't have a workspace-id yet
+ const splitTabs = document.querySelectorAll('split-tab')
+ for (let i = splitTabs.length - 1; i >= 0; i--) {
+ const splitTab = splitTabs[i]
+ if (!splitTab.hasAttribute('data-workspace-id')) {
+ splitTab.setAttribute('data-workspace-id', workspaceId)
+ break
+ }
+ }
+ }
+
+ private generateCSS(workspaceId: string, bg: WorkspaceBackground): string {
+ if (bg.type === 'none' || !bg.value) return ''
+
+ return `
+ split-tab[data-workspace-id="${workspaceId}"] {
+ background: ${bg.value} !important;
+ }
+ split-tab[data-workspace-id="${workspaceId}"] .xterm-viewport,
+ split-tab[data-workspace-id="${workspaceId}"] .xterm-screen {
+ background: transparent !important;
+ }
+ `
+ }
+
+ private injectCSS(workspaceId: string, css: string): void {
+ if (!this.styleElement) {
+ this.styleElement = document.createElement('style')
+ this.styleElement.id = 'tabbyspaces-backgrounds'
+ document.head.appendChild(this.styleElement)
+ }
+
+ this.appliedWorkspaces.set(workspaceId, css)
+ this.updateStyleElement()
+ }
+
+ private removeBackground(workspaceId: string): void {
+ this.appliedWorkspaces.delete(workspaceId)
+ this.updateStyleElement()
+ }
+
+ private updateStyleElement(): void {
+ if (this.styleElement) {
+ this.styleElement.textContent = Array.from(this.appliedWorkspaces.values()).join('\n')
+ }
+ }
+
+ /**
+ * Refresh background for a specific workspace.
+ * Call this when workspace background is updated in settings.
+ */
+ refreshWorkspaceBackground(workspaceId: string): void {
+ const workspace = this.workspaceService.getWorkspaces()
+ .find(w => w.id === workspaceId)
+
+ if (!workspace) {
+ this.removeBackground(workspaceId)
+ return
+ }
+
+ if (workspace.background && workspace.background.type !== 'none') {
+ const css = this.generateCSS(workspaceId, workspace.background)
+ this.appliedWorkspaces.set(workspaceId, css)
+ } else {
+ this.appliedWorkspaces.delete(workspaceId)
+ }
+ this.updateStyleElement()
+ }
+}
diff --git a/src/services/workspaceEditor.service.ts b/src/services/workspaceEditor.service.ts
index 8821d62..0f53424 100644
--- a/src/services/workspaceEditor.service.ts
+++ b/src/services/workspaceEditor.service.ts
@@ -6,6 +6,7 @@ import {
WorkspaceSplit,
isWorkspaceSplit,
generateUUID,
+ deepClone,
TabbyProfile,
TabbyRecoveryToken,
TabbySplitLayoutProfile,
@@ -15,7 +16,9 @@ import { PendingCommand } from './startupCommand.service'
@Injectable({ providedIn: 'root' })
export class WorkspaceEditorService {
- private cachedProfiles: TabbyProfile[] = []
+ private cachedProfiles: TabbyProfile[] | null = null
+ private cacheTimestamp: number = 0
+ private readonly CACHE_TTL = 30000 // 30 seconds
constructor(
private config: ConfigService,
@@ -23,49 +26,78 @@ export class WorkspaceEditorService {
private profilesService: ProfilesService
) {}
- private async cacheProfiles(): Promise {
- this.cachedProfiles = (await this.profilesService.getProfiles()) as TabbyProfile[]
+ private async getCachedProfiles(): Promise {
+ const now = Date.now()
+ if (!this.cachedProfiles || now - this.cacheTimestamp > this.CACHE_TTL) {
+ this.cachedProfiles = (await this.profilesService.getProfiles()) as TabbyProfile[]
+ this.cacheTimestamp = now
+ }
+ return this.cachedProfiles
}
+ /** Returns all saved workspaces from config. */
getWorkspaces(): Workspace[] {
return this.config.store?.[CONFIG_KEY]?.workspaces ?? []
}
- async saveWorkspaces(workspaces: Workspace[]): Promise {
+ /**
+ * Saves the workspace list to config.
+ * @throws Error if config store is not initialized
+ */
+ async saveWorkspaces(workspaces: Workspace[]): Promise {
if (!this.config.store?.[CONFIG_KEY]) {
- return false
+ throw new Error('Config store not initialized')
}
this.config.store[CONFIG_KEY].workspaces = workspaces
- return await this.saveConfig()
+ await this.saveConfig()
}
+ /** Adds a new workspace and shows notification. */
async addWorkspace(workspace: Workspace): Promise {
- const workspaces = this.getWorkspaces()
- workspaces.push(workspace)
- await this.saveWorkspaces(workspaces)
- this.notifications.info(`Workspace "${workspace.name}" created`)
+ try {
+ const workspaces = this.getWorkspaces()
+ workspaces.push(workspace)
+ await this.saveWorkspaces(workspaces)
+ this.notifications.info(`Workspace "${workspace.name}" created`)
+ } catch (error) {
+ this.notifications.error(`Failed to create workspace "${workspace.name}"`)
+ throw error
+ }
}
+ /** Updates an existing workspace by ID and shows notification. */
async updateWorkspace(workspace: Workspace): Promise {
- const workspaces = this.getWorkspaces()
- const index = workspaces.findIndex((w) => w.id === workspace.id)
- if (index !== -1) {
- workspaces[index] = workspace
- await this.saveWorkspaces(workspaces)
- this.notifications.info(`Workspace "${workspace.name}" updated`)
+ try {
+ const workspaces = this.getWorkspaces()
+ const index = workspaces.findIndex((w) => w.id === workspace.id)
+ if (index !== -1) {
+ workspaces[index] = workspace
+ await this.saveWorkspaces(workspaces)
+ this.notifications.info(`Workspace "${workspace.name}" updated`)
+ }
+ } catch (error) {
+ this.notifications.error(`Failed to update workspace "${workspace.name}"`)
+ throw error
}
}
+ /** Deletes a workspace by ID and shows notification. */
async deleteWorkspace(workspaceId: string): Promise {
const workspaces = this.getWorkspaces()
const workspace = workspaces.find((w) => w.id === workspaceId)
- const filtered = workspaces.filter((w) => w.id !== workspaceId)
- await this.saveWorkspaces(filtered)
- if (workspace) {
- this.notifications.info(`Workspace "${workspace.name}" deleted`)
+ try {
+ const filtered = workspaces.filter((w) => w.id !== workspaceId)
+ await this.saveWorkspaces(filtered)
+ if (workspace) {
+ this.notifications.info(`Workspace "${workspace.name}" deleted`)
+ }
+ } catch (error) {
+ this.notifications.error(`Failed to delete workspace "${workspace?.name || workspaceId}"`)
+ throw error
}
}
+ /** Returns all local shell profiles available for use in workspaces. */
async getAvailableProfiles(): Promise {
const allProfiles = await this.profilesService.getProfiles()
return allProfiles.filter(
@@ -93,8 +125,9 @@ export class WorkspaceEditorService {
}
}
+ /** Generates a Tabby split-layout profile from a workspace for opening. */
async generateTabbyProfile(workspace: Workspace): Promise {
- await this.cacheProfiles()
+ await this.getCachedProfiles()
const safeName = this.sanitizeForProfileId(workspace.name)
return {
id: `split-layout:${CONFIG_KEY}:${safeName}:${workspace.id}`,
@@ -105,26 +138,27 @@ export class WorkspaceEditorService {
color: workspace.color,
isBuiltin: false,
options: {
- recoveryToken: this.generateRecoveryToken(workspace.root),
+ recoveryToken: this.generateRecoveryToken(workspace.root, workspace.name, workspace.id),
},
}
}
- private generateRecoveryToken(split: WorkspaceSplit): TabbyRecoveryToken {
+ private generateRecoveryToken(split: WorkspaceSplit, workspaceName: string, workspaceId: string): TabbyRecoveryToken {
return {
type: 'app:split-tab',
orientation: split.orientation === 'horizontal' ? 'h' : 'v',
ratios: split.ratios,
+ workspaceId,
children: split.children.map((child) => {
if (isWorkspaceSplit(child)) {
- return this.generateRecoveryToken(child)
+ return this.generateRecoveryToken(child, workspaceName, workspaceId)
}
- return this.generatePaneToken(child)
+ return this.generatePaneToken(child, workspaceName, workspaceId)
}),
}
}
- private generatePaneToken(pane: WorkspacePane): TabbyRecoveryToken {
+ private generatePaneToken(pane: WorkspacePane, workspaceName: string, workspaceId: string): TabbyRecoveryToken {
const baseProfile = this.getProfileById(pane.profileId)
if (!baseProfile) {
@@ -162,7 +196,7 @@ export class WorkspaceEditorService {
options,
icon: baseProfile.icon || '',
color: baseProfile.color || '',
- disableDynamicTitle: false,
+ disableDynamicTitle: true,
weight: 0,
isBuiltin: false,
isTemplate: false,
@@ -170,22 +204,25 @@ export class WorkspaceEditorService {
behaviorOnSessionEnd: 'auto',
}
- // Use pane.id for matching in StartupCommandService
- // Original title will be restored after command execution
+ // tabTitle: workspace name (what user sees)
+ // tabCustomTitle: pane.id (for matching in StartupCommandService)
+ // workspaceId: for duplicate detection after Tabby recovery
const cwd = pane.cwd || baseProfile.options?.cwd || ''
return {
type: 'app:local-tab',
profile,
savedState: false,
- tabTitle: pane.id,
+ tabTitle: workspaceName,
tabCustomTitle: pane.id,
- disableDynamicTitle: false,
+ workspaceId,
+ disableDynamicTitle: true,
cwd,
}
}
+ /** Creates a deep copy of a workspace with new IDs. */
duplicateWorkspace(workspace: Workspace): Workspace {
- const clone = JSON.parse(JSON.stringify(workspace)) as Workspace
+ const clone = deepClone(workspace)
clone.id = generateUUID()
clone.name = `${workspace.name} (Copy)`
clone.launchOnStartup = false
@@ -221,40 +258,40 @@ export class WorkspaceEditorService {
if (found) return found
// Fallback: check cached profiles (includes built-ins)
- return this.cachedProfiles.find((p) => p.id === profileId && isLocalType(p.type))
+ return this.cachedProfiles?.find((p) => p.id === profileId && isLocalType(p.type))
}
+ /** Collects all startup commands from panes in a workspace. */
collectStartupCommands(workspace: Workspace): PendingCommand[] {
const commands: PendingCommand[] = []
- this.collectCommandsFromNode(workspace.root, commands)
+ this.collectCommandsFromNode(workspace.root, workspace.name, commands)
return commands
}
private collectCommandsFromNode(
node: WorkspacePane | WorkspaceSplit,
+ workspaceName: string,
commands: PendingCommand[]
): void {
if (isWorkspaceSplit(node)) {
for (const child of node.children) {
- this.collectCommandsFromNode(child, commands)
+ this.collectCommandsFromNode(child, workspaceName, commands)
}
} else if (node.startupCommand) {
commands.push({
paneId: node.id,
command: node.startupCommand,
- originalTitle: node.title || '',
+ originalTitle: workspaceName,
})
}
}
- private async saveConfig(): Promise {
+ private async saveConfig(): Promise {
try {
await this.config.save()
- return true
} catch (error) {
- this.notifications.error('Failed to save configuration')
console.error('TabbySpaces save error:', error)
- return false
+ throw error
}
}
}
diff --git a/src/styles/_index.scss b/src/styles/_index.scss
new file mode 100644
index 0000000..0311771
--- /dev/null
+++ b/src/styles/_index.scss
@@ -0,0 +1,3 @@
+// Main entry point for shared styles
+@forward 'variables';
+@forward 'mixins';
diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss
new file mode 100644
index 0000000..284b583
--- /dev/null
+++ b/src/styles/_mixins.scss
@@ -0,0 +1,180 @@
+@use 'variables' as *;
+
+// ======================
+// LAYOUT MIXINS
+// ======================
+
+@mixin flex-center {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+// ======================
+// FORM INPUT MIXIN
+// ======================
+
+@mixin form-input($bg: var(--theme-bg)) {
+ padding: $spacing-sm $spacing-md;
+ border-radius: $radius-sm;
+ border: 1px solid var(--theme-border, $fallback-border);
+ background: $bg;
+ color: var(--theme-fg);
+ font-size: $font-md;
+
+ &:focus {
+ outline: none;
+ border-color: var(--theme-primary);
+ }
+
+ &::placeholder {
+ color: var(--theme-fg-more, $fallback-fg-more);
+ }
+}
+
+// ======================
+// FORM LABEL MIXIN (S1: Uppercase compact)
+// ======================
+
+@mixin form-label {
+ display: block;
+ font-size: $font-xs;
+ color: var(--theme-fg-less);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: $spacing-xs;
+}
+
+// ======================
+// BUTTON MIXINS
+// ======================
+
+@mixin toolbar-btn {
+ padding: $spacing-sm $spacing-md;
+ background: var(--theme-bg);
+ border: 1px solid var(--theme-border, $fallback-border);
+ border-radius: $radius-sm;
+ color: var(--theme-fg);
+ cursor: pointer;
+ transition: all $transition-fast;
+ font-size: 0.85rem;
+
+ &:hover:not(:disabled) {
+ background: var(--theme-bg-more-more);
+ }
+
+ &:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+
+ &.danger {
+ color: var(--theme-danger, $color-danger);
+ }
+}
+
+@mixin btn-success {
+ background: $color-success;
+ border-color: $color-success;
+ color: white;
+
+ &:hover {
+ background: $color-success-hover;
+ border-color: $color-success-hover;
+ }
+}
+
+@mixin btn-base {
+ display: inline-flex;
+ align-items: center;
+ gap: $spacing-sm;
+ padding: $spacing-sm $spacing-lg;
+ font-size: $font-sm;
+ border-radius: $radius-sm;
+ cursor: pointer;
+ transition: all $transition-fast;
+}
+
+@mixin btn-ghost {
+ @include btn-base;
+ background: transparent;
+ border: 1px solid var(--theme-border, $fallback-border);
+ color: var(--theme-fg);
+
+ &:hover {
+ background: var(--theme-bg-more);
+ }
+}
+
+@mixin btn-primary {
+ @include btn-base;
+ background: var(--theme-primary);
+ border: 1px solid var(--theme-primary);
+ color: white;
+
+ &:hover {
+ filter: brightness(1.1);
+ }
+}
+
+@mixin icon-btn-sm($size: 24px) {
+ width: $size;
+ height: $size;
+ @include flex-center;
+ background: var(--theme-bg);
+ border: 1px solid var(--theme-border, $fallback-border);
+ border-radius: $radius-sm;
+ color: var(--theme-fg-more, $fallback-fg-more);
+ cursor: pointer;
+ font-size: 10px;
+ transition: all $transition-fast;
+
+ &:hover:not(:disabled) {
+ border-color: var(--theme-primary);
+ color: var(--theme-primary);
+ }
+
+ &.danger:hover:not(:disabled) {
+ border-color: var(--theme-danger);
+ color: var(--theme-danger);
+ }
+
+ &:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+}
+
+// ======================
+// OVERLAY/MODAL MIXINS
+// ======================
+
+@mixin full-overlay($z-index: $z-modal-overlay) {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: $z-index;
+}
+
+// ======================
+// TEXT UTILITIES
+// ======================
+
+@mixin text-ellipsis {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+// ======================
+// DROPDOWN/POPUP
+// ======================
+
+@mixin dropdown-panel {
+ background: var(--theme-bg-more);
+ border: 1px solid var(--theme-border, $fallback-border);
+ border-radius: $radius-md;
+ box-shadow: $shadow-dropdown;
+}
diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss
new file mode 100644
index 0000000..50d55a6
--- /dev/null
+++ b/src/styles/_variables.scss
@@ -0,0 +1,67 @@
+// ======================
+// SPACING SCALE (S1: Tight)
+// ======================
+$spacing-xs: 2px; // toolbar group gap, tiny margins
+$spacing-sm: 4px; // gaps, small padding
+$spacing-md: 6px; // default gaps, button padding
+$spacing-lg: 10px; // section gaps, form spacing
+$spacing-xl: 14px; // container padding, large gaps
+$spacing-2xl: 18px; // modal padding, section margins
+
+// ======================
+// BORDER RADIUS SCALE (S1: Sharp)
+// ======================
+$radius-xs: 2px; // tiny elements
+$radius-sm: 2px; // buttons, small elements
+$radius-md: 3px; // inputs, dropdowns
+$radius-lg: 4px; // cards, containers, modals
+
+// ======================
+// CUSTOM COLORS
+// ======================
+// These extend Tabby's --theme-* variables
+$color-success: #10b981;
+$color-success-hover: #059669;
+$color-warning: #f59e0b;
+$color-danger: #ef4444;
+
+// Fallback values for Tabby theme variables that may not be defined
+$fallback-border: rgba(255, 255, 255, 0.1);
+$fallback-fg-more: rgba(255, 255, 255, 0.3);
+
+// ======================
+// SHADOWS (S1: Flat - no shadows)
+// ======================
+$shadow-dropdown: none;
+$shadow-context-menu: none;
+$shadow-modal: none;
+$overlay-bg: rgba(0, 0, 0, 0.7);
+
+// ======================
+// PREVIEW COMPONENT
+// ======================
+$preview-height: 140px;
+$nested-split-bg: rgba(255, 255, 255, 0.02);
+$selected-pane-gradient-start: rgba(59, 130, 246, 0.08);
+$selected-pane-gradient-end: rgba(59, 130, 246, 0.04);
+
+// ======================
+// FONT SIZES (S1: Compact)
+// ======================
+$font-xs: 0.7rem; // 11px - sublabels, hints
+$font-sm: 0.8rem; // 13px - labels, secondary text
+$font-md: 0.85rem; // 14px - default body
+
+// ======================
+// Z-INDEX SCALE
+// ======================
+$z-dropdown: 100;
+$z-modal-overlay: 1100;
+$z-context-menu-overlay: 1200;
+$z-context-menu: 1201;
+
+// ======================
+// TRANSITIONS
+// ======================
+$transition-fast: 0.15s;
+$transition-normal: 0.2s;