From d42bf9c836e50d9a991f01af52897d897068a4f3 Mon Sep 17 00:00:00 2001 From: ruscher Date: Wed, 8 Apr 2026 02:11:12 -0300 Subject: [PATCH] feat: tab groups, live CWD tracking, and UX improvements (v1.8.8) - Add Chrome-style tab groups with color-coded chips and alphabetical naming (A-Z, 1A-1Z...) - Add /proc//cwd polling for real-time tab title updates (1s interval) - Add VTE_VERSION env var for OSC7 shell integration activation - Add full path tooltip on tab hover - Fix tab title reset to 'Local' on focus/click (fallback to last polled CWD) - Fix tab move auto-join for right side of last group tab - Fix tab resizing during move mode (use opacity instead of visibility) - Add tab group CSS styles and chip/collapse UI - Add group move, rename, ungroup, and context menu actions - Update README with tab groups and live directory tracking features --- README.md | 5 +- docs/chrome_tab_groups_reference.md | 165 +++++++ docs/tab_groups_plan.md | 317 +++++++++++++ src/ashyterm/data/styles/components.css | 16 +- src/ashyterm/data/styles/tab_groups.css | 55 +++ src/ashyterm/settings/config.py | 2 +- src/ashyterm/state/window_state.py | 43 +- src/ashyterm/terminal/manager.py | 61 ++- src/ashyterm/terminal/tab_groups.py | 213 +++++++++ src/ashyterm/terminal/tabs.py | 590 ++++++++++++++++++++++-- src/ashyterm/ui/window_ui.py | 5 +- src/ashyterm/utils/platform.py | 11 + src/ashyterm/window_actions.py | 15 + tests/test_tab_groups.py | 151 ++++++ 14 files changed, 1591 insertions(+), 58 deletions(-) create mode 100644 docs/chrome_tab_groups_reference.md create mode 100644 docs/tab_groups_plan.md create mode 100644 src/ashyterm/data/styles/tab_groups.css create mode 100644 src/ashyterm/terminal/tab_groups.py create mode 100644 tests/test_tab_groups.py diff --git a/README.md b/README.md index 9d2d39cb..aaa45b14 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Ashy Terminal

- Version + Version BigCommunity Platform @@ -89,7 +89,8 @@ This gives you a clearer, more readable view of command output—especially in e ### 🖥️ Core Terminal Functionality - **Session Management**: Save, organize (with folders), and launch Local, SSH, and SFTP sessions. - **Flexible Layouts**: Split panes horizontally and vertically; save and restore complex window layouts. -- **Directory Tracking**: Updates tab titles automatically based on the current working directory (OSC7 support). +- **Tab Groups**: Chrome-style tab grouping with color-coded chips, drag-to-reorder, collapse/expand, and auto-join. Group names follow alphabetical sequence (A, B, ..., Z, 1A, 1B, ...). +- **Live Directory Tracking**: Tab titles update in real-time to show the current working directory via `/proc//cwd` polling, with full path tooltip on hover. - **Deep Customization**: Visual theme editor, font sizing, transparency (window and headerbar), and extensive keyboard shortcuts. diff --git a/docs/chrome_tab_groups_reference.md b/docs/chrome_tab_groups_reference.md new file mode 100644 index 00000000..9a498b07 --- /dev/null +++ b/docs/chrome_tab_groups_reference.md @@ -0,0 +1,165 @@ +# Referência: Como o Google Chrome Agrupa Abas (Tab Groups) + +> **Fonte:** blog.google, suporte oficial Chrome, observação direta do comportamento +> **Data:** 2026-04-07 + +--- + +## 1. Criação de Grupos + +### Como criar +- **Clique direito** em qualquer aba → opção **"Adicionar guia ao novo grupo"** +- Também disponível: **"Adicionar guia ao grupo existente"** → mostra lista dos grupos abertos +- Ao criar, o grupo recebe automaticamente uma **cor** da paleta e o cursor foca no **campo de nome** do label + +### Seleção múltipla +- `Ctrl+Click` em várias abas → clique direito → **"Adicionar guias ao novo grupo"** +- `Shift+Click` seleciona intervalo contíguo de abas + +--- + +## 2. Visual do Grupo + +### Label (Chip) do grupo +- É um **retângulo arredondado** com **fundo na cor do grupo** +- Fica **à esquerda** das abas do grupo, na **mesma linha da barra de abas** +- Contém o **nome do grupo** (texto) — pode ser texto, emoji, ou vazio +- Se o nome estiver vazio, o chip é apenas um **círculo/pílula** colorido sem texto (um "dot") +- **Altura** do chip é a mesma das abas — alinhamento vertical perfeito + +### Abas agrupadas +- As abas do grupo ficam **contíguas** (sem espaço entre elas) logo após o chip +- Cada aba do grupo tem uma **barra/underline colorida na parte inferior** na cor do grupo +- A underline tem ~3px de espessura e é **contínua** entre as abas (parece uma linha só) +- O restante da aba mantém aparência normal (mesma altura, ícone, título, botão fechar) +- Abas agrupadas **não** têm fundo colorido — apenas a underline inferior + +### Espaçamento +- **Entre chip e primeira aba do grupo:** 0px (grudados) +- **Entre abas do mesmo grupo:** 0px (grudadas, underline contínua) +- **Entre última aba do grupo e próxima aba/grupo:** espaçamento normal (4-6px) + +--- + +## 3. Interações + +### Clique no chip +- **Clique esquerdo** no chip → **colapsa/expande** o grupo + - **Colapsado:** todas as abas do grupo ficam ocultas, só o chip aparece + - **Expandido:** as abas voltam a aparecer à direita do chip + - Transição suave (animação de slide) + +### Clique direito no chip +- Menu de contexto com: + - **"Novo grupo de guias neste grupo"** (abre nova aba já no grupo) + - **"Desagrupar"** (remove agrupamento, abas ficam soltas) + - **"Fechar grupo"** (fecha TODAS as abas do grupo) + - **Paleta de cores** (8 cores disponíveis para trocar) + - **Campo de nome** editável inline + +### Arrastar (Drag & Drop) +- Arrastar uma aba para **dentro da área do grupo** (entre chip e última aba) → adiciona ao grupo +- Arrastar uma aba para **fora do grupo** → remove do grupo (fica solta) +- Arrastar o **chip** → move o grupo inteiro (todas as abas se movem junto) +- Arrastar entre abas de um mesmo grupo → reordena dentro do grupo + +### Clique direito em aba agrupada +- Menu normal da aba + opcão **"Remover do grupo"** +- Também: **"Mover guia para outro grupo"** → lista grupos existentes + +--- + +## 4. Cores da Paleta + +O Chrome oferece **8 cores** fixas para grupos: + +| Nome | Cor aproximada | +|----------|---------------| +| Grey | #5F6368 | +| Blue | #1A73E8 | +| Red | #D93025 | +| Yellow | #F9AB00 | +| Green | #188038 | +| Pink | #D01884 | +| Purple | #7627BB | +| Cyan | #007B83 | + +- A primeira cor é atribuída automaticamente +- As cores **rotacionam** — próximo grupo pega a próxima cor da paleta +- O usuário pode trocar a cor a qualquer momento pelo menu do chip + +--- + +## 5. Colapsar/Expandir + +- Clicar no chip **colapsa** → abas desaparecem, chip vira "pílula" compacta +- Clicar de novo **expande** → abas reaparecem +- Quando colapsado: + - O chip mantém a cor e o nome + - Tooltip mostra quantas abas estão no grupo + - O espaço das abas é liberado na barra de abas + +--- + +## 6. Persistência + +- Grupos são **salvos** quando o Chrome é fechado +- Ao reabrir, os grupos são restaurados com: + - Nome, cor, estado (colapsado/expandido) + - Ordem das abas dentro do grupo + - Posição do grupo na barra + +--- + +## 7. Comportamentos Especiais + +- **Nova aba** aberta por link de uma aba agrupada → automaticamente adicionada ao mesmo grupo +- **Arrastar aba para fora da janela** → cria nova janela, aba sai do grupo +- **Fechar última aba** de um grupo → grupo é automaticamente deletado +- **Renomear** → clique direto no texto do chip ou via menu de contexto +- **Undo** (Ctrl+Z) após fechar grupo → restaura o grupo inteiro + +--- + +## 8. Resumo do Layout Visual + +``` +Barra de Abas: +┌─────────┬──────────┬──────────┬───┬──────────────┬──────────┬──────────┐ +│ ■ Dev │ Tab A │ Tab B │ │ ■ Prod │ Tab D │ Tab F │ +│ (azul) │ ________ │ ________ │ │ (vermelho) │ ________ │ (solta) │ +│ chip │ underline│ underline│ │ chip │ underline│ │ +└─────────┴──────────┴──────────┘ └──────────────┴──────────┘──────────┘ + grupo 1 (expandido) grupo 2 (expandido) sem grupo + +Grupo colapsado: +┌─────────┬───┬──────────────┬──────────┬──────────┐ +│ ■ Dev │ │ ■ Prod │ Tab D │ Tab F │ +│ (azul) │ │ (vermelho) │ ________ │ (solta) │ +│ pílula │ │ chip │ underline│ │ +└─────────┘ └──────────────┴──────────┘──────────┘ + grupo 1 grupo 2 sem grupo + (colapsado) (expandido) +``` + +--- + +## 9. O Que Implementar no AshyTerm + +### Prioridade Alta (Chrome Core) +1. Chip com fundo colorido, nome editável, click para colapsar/expandir +2. Underline colorida contínua nas abas agrupadas (3px, mesma cor do grupo) +3. Menu de contexto do chip: renomear, trocar cor, desagrupar, fechar grupo +4. Menu de contexto da aba: "Novo grupo", "Adicionar ao grupo X", "Remover do grupo" +5. Colapso: chip vira pílula, abas somem; expandir restaura +6. Persistência: salvar/restaurar grupos com estado + +### Prioridade Média (UX refinado) +7. Seleção múltipla de abas (Ctrl+Click) para agrupar várias de uma vez +8. Drag & drop: arrastar aba para dentro/fora do grupo +9. Animação suave de colapso/expansão +10. Atalho de teclado para criar grupo (Ctrl+Shift+G já implementado) + +### Prioridade Baixa (Nice to have) +11. Tooltip com contagem de abas no chip colapsado +12. Renomear inline direto no chip (clique duplo) diff --git a/docs/tab_groups_plan.md b/docs/tab_groups_plan.md new file mode 100644 index 00000000..debb96b4 --- /dev/null +++ b/docs/tab_groups_plan.md @@ -0,0 +1,317 @@ +# Planejamento: Sistema de Grupos de Abas (Tab Groups) — AshyTerm + +> **Status:** Planejamento +> **Data:** 2026-03-30 +> **Autor:** Copilot + Ruscher + +--- + +## 1. Resumo Executivo + +Implementar grupos de abas nomeados e coloridos, similares aos de navegadores modernos (Chrome/Edge), permitindo que o usuário organize terminais logicamente. Os grupos devem ser persistentes entre sessões. + +--- + +## 2. Arquitetura Atual (Resumo) + +### Componentes-chave envolvidos + +| Arquivo | Responsabilidade | +|---|---| +| `terminal/tabs.py` → `TabManager` | Gerencia abas, `ViewStack`, tab bar custom (`Gtk.Box` horizontal), criação/fechamento, DnD manual, menu de contexto | +| `terminal/manager.py` → `TerminalManager` | Ciclo de vida de terminais (local, SSH, SFTP), eventos, settings | +| `terminal/registry.py` → `TerminalRegistry` | Registro central de terminais ativos com metadados | +| `terminal/pane_manager.py` → `PaneManager` | Splits horizontais/verticais dentro de uma aba | +| `state/window_state.py` → `WindowStateManager` | Serialização/restauração do layout (`session_state.json`) | +| `sessions/models.py` → `SessionItem` | Modelo GObject de sessão (nome, host, cor, etc.) | +| `window.py` → `CommTerminalWindow` | Janela principal, ações, atalhos, integração geral | +| `data/styles/window.css` | Estilos das abas (`.custom-tab-button`, etc.) | + +### Estrutura atual da tab bar + +``` +CommTerminalWindow + └─ scrolled_tab_bar (Gtk.ScrolledWindow) + └─ tab_bar_box (Gtk.Box horizontal, spacing=4) + ├─ tab_widget_1 (Gtk.Box .custom-tab-button) + ├─ tab_widget_2 (Gtk.Box .custom-tab-button) + └─ ... +``` + +- Cada `tab_widget` é um `Gtk.Box` com ícone + label + botão fechar. +- `TabManager.tabs` → `List[Gtk.Box]` (ordem visual). +- `TabManager.pages` → `WeakKeyDictionary[Gtk.Box, Adw.ViewStackPage]`. +- `TabManager.view_stack` → `Adw.ViewStack` contém o conteúdo de cada aba. +- Não existe conceito de agrupamento — tudo é lista plana. + +### Persistência atual (`session_state.json`) + +```json +{ + "_schema_version": 1, + "tabs": [ + { "type": "terminal", "session_type": "local", "session_name": "Local", "working_dir": "/home/user" }, + { "type": "paned", "orientation": "horizontal", "start_child": {...}, "end_child": {...} } + ] +} +``` + +--- + +## 3. Design Proposto + +### 3.1 Modelo de Dados + +```python +# Novo arquivo: src/ashyterm/terminal/tab_groups.py + +@dataclass +class TabGroup: + id: str # UUID + name: str # Nome editável pelo usuário + color: str # Cor CSS (ex: "#f28b82") + is_collapsed: bool = False # Grupo recolhido? + tab_ids: list[str] = field(default_factory=list) # IDs de tab_widgets ordenados +``` + +### 3.2 Estrutura Visual Proposta + +``` +tab_bar_box (Gtk.Box horizontal) + ├─ [group_chip_A] (Gtk.Box .tab-group-chip) ← clicável: expande/recolhe + │ ├─ color_dot (Gtk.DrawingArea) + │ ├─ label "Servidores" + │ └─ chevron_icon (expand/collapse) + ├─ tab_widget_1 (Gtk.Box .custom-tab-button .in-group .group-A) + ├─ tab_widget_2 (Gtk.Box .custom-tab-button .in-group .group-A) + ├─ [group_chip_B] + │ └─ ... + ├─ tab_widget_5 (Gtk.Box .custom-tab-button .in-group .group-B) + ├─ tab_widget_6 (Gtk.Box .custom-tab-button) ← sem grupo + └─ ... +``` + +**Decisão arquitetural:** Em vez de `Gtk.Notebook` aninhado (complexo, conflita com `ViewStack`), manter a **tab bar plana** e inserir **"chips"** de grupo como widgets separados no `tab_bar_box`. Os chips atuam como cabeçalhos visuais. Abas do grupo ficam contíguas após o chip. Isso minimiza a refatoração e mantém o `Adw.ViewStack` inalterado. + +### 3.3 Persistência (Schema v2) + +```json +{ + "_schema_version": 2, + "groups": [ + { + "id": "uuid-1", + "name": "Servidores", + "color": "#f28b82", + "is_collapsed": false + } + ], + "tabs": [ + { + "type": "terminal", + "session_type": "ssh", + "session_name": "prod-server", + "working_dir": null, + "group_id": "uuid-1" + }, + { + "type": "terminal", + "session_type": "local", + "session_name": "Local", + "working_dir": "/home/user", + "group_id": null + } + ], + "active_tab_id": null +} +``` + +- **Migração v1→v2:** Se `groups` ausente, tratar todos os tabs como `group_id: null`. +- Campo `group_id` (nullable) adicionado a cada tab serializado. +- Lista `groups` no top-level define os metadados de cada grupo. + +--- + +## 4. Arquivos a Criar/Modificar + +### Novos + +| Arquivo | Conteúdo | +|---|---| +| `src/ashyterm/terminal/tab_groups.py` | `TabGroup` (dataclass), `TabGroupManager` (CRUD de grupos, estado, serialização) | +| `src/ashyterm/data/styles/tab_groups.css` | Estilos do chip de grupo, borda colorida, indicador de cor | + +### Modificados + +| Arquivo | Mudanças | +|---|---| +| `terminal/tabs.py` (`TabManager`) | Integrar `TabGroupManager`, renderizar chips no `tab_bar_box`, estender menu de contexto, lógica de colapsar/expandir, reordenação com grupos | +| `state/window_state.py` (`WindowStateManager`) | Schema v2, serializar `group_id` por aba, serializar lista de grupos, migração v1→v2 | +| `window.py` (`CommTerminalWindow`) | Registrar novos atalhos de teclado, ações GAction para grupos | +| `data/styles/window.css` | Classe `.in-group` para abas agrupadas (borda inferior colorida) | +| `settings/config.py` | (Opcional) Setting para habilitar/desabilitar grupos | + +--- + +## 5. Fases de Implementação + +### Fase 1 — Modelo e CRUD (sem UI) +**Objetivo:** Criar e testar a camada de dados isoladamente. + +- [ ] Criar `tab_groups.py` com `TabGroup` (dataclass) e `TabGroupManager` + - Métodos: `create_group()`, `delete_group()`, `rename_group()`, `set_color()`, `add_tab()`, `remove_tab()`, `toggle_collapsed()`, `get_group_for_tab()`, `reorder_tab_in_group()`, `to_dict()`, `from_dict()` +- [ ] Testes unitários para `TabGroupManager` +- [ ] Estender `WindowStateManager` com schema v2 + migração v1→v2 +- [ ] Testes para migração de schema + +### Fase 2 — Widget Chip de Grupo + Renderização +**Objetivo:** Mostrar chips de grupo na tab bar e agrupar abas visualmente. + +- [ ] Criar widget `_create_group_chip(group: TabGroup) -> Gtk.Box` + - Cor (dot ou borda), nome (label editável inline), chevron expandir/colapsar +- [ ] Criar CSS `tab_groups.css` com estilos para `.tab-group-chip`, `.in-group`, `.group-collapsed` +- [ ] Método `_rebuild_tab_bar_with_groups()` em `TabManager`: + - Remove todos os filhos de `tab_bar_box` + - Insere chips e abas na ordem correta (grupos primeiro com suas abas, depois não agrupadas) + - Usa `group.is_collapsed` para esconder/mostrar abas do grupo +- [ ] Conectar chip click → `toggle_collapsed()` → rebuild + +### Fase 3 — Menu de Contexto e Ações +**Objetivo:** Permitir gestão de grupos via menu de contexto. + +- [ ] Estender `_on_tab_right_click` com seção "Grupo": + - "Adicionar ao Novo Grupo" → cria grupo com a aba selecionada + - "Mover para Grupo ▶" → submenu listando grupos existentes + - "Remover do Grupo" → desagrupa a aba (visível apenas se aba está num grupo) +- [ ] Click direito no chip do grupo: + - "Renomear Grupo" + - "Cor do Grupo…" + - "Desagrupar Todas" + - "Fechar Grupo" + - "Mover Grupo para Nova Janela" (se `on_detach_tab_requested` suportar multiplos) +- [ ] Criar `GAction`s para cada operação de grupo em `CommTerminalWindow._setup_actions()` + +### Fase 4 — Atalhos de Teclado +**Objetivo:** Atalhos para ações frequentes. + +| Atalho | Ação | +|---|---| +| `Ctrl+Shift+G` | Adicionar aba atual a novo grupo | +| `Ctrl+Shift+U` | Remover aba atual do grupo | +| `Alt+Shift+Left/Right` | Mover aba entre grupos / reordenar dentro do grupo | + +- [ ] Registrar em `_setup_keyboard_shortcuts()` +- [ ] Testar conflitos com atalhos existentes + +### Fase 5 — Drag and Drop entre Grupos +**Objetivo:** Mover abas entre grupos via arrastar-e-soltar. + +- [ ] Estender a lógica de move existente (`_start_tab_move`, `_perform_tab_move`): + - Ao soltar sobre um chip de grupo → adicionar ao grupo + - Ao soltar entre abas de outro grupo → mover para aquele grupo na posição correta + - Ao soltar fora de qualquer grupo → desagrupar +- [ ] Visual feedback durante drag: highlight do chip/grupo destino +- [ ] **Nota:** DnD manual via `_tab_being_moved` já existe. Estender, não reescrever. + +### Fase 6 — Persistência e Restauração +**Objetivo:** Grupos sobrevivem ao reinício. + +- [ ] `save_session_state()`: incluir `group_id` em cada tab + lista `groups` +- [ ] `restore_session_state()`: recriar `TabGroupManager` a partir do state, recriar abas com grupo +- [ ] Testar cenários: + - Salvar com grupos → restaurar → layout idêntico + - State v1 (sem grupos) → migrar → abrir sem erros + - Grupo com aba SSH que falha ao reconectar + +### Fase 7 — Polish e Edge Cases +**Objetivo:** Robustecer e polir. + +- [ ] Fechar última aba de um grupo → deletar grupo automaticamente +- [ ] Criar aba nova → herdar grupo da aba ativa (configurável) +- [ ] Split terminal → manter no mesmo grupo da aba pai +- [ ] Duplicar aba → manter no mesmo grupo +- [ ] Detach tab → remover do grupo +- [ ] Performance com 50+ abas: garantir que rebuild é O(n) e não acumula widgets +- [ ] Acessibilidade: labels ARIA nos chips, navegação por teclado nos grupos + +--- + +## 6. Considerações Técnicas + +### ViewStack e Grupos +O `Adw.ViewStack` permanece inalterado — ele gerencia o conteúdo (area do terminal). Os grupos afetam apenas a **tab bar visual** (`tab_bar_box`). Isso é fundamental para minimizar o impacto. + +### Armazenamento do `group_id` na tab +Cada `tab_widget` (Gtk.Box) já recebe atributos custom (`label_widget`, `session_item`, etc.). Adicionaremos `tab_widget.group_id: Optional[str]`. + +### CSS Approach +```css +/* Chip do grupo */ +.tab-group-chip { + padding: 2px 8px; + border-radius: 12px; + font-size: 0.85em; + font-weight: 600; + margin-right: 2px; +} + +/* Aba dentro de um grupo — borda inferior colorida */ +.custom-tab-button.in-group { + border-bottom: 2px solid var(--group-color); + border-radius: 10px 10px 4px 4px; +} + +/* Grupo recolhido — esconder abas filhas */ +/* Controlado programaticamente via widget.set_visible(False) */ +``` + +### Wayland/X11 +O DnD atual já foi adaptado para Wayland (motion controllers desabilitados por freezes). O sistema de move manual via click é compatível. A extensão para grupos segue o mesmo padrão. + +--- + +## 7. Riscos e Mitigações + +| Risco | Impacto | Mitigação | +|---|---|---| +| Rebuild frequente da tab bar causa flicker | Médio | Usar `tab_bar_box.freeze_child_notify()` / batch updates | +| Conflito de DnD com lógica existente | Alto | Estender `_perform_tab_move`, não substituir | +| Schema migration corrompe state | Alto | Backup automático antes de migrar; fallback para v1 | +| Label editável inline no chip causa bugs de foco | Baixo | Usar dialog (Adw.MessageDialog) para renomear, não inline editing | +| Performance com muitos grupos | Baixo | Lazy rebuild; grupos colapsados = widgets hidden, não destruídos | + +--- + +## 8. Testes Necessários + +| Teste | Tipo | Fase | +|---|---|---| +| CRUD de TabGroup/TabGroupManager | Unitário | 1 | +| Serialização/deserialização de grupos | Unitário | 1 | +| Migração schema v1→v2 | Unitário | 1 | +| Rebuild visual da tab bar com grupos | Integração | 2 | +| Menu de contexto com opções de grupo | Manual/UI | 3 | +| Persistência entre sessões | Integração | 6 | +| Fechar grupo → fechar todas as abas | Integração | 3 | +| Edge case: último tab do grupo | Unitário | 7 | + +--- + +## 9. Dependências + +- Nenhuma dependência nova de pacote. +- Usa apenas GTK4, libadwaita, Python stdlib (`uuid`, `dataclasses`, `json`). + +--- + +## 10. Definição de Pronto (DoD) + +- [ ] Grupos podem ser criados, renomeados, recoloridos e deletados +- [ ] Abas podem ser movidas entre grupos e desagrupadas +- [ ] Grupos podem ser expandidos/recolhidos +- [ ] Layout de grupos persiste entre sessões +- [ ] Migração automática de sessões existentes (v1→v2) +- [ ] Atalhos de teclado funcionais +- [ ] CSS integrado ao tema Adwaita +- [ ] Todos os testes passando +- [ ] Sem regressão em abas sem grupo (comportamento padrão inalterado) diff --git a/src/ashyterm/data/styles/components.css b/src/ashyterm/data/styles/components.css index c8800685..b7688283 100644 --- a/src/ashyterm/data/styles/components.css +++ b/src/ashyterm/data/styles/components.css @@ -108,15 +108,15 @@ .tab-moving { background-image: none; - background-color: @accent_bg_color; + background-color: alpha(@accent_bg_color, 0.4); border-radius: 6px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); - border: 2px dashed @theme_fg_color; - opacity: 0.85; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + border: 2px dashed @accent_bg_color; + opacity: 0.5; } .tab-bar-move-mode { - background-color: alpha(@accent_bg_color, 0.15); + background-color: alpha(@accent_bg_color, 0.08); border-radius: 8px; padding: 2px; } @@ -128,15 +128,17 @@ } .tab-drop-left { - background-color: alpha(@theme_fg_color, 0.2); + background-color: alpha(@accent_bg_color, 0.15); border-radius: 6px; box-shadow: inset 4px 0 0 0 @accent_bg_color; + transition: background-color 150ms ease, box-shadow 150ms ease; } .tab-drop-right { - background-color: alpha(@theme_fg_color, 0.2); + background-color: alpha(@accent_bg_color, 0.15); border-radius: 6px; box-shadow: inset -4px 0 0 0 @accent_bg_color; + transition: background-color 150ms ease, box-shadow 150ms ease; } /* ========================================================================== diff --git a/src/ashyterm/data/styles/tab_groups.css b/src/ashyterm/data/styles/tab_groups.css new file mode 100644 index 00000000..1476950b --- /dev/null +++ b/src/ashyterm/data/styles/tab_groups.css @@ -0,0 +1,55 @@ +/* ── Tab group chip (Chrome-style group label) ───────────── */ +.tab-group-chip { + padding: 4px 10px; + border-radius: 8px 8px 0 0; + font-size: 0.82em; + font-weight: 600; + margin: 0; + margin-top: 2px; + min-height: 22px; + transition: background 150ms ease, opacity 150ms ease; +} + +.tab-group-chip:hover { + filter: brightness(1.1); +} + +.tab-group-chip .group-name-label { + margin: 0 4px; + color: inherit; +} + +/* ── Unnamed chip: show as small colored dot/circle ────── */ +.tab-group-chip.unnamed { + min-width: 12px; + min-height: 12px; + padding: 6px; + border-radius: 50%; +} + +/* ── Collapsed chip: pílula compacta ───────────────────────── */ +.tab-group-chip.collapsed { + border-radius: 8px; + opacity: 0.9; + padding: 4px 8px; +} + +.tab-group-chip.collapsed.unnamed { + border-radius: 50%; + padding: 6px; +} + +/* ── Grouped tabs: Chrome-style colored underline ──────────── */ +.custom-tab-button.in-group { + border-bottom-width: 3px; + border-bottom-style: solid; + border-radius: 8px 8px 0 0; + margin-bottom: 0; + padding-bottom: 2px; +} + +/* Reduce spacing between grouped tabs for "connected" feel */ +.tab-group-chip + .custom-tab-button.in-group, +.custom-tab-button.in-group + .custom-tab-button.in-group { + margin-left: 0; +} diff --git a/src/ashyterm/settings/config.py b/src/ashyterm/settings/config.py index 312bcee8..1dccbdec 100644 --- a/src/ashyterm/settings/config.py +++ b/src/ashyterm/settings/config.py @@ -45,7 +45,7 @@ class AppConstants: APP_ID = "org.communitybig.ashyterm" APP_TITLE = "Ashy Terminal" - APP_VERSION = "1.8.7" + APP_VERSION = "1.8.8" DEVELOPER_NAME = "BigCommunity" DEVELOPER_TEAM = ["BigCommunity Team"] COPYRIGHT = "© 2025 BigCommunity" diff --git a/src/ashyterm/state/window_state.py b/src/ashyterm/state/window_state.py index c6968f3d..38345701 100644 --- a/src/ashyterm/state/window_state.py +++ b/src/ashyterm/state/window_state.py @@ -30,8 +30,19 @@ class WindowStateManager: splits, and user-defined layouts. """ - SCHEMA_VERSION = 1 - MIGRATIONS: dict[int, Callable[[dict[str, Any]], dict[str, Any]]] = {} + SCHEMA_VERSION = 2 + + @staticmethod + def _migrate_v1_to_v2(data: dict[str, Any]) -> dict[str, Any]: + """Add empty groups list and null group_id to each tab.""" + data.setdefault("groups", []) + for tab in data.get("tabs", []): + tab.setdefault("group_id", None) + return data + + MIGRATIONS: dict[int, Callable[[dict[str, Any]], dict[str, Any]]] = { + 1: _migrate_v1_to_v2.__func__, # type: ignore[attr-defined] + } def __init__(self, window: "CommTerminalWindow"): self.window = window @@ -42,12 +53,23 @@ def __init__(self, window: "CommTerminalWindow"): def save_session_state(self): """Serializes the current tab and pane layout to a state file.""" - state = stamp_version({"tabs": []}, self.SCHEMA_VERSION) - for page in self.tab_manager.pages.values(): + state = stamp_version({"groups": [], "tabs": []}, self.SCHEMA_VERSION) + + # Serialize groups + state["groups"] = self.tab_manager.group_manager.to_list() + + for tab_widget in self.tab_manager.tabs: + page = self.tab_manager.pages.get(tab_widget) + if not page: + continue tab_content = page.get_child() if tab_content: tab_structure = self._serialize_widget_tree(tab_content) if tab_structure: + # Attach group_id + tab_id = self.tab_manager.get_tab_id(tab_widget) + group = self.tab_manager.group_manager.get_group_for_tab(tab_id) + tab_structure["group_id"] = group.id if group else None state["tabs"].append(tab_structure) try: @@ -76,9 +98,22 @@ def restore_session_state(self) -> bool: if not state.get("tabs"): return False + # Restore groups metadata + if state.get("groups"): + self.tab_manager.group_manager.load_from_list(state["groups"]) + self.logger.info(f"Restoring {len(state['tabs'])} tabs from previous session.") for tab_structure in state["tabs"]: + group_id = tab_structure.pop("group_id", None) self.tab_manager.recreate_tab_from_structure(tab_structure) + # Re-assign group membership using the last-created tab + if group_id and self.tab_manager.tabs: + new_tab = self.tab_manager.tabs[-1] + tab_id = self.tab_manager.get_tab_id(new_tab) + self.tab_manager.group_manager.add_tab_to_group(group_id, tab_id) + + # Rebuild tab bar to show group chips + self.tab_manager._rebuild_tab_bar_order() self.clear_session_state() return True diff --git a/src/ashyterm/terminal/manager.py b/src/ashyterm/terminal/manager.py index aea72347..78610171 100644 --- a/src/ashyterm/terminal/manager.py +++ b/src/ashyterm/terminal/manager.py @@ -91,18 +91,18 @@ def __init__(self, parent_window, settings_manager: SettingsManager, spawner=Non self._highlight_manager = None # Track when commands start for long-running command notifications self._command_start_times: Dict[int, float] = {} - # Process check timer runs every 3 seconds to reduce main thread load - # (psutil calls can be blocking and cause UI freezes on some systems) - self._process_check_timer_id = GLib.timeout_add_seconds( - 3, self._periodic_process_check + # Process check timer — runs every second to keep tab titles + # up-to-date via /proc//cwd polling (readlink is near-instant). + self._process_check_timer_id = GLib.timeout_add( + 1000, self._periodic_process_check ) self.logger.info("Terminal manager initialized") def _ensure_process_check_timer(self) -> None: """Re-arm the periodic process check timer if it was stopped.""" if self._process_check_timer_id is None: - self._process_check_timer_id = GLib.timeout_add_seconds( - 3, self._periodic_process_check + self._process_check_timer_id = GLib.timeout_add( + 1000, self._periodic_process_check ) def prepare_initial_terminal(self) -> None: @@ -241,11 +241,11 @@ def set_terminal_exit_handler(self, handler: Callable): def _periodic_process_check(self) -> bool: """ - Periodic check to detect manual SSH sessions in local terminals. + Periodic check to detect manual SSH sessions and update CWD titles. - This runs every 3 seconds and checks for manual SSH sessions. - Note: Context-aware highlighting is now handled by CommandDetector - which parses the terminal output stream in real-time. + This runs every 3 seconds and checks for: + - Manual SSH sessions in local terminals + - Current working directory changes via /proc//cwd """ # Stop timer if no terminals are registered if not self.registry.get_all_terminal_ids(): @@ -261,10 +261,42 @@ def _periodic_process_check(self) -> bool: if terminal_id is not None: # Check for manual SSH sessions self.manual_ssh_tracker.check_process_tree(terminal_id) + + # Poll /proc//cwd for all local terminals to keep tab titles updated + self._poll_terminal_cwd() except Exception as e: self.logger.debug(f"Periodic check error: {e}") return True + def _poll_terminal_cwd(self) -> None: + """Read /proc//cwd for tracked processes and update tab titles.""" + tracker = self.spawner.process_tracker + with tracker._lock: + processes = dict(tracker._processes) + + for pid, info in processes.items(): + if info.get("type") != "local": + continue + terminal = info.get("terminal") + if not terminal: + continue + try: + cwd = os.readlink(f"/proc/{pid}/cwd") + except OSError: + continue + + # Only update if directory actually changed + last_cwd = getattr(terminal, "_last_polled_cwd", None) + if cwd == last_cwd: + continue + terminal._last_polled_cwd = cwd + + osc7_info = parse_directory_uri( + f"file://{os.uname().nodename}{cwd}", self.osc7_tracker.parser + ) + if osc7_info: + self._update_title(terminal, osc7_info) + def _on_manual_ssh_state_changed(self, terminal: Vte.Terminal): self._update_title(terminal) return False @@ -449,6 +481,15 @@ def _update_title( if uri: osc7_info = parse_directory_uri(uri, self.osc7_tracker.parser) + # Fallback: use last polled CWD from /proc//cwd + if osc7_info is None: + last_cwd = getattr(terminal, "_last_polled_cwd", None) + if last_cwd: + osc7_info = parse_directory_uri( + f"file://{os.uname().nodename}{last_cwd}", + self.osc7_tracker.parser, + ) + new_title = self._compute_terminal_title( terminal_info, terminal_id, terminal, osc7_info ) diff --git a/src/ashyterm/terminal/tab_groups.py b/src/ashyterm/terminal/tab_groups.py new file mode 100644 index 00000000..f17cfc9c --- /dev/null +++ b/src/ashyterm/terminal/tab_groups.py @@ -0,0 +1,213 @@ +"""Tab group data model and management for organizing tabs into named, colored groups.""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class TabGroup: + """Represents a named, colored group of tabs.""" + + id: str = field(default_factory=lambda: str(uuid.uuid4())) + name: str = "" + color: str = "" + is_collapsed: bool = False + tab_ids: list[str] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "color": self.color, + "is_collapsed": self.is_collapsed, + } + + @classmethod + def from_dict(cls, data: dict) -> TabGroup: + return cls( + id=data.get("id", str(uuid.uuid4())), + name=data.get("name", ""), + color=data.get("color", ""), + is_collapsed=data.get("is_collapsed", False), + ) + + +class TabGroupManager: + """Manages the lifecycle and state of tab groups. + + Tab IDs here correspond to the string identity of tab widgets + (derived from ``id(tab_widget)``). The manager is UI-agnostic: + it only tracks membership and metadata. + """ + + # Predefined palette aligned with Adwaita named colors + PALETTE: list[tuple[str, str]] = [ + ("red", "#f66151"), + ("orange", "#ffa348"), + ("yellow", "#f8e45c"), + ("green", "#57e389"), + ("cyan", "#5bc8af"), + ("blue", "#62a0ea"), + ("purple", "#c061cb"), + ("pink", "#f5c2e7"), + ] + + def __init__(self) -> None: + self._groups: dict[str, TabGroup] = {} + # Ordered list of group ids (visual order in tab bar) + self._group_order: list[str] = [] + + # ── queries ────────────────────────────────────────────── + + @property + def groups(self) -> list[TabGroup]: + """Return groups in visual order.""" + return [self._groups[gid] for gid in self._group_order if gid in self._groups] + + def get_group(self, group_id: str) -> Optional[TabGroup]: + return self._groups.get(group_id) + + def get_group_for_tab(self, tab_id: str) -> Optional[TabGroup]: + for group in self._groups.values(): + if tab_id in group.tab_ids: + return group + return None + + def has_groups(self) -> bool: + return bool(self._groups) + + # ── CRUD ───────────────────────────────────────────────── + + def create_group( + self, + name: str, + color: str = "", + initial_tab_ids: Optional[list[str]] = None, + ) -> TabGroup: + """Create a new group and optionally assign tabs to it.""" + if not color: + color = self._next_color() + group = TabGroup(name=name, color=color) + if initial_tab_ids: + for tid in initial_tab_ids: + self._remove_tab_from_current_group(tid) + group.tab_ids = list(initial_tab_ids) + self._groups[group.id] = group + self._group_order.append(group.id) + return group + + def delete_group(self, group_id: str) -> list[str]: + """Delete a group. Returns the tab_ids that were in it (now ungrouped).""" + group = self._groups.pop(group_id, None) + if not group: + return [] + if group_id in self._group_order: + self._group_order.remove(group_id) + return list(group.tab_ids) + + def rename_group(self, group_id: str, new_name: str) -> None: + group = self._groups.get(group_id) + if group: + group.name = new_name + + def set_group_color(self, group_id: str, color: str) -> None: + group = self._groups.get(group_id) + if group: + group.color = color + + def toggle_collapsed(self, group_id: str) -> bool: + """Toggle collapsed state. Returns the new state.""" + group = self._groups.get(group_id) + if not group: + return False + group.is_collapsed = not group.is_collapsed + return group.is_collapsed + + # ── tab membership ─────────────────────────────────────── + + def add_tab_to_group(self, group_id: str, tab_id: str, position: int = -1) -> bool: + """Add a tab to a group. Removes from current group first if any.""" + group = self._groups.get(group_id) + if not group: + return False + self._remove_tab_from_current_group(tab_id) + if position < 0 or position >= len(group.tab_ids): + group.tab_ids.append(tab_id) + else: + group.tab_ids.insert(position, tab_id) + return True + + def remove_tab_from_group(self, tab_id: str) -> Optional[str]: + """Remove a tab from whatever group it belongs to. + Returns the group_id it was removed from, or None.""" + group = self.get_group_for_tab(tab_id) + if not group: + return None + group.tab_ids.remove(tab_id) + removed_from = group.id + # Auto-delete empty groups + if not group.tab_ids: + self.delete_group(group.id) + return removed_from + + def move_tab_in_group(self, tab_id: str, new_position: int) -> None: + """Reorder a tab within its current group.""" + group = self.get_group_for_tab(tab_id) + if not group or tab_id not in group.tab_ids: + return + group.tab_ids.remove(tab_id) + if new_position < 0 or new_position >= len(group.tab_ids): + group.tab_ids.append(tab_id) + else: + group.tab_ids.insert(new_position, tab_id) + + def on_tab_removed(self, tab_id: str) -> None: + """Notify the manager that a tab was closed/destroyed.""" + self.remove_tab_from_group(tab_id) + + # ── serialization ──────────────────────────────────────── + + def to_list(self) -> list[dict]: + """Serialize all groups for persistence.""" + return [self._groups[gid].to_dict() for gid in self._group_order if gid in self._groups] + + def load_from_list(self, data: list[dict]) -> None: + """Restore groups from persisted data. Tab membership is set by the caller.""" + self._groups.clear() + self._group_order.clear() + for item in data: + group = TabGroup.from_dict(item) + self._groups[group.id] = group + self._group_order.append(group.id) + + # ── private helpers ────────────────────────────────────── + + def _remove_tab_from_current_group(self, tab_id: str) -> None: + group = self.get_group_for_tab(tab_id) + if group and tab_id in group.tab_ids: + group.tab_ids.remove(tab_id) + if not group.tab_ids: + self.delete_group(group.id) + + def move_group(self, group_id: str, new_index: int) -> None: + """Move a group to a new position in the visual order.""" + if group_id not in self._group_order: + return + self._group_order.remove(group_id) + new_index = max(0, min(new_index, len(self._group_order))) + self._group_order.insert(new_index, group_id) + + def _next_color(self) -> str: + """Pick the next color from the palette that is used the least.""" + usage: dict[str, int] = {c: 0 for _, c in self.PALETTE} + for g in self._groups.values(): + if g.color in usage: + usage[g.color] += 1 + min_count = min(usage.values()) if usage else 0 + for _, c in self.PALETTE: + if usage.get(c, 0) == min_count: + return c + return self.PALETTE[0][1] diff --git a/src/ashyterm/terminal/tabs.py b/src/ashyterm/terminal/tabs.py index c923ba69..dc370756 100644 --- a/src/ashyterm/terminal/tabs.py +++ b/src/ashyterm/terminal/tabs.py @@ -3,6 +3,7 @@ import re import threading import weakref +from pathlib import Path from typing import TYPE_CHECKING, Callable, List, Optional import gi @@ -25,6 +26,7 @@ from .manager import TerminalManager from .pane_manager import PaneManager from .scroll_handler import ScrollHandler +from .tab_groups import TabGroupManager if TYPE_CHECKING: from ..filemanager.manager import FileManager @@ -139,6 +141,7 @@ def __init__( self._drop_side: str = ( "left" # "left" or "right" - which side of target to drop ) + self._group_being_moved = None # TabGroup being moved (or None) # Set up tab bar for receiving move drop events self._setup_tab_bar_move_handlers() @@ -147,6 +150,9 @@ def __init__( self._cleanup_lock = threading.Lock() self._last_focused_terminal = None + # Tab groups + self.group_manager = TabGroupManager() + # Delegates self.scroll_handler = ScrollHandler(self) self.fm_handler = FileManagerIntegration(self) @@ -213,27 +219,30 @@ def _perform_tab_move(self): # Calculate final position if side == "left": - # Insert before target new_idx = target_idx else: - # Insert after target new_idx = target_idx + 1 # Adjust if moving from before the target if moving_idx < new_idx: new_idx -= 1 - # Only move if position actually changes - if moving_idx == new_idx: - return + # Remove tab from its current group + tab_id = self.get_tab_id(moving_tab) + self.group_manager.remove_tab_from_group(tab_id) + moving_tab.remove_css_class("in-group") - # Remove from old position - self.tabs.remove(moving_tab) + # Move in list only if position actually changes + if moving_idx != new_idx: + self.tabs.remove(moving_tab) + self.tabs.insert(new_idx, moving_tab) - # Insert at new position - self.tabs.insert(new_idx, moving_tab) + # Auto-join group if dropped next to a grouped tab + target_tab_id = self.get_tab_id(target_tab) + target_group = self.group_manager.get_group_for_tab(target_tab_id) + if target_group: + self.group_manager.add_tab_to_group(target_group.id, tab_id) - # Rebuild visual order self._rebuild_tab_bar_order() self.logger.info( @@ -242,6 +251,9 @@ def _perform_tab_move(self): def cancel_tab_move_if_active(self) -> bool: """Cancel the tab move operation if one is active. Returns True if cancelled.""" + if self._group_being_moved is not None: + self._cancel_group_move() + return True if self._tab_being_moved is not None: self._cancel_tab_move() return True @@ -257,6 +269,391 @@ def _update_tab_alignment(self): else: # center or any other value defaults to center self.tab_bar_box.set_halign(Gtk.Align.CENTER) + # ── Tab group helpers ──────────────────────────────────── + + def get_tab_id(self, tab_widget: Gtk.Box) -> str: + """Return a stable string identifier for a tab widget.""" + return str(id(tab_widget)) + + def _get_tab_by_id(self, tab_id: str) -> Optional[Gtk.Box]: + """Find a tab widget by its string id.""" + for tab in self.tabs: + if self.get_tab_id(tab) == tab_id: + return tab + return None + + def _ensure_group_tabs_contiguous(self, group_id: str) -> None: + """Move group tabs together in self.tabs so they are contiguous.""" + group = self.group_manager.get_group(group_id) + if not group or len(group.tab_ids) < 2: + return + group_tab_id_set = set(group.tab_ids) + # Find the first group tab position in self.tabs + first_idx = next( + (i for i, t in enumerate(self.tabs) + if self.get_tab_id(t) in group_tab_id_set), + None, + ) + if first_idx is None: + return + # Collect and remove non-contiguous group tabs + insert_pos = first_idx + 1 + i = insert_pos + while i < len(self.tabs): + tab_id = self.get_tab_id(self.tabs[i]) + if tab_id in group_tab_id_set: + # Already in place, advance insert position + insert_pos = i + 1 + i += 1 + elif insert_pos <= i: + i += 1 + else: + i += 1 + # Simpler approach: extract all group tabs, remove them, reinsert contiguously + group_tabs = [t for t in self.tabs if self.get_tab_id(t) in group_tab_id_set] + for t in group_tabs: + self.tabs.remove(t) + # Insert at the position of the first group tab (or end if nothing before) + insert_at = min(first_idx, len(self.tabs)) + for j, t in enumerate(group_tabs): + self.tabs.insert(insert_at + j, t) + + def _next_group_name(self) -> str: + """Generate a unique sequential group name: Grupo A, B, ..., Z, 1A, 1B, ...""" + existing_names = {g.name for g in self.group_manager.groups} + prefix_num = 0 + while True: + for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": + prefix = f"{prefix_num}" if prefix_num > 0 else "" + candidate = f"{_('Group')} {prefix}{letter}" + if candidate not in existing_names: + return candidate + prefix_num += 1 + + def create_group_from_tabs(self, tab_widgets: List[Gtk.Box], name: str = "") -> None: + """Create a new group for the given tabs.""" + tab_ids = [self.get_tab_id(t) for t in tab_widgets] + if not name: + name = self._next_group_name() + group = self.group_manager.create_group(name, initial_tab_ids=tab_ids) + self._ensure_group_tabs_contiguous(group.id) + self._rebuild_tab_bar_order() + self.logger.info(f"Created group '{group.name}' with {len(tab_ids)} tab(s)") + + def create_group_from_active_tab(self) -> None: + """Create a new group containing only the active tab.""" + if self.active_tab: + self.create_group_from_tabs([self.active_tab]) + + def ungroup_active_tab(self) -> None: + """Remove the active tab from its group.""" + if not self.active_tab: + return + tab_id = self.get_tab_id(self.active_tab) + self.group_manager.remove_tab_from_group(tab_id) + self.active_tab.remove_css_class("in-group") + self._rebuild_tab_bar_order() + + def _create_group_chip(self, group) -> Gtk.Box: + """Build the Chrome-style chip widget for a group header in the tab bar.""" + chip = Gtk.Box(spacing=0) + chip.add_css_class("tab-group-chip") + if group.is_collapsed: + chip.add_css_class("collapsed") + if not group.name: + chip.add_css_class("unnamed") + + # Apply group color as background + provider = Gtk.CssProvider() + text_color = self._get_contrasting_text_for_hex(group.color) + css = f""" + .tab-group-chip {{ + background-color: {group.color}; + color: {text_color}; + }} + """ + provider.load_from_data(css.encode("utf-8")) + chip.get_style_context().add_provider(provider, Gtk.STYLE_PROVIDER_PRIORITY_USER) + + # Group name label with tab count + tab_count = len(group.tab_ids) + display_name = f"{group.name} ({tab_count})" if group.name else f"({tab_count})" + label = Gtk.Label(label=display_name) + label.add_css_class("group-name-label") + chip.append(label) + + # Tooltip with full name and count + chip.set_tooltip_text(display_name) + + # Click gesture to toggle collapse + click = Gtk.GestureClick.new() + click.connect("pressed", self._on_group_chip_clicked, group) + chip.add_controller(click) + + # Right-click for context menu + right_click = Gtk.GestureClick.new() + right_click.set_button(Gdk.BUTTON_SECONDARY) + right_click.connect("pressed", self._on_group_chip_right_click, chip, group) + chip.add_controller(right_click) + + chip._group_id = group.id + return chip + + @staticmethod + def _get_contrasting_text_for_hex(hex_color: str) -> str: + """Return black or white text based on hex background color luminance.""" + try: + h = hex_color.lstrip("#") + r, g, b = int(h[0:2], 16) / 255.0, int(h[2:4], 16) / 255.0, int(h[4:6], 16) / 255.0 + luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b + return "#000000" if luminance > 0.5 else "#FFFFFF" + except (ValueError, IndexError): + return "#000000" + + def _on_group_chip_clicked(self, _gesture, _n, _x, _y, group): + """Toggle group collapsed state and rebuild tab bar.""" + # If in group move mode, clicking a chip acts as drop target + if self._group_being_moved is not None: + if group.id != self._group_being_moved.id: + groups = self.group_manager.groups + target_idx = next( + (i for i, g in enumerate(groups) if g.id == group.id), -1 + ) + if target_idx >= 0: + self.group_manager.move_group(self._group_being_moved.id, target_idx) + self._rebuild_tab_bar_order() + self._cancel_group_move() + return + # Cancel tab move mode if active + if self._tab_being_moved is not None: + self._cancel_tab_move() + return + self.group_manager.toggle_collapsed(group.id) + self._rebuild_tab_bar_order() + + def _on_group_chip_right_click(self, _gesture, _n, x, y, chip, group): + """Show context menu on group chip.""" + menu = Gio.Menu() + menu.append(_("New Tab in Group"), "win.new-tab-in-group") + + edit_section = Gio.Menu() + edit_section.append(_("Rename Group"), "win.rename-group") + edit_section.append(_("Group Color…"), "win.group-color") + edit_section.append(_("Move Group"), "win.move-group") + menu.append_section(None, edit_section) + + action_section = Gio.Menu() + action_section.append(_("Ungroup"), "win.ungroup-all") + action_section.append(_("Close Group"), "win.close-group") + menu.append_section(None, action_section) + + popover = create_themed_popover_menu(menu, chip) + + action_group = Gio.SimpleActionGroup() + + new_tab_action = Gio.SimpleAction.new("new-tab-in-group", None) + new_tab_action.connect("activate", lambda _a, _p, g=group: self._new_tab_in_group(g)) + action_group.add_action(new_tab_action) + + rename_action = Gio.SimpleAction.new("rename-group", None) + rename_action.connect("activate", lambda _a, _p, g=group: self._rename_group_dialog(g)) + action_group.add_action(rename_action) + + color_action = Gio.SimpleAction.new("group-color", None) + color_action.connect( + "activate", + lambda _a, _p, g=group, pop=popover: self._pick_group_color(g, pop), + ) + action_group.add_action(color_action) + + move_action = Gio.SimpleAction.new("move-group", None) + move_action.connect( + "activate", + lambda _a, _p, g=group: GLib.idle_add(self._start_group_move, g), + ) + action_group.add_action(move_action) + + ungroup_action = Gio.SimpleAction.new("ungroup-all", None) + ungroup_action.connect("activate", lambda _a, _p, g=group: self._ungroup_all(g)) + action_group.add_action(ungroup_action) + + close_action = Gio.SimpleAction.new("close-group", None) + close_action.connect("activate", lambda _a, _p, g=group: self._close_group(g)) + action_group.add_action(close_action) + + popover.insert_action_group("win", action_group) + + rect = Gdk.Rectangle() + rect.x = int(x) + rect.y = int(y) + popover.set_pointing_to(rect) + popover.popup() + + def _start_group_move(self, group) -> None: + """Enter group move mode — group chip + tabs shown as ghost.""" + self._group_being_moved = group + self.tab_bar_box.add_css_class("tab-bar-move-mode") + # Apply ghost style to group chip and its tabs + child = self.tab_bar_box.get_first_child() + while child: + if hasattr(child, "_group_id") and child._group_id == group.id: + child.add_css_class("tab-moving") + child = child.get_next_sibling() + for tab_id in group.tab_ids: + tab = self._get_tab_by_id(tab_id) + if tab: + tab.add_css_class("tab-moving") + # Hide close buttons + for tab in self.tabs: + close_btn = self._get_tab_close_button(tab) + if close_btn: + close_btn.set_opacity(0) + close_btn.set_sensitive(False) + self.logger.info(f"Group move started for: {group.name}") + + def _cancel_group_move(self) -> None: + """Cancel group move mode and restore visuals.""" + if self._group_being_moved is None: + return + group = self._group_being_moved + self.tab_bar_box.remove_css_class("tab-bar-move-mode") + self._clear_tab_drop_highlights() + # Remove ghost from chip + child = self.tab_bar_box.get_first_child() + while child: + if hasattr(child, "_group_id") and child._group_id == group.id: + child.remove_css_class("tab-moving") + child = child.get_next_sibling() + # Remove ghost from tabs + for tab_id in group.tab_ids: + tab = self._get_tab_by_id(tab_id) + if tab: + tab.remove_css_class("tab-moving") + # Restore close buttons + for tab in self.tabs: + close_btn = self._get_tab_close_button(tab) + if close_btn: + close_btn.set_opacity(1) + close_btn.set_sensitive(True) + self._group_being_moved = None + + def _perform_group_move(self, target_tab: Gtk.Box, side: str) -> None: + """Move all group tabs together in self.tabs to a new position.""" + group = self._group_being_moved + if not group: + return + + target_tab_id = self.get_tab_id(target_tab) + if target_tab_id in group.tab_ids: + return + + # Collect group tabs in their current self.tabs order + group_tab_id_set = set(group.tab_ids) + group_tabs = [t for t in self.tabs if self.get_tab_id(t) in group_tab_id_set] + + # Remove group tabs from self.tabs + for t in group_tabs: + self.tabs.remove(t) + + # Find target position after removal + target_idx = self.tabs.index(target_tab) + insert_idx = target_idx if side == "left" else target_idx + 1 + + # Insert group tabs at the new position + for i, t in enumerate(group_tabs): + self.tabs.insert(insert_idx + i, t) + + self._rebuild_tab_bar_order() + self.logger.info(f"Group '{group.name}' moved to position {insert_idx}") + + def _new_tab_in_group(self, group) -> None: + """Create a new local tab and add it to the group.""" + terminal = self.create_local_tab() + if terminal and self.tabs: + new_tab = self.tabs[-1] + tab_id = self.get_tab_id(new_tab) + self.group_manager.add_tab_to_group(group.id, tab_id) + self._ensure_group_tabs_contiguous(group.id) + self._rebuild_tab_bar_order() + + def _pick_group_color(self, group, popover: Gtk.Popover) -> None: + """Open color chooser dialog for a group (same as tab color).""" + popover.popdown() + dialog = Gtk.ColorDialog(title=_("Group Color")) + dialog.choose_rgba( + self.view_stack.get_root(), None, None, self._on_group_color_chosen, group + ) + + def _on_group_color_chosen(self, dialog, result, group) -> None: + """Handle color chooser result for a group.""" + try: + color = dialog.choose_rgba_finish(result) + except GLib.Error: + return + r, g, b = int(color.red * 255), int(color.green * 255), int(color.blue * 255) + hex_color = f"#{r:02x}{g:02x}{b:02x}" + self.group_manager.set_group_color(group.id, hex_color) + self._rebuild_tab_bar_order() + + def _close_group(self, group) -> None: + """Close all tabs in a group (Chrome 'Close group' behavior).""" + tabs_to_close = [] + for tab_id in list(group.tab_ids): + tab = self._get_tab_by_id(tab_id) + if tab: + tabs_to_close.append(tab) + self.group_manager.delete_group(group.id) + for tab in tabs_to_close: + self._on_tab_close_button_clicked(None, tab) + + def _rename_group_dialog(self, group) -> None: + """Show dialog to rename a group.""" + dialog = Adw.AlertDialog( + heading=_("Rename Group"), + body=_("Enter a new name for the group:"), + close_response="cancel", + ) + entry = Gtk.Entry(text=group.name, activates_default=True) + dialog.set_extra_child(entry) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("rename", _("Rename")) + dialog.set_response_appearance("rename", Adw.ResponseAppearance.SUGGESTED) + dialog.set_default_response("rename") + dialog.connect( + "response", + lambda d, r, e=entry, g=group: self._on_rename_group_response(r, e, g), + ) + dialog.present(self.view_stack.get_root()) + + def _on_rename_group_response(self, response: str, entry: Gtk.Entry, group) -> None: + if response != "rename": + return + new_name = entry.get_text().strip() + self.group_manager.rename_group(group.id, new_name) + self._rebuild_tab_bar_order() + + def _ungroup_all(self, group) -> None: + """Remove all tabs from a group (tabs remain open).""" + for tab_id in list(group.tab_ids): + tab = self._get_tab_by_id(tab_id) + if tab: + tab.remove_css_class("in-group") + self.group_manager.delete_group(group.id) + self._rebuild_tab_bar_order() + + def _apply_group_border_color(self, tab_widget: Gtk.Box, color: str) -> None: + """Apply a group color as bottom border indicator.""" + style_context = tab_widget.get_style_context() + # Remove previous group border provider if any + if hasattr(tab_widget, "_group_border_provider"): + style_context.remove_provider(tab_widget._group_border_provider) + + provider = Gtk.CssProvider() + css = f".custom-tab-button.in-group {{ border-bottom-color: {color}; }}" + provider.load_from_data(css.encode("utf-8")) + style_context.add_provider(provider, Gtk.STYLE_PROVIDER_PRIORITY_USER) + tab_widget._group_border_provider = provider + def get_view_stack(self) -> Adw.ViewStack: return self.view_stack @@ -330,7 +727,8 @@ def create_local_tab( close_after_execute=close_after_execute, ) if terminal: - self._create_tab_for_terminal(terminal, session) + resolved_dir = effective_working_dir or str(Path.home()) + self._create_tab_for_terminal(terminal, session, working_directory=resolved_dir) return terminal def create_ssh_tab( @@ -431,7 +829,8 @@ def _remove_bell_class(): tab_widget._bell_timeout_id = GLib.timeout_add(1500, _remove_bell_class) def _create_tab_for_terminal( - self, terminal: Vte.Terminal, session: SessionItem + self, terminal: Vte.Terminal, session: SessionItem, + working_directory: Optional[str] = None, ) -> None: terminal.connect("contents-changed", self.scroll_handler.on_terminal_contents_changed) terminal.connect("bell", self._on_terminal_bell) @@ -469,6 +868,16 @@ def _create_tab_for_terminal( self.tab_bar_box.append(tab_widget) self.set_active_tab(tab_widget) + + # For local tabs, set initial title to the working directory path + if session.is_local() and working_directory: + display_path = self.terminal_manager.osc7_tracker.parser._create_display_path( + working_directory + ) + tab_widget._base_title = display_path + tab_widget.label_widget.set_text(display_path) + page.set_title(display_path) + self.update_all_tab_titles() if self.on_tab_count_changed: @@ -556,11 +965,10 @@ def _create_tab_widget( tab_widget.add_controller(right_click) # Motion controller for hover highlighting during tab move - # DISABLED FOR WAYLAND DEBUGGING: Manual drag-and-drop can cause freezes - # motion_controller = Gtk.EventControllerMotion() - # motion_controller.connect("motion", self._on_tab_motion, tab_widget) - # motion_controller.connect("leave", self._on_tab_leave, tab_widget) - # tab_widget.add_controller(motion_controller) + motion_controller = Gtk.EventControllerMotion() + motion_controller.connect("motion", self._on_tab_motion, tab_widget) + motion_controller.connect("leave", self._on_tab_leave, tab_widget) + tab_widget.add_controller(motion_controller) close_button.connect("clicked", self._on_tab_close_button_clicked, tab_widget) @@ -576,8 +984,15 @@ def _create_tab_widget( def _on_tab_motion(self, controller, x, y, tab_widget): """Handle mouse motion over a tab during move mode.""" - if self._tab_being_moved is None or tab_widget == self._tab_being_moved: + if self._tab_being_moved is None and self._group_being_moved is None: + return + if tab_widget == self._tab_being_moved: return + # Skip tabs belonging to the group being moved + if self._group_being_moved: + tab_id = self.get_tab_id(tab_widget) + if tab_id in self._group_being_moved.tab_ids: + return # Determine which half of the tab we're over tab_width = tab_widget.get_width() @@ -588,13 +1003,22 @@ def _on_tab_motion(self, controller, x, y, tab_widget): def _on_tab_leave(self, controller, tab_widget): """Handle mouse leaving a tab during move mode.""" - if self._tab_being_moved is None: + if self._tab_being_moved is None and self._group_being_moved is None: return # Clear highlight when leaving a tab self._clear_tab_drop_highlights() def _on_tab_clicked(self, gesture, _n_press, x, _y, tab_widget): - # If we're in move mode, handle the drop + # If we're in group move mode, handle the drop + if self._group_being_moved is not None: + tab_id = self.get_tab_id(tab_widget) + if tab_id not in self._group_being_moved.tab_ids: + tab_width = tab_widget.get_width() + side = "left" if x < tab_width / 2 else "right" + self._perform_group_move(tab_widget, side) + self._cancel_group_move() + return + # If we're in tab move mode, handle the drop if self._tab_being_moved is not None: if self._tab_being_moved != tab_widget: # Determine which half of the tab was clicked @@ -613,6 +1037,8 @@ def _on_tab_right_click(self, _gesture, _n_press, x, y, tab_widget): # Cancel any ongoing move operation when right-clicking if self._tab_being_moved is not None: self._cancel_tab_move() + if self._group_being_moved is not None: + self._cancel_group_move() menu = Gio.Menu() menu.append(_("Move Tab"), "win.move-tab") @@ -626,6 +1052,24 @@ def _on_tab_right_click(self, _gesture, _n_press, x, y, tab_widget): color_section.append(_("Clear Tab Color"), "win.clear-tab-color") menu.append_section(None, color_section) + # Group section + group_section = Gio.Menu() + tab_id = self.get_tab_id(tab_widget) + current_group = self.group_manager.get_group_for_tab(tab_id) + if current_group: + group_section.append(_("Remove from Group"), "win.remove-from-group") + else: + group_section.append(_("New Group from Tab"), "win.new-group-from-tab") + # Submenu to add to existing groups + if self.group_manager.has_groups(): + for group in self.group_manager.groups: + if group != current_group: + group_section.append( + _("Add to '{}'").format(group.name), + f"win.add-to-group-{group.id}", + ) + menu.append_section(None, group_section) + popover = create_themed_popover_menu(menu, tab_widget) page = self.pages.get(tab_widget) @@ -635,7 +1079,7 @@ def _on_tab_right_click(self, _gesture, _n_press, x, y, tab_widget): move_action = Gio.SimpleAction.new("move-tab", None) move_action.connect( "activate", - lambda _action, _param, tab=tab_widget: self._start_tab_move(tab), + lambda _action, _param, tab=tab_widget: GLib.idle_add(self._start_tab_move, tab), ) action_group.add_action(move_action) @@ -669,6 +1113,30 @@ def _on_tab_right_click(self, _gesture, _n_press, x, y, tab_widget): ) action_group.add_action(clear_color_action) + # Group-related actions + new_group_action = Gio.SimpleAction.new("new-group-from-tab", None) + new_group_action.connect( + "activate", + lambda _a, _p, tab=tab_widget: self.create_group_from_tabs([tab]), + ) + action_group.add_action(new_group_action) + + remove_group_action = Gio.SimpleAction.new("remove-from-group", None) + remove_group_action.connect( + "activate", + lambda _a, _p, tab=tab_widget: self._remove_tab_from_group_action(tab), + ) + action_group.add_action(remove_group_action) + + # Dynamic "add to group X" actions + for group in self.group_manager.groups: + add_action = Gio.SimpleAction.new(f"add-to-group-{group.id}", None) + add_action.connect( + "activate", + lambda _a, _p, g_id=group.id, tab=tab_widget: self._add_tab_to_group_action(tab, g_id), + ) + action_group.add_action(add_action) + popover.insert_action_group("win", action_group) rect = Gdk.Rectangle() @@ -677,6 +1145,24 @@ def _on_tab_right_click(self, _gesture, _n_press, x, y, tab_widget): popover.set_pointing_to(rect) popover.popup() + def _remove_tab_from_group_action(self, tab_widget: Gtk.Box) -> None: + """Action handler: remove tab from its current group.""" + tab_id = self.get_tab_id(tab_widget) + self.group_manager.remove_tab_from_group(tab_id) + tab_widget.remove_css_class("in-group") + self._rebuild_tab_bar_order() + + def _add_tab_to_group_action(self, tab_widget: Gtk.Box, group_id: str) -> None: + """Action handler: add tab to an existing group.""" + tab_id = self.get_tab_id(tab_widget) + self.group_manager.add_tab_to_group(group_id, tab_id) + self._ensure_group_tabs_contiguous(group_id) + # If the group was collapsed, expand it so user sees the tab + group = self.group_manager.get_group(group_id) + if group and group.is_collapsed: + self.group_manager.toggle_collapsed(group_id) + self._rebuild_tab_bar_order() + def _pick_tab_color(self, tab_widget: Gtk.Box, popover: Gtk.Popover) -> None: """Open color chooser dialog for a tab.""" popover.popdown() @@ -719,11 +1205,11 @@ def _start_tab_move(self, tab_widget: Gtk.Box) -> None: tab_widget.add_css_class("tab-moving") self.tab_bar_box.add_css_class("tab-bar-move-mode") - # Hide and disable all close buttons during move mode to prevent accidental closing + # Make close buttons invisible but keep layout space during move mode for tab in self.tabs: close_btn = self._get_tab_close_button(tab) if close_btn: - close_btn.set_visible(False) + close_btn.set_opacity(0) close_btn.set_sensitive(False) self.logger.info(f"Tab move started for: {tab_widget.label_widget.get_text()}") @@ -739,7 +1225,7 @@ def _cancel_tab_move(self) -> None: for tab in self.tabs: close_btn = self._get_tab_close_button(tab) if close_btn: - close_btn.set_visible(True) + close_btn.set_opacity(1) close_btn.set_sensitive(True) self.logger.debug("Tab move cancelled.") @@ -759,14 +1245,39 @@ def _get_tab_close_button(self, tab_widget: Gtk.Box) -> Optional[Gtk.Button]: return None def _rebuild_tab_bar_order(self) -> None: - """Rebuilds the tab bar widget order to match self.tabs list.""" - # Remove all tabs from the box - for tab in self.tabs: - self.tab_bar_box.remove(tab) + """Rebuilds tab bar based on self.tabs order. Groups are interspersed.""" + # Remove ALL children from tab_bar_box (tabs + old group chips) + child = self.tab_bar_box.get_first_child() + while child: + next_child = child.get_next_sibling() + self.tab_bar_box.remove(child) + child = next_child + + # Track which groups already have their chip placed + groups_with_chip: set[str] = set() - # Re-add them in the correct order for tab in self.tabs: - self.tab_bar_box.append(tab) + tab_id = self.get_tab_id(tab) + group = self.group_manager.get_group_for_tab(tab_id) + + if group: + # Insert chip before the first tab of this group in visual order + if group.id not in groups_with_chip: + chip = self._create_group_chip(group) + self.tab_bar_box.append(chip) + groups_with_chip.add(group.id) + + tab.add_css_class("in-group") + self._apply_group_border_color(tab, group.color) + if group.is_collapsed: + tab.set_visible(False) + else: + tab.set_visible(True) + self.tab_bar_box.append(tab) + else: + tab.remove_css_class("in-group") + tab.set_visible(True) + self.tab_bar_box.append(tab) def _duplicate_tab(self, tab_widget: Gtk.Box) -> None: """Creates a new tab duplicating the session represented by the given tab widget.""" @@ -864,6 +1375,13 @@ def set_active_tab(self, tab_to_activate: Gtk.Box): if self.active_tab == tab_to_activate: return + # Auto-expand collapsed group if the target tab is hidden + tab_id = self.get_tab_id(tab_to_activate) + group = self.group_manager.get_group_for_tab(tab_id) + if group and group.is_collapsed: + self.group_manager.toggle_collapsed(group.id) + self._rebuild_tab_bar_order() + self._handle_previous_tab_focus() if self.active_tab: @@ -1126,6 +1644,8 @@ def _remove_tab_from_tracking(self, tab: Gtk.Box): if hasattr(tab, "_bell_timeout_id"): GLib.source_remove(tab._bell_timeout_id) del tab._bell_timeout_id + # Notify group manager + self.group_manager.on_tab_removed(self.get_tab_id(tab)) self.tab_bar_box.remove(tab) self.tabs.remove(tab) if tab in self.pages: @@ -1196,8 +1716,11 @@ def update_titles_for_terminal(self, terminal, new_title: str, _osc7_info=None): if not page: return + # Full path for tooltip + full_path = _osc7_info.path if _osc7_info else None + # Update the main tab title - self.set_tab_title(page, new_title) + self.set_tab_title(page, new_title, tooltip=full_path) # Update the specific pane's title pane = self.banner_handler.find_pane_for_terminal(page, terminal) @@ -1233,7 +1756,7 @@ def _append_terminal_count(self, page, display_title: str) -> str: return f"{display_title} ({terminal_count})" return display_title - def set_tab_title(self, page: Adw.ViewStackPage, new_title: str) -> None: + def set_tab_title(self, page: Adw.ViewStackPage, new_title: str, tooltip: Optional[str] = None) -> None: if not (page and new_title): return @@ -1248,6 +1771,9 @@ def set_tab_title(self, page: Adw.ViewStackPage, new_title: str) -> None: page.set_title(display_title) a11y_label(tab_button, display_title) + if tooltip: + tab_button.set_tooltip_text(tooltip) + if hasattr(self.terminal_manager.parent_window, "_update_tab_layout"): self.terminal_manager.parent_window._update_tab_layout() diff --git a/src/ashyterm/ui/window_ui.py b/src/ashyterm/ui/window_ui.py index c8ad70cb..5c103035 100644 --- a/src/ashyterm/ui/window_ui.py +++ b/src/ashyterm/ui/window_ui.py @@ -149,11 +149,12 @@ def _setup_styles(self) -> None: self._load_css("components.css") # Deferred: not needed for initial window render - def load_dialog_styles(): + def load_deferred_styles(): + self._load_css("tab_groups.css") self._load_css("dialogs.css") return GLib.SOURCE_REMOVE - GLib.idle_add(load_dialog_styles, priority=GLib.PRIORITY_LOW) + GLib.idle_add(load_deferred_styles, priority=GLib.PRIORITY_LOW) # Separate provider for window borders (conditional on maximized state) self.border_provider = Gtk.CssProvider() diff --git a/src/ashyterm/utils/platform.py b/src/ashyterm/utils/platform.py index df2b0764..875328e7 100644 --- a/src/ashyterm/utils/platform.py +++ b/src/ashyterm/utils/platform.py @@ -127,6 +127,17 @@ def get_terminal_environment(self) -> Dict[str, str]: env = os.environ.copy() env["TERM"] = "xterm-256color" env["COLORTERM"] = "truecolor" + # VTE_VERSION is required by /etc/profile.d/vte.sh for OSC7 shell integration + try: + import gi + gi.require_version("Vte", "3.91") + from gi.repository import Vte + major = Vte.MAJOR_VERSION + minor = Vte.MINOR_VERSION + micro = Vte.MICRO_VERSION + env["VTE_VERSION"] = str(major * 10000 + minor * 100 + micro) + except Exception: + env["VTE_VERSION"] = "8203" if "LANG" not in env: import locale diff --git a/src/ashyterm/window_actions.py b/src/ashyterm/window_actions.py index 9070ddb6..012b4674 100644 --- a/src/ashyterm/window_actions.py +++ b/src/ashyterm/window_actions.py @@ -50,6 +50,8 @@ def _on_key_pressed(self, _controller, keyval, _keycode, state): return Gdk.EVENT_STOP if self._handle_search_shortcut(keyval, state): return Gdk.EVENT_STOP + if self._handle_tab_group_shortcuts(keyval, state): + return Gdk.EVENT_STOP accel_string = Gtk.accelerator_name( keyval, state & Gtk.accelerator_get_default_mod_mask() ) @@ -147,6 +149,19 @@ def _handle_emergency_dialog_close(self, keyval, state) -> bool: return True return False + def _handle_tab_group_shortcuts(self, keyval, state) -> bool: + """Handle Ctrl+Shift+G (new group) and Ctrl+Shift+U (ungroup).""" + ctrl_shift = Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK + if (state & ctrl_shift) != ctrl_shift: + return False + if keyval in (Gdk.KEY_g, Gdk.KEY_G): + self.tab_manager.create_group_from_active_tab() + return True + if keyval in (Gdk.KEY_u, Gdk.KEY_U): + self.tab_manager.ungroup_active_tab() + return True + return False + def _force_close_all_dialogs(self): """Force close all dialogs and transient windows.""" if hasattr(self, "get_dialogs"): diff --git a/tests/test_tab_groups.py b/tests/test_tab_groups.py new file mode 100644 index 00000000..34fbaf60 --- /dev/null +++ b/tests/test_tab_groups.py @@ -0,0 +1,151 @@ +"""Tests for TabGroup and TabGroupManager.""" + +import pytest + +from ashyterm.terminal.tab_groups import TabGroup, TabGroupManager + + +class TestTabGroup: + def test_defaults(self): + g = TabGroup(name="Dev", color="#f66151") + assert g.name == "Dev" + assert g.color == "#f66151" + assert g.is_collapsed is False + assert g.tab_ids == [] + assert g.id # auto-generated uuid + + def test_to_dict_roundtrip(self): + g = TabGroup(id="abc", name="Servers", color="#62a0ea", is_collapsed=True) + d = g.to_dict() + g2 = TabGroup.from_dict(d) + assert g2.id == "abc" + assert g2.name == "Servers" + assert g2.color == "#62a0ea" + assert g2.is_collapsed is True + + def test_from_dict_missing_fields(self): + g = TabGroup.from_dict({}) + assert g.name == "" + assert g.color == "" + assert g.is_collapsed is False + + +class TestTabGroupManager: + def setup_method(self): + self.mgr = TabGroupManager() + + def test_create_group(self): + g = self.mgr.create_group("Servers") + assert g.name == "Servers" + assert g.color # auto-picked from palette + assert self.mgr.get_group(g.id) is g + assert self.mgr.has_groups() + + def test_create_group_with_initial_tabs(self): + g = self.mgr.create_group("Dev", initial_tab_ids=["t1", "t2"]) + assert g.tab_ids == ["t1", "t2"] + assert self.mgr.get_group_for_tab("t1") is g + assert self.mgr.get_group_for_tab("t2") is g + + def test_delete_group_returns_orphaned_tabs(self): + g = self.mgr.create_group("X", initial_tab_ids=["t1", "t2"]) + orphans = self.mgr.delete_group(g.id) + assert set(orphans) == {"t1", "t2"} + assert not self.mgr.has_groups() + + def test_delete_nonexistent_group(self): + assert self.mgr.delete_group("nope") == [] + + def test_rename_group(self): + g = self.mgr.create_group("Old") + self.mgr.rename_group(g.id, "New") + assert g.name == "New" + + def test_set_group_color(self): + g = self.mgr.create_group("X") + self.mgr.set_group_color(g.id, "#000000") + assert g.color == "#000000" + + def test_toggle_collapsed(self): + g = self.mgr.create_group("X") + assert g.is_collapsed is False + state = self.mgr.toggle_collapsed(g.id) + assert state is True + assert g.is_collapsed is True + state = self.mgr.toggle_collapsed(g.id) + assert state is False + + def test_add_tab_to_group(self): + g = self.mgr.create_group("X") + assert self.mgr.add_tab_to_group(g.id, "t1") + assert "t1" in g.tab_ids + + def test_add_tab_at_position(self): + g = self.mgr.create_group("X", initial_tab_ids=["t1", "t3"]) + self.mgr.add_tab_to_group(g.id, "t2", position=1) + assert g.tab_ids == ["t1", "t2", "t3"] + + def test_add_tab_moves_from_existing_group(self): + g1 = self.mgr.create_group("A", initial_tab_ids=["t1", "t2"]) + g2 = self.mgr.create_group("B") + self.mgr.add_tab_to_group(g2.id, "t1") + assert "t1" not in g1.tab_ids + assert "t1" in g2.tab_ids + + def test_remove_tab_from_group(self): + g = self.mgr.create_group("X", initial_tab_ids=["t1", "t2"]) + removed_from = self.mgr.remove_tab_from_group("t1") + assert removed_from == g.id + assert "t1" not in g.tab_ids + + def test_remove_tab_auto_deletes_empty_group(self): + g = self.mgr.create_group("X", initial_tab_ids=["t1"]) + self.mgr.remove_tab_from_group("t1") + assert not self.mgr.has_groups() + + def test_remove_tab_not_in_group(self): + assert self.mgr.remove_tab_from_group("t99") is None + + def test_move_tab_in_group(self): + g = self.mgr.create_group("X", initial_tab_ids=["t1", "t2", "t3"]) + self.mgr.move_tab_in_group("t3", 0) + assert g.tab_ids == ["t3", "t1", "t2"] + + def test_on_tab_removed(self): + g = self.mgr.create_group("X", initial_tab_ids=["t1", "t2"]) + self.mgr.on_tab_removed("t1") + assert "t1" not in g.tab_ids + + def test_groups_order_preserved(self): + g1 = self.mgr.create_group("First") + g2 = self.mgr.create_group("Second") + g3 = self.mgr.create_group("Third") + assert self.mgr.groups == [g1, g2, g3] + + def test_serialization_roundtrip(self): + g1 = self.mgr.create_group("A", color="#f66151", initial_tab_ids=["t1"]) + g2 = self.mgr.create_group("B", color="#62a0ea", initial_tab_ids=["t2", "t3"]) + self.mgr.toggle_collapsed(g2.id) + + data = self.mgr.to_list() + assert len(data) == 2 + + mgr2 = TabGroupManager() + mgr2.load_from_list(data) + # tab_ids must be restored separately by the caller + assert len(mgr2.groups) == 2 + assert mgr2.groups[0].name == "A" + assert mgr2.groups[1].name == "B" + assert mgr2.groups[1].is_collapsed is True + + def test_next_color_rotates(self): + colors_seen = set() + for i in range(len(TabGroupManager.PALETTE)): + g = self.mgr.create_group(f"G{i}") + colors_seen.add(g.color) + # All palette colors should have been used + palette_colors = {c for _, c in TabGroupManager.PALETTE} + assert colors_seen == palette_colors + + def test_get_group_for_ungrouped_tab(self): + assert self.mgr.get_group_for_tab("nonexistent") is None