Compare commits
@@ -0,0 +1,183 @@
|
||||
---
|
||||
name: theme-ui
|
||||
description: >-
|
||||
Token selection guide for clide UI development. Use when building or
|
||||
modifying widgets, panels, pane chrome, status indicators, or any
|
||||
visual surface. Ensures correct background, border, text, and hover
|
||||
tokens are applied per surface type. Triggers on: new widget code,
|
||||
theme-related changes, "which token", "what color", color/background
|
||||
questions, visual inconsistency fixes, new panel/pane/view development.
|
||||
---
|
||||
|
||||
# Theme-UI — token selection for clide surfaces
|
||||
|
||||
All colors come from `SurfaceTokens` via `ClideTheme.of(context).surface`.
|
||||
Never hardcode colors. Never use Material/Cupertino color constants.
|
||||
|
||||
## Token selection by surface
|
||||
|
||||
Pick tokens based on **where** the widget lives, not what it does.
|
||||
|
||||
### Chrome (hat bar, status bar, sidebar, context panel, spines, drag handles)
|
||||
|
||||
```
|
||||
background → chromeBackground
|
||||
text → chromeForeground
|
||||
border → chromeBorder (1px)
|
||||
active text → globalForeground
|
||||
```
|
||||
|
||||
### Side panels (sidebar, context panel)
|
||||
|
||||
```
|
||||
background → chromeBackground (both sides — they're chrome frame)
|
||||
text → sidebarForeground
|
||||
hover → sidebarItemHover
|
||||
selected → sidebarItemSelected
|
||||
section head → sidebarSectionHeader (muted, used for "START", "FILES", etc.)
|
||||
```
|
||||
|
||||
Padding: 2px on outer edges, 0px on divider edge.
|
||||
|
||||
### Center column (workspace, Claude pane, editor)
|
||||
|
||||
```
|
||||
background → panelBackground
|
||||
text → globalForeground
|
||||
```
|
||||
|
||||
No padding — content fills edge to edge.
|
||||
|
||||
### Pane headers (`ClidePaneChrome`)
|
||||
|
||||
```
|
||||
background → panelHeader
|
||||
text (title) → panelHeaderForeground
|
||||
text (sub) → globalTextMuted
|
||||
```
|
||||
|
||||
### List items (decisions, tickets, file rows, backlinks)
|
||||
|
||||
```
|
||||
background → (none / transparent)
|
||||
hover bg → listItemHoverBackground
|
||||
selected bg → listItemSelectedBackground
|
||||
text → listItemForeground / sidebarForeground (in sidebar)
|
||||
selected txt → listItemSelectedForeground
|
||||
```
|
||||
|
||||
In sidebar context, use `sidebarItemHover` not `listItemHoverBackground`.
|
||||
|
||||
### Buttons
|
||||
|
||||
```
|
||||
normal → buttonBackground / buttonForeground / buttonBorder
|
||||
hover → buttonHoverBackground
|
||||
active → buttonActiveBackground
|
||||
primary → buttonActiveBackground bg + globalBackground text
|
||||
subtle → listItemBackground / listItemHoverBackground (no border)
|
||||
```
|
||||
|
||||
### Dividers and separators
|
||||
|
||||
```
|
||||
line → dividerColor (always, everywhere)
|
||||
drag handle → 8px hit area, 1px visible line, panel bg fill
|
||||
hover line → panelActiveBorder
|
||||
```
|
||||
|
||||
### Status indicators
|
||||
|
||||
```
|
||||
success/ok → statusSuccess (green: done, added, connected)
|
||||
warning → statusWarning (amber: question, modified, missing)
|
||||
error → statusError (red: deleted, rejected, cancelled)
|
||||
info → statusInfo (blue: in_progress, modified)
|
||||
```
|
||||
|
||||
Map semantic states, not visual styles:
|
||||
- `done` / `added` / `ok` → `statusSuccess`
|
||||
- `in_progress` / `modified` → `statusInfo`
|
||||
- `question` / `warning` → `statusWarning`
|
||||
- `cancelled` / `deleted` / `error` → `statusError`
|
||||
|
||||
### Overlays (dialogs, palette, tooltips)
|
||||
|
||||
```
|
||||
dialog bg → modalSurfaceBackground
|
||||
dialog border→ modalSurfaceBorder
|
||||
backdrop → modalOverlayBackground
|
||||
tooltip → tooltipBackground / tooltipForeground / tooltipBorder
|
||||
dropdown → dropdownBackground / dropdownForeground / dropdownBorder
|
||||
```
|
||||
|
||||
## Typography
|
||||
|
||||
Three constants — never hardcode sizes or families.
|
||||
|
||||
```
|
||||
family UI → inherited from DefaultTextStyle (JosefinSans Light 300)
|
||||
family mono → clideMonoFamily (JetBrainsMono)
|
||||
body size → clideFontBody (15)
|
||||
caption size → clideFontCaption (14) — status bar, section headers, git info
|
||||
mono size → clideFontMono (14) — terminal, code, paths, IDs
|
||||
```
|
||||
|
||||
Use `ClideText` for themed text. Set `muted: true` for secondary text
|
||||
(resolves to `globalTextMuted`). Set `fontFamily: clideMonoFamily` for
|
||||
code/paths/IDs. Don't set fontFamily for UI text — it inherits.
|
||||
|
||||
## Token identity rule
|
||||
|
||||
Every visual surface gets its own named token. Never borrow a token from
|
||||
another surface just because they happen to resolve to the same color.
|
||||
|
||||
**Wrong:** `sidebarBackground` for the hat bar (the hat isn't a sidebar).
|
||||
**Right:** Create `chromeBackground` that resolves to the same palette key.
|
||||
|
||||
When two surfaces share a color:
|
||||
1. **If they're the same conceptual surface** (sidebar + context panel are
|
||||
both "side panels") → one shared token set is fine.
|
||||
2. **If they're different surfaces that happen to match** (hat bar + sidebar
|
||||
+ status bar are all "chrome frame") → create a shared primitive in the
|
||||
palette/semantic layer (e.g. `bgChrome`) and give each surface its own
|
||||
token that maps to that primitive. This lets themes diverge them later.
|
||||
|
||||
The palette layer has these depth primitives:
|
||||
- `bg` (`#20202C`) — outermost root, behind everything
|
||||
- `bgSunken` (`#1A1A24`) — chrome frame: sidebar, hat, statusbar
|
||||
- `surface` (`#242838`) — elevated: pane headers, active tabs
|
||||
- `surfaceHi` (`#2C3046`) — interactive: hover states, selections
|
||||
|
||||
Chrome tokens (`chromeBackground`/`chromeForeground`/`chromeBorder`) are
|
||||
the shared root for all frame surfaces. They resolve to `bgSunken` /
|
||||
`textDim` / `border` in the palette. Themes can override them to diverge
|
||||
hat from sidebar from status bar if desired.
|
||||
|
||||
## Extension-owned domain colors
|
||||
|
||||
Extensions that need domain-specific color coding (ticket types, decision
|
||||
types, priority levels) should NOT add tokens to `SurfaceTokens`. Instead:
|
||||
|
||||
1. Create a color map class in the extension (e.g. `TicketTypeColors`)
|
||||
2. Ship dark and light presets, auto-selected via `ClideTheme.of(context).dark`
|
||||
3. Store user overrides under `ext.<id>.colors` in settings
|
||||
4. Reference: `lib/builtin/tickets/src/ticket_colors.dart`
|
||||
|
||||
This keeps the core token surface lean and lets each extension own its
|
||||
palette. The pattern scales to any extension needing domain colors.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Borrowing another surface's token (`sidebarBackground` for hat bar)
|
||||
- `globalBackground` for panel fill → use `panelBackground`
|
||||
- `listItemHoverBackground` in sidebar → use `sidebarItemHover`
|
||||
- Hardcoded `Color(0xFF...)` → use a token
|
||||
- `fontSize: 14` → use `clideFontCaption` or `clideFontMono`
|
||||
- `fontFamily: 'JetBrainsMono'` → use `clideMonoFamily`
|
||||
|
||||
## Reference
|
||||
|
||||
Full token list: `lib/kernel/src/theme/tokens.dart`
|
||||
Resolver fallbacks: `lib/kernel/src/theme/resolver.dart`
|
||||
Theme YAML example: `lib/kernel/src/theme/themes/clide.yaml`
|
||||
@@ -88,3 +88,6 @@ legacy/**/.coverage
|
||||
legacy/**/.coverage.*
|
||||
.clide/settings.yaml
|
||||
.claude/skills/pql/
|
||||
.pql/*
|
||||
!.pql/pql-plan.json
|
||||
!.pql/hooks/
|
||||
|
||||
@@ -18,52 +18,74 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- Shared ClideFilterBox widget with search icon, clear button, and
|
||||
debounced input. Applied consistently across all sidebar panes:
|
||||
Files (path filter with flat results), Git (filter staged/unstaged
|
||||
by path), Decisions (filter by ID/title/domain), Tickets (filter
|
||||
by ID/title/status), Problems (filter by source/message), and
|
||||
pql Query (replaces custom input).
|
||||
|
||||
- Interaction model from Wireframe Flows v3: eight new D-records
|
||||
(D-047 through D-054) and five Q-records (Q-026 through Q-030)
|
||||
(D-47 through D-54) and five Q-records (Q-26 through Q-30)
|
||||
codifying layout invariants, chrome budget, editor mode, context
|
||||
auto-behavior, collapse spine, focus mode, state persistence, and
|
||||
the canonical keyboard map.
|
||||
|
||||
- Panel collapse spine — collapsed side panels render as a 12px
|
||||
vertical spine with rotated label, hover highlight, and badge dot
|
||||
for pending context (D-051, T-030).
|
||||
for pending context (D-51, T-30).
|
||||
|
||||
- Focus mode — `Ctrl+.` takes the active panel full-window;
|
||||
`Escape` restores the prior layout with collapse states and
|
||||
divider positions intact (D-052, T-031).
|
||||
divider positions intact (D-52, T-31).
|
||||
|
||||
- Canonical keyboard shortcuts from the interaction model: collapse
|
||||
toggles (`Ctrl+Shift+1/3`), panel focus (`Ctrl+1/2/3`), sidebar
|
||||
section switching (`Alt+1–5`), focus mode, and `Escape` dismiss
|
||||
(D-054, T-033).
|
||||
(D-54, T-33).
|
||||
|
||||
- Right panel (context) icon rail — bottom section switcher matching
|
||||
the left sidebar rail pattern (D-047, T-034).
|
||||
the left sidebar rail pattern (D-47, T-34).
|
||||
|
||||
- Editor-above-Claude mode — `Ctrl+E` opens the editor as a split
|
||||
above Claude in the middle column with a draggable divider;
|
||||
`Ctrl+W` or `Escape` closes it. Prompt bar Y stays fixed
|
||||
(D-049, T-035).
|
||||
(D-49, T-35).
|
||||
|
||||
- Layout state persists across sessions — collapse state, sidebar
|
||||
and context panel sizes, active sections, and editor split ratio
|
||||
saved to `.clide/settings.yaml` (D-053, T-032).
|
||||
saved to `.clide/settings.yaml` (D-53, T-32).
|
||||
|
||||
- Phosphor Icons font (v2.0.8, MIT) — regular, bold, and fill
|
||||
weights. Replaces hand-painted CustomPaint icons in sidebar and
|
||||
context panel icon rails.
|
||||
|
||||
- Decisions panel in sidebar — lists confirmed D-records from
|
||||
`pql decisions list` with ID and title (T-037).
|
||||
`pql decisions list` with ID and title (T-37).
|
||||
|
||||
- Tickets panel in sidebar — lists tickets from `pql ticket list`
|
||||
with status dot color-coded by state (T-037).
|
||||
with status dot color-coded by state (T-37).
|
||||
|
||||
- Markdown viewer in context panel — shows raw content of the
|
||||
active .md file, auto-updating on buffer switch (T-038).
|
||||
active .md file, auto-updating on buffer switch (T-38).
|
||||
|
||||
- Clickable DQRT record links in markdown — `[D-56]`, `[T-43]` etc.
|
||||
rendered as tappable links that navigate to the decision or ticket
|
||||
detail pane via the message bus.
|
||||
|
||||
- Ctrl+Plus / Ctrl+Minus / Ctrl+0 text zoom (5% steps, 60%–200%
|
||||
range) via `MediaQuery.textScaler`. Resets on app restart.
|
||||
|
||||
- Sidebar focus indicators — selecting a ticket or decision
|
||||
highlights the active card in the sidebar list, auto-expands the
|
||||
accordion section if collapsed, and scrolls the card into view.
|
||||
|
||||
- Typography scale constants `clideFontSmall` (12) and
|
||||
`clideFontBadge` (11) for sidebar metadata and status badges.
|
||||
`clideLineHeight` (1.25) applied app-wide via `DefaultTextStyle`.
|
||||
|
||||
- Graph view in context panel — lists files with inbound/outbound
|
||||
link counts from `pql search --connections` (T-039).
|
||||
link counts from `pql search --connections` (T-39).
|
||||
|
||||
- Welcome screen redesigned as full-screen overlay with two-column
|
||||
layout: START actions (open folder, clone, Claude session) with
|
||||
@@ -78,13 +100,30 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
### Changed
|
||||
|
||||
- Workspace renders Claude as the always-visible primary surface
|
||||
instead of showing a tab bar (D-047, D-048). The editor is a
|
||||
instead of showing a tab bar (D-47, D-48). The editor is a
|
||||
split overlay, not a tab.
|
||||
|
||||
- Syntax highlighting via tree-sitter (dart:ffi to vendored
|
||||
libtree-sitter.so with embedded wasmtime). 48 grammar WASM files,
|
||||
48 highlight queries. Colors map to theme syntax tokens.
|
||||
|
||||
- ClideMarkdown renderer — inline grouping for tight list items,
|
||||
proper HTML entity unescaping after AST parse, Josefin Sans Light
|
||||
with 1.25 line height, 16px body text. Inline code sized to
|
||||
`clideFontMono` to match surrounding text weight.
|
||||
|
||||
- Default sidebar and context panel widths widened to 400px and
|
||||
420px respectively (previous maximums).
|
||||
|
||||
- Ticket descriptions render through ClideMarkdown instead of plain
|
||||
text, with clickable DQRT record links.
|
||||
|
||||
- Decision detail view subscribes to the message bus for navigation
|
||||
(same pattern as tickets), enabling navigation from any source.
|
||||
|
||||
- Context panel drag resize fixed — dragging left now correctly
|
||||
grows the right panel instead of shrinking it.
|
||||
|
||||
- POLICY.md — project-wide rules for runtime behavior, dependency
|
||||
vetting, vendored binary management, telemetry, and licensing.
|
||||
|
||||
@@ -92,7 +131,7 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
- Line length set to 160 across .editorconfig and dart formatter.
|
||||
|
||||
- Core frame vs shipped extension boundary defined (D-046). Builtins
|
||||
- Core frame vs shipped extension boundary defined (D-46). Builtins
|
||||
are frame infrastructure only; content extensions are bundled but
|
||||
architecturally removable.
|
||||
|
||||
@@ -114,7 +153,7 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
`.claude/settings.json`.
|
||||
|
||||
- pql daemon subsystem (`lib/src/pql/`). `PqlClient` wraps the pql
|
||||
CLI per D-003. IPC verbs `pql.files | meta | backlinks | outlinks
|
||||
CLI per D-3. IPC verbs `pql.files | meta | backlinks | outlinks
|
||||
| tags | schema | query | doctor | decisions.sync | decisions.list
|
||||
| decisions.show | decisions.coverage | tickets.list | tickets.show
|
||||
| tickets.board | plan.status`. 15 new core tests.
|
||||
@@ -164,7 +203,7 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
echo-suppression guard avoids clobbering the caret when the
|
||||
daemon's authoritative edit echo comes back. Text surface is
|
||||
Flutter's `EditableText` primitive — no `TextField` / Material —
|
||||
so the D-007 "no Material root" stance carries into the editor;
|
||||
so the D-7 "no Material root" stance carries into the editor;
|
||||
JetBrainsMono via the shared `clideMonoFamily` constants, cursor
|
||||
+ selection colours bind to the theme.
|
||||
|
||||
@@ -197,7 +236,7 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
dispatcher round-trips.
|
||||
|
||||
- `builtin.claude` — Tier-1 stub upgraded to the real Claude pane per
|
||||
D-041. Contributes a primary `Claude` tab in the workspace slot that
|
||||
D-41. Contributes a primary `Claude` tab in the workspace slot that
|
||||
spawns `tmux new-session -A -s clide-claude-<hash> -- claude` via
|
||||
IPC `pane.spawn`, with `<hash>` derived from the git root path so
|
||||
reopening the app re-attaches to the running conversation. Primary
|
||||
@@ -205,8 +244,8 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
Command `claude.new-secondary` is registered for the palette wiring
|
||||
that's coming next. If tmux isn't on PATH, falls back to spawning
|
||||
`claude` directly and surfaces "no-tmux · fresh every launch" in
|
||||
the header subtitle. Accompanied by D-041 in
|
||||
[`decisions/architecture.md`](decisions/architecture.md#d-041-claude-panes-one-primary-per-repo-tmux-backed).
|
||||
the header subtitle. Accompanied by D-41 in
|
||||
[`decisions/architecture.md`](decisions/architecture.md#d-41-claude-panes-one-primary-per-repo-tmux-backed).
|
||||
|
||||
- `builtin.files` — workspace filesystem panel in the sidebar. Lazy
|
||||
tree rooted at the git root, expand/collapse, click-to-open plumbed
|
||||
@@ -216,7 +255,7 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
with ignore-file filtering. Ignore set composes clide's built-in
|
||||
hide list (`.git/`, `.pql/`, `.clide/`, `.dart_tool/`, `build/`,
|
||||
`node_modules/`) with `.gitignore` / `.clideignore` at the root per
|
||||
D-004. `IgnoreSet` + `IgnorePattern` support line-per-pattern, `#`
|
||||
D-4. `IgnoreSet` + `IgnorePattern` support line-per-pattern, `#`
|
||||
comments, anchored / directory-only / negated forms, and `**` across
|
||||
directories. 11 new unit tests on the matcher; 5 new dispatcher
|
||||
tests; 171 app tests still green.
|
||||
@@ -229,7 +268,7 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
`pane.resize` on viewport change. `initState` → spawn;
|
||||
`dispose` → `pane.close`. Error-state surface for "daemon not
|
||||
connected" / "shell exited." No Claude-specific behaviour — that
|
||||
lives in `builtin.claude` + D-041.
|
||||
lives in `builtin.claude` + D-41.
|
||||
|
||||
- Shared pane widgets under `app/lib/widgets/`: `ClidePtyView` wraps
|
||||
`xterm.dart` with clide-theme token bindings, JetBrains Mono as the
|
||||
@@ -241,15 +280,15 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
no IPC coupling.
|
||||
|
||||
- `xterm: 4.0.0` Dart dependency on the Flutter app — MIT, listed in
|
||||
`licenses.yaml` per D-042. Hand-rolling a VT100 / xterm / truecolour
|
||||
`licenses.yaml` per D-42. Hand-rolling a VT100 / xterm / truecolour
|
||||
parser + renderer would be weeks for no fidelity win.
|
||||
|
||||
- `Q-023` — open question on SSH-remote development (run clide against
|
||||
- `Q-23` — open question on SSH-remote development (run clide against
|
||||
a workspace on another host). Local-first stays the Tier-1 target;
|
||||
this records the constraint so the daemon / IPC / extension seams
|
||||
don't unknowingly accrete local-only assumptions.
|
||||
|
||||
- IPC `pane` subsystem in the daemon (per D-006). Commands:
|
||||
- IPC `pane` subsystem in the daemon (per D-6). Commands:
|
||||
`pane.spawn | list | focus | close | write | resize | tail`. Events:
|
||||
`pane.spawned`, `pane.output` (base64-framed), `pane.exit`,
|
||||
`pane.resized`, `pane.focused`, `pane.closed`. `PaneRegistry` owns
|
||||
@@ -274,7 +313,7 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
verification, idempotent close.
|
||||
|
||||
- `ffi: 2.1.3` as a runtime dependency on the Dart core — justified
|
||||
in `pubspec.yaml` + documented in `licenses.yaml` per D-042. Used
|
||||
in `pubspec.yaml` + documented in `licenses.yaml` per D-42. Used
|
||||
by `lib/src/pty/ffi/` for `socketpair`, `recvmsg` with `SCM_RIGHTS`,
|
||||
`read`/`write` on raw fds, and `ioctl(TIOCSWINSZ)`.
|
||||
|
||||
@@ -305,7 +344,7 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
`license_file` pointer, and a one-line purpose. Bundled alongside
|
||||
the per-dep license texts. The About screen (Tier 6) will render
|
||||
this file verbatim. Accompanied by
|
||||
[`D-042`](decisions/tooling.md#d-042-bundled-dependencies-documented-in-licensesyaml):
|
||||
[`D-42`](decisions/tooling.md#d-42-bundled-dependencies-documented-in-licensesyaml):
|
||||
adding a dep is a two-step commit (artefact + `licenses.yaml`
|
||||
entry in the same changeset).
|
||||
|
||||
@@ -313,10 +352,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
- CLAUDE.md "Dependencies & supply chain" section gains the
|
||||
"document every bundled dependency" rule, pointing at
|
||||
`app/assets/licenses.yaml` and `D-042`.
|
||||
`app/assets/licenses.yaml` and `D-42`.
|
||||
|
||||
- `ptyc/` — the C PTY-spawn helper, peer of `pql` per
|
||||
[`D-005`](decisions/architecture.md#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer).
|
||||
[`D-5`](decisions/architecture.md#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer).
|
||||
One-shot, libc-only, ~400 LOC. Reads a JSON request on stdin
|
||||
(`argv`, optional `cwd`/`env`/`cols`/`rows`), does
|
||||
`posix_openpt` + `fork` + `execvp`, and hands the master fd back
|
||||
@@ -329,10 +368,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
`ptyc-build` / `ptyc-clean`.
|
||||
|
||||
- Migrated the `docs/ADRs/` content into `decisions/` as D/R records:
|
||||
ADR 0001 → `D-001`, ADR 0002 → `R-002` (superseded by `D-005`), ADR
|
||||
0003 → `D-003`, ADR 0004 → `D-004`, ADR 0005 → `D-005`, ADR 0006 →
|
||||
`D-006`. Titles preserved; ADR 0006's trailing open questions moved
|
||||
to `questions-architecture.md` as `Q-001` / `Q-002` / `Q-003`. The
|
||||
ADR 0001 → `D-1`, ADR 0002 → `R-2` (superseded by `D-5`), ADR
|
||||
0003 → `D-3`, ADR 0004 → `D-4`, ADR 0005 → `D-5`, ADR 0006 →
|
||||
`D-6`. Titles preserved; ADR 0006's trailing open questions moved
|
||||
to `questions-architecture.md` as `Q-1` / `Q-2` / `Q-3`. The
|
||||
originals are preserved in git history.
|
||||
|
||||
- `decisions/` at the repo root — Q&D record system ported from
|
||||
@@ -357,8 +396,8 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
(`tools/scripts/plan ` → `pql `). Ported from settled-reach with
|
||||
the Scrum layer stripped; ticket IDs are `T-NNN` (TEXT PKs) and
|
||||
there's no `sprints` table. Time-limited per
|
||||
[`D-040`](decisions/process.md#d-040-python-stopgap-under-toolsscriptsplan)
|
||||
/ [`R-011`](decisions/rejected.md#r-011-permanent-stopgap).
|
||||
[`D-40`](decisions/process.md#d-40-python-stopgap-under-toolsscriptsplan)
|
||||
/ [`R-11`](decisions/rejected.md#r-11-permanent-stopgap).
|
||||
|
||||
- `make decisions-validate` — cheap parser dry-run wired into
|
||||
`push-check`. Catches malformed records before push.
|
||||
@@ -366,7 +405,7 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
- Reserved extension slots — `builtin.decisions`, `builtin.tickets`,
|
||||
`builtin.claude-control`. Id-reserving stubs under
|
||||
`app/lib/builtin/` with no contributions yet. Implementations land
|
||||
once [`Q-021`](decisions/questions-architecture.md#q-021-pql-absorbs-planning-vs-keeps-separate)
|
||||
once [`Q-21`](decisions/questions-architecture.md#q-21-pql-absorbs-planning-vs-keeps-separate)
|
||||
resolves (decisions + tickets) or when the claude-control tier
|
||||
arrives (`.claude/` first-class surface — distinct from the
|
||||
existing `builtin.claude` PTY-pane stub).
|
||||
@@ -393,9 +432,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
- `tools/scripts/plan` — Python stopgap planning scripts, superseded
|
||||
by `pql` 1.0 native `decisions` and `ticket` subcommands. Sunset
|
||||
condition from
|
||||
[`D-040`](decisions/process.md#d-040-python-stopgap-under-toolsscriptsplan)
|
||||
[`D-40`](decisions/process.md#d-40-python-stopgap-under-toolsscriptsplan)
|
||||
met; deletion per
|
||||
[`R-011`](decisions/rejected.md#r-011-permanent-stopgap).
|
||||
[`R-11`](decisions/rejected.md#r-11-permanent-stopgap).
|
||||
|
||||
### Removed
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
A Flutter desktop IDE for Claude Code. Single Flutter package at the repo root, plus small native supporter tools where Dart can't reach.
|
||||
|
||||
- **`lib/`** — all Dart code. Subsystem handlers (`lib/src/daemon/`, `lib/src/pty/`, `lib/src/ipc/`, `lib/src/git/`, `lib/src/pql/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), and the extension framework (`lib/extension/`). The Flutter app hosts the IPC server in-process (D-056).
|
||||
- **`lib/`** — all Dart code. Subsystem handlers (`lib/src/daemon/`, `lib/src/pty/`, `lib/src/ipc/`, `lib/src/git/`, `lib/src/pql/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), and the extension framework (`lib/extension/`). The Flutter app hosts the IPC server in-process (D-56).
|
||||
- **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it.
|
||||
- **`ptyc/`** — small C supporter tool, peer of pql. Spawns a PTY + child and hands the master fd back over `SCM_RIGHTS`. Clide shells out to it for every pane (shell, tmux, claude, LSP, debug adapter).
|
||||
|
||||
tmux owns Claude session persistence (D-041) — the app re-attaches on restart via `tmux new-session -A`. Native rendering — markdown, canvas, graph — is Dart/Flutter (`CustomPaint` + widgets), not third-party packages.
|
||||
tmux owns Claude session persistence (D-41) — the app re-attaches on restart via `tmux new-session -A`. Native rendering — markdown, canvas, graph — is Dart/Flutter (`CustomPaint` + widgets), not third-party packages.
|
||||
|
||||
Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Decisions: [`decisions/`](decisions/) (`D-NNN` confirmed, `Q-NNN` open, `R-NNN` rejected — see [`decisions/README.md`](decisions/README.md)). Python Textual predecessor under [`legacy/`](legacy/).
|
||||
|
||||
@@ -19,14 +19,14 @@ Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Decisions: [`decisio
|
||||
These are load-bearing. Violating any means the design is wrong, not the rule.
|
||||
|
||||
- **Flutter desktop is the host. No Electron, ever.** Web target may work as a happy accident — don't compromise desktop fidelity for it. If we ship a web build at all, prefer Flutter's **WebAssembly (CanvasKit/Skwasm) compile** over the JS/HTML renderer. `xterm.dart` is the terminal renderer; markdown, canvas, graph are custom `CustomPaint`/widget components.
|
||||
- **Single process.** The Flutter app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), extensions. No separate daemon binary (D-056 dissolved it). The CLI surface for Claude is a thin C client (ptyc peer).
|
||||
- **CLI-first, not MCP.** Claude talks via Bash (`clide ...`), matching pql's contract. See [`D-001`](decisions/architecture.md#d-001-cli-first-not-mcp).
|
||||
- **Dart is the core; native supporter tools fill specific gaps.** `ptyc` (C) for PTY spawning. `pql` (Go) for queries. No second "core language." See [`D-005`](decisions/architecture.md#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended by D-056).
|
||||
- **Single process.** The Flutter app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), extensions. No separate daemon binary (D-56 dissolved it). The CLI surface for Claude is a thin C client (ptyc peer).
|
||||
- **CLI-first, not MCP.** Claude talks via Bash (`clide ...`), matching pql's contract. See [`D-1`](decisions/architecture.md#d-1-cli-first-not-mcp).
|
||||
- **Dart is the core; native supporter tools fill specific gaps.** `ptyc` (C) for PTY spawning. `pql` (Go) for queries. No second "core language." See [`D-5`](decisions/architecture.md#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended by D-56).
|
||||
- **Own the rendering stack.** PTY (via `ptyc`), markdown renderer, graph, canvas — all clide-owned, not pulled from opinionated packages.
|
||||
- **User/Claude parity.** Every CLI subcommand has a UI affordance, and every UI action has a CLI. See [`D-006`](decisions/architecture.md#d-006-cli-and-event-surface-contract).
|
||||
- **pql: wrap, don't duplicate.** Pql logic only lives in `lib/src/pql/` (pure shell-outs). Clide owns pql's `ignore_files:` config key; it never touches pql's `.pql/` index/cache data. See [`D-003`](decisions/architecture.md#d-003-pql-as-supporter-tool-clide-wraps-never-duplicates).
|
||||
- **User/Claude parity.** Every CLI subcommand has a UI affordance, and every UI action has a CLI. See [`D-6`](decisions/architecture.md#d-6-cli-and-event-surface-contract).
|
||||
- **pql: wrap, don't duplicate.** Pql logic only lives in `lib/src/pql/` (pure shell-outs). Clide owns pql's `ignore_files:` config key; it never touches pql's `.pql/` index/cache data. See [`D-3`](decisions/architecture.md#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates).
|
||||
- **Repo-is-the-workspace.** The git repo root is the workspace — no parallel "vault" concept.
|
||||
- **Ignore discipline.** Single knob: `ignore_files:` in `.pql/config.yaml`, ordered layering. See [`D-004`](decisions/architecture.md#d-004-ignore-file-strategy).
|
||||
- **Ignore discipline.** Single knob: `ignore_files:` in `.pql/config.yaml`, ordered layering. See [`D-4`](decisions/architecture.md#d-4-ignore-file-strategy).
|
||||
- **Decision discipline.** All architectural choices live in `decisions/<domain>.md` as `D-NNN` records. Open questions as `Q-NNN`. Rejected alternatives as `R-NNN`. Claim new IDs via `pql decisions claim D <domain> "title"`. See [`decisions/README.md`](decisions/README.md).
|
||||
|
||||
## Repo layout
|
||||
@@ -55,7 +55,7 @@ legacy/ # Python Textual clide v1.2 (frozen)
|
||||
## Dependencies & supply chain
|
||||
|
||||
- **Prefer-zero-deps.** Flutter-SDK widgets first; third-party packages need justification. What stays is exact-pinned in `pubspec.yaml` (no caret ranges). Advisories reviewed before every bump; `pubspec.lock` committed.
|
||||
- **Document every bundled dependency.** Listed in [`assets/licenses.yaml`](assets/licenses.yaml) with name, kind, version, homepage, license, and purpose. Adding a dep is a two-step commit: add the artefact **and** the `licenses.yaml` entry. See [`D-042`](decisions/tooling.md#d-042-bundled-dependencies-documented-in-licensesyaml).
|
||||
- **Document every bundled dependency.** Listed in [`assets/licenses.yaml`](assets/licenses.yaml) with name, kind, version, homepage, license, and purpose. Adding a dep is a two-step commit: add the artefact **and** the `licenses.yaml` entry. See [`D-42`](decisions/tooling.md#d-42-bundled-dependencies-documented-in-licensesyaml).
|
||||
- **`ptyc` and any future native supporter tool:** no dep graph by design (libc-only for `ptyc`). "Audit" is reading the source before each bump.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -19,12 +19,16 @@ help: ## Show this help.
|
||||
|
||||
.PHONY: run
|
||||
run: ## Launch the Flutter desktop app.
|
||||
LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter run -d linux
|
||||
GDK_BACKEND=x11 LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter run -d linux
|
||||
|
||||
.PHONY: pubget
|
||||
pubget: ## flutter pub get.
|
||||
flutter pub get
|
||||
|
||||
.PHONY: build-check
|
||||
build-check: ## Verify native + Dart build compiles (no run).
|
||||
LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter build linux
|
||||
|
||||
.PHONY: analyze
|
||||
analyze: ## flutter analyze.
|
||||
flutter analyze
|
||||
|
||||
@@ -63,6 +63,7 @@ This is not a preference. It is the project's architectural stance, documented i
|
||||
- **Prefer inlining over depending.** If a package is small and does one thing we need, copy the relevant code (with attribution) rather than taking on the dependency. License permitting, this is usually the right call for utilities under a few hundred lines.
|
||||
- **Prefer vendoring over fetching.** When a dependency is unavoidable and small enough to own, fork it into the repo and maintain it ourselves. The dep graph stops at our fence.
|
||||
- **Reject deep trees.** A direct dependency that itself has fifteen transitive dependencies is fifteen dependencies we are taking on, not one. Evaluate the whole subtree, not just the top.
|
||||
- **Format engines clear the bar.** Packages that parse or render external file formats (SVG, markdown, HTML, terminal escapes, tree-sitter grammars) are adoptable — they are not shortcuts for lazy coding but maintained renderers for specs we didn't write. The distinction: UI chrome (panels, tabs, canvas, layout) we own; someone else's file format we adopt and sandbox. See D-58.
|
||||
|
||||
### Every dependency is audited
|
||||
|
||||
|
||||
@@ -142,6 +142,39 @@ dependencies:
|
||||
wasmtime engine. Source repos are tree-sitter/tree-sitter-<lang>
|
||||
on GitHub; sqlite is dhcmrlchtdj/tree-sitter-sqlite (MIT).
|
||||
|
||||
- name: jovial_svg
|
||||
kind: dart-package
|
||||
version: "1.1.26"
|
||||
homepage: https://pub.dev/packages/jovial_svg
|
||||
license: BSD-3-Clause
|
||||
purpose: >-
|
||||
SVG renderer. CustomPaint-based — parses SVG at runtime into
|
||||
a DAG of drawing commands and renders via Canvas. Used for
|
||||
workspace SVG files and the app icon. Format engine, not UI
|
||||
chrome.
|
||||
|
||||
- name: markdown
|
||||
kind: dart-package
|
||||
version: "7.2.2"
|
||||
homepage: https://pub.dev/packages/markdown
|
||||
license: BSD-3-Clause
|
||||
purpose: >-
|
||||
Markdown parser (AST only). Parses GFM (tables, task lists,
|
||||
fenced code blocks) into a node tree. Clide owns the renderer
|
||||
— walks the AST to produce Flutter widgets with tree-sitter
|
||||
syntax highlighting in code blocks.
|
||||
|
||||
- name: flutter_widget_from_html_core
|
||||
kind: dart-package
|
||||
version: "0.17.2"
|
||||
homepage: https://pub.dev/packages/flutter_widget_from_html_core
|
||||
license: MIT
|
||||
purpose: >-
|
||||
HTML-to-widget renderer. Parses HTML into Flutter widgets
|
||||
with no JavaScript execution. Used for rendering HTML content
|
||||
in the viewer. Sandboxed — tag/attribute whitelist enforced
|
||||
at usage site.
|
||||
|
||||
# Build-time-only dependencies — test runners, mocks, lints. Tracked
|
||||
# here for audit completeness; NOT rendered in the About screen.
|
||||
dev_dependencies:
|
||||
|
||||
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 73 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" width="160" height="160">
|
||||
<circle cx="80" cy="80" r="72" fill="#20202C"/>
|
||||
<g fill="none" stroke-linecap="round" stroke-linejoin="round" transform="translate(-20,0)">
|
||||
<polyline points="89,55 66,80 89,105" stroke="#5691a1" stroke-width="8"/>
|
||||
<polyline points="111,55 134,80 111,105" stroke="#33404c" stroke-width="8"/>
|
||||
<line x1="100" y1="118" x2="134" y2="118" stroke="#d97757" stroke-width="5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 487 B |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 950 B |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 969 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 160" width="200" height="160">
|
||||
<g fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" width="160" height="160">
|
||||
<g fill="none" stroke-linecap="round" stroke-linejoin="round" transform="translate(-20,0)">
|
||||
<polyline points="89,55 66,80 89,105" stroke="#5691a1" stroke-width="8"/>
|
||||
<polyline points="111,55 134,80 111,105" stroke="#33404c" stroke-width="8"/>
|
||||
<line x1="100" y1="118" x2="134" y2="118" stroke="#d97757" stroke-width="5"/>
|
||||
|
Before Width: | Height: | Size: 408 B After Width: | Height: | Size: 437 B |
@@ -49,7 +49,7 @@ Domain-specific fields (`Kill switch:`, `Evaluation reports:`,
|
||||
`Amendment:`, `Cross-reference:`) are additive. Amendments are inline
|
||||
and dated: `**Amendment (YYYY-MM-DD):** …`. Cross-references use
|
||||
markdown anchor links with the full slug:
|
||||
`[D-005](architecture.md#d-005-dart-core-ptyc-peer)`.
|
||||
`[D-5](architecture.md#d-5-dart-core-ptyc-peer)`.
|
||||
|
||||
Open questions (`Q-NNN`):
|
||||
|
||||
@@ -73,7 +73,7 @@ Rejected alternatives (`R-NNN`):
|
||||
|
||||
## Claiming an ID
|
||||
|
||||
Until the pql planning subcommands land ([`Q-021`](questions-process.md)),
|
||||
Until the pql planning subcommands land ([`Q-21`](questions-process.md)),
|
||||
claim IDs by inspecting the highest existing `D-NNN` / `Q-NNN` /
|
||||
`R-NNN` in the target file and incrementing.
|
||||
|
||||
@@ -89,7 +89,7 @@ Common queries:
|
||||
|
||||
```bash
|
||||
pql decisions list --type confirmed --domain architecture
|
||||
pql decisions show D-005 --with-refs
|
||||
pql decisions show D-5 --with-refs
|
||||
pql decisions coverage # D-records without tickets
|
||||
pql decisions validate # pre-push parser gate
|
||||
pql ticket board # kanban view of tickets
|
||||
|
||||
@@ -4,21 +4,21 @@ A11y + i18n are Tier-0 contracts, not Tier-6 polish.
|
||||
|
||||
---
|
||||
|
||||
### D-020: A11y is a Tier-0 contract
|
||||
### D-20: A11y is a Tier-0 contract
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Every widget primitive wraps its interaction surface in a `Semantics` node at the point of creation. A11y coverage is a test-time gate (`ci/test_a11y.sh`), not a post-hoc polish pass. `ensureSemantics()` fires at app boot; Flutter's semantics tree is always populated.
|
||||
- **Rationale:** Retrofitting a11y onto a grown UI is what every project that skips this promises to do later and then doesn't. Making it a Tier-0 contract costs one `Semantics` line per primitive and a semantic-coverage test; postponing costs a rewrite.
|
||||
- **Cost:** Widget authors maintain correct labels; tests reject new primitives without semantics. Enforced by `app/test/a11y/` coverage tests.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-021: i18n is a Tier-0 contract (fframe pattern + locale-fallback chain)
|
||||
### D-21: i18n is a Tier-0 contract (fframe pattern + locale-fallback chain)
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** All user-facing strings resolve through a namespaced i18n catalogue loader ported from fframe's text-driven pattern, extended with a locale-fallback chain fframe lacks. JSON per locale; `I18n.of(context).t('namespace.key', {vars})`. Missing keys resolve down the chain (e.g. `en_GB` → `en` → default), never fail silently; missing at the base locale logs a dev-mode error.
|
||||
- **Rationale:** Flutter's `intl` + ARB codegen is inflexible for plugin-contributed catalogs (see [R-004](rejected.md#r-004-flutter-intl-and-arb-codegen)) — we need per-extension catalogs that merge without a codegen step. fframe's shape fits; its silent-fallback behaviour does not, so we add the chain.
|
||||
- **Rationale:** Flutter's `intl` + ARB codegen is inflexible for plugin-contributed catalogs (see [R-4](rejected.md#r-4-flutter-intl-and-arb-codegen)) — we need per-extension catalogs that merge without a codegen step. fframe's shape fits; its silent-fallback behaviour does not, so we add the chain.
|
||||
- **Cost:** JSON has no comments and no trailing commas; translation tooling has to accept that. Separate `i18n` facade on every feature.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-022: WCAG-AA contrast gate on bundled themes
|
||||
### D-22: WCAG-AA contrast gate on bundled themes
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Every bundled theme must pass a WCAG-AA contrast check on its canonical token pairs (text/background, link/background, focus-ring/background) at test time. `ci/test_a11y.sh` runs the gate; CI fails on regressions.
|
||||
- **Rationale:** Themes drift under "looks nicer" tweaks; contrast regressions land silently. Running the gate on every PR is the cheapest insurance. Ran the gate on initial themes — caught one summer-night muted token at 2.81:1 (below AA), fixed before landing.
|
||||
|
||||
@@ -4,56 +4,56 @@ Core, rendering, IPC, kernel, panel manager.
|
||||
|
||||
---
|
||||
|
||||
### D-007: App root is bare `WidgetsApp`
|
||||
### D-7: App root is bare `WidgetsApp`
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** The Flutter app root is `WidgetsApp`, not `MaterialApp` or `CupertinoApp`. Clide's look is fully custom; the Material/Cupertino shells would drag in opinionated theming, default icons, and platform chrome we'd then have to fight.
|
||||
- **Rationale:** Clide is a Linux-primary desktop IDE with a custom theme pipeline and custom primitives (panels, tabs, panes, canvas). Material's implicit theming collides with [D-009](#d-009-three-tier-theme-pipeline); Cupertino is iOS-flavoured. `WidgetsApp` gives us routing, locale, focus traversal, semantics, and Directionality without aesthetic baggage.
|
||||
- **Cost:** We build and own every primitive; no `ElevatedButton` fallback. See [R-003](rejected.md#r-003-materialapp-root) and [R-007](rejected.md#r-007-cupertinoapp-root).
|
||||
- **Rationale:** Clide is a Linux-primary desktop IDE with a custom theme pipeline and custom primitives (panels, tabs, panes, canvas). Material's implicit theming collides with [D-9](#d-9-three-tier-theme-pipeline); Cupertino is iOS-flavoured. `WidgetsApp` gives us routing, locale, focus traversal, semantics, and Directionality without aesthetic baggage.
|
||||
- **Cost:** We build and own every primitive; no `ElevatedButton` fallback. See [R-3](rejected.md#r-3-materialapp-root) and [R-7](rejected.md#r-7-cupertinoapp-root).
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-008: Feature-first folder layout
|
||||
### D-8: Feature-first folder layout
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Under `app/lib/`, organise by feature (`kernel/`, `extension/`, `widgets/`, `builtin/<name>/`) rather than by layer (`models/`, `views/`, `controllers/`). Private implementation lives under each feature's `src/`; the feature's public surface is a barrel file at the feature root (e.g. `app/lib/kernel/kernel.dart`).
|
||||
- **Rationale:** Features grow and get deleted as units; layer-first layouts fragment a feature across three directories and make deletions risky. Matches extensions-as-features (every extension already has its own folder).
|
||||
- **Cost:** Imports cross features only via the barrel — enforce by review, no automated check yet.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-009: Three-tier theme pipeline
|
||||
### D-9: Three-tier theme pipeline
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Themes resolve through three layers: (1) palette — raw named colours per theme YAML; (2) semantic — roles like `surface.background`, `text.primary`, `accent.focus`; (3) surface — component-scoped tokens derived from semantic roles (button bg/fg/border hover/pressed/disabled states).
|
||||
- **Rationale:** Direct palette-to-component binding collapses under multi-theme work; VS Code's 600-token surface map is the proof. The semantic layer is where a11y contrast gates apply; the surface layer is where components bind.
|
||||
- **Cost:** Three layers to keep coherent per theme. Contrast gate ([D-022](accessibility.md#d-022-wcag-aa-contrast-gate-on-bundled-themes)) enforces the semantic layer on every bundled theme.
|
||||
- **Cost:** Three layers to keep coherent per theme. Contrast gate ([D-22](accessibility.md#d-22-wcag-aa-contrast-gate-on-bundled-themes)) enforces the semantic layer on every bundled theme.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-010: State management — `ChangeNotifier` + `ListenableBuilder`
|
||||
### D-10: State management — `ChangeNotifier` + `ListenableBuilder`
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Per-feature state uses `ChangeNotifier` exposed through a feature facade (singleton-per-kernel); widgets subscribe via `ListenableBuilder`. No Riverpod, Provider, BLoC, or Redux.
|
||||
- **Rationale:** SDK-shipped, zero deps, trivial to fake in tests (hand-rolled fakes in [D-025](testing.md#d-025-mocks-mocktail-at-io-plus-hand-rolled-fakes)). Violates [D-031 prefer-zero-deps](tooling.md#d-031-prefer-zero-deps-exact-pin) otherwise. See [R-008](rejected.md#r-008-riverpod-provider-bloc-for-state).
|
||||
- **Rationale:** SDK-shipped, zero deps, trivial to fake in tests (hand-rolled fakes in [D-25](testing.md#d-25-mocks-mocktail-at-io-plus-hand-rolled-fakes)). Violates [D-31 prefer-zero-deps](tooling.md#d-31-prefer-zero-deps-exact-pin) otherwise. See [R-8](rejected.md#r-8-riverpod-provider-bloc-for-state).
|
||||
- **Cost:** No codegen ergonomics; manual `notifyListeners()` discipline. The `ListenableBuilder.listenable` contract rejects rebuilds outside the subscribed notifier — intentional.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-011: Panel manager is kernel; layout is data; three-column is a preset
|
||||
### D-11: Panel manager is kernel; layout is data; three-column is a preset
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** The kernel owns a panel manager that treats layout as declarative data (tree of splits + leaves). The default "three-column IDE" (sidebar / editor / assistant) is one preset; alternative presets (writer-focus single-column, debugger four-pane) ship as data, not code forks.
|
||||
- **Rationale:** Hard-coded three-column layouts paint us into corners when future tiers add canvas, graph, terminal-grid. Data-driven layout also lets extensions contribute presets without patching the panel manager.
|
||||
- **Cost:** More kernel surface up-front; pays back at Tier 5 (canvas) and Tier 6 (extension-contributed layouts).
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-012: Kernel admission rule — mandatory shared singletons only
|
||||
### D-12: Kernel admission rule — mandatory shared singletons only
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** A service joins the kernel only if it is (a) mandatory for app boot and (b) a shared singleton across features. Everything else is an extension or a feature-local service.
|
||||
- **Rationale:** Keeps the kernel auditable. Previous drafts piled "useful globals" into the kernel; result was a 40-service god-object. The admission rule forced 18 services out of 31 candidates.
|
||||
- **Cost:** Some legitimate cross-cutting concerns (telemetry, crash reporter when they land) must pass the test; we expect a few more admissions as Tiers 3-6 land.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-013: Git hardcoded in kernel project-loader
|
||||
### D-13: Git hardcoded in kernel project-loader
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** The kernel's project loader treats "repo root" as a `git` concept — runs `git rev-parse --show-toplevel` to find workspace root, subscribes to filesystem events, and shells out to `git` for status/diff/stage. No VCS abstraction layer.
|
||||
- **Rationale:** Option B (VCS abstraction) is premature generalisation — we have one VCS today, Mercurial/Fossil/Sapling users are a rounding error on the Linux desktop IDE market, and the abstraction adds a seam that has to be tested against nothing. When a second VCS shows up we refactor.
|
||||
- **Cost:** Adding Mercurial support later costs a real refactor, not just a plugin. Acceptable.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-014: Two-tier disable — kernel locked, everything else extension-shaped
|
||||
### D-14: Two-tier disable — kernel locked, everything else extension-shaped
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Kernel services cannot be disabled at runtime. Extensions (including every bundled built-in) can be toggled via the extension manager. This creates exactly two disable tiers: kernel (always on) and extension (toggleable).
|
||||
- **Rationale:** A three-tier system (kernel / bundled-cannot-disable / user-can-disable) is dishonest — if a "bundled built-in" can't be disabled, it's kernel and belongs in kernel admission review. Forcing every bundled feature to pass the extension contract is also the best test we have that the contract is actually usable.
|
||||
@@ -62,156 +62,156 @@ Core, rendering, IPC, kernel, panel manager.
|
||||
|
||||
---
|
||||
|
||||
### D-041: Claude panes — one primary per repo, tmux-backed
|
||||
### D-41: Claude panes — one primary per repo, tmux-backed
|
||||
- **Date:** 2026-04-22
|
||||
- **Decision:** Every repo (keyed on the git root) hosts **exactly one primary Claude pane** plus zero or more **secondary** Claude panes. The primary persists across clide restarts; secondaries are ephemeral. Persistence layer is **tmux**: the daemon spawns the primary as `tmux new-session -A -s clide-claude-<repohash> -- claude`, which re-attaches to the running session if the app restarts. Secondaries spawn as `tmux new-session -A -s clide-claude-<repohash>-N -- claude` with `N` incrementing. Close semantics: closing a secondary kills that tmux session and focus collapses back to the primary (or to the next-most-recent secondary); the primary has **no close affordance** — close-gestures on it hide it / minimise to a dock, they don't kill the session. Daemon is the owner; the UI doesn't track tmux session state directly, it just asks the pane subsystem to spawn/close and observes events. General-purpose terminal panes (`builtin.terminal`) do **not** get tmux wrapping or persistence — they're per-app-lifetime.
|
||||
- **Context:** 2026-04-22 planning. The user workflow is "open repo → Claude is already there, with my last conversation intact." A cold session-restart every time clide re-launches defeats the premise. tmux already solves "reattach to a shell-like session across disconnects"; layering our own persistence protocol on top of ptyc would duplicate it.
|
||||
- **Rationale:** (1) tmux is battle-tested — no new persistence code to review. (2) The pane subsystem stays neutral; Claude-specific behaviour lives in `builtin.claude`. (3) Keying by git root means the user doesn't manage session names manually — opening a repo is enough. (4) "Always one primary" removes a failure mode: there's never "no Claude to talk to." (5) Secondaries stay frictionless — the user spawns and closes them at will without breaking the primary.
|
||||
- **Cost:** Requires tmux on the PATH of the daemon's runtime environment (reasonable for Linux + macOS; Windows support via WSL or a separate approach). Killing a primary (via the daemon on shutdown) still leaves the detached tmux session around until the next clide start re-attaches; acceptable but worth documenting for support. Secondary numbering (`-1`, `-2`, …) resets between clide runs since ephemeral state is lost — also acceptable.
|
||||
- **Raised by:** 2026-04-22 planning, Tier 1 implementation.
|
||||
- **Cross-reference:** [`D-005`](#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (ptyc as the spawn primitive tmux runs under), [`D-006`](#d-006-cli-and-event-surface-contract) (pane.\* IPC surface), [`R-009`](rejected.md#r-009-port-planning-tooling-into-clide) (why per-repo scoping via git root matches the wrap-don't-duplicate theme).
|
||||
- **Cross-reference:** [`D-5`](#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (ptyc as the spawn primitive tmux runs under), [`D-6`](#d-6-cli-and-event-surface-contract) (pane.\* IPC surface), [`R-9`](rejected.md#r-9-port-planning-tooling-into-clide) (why per-repo scoping via git root matches the wrap-don't-duplicate theme).
|
||||
|
||||
### D-001: CLI-first, not MCP
|
||||
### D-1: CLI-first, not MCP
|
||||
- **Date:** 2026-04-20 (was ADR 0001; ported from the claudian lineage)
|
||||
- **Decision:** Claude talks to clide exclusively via Bash (`clide …`). No MCP server. No protocol layer in Claude's face. The CLI uses the same exit-code + stderr-JSON contract as pql.
|
||||
- **Context:** The two mainstream options for the agent-facing surface were an MCP server or a plain Bash CLI matching pql's contract.
|
||||
- **Rationale:** Same mental model as pql for the agent — one tool-use pattern covers both. No MCP runtime to host, authenticate, or keep in sync with client versions. User/Claude parity is easier to enforce: every CLI subcommand must have a UI affordance in the Flutter app and vice versa ([D-006](#d-006-cli-and-event-surface-contract)). Claude Code's `Bash(clide *)` allow rule is the only configuration clide needs on the agent side.
|
||||
- **Rationale:** Same mental model as pql for the agent — one tool-use pattern covers both. No MCP runtime to host, authenticate, or keep in sync with client versions. User/Claude parity is easier to enforce: every CLI subcommand must have a UI affordance in the Flutter app and vice versa ([D-6](#d-6-cli-and-event-surface-contract)). Claude Code's `Bash(clide *)` allow rule is the only configuration clide needs on the agent side.
|
||||
- **Cost:** If an MCP-only integration becomes compelling later (e.g. a multi-agent scenario), nothing here precludes adding one that shells out to the same CLI.
|
||||
- **Raised by:** Ported from the claudian lineage.
|
||||
|
||||
### D-003: pql as supporter tool; clide wraps, never duplicates
|
||||
### D-3: pql as supporter tool; clide wraps, never duplicates
|
||||
- **Date:** 2026-04-20 (was ADR 0003; ported from the claudian lineage)
|
||||
- **Decision:** Two complementary rules. **(1) Wrap, don't duplicate.** Clide never re-implements backlinks, ranking, frontmatter parsing, or wikilink resolution for query purposes. If a capability is missing in pql, it is added upstream in pql's repo and clide bumps the dependency. The only place clide contains pql logic is `lib/src/pql/` — pure shell-outs to the `pql` binary. **(2) pql is a clide subsystem when clide is present in the repo.** On load, clide writes its current state into `.pql/config.yaml` — no conditional sync. Clide only stomps keys it manages (starting with `ignore_files:` — see [D-004](#d-004-ignore-file-strategy)). Other pql config keys are left alone. Clide does **not** touch pql's index/cache data under `<repo>/.pql/` — that stays pql's private store.
|
||||
- **Decision:** Two complementary rules. **(1) Wrap, don't duplicate.** Clide never re-implements backlinks, ranking, frontmatter parsing, or wikilink resolution for query purposes. If a capability is missing in pql, it is added upstream in pql's repo and clide bumps the dependency. The only place clide contains pql logic is `lib/src/pql/` — pure shell-outs to the `pql` binary. **(2) pql is a clide subsystem when clide is present in the repo.** On load, clide writes its current state into `.pql/config.yaml` — no conditional sync. Clide only stomps keys it manages (starting with `ignore_files:` — see [D-4](#d-4-ignore-file-strategy)). Other pql config keys are left alone. Clide does **not** touch pql's index/cache data under `<repo>/.pql/` — that stays pql's private store.
|
||||
- **Context:** [`pql`](https://github.com/postmeridiem/pql) is a pre-existing Go CLI that indexes a markdown-bearing directory tree into SQLite and exposes frontmatter, wikilinks, tags, headings, and bases through a query surface. Clide needs those capabilities for its Query panel, canvas drivers, graph view, and any feature that needs to know structure.
|
||||
- **Rationale:** One source of truth for markdown semantics. Any new query capability the UI wants goes through a pql upstream PR, not a local workaround. Users never have to learn pql's config file to get consistent behaviour — clide manages it. The arrow clide → pql is never inverted: pql stays ignorant of its wrapper.
|
||||
- **Cost:** Clide's `lib/src/pql/` package is deliberately thin. pql is also the **only** query engine — Obsidian-style inline "bases" are explicitly not supported; queries live at the repo level. In repos without clide, pql works standalone unaffected.
|
||||
- **Raised by:** Ported from the claudian lineage. Load-bearing for [D-039](process.md#d-039-planning-tooling-lives-in-pql).
|
||||
- **Raised by:** Ported from the claudian lineage. Load-bearing for [D-39](process.md#d-39-planning-tooling-lives-in-pql).
|
||||
|
||||
### D-004: Ignore file strategy
|
||||
### D-4: Ignore file strategy
|
||||
- **Date:** 2026-04-20 (was ADR 0004; ported from the claudian lineage)
|
||||
- **Decision:** One mechanism everywhere: the `ignore_files:` list in `.pql/config.yaml`. Ordered list of gitignore-shaped files; later entries win on per-pattern conflicts. pql defaults to `ignore_files: [.gitignore]`. Per [D-003](#d-003-pql-as-supporter-tool), clide writes the list on load — `[.gitignore, .clideignore]` if `.clideignore` exists, else `[.gitignore]`. `.clideignore` carries **only** the clide-specific deviations from `.gitignore` (supports `!pattern` negations); never duplicate gitignore's contents. Walker magic: none except `.git/` — every other tool-owned dir (`.pql/`, `.clide/`) is added to `.gitignore` at install time; exclusion flows through the normal `ignore_files:` chain.
|
||||
- **Decision:** One mechanism everywhere: the `ignore_files:` list in `.pql/config.yaml`. Ordered list of gitignore-shaped files; later entries win on per-pattern conflicts. pql defaults to `ignore_files: [.gitignore]`. Per [D-3](#d-3-pql-as-supporter-tool), clide writes the list on load — `[.gitignore, .clideignore]` if `.clideignore` exists, else `[.gitignore]`. `.clideignore` carries **only** the clide-specific deviations from `.gitignore` (supports `!pattern` negations); never duplicate gitignore's contents. Walker magic: none except `.git/` — every other tool-owned dir (`.pql/`, `.clide/`) is added to `.gitignore` at install time; exclusion flows through the normal `ignore_files:` chain.
|
||||
- **Context:** Every file-enumerating surface in clide (pql query panels, canvas drivers, graph view, file watchers, pane lists, file tree) needs to skip the obvious junk — `vendor/`, `node_modules/`, `dist/`, build artifacts — or results drown in noise. Clide's working assumption is that the git repo *is* the workspace — no separate "vault" concept.
|
||||
- **Rationale:** Users get one config knob, in a file they might already know (pql users) or never need to touch (clide-only users). `.clideignore` is short by design — it's deltas, not a full list. Sidecar consumers read the same key and apply identical precedence, so Claude and the user always see the same filtered surface.
|
||||
- **Cost:** Removing clide from a repo leaves pql working with vanilla defaults (clide's last-written `ignore_files:` stays until pql or the user rewrites it; worth reconsidering during uninstall design).
|
||||
- **Raised by:** Ported from the claudian lineage.
|
||||
|
||||
### D-005: Dart core; sidecar dissolved; `ptyc` as pql-peer
|
||||
- **Date:** 2026-04-20 (was ADR 0005; supersedes [R-002](rejected.md#r-002-go-sidecar))
|
||||
- **Amendment (2026-04-23):** The separate daemon process and two-package layout are dissolved per [D-056](#d-056-dissolve-daemon-process-flutter-app-hosts-ipc-server). Dart-core and ptyc-as-peer principles survive; the daemon binary does not.
|
||||
### D-5: Dart core; sidecar dissolved; `ptyc` as pql-peer
|
||||
- **Date:** 2026-04-20 (was ADR 0005; supersedes [R-2](rejected.md#r-2-go-sidecar))
|
||||
- **Amendment (2026-04-23):** The separate daemon process and two-package layout are dissolved per [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server). Dart-core and ptyc-as-peer principles survive; the daemon binary does not.
|
||||
- **Decision:** Three moves. **(1) Dart is the core language.** Everything that used to live under `sidecar/` — IPC server, CLI dispatch, process management, file watching, git shell-outs, pql wrapper — is written in Dart. Two execution modes of one Dart AOT binary: `clide <subcommand>` (one-shot, pql-style) and `clide --daemon` (long-running, owns PTYs and subprocesses, survives app restarts). The Flutter app imports the Dart core as a library *and* connects to the daemon over IPC. **(2) The sidecar directory dissolves.** Layout is `app/` (Flutter UI), `lib/` (Dart core), `bin/clide.dart` (AOT entry), `ptyc/` (C helper), no `sidecar/`, no Go module. **(3) `ptyc` is a pql-peer supporter tool.** Small C binary that does `posix_openpt` + `fork` + `exec` + fd-passing via `SCM_RIGHTS`; clide wraps it the same way it wraps pql. Shells out for every PTY (terminal pane, tmux session, Claude, LSP server, debug adapter — one code path). Consumers other than clide can use `ptyc` standalone.
|
||||
- **Context:** [R-002](rejected.md#r-002-go-sidecar) picked Go for the sidecar/CLI on two premises: (a) the heavy work belongs in a language separate from the UI layer, and (b) pql is Go so the muscle memory transfers. On reassessment, both premises broke: the "heavy work" is I/O-bound glue that `dart:io` covers cleanly — the real choice was **separate process vs shared language**, and separate-process is what matters. PTY is the one place Dart is genuinely weak (multi-threaded VM can't safely `fork()`), and once you accept a small native helper, *nothing else* needs to be in the same language.
|
||||
- **Context:** [R-2](rejected.md#r-2-go-sidecar) picked Go for the sidecar/CLI on two premises: (a) the heavy work belongs in a language separate from the UI layer, and (b) pql is Go so the muscle memory transfers. On reassessment, both premises broke: the "heavy work" is I/O-bound glue that `dart:io` covers cleanly — the real choice was **separate process vs shared language**, and separate-process is what matters. PTY is the one place Dart is genuinely weak (multi-threaded VM can't safely `fork()`), and once you accept a small native helper, *nothing else* needs to be in the same language.
|
||||
- **Rationale:** One toolchain for the IDE proper (Flutter + Dart). C toolchain needed only to build `ptyc` — tiny, rarely-changing. Session persistence stays because PTY master fds live in the Dart daemon process, not the app. `ptyc` naming: **p** for *project* (parallel to pql's *project query language*), **ptyc** reads as both "PTY + child" (domain vocabulary) and "PTY + C" (implementation language). Usable from Dart, Python, Go, shell — anywhere a subprocess can be spawned and a fd received.
|
||||
- **Cost:** Rust remains an escape hatch, not a plan. If a Dart limit later forces a second native helper (file-watching at scale on macOS, a tree-sitter host, etc.), the precedent is: new native need → new supporter tool, peer of pql and `ptyc`. Never a second "core language." Supply-chain gates stay, shape changes — Go `govulncheck` removed, Dart advisories review + exact-pin stays, `ptyc` gets a "read the 150 lines" review checklist (see `make security`).
|
||||
- **Raised by:** 2026-04-20 reassessment. See also the `ptyc` naming note in the original ADR (read as Project Terminal Controller / PTY+C / PTY+child).
|
||||
|
||||
### D-006: CLI and event surface contract
|
||||
### D-6: CLI and event surface contract
|
||||
- **Date:** 2026-04-20 (was ADR 0006)
|
||||
- **Decision:** The CLI is organised into **subsystems**. Each subsystem owns a noun, a set of verbs, and a set of events. The set is closed at any point in time (documented); growth is additive (new verbs, new events — never renaming existing ones without a version bump). Initial subsystems (by tier): `pane`, `tab`, `open`, `editor`, `panel`, `tree`, `git`, `pql`, `canvas`, `graph`, `theme`, `settings`, `project`. Two umbrella entry points sit outside any subsystem: `clide tail --events [--filter <subsystem>[:<id>]]` and `clide status`. Command shape: `clide <subsystem> <verb> [<positional>...] [--flag ...] [-- argv...]`. Exit codes parity with pql (`0/1/2/3/4` + `64-78` sysexits reserved); diagnostic JSON on **stderr** on non-zero exit; stdout stays machine-parseable on success. Events are JSON objects, one per line, with `v`, `ts`, `type` (`<subsystem>.<verb_past|noun_changed>`), `subsystem`, `id`, and `payload`; binary payloads base64. Every state-changing command emits at least one event; read-only commands emit nothing. Replay buffer per subsystem (default depth 16) so late subscribers still see recent effects. Parity rule: every UI affordance has a matching CLI verb (or a follow-up task naming the verb); every CLI verb surfaces in the UI (or documents why it's Claude-only).
|
||||
- **Context:** [D-001](#d-001-cli-first-not-mcp) established that Claude drives clide via a Bash CLI. That decided the *channel* — it did not define the *surface*. CLAUDE.md stated the rule colloquially ("every CLI subcommand has a UI affordance … if you add one side without the other, the feature is incomplete"); this record restates it as an implementable contract that satisfies user/Claude parity, daemon-as-authoritative-state, and pql-style ergonomics at once.
|
||||
- **Rationale:** Surface is enumerable — adding a subsystem means adding a row and specifying verbs + events. Wire schema is versioned (`v: 1` starting point; compatibility breaks bump the major and land alongside a `pubspec.yaml` `schema_version:` bump — see [Q-005](questions-architecture.md#q-005-ipc-wire-format-stability)). Events are the only UI→app state channel; the Flutter app does not poll. Extensions inherit this — a Dart extension publishes a subsystem; the same registration pipeline exposes it to Claude via the CLI.
|
||||
- **Cost:** Replay-buffer memory per subsystem (cheap — most emit seldom). Back-pressure on firehose streams ([Q-002](questions-architecture.md#q-002-back-pressure-on-event-streams)), authorisation granularity ([Q-001](questions-architecture.md#q-001-authorisation-granularity)), and event persistence ([Q-003](questions-architecture.md#q-003-event-persistence-audit-undo)) are all deferred until Tier 1 is in real use.
|
||||
- **Context:** [D-1](#d-1-cli-first-not-mcp) established that Claude drives clide via a Bash CLI. That decided the *channel* — it did not define the *surface*. CLAUDE.md stated the rule colloquially ("every CLI subcommand has a UI affordance … if you add one side without the other, the feature is incomplete"); this record restates it as an implementable contract that satisfies user/Claude parity, daemon-as-authoritative-state, and pql-style ergonomics at once.
|
||||
- **Rationale:** Surface is enumerable — adding a subsystem means adding a row and specifying verbs + events. Wire schema is versioned (`v: 1` starting point; compatibility breaks bump the major and land alongside a `pubspec.yaml` `schema_version:` bump — see [Q-5](questions-architecture.md#q-5-ipc-wire-format-stability)). Events are the only UI→app state channel; the Flutter app does not poll. Extensions inherit this — a Dart extension publishes a subsystem; the same registration pipeline exposes it to Claude via the CLI.
|
||||
- **Cost:** Replay-buffer memory per subsystem (cheap — most emit seldom). Back-pressure on firehose streams ([Q-2](questions-architecture.md#q-2-back-pressure-on-event-streams)), authorisation granularity ([Q-1](questions-architecture.md#q-1-authorisation-granularity)), and event persistence ([Q-3](questions-architecture.md#q-3-event-persistence-audit-undo)) are all deferred until Tier 1 is in real use.
|
||||
- **Raised by:** 2026-04-20 planning.
|
||||
|
||||
### D-043: Design handoff — adopt token palettes, reject Material wrapper
|
||||
### D-43: Design handoff — adopt token palettes, reject Material wrapper
|
||||
- **Date:** 2026-04-22
|
||||
- **Decision:** The claude.ai/design handoff (`docs/claude-design/`) delivers hi-fi mockups, interaction flows, a design system, and four theme palettes (clide, midnight, paper, terminal) as Dart files using `MaterialApp`/`ThemeData`. We adopt the colour tokens, layout annotations, typography direction, and syntax highlighting palettes. We reject the `MaterialApp` wrapper — tokens are translated into our existing YAML theme pipeline and `SurfaceTokens` (per [D-007](#d-007-app-root-is-bare-widgetsapp)). The design files stay in `docs/claude-design/` as reference; they are not runtime assets.
|
||||
- **Decision:** The claude.ai/design handoff (`docs/claude-design/`) delivers hi-fi mockups, interaction flows, a design system, and four theme palettes (clide, midnight, paper, terminal) as Dart files using `MaterialApp`/`ThemeData`. We adopt the colour tokens, layout annotations, typography direction, and syntax highlighting palettes. We reject the `MaterialApp` wrapper — tokens are translated into our existing YAML theme pipeline and `SurfaceTokens` (per [D-7](#d-7-app-root-is-bare-widgetsapp)). The design files stay in `docs/claude-design/` as reference; they are not runtime assets.
|
||||
- **Rationale:** The design's value is in the palette + layout + component vocabulary, not in the delivery format. Material's `ThemeData` fights our bare-`WidgetsApp` + `CustomPaint` stance. Translating tokens preserves design intent without absorbing Material's widget opinions.
|
||||
- **Cost:** Manual translation of four theme files into YAML. Ongoing: any design refresh needs the same translation pass.
|
||||
- **Cross-reference:** [D-007](#d-007-app-root-is-bare-widgetsapp), [D-009](#d-009-three-tier-theme-pipeline), [R-012](rejected.md#r-012-materialapp-wrapper-from-design-handoff).
|
||||
- **Cross-reference:** [D-7](#d-7-app-root-is-bare-widgetsapp), [D-9](#d-9-three-tier-theme-pipeline), [R-12](rejected.md#r-12-materialapp-wrapper-from-design-handoff).
|
||||
- **Raised by:** 2026-04-22 design handoff review.
|
||||
|
||||
### D-044: Four bundled themes — clide, midnight, paper, terminal
|
||||
### D-44: Four bundled themes — clide, midnight, paper, terminal
|
||||
- **Date:** 2026-04-22
|
||||
- **Decision:** Ship four bundled themes replacing the single summer-night preset. `clide` (cool near-black + periwinkle, default), `midnight` (VS Code-adjacent muted dark), `paper` (drafting-sheet light), `terminal` (near-black + amber). All share the same semantic token names. Source palettes in `docs/claude-design/themes/`; runtime YAML under `lib/kernel/src/theme/themes/`.
|
||||
- **Rationale:** Summer-night was a placeholder carried from the legacy TUI. The design system delivers a coherent set of four that covers dark, muted-dark, light, and monochrome workflows.
|
||||
- **Cost:** Summer-night users lose their theme (acceptable — it was dev-only). Four YAML files to maintain.
|
||||
- **Cross-reference:** [D-043](#d-043-design-handoff-adopt-token-palettes-reject-material-wrapper), [D-022](accessibility.md#d-022-wcag-aa-contrast-gate-on-bundled-themes).
|
||||
- **Cross-reference:** [D-43](#d-43-design-handoff-adopt-token-palettes-reject-material-wrapper), [D-22](accessibility.md#d-22-wcag-aa-contrast-gate-on-bundled-themes).
|
||||
- **Raised by:** 2026-04-22 design handoff review.
|
||||
|
||||
### D-045: Syntax highlighting tokens in the theme pipeline
|
||||
### D-45: Syntax highlighting tokens in the theme pipeline
|
||||
- **Date:** 2026-04-22
|
||||
- **Decision:** Add syntax-role colour tokens to `SurfaceTokens`: keyword, type, string, number, comment, method, punctuation. Each bundled theme defines these. The editor and diff views consume them; tree-sitter (when it lands per [Q-015](questions-architecture.md#q-015-editor-tab-full-lsp-vs-tree-sitter-only)) maps grammar scopes to these tokens.
|
||||
- **Decision:** Add syntax-role colour tokens to `SurfaceTokens`: keyword, type, string, number, comment, method, punctuation. Each bundled theme defines these. The editor and diff views consume them; tree-sitter (when it lands per [Q-15](questions-architecture.md#q-15-editor-tab-full-lsp-vs-tree-sitter-only)) maps grammar scopes to these tokens.
|
||||
- **Rationale:** The design system ships syntax palettes per theme. Adding them now means the token surface is ready when syntax highlighting lands.
|
||||
- **Cost:** Seven new fields on `SurfaceTokens`. Default resolution falls back to semantic roles (keyword → accent, comment → textMuted, etc.) so themes that don't declare syntax tokens still compile.
|
||||
- **Raised by:** 2026-04-22 design handoff review.
|
||||
|
||||
### D-047: Interaction model — Claude-is-home layout
|
||||
### D-47: Interaction model — Claude-is-home layout
|
||||
- **Date:** 2026-04-22
|
||||
- **Decision:** The prompt bar is pinned to a fixed Y-position in the middle column; every other surface makes room *around* Claude — never on top, never pushing the prompt off-Y. Three hard rules: (1) prompt bar Y-position is invariant across all states (open, collapsed, focus, editor, viewer); (2) the three bottom strips (left icon rail, app strip, right icon rail) align to one continuous horizontal line; (3) Claude is always the largest surface when present. The three-column layout from [D-011](#d-011-panel-manager-is-kernel-layout-is-data-three-column-is-a-preset) is refined: left = overview (tickets, decisions, files, git, PRs), middle = Claude (+ optional editor above), right = context (viewer, pql graph, links, images). Both side panels have a bottom icon rail for section switching; keyboard: `⌥1–5` (left), context-type switcher (right).
|
||||
- **Decision:** The prompt bar is pinned to a fixed Y-position in the middle column; every other surface makes room *around* Claude — never on top, never pushing the prompt off-Y. Three hard rules: (1) prompt bar Y-position is invariant across all states (open, collapsed, focus, editor, viewer); (2) the three bottom strips (left icon rail, app strip, right icon rail) align to one continuous horizontal line; (3) Claude is always the largest surface when present. The three-column layout from [D-11](#d-11-panel-manager-is-kernel-layout-is-data-three-column-is-a-preset) is refined: left = overview (tickets, decisions, files, git, PRs), middle = Claude (+ optional editor above), right = context (viewer, pql graph, links, images). Both side panels have a bottom icon rail for section switching; keyboard: `⌥1–5` (left), context-type switcher (right).
|
||||
- **Rationale:** "Claude is home" means the prompt never moves, regardless of what opens or closes around it. Every layout mutation respects this invariant. The three-column refinement assigns purpose to columns rather than leaving them generic.
|
||||
- **Cost:** The prompt bar invariant constrains future layout presets — any preset that repositions Claude must explicitly break this rule. Editor mode (see [D-049](#d-049-editor-mode-inline-above-claude-viewer-swap)) is the only case where another surface shares the middle column, and it opens *above* Claude rather than displacing it.
|
||||
- **Cross-reference:** [D-011](#d-011-panel-manager-is-kernel-layout-is-data-three-column-is-a-preset), [D-048](#d-048-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first), [D-049](#d-049-editor-mode-inline-above-claude-viewer-swap).
|
||||
- **Cost:** The prompt bar invariant constrains future layout presets — any preset that repositions Claude must explicitly break this rule. Editor mode (see [D-49](#d-49-editor-mode-inline-above-claude-viewer-swap)) is the only case where another surface shares the middle column, and it opens *above* Claude rather than displacing it.
|
||||
- **Cross-reference:** [D-11](#d-11-panel-manager-is-kernel-layout-is-data-three-column-is-a-preset), [D-48](#d-48-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first), [D-49](#d-49-editor-mode-inline-above-claude-viewer-swap).
|
||||
- **Raised by:** 2026-04-22 interaction model spec (Wireframe — Flows v3).
|
||||
|
||||
### D-048: Chrome budget — no tabs, no breadcrumbs, keyboard-first
|
||||
### D-48: Chrome budget — no tabs, no breadcrumbs, keyboard-first
|
||||
- **Date:** 2026-04-22
|
||||
- **Decision:** Clide deletes classic IDE chrome: no buffer tabs, no breadcrumbs, no VS Code-style activity bar, no separate status bar row (merged into app strip). Total persistent chrome: 2 edge arrows (collapse toggles), 1 hover-only `⛶` glyph per panel (focus mode), 0 always-visible buttons beyond icon rails. `⌘P` overlay is the fuzzy finder — no layout shift. Keyboard is the primary interaction surface; icons are escape hatches. Files open individually; opening a second file closes the first (split on explicit command — deferred, see [Q-027](questions-architecture.md#q-027-two-editor-split)).
|
||||
- **Rationale:** Every pixel of chrome that isn't Claude is a tax on the "Claude is home" principle. Tabs and breadcrumbs are navigation affordances for a multi-buffer editor; clide's editor is a secondary surface (viewer ↔ editor swap per [D-049](#d-049-editor-mode-inline-above-claude-viewer-swap)), not a primary one. The fuzzy finder (`⌘P`) replaces all navigation chrome.
|
||||
- **Cost:** Users accustomed to VS Code/IntelliJ tab workflows have no tabs to fall back on. Mitigated by `⌘P` fuzzy find being the universal navigation path. Resolves T-022 (multi-buffer editor tabs) as rejected in favour of this approach.
|
||||
- **Cross-reference:** [D-047](#d-047-interaction-model-claude-is-home-layout), [D-049](#d-049-editor-mode-inline-above-claude-viewer-swap).
|
||||
- **Decision:** Clide deletes classic IDE chrome: no buffer tabs, no breadcrumbs, no VS Code-style activity bar, no separate status bar row (merged into app strip). Total persistent chrome: 2 edge arrows (collapse toggles), 1 hover-only `⛶` glyph per panel (focus mode), 0 always-visible buttons beyond icon rails. `⌘P` overlay is the fuzzy finder — no layout shift. Keyboard is the primary interaction surface; icons are escape hatches. Files open individually; opening a second file closes the first (split on explicit command — deferred, see [Q-27](questions-architecture.md#q-27-two-editor-split)).
|
||||
- **Rationale:** Every pixel of chrome that isn't Claude is a tax on the "Claude is home" principle. Tabs and breadcrumbs are navigation affordances for a multi-buffer editor; clide's editor is a secondary surface (viewer ↔ editor swap per [D-49](#d-49-editor-mode-inline-above-claude-viewer-swap)), not a primary one. The fuzzy finder (`⌘P`) replaces all navigation chrome.
|
||||
- **Cost:** Users accustomed to VS Code/IntelliJ tab workflows have no tabs to fall back on. Mitigated by `⌘P` fuzzy find being the universal navigation path. Resolves T-22 (multi-buffer editor tabs) as rejected in favour of this approach.
|
||||
- **Cross-reference:** [D-47](#d-47-interaction-model-claude-is-home-layout), [D-49](#d-49-editor-mode-inline-above-claude-viewer-swap).
|
||||
- **Raised by:** 2026-04-22 interaction model spec (Wireframe — Flows v3).
|
||||
|
||||
### D-049: Editor mode — inline above Claude, viewer swap
|
||||
### D-49: Editor mode — inline above Claude, viewer swap
|
||||
- **Date:** 2026-04-22
|
||||
- **Decision:** Editor invoked via `⌘E` on a file or `✎` icon in a viewer. Editor lifts *above* Claude in the middle column, occupying 30–40% of vertical space; Claude keeps the remainder; prompt bar Y unchanged. Close with `⌘W`. Draggable divider between editor and Claude. The viewer (`👁`) and editor (`✎`) are mutually exclusive for the same file — a `✎` click on a viewer promotes the file to editor in the middle column and snaps the right panel back to nav; a `👁` click on an editor demotes the file to viewer in the right panel and closes the editor. Different files can coexist (editor on `main.dart` + viewer on `README.md`). When editor is open on `.md`, the viewer auto-opens with live sync to editor content; no auto-viewer for non-renderable files (`.dart`, `.yaml`, etc.).
|
||||
- **Rationale:** The editor is not a primary surface — it's a temporary intervention. Claude's prompt bar must never move ([D-047](#d-047-interaction-model-claude-is-home-layout)), so the editor opens above, not replacing. The viewer ↔ editor swap prevents two surfaces showing the same file simultaneously, which simplifies state management and avoids confusion about which surface is authoritative.
|
||||
- **Cost:** Only one file in the editor at a time (no tabs per [D-048](#d-048-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first)). Power users wanting two files side-by-side must wait for split (see [Q-027](questions-architecture.md#q-027-two-editor-split)).
|
||||
- **Cross-reference:** [D-047](#d-047-interaction-model-claude-is-home-layout), [D-048](#d-048-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first).
|
||||
- **Rationale:** The editor is not a primary surface — it's a temporary intervention. Claude's prompt bar must never move ([D-47](#d-47-interaction-model-claude-is-home-layout)), so the editor opens above, not replacing. The viewer ↔ editor swap prevents two surfaces showing the same file simultaneously, which simplifies state management and avoids confusion about which surface is authoritative.
|
||||
- **Cost:** Only one file in the editor at a time (no tabs per [D-48](#d-48-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first)). Power users wanting two files side-by-side must wait for split (see [Q-27](questions-architecture.md#q-27-two-editor-split)).
|
||||
- **Cross-reference:** [D-47](#d-47-interaction-model-claude-is-home-layout), [D-48](#d-48-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first).
|
||||
- **Raised by:** 2026-04-22 interaction model spec (Wireframe — Flows v3).
|
||||
|
||||
### D-050: Context auto-behavior — right panel reacts to Claude
|
||||
### D-50: Context auto-behavior — right panel reacts to Claude
|
||||
- **Date:** 2026-04-22
|
||||
- **Decision:** The right panel responds to Claude's content references automatically: (1) right open + empty → panel holds footprint, stays empty; (2) right open + viewer loaded + Claude links `foo.md` → swap in, replaces current viewer; (3) right collapsed + Claude links `foo.md` → badge on spine ("2"), no layout shift; (4) editor open on `.md` → viewer auto-opens with live sync; (5) editor on non-renderable file → no auto-viewer.
|
||||
- **Rationale:** Claude is the driver; the context panel is reactive. Auto-swapping when the panel is open reduces user clicks. Badging when collapsed respects the user's decision to collapse — no involuntary layout shifts.
|
||||
- **Cost:** The auto-swap requires the daemon (or Claude integration) to emit structured content references, not just terminal text. This implies a lightweight parser or event that identifies file references in Claude's output — deferred to implementation.
|
||||
- **Cross-reference:** [D-047](#d-047-interaction-model-claude-is-home-layout), [D-051](#d-051-panel-collapse-12px-spine-with-badge).
|
||||
- **Cross-reference:** [D-47](#d-47-interaction-model-claude-is-home-layout), [D-51](#d-51-panel-collapse-12px-spine-with-badge).
|
||||
- **Raised by:** 2026-04-22 interaction model spec (Wireframe — Flows v3).
|
||||
|
||||
### D-051: Panel collapse — 12px spine with badge
|
||||
### D-51: Panel collapse — 12px spine with badge
|
||||
- **Date:** 2026-04-22
|
||||
- **Decision:** When collapsed, a panel becomes a 12px spine: vertically rotated label ("tickets" / "context"), no icon rail, `paper-2` background (slightly darker than main paper), border on inner edge only. Click anywhere on spine to expand. If a context badge is pending (e.g. Claude linked a file while collapsed): small filled dot with count at top of spine. Edge arrow on outer boundary toggles collapse; keyboard: `⌘⇧1` (left) / `⌘⇧3` (right). Expand restores prior size and section state.
|
||||
- **Rationale:** Collapsed panels must not consume significant horizontal space (12px = 1 icon-width) but must remain discoverable and able to signal pending content. The badge-on-spine avoids involuntary expand while still communicating that something arrived.
|
||||
- **Cost:** The spine replaces the current simple `setVisible(false)` toggle with a real collapsed-state widget. Collapse state must be persisted across sessions (see [D-053](#d-053-state-persistence-across-sessions)).
|
||||
- **Cross-reference:** [D-047](#d-047-interaction-model-claude-is-home-layout), [D-050](#d-050-context-auto-behavior-right-panel-reacts-to-claude).
|
||||
- **Cost:** The spine replaces the current simple `setVisible(false)` toggle with a real collapsed-state widget. Collapse state must be persisted across sessions (see [D-53](#d-53-state-persistence-across-sessions)).
|
||||
- **Cross-reference:** [D-47](#d-47-interaction-model-claude-is-home-layout), [D-50](#d-50-context-auto-behavior-right-panel-reacts-to-claude).
|
||||
- **Raised by:** 2026-04-22 interaction model spec (Wireframe — Flows v3).
|
||||
|
||||
### D-052: Focus mode — full-window takeover
|
||||
### D-52: Focus mode — full-window takeover
|
||||
- **Date:** 2026-04-22
|
||||
- **Decision:** Focus mode entered via double-click on panel header, hover-visible `⛶` glyph in header, or `⌘.`. Active panel takes the full window; all others hidden. Header shows "Esc" hint. `Esc` restores the exact prior layout (collapse state, divider positions, active sections). Focus mode is per-panel, not per-tab.
|
||||
- **Rationale:** When the user wants to concentrate on a single surface — Claude conversation, file tree, diff view — they shouldn't have to manually collapse both side panels. Focus mode is a single-action "maximise and restore" with no state loss.
|
||||
- **Cost:** Must snapshot and restore full `LayoutArrangement` state on enter/exit. Interacts with responsive behaviour — focus mode at narrow widths should work identically.
|
||||
- **Cross-reference:** [D-047](#d-047-interaction-model-claude-is-home-layout), [D-053](#d-053-state-persistence-across-sessions).
|
||||
- **Cross-reference:** [D-47](#d-47-interaction-model-claude-is-home-layout), [D-53](#d-53-state-persistence-across-sessions).
|
||||
- **Raised by:** 2026-04-22 interaction model spec (Wireframe — Flows v3).
|
||||
|
||||
### D-053: State persistence across sessions
|
||||
### D-53: State persistence across sessions
|
||||
- **Date:** 2026-04-22
|
||||
- **Decision:** The following layout state is persisted across app restarts: collapse state of left and right panels, active left section (tickets/decisions/files/git/pr), active right context type, pql pane expanded/collapsed, editor split ratio when open, fuzzy find recent picks. Stored via `SettingsStore` in project-scoped settings (`.clide/settings.yaml`).
|
||||
- **Rationale:** Users expect their workspace layout to survive restarts. Without persistence, every launch starts at the default layout preset, which is disorienting when the user has customised their column widths and panel states.
|
||||
- **Cost:** Adds write-on-change to several layout operations. Must handle migration if the setting keys evolve. `.clide/settings.yaml` is already gitignored, so personal layout state stays personal.
|
||||
- **Cross-reference:** [D-047](#d-047-interaction-model-claude-is-home-layout), [D-051](#d-051-panel-collapse-12px-spine-with-badge), [D-052](#d-052-focus-mode-full-window-takeover).
|
||||
- **Cross-reference:** [D-47](#d-47-interaction-model-claude-is-home-layout), [D-51](#d-51-panel-collapse-12px-spine-with-badge), [D-52](#d-52-focus-mode-full-window-takeover).
|
||||
- **Raised by:** 2026-04-22 interaction model spec (Wireframe — Flows v3).
|
||||
|
||||
### D-054: Keyboard map — canonical shortcuts
|
||||
### D-54: Keyboard map — canonical shortcuts
|
||||
- **Date:** 2026-04-22
|
||||
- **Decision:** Canonical keyboard shortcuts (cross-platform, `⌘` = `Ctrl` on Linux): `⌘P` fuzzy find overlay; `⌘⇧1` / `⌘⇧3` collapse/expand left / right panel; `⌘1` / `⌘2` / `⌘3` focus left / middle / right panel; `⌘.` toggle focus mode on focused panel; `⌥1–⌥5` left-panel section switch (tickets, decisions, files, git, pr); `⌘E` open current file in editor; `⌘W` close editor / dismiss viewer; `Esc` exit focus mode / close fuzzy finder / dismiss viewer. Responsive breakpoints: ≥ 1600px splits relax toward 30%; 1200–1600px default (L 200px, R 220px, middle flex); < 1200px splits toward 40%, consider auto-collapse right; < 1000px deferred (see [Q-026](questions-architecture.md#q-026-small-screen-layout)).
|
||||
- **Rationale:** These shortcuts follow the "keyboard is the primary surface" principle from [D-048](#d-048-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first). The set is minimal and covers all layout operations. `⌘.` for focus mode follows VS Code precedent (quick-fix → general "do the thing").
|
||||
- **Cost:** Some shortcuts may conflict with OS-level bindings on specific Linux desktops; the keybinding resolver ([D-017](extensions.md#d-017-panels-are-extension-shaped-from-day-one)) allows user override.
|
||||
- **Cross-reference:** [D-047](#d-047-interaction-model-claude-is-home-layout), [D-048](#d-048-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first), [D-052](#d-052-focus-mode-full-window-takeover).
|
||||
- **Decision:** Canonical keyboard shortcuts (cross-platform, `⌘` = `Ctrl` on Linux): `⌘P` fuzzy find overlay; `⌘⇧1` / `⌘⇧3` collapse/expand left / right panel; `⌘1` / `⌘2` / `⌘3` focus left / middle / right panel; `⌘.` toggle focus mode on focused panel; `⌥1–⌥5` left-panel section switch (tickets, decisions, files, git, pr); `⌘E` open current file in editor; `⌘W` close editor / dismiss viewer; `Esc` exit focus mode / close fuzzy finder / dismiss viewer. Responsive breakpoints: ≥ 1600px splits relax toward 30%; 1200–1600px default (L 200px, R 220px, middle flex); < 1200px splits toward 40%, consider auto-collapse right; < 1000px deferred (see [Q-26](questions-architecture.md#q-26-small-screen-layout)).
|
||||
- **Rationale:** These shortcuts follow the "keyboard is the primary surface" principle from [D-48](#d-48-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first). The set is minimal and covers all layout operations. `⌘.` for focus mode follows VS Code precedent (quick-fix → general "do the thing").
|
||||
- **Cost:** Some shortcuts may conflict with OS-level bindings on specific Linux desktops; the keybinding resolver ([D-17](extensions.md#d-17-panels-are-extension-shaped-from-day-one)) allows user override.
|
||||
- **Cross-reference:** [D-47](#d-47-interaction-model-claude-is-home-layout), [D-48](#d-48-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first), [D-52](#d-52-focus-mode-full-window-takeover).
|
||||
- **Raised by:** 2026-04-22 interaction model spec (Wireframe — Flows v3).
|
||||
|
||||
### D-055: Claude pane internal tabs for multi-session
|
||||
### D-55: Claude pane internal tabs for multi-session
|
||||
- **Date:** 2026-04-23
|
||||
- **Decision:** Multiple Claude sessions share the workspace as internal tabs inside the Claude pane header — not as workspace-level tabs (which would violate [D-048](#d-048-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first)). The primary session tab has no close affordance (per [D-041](#d-041-claude-panes-one-primary-per-repo-tmux-backed)). Secondary session tabs show a close `×`. A small `+` button sits at the right end of the tab row to spawn a new secondary. Double-clicking empty space in the tab row also spawns a new secondary. When a secondary is closed, focus collapses to the most-recently-active remaining tab (primary or another secondary). The tab row is hidden when only the primary exists — it appears on first secondary spawn and disappears when the last secondary closes. Session names in the tab row use the tmux session name slug (readable path, per the session naming convention).
|
||||
- **Amendment to [D-041](#d-041-claude-panes-one-primary-per-repo-tmux-backed):** D-041 defined the lifecycle (primary persists, secondaries are ephemeral, close semantics) but left the multi-session UI unspecified. This record fills that gap. The `claude.new-secondary` command (already registered but not wired) is the spawn mechanism; the tab row is the UI surface.
|
||||
- **Rationale:** The workspace is Claude's space ([D-047](#d-047-interaction-model-claude-is-home-layout)). Multiple Claude sessions are a Claude concern, not a workspace concern. Internal tabs keep the multiplicity contained — the workspace slot doesn't know how many sessions exist, it just renders the Claude pane. The hide-when-one rule keeps the common case (single primary) chrome-free.
|
||||
- **Decision:** Multiple Claude sessions share the workspace as internal tabs inside the Claude pane header — not as workspace-level tabs (which would violate [D-48](#d-48-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first)). The primary session tab has no close affordance (per [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed)). Secondary session tabs show a close `×`. A small `+` button sits at the right end of the tab row to spawn a new secondary. Double-clicking empty space in the tab row also spawns a new secondary. When a secondary is closed, focus collapses to the most-recently-active remaining tab (primary or another secondary). The tab row is hidden when only the primary exists — it appears on first secondary spawn and disappears when the last secondary closes. Session names in the tab row use the tmux session name slug (readable path, per the session naming convention).
|
||||
- **Amendment to [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed):** D-41 defined the lifecycle (primary persists, secondaries are ephemeral, close semantics) but left the multi-session UI unspecified. This record fills that gap. The `claude.new-secondary` command (already registered but not wired) is the spawn mechanism; the tab row is the UI surface.
|
||||
- **Rationale:** The workspace is Claude's space ([D-47](#d-47-interaction-model-claude-is-home-layout)). Multiple Claude sessions are a Claude concern, not a workspace concern. Internal tabs keep the multiplicity contained — the workspace slot doesn't know how many sessions exist, it just renders the Claude pane. The hide-when-one rule keeps the common case (single primary) chrome-free.
|
||||
- **Cost:** The Claude pane grows its own tab model (lightweight — just a list of session IDs + which is active). The `builtin.claude` extension owns this; no kernel changes needed.
|
||||
- **Cross-reference:** [D-041](#d-041-claude-panes-one-primary-per-repo-tmux-backed), [D-047](#d-047-interaction-model-claude-is-home-layout), [D-048](#d-048-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first).
|
||||
- **Cross-reference:** [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed), [D-47](#d-47-interaction-model-claude-is-home-layout), [D-48](#d-48-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first).
|
||||
- **Raised by:** 2026-04-23 interaction model refinement.
|
||||
|
||||
### D-056: Dissolve daemon process; Flutter app hosts IPC server
|
||||
### D-56: Dissolve daemon process; Flutter app hosts IPC server
|
||||
- **Date:** 2026-04-23
|
||||
- **Decision:** The separate Dart daemon process (`clide --daemon`) and the two-package repo layout (`lib/` core + `app/` Flutter) are dissolved. The Flutter app moves to the repo root (one `pubspec.yaml`) and hosts the IPC server in-process. All subsystem handlers (pane, files, editor, git, pql) run inside the Flutter process. The `bin/clide.dart` AOT binary is removed. The CLI surface for Claude (`clide <command>`) becomes a thin C client — either a new peer of `ptyc` or a mode within `ptyc` itself — that connects to the app's unix socket, sends a JSON-lines request, prints the response, and exits. tmux owns session persistence (it already did per [D-041](#d-041-claude-panes-one-primary-per-repo-tmux-backed)); the daemon's PTY ownership was redundant.
|
||||
- **Amendment to [D-005](#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer):** D-005's "two execution modes of one Dart AOT binary" premise assumed the daemon needed to outlive the app to preserve PTY sessions. tmux already solves this — `tmux new-session -A` re-attaches regardless of which process originally spawned it. The daemon process added complexity (two packages, two build targets, IPC client/server split, process lifecycle management) without a benefit tmux doesn't already provide. D-005's other principles survive: Dart is the core language, `ptyc` is a C peer of pql, one language for the IDE proper.
|
||||
- **Decision:** The separate Dart daemon process (`clide --daemon`) and the two-package repo layout (`lib/` core + `app/` Flutter) are dissolved. The Flutter app moves to the repo root (one `pubspec.yaml`) and hosts the IPC server in-process. All subsystem handlers (pane, files, editor, git, pql) run inside the Flutter process. The `bin/clide.dart` AOT binary is removed. The CLI surface for Claude (`clide <command>`) becomes a thin C client — either a new peer of `ptyc` or a mode within `ptyc` itself — that connects to the app's unix socket, sends a JSON-lines request, prints the response, and exits. tmux owns session persistence (it already did per [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed)); the daemon's PTY ownership was redundant.
|
||||
- **Amendment to [D-5](#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer):** D-5's "two execution modes of one Dart AOT binary" premise assumed the daemon needed to outlive the app to preserve PTY sessions. tmux already solves this — `tmux new-session -A` re-attaches regardless of which process originally spawned it. The daemon process added complexity (two packages, two build targets, IPC client/server split, process lifecycle management) without a benefit tmux doesn't already provide. D-5's other principles survive: Dart is the core language, `ptyc` is a C peer of pql, one language for the IDE proper.
|
||||
- **Repo layout after dissolution:**
|
||||
- `/pubspec.yaml` — single Flutter package (was `app/pubspec.yaml`)
|
||||
- `/lib/` — all Dart code: kernel, extensions, widgets, subsystem handlers
|
||||
@@ -223,7 +223,16 @@ Core, rendering, IPC, kernel, panel manager.
|
||||
- `/decisions/`, `/docs/`, `/legacy/` — unchanged
|
||||
- **Rationale:** One package means one `pubspec.yaml`, one `flutter analyze`, one `flutter test`, no `cd` gymnastics, no cross-package import barriers. The IPC server running in-process eliminates the daemon lifecycle (start, stop, reconnect, pid file). If the app crashes, tmux sessions survive; the app re-attaches on restart. The CLI client in C is ~100 lines (socket connect + JSON exchange) with the same contract as pql.
|
||||
- **Cost:** If the Flutter app is not running, Claude's `clide` commands fail. In practice this is acceptable — the IDE being closed means the user isn't working. A future "headless mode" could start the Flutter engine without a window if needed.
|
||||
- **Cross-reference:** [D-005](#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended), [D-041](#d-041-claude-panes-one-primary-per-repo-tmux-backed) (tmux persistence), [D-001](#d-001-cli-first-not-mcp) (CLI-first surface preserved via C client).
|
||||
- **Cross-reference:** [D-5](#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended), [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed) (tmux persistence), [D-1](#d-1-cli-first-not-mcp) (CLI-first surface preserved via C client).
|
||||
- **Raised by:** 2026-04-23 architectural simplification.
|
||||
|
||||
### D-57: Frameless custom chrome with per-column 24px hats
|
||||
- **Date:** 2026-04-23
|
||||
- **Decision:** The OS-native title bar is hidden. Each of the three columns wears its own 24px "hat" that serves as both a drag region and a host for window controls. Left hat: macOS traffic lights (Linux/Windows: plain drag). Center hat: `clide > branch` label, always present. Right hat: minimize/maximize/close glyph buttons on Linux/Windows (macOS: plain drag). Entire hat surface is draggable; buttons opt out of hit testing. When a column collapses to a 12px spine, its hat shrinks to a 12px drag cap — no buttons, still draggable. The center hat never collapses. Three `ChromeStyle` variants: `seam` (default desktop — full hats), `prompt` (center hat only — presentations/focus), `inline` (web/wasm — no hats, browser owns window controls). Persisted in settings as `app.chromeStyle`. Platform bridge via `MethodChannel('clide/window')` — custom GTK C and Cocoa Swift handlers, no third-party package.
|
||||
- **Resolves:** [Q-6](questions-architecture.md#q-6-window-chrome-native-frame-vs-frameless-custom).
|
||||
- **Rationale:** The GTK headerbar wastes 30+ vertical pixels and clashes with the custom theme. Per-column hats add zero net rows — they reuse the space each column header already occupied. Custom FFI avoids a `window_manager` dependency (D-31). The `ChromeStyle` enum keeps web builds clean and allows user override.
|
||||
- **Cost:** ~150 lines C (GTK) + ~100 lines Swift (Cocoa) for the platform channel. Window controls become unreachable when their column collapses — mitigated by keyboard shortcuts (`⌘Q` to close, `⌘1`/`⌘3` to expand).
|
||||
- **Cross-reference:** [D-47](#d-47-interaction-model-claude-is-home-layout) (center hat always visible), [D-51](#d-51-panel-collapse-12px-spine-with-badge) (spine-cap behavior).
|
||||
- **Raised by:** 2026-04-23 interaction model refinement.
|
||||
|
||||
---
|
||||
|
||||
@@ -4,42 +4,42 @@ Extension contract, Lua runtime, grain, contribution points.
|
||||
|
||||
---
|
||||
|
||||
### D-015: Extension grain — container-level, multi-contribution
|
||||
### D-15: Extension grain — container-level, multi-contribution
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** An extension is a shipping unit that contributes one or more named contributions (panel, command, theme, keybinding, language, layout, provider, view). Grain is container-sized — a single `builtin.git` extension contributes panel + commands + keybindings + status-bar items; we do not ship one extension per contribution.
|
||||
- **Rationale:** Finer grain (one extension per contribution) multiplies manifest files with no win and fragments ownership. Coarser grain (one mega-extension per domain) hides which parts a user might reasonably disable.
|
||||
- **Cost:** Extension authors make a taste call about grouping; disagreements go to review.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-016: Built-ins in Dart, third-party in sandboxed Lua
|
||||
### D-16: Built-ins in Dart, third-party in sandboxed Lua
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Bundled extensions (every `app/lib/builtin/<name>`) are Dart — they link into the app binary. Third-party extensions (Tier 6) run in sandboxed Lua via the `ptyc`-peer Lua runtime (see [D-019](#d-019-lua-runtime-as-ptyc-peer-supporter-tool)). The contribution contract is language-agnostic — same contribution shapes, same manifest schema.
|
||||
- **Decision:** Bundled extensions (every `app/lib/builtin/<name>`) are Dart — they link into the app binary. Third-party extensions (Tier 6) run in sandboxed Lua via the `ptyc`-peer Lua runtime (see [D-19](#d-19-lua-runtime-as-ptyc-peer-supporter-tool)). The contribution contract is language-agnostic — same contribution shapes, same manifest schema.
|
||||
- **Rationale:** Dart built-ins get full SDK power (custom painters, isolates, FFI); third-party Lua gets a narrow capability API, no arbitrary syscalls, no deps on pub.dev. VS Code's Node-runs-with-full-power model is a supply-chain nightmare we're explicitly rejecting.
|
||||
- **Cost:** Two implementation paths for the same contract; we pay in API design to keep them equivalent at the seams.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-017: Panels are extension-shaped from day one
|
||||
### D-17: Panels are extension-shaped from day one
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Every bundled panel (file tree, git, problems, pql query, terminal, etc.) is a contribution on the extension contract, not a hardcoded widget tree in the panel manager. First party contributes via Dart; third party contributes via Lua; same contract.
|
||||
- **Rationale:** Forces the contract to be real on day one. The alternative ("extensions can contribute panels *later*") always becomes "the contract doesn't quite fit our bundled panels, so built-ins get a shortcut" — and the shortcut becomes permanent.
|
||||
- **Cost:** Every panel goes through the manifest/registration path even when it's trivial. Price paid once.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-018: YAML for themes + manifests; JSON for i18n catalogs
|
||||
### D-18: YAML for themes + manifests; JSON for i18n catalogs
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Themes and extension manifests are YAML; i18n catalogues are JSON (fframe parity — see [D-021](accessibility.md#d-021-i18n-is-a-tier-0-contract)).
|
||||
- **Decision:** Themes and extension manifests are YAML; i18n catalogues are JSON (fframe parity — see [D-21](accessibility.md#d-21-i18n-is-a-tier-0-contract)).
|
||||
- **Rationale:** YAML for human-edited config files (themes, manifests) — comments, multi-line strings, less noise. JSON for machine-written / machine-read files (i18n catalogs get generated by translation tooling eventually). Mixing is fine; each format is where it's best.
|
||||
- **Cost:** Two parsers in the tree. `yaml: 3.1.3` is exact-pinned.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-019: Lua runtime as `ptyc`-peer supporter tool
|
||||
### D-19: Lua runtime as `ptyc`-peer supporter tool
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Third-party extensions run Lua inside a sandboxed runtime (working name TBD; same peer-status as `ptyc` and `pql`). The runtime vendors liblua, links from Dart via `dart:ffi`, exposes a narrow capability API, and takes contributions as a declarative render-intent DSL (widget shapes, not arbitrary widget code).
|
||||
- **Rationale:** Lua is small, embeddable, battle-tested (Neovim, World of Warcraft, Redis). Native sandboxing at the VM level is cheap. A declarative render-intent DSL keeps "run arbitrary Flutter widgets from Lua" off the table — the runtime interprets the DSL into Dart widgets, which keeps Tier 6 supply-chain risk bounded.
|
||||
- **Cost:** Runtime is a separate supporter tool to build; ffi is tricky. Deferred to Tier 6; only the slot is reserved now.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-046: Core frame builtins vs shipped extensions boundary
|
||||
### D-46: Core frame builtins vs shipped extensions boundary
|
||||
- **Date:** 2026-04-22
|
||||
- **Decision:** The `app/lib/builtin/` directory is reserved for core frame infrastructure — components the shell cannot function without. Everything that renders *content* (editor surfaces, tool panels, integrations) is a shipped extension: still Dart, still bundled in the binary, but architecturally an extension that registers through the contribution contract and could in principle be disabled by the user.
|
||||
|
||||
@@ -60,5 +60,5 @@ Extension contract, Lua runtime, grain, contribution points.
|
||||
---
|
||||
|
||||
*See also the existing `builtin.grammars_core` stub for tree-sitter
|
||||
questions ([Q-015](questions-process.md#q-015-editor-tab-full-lsp-vs-tree-sitter-only),
|
||||
[Q-016](questions-process.md#q-016-tree-sitter-dart-grammar-maintenance)).*
|
||||
questions ([Q-15](questions-process.md#q-15-editor-tab-full-lsp-vs-tree-sitter-only),
|
||||
[Q-16](questions-process.md#q-16-tree-sitter-dart-grammar-maintenance)).*
|
||||
|
||||
@@ -4,55 +4,55 @@ Q&D record system itself, kanban, commit conventions, changelog.
|
||||
|
||||
---
|
||||
|
||||
### D-034: Q&D record system
|
||||
### D-34: Q&D record system
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Adopt settled-reach's Q&D record convention. Confirmed decisions are `D-NNN` under `decisions/<domain>.md`; open questions are `Q-NNN` under `decisions/questions-<domain>.md`; rejected alternatives are `R-NNN` under `decisions/rejected.md`. Markdown is the source of truth; `.pql/pql.db` is a query index built from markdown. Record shape and claiming rules live in [`decisions/README.md`](README.md).
|
||||
- **Rationale:** Two places currently hold clide's architectural knowledge — ADRs and scattered plan files — and neither lets an agent or reviewer locate "the unresolved thing in this subsystem." Q&D fixes that: one index, one shape, one claim rule. Proven in daily use in settled-reach.
|
||||
- **Cost:** One more directory to maintain. A learning curve for contributors (tiny: read `decisions/README.md`).
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-035: Kanban / waterfall, not Scrum
|
||||
### D-35: Kanban / waterfall, not Scrum
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Ticketing is kanban + waterfall. Tickets flow backlog → ready → in_progress → review → done → cancelled. No sprints, no velocity, no story points. Settled-reach's Scrum layer (sprints, sprint reviews, sprint close as a sync event) is stripped.
|
||||
- **Rationale:** Clide has a solo-or-small-team cadence. Sprint ceremonies add overhead without adding signal at this scale. Kanban matches how the work actually happens.
|
||||
- **Cost:** No natural "sprint close" event to sync shared state. See [Q-022](questions-process.md#q-022-ticket-persistence-strategy).
|
||||
- **Cost:** No natural "sprint close" event to sync shared state. See [Q-22](questions-process.md#q-22-ticket-persistence-strategy).
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-036: `.claude/` is committed project surface, managed through the IDE
|
||||
### D-36: `.claude/` is committed project surface, managed through the IDE
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** `.claude/` (hooks, skills, agents, MCP settings) is committed alongside code. Only `.claude/settings.local.json` is gitignored. The reserved `builtin.claude-control` extension surfaces `.claude/` as a first-class sidebar tab (sub-tabs: Settings / Skills / Agents / Hooks / MCP) in a future tier.
|
||||
- **Rationale:** `.claude/` is project governance — same status as `CLAUDE.md`, `decisions/`, `Makefile`. Treating it as dotfile-cruft loses project-wide conventions (skills, hooks) that should travel with the repo.
|
||||
- **Cost:** Contributors commit Claude Code config alongside code changes. Discipline required; minor.
|
||||
- **Raised by:** 2026-04-21 planning. Distinct from the existing `builtin.claude` stub reserved for Tier 1's "run Claude Code in a PTY pane."
|
||||
|
||||
### D-037: Commit conventions per git-commit skill
|
||||
### D-37: Commit conventions per git-commit skill
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Commits follow `.claude/skills/git-commit/SKILL.md`: imperative subject ≤ 70 chars, no `feat:`/`fix:` type prefixes, no emojis, optional body wrapped at ~72 chars, multi-line messages via HEREDOC, attribution trailer `Co-Authored-By: Claude <noreply@anthropic.com>` (the model-identifier variant the harness produces is also accepted).
|
||||
- **Rationale:** Python-era clide under `legacy/` used Conventional Commits; the Flutter rebuild does not. Imperative mood reads better for a project-governance log; types are noise when every commit is scoped to a subsystem already.
|
||||
- **Cost:** Contributors with Conventional Commits muscle memory adjust.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-038: Changelog discipline — Keep a Changelog 1.1.0
|
||||
### D-38: Changelog discipline — Keep a Changelog 1.1.0
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** `CHANGELOG.md` follows Keep a Changelog 1.1.0. Every user-visible commit adds an entry under `## [Unreleased]` in the appropriate subsection (Added / Changed / Deprecated / Removed / Fixed / Security). Cutting a release moves entries under a dated heading and bumps `pubspec.yaml` `version:` in the same commit. Pure bookkeeping commits (comment-only, .gitignore tweak, lint config) skip the changelog.
|
||||
- **Rationale:** Release notes that have to be written after the fact aren't written. Writing them per commit keeps the log honest.
|
||||
- **Cost:** One extra edit per user-visible commit; zero if the change is invisible.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-039: Planning tooling lives in pql, not clide
|
||||
### D-39: Planning tooling lives in pql, not clide
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Planning subcommands (`decisions`, `ticket`, `plan`) land in pql's repo long-term. Clide consumes them via shell-out, matching [D-003](architecture.md)'s wrap-don't-duplicate rule for pql. Clide does not grow Dart subcommands for planning.
|
||||
- **Rationale:** A terminal user or a user in VS Code / JetBrains still needs Q&D access. Binding planning tooling to clide-the-Flutter-app would cut them off from their own work — see [R-009](rejected.md#r-009-port-planning-tooling-into-clide). pql is already the CLI, already universal, already wrapped by clide.
|
||||
- **Cost:** Planning features don't ship until pql catches up. Mitigated by [D-040](#d-040-python-stopgap-under-toolsscriptsplan). Gated by [Q-021](questions-process.md#q-021-pql-absorbs-planning-vs-keeps-separate).
|
||||
- **Decision:** Planning subcommands (`decisions`, `ticket`, `plan`) land in pql's repo long-term. Clide consumes them via shell-out, matching [D-3](architecture.md)'s wrap-don't-duplicate rule for pql. Clide does not grow Dart subcommands for planning.
|
||||
- **Rationale:** A terminal user or a user in VS Code / JetBrains still needs Q&D access. Binding planning tooling to clide-the-Flutter-app would cut them off from their own work — see [R-9](rejected.md#r-9-port-planning-tooling-into-clide). pql is already the CLI, already universal, already wrapped by clide.
|
||||
- **Cost:** Planning features don't ship until pql catches up. Mitigated by [D-40](#d-40-python-stopgap-under-toolsscriptsplan). Gated by [Q-21](questions-process.md#q-21-pql-absorbs-planning-vs-keeps-separate).
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-040: [SUPERSEDED] Python stopgap under `tools/scripts/plan`
|
||||
### D-40: [SUPERSEDED] Python stopgap under `tools/scripts/plan`
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** A time-limited Python port of settled-reach's `decisions_sync.py` + `ticket` + `decision` scripts lives at `tools/scripts/plan` with support modules under `tools/scripts/planning/`. Writes to `.pql/pql.db` (gitignored). Ticket IDs are `T-NNN` (TEXT PK, reshape from settled-reach's integers). Same schema, same markdown, same verb shape as the eventual `pql` subcommands.
|
||||
- **Sunset:** Delete the stopgap when pql ships `pql decisions sync | validate | list | show | claim | coverage` + `pql ticket new | list | show | status | assign | block | board` with feature parity, and reads the same `.pql/pql.db` file the stopgap wrote. Removal commit shape: [R-011](rejected.md#r-011-permanent-stopgap).
|
||||
- **Sunset:** Delete the stopgap when pql ships `pql decisions sync | validate | list | show | claim | coverage` + `pql ticket new | list | show | status | assign | block | board` with feature parity, and reads the same `.pql/pql.db` file the stopgap wrote. Removal commit shape: [R-11](rejected.md#r-11-permanent-stopgap).
|
||||
- **Rationale:** Planning tooling must work day one. Pql's Go implementation won't land for at least a cycle or two. Without a stopgap, the convention lives on paper; with one, tickets + decisions are queryable from today. Same schema means migration is call-site find-replace (`tools/scripts/plan ` → `pql `), no data migration.
|
||||
- **Cost:** Python dep on contributors' machines (already present on most Linux dists). One time-limited tool to maintain. See [R-010](rejected.md#r-010-python-script-stopgap-at-toolingdb) for why `tools/scripts/plan` and not `tooling/db/`.
|
||||
- **Cost:** Python dep on contributors' machines (already present on most Linux dists). One time-limited tool to maintain. See [R-10](rejected.md#r-10-python-script-stopgap-at-toolingdb) for why `tools/scripts/plan` and not `tooling/db/`.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
- **Amendment (2026-04-22):** Sunset condition met. pql 1.0.0 ships full feature parity. Stopgap deleted per [R-011](rejected.md#r-011-permanent-stopgap).
|
||||
- **Amendment (2026-04-22):** Sunset condition met. pql 1.0.0 ships full feature parity. Stopgap deleted per [R-11](rejected.md#r-11-permanent-stopgap).
|
||||
|
||||
---
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
---
|
||||
|
||||
### Q-013: Web production-mode a11y
|
||||
### Q-13: Web production-mode a11y
|
||||
- **Status:** Open
|
||||
- **Question:** In a Flutter web release build, is the semantics tree always on (what we need for Playwright and for end-user screen readers) or gated behind an accessibility toggle (Flutter's default)?
|
||||
- **Context:** Today the driver clicks `flt-semantics-placeholder` to activate. For user-facing builds we need semantics-always-on.
|
||||
- **Source:** 2026-04-21 planning.
|
||||
|
||||
### Q-014: i18n plurals / gender / date-format tooling
|
||||
### Q-14: i18n plurals / gender / date-format tooling
|
||||
- **Status:** Open
|
||||
- **Question:** fframe's pattern covers straight key→string lookup with variable interpolation. Plurals, gendered forms, and ICU-style date formatting aren't in scope there. Do we add them to the i18n facade, defer to a runtime library (violates [D-031](tooling.md#d-031-prefer-zero-deps-exact-pin)), or require catalogues to provide pre-formatted strings per count/gender?
|
||||
- **Question:** fframe's pattern covers straight key→string lookup with variable interpolation. Plurals, gendered forms, and ICU-style date formatting aren't in scope there. Do we add them to the i18n facade, defer to a runtime library (violates [D-31](tooling.md#d-31-prefer-zero-deps-exact-pin)), or require catalogues to provide pre-formatted strings per count/gender?
|
||||
- **Context:** Probably becomes painful at Tier 3 (git panel, problem counts) and Tier 4 (pql results).
|
||||
- **Source:** 2026-04-21 planning.
|
||||
|
||||
|
||||
@@ -5,100 +5,124 @@ ticket persistence.
|
||||
|
||||
---
|
||||
|
||||
### Q-001: Authorisation granularity on the IPC socket
|
||||
### Q-1: Authorisation granularity on the IPC socket
|
||||
- **Status:** Open
|
||||
- **Question:** The daemon's token auth is coarse (allow all / deny all). Do we need per-subsystem grants later (e.g. restrict `git push`), and if so, what's the model — capability tokens? An explicit grant table per client? Time-limited grants?
|
||||
- **Context:** Surfaced in the old ADR 0006 open-questions footer; deferred until Tier 1 is in real use.
|
||||
- **Source:** ADR 0006 (migrated to [D-006](architecture.md)).
|
||||
- **Source:** ADR 0006 (migrated to [D-6](architecture.md)).
|
||||
|
||||
### Q-002: Back-pressure on event streams
|
||||
### Q-2: Back-pressure on event streams
|
||||
- **Status:** Open
|
||||
- **Question:** A subscriber that falls behind on `pane.output` (a firehose) needs a policy: drop oldest, block producer, coalesce, or kill subscriber. Which?
|
||||
- **Context:** The event bus is in-memory; back-pressure policy is undefined. Defer until Tier 1 is in real use and we have a real firehose to measure against.
|
||||
- **Source:** ADR 0006 (migrated to [D-006](architecture.md)).
|
||||
- **Source:** ADR 0006 (migrated to [D-6](architecture.md)).
|
||||
|
||||
### Q-003: Event persistence + audit/undo
|
||||
### Q-3: Event persistence + audit/undo
|
||||
- **Status:** Open
|
||||
- **Question:** Events are in-memory only in v1. If a future need (audit log, undo history) wants persistence, is it a property of the bus or a subsystem that subscribes and writes?
|
||||
- **Context:** ADR 0006 leaned "subsystem that subscribes and writes" but didn't commit.
|
||||
- **Source:** ADR 0006 (migrated to [D-006](architecture.md)).
|
||||
- **Source:** ADR 0006 (migrated to [D-6](architecture.md)).
|
||||
|
||||
### Q-004: `.canvas` schema compatibility with Obsidian
|
||||
### Q-4: `.canvas` schema compatibility with Obsidian
|
||||
- **Status:** Open
|
||||
- **Question:** Clide's canvas (Tier 5) should read/write something — either Obsidian's `.canvas` JSON schema verbatim, a compatible-ish superset, or our own format. Each has trade-offs.
|
||||
- **Context:** Obsidian's canvas users might want their canvases portable; conversely, bending to Obsidian's schema constrains our canvas features.
|
||||
- **Source:** CLAUDE.md "Open questions" footer.
|
||||
|
||||
### Q-005: IPC wire-format stability + `schema_version:`
|
||||
### Q-5: IPC wire-format stability + `schema_version:`
|
||||
- **Status:** Open
|
||||
- **Question:** When do we freeze the IPC envelope / schema and introduce `schema_version:` in `pubspec.yaml`? What's the bump policy for breaking changes?
|
||||
- **Context:** Covered partially by [D-006](architecture.md)'s `v: 1` starting point; CLAUDE.md flags this as "decide when the first real subcommand lands."
|
||||
- **Context:** Covered partially by [D-6](architecture.md)'s `v: 1` starting point; CLAUDE.md flags this as "decide when the first real subcommand lands."
|
||||
- **Source:** CLAUDE.md "Open questions" footer.
|
||||
|
||||
### Q-006: Window chrome — native frame vs frameless custom
|
||||
- **Status:** Open
|
||||
### Q-6: Window chrome — native frame vs frameless custom
|
||||
- **Status:** Resolved → [D-57](architecture.md#d-57-frameless-custom-chrome-with-per-column-24px-hats)
|
||||
- **Question:** Does clide ship with the OS-native window frame (title bar, min/max/close from the WM) or a frameless custom chrome that gives us pixel control at the cost of reimplementing window controls per-platform?
|
||||
- **Context:** Surfaced during Tier-0 plumbing discussion; decision deferred.
|
||||
- **Context:** Surfaced during Tier-0 plumbing discussion; resolved 2026-04-23 — frameless with per-column 24px hats.
|
||||
- **Source:** 2026-04-21 planning.
|
||||
|
||||
### Q-007: macOS app bundle signing / notarisation
|
||||
### Q-7: macOS app bundle signing / notarisation
|
||||
- **Status:** Open
|
||||
- **Question:** Distributing a signed macOS `.app` requires a Developer ID and a notarisation pipeline. Do we gate macOS builds on this (Tier 6), or ship unsigned with a known "right-click, open" user workflow for early testers?
|
||||
- **Context:** Linux is primary; macOS is a stretch target. Notarisation is a separate cost from the Flutter build.
|
||||
- **Source:** 2026-04-21 planning.
|
||||
|
||||
### Q-021: Pql absorbs planning vs keeps separate
|
||||
### Q-21: Pql absorbs planning vs keeps separate
|
||||
- **Status:** Open
|
||||
- **Question:** Three shapes for planning tooling's long-term home: (A) Pql absorbs planning — `pql decisions …` + `pql ticket …` subcommands; clide shells out. (B) Clide absorbs pql — reverse [D-003](architecture.md), one big Dart tool. (C) Separate new binary just for planning.
|
||||
- **Context:** User is leaning (A). This plan assumes (A) without committing. If (A) doesn't land, [D-040](process.md#d-040-python-stopgap-under-toolsscriptsplan)'s sunset condition changes. Gates all tooling work. Integration constraints that shape this question are captured in [D-039](process.md#d-039-planning-tooling-lives-in-pql) / [R-009](rejected.md#r-009-port-planning-tooling-into-clide).
|
||||
- **Question:** Three shapes for planning tooling's long-term home: (A) Pql absorbs planning — `pql decisions …` + `pql ticket …` subcommands; clide shells out. (B) Clide absorbs pql — reverse [D-3](architecture.md), one big Dart tool. (C) Separate new binary just for planning.
|
||||
- **Context:** User is leaning (A). This plan assumes (A) without committing. If (A) doesn't land, [D-40](process.md#d-40-python-stopgap-under-toolsscriptsplan)'s sunset condition changes. Gates all tooling work. Integration constraints that shape this question are captured in [D-39](process.md#d-39-planning-tooling-lives-in-pql) / [R-9](rejected.md#r-9-port-planning-tooling-into-clide).
|
||||
- **Source:** 2026-04-21 planning.
|
||||
|
||||
### Q-023: SSH-remote development — run clide against a remote workspace
|
||||
### Q-23: SSH-remote development — run clide against a remote workspace
|
||||
- **Status:** Open
|
||||
- **Question:** Clide today assumes the workspace, the daemon, and the Flutter UI all run on the same machine. A growing class of users edits on remote systems (build servers, GPU boxes, cloud dev environments). What's the architecture for "open repo on host-B from UI on host-A"? Two shapes: (A) daemon-on-remote — clide's Dart daemon runs on the remote; the app talks to it over an SSH-tunnelled unix socket or a dedicated TCP socket (mTLS?), pty/process/filesystem work stays server-side; local app is pure UI. (B) filesystem-mounted — remote mounted via sshfs/9p/rclone, daemon runs locally against the mount; simpler but every fs op + git call crosses the network, and PTYs get complicated (local shell on remote filesystem? ssh-exec per command?). (A) matches VS Code Remote / JetBrains Gateway; (B) matches nothing load-bearing. Sub-questions either way: auth (ssh-agent? per-project keys? OIDC?), tmux / Claude session persistence semantics (does primary-per-repo re-key on host + repo?), multi-host identity in `.pql/pql.db`, latency tolerance for the event stream, re-sync on disconnect.
|
||||
- **Context:** Surfaced 2026-04-22 during Tier-1 planning. Not a Tier 1 concern — terminal + Claude panes land local-first — but the daemon/IPC seam decisions (notably `D-005` and `D-006`) constrain the future answer. Worth scoping before Tier 6 (extension API) so third-party extensions don't accrue assumptions the remote path would have to unwind.
|
||||
- **Context:** Surfaced 2026-04-22 during Tier-1 planning. Not a Tier 1 concern — terminal + Claude panes land local-first — but the daemon/IPC seam decisions (notably `D-5` and `D-6`) constrain the future answer. Worth scoping before Tier 6 (extension API) so third-party extensions don't accrue assumptions the remote path would have to unwind.
|
||||
- **Source:** 2026-04-22 planning (user-raised).
|
||||
|
||||
### Q-022: Ticket persistence strategy
|
||||
### Q-22: Ticket persistence strategy
|
||||
- **Status:** Open
|
||||
- **Question:** Once [Q-021](#q-021-pql-absorbs-planning-vs-keeps-separate) resolves in favour of (A), how do tickets handle shared team state? (1) Never commit (per-dev, ephemeral — works for solo). (2) Commit on milestone (settled-reach's sprint-close pattern — kanban has no natural equivalent, `release` or `tier-cut` is the closest). (3) Markdown mirror — every mutation writes `tickets/T-NNN.md` alongside SQLite; git-legible authoritative record; DB is rebuildable. (3) is probably the eventual answer.
|
||||
- **Question:** Once [Q-21](#q-21-pql-absorbs-planning-vs-keeps-separate) resolves in favour of (A), how do tickets handle shared team state? (1) Never commit (per-dev, ephemeral — works for solo). (2) Commit on milestone (settled-reach's sprint-close pattern — kanban has no natural equivalent, `release` or `tier-cut` is the closest). (3) Markdown mirror — every mutation writes `tickets/T-NNN.md` alongside SQLite; git-legible authoritative record; DB is rebuildable. (3) is probably the eventual answer.
|
||||
- **Context:** Kanban's lack of a sync event breaks settled-reach's SQLite-authoritative approach the moment two devs collaborate.
|
||||
- **Source:** 2026-04-21 planning.
|
||||
|
||||
### Q-025: Body text face — mono everywhere vs Josefin Sans UI + mono code
|
||||
### Q-25: Body text face — mono everywhere vs Josefin Sans UI + mono code
|
||||
- **Status:** Open
|
||||
- **Question:** The design handoff uses JetBrains Mono for all UI text (tab labels, file paths, status bar, sidebar labels), reserving Josefin Sans only for display/title text. Our current implementation uses Josefin Sans as the ambient UI face with JetBrains Mono only for code/terminal/diff surfaces. Which direction?
|
||||
- **Context:** The design's "mono everywhere" rationale: clide is an IDE for people who like grids. The current Josefin Sans rationale: visual distinction between chrome text and code text, warmer feel. Both are valid — this is a feel decision, not a technical one.
|
||||
- **Source:** 2026-04-22 design handoff review.
|
||||
|
||||
### Q-026: Small screen layout (< 1000px)
|
||||
### Q-26: Small screen layout (< 1000px)
|
||||
- **Status:** Open
|
||||
- **Question:** Below 1000px window width, should clide switch to modal viewer/editor instead of split, or stack panels vertically? The spec defers this but flags it.
|
||||
- **Context:** The interaction model defines breakpoints down to 1200px but punts on < 1000px. This matters for small laptops and tiling WM users who give clide half a screen.
|
||||
- **Source:** 2026-04-22 interaction model spec (Wireframe — Flows v3), open question 1.
|
||||
|
||||
### Q-027: Two-editor split
|
||||
### Q-27: Two-editor split
|
||||
- **Status:** Open
|
||||
- **Question:** Should clide support two files open in the editor simultaneously (horizontal split in the middle column)? The spec says resist until proven needed — feels like tabs creeping back.
|
||||
- **Context:** [D-048](architecture.md#d-048-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first) deletes buffer tabs. A two-editor split would be the only way to compare files side-by-side without using the viewer ↔ editor swap. The diff view may cover most of this need.
|
||||
- **Context:** [D-48](architecture.md#d-48-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first) deletes buffer tabs. A two-editor split would be the only way to compare files side-by-side without using the viewer ↔ editor swap. The diff view may cover most of this need.
|
||||
- **Source:** 2026-04-22 interaction model spec (Wireframe — Flows v3), open question 2.
|
||||
|
||||
### Q-028: Terminal strip scope — shell only or logs/errors/tests
|
||||
### Q-28: Terminal strip scope — shell only or logs/errors/tests
|
||||
- **Status:** Open
|
||||
- **Question:** Is the app strip (bottom bar) purely a terminal shell + status, or does it also host tabs for logs, errors, and test output? Probably both, later.
|
||||
- **Context:** The interaction model spec defines the app strip as 14px with terminal shell + daemon indicator + branch; expanding on focus. If it grows to host logs/errors/tests, it becomes a mini-panel with its own tab model.
|
||||
- **Source:** 2026-04-22 interaction model spec (Wireframe — Flows v3), open question 3.
|
||||
|
||||
### Q-029: Branch picker location
|
||||
### Q-29: Branch picker location
|
||||
- **Status:** Open
|
||||
- **Question:** The branch picker was moved out of the bottom status bar. Best place is inside the git section header (left panel), with a compact indicator in the app strip. Confirm or revise?
|
||||
- **Context:** The interaction model spec suggests the git section header as the primary location. The app strip shows a compact indicator (branch name only) for at-a-glance awareness.
|
||||
- **Source:** 2026-04-22 interaction model spec (Wireframe — Flows v3), open question 4.
|
||||
|
||||
### Q-030: Focus behavior when editor is dirty and viewer is peeked
|
||||
### Q-30: Focus behavior when editor is dirty and viewer is peeked
|
||||
- **Status:** Open
|
||||
- **Question:** When the editor has unsaved changes and the user peeks a viewer, where does focus land? The spec says prompt-bar-rule wins: focus stays in Claude.
|
||||
- **Context:** This intersects [D-047](architecture.md#d-047-interaction-model-claude-is-home-layout) (Claude is home) and [D-049](architecture.md#d-049-editor-mode-inline-above-claude-viewer-swap) (editor mode). If focus always snaps to Claude, the user must explicitly re-focus the editor to continue typing.
|
||||
- **Context:** This intersects [D-47](architecture.md#d-47-interaction-model-claude-is-home-layout) (Claude is home) and [D-49](architecture.md#d-49-editor-mode-inline-above-claude-viewer-swap) (editor mode). If focus always snaps to Claude, the user must explicitly re-focus the editor to continue typing.
|
||||
- **Source:** 2026-04-22 interaction model spec (Wireframe — Flows v3), open question 5.
|
||||
|
||||
### Q-31: XWayland fallback for frameless — proper Wayland protocol needed
|
||||
- **Status:** Open (load-bearing workaround in place)
|
||||
- **Question:** The frameless window (D-57) currently forces `GDK_BACKEND=x11` because GTK3 doesn't implement the `xdg-decoration` Wayland protocol and KWin ignores `gtk_window_set_decorated(FALSE)` on native Wayland. When do we replace this with a proper implementation?
|
||||
- **Context:** The XWayland fallback works but has tradeoffs: one extra buffer copy per frame, degraded fractional-scaling on HiDPI (125%/150% gets blurry), loss of native Wayland touchpad gestures and per-window DPI, and slightly less reliable cross-app drag-and-drop. For an IDE these are tolerable but not ideal.
|
||||
|
||||
**Current workaround (`Makefile`):**
|
||||
```
|
||||
GDK_BACKEND=x11 flutter run -d linux
|
||||
```
|
||||
Also set in the native runner: `gtk_window_set_decorated(window, FALSE)` + `gdk_window_set_decorations(gdk_win, 0)` in `linux/runner/my_application.cc`.
|
||||
|
||||
**Rollback if unstable:** Remove `GDK_BACKEND=x11` from the Makefile `run` target. The OS title bar returns but the app runs on native Wayland. Our hat bar renders below the OS bar (double chrome) until the proper fix lands.
|
||||
|
||||
**Proper fix — bypass GTK, talk `libwayland-client` directly:**
|
||||
1. Link `libwayland-client` in `linux/runner/CMakeLists.txt`.
|
||||
2. Generate protocol headers from `xdg-decoration-unstable-v1.xml` (ships with `wayland-protocols` package) via `wayland-scanner`.
|
||||
3. In `my_application.cc` after window realize: get the `wl_display` via `gdk_wayland_display_get_wl_display()`, bind to `zxdg_decoration_manager_v1` from the registry, get the toplevel decoration object, call `zxdg_toplevel_decoration_v1_set_mode(deco, MODE_CLIENT_SIDE)`.
|
||||
4. ~100 lines C total. Needs `wayland-protocols` as a build dep.
|
||||
5. The `GDK_BACKEND=x11` env var is then removed.
|
||||
|
||||
**Alternative timeline:** Flutter moves to GTK4 (which has built-in xdg-decoration support). Track Flutter issue #94381. When it ships, drop all native decoration code and use `gtk_window_set_decorated(FALSE)` — it will just work.
|
||||
|
||||
- **Source:** 2026-04-23 D-57 implementation.
|
||||
|
||||
---
|
||||
|
||||
@@ -4,21 +4,21 @@ Extension API shape, Lua runtime vendoring, manifest schema version.
|
||||
|
||||
---
|
||||
|
||||
### Q-008: Extension API shape — widgets, subcommands, both?
|
||||
### Q-8: Extension API shape — widgets, subcommands, both?
|
||||
- **Status:** Open
|
||||
- **Question:** Should extensions contribute widgets (panels, tabs, status-bar items), subcommands (CLI verbs), or both? Both is the obvious answer but has a cost in API surface that must be designed carefully to satisfy user/Claude parity ([D-006](architecture.md)).
|
||||
- **Question:** Should extensions contribute widgets (panels, tabs, status-bar items), subcommands (CLI verbs), or both? Both is the obvious answer but has a cost in API surface that must be designed carefully to satisfy user/Claude parity ([D-6](architecture.md)).
|
||||
- **Context:** CLAUDE.md flags this as "decide during Tier 6." `builtin.*` stubs today contribute widgets + commands + keybindings; the third-party Lua contract has to match.
|
||||
- **Source:** CLAUDE.md "Open questions" footer.
|
||||
|
||||
### Q-009: Lua runtime vendoring
|
||||
### Q-9: Lua runtime vendoring
|
||||
- **Status:** Open
|
||||
- **Question:** Does the Lua supporter tool ([D-019](extensions.md#d-019-lua-runtime-as-ptyc-peer-supporter-tool)) bundle liblua source (build with the binary) or link system liblua (smaller binary, fragile ABI)?
|
||||
- **Question:** Does the Lua supporter tool ([D-19](extensions.md#d-19-lua-runtime-as-ptyc-peer-supporter-tool)) bundle liblua source (build with the binary) or link system liblua (smaller binary, fragile ABI)?
|
||||
- **Context:** `ptyc` has no deps; Lua is different — it's a whole VM. Bundling is the straightforward choice but locks a Lua version per clide release.
|
||||
- **Source:** 2026-04-21 planning.
|
||||
|
||||
### Q-010: Extension manifest `schema_version:`
|
||||
### Q-10: Extension manifest `schema_version:`
|
||||
- **Status:** Open
|
||||
- **Question:** What's the manifest schema-version scheme and bump policy? Coupled with [Q-005](questions-architecture.md#q-005-ipc-wire-format-stability) (IPC wire format) — both want a versioning story.
|
||||
- **Question:** What's the manifest schema-version scheme and bump policy? Coupled with [Q-5](questions-architecture.md#q-5-ipc-wire-format-stability) (IPC wire format) — both want a versioning story.
|
||||
- **Context:** Today's manifests have no `schema_version:`. Adding one is cheap; the hard part is deciding when we bump.
|
||||
- **Source:** 2026-04-21 planning.
|
||||
|
||||
|
||||
@@ -7,37 +7,37 @@ Tooling-domain questions currently live here too. Split into
|
||||
|
||||
---
|
||||
|
||||
### Q-015: Editor tab — full LSP vs tree-sitter-only highlight
|
||||
### Q-15: Editor tab — full LSP vs tree-sitter-only highlight
|
||||
- **Status:** Open
|
||||
- **Question:** Tier 2's editor tab: do we integrate a full LSP story (analyzer server + hovers + completions + diagnostics) or ship tree-sitter-only syntax highlighting and defer LSP to Tier 6?
|
||||
- **Context:** Full LSP is a large subsystem; tree-sitter is a weekend. User is a heavy LSP user in other IDEs — missing it hurts. CLAUDE.md flags this as "decide during Tier 2."
|
||||
- **Source:** CLAUDE.md "Open questions" footer.
|
||||
|
||||
### Q-016: `tree-sitter-dart` grammar maintenance
|
||||
### Q-16: `tree-sitter-dart` grammar maintenance
|
||||
- **Status:** Open
|
||||
- **Question:** `UserNobody14/tree-sitter-dart` is archived. `nielsenko/tree-sitter-dart` is the maintained fork. Do we pin `nielsenko/`, mirror it in-repo, or lean on the Dart analyzer's own semantic output and skip tree-sitter for Dart?
|
||||
- **Context:** If tree-sitter is the Tier-2 answer ([Q-015](#q-015-editor-tab-full-lsp-vs-tree-sitter-only)), grammar sourcing matters.
|
||||
- **Context:** If tree-sitter is the Tier-2 answer ([Q-15](#q-15-editor-tab-full-lsp-vs-tree-sitter-only)), grammar sourcing matters.
|
||||
- **Source:** 2026-04-21 planning.
|
||||
|
||||
### Q-017: Icon set growth
|
||||
### Q-17: Icon set growth
|
||||
- **Status:** Open
|
||||
- **Question:** Hand-drawn `CustomPainter` catalogue (total control, pixel-perfect on every theme, slow to grow) vs SVG + parser (faster to grow, one more dep, theming is harder)?
|
||||
- **Context:** We rejected Nerd-font glyphs ([R-006](rejected.md#r-006-nerd-font-glyph-icons)); something has to fill the gap.
|
||||
- **Context:** We rejected Nerd-font glyphs ([R-6](rejected.md#r-6-nerd-font-glyph-icons)); something has to fill the gap.
|
||||
- **Source:** 2026-04-21 planning.
|
||||
|
||||
### Q-018: Theme hot-reload in release builds
|
||||
### Q-18: Theme hot-reload in release builds
|
||||
- **Status:** Open
|
||||
- **Question:** The theme picker supports live-reloading a YAML during development. Does the same path stay open in release builds (user tweaks `~/.config/clide/themes/foo.yaml` and the app re-reads on focus) or is release-build theming restricted to built-in + settings-UI-installed themes?
|
||||
- **Context:** Hot-reload is powerful for theme authoring but opens a file-watch + re-parse path in release code.
|
||||
- **Source:** 2026-04-21 planning.
|
||||
|
||||
### Q-019: (withdrawn)
|
||||
### Q-19: (withdrawn)
|
||||
- **Status:** Resolved → n/a
|
||||
- **Note:** Earlier floated as "ticket markdown mirror vs SQLite" — no longer a split question. Markdown mirror is tracked in [Q-022](questions-architecture.md#q-022-ticket-persistence-strategy); SQLite is the current stopgap per [D-040](process.md#d-040-python-stopgap-under-toolsscriptsplan).
|
||||
- **Note:** Earlier floated as "ticket markdown mirror vs SQLite" — no longer a split question. Markdown mirror is tracked in [Q-22](questions-architecture.md#q-22-ticket-persistence-strategy); SQLite is the current stopgap per [D-40](process.md#d-40-python-stopgap-under-toolsscriptsplan).
|
||||
|
||||
### Q-020: Kernel DB service — namespaced SQL access?
|
||||
### Q-20: Kernel DB service — namespaced SQL access?
|
||||
- **Status:** Open
|
||||
- **Question:** Do extensions get namespaced SQL access to `.clide/clide.db` (tables prefixed `ext_<id>_…`) or stay on the `kernel.settings` key/value facade? Admission-level question ([D-012](architecture.md#d-012-kernel-admission-rule)).
|
||||
- **Question:** Do extensions get namespaced SQL access to `.clide/clide.db` (tables prefixed `ext_<id>_…`) or stay on the `kernel.settings` key/value facade? Admission-level question ([D-12](architecture.md#d-12-kernel-admission-rule)).
|
||||
- **Context:** Some extensions (tickets, canvas, graph) naturally want relational storage. K/V gets awkward fast.
|
||||
- **Source:** 2026-04-21 planning.
|
||||
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
---
|
||||
|
||||
### Q-011: Coverage gates — hard thresholds vs soft reporting
|
||||
### Q-11: Coverage gates — hard thresholds vs soft reporting
|
||||
- **Status:** Open
|
||||
- **Question:** `ci/test_coverage.sh` emits lcov + a summary. Do we gate merges on a hard threshold (fail < 80%), report softly, or tier per directory (kernel > 90%, built-ins > 70%, widgets covered by goldens exempt)?
|
||||
- **Context:** Hard thresholds force tests-for-coverage-sake; soft reporting gets ignored.
|
||||
- **Source:** 2026-04-21 planning.
|
||||
|
||||
### Q-012: Screen-reader automation (axe-core via Playwright)
|
||||
### Q-12: Screen-reader automation (axe-core via Playwright)
|
||||
- **Status:** Open
|
||||
- **Question:** [D-022](accessibility.md#d-022-wcag-aa-contrast-gate-on-bundled-themes) gates contrast at build time. Do we also run axe-core against the WASM build in Playwright for runtime a11y issues (missing labels, invalid roles, orphan focusables)?
|
||||
- **Question:** [D-22](accessibility.md#d-22-wcag-aa-contrast-gate-on-bundled-themes) gates contrast at build time. Do we also run axe-core against the WASM build in Playwright for runtime a11y issues (missing labels, invalid roles, orphan focusables)?
|
||||
- **Context:** axe-core is JS; runs in the browser against the rendered tree. Extra CI time; extra signal.
|
||||
- **Source:** 2026-04-21 planning.
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ open questions with one-line summaries.
|
||||
|
||||
## Load-bearing questions (gate other work)
|
||||
|
||||
- **[Q-021](questions-process.md#q-021-pql-absorbs-planning-vs-keeps-separate)** — Pql absorbs planning features vs clide absorbs pql vs separate CLI. Blocks the stopgap sunset and shapes the pql-side planning session.
|
||||
- **[Q-022](questions-process.md#q-022-ticket-persistence-strategy)** — Ticket persistence: per-dev only / milestone-committed / markdown-mirrored. Shapes multi-contributor story.
|
||||
- **[Q-005](questions-architecture.md#q-005-ipc-wire-format-stability)** — IPC wire-format stability and `schema_version:` in `pubspec.yaml`. Decide when the first real subcommand lands.
|
||||
- **[Q-015](questions-process.md#q-015-editor-tab-full-lsp-vs-tree-sitter-only)** — Editor tab: full LSP integration vs tree-sitter-only highlight. Decide during Tier 2.
|
||||
- **[Q-21](questions-process.md#q-21-pql-absorbs-planning-vs-keeps-separate)** — Pql absorbs planning features vs clide absorbs pql vs separate CLI. Blocks the stopgap sunset and shapes the pql-side planning session.
|
||||
- **[Q-22](questions-process.md#q-22-ticket-persistence-strategy)** — Ticket persistence: per-dev only / milestone-committed / markdown-mirrored. Shapes multi-contributor story.
|
||||
- **[Q-5](questions-architecture.md#q-5-ipc-wire-format-stability)** — IPC wire-format stability and `schema_version:` in `pubspec.yaml`. Decide when the first real subcommand lands.
|
||||
- **[Q-15](questions-process.md#q-15-editor-tab-full-lsp-vs-tree-sitter-only)** — Editor tab: full LSP integration vs tree-sitter-only highlight. Decide during Tier 2.
|
||||
|
||||
---
|
||||
|
||||
@@ -5,59 +5,59 @@ future reference.
|
||||
|
||||
---
|
||||
|
||||
### R-002: Go sidecar
|
||||
- **Rejected:** 2026-04-20 (was ADR 0002; superseded by [D-005](architecture.md#d-005-dart-core-ptyc-peer))
|
||||
### R-2: Go sidecar
|
||||
- **Rejected:** 2026-04-20 (was ADR 0002; superseded by [D-5](architecture.md#d-5-dart-core-ptyc-peer))
|
||||
- **Reason:** The ADR picked Go on two premises — (a) the heavy work belongs in a language separate from the UI layer, and (b) pql is Go so muscle memory transfers. Both broke on reassessment. The sidecar stripped of PTY is I/O-bound glue that `dart:io` covers cleanly (unix sockets, JSON-lines framing, process tables, shell-outs). The real axis was *separate process vs shared language*, not Go vs Rust, and separate-process is what matters (session persistence needs the daemon to outlive the app), not language. PTY is the one place Dart is genuinely weak — Dart's multi-threaded VM can't safely `fork()` — and that single constraint forces a native helper regardless, independent of whether the rest of the core is Dart. Once a small native helper is accepted, the question "does *everything else* need to be in that same native language" answers itself: no. Go sidecar directory dissolved; `ptyc` (C, PTY-only, pql-peer) is the surviving native supporter tool.
|
||||
- **Cross-reference:** [D-005](architecture.md#d-005-dart-core-ptyc-peer)
|
||||
- **Cross-reference:** [D-5](architecture.md#d-5-dart-core-ptyc-peer)
|
||||
|
||||
### R-003: `MaterialApp` root
|
||||
### R-3: `MaterialApp` root
|
||||
- **Rejected:** 2026-04-21
|
||||
- **Reason:** Dragged in Material theming, default icons, and platform chrome that fought the custom three-tier theme pipeline ([D-009](architecture.md#d-009-three-tier-theme-pipeline)). Every bundled theme had to override Material defaults to look like clide; the overrides were visible in widget tests as "why is this `ElevatedButton` colored this way."
|
||||
- **Cross-reference:** [D-007](architecture.md#d-007-app-root-is-bare-widgetsapp)
|
||||
- **Reason:** Dragged in Material theming, default icons, and platform chrome that fought the custom three-tier theme pipeline ([D-9](architecture.md#d-9-three-tier-theme-pipeline)). Every bundled theme had to override Material defaults to look like clide; the overrides were visible in widget tests as "why is this `ElevatedButton` colored this way."
|
||||
- **Cross-reference:** [D-7](architecture.md#d-7-app-root-is-bare-widgetsapp)
|
||||
|
||||
### R-004: Flutter `intl` + ARB codegen for i18n
|
||||
### R-4: Flutter `intl` + ARB codegen for i18n
|
||||
- **Rejected:** 2026-04-21
|
||||
- **Reason:** ARB codegen is inflexible for plugin-contributed catalogs — every catalogue needs a codegen pass, every extension ships with pre-generated Dart, and runtime merging is fighting the tool. The fframe text-driven pattern reads JSON at runtime with no codegen, which fits extension-shipped catalogs cleanly.
|
||||
- **Cross-reference:** [D-021](accessibility.md#d-021-i18n-is-a-tier-0-contract)
|
||||
- **Cross-reference:** [D-21](accessibility.md#d-21-i18n-is-a-tier-0-contract)
|
||||
|
||||
### R-005: Patrol test runner
|
||||
### R-5: Patrol test runner
|
||||
- **Rejected:** 2026-04-21
|
||||
- **Reason:** Adds a dependency (violates [D-031](tooling.md#d-031-prefer-zero-deps-exact-pin)) for a capability we get from Playwright + Flutter's own semantics tree. Patrol's value proposition (native-gesture emulation) is less relevant on Linux desktop than on mobile.
|
||||
- **Cross-reference:** [D-026](testing.md#d-026-web-driver-raw-playwright-plus-flutter-semantics)
|
||||
- **Reason:** Adds a dependency (violates [D-31](tooling.md#d-31-prefer-zero-deps-exact-pin)) for a capability we get from Playwright + Flutter's own semantics tree. Patrol's value proposition (native-gesture emulation) is less relevant on Linux desktop than on mobile.
|
||||
- **Cross-reference:** [D-26](testing.md#d-26-web-driver-raw-playwright-plus-flutter-semantics)
|
||||
|
||||
### R-006: Nerd-font glyph icons
|
||||
### R-6: Nerd-font glyph icons
|
||||
- **Rejected:** 2026-04-21
|
||||
- **Reason:** TUI hangover from the Python-era clide under `legacy/`. Not desktop-native; forces a font dependency; doesn't theme consistently. Clide uses custom icon primitives (Tier 6 revisits with proper icon-set design).
|
||||
- **Cross-reference:** [Q-017](questions-process.md#q-017-icon-set-growth)
|
||||
- **Cross-reference:** [Q-17](questions-process.md#q-17-icon-set-growth)
|
||||
|
||||
### R-007: `CupertinoApp` root
|
||||
### R-7: `CupertinoApp` root
|
||||
- **Rejected:** 2026-04-21
|
||||
- **Reason:** iOS-opinionated; wrong shell for a Linux-primary desktop IDE. Same theming-collision problem as [R-003](#r-003-materialapp-root).
|
||||
- **Cross-reference:** [D-007](architecture.md#d-007-app-root-is-bare-widgetsapp)
|
||||
- **Reason:** iOS-opinionated; wrong shell for a Linux-primary desktop IDE. Same theming-collision problem as [R-3](#r-3-materialapp-root).
|
||||
- **Cross-reference:** [D-7](architecture.md#d-7-app-root-is-bare-widgetsapp)
|
||||
|
||||
### R-008: Riverpod / Provider / BLoC for state
|
||||
### R-8: Riverpod / Provider / BLoC for state
|
||||
- **Rejected:** 2026-04-21
|
||||
- **Reason:** Violates [D-031](tooling.md#d-031-prefer-zero-deps-exact-pin). `ChangeNotifier` + `ListenableBuilder` ship in the SDK, fake trivially, and cover the state model we need. The ergonomic wins of Riverpod / Provider don't clear the "new dependency" bar at clide's scale.
|
||||
- **Cross-reference:** [D-010](architecture.md#d-010-state-management-changenotifier)
|
||||
- **Reason:** Violates [D-31](tooling.md#d-31-prefer-zero-deps-exact-pin). `ChangeNotifier` + `ListenableBuilder` ship in the SDK, fake trivially, and cover the state model we need. The ergonomic wins of Riverpod / Provider don't clear the "new dependency" bar at clide's scale.
|
||||
- **Cross-reference:** [D-10](architecture.md#d-10-state-management-changenotifier)
|
||||
|
||||
### R-009: Port planning tooling into clide
|
||||
### R-9: Port planning tooling into clide
|
||||
- **Rejected:** 2026-04-21
|
||||
- **Reason:** Earlier in the planning session the assumption was "clide owns Dart subcommands for decisions + tickets." That breaks the day a contributor works in a terminal or in VS Code / JetBrains — they have no `clide` binary to run. Reversing: pql owns planning long-term (see [D-039](process.md#d-039-planning-tooling-lives-in-pql)); clide consumes via shell-out.
|
||||
- **Cross-reference:** [D-039](process.md#d-039-planning-tooling-lives-in-pql)
|
||||
- **Reason:** Earlier in the planning session the assumption was "clide owns Dart subcommands for decisions + tickets." That breaks the day a contributor works in a terminal or in VS Code / JetBrains — they have no `clide` binary to run. Reversing: pql owns planning long-term (see [D-39](process.md#d-39-planning-tooling-lives-in-pql)); clide consumes via shell-out.
|
||||
- **Cross-reference:** [D-39](process.md#d-39-planning-tooling-lives-in-pql)
|
||||
|
||||
### R-010: Python-script stopgap under `tooling/db/`
|
||||
### R-10: Python-script stopgap under `tooling/db/`
|
||||
- **Rejected:** 2026-04-21
|
||||
- **Reason:** Location, not language. Settled-reach puts scripts at `tooling/db/` — copying that path here creates a script-pollution problem: every project using the pattern commits its own copy. The accepted Python port ([D-040](process.md#d-040-python-stopgap-under-toolsscriptsplan)) lives at `tools/scripts/plan`, clearly signalled as dev-tooling and time-limited.
|
||||
- **Cross-reference:** [D-040](process.md#d-040-python-stopgap-under-toolsscriptsplan)
|
||||
- **Reason:** Location, not language. Settled-reach puts scripts at `tooling/db/` — copying that path here creates a script-pollution problem: every project using the pattern commits its own copy. The accepted Python port ([D-40](process.md#d-40-python-stopgap-under-toolsscriptsplan)) lives at `tools/scripts/plan`, clearly signalled as dev-tooling and time-limited.
|
||||
- **Cross-reference:** [D-40](process.md#d-40-python-stopgap-under-toolsscriptsplan)
|
||||
|
||||
### R-011: Permanent stopgap
|
||||
### R-11: Permanent stopgap
|
||||
- **Rejected:** 2026-04-21
|
||||
- **Reason:** If the Python port under `tools/scripts/plan` outlasts pql's feature parity, delete it. The deletion commit should be one changeset: remove `tools/scripts/plan`, remove its Makefile target (`decisions-validate` rewires to `pql decisions validate`), add a `CHANGELOG.md` entry under Removed, and verify `.pql/pql.db` still opens under the new `pql` binary.
|
||||
- **Cross-reference:** [D-040](process.md#d-040-python-stopgap-under-toolsscriptsplan)
|
||||
- **Cross-reference:** [D-40](process.md#d-40-python-stopgap-under-toolsscriptsplan)
|
||||
|
||||
### R-012: MaterialApp wrapper from design handoff
|
||||
### R-12: MaterialApp wrapper from design handoff
|
||||
- **Rejected:** 2026-04-22
|
||||
- **Reason:** The design handoff delivers theme files as `MaterialApp`/`ThemeData` Dart classes. This is the delivery format of claude.ai/design, not a design intent. Adopting Material's widget system would contradict [D-007](architecture.md#d-007-app-root-is-bare-widgetsapp) (bare WidgetsApp, no Material/Cupertino). We translate the palette tokens and syntax roles into our existing YAML + `SurfaceTokens` pipeline.
|
||||
- **Cross-reference:** [D-043](architecture.md#d-043-design-handoff-adopt-token-palettes-reject-material-wrapper)
|
||||
- **Reason:** The design handoff delivers theme files as `MaterialApp`/`ThemeData` Dart classes. This is the delivery format of claude.ai/design, not a design intent. Adopting Material's widget system would contradict [D-7](architecture.md#d-7-app-root-is-bare-widgetsapp) (bare WidgetsApp, no Material/Cupertino). We translate the palette tokens and syntax roles into our existing YAML + `SurfaceTokens` pipeline.
|
||||
- **Cross-reference:** [D-43](architecture.md#d-43-design-handoff-adopt-token-palettes-reject-material-wrapper)
|
||||
|
||||
---
|
||||
|
||||
@@ -4,56 +4,56 @@ Test pyramid, drivers, client-side constraint.
|
||||
|
||||
---
|
||||
|
||||
### D-023: Test pyramid — seven layers
|
||||
### D-23: Test pyramid — seven layers
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** The pyramid has seven layers: unit (pure Dart) → widget (pumped + find) → golden (visual primitives) → a11y (semantics coverage + keyboard + contrast + i18n) → integration (`flutter test integration_test/`) → E2E (Playwright driving the WASM build + `clide --daemon` subprocess) → startup-smoke (`ci/smoke_bundle.sh`: build Linux release, run under xvfb for 5 s).
|
||||
- **Rationale:** Each layer catches a distinct regression class. Skipping any layer means that class ships unprotected. Pushed back when earlier rounds proposed "just widget + E2E"; widget can't catch paint regressions (that's golden), E2E can't catch a11y tree drift (that's semantics).
|
||||
- **Cost:** Seven CI jobs; total wall time budgeted at < 15 min. Pre-push runs layers 1-4 (< 90 s — see [D-029](#d-029-pre-push-gate-fast-layer-only)).
|
||||
- **Cost:** Seven CI jobs; total wall time budgeted at < 15 min. Pre-push runs layers 1-4 (< 90 s — see [D-29](#d-29-pre-push-gate-fast-layer-only)).
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-024: Golden tests — primitives only, Alchemist + Ahem
|
||||
### D-24: Golden tests — primitives only, Alchemist + Ahem
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Goldens cover primitive widgets only (button, tab, panel header, token-bound surfaces). Composed layouts are tested via widget-find assertions, not pixel goldens. Golden rendering uses Alchemist with the Ahem font to get deterministic text metrics across platforms.
|
||||
- **Rationale:** Pixel goldens of composed layouts churn constantly (one tweak → fifty golden diffs) without catching more than primitive goldens would. Alchemist + Ahem sidesteps the "font rendering differs between Linux CI and macOS dev" trap.
|
||||
- **Cost:** Goldens have zero real text; layouts rely on widget tests. Acceptable.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-025: Mocks — mocktail at IO, hand-rolled fakes for ChangeNotifiers
|
||||
### D-25: Mocks — mocktail at IO, hand-rolled fakes for ChangeNotifiers
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** `mocktail 1.0.4` mocks IO boundaries (sockets, processes, `dart:io` File/Directory). `ChangeNotifier` facades get hand-rolled fakes — tiny classes that extend `ChangeNotifier` with test-controlled setters. No `mocktail` for notifiers.
|
||||
- **Rationale:** Mocking a `ChangeNotifier` with a generated mock hides subscription bugs — `notifyListeners` becomes a mock call instead of actually firing. Hand-rolled fakes exercise the real subscription machinery.
|
||||
- **Cost:** Roughly 20 lines per fake. Rounds out to less code than configuring a mocktail whenCall chain.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-026: Web driver — raw Playwright + Flutter semantics
|
||||
### D-26: Web driver — raw Playwright + Flutter semantics
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** The browser-side E2E driver uses Playwright directly against Flutter's semantics tree (`flt-semantics[aria-label]`). No Patrol, no flutter_driver for web. The driver (`tools/ui/driver.ts`) clicks `flt-semantics-placeholder` on load to activate semantics, then queries by substring aria-label (Flutter merges sibling labels).
|
||||
- **Rationale:** Patrol adds a dependency for a capability we get from semantics + Playwright directly. Labels are the a11y tree we already contract to maintain ([D-020](accessibility.md#d-020-a11y-is-a-tier-0-contract)); reusing them for E2E is a win.
|
||||
- **Rationale:** Patrol adds a dependency for a capability we get from semantics + Playwright directly. Labels are the a11y tree we already contract to maintain ([D-20](accessibility.md#d-20-a11y-is-a-tier-0-contract)); reusing them for E2E is a win.
|
||||
- **Cost:** Driver has to know Flutter's sibling-merging behaviour — documented in `docs/testing/claude-ui-workflow.md`.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-027: Startup regression gate
|
||||
### D-27: Startup regression gate
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Two gates guard boot regressions: `integration_test/app_starts_test.dart` (fast — boots the app under `flutter test`) and `ci/smoke_bundle.sh` (slow — `flutter build linux`, run the bundle under `xvfb` for 5 s, assert no exit code). Both run in CI as `startup-bundle` job.
|
||||
- **Rationale:** The fast integration test catches "boot hangs in Dart land"; the bundle smoke catches "boot breaks under release compile + production xvfb" — different regression classes.
|
||||
- **Cost:** One extra CI job + `xvfb` on the runner. Five seconds of boot is enough; we've already caught one regression at this gate.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-028: Test organisation — mirror `lib/` in `test/`
|
||||
### D-28: Test organisation — mirror `lib/` in `test/`
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Every test file lives at the same relative path as its subject. `app/lib/kernel/src/i18n/catalog_loader.dart` pairs with `app/test/kernel/i18n/catalog_loader_test.dart`. No separate `unit/` vs `widget/` directories; test type is detected by what the test imports.
|
||||
- **Rationale:** Matching paths makes "jump to test" predictable in any editor. Type-by-imports matches how `flutter test` already works.
|
||||
- **Cost:** Large feature folders mirror into large test folders. Acceptable.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-029: Pre-push gate — fast layer only
|
||||
### D-29: Pre-push gate — fast layer only
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** `make push-check` runs analyze + format + unit + widget + golden + a11y, target < 90 s. Integration, E2E, and startup-bundle run in CI but not on pre-push.
|
||||
- **Rationale:** Pre-push gates that exceed ~90 s get disabled by muscle memory ("just push, it'll catch in CI"). Keeping the gate fast keeps it respected. Integration + E2E + bundle still gate merge via CI.
|
||||
- **Cost:** Some regressions land on `main` that CI catches. Rollback or hotfix — acceptable for a solo-or-small-team cadence.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-030: Tests are client-side only
|
||||
### D-30: Tests are client-side only
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** No test hits the network. No test depends on remote fixtures, shared DBs, or state outside the test process. Fakes and fixtures live in-tree.
|
||||
- **Rationale:** Network-dependent tests flake; flaky tests get quarantined; quarantined tests get deleted. Client-side-only makes CI deterministic offline.
|
||||
|
||||
@@ -4,32 +4,42 @@ Toolchain, supply chain, CI, ignore strategy.
|
||||
|
||||
---
|
||||
|
||||
### D-031: Prefer-zero-deps, exact-pin
|
||||
### D-31: Prefer-zero-deps, exact-pin
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** Default to writing code ourselves. Every third-party Dart dependency needs a paragraph of justification in the PR that adds it. What stays is exact-pinned in `pubspec.yaml` (no caret ranges), `pubspec.lock` is committed, and advisories are reviewed before every bump.
|
||||
- **Rationale:** Supply-chain gate. Flutter SDK + Dart SDK give us most of what we need; the dependencies we keep are the ones we can't reasonably write (yaml parser, mocktail, alchemist). Exact-pin because caret ranges mean "the CVE bumps itself in silently."
|
||||
- **Cost:** Longer PR descriptions for deps; occasional reinvention of a convenience. Accepted.
|
||||
- **Raised by:** 2026-04-21 planning; reinforced by user feedback memory.
|
||||
|
||||
### D-032: CI — Gitea primary, Linux-only runners, not yet activated
|
||||
### D-32: CI — Gitea primary, Linux-only runners, not yet activated
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** CI config lives at `.gitea/workflows/test.yml` (Gitea Actions consumes GitHub-Actions syntax). Runners are Linux only; macOS is tested locally. The workflow is ready but Gitea Actions is not yet activated on the instance — the file is a staged pipeline for review. If the repo moves to GitHub, the file copies to `.github/workflows/test.yml` verbatim.
|
||||
- **Rationale:** We want the CI story defined before we turn CI on — lower blast radius on early red builds. GitHub portability is free because the syntax is shared.
|
||||
- **Cost:** PRs don't run CI yet; `make push-check` is the gate until activation.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-042: Dependencies documented in `licenses.yaml`
|
||||
### D-42: Dependencies documented in `licenses.yaml`
|
||||
- **Date:** 2026-04-22
|
||||
- **Decision:** `app/assets/licenses.yaml` has three sections: `self:` (clide's MIT license, rendered first in the About screen so the user knows what they're running), `dependencies:` (third-party artefacts that **ship in the binary** — fonts, runtime Dart packages, native supporter tools, bundled data), and `dev_dependencies:` (build-time-only tooling — test runners, mocks, lints, golden harness — tracked for audit but **not rendered** in the About screen because they don't reach the user). Each entry has name, kind, version, homepage, license identifier, and a one-line purpose; runtime entries also carry a `license_file:` pointer to the bundled license text so the About screen can display it verbatim. Adding any dependency is a two-step commit: add the artefact **and** the corresponding `licenses.yaml` entry in the same changeset, under the correct section.
|
||||
- **Rationale:** Complements [D-031](#d-031-prefer-zero-deps-exact-pin). Prefer-zero-deps is a *budget*; `licenses.yaml` is the *visible consequence*. An extra row in the About screen is a review-time signal that the shipped-binary surface grew. Splitting dev deps out keeps the user-facing list small and honest — a test framework is not something the user needs to see in About — while still documenting every supply-chain input for audit completeness. The runtime entries discharge the redistribution obligations bundled licenses impose (OFL, MIT, BSD all require preserving the license text alongside the binary) without ad-hoc NOTICE files.
|
||||
- **Rationale:** Complements [D-31](#d-31-prefer-zero-deps-exact-pin). Prefer-zero-deps is a *budget*; `licenses.yaml` is the *visible consequence*. An extra row in the About screen is a review-time signal that the shipped-binary surface grew. Splitting dev deps out keeps the user-facing list small and honest — a test framework is not something the user needs to see in About — while still documenting every supply-chain input for audit completeness. The runtime entries discharge the redistribution obligations bundled licenses impose (OFL, MIT, BSD all require preserving the license text alongside the binary) without ad-hoc NOTICE files.
|
||||
- **Cost:** One extra edit per dep. Zero tolerance for drift — an un-listed dep is a contributor-visible bug. Until the About screen lands at Tier 6, `licenses.yaml` is accurate but not rendered; the discipline applies from now regardless so Tier 6 inherits a clean list.
|
||||
- **Raised by:** 2026-04-22 planning (user-directed best practice).
|
||||
|
||||
### D-033: Golden-output ignore pattern — `coverage.*` excludes output, not scripts
|
||||
### D-33: Golden-output ignore pattern — `coverage.*` excludes output, not scripts
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** `.gitignore` excludes `coverage.*` (the lcov output files from `flutter test --coverage`). Coverage-related scripts are named `ci/test_coverage.sh` (not `ci/coverage.sh`) to stay outside the pattern.
|
||||
- **Rationale:** An earlier draft named the script `ci/coverage.sh` and it was silently git-ignored. Renaming the script is cheaper than narrowing the gitignore pattern (which risks re-introducing output churn).
|
||||
- **Cost:** Script names have a convention to follow.
|
||||
- **Raised by:** 2026-04-21 planning (caught during commit rehearsal).
|
||||
|
||||
### D-58: Format engines are adoptable dependencies
|
||||
- **Date:** 2026-04-23
|
||||
- **Decision:** The "own the rendering stack" guardrail applies to **UI chrome** — panels, tabs, panes, canvas, terminal, layout primitives. **Format engines** — packages that parse or render external file formats (SVG, markdown, HTML, terminal escape sequences, tree-sitter grammars) — are adoptable like any other dependency: vet, exact-pin, CVE-lock, document in `licenses.yaml`. They are not shortcuts for lazy coding; they are well-maintained renderers for formats we didn't invent. The distinction: if it renders *our* UI, we own it; if it renders *someone else's file format*, we adopt a parser/renderer and sandbox it.
|
||||
- **Adopted under this rule:** `jovial_svg` (SVG renderer), `markdown` (MD parser; renderer is ours), `flutter_widget_from_html_core` (HTML renderer; sandboxed), `xterm` (terminal emulator), tree-sitter (syntax highlighting). Canvas (`CustomPaint` + `InteractiveViewer`) stays in-house — UI chrome, not a format engine.
|
||||
- **Amendment to D-31 (prefer-zero-deps):** D-31's "prefer-zero-deps" still applies — every new dependency needs justification. This record clarifies that format engines clear the justification bar by default. The supply-chain gate (exact-pin, advisory review, `licenses.yaml`) still applies.
|
||||
- **Rationale:** Reimplementing SVG, markdown, or VT100 parsing adds months of work for no fidelity gain. tree-sitter already set this precedent. The key is sandboxing: HTML rendering must whitelist tags/attributes; SVG must not execute scripts; markdown rendering goes through our own widget builder so we control the output.
|
||||
- **Cost:** Each adopted engine adds transitive dependencies and supply-chain surface. Mitigated by exact-pinning and `make security`.
|
||||
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml).
|
||||
- **Raised by:** 2026-04-23 format engine evaluation.
|
||||
|
||||
---
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform, Process, ProcessStartMode;
|
||||
|
||||
import 'package:clide/builtin/welcome/src/welcome_view.dart';
|
||||
import 'package:clide/extension/src/contribution.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@@ -51,6 +55,11 @@ class _RootShell extends StatefulWidget {
|
||||
|
||||
class _RootShellState extends State<_RootShell> {
|
||||
late final FocusNode _keyFocus;
|
||||
double _textScale = 1.0;
|
||||
|
||||
static const double _scaleStep = 0.05;
|
||||
static const double _scaleMin = 0.6;
|
||||
static const double _scaleMax = 2.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -71,32 +80,63 @@ class _RootShellState extends State<_RootShell> {
|
||||
style: TextStyle(
|
||||
color: tokens.globalForeground,
|
||||
fontSize: 15,
|
||||
height: clideLineHeight,
|
||||
fontWeight: clideUiDefaultWeight,
|
||||
fontFamily: clideUiFamily,
|
||||
fontFamilyFallback: clideUiFamilyFallback,
|
||||
),
|
||||
child: KeyboardListener(
|
||||
focusNode: _keyFocus,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: ColoredBox(
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(_textScale)),
|
||||
child: KeyboardListener(
|
||||
focusNode: _keyFocus,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: ColoredBox(
|
||||
color: tokens.globalBackground,
|
||||
child: DialogHost(
|
||||
router: widget.services.dialog,
|
||||
child: Stack(
|
||||
child: ClideResizeBorder(
|
||||
windowControls: widget.services.window,
|
||||
child: Column(
|
||||
children: [
|
||||
const Positioned.fill(child: RootLayout()),
|
||||
const ClidePalette(),
|
||||
const Positioned.fill(child: _WelcomeOverlay()),
|
||||
_HatBar(kernel: widget.services),
|
||||
Expanded(
|
||||
child: DialogHost(
|
||||
router: widget.services.dialog,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: RootLayout()),
|
||||
const ClidePalette(),
|
||||
const Positioned.fill(child: _WelcomeOverlay()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onKey(KeyEvent event) {
|
||||
if (event is KeyDownEvent || event is KeyRepeatEvent) {
|
||||
final ctrl = HardwareKeyboard.instance.isControlPressed;
|
||||
if (ctrl) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.equal || event.logicalKey == LogicalKeyboardKey.add) {
|
||||
setState(() => _textScale = (_textScale + _scaleStep).clamp(_scaleMin, _scaleMax));
|
||||
return;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.minus) {
|
||||
setState(() => _textScale = (_textScale - _scaleStep).clamp(_scaleMin, _scaleMax));
|
||||
return;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.digit0) {
|
||||
setState(() => _textScale = 1.0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
final binding = KeybindingResolver.fromKeyEvent(
|
||||
event,
|
||||
HardwareKeyboard.instance,
|
||||
@@ -123,8 +163,8 @@ class RootLayout extends StatelessWidget {
|
||||
final contextVisible = a.isVisible(Slots.contextPanel);
|
||||
final contextCollapsed = a.isCollapsed(Slots.contextPanel);
|
||||
final statusVisible = a.isVisible(Slots.statusbar);
|
||||
final sidebarSize = a.sizeOf(Slots.sidebar) ?? 240;
|
||||
final contextSize = a.sizeOf(Slots.contextPanel) ?? 280;
|
||||
final sidebarSize = a.sizeOf(Slots.sidebar) ?? 400;
|
||||
final contextSize = a.sizeOf(Slots.contextPanel) ?? 420;
|
||||
final statusHeight = a.sizeOf(Slots.statusbar) ?? 26;
|
||||
|
||||
return Column(
|
||||
@@ -173,7 +213,7 @@ class RootLayout extends StatelessWidget {
|
||||
if (statusVisible)
|
||||
Container(
|
||||
height: statusHeight,
|
||||
decoration: BoxDecoration(border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.dividerColor))),
|
||||
decoration: BoxDecoration(border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder))),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -206,6 +246,441 @@ class RootLayout extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _HatBar extends StatelessWidget {
|
||||
const _HatBar({required this.kernel});
|
||||
final KernelServices kernel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return GestureDetector(
|
||||
onPanStart: (_) => kernel.window.startDrag(),
|
||||
child: Container(
|
||||
height: hatHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.chromeBackground,
|
||||
border: Border(bottom: BorderSide(color: tokens.chromeBorder, width: 1)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
_LeftHatContent(tokens: tokens, wc: kernel.window),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: _ProjectSwitcherButton(kernel: kernel, tokens: tokens),
|
||||
),
|
||||
),
|
||||
_RightHatContent(tokens: tokens, wc: kernel.window),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LeftHatContent extends StatelessWidget {
|
||||
const _LeftHatContent({required this.tokens, required this.wc});
|
||||
final SurfaceTokens tokens;
|
||||
final WindowControls wc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (kIsWeb) return const SizedBox.shrink();
|
||||
if (!kIsWeb && Platform.isMacOS) {
|
||||
return Row(children: [
|
||||
_TrafficDot(color: const Color(0xFFFF5F57), onTap: wc.close),
|
||||
const SizedBox(width: 6),
|
||||
_TrafficDot(color: const Color(0xFFFEBC2E), onTap: wc.minimize),
|
||||
const SizedBox(width: 6),
|
||||
_TrafficDot(color: const Color(0xFF28C840), onTap: wc.toggleMaximize),
|
||||
]);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
class _RightHatContent extends StatelessWidget {
|
||||
const _RightHatContent({required this.tokens, required this.wc});
|
||||
final SurfaceTokens tokens;
|
||||
final WindowControls wc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (kIsWeb) return const SizedBox.shrink();
|
||||
if (!kIsWeb && Platform.isMacOS) return const SizedBox.shrink();
|
||||
return Row(children: [
|
||||
_WinBtn(icon: const PhosphorIconPainter(0xe32a), onTap: wc.minimize, tokens: tokens),
|
||||
_WinBtn(icon: const PhosphorIconPainter(0xe45e), onTap: wc.toggleMaximize, tokens: tokens),
|
||||
_WinBtn(icon: PhosphorIcons.xMark, onTap: wc.close, tokens: tokens, isClose: true),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrafficDot extends StatelessWidget {
|
||||
const _TrafficDot({required this.color, required this.onTap});
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
width: 12, height: 12,
|
||||
decoration: BoxDecoration(color: hovered ? color : color.withAlpha(0xCC), shape: BoxShape.circle),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WinBtn extends StatelessWidget {
|
||||
const _WinBtn({required this.icon, required this.onTap, required this.tokens, this.isClose = false});
|
||||
final ClideIconPainter icon;
|
||||
final VoidCallback onTap;
|
||||
final SurfaceTokens tokens;
|
||||
final bool isClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hoverBg = isClose ? const Color(0xFFE81123) : tokens.listItemHoverBackground;
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
width: 36, height: hatHeight,
|
||||
color: hovered ? hoverBg : null,
|
||||
alignment: Alignment.center,
|
||||
child: ClideIcon(icon, size: 14, color: hovered && isClose ? const Color(0xFFFFFFFF) : tokens.chromeForeground),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProjectSwitcherButton extends StatelessWidget {
|
||||
const _ProjectSwitcherButton({required this.kernel, required this.tokens});
|
||||
final KernelServices kernel;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
void _openSwitcher() {
|
||||
kernel.dialog.show<String>((ctx, dismiss) {
|
||||
return _ProjectSwitcherDropdown(kernel: kernel, onDismiss: dismiss);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.project,
|
||||
builder: (ctx, _) {
|
||||
final name = kernel.project.current?.path.split('/').last;
|
||||
final label = name != null ? 'clide > $name' : 'clide';
|
||||
return ClideTappable(
|
||||
onTap: _openSwitcher,
|
||||
builder: (context, hovered, _) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(label, fontSize: 12, color: hovered ? tokens.globalForeground : tokens.chromeForeground, fontFamily: clideMonoFamily),
|
||||
const SizedBox(width: 4),
|
||||
ClideIcon(PhosphorIcons.caretDown, size: 8, color: tokens.chromeForeground),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProjectSwitcherDropdown extends StatefulWidget {
|
||||
const _ProjectSwitcherDropdown({required this.kernel, required this.onDismiss});
|
||||
final KernelServices kernel;
|
||||
final void Function([String?]) onDismiss;
|
||||
|
||||
@override
|
||||
State<_ProjectSwitcherDropdown> createState() => _ProjectSwitcherDropdownState();
|
||||
}
|
||||
|
||||
class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> {
|
||||
String _filter = '';
|
||||
late final FocusNode _focus;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focus = FocusNode()..requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _openProject(String path) async {
|
||||
final ok = await widget.kernel.project.open(path);
|
||||
if (ok) {
|
||||
widget.kernel.panels.activateTab(Slots.workspace, 'claude.primary');
|
||||
widget.onDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
void _closeWorkspace() {
|
||||
widget.kernel.project.close();
|
||||
widget.onDismiss();
|
||||
}
|
||||
|
||||
void _newWindow() {
|
||||
Process.start(Platform.resolvedExecutable, [], mode: ProcessStartMode.detached);
|
||||
widget.onDismiss();
|
||||
}
|
||||
|
||||
void _openFolder() {
|
||||
widget.onDismiss();
|
||||
widget.kernel.dialog.show<String>((ctx, dismiss) {
|
||||
return _OpenFolderDialog(
|
||||
onOpen: (path) async {
|
||||
final ok = await widget.kernel.project.open(path);
|
||||
if (ok) {
|
||||
widget.kernel.panels.activateTab(Slots.workspace, 'claude.primary');
|
||||
dismiss(path);
|
||||
}
|
||||
},
|
||||
onCancel: () => dismiss(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
widget.onDismiss();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final recents = widget.kernel.project.recents;
|
||||
final lf = _filter.toLowerCase();
|
||||
final filtered = lf.isEmpty ? recents : recents.where((r) => r.name.toLowerCase().contains(lf) || r.path.toLowerCase().contains(lf)).toList();
|
||||
|
||||
return Focus(
|
||||
focusNode: _focus,
|
||||
onKeyEvent: _onKey,
|
||||
child: Container(
|
||||
width: 380,
|
||||
constraints: const BoxConstraints(maxHeight: 420),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.dropdownBackground,
|
||||
border: Border.all(color: tokens.dropdownBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideFilterBox(hint: 'Search projects…', onChanged: (v) => setState(() => _filter = v)),
|
||||
if (filtered.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ClideText('Recent Projects', fontSize: clideFontCaption, color: tokens.globalTextMuted),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (ctx, i) => _RecentProjectRow(
|
||||
project: filtered[i],
|
||||
tokens: tokens,
|
||||
onTap: () => _openProject(filtered[i].path),
|
||||
),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('No recent projects.', muted: true)),
|
||||
Container(
|
||||
decoration: BoxDecoration(border: Border(top: BorderSide(color: tokens.dividerColor))),
|
||||
child: Column(
|
||||
children: [
|
||||
_ActionRow(label: 'Open Local Project', shortcut: 'Ctrl+O', tokens: tokens, onTap: _openFolder),
|
||||
_ActionRow(label: 'New Window', shortcut: 'Ctrl+Shift+N', tokens: tokens, onTap: _newWindow),
|
||||
if (widget.kernel.project.isOpen)
|
||||
_ActionRow(label: 'Close Workspace', shortcut: '', tokens: tokens, onTap: _closeWorkspace),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentProjectRow extends StatelessWidget {
|
||||
const _RecentProjectRow({required this.project, required this.tokens, required this.onTap});
|
||||
final RecentProject project;
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideIcon(PhosphorIcons.folder, size: 14, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(project.name, fontSize: 14),
|
||||
if (project.branch != null)
|
||||
Row(
|
||||
children: [
|
||||
ClideText(project.relativePath, muted: true, fontSize: 12, fontFamily: clideMonoFamily),
|
||||
ClideText(' · ', muted: true, fontSize: 12),
|
||||
ClideIcon(PhosphorIcons.gitBranch, size: 10, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 3),
|
||||
ClideText(project.branch!, muted: true, fontSize: 12, fontFamily: clideMonoFamily),
|
||||
],
|
||||
)
|
||||
else
|
||||
ClideText(project.relativePath, muted: true, fontSize: 12, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
),
|
||||
ClideText(project.timeAgo, muted: true, fontSize: 11),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionRow extends StatelessWidget {
|
||||
const _ActionRow({required this.label, this.shortcut, required this.tokens, required this.onTap});
|
||||
final String label;
|
||||
final String? shortcut;
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: ClideText(label, fontSize: 14)),
|
||||
if (shortcut != null && shortcut!.isNotEmpty)
|
||||
ClideText(shortcut!, fontSize: 12, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OpenFolderDialog extends StatefulWidget {
|
||||
const _OpenFolderDialog({required this.onOpen, required this.onCancel});
|
||||
final Future<void> Function(String path) onOpen;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
@override
|
||||
State<_OpenFolderDialog> createState() => _OpenFolderDialogState();
|
||||
}
|
||||
|
||||
class _OpenFolderDialogState extends State<_OpenFolderDialog> {
|
||||
final _controller = TextEditingController();
|
||||
final _focus = FocusNode();
|
||||
String? _error;
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focus.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final path = _controller.text.trim();
|
||||
if (path.isEmpty) return;
|
||||
setState(() { _loading = true; _error = null; });
|
||||
try {
|
||||
await widget.onOpen(path);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _error = 'Not a git repository');
|
||||
}
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
width: 420,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.modalSurfaceBackground,
|
||||
border: Border.all(color: tokens.modalSurfaceBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ClideText('Open project', fontSize: 16, fontWeight: FontWeight.w600),
|
||||
const SizedBox(height: 4),
|
||||
const ClideText('Enter the path to a git repository.', muted: true, fontSize: 13),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border.all(color: tokens.globalBorder),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: EditableText(
|
||||
controller: _controller,
|
||||
focusNode: _focus,
|
||||
style: TextStyle(color: tokens.globalForeground, fontSize: 14, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback),
|
||||
cursorColor: tokens.globalForeground,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
onSubmitted: (_) => unawaited(_submit()),
|
||||
),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
ClideText(_error!, color: tokens.statusError, fontSize: 12),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ClideButton(label: 'Cancel', onPressed: widget.onCancel),
|
||||
const SizedBox(width: 8),
|
||||
ClideButton(label: _loading ? 'Opening…' : 'Open', onPressed: _loading ? null : _submit),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SlotHost extends StatelessWidget {
|
||||
const SlotHost({super.key, required this.slot});
|
||||
final SlotId slot;
|
||||
@@ -298,8 +773,9 @@ class _SidebarSlot extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
color: tokens.sidebarBackground,
|
||||
color: tokens.chromeBackground,
|
||||
alignment: Alignment.topLeft,
|
||||
padding: const EdgeInsets.fromLTRB(2, 2, 0, 0),
|
||||
child: active.build(context),
|
||||
);
|
||||
}
|
||||
@@ -416,6 +892,7 @@ class _ContextSlot extends StatelessWidget {
|
||||
return Container(
|
||||
color: tokens.panelBackground,
|
||||
alignment: Alignment.topLeft,
|
||||
padding: const EdgeInsets.fromLTRB(0, 2, 2, 0),
|
||||
child: active.build(context),
|
||||
);
|
||||
}
|
||||
@@ -433,10 +910,10 @@ class _BottomRail extends StatelessWidget {
|
||||
listenable: kernel.panels,
|
||||
builder: (ctx, _) {
|
||||
final tabs = kernel.panels.tabsFor(slot);
|
||||
if (tabs.isEmpty) return Container(color: tokens.statusBarBackground);
|
||||
if (tabs.isEmpty) return Container(color: tokens.chromeBackground);
|
||||
final activeId = kernel.panels.activeTabIn(slot) ?? tabs.first.id;
|
||||
return Container(
|
||||
color: tokens.statusBarBackground,
|
||||
color: tokens.chromeBackground,
|
||||
child: ClideIconRail(
|
||||
items: [
|
||||
for (final t in tabs)
|
||||
@@ -490,7 +967,7 @@ class StatusbarHost extends StatelessWidget {
|
||||
final left = items.where((i) => i.priority < 100).toList();
|
||||
final right = items.where((i) => i.priority >= 100).toList();
|
||||
return Container(
|
||||
color: tokens.statusBarBackground,
|
||||
color: tokens.chromeBackground,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
|
||||
@@ -120,7 +120,7 @@ class _TabRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _Tab extends StatefulWidget {
|
||||
class _Tab extends StatelessWidget {
|
||||
const _Tab({required this.session, required this.active, required this.tokens, required this.onTap, this.onClose});
|
||||
final _Session session;
|
||||
final bool active;
|
||||
@@ -128,75 +128,52 @@ class _Tab extends StatefulWidget {
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onClose;
|
||||
|
||||
@override
|
||||
State<_Tab> createState() => _TabState();
|
||||
}
|
||||
|
||||
class _TabState extends State<_Tab> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered && !widget.active ? widget.tokens.tabInactive : null,
|
||||
border: Border(bottom: BorderSide(color: widget.active ? widget.tokens.tabActiveBorder : const Color(0x00000000), width: 2)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(
|
||||
widget.session.label,
|
||||
fontSize: 12,
|
||||
color: widget.active ? widget.tokens.tabActiveForeground : widget.tokens.tabInactiveForeground,
|
||||
fontFamily: clideMonoFamily,
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: hovered && !active ? tokens.tabInactive : null,
|
||||
border: Border(bottom: BorderSide(color: active ? tokens.tabActiveBorder : const Color(0x00000000), width: 2)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(
|
||||
session.label,
|
||||
fontSize: 12,
|
||||
color: active ? tokens.tabActiveForeground : tokens.tabInactiveForeground,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
if (onClose != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
GestureDetector(
|
||||
onTap: onClose,
|
||||
child: ClideIcon(PhosphorIcons.xMark, size: 10, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
if (widget.onClose != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
GestureDetector(
|
||||
onTap: widget.onClose,
|
||||
child: ClideIcon(PhosphorIcons.xMark, size: 10, color: _hovered ? widget.tokens.globalForeground : widget.tokens.globalTextMuted),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddButton extends StatefulWidget {
|
||||
class _AddButton extends StatelessWidget {
|
||||
const _AddButton({required this.tokens, required this.onTap});
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_AddButton> createState() => _AddButtonState();
|
||||
}
|
||||
|
||||
class _AddButtonState extends State<_AddButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: ClideText('+', fontSize: 14, color: _hovered ? widget.tokens.globalForeground : widget.tokens.globalTextMuted),
|
||||
),
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
tooltip: 'New session',
|
||||
builder: (context, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: ClideText('+', fontSize: 14, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
class DecisionTypeColors {
|
||||
const DecisionTypeColors({
|
||||
required this.confirmed,
|
||||
required this.question,
|
||||
required this.rejected,
|
||||
});
|
||||
|
||||
final Color confirmed;
|
||||
final Color question;
|
||||
final Color rejected;
|
||||
|
||||
Color forType(String? type) => switch (type) {
|
||||
'confirmed' => confirmed,
|
||||
'question' => question,
|
||||
'rejected' => rejected,
|
||||
_ => confirmed,
|
||||
};
|
||||
|
||||
static const dark = DecisionTypeColors(
|
||||
confirmed: Color(0xFF7DD3A8),
|
||||
question: Color(0xFFE6C370),
|
||||
rejected: Color(0xFFE87D7D),
|
||||
);
|
||||
|
||||
static const light = DecisionTypeColors(
|
||||
confirmed: Color(0xFF1D7A4E),
|
||||
question: Color(0xFFB08A20),
|
||||
rejected: Color(0xFFC03030),
|
||||
);
|
||||
|
||||
static DecisionTypeColors forTheme({required bool dark}) =>
|
||||
dark ? DecisionTypeColors.dark : DecisionTypeColors.light;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/decisions/src/decision_colors.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class DecisionDetailView extends StatefulWidget {
|
||||
const DecisionDetailView({super.key, this.initialId});
|
||||
final String? initialId;
|
||||
|
||||
@override
|
||||
State<DecisionDetailView> createState() => _DecisionDetailViewState();
|
||||
}
|
||||
|
||||
class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
Map<String, Object?>? _decision;
|
||||
bool _loading = false;
|
||||
StreamSubscription<Message>? _sub;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_sub != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_sub = kernel.messages.subscribe(publisher: 'builtin.decisions', channel: 'selection').listen((msg) {
|
||||
final id = msg.data['id'] as String?;
|
||||
if (id != null) unawaited(_load(id));
|
||||
});
|
||||
if (widget.initialId != null) {
|
||||
unawaited(_load(widget.initialId!));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load(String id) async {
|
||||
setState(() => _loading = true);
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request('pql.decisions.read', args: {'id': id});
|
||||
if (!mounted) return;
|
||||
if (resp.ok) {
|
||||
kernel.messages.publish('builtin.decisions', 'focus', {'id': id});
|
||||
}
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_decision = resp.ok ? resp.data : null;
|
||||
});
|
||||
}
|
||||
|
||||
void _navigateToRecord(BuildContext context, String id) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
if (id.startsWith('T-')) {
|
||||
kernel.messages.publish('builtin.tickets', 'selection', {'id': id});
|
||||
} else {
|
||||
kernel.messages.publish('builtin.decisions', 'selection', {'id': id});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading) return const Center(child: ClideText('Loading…', muted: true));
|
||||
final d = _decision;
|
||||
if (d == null) return const Padding(padding: EdgeInsets.all(12), child: ClideText('Select a decision to view details.', muted: true));
|
||||
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final isDark = ClideTheme.of(context).dark;
|
||||
final typeColors = DecisionTypeColors.forTheme(dark: isDark);
|
||||
|
||||
final id = d['id'] as String? ?? '';
|
||||
final title = d['title'] as String? ?? '';
|
||||
final type = d['type'] as String?;
|
||||
final domain = d['domain'] as String?;
|
||||
final status = d['status'] as String?;
|
||||
final date = d['date'] as String?;
|
||||
final body = d['body'] as String?;
|
||||
final refs = (d['refs'] as List?)?.cast<Map<String, Object?>>() ?? const [];
|
||||
final typeColor = typeColors.forType(type);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: tokens.panelBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClideTooltip(
|
||||
message: type ?? 'confirmed',
|
||||
child: Container(width: 10, height: 10, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ClideText(id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily),
|
||||
const Spacer(),
|
||||
if (domain != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: tokens.panelBorder, borderRadius: BorderRadius.circular(3)),
|
||||
child: ClideText(domain, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClideText(title, fontSize: 15, fontWeight: FontWeight.w500),
|
||||
if (date != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
ClideText(date, muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily),
|
||||
],
|
||||
if (status != null && status != 'active') ...[
|
||||
const SizedBox(height: 8),
|
||||
_StatusBadge(status: status, tokens: tokens),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (body != null && body.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id)),
|
||||
],
|
||||
if (refs.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
ClideText('CROSS-REFERENCES', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
const SizedBox(height: 6),
|
||||
for (final ref in refs) _RefCard(ref: ref, tokens: tokens),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RefCard extends StatelessWidget {
|
||||
const _RefCard({required this.ref, required this.tokens});
|
||||
final Map<String, Object?> ref;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final targetId = (ref['target_id'] ?? ref['source_id']) as String? ?? '';
|
||||
final refType = ref['ref_type'] as String? ?? '';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: ClideTappable(
|
||||
onTap: () => ClideKernel.of(context).messages.publish('builtin.decisions', 'selection', {'id': targetId}),
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.sidebarItemHover : tokens.panelBackground,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: hovered ? tokens.panelActiveBorder : tokens.panelBorder),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(targetId, fontSize: clideFontSmall, color: tokens.globalFocus, fontFamily: clideMonoFamily),
|
||||
const SizedBox(width: 8),
|
||||
ClideText(refType, fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.status, required this.tokens});
|
||||
final String status;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = switch (status) {
|
||||
'open' => tokens.statusWarning,
|
||||
'resolved' => tokens.statusSuccess,
|
||||
_ => tokens.globalTextMuted,
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: color.withAlpha(0x30), borderRadius: BorderRadius.circular(3)),
|
||||
child: ClideText(status.toUpperCase(), fontSize: clideFontBadge, color: color, fontFamily: clideMonoFamily),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:clide/builtin/decisions/src/decision_colors.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -16,19 +16,27 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
List<_DecisionEntry> _decisions = [];
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
String _filter = '';
|
||||
String? _focusedId;
|
||||
final _focusedKey = GlobalKey();
|
||||
final Set<String> _expanded = {'confirmed'};
|
||||
StreamSubscription<Message>? _focusSub;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_focusSub == null) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
_focusSub = kernel.messages.subscribe(publisher: 'builtin.decisions', channel: 'focus').listen(_onFocus);
|
||||
}
|
||||
if (!_loading || _decisions.isNotEmpty) return;
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request('pql.exec', args: {
|
||||
'argv': ['decisions', 'list', '--type', 'confirmed'],
|
||||
});
|
||||
await kernel.ipc.request('pql.decisions.sync');
|
||||
final resp = await kernel.ipc.request('pql.decisions.list');
|
||||
if (!mounted) return;
|
||||
if (!resp.ok) {
|
||||
setState(() {
|
||||
@@ -37,90 +45,213 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
});
|
||||
return;
|
||||
}
|
||||
final raw = resp.data['stdout'] as String? ?? '[]';
|
||||
try {
|
||||
final list = (jsonDecode(raw) as List).cast<Map<String, dynamic>>();
|
||||
final raw = resp.data['decisions'];
|
||||
if (raw is List) {
|
||||
setState(() {
|
||||
_decisions = list.map(_DecisionEntry.fromJson).toList();
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'parse error: $e';
|
||||
_decisions = [for (final e in raw) _DecisionEntry.fromJson((e as Map).cast<String, dynamic>())];
|
||||
_loading = false;
|
||||
});
|
||||
} else {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocus(Message msg) {
|
||||
final id = msg.data['id'] as String?;
|
||||
if (id == null || id == _focusedId) return;
|
||||
final entry = _decisions.where((d) => d.id == id).firstOrNull;
|
||||
final section = entry?.type ?? 'confirmed';
|
||||
setState(() {
|
||||
_focusedId = id;
|
||||
_expanded.add(section);
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final ctx = _focusedKey.currentContext;
|
||||
if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3);
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleSection(String key) {
|
||||
setState(() {
|
||||
if (_expanded.contains(key)) {
|
||||
_expanded.remove(key);
|
||||
} else {
|
||||
_expanded.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (_loading) {
|
||||
return const Center(child: ClideText('Loading decisions...', muted: true));
|
||||
}
|
||||
if (_error != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(_error!, muted: true),
|
||||
);
|
||||
}
|
||||
if (_decisions.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('No decisions found.\nRun `pql decisions sync` to index.', muted: true),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: _decisions.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final d = _decisions[i];
|
||||
return _DecisionRow(entry: d, tokens: tokens);
|
||||
},
|
||||
if (_loading) return const Center(child: ClideText('Loading decisions...', muted: true));
|
||||
if (_error != null) return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
|
||||
if (_decisions.isEmpty) return const Padding(padding: EdgeInsets.all(12), child: ClideText('No decisions found.\nRun `pql decisions sync` to index.', muted: true));
|
||||
|
||||
final lf = _filter.toLowerCase();
|
||||
final hasFilter = lf.isNotEmpty;
|
||||
final filtered = hasFilter ? _decisions.where((d) => d.id.toLowerCase().contains(lf) || d.title.toLowerCase().contains(lf) || (d.domain ?? '').toLowerCase().contains(lf) || (d.type ?? '').contains(lf)).toList() : _decisions;
|
||||
|
||||
final confirmed = filtered.where((d) => d.type == 'confirmed').toList();
|
||||
final questions = filtered.where((d) => d.type == 'question').toList();
|
||||
final rejected = filtered.where((d) => d.type == 'rejected').toList();
|
||||
|
||||
final isDark = ClideTheme.of(context).dark;
|
||||
final typeColors = DecisionTypeColors.forTheme(dark: isDark);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ClideFilterBox(hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v)),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (confirmed.isNotEmpty) _AccordionSection(
|
||||
label: 'CONFIRMED', count: confirmed.length, tokens: tokens,
|
||||
color: typeColors.confirmed,
|
||||
expanded: hasFilter || _expanded.contains('confirmed'),
|
||||
onToggle: () => _toggleSection('confirmed'),
|
||||
children: [for (final d in confirmed) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)],
|
||||
),
|
||||
if (questions.isNotEmpty) _AccordionSection(
|
||||
label: 'QUESTIONS', count: questions.length, tokens: tokens,
|
||||
color: typeColors.question,
|
||||
expanded: hasFilter || _expanded.contains('question'),
|
||||
onToggle: () => _toggleSection('question'),
|
||||
children: [for (final d in questions) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)],
|
||||
),
|
||||
if (rejected.isNotEmpty) _AccordionSection(
|
||||
label: 'REJECTED', count: rejected.length, tokens: tokens,
|
||||
color: typeColors.rejected,
|
||||
expanded: hasFilter || _expanded.contains('rejected'),
|
||||
onToggle: () => _toggleSection('rejected'),
|
||||
children: [for (final d in rejected) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DecisionEntry {
|
||||
const _DecisionEntry({required this.id, required this.title, this.domain, this.status});
|
||||
const _DecisionEntry({required this.id, required this.title, this.type, this.domain, this.status});
|
||||
final String id;
|
||||
final String title;
|
||||
final String? type;
|
||||
final String? domain;
|
||||
final String? status;
|
||||
|
||||
factory _DecisionEntry.fromJson(Map<String, dynamic> json) => _DecisionEntry(
|
||||
id: json['id'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
type: json['type'] as String?,
|
||||
domain: json['domain'] as String?,
|
||||
status: json['status'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class _DecisionRow extends StatefulWidget {
|
||||
const _DecisionRow({required this.entry, required this.tokens});
|
||||
final _DecisionEntry entry;
|
||||
class _AccordionSection extends StatelessWidget {
|
||||
const _AccordionSection({
|
||||
required this.label, required this.count, required this.tokens,
|
||||
required this.color, required this.expanded, required this.onToggle,
|
||||
required this.children,
|
||||
});
|
||||
final String label;
|
||||
final int count;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
State<_DecisionRow> createState() => _DecisionRowState();
|
||||
}
|
||||
|
||||
class _DecisionRowState extends State<_DecisionRow> {
|
||||
bool _hovered = false;
|
||||
final Color color;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggle;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Container(
|
||||
color: _hovered ? widget.tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(widget.entry.id, color: widget.tokens.globalTextMuted, fontSize: 12),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: ClideText(widget.entry.title, fontSize: 13)),
|
||||
],
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideTappable(
|
||||
onTap: onToggle,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.only(left: 4, top: 10, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideIcon(expanded ? PhosphorIcons.caretDown : PhosphorIcons.caretRight, size: 10, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 6),
|
||||
Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
ClideText('$label · $count', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (expanded) ...children,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DecisionCard extends StatelessWidget {
|
||||
const _DecisionCard({required this.entry, required this.tokens, required this.typeColors, this.focused = false, this.focusKey});
|
||||
final _DecisionEntry entry;
|
||||
final SurfaceTokens tokens;
|
||||
final DecisionTypeColors typeColors;
|
||||
final bool focused;
|
||||
final GlobalKey? focusKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final typeColor = typeColors.forType(entry.type);
|
||||
return Padding(
|
||||
key: focusKey,
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: ClideTappable(
|
||||
onTap: () => ClideKernel.of(context).messages.publish('builtin.decisions', 'selection', {'id': entry.id}),
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.sidebarItemHover : (focused ? tokens.sidebarItemSelected : tokens.panelBackground),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: focused ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.panelBorder), width: 1),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClideTooltip(
|
||||
message: entry.type ?? 'confirmed',
|
||||
child: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
const Spacer(),
|
||||
if (entry.domain != null)
|
||||
ClideText(entry.domain!, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClideText(entry.title, fontSize: clideFontCaption),
|
||||
if (entry.status == 'resolved') ...[
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: tokens.statusSuccess.withAlpha(0x30), borderRadius: BorderRadius.circular(3)),
|
||||
child: ClideText('resolved', fontSize: clideFontBadge, color: tokens.statusSuccess, fontFamily: clideMonoFamily),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/decisions/src/decision_detail_view.dart';
|
||||
import 'package:clide/builtin/decisions/src/decisions_view.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
|
||||
class DecisionsExtension extends ClideExtension {
|
||||
@override
|
||||
@@ -8,10 +13,32 @@ class DecisionsExtension extends ClideExtension {
|
||||
@override
|
||||
String get title => 'Decisions';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
String get version => '0.6.0';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
StreamSubscription<Message>? _sub;
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
_sub = ctx.messages.subscribe(publisher: id, channel: 'selection').listen((msg) {
|
||||
final selectedId = msg.data['id'] as String?;
|
||||
if (selectedId == null) return;
|
||||
ctx.panels.uncontribute('decisions.detail');
|
||||
ctx.panels.contribute(TabContribution(
|
||||
id: 'decisions.detail',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Decision',
|
||||
icon: PhosphorIcons.lightbulb,
|
||||
build: (_) => DecisionDetailView(initialId: selectedId),
|
||||
));
|
||||
ctx.panels.activateTab(Slots.contextPanel, 'decisions.detail');
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deactivate() async => _sub?.cancel();
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
@@ -20,8 +47,15 @@ class DecisionsExtension extends ClideExtension {
|
||||
title: 'Decisions',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -20,
|
||||
icon: PhosphorIcons.lightbulb,
|
||||
build: (_) => const DecisionsView(),
|
||||
),
|
||||
TabContribution(
|
||||
id: 'decisions.detail',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Decision',
|
||||
icon: PhosphorIcons.lightbulb,
|
||||
build: (_) => const DecisionDetailView(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -120,6 +120,14 @@ class DefaultLayoutExtension extends ClideExtension {
|
||||
|
||||
void _restoreLayout(ClideExtensionContext ctx) {
|
||||
final s = ctx.settings;
|
||||
final sidebarOrder = s.get<List>('project.layout.sidebar.order');
|
||||
if (sidebarOrder != null) {
|
||||
ctx.panels.setTabOrder(Slots.sidebar, sidebarOrder.cast<String>());
|
||||
}
|
||||
final contextOrder = s.get<List>('project.layout.context.order');
|
||||
if (contextOrder != null) {
|
||||
ctx.panels.setTabOrder(Slots.contextPanel, contextOrder.cast<String>());
|
||||
}
|
||||
final sidebarCollapsed = s.get<bool>(_kSidebarCollapsed);
|
||||
if (sidebarCollapsed != null) {
|
||||
ctx.arrangement.setCollapsed(Slots.sidebar, sidebarCollapsed);
|
||||
|
||||
@@ -15,7 +15,7 @@ class DiffController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
final DaemonClient ipc;
|
||||
final EventBus events;
|
||||
final DaemonBus events;
|
||||
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
|
||||
|
||||
@@ -17,14 +17,14 @@ import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class EditorController extends ChangeNotifier {
|
||||
EditorController({required this.ipc, required EventBus events})
|
||||
EditorController({required this.ipc, required DaemonBus events})
|
||||
: _events = events {
|
||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||
}
|
||||
|
||||
final DaemonClient ipc;
|
||||
// ignore: unused_field — kept for future subscription changes
|
||||
final EventBus _events;
|
||||
final DaemonBus _events;
|
||||
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:clide/builtin/files/src/file_tree_view.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
|
||||
/// Workspace filesystem panel. Contributes a sidebar tab that renders
|
||||
/// the workspace file tree rooted at the git root, powered by the
|
||||
@@ -22,6 +23,7 @@ class FilesExtension extends ClideExtension {
|
||||
id: 'files.tree',
|
||||
slot: Slots.sidebar,
|
||||
title: 'Files',
|
||||
icon: PhosphorIcons.folder,
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -100,
|
||||
|
||||
@@ -20,7 +20,7 @@ class FileTreeController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
final DaemonClient ipc;
|
||||
final EventBus events;
|
||||
final DaemonBus events;
|
||||
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
|
||||
@@ -38,6 +38,14 @@ class FileTreeController extends ChangeNotifier {
|
||||
final Map<String, List<FileEntry>> _entries = {};
|
||||
List<FileEntry>? entriesFor(String path) => _entries[path];
|
||||
|
||||
List<FileEntry> allLoadedEntries() {
|
||||
final out = <FileEntry>[];
|
||||
for (final list in _entries.values) {
|
||||
out.addAll(list.where((e) => !e.isDirectory));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Initial boot: resolve the workspace root, load the root dir,
|
||||
/// subscribe to `files.changed` events.
|
||||
Future<void> load() async {
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:clide/src/files/listing.dart' show FileEntry;
|
||||
|
||||
import 'file_tree_controller.dart';
|
||||
|
||||
/// Sidebar panel rendering the workspace file tree.
|
||||
@@ -24,6 +26,7 @@ class FileTreeView extends StatefulWidget {
|
||||
|
||||
class _FileTreeViewState extends State<FileTreeView> {
|
||||
FileTreeController? _controller;
|
||||
String _filter = '';
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
@@ -61,31 +64,45 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
);
|
||||
}
|
||||
final rootName = root.split(Platform.pathSeparator).last;
|
||||
return Semantics(
|
||||
label: 'file tree — $rootName',
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_DirRow(
|
||||
name: rootName,
|
||||
path: '',
|
||||
controller: c,
|
||||
depth: 0,
|
||||
return Column(
|
||||
children: [
|
||||
ClideFilterBox(hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)),
|
||||
Expanded(
|
||||
child: Semantics(
|
||||
label: 'file tree — $rootName',
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_filter.isEmpty) ...[
|
||||
_DirRow(name: rootName, path: '', controller: c, depth: 0),
|
||||
if (c.isExpanded('')) _Children(path: '', controller: c, depth: 1),
|
||||
] else
|
||||
..._filteredEntries(c),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (c.isExpanded(''))
|
||||
_Children(path: '', controller: c, depth: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _filteredEntries(FileTreeController c) {
|
||||
final lowerFilter = _filter.toLowerCase();
|
||||
final matches = c.allLoadedEntries().where((e) {
|
||||
return e.path.toLowerCase().contains(lowerFilter) || e.name.toLowerCase().contains(lowerFilter);
|
||||
}).toList();
|
||||
return [
|
||||
for (final e in matches) _FilteredFileRow(entry: e),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class _Children extends StatelessWidget {
|
||||
@@ -208,7 +225,7 @@ class _FileRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _Row extends StatefulWidget {
|
||||
class _Row extends StatelessWidget {
|
||||
const _Row({
|
||||
required this.depth,
|
||||
required this.onTap,
|
||||
@@ -223,49 +240,58 @@ class _Row extends StatefulWidget {
|
||||
final Widget? leading;
|
||||
final bool rotateLeading;
|
||||
|
||||
@override
|
||||
State<_Row> createState() => _RowState();
|
||||
}
|
||||
|
||||
class _RowState extends State<_Row> {
|
||||
bool _hover = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final leftPadding = 8.0 + (widget.depth * 14.0);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
color: _hover ? tokens.sidebarItemHover : null,
|
||||
padding: EdgeInsets.only(left: leftPadding, right: 8, top: 3, bottom: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.leading != null) ...[
|
||||
Transform.rotate(
|
||||
angle: widget.rotateLeading ? 1.5708 : 0, // 90° when expanded
|
||||
child: widget.leading,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
] else
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
widget.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
final leftPadding = 8.0 + (depth * 14.0);
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.sidebarItemHover : null,
|
||||
padding: EdgeInsets.only(left: leftPadding, right: 8, top: 3, bottom: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) ...[
|
||||
Transform.rotate(
|
||||
angle: rotateLeading ? 1.5708 : 0, // 90° when expanded
|
||||
child: leading,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
] else
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilteredFileRow extends StatelessWidget {
|
||||
const _FilteredFileRow({required this.entry});
|
||||
final FileEntry entry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ClideTappable(
|
||||
onTap: () {
|
||||
final kernel = ClideKernel.of(context);
|
||||
unawaited(kernel.ipc.request('editor.open', args: {'path': entry.path}));
|
||||
},
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.sidebarItemHover : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
|
||||
child: ClideText(entry.path, maxLines: 1, overflow: TextOverflow.ellipsis, color: tokens.sidebarForeground),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:clide/builtin/git/src/git_panel_view.dart';
|
||||
import 'package:clide/builtin/git/src/git_status_item.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
|
||||
class GitExtension extends ClideExtension {
|
||||
@override
|
||||
@@ -19,6 +20,7 @@ class GitExtension extends ClideExtension {
|
||||
id: 'git.panel',
|
||||
slot: Slots.sidebar,
|
||||
title: 'Git',
|
||||
icon: PhosphorIcons.gitBranch,
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -80,
|
||||
|
||||
@@ -17,7 +17,7 @@ class GitController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
final DaemonClient ipc;
|
||||
final EventBus events;
|
||||
final DaemonBus events;
|
||||
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
|
||||
|
||||
@@ -22,6 +22,13 @@ class _GitPanelViewState extends State<GitPanelView> {
|
||||
GitController? _controller;
|
||||
final TextEditingController _commitMsg = TextEditingController();
|
||||
final FocusNode _commitFocus = FocusNode();
|
||||
String _filter = '';
|
||||
|
||||
List<Map<String, Object?>> _applyFilter(List<Map<String, Object?>> entries) {
|
||||
if (_filter.isEmpty) return entries;
|
||||
final lf = _filter.toLowerCase();
|
||||
return entries.where((e) => ((e['path'] as String?) ?? '').toLowerCase().contains(lf)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
@@ -66,7 +73,10 @@ class _GitPanelViewState extends State<GitPanelView> {
|
||||
label: 'git panel',
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
ClideFilterBox(hint: 'Filter changes…', onChanged: (v) => setState(() => _filter = v)),
|
||||
Expanded(child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -98,13 +108,13 @@ class _GitPanelViewState extends State<GitPanelView> {
|
||||
if (c.conflicted.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Merge conflicts',
|
||||
entries: c.conflicted,
|
||||
entries: _applyFilter(c.conflicted),
|
||||
actions: const [],
|
||||
),
|
||||
if (c.staged.isNotEmpty) ...[
|
||||
_FileGroup(
|
||||
label: 'Staged',
|
||||
entries: c.staged,
|
||||
entries: _applyFilter(c.staged),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Unstage all',
|
||||
@@ -122,7 +132,7 @@ class _GitPanelViewState extends State<GitPanelView> {
|
||||
if (c.unstaged.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Changes',
|
||||
entries: c.unstaged,
|
||||
entries: _applyFilter(c.unstaged),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Stage all',
|
||||
@@ -135,7 +145,7 @@ class _GitPanelViewState extends State<GitPanelView> {
|
||||
if (c.untracked.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Untracked',
|
||||
entries: c.untracked,
|
||||
entries: _applyFilter(c.untracked),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Stage all',
|
||||
@@ -151,7 +161,9 @@ class _GitPanelViewState extends State<GitPanelView> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -324,7 +336,7 @@ class _FileGroup extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _GitFileRow extends StatefulWidget {
|
||||
class _GitFileRow extends StatelessWidget {
|
||||
const _GitFileRow({
|
||||
required this.entry,
|
||||
this.onStage,
|
||||
@@ -337,78 +349,65 @@ class _GitFileRow extends StatefulWidget {
|
||||
final void Function(String path)? onUnstage;
|
||||
final void Function(String path)? onDiscard;
|
||||
|
||||
@override
|
||||
State<_GitFileRow> createState() => _GitFileRowState();
|
||||
}
|
||||
|
||||
class _GitFileRowState extends State<_GitFileRow> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final path = widget.entry['path'] as String? ?? '';
|
||||
final path = entry['path'] as String? ?? '';
|
||||
final name = path.split('/').last;
|
||||
final indexState = widget.entry['indexState'] as String?;
|
||||
final workTreeState = widget.entry['workTreeState'] as String?;
|
||||
final indexState = entry['indexState'] as String?;
|
||||
final workTreeState = entry['workTreeState'] as String?;
|
||||
final state = indexState ?? workTreeState ?? '';
|
||||
final stateLabel = _stateLabel(state);
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: '$name $stateLabel',
|
||||
child: ClideTappable(
|
||||
onTap: () {
|
||||
final kernel = ClideKernel.of(context);
|
||||
unawaited(kernel.ipc.request('editor.open', args: {'path': path}));
|
||||
},
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: '$name $stateLabel',
|
||||
child: Container(
|
||||
color: _hover ? tokens.sidebarItemHover : null,
|
||||
padding: const EdgeInsets.only(
|
||||
left: 20, right: 8, top: 2, bottom: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(
|
||||
_stateIndicator(state),
|
||||
fontSize: clideFontCaption,
|
||||
color: _stateColor(state, tokens),
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.sidebarItemHover : null,
|
||||
padding: const EdgeInsets.only(
|
||||
left: 20, right: 8, top: 2, bottom: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(
|
||||
_stateIndicator(state),
|
||||
fontSize: clideFontCaption,
|
||||
color: _stateColor(state, tokens),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
if (hovered) ...[
|
||||
if (onStage != null)
|
||||
_SmallAction(
|
||||
label: '+',
|
||||
semanticsLabel: 'stage $name',
|
||||
onTap: () => onStage!(path),
|
||||
),
|
||||
if (onUnstage != null)
|
||||
_SmallAction(
|
||||
label: '-',
|
||||
semanticsLabel: 'unstage $name',
|
||||
onTap: () => onUnstage!(path),
|
||||
),
|
||||
if (onDiscard != null)
|
||||
_SmallAction(
|
||||
label: 'x',
|
||||
semanticsLabel: 'discard changes to $name',
|
||||
onTap: () => onDiscard!(path),
|
||||
),
|
||||
),
|
||||
if (_hover) ...[
|
||||
if (widget.onStage != null)
|
||||
_SmallAction(
|
||||
label: '+',
|
||||
semanticsLabel: 'stage $name',
|
||||
onTap: () => widget.onStage!(path),
|
||||
),
|
||||
if (widget.onUnstage != null)
|
||||
_SmallAction(
|
||||
label: '-',
|
||||
semanticsLabel: 'unstage $name',
|
||||
onTap: () => widget.onUnstage!(path),
|
||||
),
|
||||
if (widget.onDiscard != null)
|
||||
_SmallAction(
|
||||
label: 'x',
|
||||
semanticsLabel: 'discard changes to $name',
|
||||
onTap: () => widget.onDiscard!(path),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -228,7 +228,7 @@ class _BranchPickerState extends State<_BranchPicker> {
|
||||
}
|
||||
}
|
||||
|
||||
class _BranchRow extends StatefulWidget {
|
||||
class _BranchRow extends StatelessWidget {
|
||||
const _BranchRow({
|
||||
required this.name,
|
||||
required this.current,
|
||||
@@ -239,51 +239,39 @@ class _BranchRow extends StatefulWidget {
|
||||
final bool current;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
State<_BranchRow> createState() => _BranchRowState();
|
||||
}
|
||||
|
||||
class _BranchRowState extends State<_BranchRow> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return MouseRegion(
|
||||
cursor:
|
||||
widget.onTap != null ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
color: _hover ? tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.current)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ClideIcon(
|
||||
const CheckIcon(),
|
||||
size: 12,
|
||||
color: tokens.statusSuccess,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
widget.name,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontSize: clideFontMono,
|
||||
color: widget.current
|
||||
? tokens.globalForeground
|
||||
: tokens.listItemForeground,
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
cursor: onTap != null ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
if (current)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ClideIcon(
|
||||
const CheckIcon(),
|
||||
size: 12,
|
||||
color: tokens.statusSuccess,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
name,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontSize: clideFontMono,
|
||||
color: current
|
||||
? tokens.globalForeground
|
||||
: tokens.listItemForeground,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import 'package:clide/builtin/graph/src/graph_view.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
class GraphExtension extends ClideExtension {
|
||||
@override
|
||||
@@ -13,15 +11,5 @@ class GraphExtension extends ClideExtension {
|
||||
List<String> get dependsOn => const ['builtin.pql'];
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'graph.view',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Graph',
|
||||
titleKey: 'tab.graph',
|
||||
i18nNamespace: id,
|
||||
priority: -80,
|
||||
build: (_) => const GraphView(),
|
||||
),
|
||||
];
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
|
||||
@@ -90,30 +90,21 @@ class _GraphNode {
|
||||
);
|
||||
}
|
||||
|
||||
class _NodeRow extends StatefulWidget {
|
||||
class _NodeRow extends StatelessWidget {
|
||||
const _NodeRow({required this.node, required this.tokens});
|
||||
final _GraphNode node;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
State<_NodeRow> createState() => _NodeRowState();
|
||||
}
|
||||
|
||||
class _NodeRowState extends State<_NodeRow> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Container(
|
||||
color: _hovered ? widget.tokens.listItemHoverBackground : null,
|
||||
return ClideTappable(
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: ClideText(widget.node.path, fontSize: 13)),
|
||||
ClideText('${widget.node.inbound}in ${widget.node.outbound}out', color: widget.tokens.globalTextMuted, fontSize: 11),
|
||||
Expanded(child: ClideText(node.path, fontSize: clideFontCaption)),
|
||||
ClideText('${node.inbound}in ${node.outbound}out', color: tokens.globalTextMuted, fontSize: clideFontSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,85 +1,44 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ToolStatusItem extends StatefulWidget {
|
||||
class ToolStatusItem extends StatelessWidget {
|
||||
const ToolStatusItem({super.key});
|
||||
|
||||
@override
|
||||
State<ToolStatusItem> createState() => _ToolStatusItemState();
|
||||
}
|
||||
|
||||
class _ToolStatusItemState extends State<ToolStatusItem> {
|
||||
bool _ptycOk = false;
|
||||
bool _pqlOk = false;
|
||||
bool _checked = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_check();
|
||||
}
|
||||
|
||||
Future<void> _check() async {
|
||||
final cwd = Directory.current.path;
|
||||
final ptycResult = _exists('$cwd/ptyc/bin/ptyc') || await _which('ptyc');
|
||||
final pqlResult = await _which('pql');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_ptycOk = ptycResult;
|
||||
_pqlOk = pqlResult;
|
||||
_checked = true;
|
||||
});
|
||||
}
|
||||
|
||||
bool _exists(String path) => File(path).existsSync();
|
||||
|
||||
Future<bool> _which(String name) async {
|
||||
try {
|
||||
final r = await Process.run('which', [name]);
|
||||
return r.exitCode == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (!_checked) return const SizedBox.shrink();
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.toolCheck,
|
||||
builder: (ctx, _) {
|
||||
final tc = kernel.toolCheck;
|
||||
if (!tc.checked) return const SizedBox.shrink();
|
||||
if (tc.allOk) {
|
||||
return _chip('ok', tokens.statusSuccess, tokens);
|
||||
}
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < tc.errors.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 10),
|
||||
_chip(tc.errors[i], tokens.statusWarning, tokens),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chip(String label, Color color, SurfaceTokens tokens) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_Indicator(label: 'ptyc', ok: _ptycOk, tokens: tokens),
|
||||
const SizedBox(width: 10),
|
||||
_Indicator(label: 'pql', ok: _pqlOk, tokens: tokens),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Indicator extends StatelessWidget {
|
||||
const _Indicator({required this.label, required this.ok, required this.tokens});
|
||||
final String label;
|
||||
final bool ok;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = ok ? tokens.statusSuccess : tokens.statusWarning;
|
||||
return Semantics(
|
||||
label: '$label ${ok ? "available" : "not found"}',
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideIcon(PhosphorIcons.circlesFour, size: 10, color: color),
|
||||
const SizedBox(width: 4),
|
||||
ClideText(label, fontSize: clideFontCaption, color: color, fontFamily: clideMonoFamily),
|
||||
Container(width: 7, height: 7, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(label, fontSize: clideFontCaption, color: color),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/markdown/src/markdown_viewer.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
@@ -8,20 +10,51 @@ class MarkdownExtension extends ClideExtension {
|
||||
@override
|
||||
String get title => 'Markdown';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
String get version => '0.2.0';
|
||||
@override
|
||||
List<String> get dependsOn => const ['builtin.editor'];
|
||||
|
||||
ClideExtensionContext? _ctx;
|
||||
StreamSubscription<DaemonEvent>? _editorSub;
|
||||
bool _viewerSpawned = false;
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'markdown.viewer',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Viewer',
|
||||
titleKey: 'tab.viewer',
|
||||
i18nNamespace: id,
|
||||
priority: -100,
|
||||
build: (_) => const MarkdownViewer(),
|
||||
),
|
||||
];
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
_ctx = ctx;
|
||||
_editorSub = ctx.events.on<DaemonEvent>().listen((e) {
|
||||
if (e.subsystem != 'editor') return;
|
||||
if (e.kind == 'editor.active-changed' || e.kind == 'editor.opened') {
|
||||
final path = e.data['path'] as String?;
|
||||
if (path != null && path.endsWith('.md')) {
|
||||
_spawnViewer();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deactivate() async {
|
||||
_editorSub?.cancel();
|
||||
if (_viewerSpawned) {
|
||||
_ctx?.panels.uncontribute('markdown.viewer');
|
||||
_viewerSpawned = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _spawnViewer() {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null || _viewerSpawned) return;
|
||||
ctx.panels.contribute(TabContribution(
|
||||
id: 'markdown.viewer',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Viewer',
|
||||
priority: -100,
|
||||
build: (_) => const MarkdownViewer(),
|
||||
));
|
||||
_viewerSpawned = true;
|
||||
ctx.panels.activateTab(Slots.contextPanel, 'markdown.viewer');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,15 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToRecord(BuildContext context, String id) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
if (id.startsWith('T-')) {
|
||||
kernel.messages.publish('builtin.tickets', 'selection', {'id': id});
|
||||
} else {
|
||||
kernel.messages.publish('builtin.decisions', 'selection', {'id': id});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_error != null) {
|
||||
@@ -78,16 +87,12 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
|
||||
child: ClideText('Open a .md file to preview it here.', muted: true),
|
||||
);
|
||||
}
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ClidePaneChrome(
|
||||
title: _path ?? 'viewer',
|
||||
subtitle: '${_content!.split('\n').length} lines',
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
_content!,
|
||||
style: TextStyle(color: tokens.globalForeground, fontSize: 13, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback),
|
||||
),
|
||||
child: ClideMarkdown(_content!, onRecordTap: (id) => _navigateToRecord(context, id)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class BacklinksController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
final DaemonClient ipc;
|
||||
final EventBus events;
|
||||
final DaemonBus events;
|
||||
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
|
||||
|
||||
@@ -140,31 +140,22 @@ class _LinkGroup extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _LinkRow extends StatefulWidget {
|
||||
class _LinkRow extends StatelessWidget {
|
||||
const _LinkRow({required this.link, required this.pathKey});
|
||||
final Map<String, Object?> link;
|
||||
final String pathKey;
|
||||
|
||||
@override
|
||||
State<_LinkRow> createState() => _LinkRowState();
|
||||
}
|
||||
|
||||
class _LinkRowState extends State<_LinkRow> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final target = widget.link[widget.pathKey] as String? ?? '';
|
||||
final alias = widget.link['alias'] as String?;
|
||||
final target = link[pathKey] as String? ?? '';
|
||||
final alias = link['alias'] as String?;
|
||||
final display = alias ?? target;
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: target,
|
||||
child: ClideTappable(
|
||||
onTap: () {
|
||||
if (!target.startsWith('http')) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
@@ -172,21 +163,17 @@ class _LinkRowState extends State<_LinkRow> {
|
||||
kernel.ipc.request('editor.open', args: {'path': target}));
|
||||
}
|
||||
},
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: target,
|
||||
child: Container(
|
||||
color: _hover ? tokens.sidebarItemHover : null,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
|
||||
child: ClideText(
|
||||
display,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: target.startsWith('http')
|
||||
? tokens.statusInfo
|
||||
: tokens.sidebarForeground,
|
||||
),
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.sidebarItemHover : null,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
|
||||
child: ClideText(
|
||||
display,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: target.startsWith('http')
|
||||
? tokens.statusInfo
|
||||
: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:clide/builtin/pql/src/backlinks_view.dart';
|
||||
import 'package:clide/builtin/pql/src/pql_panel_view.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
|
||||
class PqlExtension extends ClideExtension {
|
||||
@override
|
||||
@@ -9,7 +10,7 @@ class PqlExtension extends ClideExtension {
|
||||
@override
|
||||
String get title => 'pql';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
String get version => '0.3.0';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
@@ -21,6 +22,7 @@ class PqlExtension extends ClideExtension {
|
||||
title: 'pql',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
icon: PhosphorIcons.magnifyingGlass,
|
||||
priority: -60,
|
||||
build: (_) => const PqlPanelView(),
|
||||
),
|
||||
@@ -28,8 +30,7 @@ class PqlExtension extends ClideExtension {
|
||||
id: 'pql.backlinks',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Links',
|
||||
titleKey: 'tab.links',
|
||||
i18nNamespace: id,
|
||||
icon: PhosphorIcons.link,
|
||||
priority: -80,
|
||||
build: (_) => const BacklinksView(),
|
||||
),
|
||||
|
||||
@@ -9,7 +9,7 @@ import 'dart:async';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum PqlView { files, query, decisions, tickets }
|
||||
enum PqlView { files, query }
|
||||
|
||||
class PqlController extends ChangeNotifier {
|
||||
PqlController({required this.ipc});
|
||||
@@ -40,10 +40,6 @@ class PqlController extends ChangeNotifier {
|
||||
switch (v) {
|
||||
case PqlView.files:
|
||||
unawaited(loadFiles());
|
||||
case PqlView.decisions:
|
||||
unawaited(loadDecisions());
|
||||
case PqlView.tickets:
|
||||
unawaited(loadTickets());
|
||||
case PqlView.query:
|
||||
break;
|
||||
}
|
||||
@@ -91,41 +87,6 @@ class PqlController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loadDecisions() async {
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
|
||||
await ipc.request('pql.decisions.sync');
|
||||
final r = await ipc.request('pql.decisions.list');
|
||||
|
||||
_loading = false;
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_error = null;
|
||||
_results = _castList(r.data['decisions']);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loadTickets() async {
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
|
||||
final r = await ipc.request('pql.tickets.board');
|
||||
|
||||
_loading = false;
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_error = null;
|
||||
_results = _castList(r.data['columns']);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loadPlanStatus() async {
|
||||
final r = await ipc.request('pql.plan.status');
|
||||
if (r.ok) {
|
||||
|
||||
@@ -19,8 +19,6 @@ class PqlPanelView extends StatefulWidget {
|
||||
|
||||
class _PqlPanelViewState extends State<PqlPanelView> {
|
||||
PqlController? _controller;
|
||||
final TextEditingController _queryInput = TextEditingController();
|
||||
final FocusNode _queryFocus = FocusNode();
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
@@ -34,8 +32,6 @@ class _PqlPanelViewState extends State<PqlPanelView> {
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
_queryInput.dispose();
|
||||
_queryFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -56,10 +52,11 @@ class _PqlPanelViewState extends State<PqlPanelView> {
|
||||
children: [
|
||||
_ViewTabs(controller: c),
|
||||
if (c.view == PqlView.query)
|
||||
_QueryInput(
|
||||
input: _queryInput,
|
||||
focus: _queryFocus,
|
||||
controller: c,
|
||||
ClideFilterBox(
|
||||
hint: 'PQL query…',
|
||||
debounce: Duration.zero,
|
||||
onChanged: (_) {},
|
||||
onSubmitted: (v) => unawaited(c.runQuery(v)),
|
||||
),
|
||||
if (c.error != null)
|
||||
Padding(
|
||||
@@ -93,10 +90,6 @@ class _PqlPanelViewState extends State<PqlPanelView> {
|
||||
for (final f in c.results) _FileRow(entry: f),
|
||||
if (c.view == PqlView.query)
|
||||
for (final r in c.results) _QueryResultRow(entry: r),
|
||||
if (c.view == PqlView.decisions)
|
||||
for (final d in c.results) _DecisionRow(entry: d),
|
||||
if (c.view == PqlView.tickets)
|
||||
for (final col in c.results) _TicketColumn(column: col),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -153,94 +146,35 @@ class _ViewTabs extends StatelessWidget {
|
||||
static String _tabLabel(PqlView v) => switch (v) {
|
||||
PqlView.files => 'Files',
|
||||
PqlView.query => 'Query',
|
||||
PqlView.decisions => 'Decisions',
|
||||
PqlView.tickets => 'Tickets',
|
||||
};
|
||||
}
|
||||
|
||||
class _QueryInput extends StatelessWidget {
|
||||
const _QueryInput({
|
||||
required this.input,
|
||||
required this.focus,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
final TextEditingController input;
|
||||
final FocusNode focus;
|
||||
final PqlController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Semantics(
|
||||
label: 'pql query',
|
||||
textField: true,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: tokens.globalBorder),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: EditableText(
|
||||
controller: input,
|
||||
focusNode: focus,
|
||||
style: TextStyle(
|
||||
fontFamily: clideMonoFamily,
|
||||
fontSize: clideFontMono,
|
||||
color: tokens.globalForeground,
|
||||
),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalFocus,
|
||||
maxLines: 1,
|
||||
onSubmitted: (_) =>
|
||||
unawaited(controller.runQuery(input.text)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FileRow extends StatefulWidget {
|
||||
class _FileRow extends StatelessWidget {
|
||||
const _FileRow({required this.entry});
|
||||
final Map<String, Object?> entry;
|
||||
|
||||
@override
|
||||
State<_FileRow> createState() => _FileRowState();
|
||||
}
|
||||
|
||||
class _FileRowState extends State<_FileRow> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final path = widget.entry['path'] as String? ?? '';
|
||||
final name = widget.entry['name'] as String? ?? path.split('/').last;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
final path = entry['path'] as String? ?? '';
|
||||
final name = entry['name'] as String? ?? path.split('/').last;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'Open $name',
|
||||
child: ClideTappable(
|
||||
onTap: () {
|
||||
final kernel = ClideKernel.of(context);
|
||||
unawaited(
|
||||
kernel.ipc.request('editor.open', args: {'path': path}));
|
||||
},
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: 'Open $name',
|
||||
child: Container(
|
||||
color: _hover ? tokens.sidebarItemHover : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
|
||||
child: ClideText(
|
||||
path,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.sidebarItemHover : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
|
||||
child: ClideText(
|
||||
path,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -274,103 +208,3 @@ class _QueryResultRow extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DecisionRow extends StatelessWidget {
|
||||
const _DecisionRow({required this.entry});
|
||||
final Map<String, Object?> entry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final id = entry['id'] as String? ?? '';
|
||||
final title = entry['title'] as String? ?? '';
|
||||
final type = entry['type'] as String? ?? '';
|
||||
final domain = entry['domain'] as String? ?? '';
|
||||
|
||||
final Color idColor = switch (type) {
|
||||
'confirmed' => tokens.statusSuccess,
|
||||
'question' => tokens.statusWarning,
|
||||
'rejected' => tokens.statusError,
|
||||
_ => tokens.sidebarForeground,
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 44,
|
||||
child: ClideText(id, fontSize: clideFontMono, color: idColor,
|
||||
fontFamily: clideMonoFamily),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
ClideText(domain, fontSize: clideFontCaption, muted: true),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TicketColumn extends StatelessWidget {
|
||||
const _TicketColumn({required this.column});
|
||||
final Map<String, Object?> column;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final status = column['status'] as String? ?? '';
|
||||
final tickets = (column['tickets'] as List?) ?? const [];
|
||||
if (tickets.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 2),
|
||||
child: ClideText(
|
||||
'$status (${tickets.length})',
|
||||
fontSize: clideFontCaption,
|
||||
muted: true,
|
||||
),
|
||||
),
|
||||
for (final t in tickets)
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 44,
|
||||
child: ClideText(
|
||||
(t as Map)['id'] as String? ?? '',
|
||||
fontSize: clideFontMono,
|
||||
fontFamily: clideMonoFamily,
|
||||
color: tokens.statusInfo,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
t['title'] as String? ?? '',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:clide/builtin/problems/src/problems_view.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
|
||||
class ProblemsExtension extends ClideExtension {
|
||||
@override
|
||||
@@ -18,6 +19,7 @@ class ProblemsExtension extends ClideExtension {
|
||||
id: 'problems.panel',
|
||||
slot: Slots.sidebar,
|
||||
title: 'Problems',
|
||||
icon: PhosphorIcons.warningCircle,
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -50,
|
||||
|
||||
@@ -18,6 +18,7 @@ class ProblemsView extends StatefulWidget {
|
||||
|
||||
class _ProblemsViewState extends State<ProblemsView> {
|
||||
ProblemsController? _controller;
|
||||
String _filter = '';
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
@@ -46,66 +47,46 @@ class _ProblemsViewState extends State<ProblemsView> {
|
||||
label: 'problems panel',
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
'Problems (${c.problems.length})',
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'refresh problems',
|
||||
child: GestureDetector(
|
||||
onTap: () => unawaited(c.refresh()),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: ClideText(
|
||||
'Refresh',
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
child: () {
|
||||
final lf = _filter.toLowerCase();
|
||||
final filtered = lf.isEmpty ? c.problems : c.problems.where((p) => p.message.toLowerCase().contains(lf) || p.source.toLowerCase().contains(lf)).toList();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideFilterBox(hint: 'Filter problems…', onChanged: (v) => setState(() => _filter = v)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: ClideText('Problems (${filtered.length})', fontSize: clideFontCaption, color: tokens.sidebarForeground)),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'refresh problems',
|
||||
child: GestureDetector(
|
||||
onTap: () => unawaited(c.refresh()),
|
||||
child: MouseRegion(cursor: SystemMouseCursors.click, child: ClideText('Refresh', fontSize: clideFontCaption, color: tokens.sidebarForeground)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (c.loading && c.problems.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Scanning…', muted: true),
|
||||
),
|
||||
if (!c.loading && c.problems.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
'No problems found.',
|
||||
muted: true,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final p in c.problems) _ProblemRow(problem: p),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (c.loading && c.problems.isEmpty)
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('Scanning…', muted: true)),
|
||||
if (!c.loading && filtered.isEmpty)
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('No problems found.', muted: true)),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [for (final p in filtered) _ProblemRow(problem: p)],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/tickets/src/ticket_detail_view.dart';
|
||||
import 'package:clide/builtin/tickets/src/tickets_view.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
|
||||
class TicketsExtension extends ClideExtension {
|
||||
@override
|
||||
@@ -8,10 +13,32 @@ class TicketsExtension extends ClideExtension {
|
||||
@override
|
||||
String get title => 'Tickets';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
String get version => '0.6.0';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
StreamSubscription<Message>? _sub;
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
_sub = ctx.messages.subscribe(publisher: id, channel: 'selection').listen((msg) {
|
||||
final selectedId = msg.data['id'] as String?;
|
||||
if (selectedId == null) return;
|
||||
ctx.panels.uncontribute('tickets.detail');
|
||||
ctx.panels.contribute(TabContribution(
|
||||
id: 'tickets.detail',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Ticket',
|
||||
icon: PhosphorIcons.ticket,
|
||||
build: (_) => TicketDetailView(initialId: selectedId),
|
||||
));
|
||||
ctx.panels.activateTab(Slots.contextPanel, 'tickets.detail');
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deactivate() async => _sub?.cancel();
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
@@ -20,8 +47,15 @@ class TicketsExtension extends ClideExtension {
|
||||
title: 'Tickets',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -10,
|
||||
icon: PhosphorIcons.ticket,
|
||||
build: (_) => const TicketsView(),
|
||||
),
|
||||
TabContribution(
|
||||
id: 'tickets.detail',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Ticket',
|
||||
icon: PhosphorIcons.ticket,
|
||||
build: (_) => const TicketDetailView(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
class TicketTypeColors {
|
||||
const TicketTypeColors({
|
||||
required this.initiative,
|
||||
required this.epic,
|
||||
required this.story,
|
||||
required this.task,
|
||||
required this.bug,
|
||||
});
|
||||
|
||||
final Color initiative;
|
||||
final Color epic;
|
||||
final Color story;
|
||||
final Color task;
|
||||
final Color bug;
|
||||
|
||||
Color forType(String? type) => switch (type) {
|
||||
'initiative' => initiative,
|
||||
'epic' => epic,
|
||||
'story' => story,
|
||||
'task' => task,
|
||||
'bug' => bug,
|
||||
_ => task,
|
||||
};
|
||||
|
||||
static const dark = TicketTypeColors(
|
||||
initiative: Color(0xFFC792EA),
|
||||
epic: Color(0xFF78A0F8),
|
||||
story: Color(0xFF7DD3A8),
|
||||
task: Color(0xFF78809C),
|
||||
bug: Color(0xFFE87D7D),
|
||||
);
|
||||
|
||||
static const light = TicketTypeColors(
|
||||
initiative: Color(0xFF7B2CB0),
|
||||
epic: Color(0xFF2962B8),
|
||||
story: Color(0xFF1D7A4E),
|
||||
task: Color(0xFF5A6070),
|
||||
bug: Color(0xFFC03030),
|
||||
);
|
||||
|
||||
static TicketTypeColors forTheme({required bool dark}) =>
|
||||
dark ? TicketTypeColors.dark : TicketTypeColors.light;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class TicketDetail {
|
||||
const TicketDetail({required this.ticket, this.parents = const [], this.decisions = const []});
|
||||
final Map<String, Object?> ticket;
|
||||
final List<Map<String, Object?>> parents;
|
||||
final List<Map<String, Object?>> decisions;
|
||||
|
||||
String get id => ticket['id'] as String? ?? '';
|
||||
String get title => ticket['title'] as String? ?? '';
|
||||
String? get type => ticket['type'] as String?;
|
||||
String? get status => ticket['status'] as String?;
|
||||
String? get priority => ticket['priority'] as String?;
|
||||
String? get description => ticket['description'] as String?;
|
||||
String? get parentId => ticket['parent_id'] as String?;
|
||||
String? get decisionRef => ticket['decision_ref'] as String?;
|
||||
String? get assignedTo => ticket['assigned_to'] as String?;
|
||||
}
|
||||
|
||||
class TicketDetailController extends ChangeNotifier {
|
||||
TicketDetailController({required this.ipc, required this.messages, required this.panels}) {
|
||||
_sub = messages.subscribe(publisher: 'builtin.tickets', channel: 'selection').listen(_onSelection);
|
||||
}
|
||||
|
||||
final DaemonClient ipc;
|
||||
final MessageBus messages;
|
||||
final PanelRegistry panels;
|
||||
StreamSubscription<Message>? _sub;
|
||||
|
||||
TicketDetail? _detail;
|
||||
TicketDetail? get detail => _detail;
|
||||
|
||||
bool _loading = false;
|
||||
bool get loading => _loading;
|
||||
|
||||
void _onSelection(Message msg) {
|
||||
final id = msg.data['id'] as String?;
|
||||
if (id != null) {
|
||||
panels.activateTab(Slots.contextPanel, 'tickets.detail');
|
||||
unawaited(load(id));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> load(String id) async {
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
|
||||
final resp = await ipc.request('pql.tickets.show', args: {'id': id});
|
||||
if (!resp.ok) {
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final ticket = resp.data;
|
||||
final parents = <Map<String, Object?>>[];
|
||||
final decisions = <Map<String, Object?>>[];
|
||||
|
||||
// Walk parent chain
|
||||
var pid = ticket['parent_id'] as String?;
|
||||
while (pid != null) {
|
||||
final pr = await ipc.request('pql.tickets.show', args: {'id': pid});
|
||||
if (!pr.ok) break;
|
||||
parents.add(pr.data);
|
||||
pid = pr.data['parent_id'] as String?;
|
||||
}
|
||||
|
||||
// Collect decision refs from ticket and parents
|
||||
final refs = <String>{};
|
||||
final dr = ticket['decision_ref'] as String?;
|
||||
if (dr != null) refs.add(dr);
|
||||
for (final p in parents) {
|
||||
final pdr = p['decision_ref'] as String?;
|
||||
if (pdr != null) refs.add(pdr);
|
||||
}
|
||||
|
||||
for (final ref in refs) {
|
||||
final dr = await ipc.request('pql.decisions.show', args: {'id': ref});
|
||||
if (dr.ok) decisions.add(dr.data);
|
||||
}
|
||||
|
||||
_detail = TicketDetail(ticket: ticket, parents: parents, decisions: decisions);
|
||||
_loading = false;
|
||||
messages.publish('builtin.tickets', 'focus', {'id': id});
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import 'package:clide/builtin/tickets/src/ticket_colors.dart';
|
||||
import 'package:clide/builtin/tickets/src/ticket_detail_controller.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TicketDetailView extends StatefulWidget {
|
||||
const TicketDetailView({super.key, this.initialId});
|
||||
final String? initialId;
|
||||
|
||||
@override
|
||||
State<TicketDetailView> createState() => _TicketDetailViewState();
|
||||
}
|
||||
|
||||
class _TicketDetailViewState extends State<TicketDetailView> {
|
||||
TicketDetailController? _controller;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_controller != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_controller = TicketDetailController(ipc: kernel.ipc, messages: kernel.messages, panels: kernel.panels);
|
||||
if (widget.initialId != null) {
|
||||
_controller!.load(widget.initialId!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _navigateToRecord(BuildContext context, String id) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
if (id.startsWith('T-')) {
|
||||
kernel.messages.publish('builtin.tickets', 'selection', {'id': id});
|
||||
} else {
|
||||
kernel.messages.publish('builtin.decisions', 'selection', {'id': id});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = _controller;
|
||||
if (c == null) return const SizedBox.shrink();
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (ctx, _) {
|
||||
if (c.loading) return const Center(child: ClideText('Loading…', muted: true));
|
||||
final d = c.detail;
|
||||
if (d == null) return const Padding(padding: EdgeInsets.all(12), child: ClideText('Select a ticket to view details.', muted: true));
|
||||
|
||||
final tokens = ClideTheme.of(ctx).surface;
|
||||
final isDark = ClideTheme.of(ctx).dark;
|
||||
final typeColors = TicketTypeColors.forTheme(dark: isDark);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_TicketHeader(detail: d, tokens: tokens, typeColors: typeColors),
|
||||
const SizedBox(height: 12),
|
||||
_StatusControls(detail: d, tokens: tokens, controller: c),
|
||||
if (d.description != null && d.description!.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
ClideMarkdown(d.description!, onRecordTap: (id) => _navigateToRecord(ctx, id)),
|
||||
],
|
||||
if (d.parents.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_SectionLabel(label: 'PARENT TREE', tokens: tokens),
|
||||
const SizedBox(height: 6),
|
||||
for (var i = 0; i < d.parents.length; i++)
|
||||
_CompactCard(
|
||||
data: d.parents[i],
|
||||
tokens: tokens,
|
||||
typeColors: typeColors,
|
||||
indent: i,
|
||||
),
|
||||
],
|
||||
if (d.decisions.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_SectionLabel(label: 'REFERENCED DECISIONS', tokens: tokens),
|
||||
const SizedBox(height: 6),
|
||||
for (final dec in d.decisions) _DecisionRefCard(data: dec, tokens: tokens),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TicketHeader extends StatelessWidget {
|
||||
const _TicketHeader({required this.detail, required this.tokens, required this.typeColors});
|
||||
final TicketDetail detail;
|
||||
final SurfaceTokens tokens;
|
||||
final TicketTypeColors typeColors;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final typeColor = typeColors.forType(detail.type);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: tokens.panelBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClideTooltip(
|
||||
message: detail.type ?? 'task',
|
||||
child: Container(width: 10, height: 10, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ClideText(detail.id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily),
|
||||
const Spacer(),
|
||||
if (detail.priority != null)
|
||||
ClideText(detail.priority!, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClideText(detail.title, fontSize: 15, fontWeight: FontWeight.w500),
|
||||
if (detail.assignedTo != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
ClideText('assigned: ${detail.assignedTo}', muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusControls extends StatelessWidget {
|
||||
const _StatusControls({required this.detail, required this.tokens, required this.controller});
|
||||
final TicketDetail detail;
|
||||
final SurfaceTokens tokens;
|
||||
final TicketDetailController controller;
|
||||
|
||||
static const _statuses = ['backlog', 'ready', 'in_progress', 'review', 'done'];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
for (final s in _statuses) ...[
|
||||
Expanded(
|
||||
child: ClideTappable(
|
||||
onTap: () async {
|
||||
await controller.ipc.request('pql.tickets.status', args: {'ids': detail.id, 'status': s});
|
||||
await controller.load(detail.id);
|
||||
},
|
||||
builder: (ctx, hovered, _) {
|
||||
final active = detail.status == s;
|
||||
final color = active ? tokens.statusInfo : (hovered ? tokens.globalForeground : tokens.globalTextMuted);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? tokens.statusInfo.withAlpha(0x30) : null,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
border: Border.all(color: active ? tokens.statusInfo : tokens.panelBorder),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: ClideText(_shortLabel(s), fontSize: clideFontBadge, color: color, fontFamily: clideMonoFamily),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (s != _statuses.last) const SizedBox(width: 4),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static String _shortLabel(String s) => switch (s) {
|
||||
'backlog' => 'BACKLOG',
|
||||
'ready' => 'READY',
|
||||
'in_progress' => 'WIP',
|
||||
'review' => 'REVIEW',
|
||||
'done' => 'DONE',
|
||||
_ => s.toUpperCase(),
|
||||
};
|
||||
}
|
||||
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
const _SectionLabel({required this.label, required this.tokens});
|
||||
final String label;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideText(label, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily);
|
||||
}
|
||||
}
|
||||
|
||||
class _CompactCard extends StatelessWidget {
|
||||
const _CompactCard({required this.data, required this.tokens, required this.typeColors, this.indent = 0});
|
||||
final Map<String, Object?> data;
|
||||
final SurfaceTokens tokens;
|
||||
final TicketTypeColors typeColors;
|
||||
final int indent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final id = data['id'] as String? ?? '';
|
||||
final title = data['title'] as String? ?? '';
|
||||
final type = data['type'] as String?;
|
||||
final typeColor = typeColors.forType(type);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: indent * 12.0, bottom: 4),
|
||||
child: ClideTappable(
|
||||
onTap: () => ClideKernel.of(context).messages.publish('builtin.tickets', 'selection', {'id': id}),
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.sidebarItemHover : tokens.panelBackground,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: hovered ? tokens.panelActiveBorder : tokens.panelBorder),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(width: 6, height: 6, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: ClideText(title, fontSize: clideFontSmall)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DecisionRefCard extends StatelessWidget {
|
||||
const _DecisionRefCard({required this.data, required this.tokens});
|
||||
final Map<String, Object?> data;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final id = data['id'] as String? ?? '';
|
||||
final title = data['title'] as String? ?? '';
|
||||
final type = data['type'] as String?;
|
||||
final domain = data['domain'] as String?;
|
||||
final color = switch (type) {
|
||||
'confirmed' => tokens.statusSuccess,
|
||||
'question' => tokens.statusWarning,
|
||||
'rejected' => tokens.statusError,
|
||||
_ => tokens.globalTextMuted,
|
||||
};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: ClideTappable(
|
||||
onTap: () => ClideKernel.of(context).messages.publish('builtin.decisions', 'selection', {'id': id}),
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.sidebarItemHover : tokens.panelBackground,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: hovered ? tokens.panelActiveBorder : tokens.panelBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(id, fontSize: clideFontSmall, color: color, fontFamily: clideMonoFamily),
|
||||
const Spacer(),
|
||||
if (domain != null) ClideText(domain, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
ClideText(title, fontSize: clideFontSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:clide/builtin/tickets/src/ticket_colors.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@@ -16,19 +17,60 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
List<_TicketEntry> _tickets = [];
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
String _filter = '';
|
||||
String? _focusedId;
|
||||
final _focusedKey = GlobalKey();
|
||||
final Set<String> _expanded = {'active', 'backlog'};
|
||||
StreamSubscription<Message>? _focusSub;
|
||||
|
||||
void _toggle(String key) {
|
||||
setState(() {
|
||||
if (_expanded.contains(key)) _expanded.remove(key); else _expanded.add(key);
|
||||
});
|
||||
}
|
||||
|
||||
static String _sectionForStatus(String? status) => switch (status) {
|
||||
'in_progress' => 'active',
|
||||
'backlog' || 'ready' => 'backlog',
|
||||
'done' => 'done',
|
||||
_ => 'other',
|
||||
};
|
||||
|
||||
void _onFocus(Message msg) {
|
||||
final id = msg.data['id'] as String?;
|
||||
if (id == null || id == _focusedId) return;
|
||||
final entry = _tickets.where((t) => t.id == id).firstOrNull;
|
||||
final section = _sectionForStatus(entry?.status);
|
||||
setState(() {
|
||||
_focusedId = id;
|
||||
_expanded.add(section);
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final ctx = _focusedKey.currentContext;
|
||||
if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_focusSub == null) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
_focusSub = kernel.messages.subscribe(publisher: 'builtin.tickets', channel: 'focus').listen(_onFocus);
|
||||
}
|
||||
if (!_loading || _tickets.isNotEmpty) return;
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request('pql.exec', args: {
|
||||
'argv': ['ticket', 'list'],
|
||||
});
|
||||
final resp = await kernel.ipc.request('pql.tickets.list');
|
||||
if (!mounted) return;
|
||||
if (!resp.ok) {
|
||||
setState(() {
|
||||
@@ -37,104 +79,198 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
});
|
||||
return;
|
||||
}
|
||||
final raw = resp.data['stdout'] as String? ?? '[]';
|
||||
try {
|
||||
final list = (jsonDecode(raw) as List).cast<Map<String, dynamic>>();
|
||||
final raw = resp.data['tickets'];
|
||||
if (raw is List) {
|
||||
setState(() {
|
||||
_tickets = list.map(_TicketEntry.fromJson).toList();
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'parse error: $e';
|
||||
_tickets = [for (final e in raw) _TicketEntry.fromJson((e as Map).cast<String, dynamic>())];
|
||||
_loading = false;
|
||||
});
|
||||
} else {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (_loading) {
|
||||
return const Center(child: ClideText('Loading tickets...', muted: true));
|
||||
}
|
||||
if (_error != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(_error!, muted: true),
|
||||
);
|
||||
}
|
||||
if (_tickets.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('No tickets found.\nRun `pql ticket new` to create one.', muted: true),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: _tickets.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final t = _tickets[i];
|
||||
return _TicketRow(entry: t, tokens: tokens);
|
||||
},
|
||||
if (_loading) return const Center(child: ClideText('Loading tickets...', muted: true));
|
||||
if (_error != null) return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
|
||||
if (_tickets.isEmpty) return const Padding(padding: EdgeInsets.all(12), child: ClideText('No tickets.\nRun `pql ticket new` to create one.', muted: true));
|
||||
|
||||
final lf = _filter.toLowerCase();
|
||||
final hasFilter = lf.isNotEmpty;
|
||||
final filtered = hasFilter ? _tickets.where((t) => t.id.toLowerCase().contains(lf) || t.title.toLowerCase().contains(lf) || (t.status ?? '').contains(lf) || (t.type ?? '').contains(lf)).toList() : _tickets;
|
||||
|
||||
final active = filtered.where((t) => t.status == 'in_progress').toList();
|
||||
final backlog = filtered.where((t) => t.status == 'backlog' || t.status == 'ready').toList();
|
||||
final done = filtered.where((t) => t.status == 'done').toList();
|
||||
final other = filtered.where((t) => t.status == 'cancelled' || t.status == 'review').toList();
|
||||
|
||||
final isDark = ClideTheme.of(context).dark;
|
||||
final typeColors = TicketTypeColors.forTheme(dark: isDark);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ClideFilterBox(hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v)),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (active.isNotEmpty) _AccordionSection(label: 'ACTIVE', count: active.length, tokens: tokens, expanded: hasFilter || _expanded.contains('active'), onToggle: () => _toggle('active'), children: [for (final t in active) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId, focusKey: t.id == _focusedId ? _focusedKey : null)]),
|
||||
if (backlog.isNotEmpty) _AccordionSection(label: 'BACKLOG', count: backlog.length, tokens: tokens, expanded: hasFilter || _expanded.contains('backlog'), onToggle: () => _toggle('backlog'), children: [for (final t in backlog) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId, focusKey: t.id == _focusedId ? _focusedKey : null)]),
|
||||
if (done.isNotEmpty) _AccordionSection(label: 'DONE', count: done.length, tokens: tokens, expanded: hasFilter || _expanded.contains('done'), onToggle: () => _toggle('done'), children: [for (final t in done) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId, focusKey: t.id == _focusedId ? _focusedKey : null)]),
|
||||
if (other.isNotEmpty) _AccordionSection(label: 'OTHER', count: other.length, tokens: tokens, expanded: hasFilter || _expanded.contains('other'), onToggle: () => _toggle('other'), children: [for (final t in other) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId, focusKey: t.id == _focusedId ? _focusedKey : null)]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TicketEntry {
|
||||
const _TicketEntry({required this.id, required this.title, this.status, this.priority});
|
||||
const _TicketEntry({required this.id, required this.title, this.type, this.status, this.priority, this.parentId});
|
||||
final String id;
|
||||
final String title;
|
||||
final String? type;
|
||||
final String? status;
|
||||
final String? priority;
|
||||
final String? parentId;
|
||||
|
||||
factory _TicketEntry.fromJson(Map<String, dynamic> json) => _TicketEntry(
|
||||
id: json['id'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
type: json['type'] as String?,
|
||||
status: json['status'] as String?,
|
||||
priority: json['priority'] as String?,
|
||||
parentId: json['parent_id'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class _TicketRow extends StatefulWidget {
|
||||
const _TicketRow({required this.entry, required this.tokens});
|
||||
final _TicketEntry entry;
|
||||
class _AccordionSection extends StatelessWidget {
|
||||
const _AccordionSection({required this.label, required this.count, required this.tokens, required this.expanded, required this.onToggle, required this.children});
|
||||
final String label;
|
||||
final int count;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
State<_TicketRow> createState() => _TicketRowState();
|
||||
}
|
||||
|
||||
class _TicketRowState extends State<_TicketRow> {
|
||||
bool _hovered = false;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggle;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final statusColor = switch (widget.entry.status) {
|
||||
'done' => widget.tokens.statusSuccess,
|
||||
'in_progress' => widget.tokens.statusInfo,
|
||||
'cancelled' => widget.tokens.statusError,
|
||||
_ => widget.tokens.globalTextMuted,
|
||||
};
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Container(
|
||||
color: _hovered ? widget.tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(widget.entry.id, color: widget.tokens.globalTextMuted, fontSize: 12),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(color: statusColor, shape: BoxShape.circle),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideTappable(
|
||||
onTap: onToggle,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.only(left: 4, top: 10, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideIcon(expanded ? PhosphorIcons.caretDown : PhosphorIcons.caretRight, size: 10, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 6),
|
||||
ClideText('$label · $count', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(child: ClideText(widget.entry.title, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (expanded) ...children,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TicketCard extends StatelessWidget {
|
||||
const _TicketCard({required this.entry, required this.tokens, required this.typeColors, this.focused = false, this.focusKey});
|
||||
final _TicketEntry entry;
|
||||
final SurfaceTokens tokens;
|
||||
final TicketTypeColors typeColors;
|
||||
final bool focused;
|
||||
final GlobalKey? focusKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final typeColor = typeColors.forType(entry.type);
|
||||
final statusLabel = _statusLabel(entry.status);
|
||||
|
||||
return Padding(
|
||||
key: focusKey,
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: ClideTappable(
|
||||
onTap: () => ClideKernel.of(context).messages.publish('builtin.tickets', 'selection', {'id': entry.id}),
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.sidebarItemHover : (focused ? tokens.sidebarItemSelected : tokens.panelBackground),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: focused ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.panelBorder), width: 1),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClideTooltip(
|
||||
message: entry.type ?? 'task',
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
if (entry.parentId != null) ...[
|
||||
ClideText(' ← ', fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
ClideText(entry.parentId!, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClideText(entry.title, fontSize: clideFontCaption),
|
||||
if (statusLabel != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
_StatusBadge(label: statusLabel, tokens: tokens, status: entry.status),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String? _statusLabel(String? status) => switch (status) {
|
||||
'in_progress' => 'WIP',
|
||||
'review' => 'REVIEW',
|
||||
'cancelled' => 'CANCELLED',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.label, required this.tokens, this.status});
|
||||
final String label;
|
||||
final SurfaceTokens tokens;
|
||||
final String? status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = switch (status) {
|
||||
'in_progress' => tokens.statusInfo,
|
||||
'review' => tokens.statusWarning,
|
||||
'cancelled' => tokens.statusError,
|
||||
_ => tokens.globalTextMuted,
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withAlpha(0x30),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: ClideText(label, fontSize: clideFontBadge, color: color, fontFamily: clideMonoFamily),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class WelcomeView extends StatelessWidget {
|
||||
children: [
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
constraints: const BoxConstraints(maxWidth: 850),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -54,7 +54,7 @@ class _Header extends StatelessWidget {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset('assets/logo/clide-logo-192.png', width: 72, height: 72),
|
||||
const ClideSvgView.asset('assets/logo/logo.svg', width: 144, height: 144),
|
||||
const SizedBox(width: 24),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -121,7 +121,7 @@ class _StartColumn extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionRow extends StatefulWidget {
|
||||
class _ActionRow extends StatelessWidget {
|
||||
const _ActionRow({required this.icon, required this.label, this.shortcut, required this.tokens, required this.onTap});
|
||||
final ClideIconPainter icon;
|
||||
final String label;
|
||||
@@ -129,36 +129,24 @@ class _ActionRow extends StatefulWidget {
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_ActionRow> createState() => _ActionRowState();
|
||||
}
|
||||
|
||||
class _ActionRowState extends State<_ActionRow> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _hover ? widget.tokens.listItemHoverBackground : null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideIcon(widget.icon, size: 18, color: widget.tokens.globalTextMuted),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(child: ClideText(widget.label, fontSize: 15, color: widget.tokens.globalForeground)),
|
||||
if (widget.shortcut != null)
|
||||
ClideText(widget.shortcut!, fontSize: 13, color: widget.tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideIcon(icon, size: 18, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(child: ClideText(label, fontSize: 15, color: tokens.globalForeground)),
|
||||
if (shortcut != null)
|
||||
ClideText(shortcut!, fontSize: 13, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -199,58 +187,46 @@ class _RecentColumn extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentRow extends StatefulWidget {
|
||||
class _RecentRow extends StatelessWidget {
|
||||
const _RecentRow({required this.project, required this.tokens, required this.onTap});
|
||||
final RecentProject project;
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_RecentRow> createState() => _RecentRowState();
|
||||
}
|
||||
|
||||
class _RecentRowState extends State<_RecentRow> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _hover ? widget.tokens.listItemHoverBackground : null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(widget.project.name, fontSize: 15, fontWeight: FontWeight.w500),
|
||||
const SizedBox(height: 3),
|
||||
Row(
|
||||
children: [
|
||||
ClideText(widget.project.relativePath, muted: true, fontSize: 13, fontFamily: clideMonoFamily),
|
||||
if (widget.project.branch != null) ...[
|
||||
ClideText(' · ', muted: true, fontSize: 13),
|
||||
ClideIcon(PhosphorIcons.gitBranch, size: 11, color: widget.tokens.globalTextMuted),
|
||||
const SizedBox(width: 3),
|
||||
ClideText(widget.project.branch!, muted: true, fontSize: 13, fontFamily: clideMonoFamily),
|
||||
],
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(project.name, fontSize: 15, fontWeight: FontWeight.w500),
|
||||
const SizedBox(height: 3),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(child: ClideText(project.relativePath, muted: true, fontSize: 13, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
if (project.branch != null) ...[
|
||||
ClideText(' · ', muted: true, fontSize: 13),
|
||||
ClideIcon(PhosphorIcons.gitBranch, size: 11, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 3),
|
||||
ClideText(project.branch!, muted: true, fontSize: 13, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
ClideText(widget.project.timeAgo, muted: true, fontSize: 13),
|
||||
],
|
||||
),
|
||||
),
|
||||
ClideText(project.timeAgo, muted: true, fontSize: 13),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -264,24 +240,24 @@ class _StatusLine extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeName = kernel.theme.currentName;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.ipc,
|
||||
listenable: kernel.toolCheck,
|
||||
builder: (ctx, _) {
|
||||
final connected = kernel.ipc.isConnected;
|
||||
final themeName = kernel.theme.currentName;
|
||||
final tc = kernel.toolCheck;
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ClideText('clide 2.0.0-dev', muted: true, fontSize: 12, fontFamily: clideMonoFamily),
|
||||
ClideText(' · ', muted: true, fontSize: 12),
|
||||
ClideText(
|
||||
connected ? 'daemon connected' : 'daemon disconnected',
|
||||
fontSize: 12,
|
||||
fontFamily: clideMonoFamily,
|
||||
color: connected ? tokens.statusSuccess : tokens.statusError,
|
||||
),
|
||||
if (!tc.checked)
|
||||
ClideText('checking…', muted: true, fontSize: 12, fontFamily: clideMonoFamily)
|
||||
else if (tc.allOk)
|
||||
ClideText('application ok', fontSize: 12, fontFamily: clideMonoFamily, color: tokens.statusSuccess)
|
||||
else
|
||||
ClideText(tc.errors.join(' · '), fontSize: 12, fontFamily: clideMonoFamily, color: tokens.statusWarning),
|
||||
ClideText(' · ', muted: true, fontSize: 12),
|
||||
ClideText('theme: ', muted: true, fontSize: 12, fontFamily: clideMonoFamily),
|
||||
ClideText(themeName, fontSize: 12, fontFamily: clideMonoFamily, color: tokens.globalFocus),
|
||||
_ThemeLink(tokens: tokens, kernel: kernel, themeName: themeName),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -289,6 +265,27 @@ class _StatusLine extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeLink extends StatelessWidget {
|
||||
const _ThemeLink({required this.tokens, required this.kernel, required this.themeName});
|
||||
final SurfaceTokens tokens;
|
||||
final KernelServices kernel;
|
||||
final String themeName;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideTappable(
|
||||
onTap: () => kernel.commands.execute('theme.pick'),
|
||||
builder: (ctx, hovered, _) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText('theme: ', muted: true, fontSize: 12, fontFamily: clideMonoFamily),
|
||||
ClideText(themeName, fontSize: 12, fontFamily: clideMonoFamily, color: hovered ? tokens.globalForeground : tokens.globalFocus),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OpenProjectDialog extends StatefulWidget {
|
||||
const _OpenProjectDialog({required this.onOpen, required this.onCancel});
|
||||
final Future<void> Function(String path) onOpen;
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:clide/kernel/src/commands/palette.dart';
|
||||
import 'package:clide/kernel/src/commands/registry.dart';
|
||||
import 'package:clide/kernel/src/dialog.dart';
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/kernel/src/files.dart';
|
||||
import 'package:clide/kernel/src/focus.dart';
|
||||
import 'package:clide/kernel/src/i18n/i18n.dart';
|
||||
@@ -53,7 +54,8 @@ abstract class ClideExtensionContext {
|
||||
String get id;
|
||||
|
||||
Logger get log;
|
||||
EventBus get events;
|
||||
DaemonBus get events;
|
||||
MessageBus get messages;
|
||||
SettingsStore get settings;
|
||||
ThemeController get theme;
|
||||
I18n get i18n;
|
||||
@@ -74,6 +76,17 @@ abstract class ClideExtensionContext {
|
||||
DaemonClient get ipc;
|
||||
}
|
||||
|
||||
/// Sugar for message bus scoped to this extension's publisher ID.
|
||||
extension ClideExtensionContextMessages on ClideExtensionContext {
|
||||
void publish(String channel, Map<String, Object?> data) {
|
||||
messages.publish(id, channel, data);
|
||||
}
|
||||
|
||||
Stream<Message> subscribe({String? publisher, String? channel}) {
|
||||
return messages.subscribe(publisher: publisher, channel: channel);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sugar for i18n lookups scoped to this extension's namespace.
|
||||
extension ClideExtensionContextI18n on ClideExtensionContext {
|
||||
/// `ctx.t('welcome.title', placeholder: 'clide')` →
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
library;
|
||||
|
||||
export 'src/events/bus.dart';
|
||||
export 'src/events/message_bus.dart';
|
||||
export 'src/events/types.dart';
|
||||
export 'src/ipc/client.dart';
|
||||
export 'src/log.dart';
|
||||
@@ -46,3 +47,5 @@ export 'src/theme/palette.dart';
|
||||
export 'src/theme/resolver.dart';
|
||||
export 'src/theme/semantic.dart';
|
||||
export 'src/theme/tokens.dart';
|
||||
export 'src/tool_check.dart';
|
||||
export 'src/window_controls.dart';
|
||||
|
||||
@@ -2,8 +2,8 @@ import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/src/events/types.dart';
|
||||
|
||||
class EventBus {
|
||||
EventBus();
|
||||
class DaemonBus {
|
||||
DaemonBus();
|
||||
|
||||
final StreamController<ClideEventEnvelope> _controller =
|
||||
StreamController<ClideEventEnvelope>.broadcast();
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'dart:async';
|
||||
|
||||
class Message {
|
||||
Message({
|
||||
required this.publisher,
|
||||
required this.channel,
|
||||
required this.data,
|
||||
}) : timestamp = DateTime.now();
|
||||
|
||||
final String publisher;
|
||||
final String channel;
|
||||
final DateTime timestamp;
|
||||
final Map<String, Object?> data;
|
||||
|
||||
String get address => '$publisher/$channel';
|
||||
}
|
||||
|
||||
class MessageBus {
|
||||
final _controller = StreamController<Message>.broadcast();
|
||||
|
||||
void publish(String publisher, String channel, Map<String, Object?> data) {
|
||||
_controller.add(Message(publisher: publisher, channel: channel, data: data));
|
||||
}
|
||||
|
||||
Stream<Message> subscribe({String? publisher, String? channel}) {
|
||||
return _controller.stream.where((m) {
|
||||
if (publisher != null && m.publisher != publisher) return false;
|
||||
if (channel != null && m.channel != channel) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_controller.close();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:clide/kernel/src/commands/palette.dart';
|
||||
import 'package:clide/kernel/src/commands/registry.dart';
|
||||
import 'package:clide/kernel/src/dialog.dart';
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/kernel/src/events/types.dart';
|
||||
import 'package:clide/kernel/src/files.dart';
|
||||
import 'package:clide/kernel/src/focus.dart';
|
||||
@@ -30,6 +31,7 @@ class ExtensionManager extends ChangeNotifier {
|
||||
ExtensionManager({
|
||||
required this.log,
|
||||
required this.events,
|
||||
required this.messages,
|
||||
required this.settings,
|
||||
required this.theme,
|
||||
required this.i18n,
|
||||
@@ -52,7 +54,8 @@ class ExtensionManager extends ChangeNotifier {
|
||||
});
|
||||
|
||||
final Logger log;
|
||||
final EventBus events;
|
||||
final DaemonBus events;
|
||||
final MessageBus messages;
|
||||
final SettingsStore settings;
|
||||
final ThemeController theme;
|
||||
final I18n i18n;
|
||||
@@ -239,7 +242,9 @@ class _ExtensionContext implements ClideExtensionContext {
|
||||
@override
|
||||
Logger get log => manager.log;
|
||||
@override
|
||||
EventBus get events => manager.events;
|
||||
DaemonBus get events => manager.events;
|
||||
@override
|
||||
MessageBus get messages => manager.messages;
|
||||
@override
|
||||
SettingsStore get settings => manager.settings;
|
||||
@override
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:clide/kernel/src/commands/palette.dart';
|
||||
import 'package:clide/kernel/src/commands/registry.dart';
|
||||
import 'package:clide/kernel/src/dialog.dart';
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/kernel/src/extensions_manager.dart';
|
||||
import 'package:clide/kernel/src/files.dart';
|
||||
import 'package:clide/kernel/src/focus.dart';
|
||||
@@ -25,7 +26,9 @@ import 'package:clide/kernel/src/secrets.dart';
|
||||
import 'package:clide/kernel/src/settings.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/loader.dart';
|
||||
import 'package:clide/kernel/src/tool_check.dart';
|
||||
import 'package:clide/kernel/src/tray.dart';
|
||||
import 'package:clide/kernel/src/window_controls.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Aggregated kernel services. Feature code that runs outside a
|
||||
@@ -36,6 +39,7 @@ class KernelServices {
|
||||
required this.log,
|
||||
required this.settings,
|
||||
required this.events,
|
||||
required this.messages,
|
||||
required this.ipc,
|
||||
required this.theme,
|
||||
required this.i18n,
|
||||
@@ -55,11 +59,14 @@ class KernelServices {
|
||||
required this.focus,
|
||||
required this.project,
|
||||
required this.extensions,
|
||||
required this.window,
|
||||
required this.toolCheck,
|
||||
});
|
||||
|
||||
final Logger log;
|
||||
final SettingsStore settings;
|
||||
final EventBus events;
|
||||
final DaemonBus events;
|
||||
final MessageBus messages;
|
||||
final DaemonClient ipc;
|
||||
final ThemeController theme;
|
||||
final I18n i18n;
|
||||
@@ -79,6 +86,8 @@ class KernelServices {
|
||||
final FocusTracker focus;
|
||||
final ProjectManager project;
|
||||
final ExtensionManager extensions;
|
||||
final WindowControls window;
|
||||
final ToolCheck toolCheck;
|
||||
|
||||
static Future<KernelServices> boot({
|
||||
required Directory appDir,
|
||||
@@ -89,11 +98,12 @@ class KernelServices {
|
||||
Locale? initialLocale,
|
||||
List<Locale> availableLocales = const [Locale('en', 'US')],
|
||||
String? socketPath,
|
||||
DaemonClient Function(Logger, EventBus)? daemonClientFactory,
|
||||
DaemonClient Function(Logger, DaemonBus)? daemonClientFactory,
|
||||
bool autoStartDaemonClient = true,
|
||||
}) async {
|
||||
final log = Logger();
|
||||
final events = EventBus();
|
||||
final events = DaemonBus();
|
||||
final messages = MessageBus();
|
||||
|
||||
final settings = SettingsStore(appDir: appDir);
|
||||
await settings.load();
|
||||
@@ -124,6 +134,8 @@ class KernelServices {
|
||||
final os = OsBridge(log: log, events: events);
|
||||
final net = NetworkStatus();
|
||||
final focus = FocusTracker();
|
||||
final window = WindowControls();
|
||||
final toolCheck = ToolCheck();
|
||||
final project = ProjectManager(
|
||||
log: log,
|
||||
events: events,
|
||||
@@ -139,6 +151,7 @@ class KernelServices {
|
||||
final extensions = ExtensionManager(
|
||||
log: log,
|
||||
events: events,
|
||||
messages: messages,
|
||||
settings: settings,
|
||||
theme: theme,
|
||||
i18n: i18n,
|
||||
@@ -168,6 +181,7 @@ class KernelServices {
|
||||
log: log,
|
||||
settings: settings,
|
||||
events: events,
|
||||
messages: messages,
|
||||
ipc: ipc,
|
||||
theme: theme,
|
||||
i18n: i18n,
|
||||
@@ -187,6 +201,8 @@ class KernelServices {
|
||||
focus: focus,
|
||||
project: project,
|
||||
extensions: extensions,
|
||||
window: window,
|
||||
toolCheck: toolCheck,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -208,6 +224,7 @@ class KernelServices {
|
||||
project.dispose();
|
||||
extensions.dispose();
|
||||
await log.dispose();
|
||||
messages.dispose();
|
||||
await events.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ class FilesDropped extends ClideEvent {
|
||||
/// the service surface real.
|
||||
class FileServices {
|
||||
FileServices(this._events);
|
||||
final EventBus _events;
|
||||
final DaemonBus _events;
|
||||
|
||||
Future<List<String>> pickOpen({
|
||||
List<String> extensions = const [],
|
||||
|
||||
@@ -13,13 +13,13 @@ class DaemonClient extends ChangeNotifier {
|
||||
DaemonClient({
|
||||
required this.socketPath,
|
||||
required Logger log,
|
||||
required EventBus events,
|
||||
required DaemonBus events,
|
||||
}) : _log = log,
|
||||
_events = events;
|
||||
|
||||
final String socketPath;
|
||||
final Logger _log;
|
||||
final EventBus _events;
|
||||
final DaemonBus _events;
|
||||
|
||||
Socket? _socket;
|
||||
bool _connected = false;
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:clide/src/daemon/dispatcher.dart';
|
||||
class InProcessClient extends DaemonClient {
|
||||
InProcessClient({
|
||||
required Logger log,
|
||||
required EventBus events,
|
||||
required DaemonBus events,
|
||||
required this.dispatcher,
|
||||
}) : super(socketPath: '', log: log, events: events);
|
||||
|
||||
|
||||
@@ -16,12 +16,12 @@ class OsLifecycleEvent extends ClideEvent {
|
||||
}
|
||||
|
||||
class OsBridge {
|
||||
OsBridge({required Logger log, required EventBus events})
|
||||
OsBridge({required Logger log, required DaemonBus events})
|
||||
: _log = log,
|
||||
_events = events;
|
||||
|
||||
final Logger _log;
|
||||
final EventBus _events;
|
||||
final DaemonBus _events;
|
||||
|
||||
Future<bool> openURL(String url) async {
|
||||
final cmd = _openCommand();
|
||||
|
||||
@@ -12,7 +12,7 @@ class DragResizeHandle extends StatefulWidget {
|
||||
required this.arrangement,
|
||||
required this.slot,
|
||||
required this.axis,
|
||||
this.thickness = 4.0,
|
||||
this.thickness = DragResizeHandle.defaultThickness,
|
||||
});
|
||||
|
||||
final LayoutArrangement arrangement;
|
||||
@@ -20,6 +20,8 @@ class DragResizeHandle extends StatefulWidget {
|
||||
final Axis axis;
|
||||
final double thickness;
|
||||
|
||||
static const defaultThickness = 8.0;
|
||||
|
||||
@override
|
||||
State<DragResizeHandle> createState() => _DragResizeHandleState();
|
||||
}
|
||||
@@ -32,7 +34,7 @@ class _DragResizeHandleState extends State<DragResizeHandle> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final color = _hovered ? tokens.panelActiveBorder : tokens.panelBorder;
|
||||
final lineColor = _hovered ? tokens.panelActiveBorder : tokens.dividerColor;
|
||||
|
||||
return MouseRegion(
|
||||
cursor: widget.axis == Axis.horizontal
|
||||
@@ -47,7 +49,15 @@ class _DragResizeHandleState extends State<DragResizeHandle> {
|
||||
child: Container(
|
||||
width: widget.axis == Axis.horizontal ? widget.thickness : null,
|
||||
height: widget.axis == Axis.vertical ? widget.thickness : null,
|
||||
color: color,
|
||||
color: tokens.chromeBackground,
|
||||
child: Align(
|
||||
alignment: widget.slot == Slots.sidebar ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: widget.axis == Axis.horizontal ? 1 : null,
|
||||
height: widget.axis == Axis.vertical ? 1 : null,
|
||||
color: lineColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -62,9 +72,10 @@ class _DragResizeHandleState extends State<DragResizeHandle> {
|
||||
final start = _dragStartSize;
|
||||
final startPt = _dragStartPointer;
|
||||
if (start == null || startPt == null) return;
|
||||
final delta = widget.axis == Axis.horizontal
|
||||
final rawDelta = widget.axis == Axis.horizontal
|
||||
? e.position.dx - startPt.dx
|
||||
: e.position.dy - startPt.dy;
|
||||
final delta = widget.slot == Slots.contextPanel ? -rawDelta : rawDelta;
|
||||
widget.arrangement.setSize(widget.slot, start + delta);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ import 'package:clide/kernel/src/panels/slot_id.dart';
|
||||
/// default-layout extension share one source of truth.
|
||||
///
|
||||
/// Columns (px):
|
||||
/// sidebar 240 (drag 180–400)
|
||||
/// sidebar 400 (drag 180–400)
|
||||
/// center flex (workspace on top, statusbar below)
|
||||
/// context 280 (drag 220–420)
|
||||
/// context 420 (drag 220–420)
|
||||
/// statusbar 26 (fixed height strip)
|
||||
LayoutPresetContribution classicPreset() => const LayoutPresetContribution(
|
||||
id: 'builtin.default-layout.classic',
|
||||
@@ -17,7 +17,7 @@ LayoutPresetContribution classicPreset() => const LayoutPresetContribution(
|
||||
LayoutSlot(
|
||||
slot: Slots.sidebar,
|
||||
position: SlotPosition.left,
|
||||
defaultSize: 240,
|
||||
defaultSize: 400,
|
||||
minSize: 180,
|
||||
maxSize: 400,
|
||||
),
|
||||
@@ -28,7 +28,7 @@ LayoutPresetContribution classicPreset() => const LayoutPresetContribution(
|
||||
LayoutSlot(
|
||||
slot: Slots.contextPanel,
|
||||
position: SlotPosition.right,
|
||||
defaultSize: 280,
|
||||
defaultSize: 420,
|
||||
minSize: 220,
|
||||
maxSize: 420,
|
||||
),
|
||||
|
||||
@@ -23,6 +23,7 @@ class PanelRegistry extends ChangeNotifier {
|
||||
final Map<SlotId, SlotDefinition> _defs = {};
|
||||
final Map<SlotId, List<ContributionPoint>> _mounts = {};
|
||||
final Map<SlotId, String?> _activeTab = {};
|
||||
final Map<SlotId, List<String>> _order = {};
|
||||
|
||||
void registerSlot(SlotDefinition def) {
|
||||
_defs[def.id] = def;
|
||||
@@ -30,14 +31,16 @@ class PanelRegistry extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setTabOrder(SlotId slot, List<String> order) {
|
||||
_order[slot] = order;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void contribute(ContributionPoint point) {
|
||||
final slot = point.slot;
|
||||
if (slot == null) return; // non-slot contributions go elsewhere
|
||||
if (slot == null) return;
|
||||
final list = _mounts.putIfAbsent(slot, () => <ContributionPoint>[]);
|
||||
list.add(point);
|
||||
list.sort((a, b) => _priority(a).compareTo(_priority(b)));
|
||||
// first tab-contribution in the sidebar/workspace/context becomes the
|
||||
// default active tab until the user picks another
|
||||
if (_activeTab[slot] == null && point is TabContribution) {
|
||||
_activeTab[slot] = point.id;
|
||||
}
|
||||
@@ -66,8 +69,20 @@ class PanelRegistry extends ChangeNotifier {
|
||||
List<ContributionPoint> contributionsFor(SlotId id) =>
|
||||
List.unmodifiable(_mounts[id] ?? const []);
|
||||
|
||||
List<TabContribution> tabsFor(SlotId id) =>
|
||||
contributionsFor(id).whereType<TabContribution>().toList();
|
||||
List<TabContribution> tabsFor(SlotId id) {
|
||||
final tabs = contributionsFor(id).whereType<TabContribution>().toList();
|
||||
final order = _order[id];
|
||||
if (order == null || order.isEmpty) return tabs;
|
||||
tabs.sort((a, b) {
|
||||
final ai = order.indexOf(a.id);
|
||||
final bi = order.indexOf(b.id);
|
||||
if (ai < 0 && bi < 0) return 0;
|
||||
if (ai < 0) return 1;
|
||||
if (bi < 0) return 1;
|
||||
return ai.compareTo(bi);
|
||||
});
|
||||
return tabs;
|
||||
}
|
||||
|
||||
String? activeTabIn(SlotId id) => _activeTab[id];
|
||||
|
||||
@@ -76,12 +91,4 @@ class PanelRegistry extends ChangeNotifier {
|
||||
_activeTab[id] = tabId;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
int _priority(ContributionPoint p) {
|
||||
if (p is TabContribution) return p.priority;
|
||||
if (p is StatusItemContribution) return p.priority;
|
||||
if (p is ToolbarButtonContribution) return p.priority;
|
||||
if (p is TrayItemContribution) return p.priority;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,14 +45,14 @@ class RecentProject {
|
||||
class ProjectManager extends ChangeNotifier {
|
||||
ProjectManager({
|
||||
required Logger log,
|
||||
required EventBus events,
|
||||
required DaemonBus events,
|
||||
required SettingsStore settings,
|
||||
}) : _log = log,
|
||||
_events = events,
|
||||
_settings = settings;
|
||||
|
||||
final Logger _log;
|
||||
final EventBus _events;
|
||||
final DaemonBus _events;
|
||||
final SettingsStore _settings;
|
||||
|
||||
Directory? _current;
|
||||
|
||||
@@ -50,6 +50,9 @@ class ThemeResolver {
|
||||
globalBorder: surface[TokenKeys.globalBorder]!,
|
||||
globalFocus: surface[TokenKeys.globalFocus]!,
|
||||
globalTextMuted: surface[TokenKeys.globalTextMuted]!,
|
||||
chromeBackground: surface[TokenKeys.chromeBackground]!,
|
||||
chromeForeground: surface[TokenKeys.chromeForeground]!,
|
||||
chromeBorder: surface[TokenKeys.chromeBorder]!,
|
||||
panelBackground: surface[TokenKeys.panelBackground]!,
|
||||
panelBorder: surface[TokenKeys.panelBorder]!,
|
||||
panelActiveBorder: surface[TokenKeys.panelActiveBorder]!,
|
||||
@@ -196,6 +199,10 @@ const Map<String, List<String>> _defaultSurfaceMap = {
|
||||
TokenKeys.globalBorder: ['border', 'semantic.surface'],
|
||||
TokenKeys.globalFocus: ['accent', 'semantic.focus'],
|
||||
TokenKeys.globalTextMuted: ['textDim', 'semantic.text_muted'],
|
||||
// chrome — frame surfaces (hat bar, sidebar, status bar, spines)
|
||||
TokenKeys.chromeBackground: ['bgSunken', 'semantic.mainchrome'],
|
||||
TokenKeys.chromeForeground: ['textDim', 'semantic.text_muted'],
|
||||
TokenKeys.chromeBorder: ['border', 'semantic.surface'],
|
||||
// panel
|
||||
TokenKeys.panelBackground: ['bgSunken', 'semantic.mainchrome'],
|
||||
TokenKeys.panelBorder: ['border', 'semantic.surface'],
|
||||
|
||||
@@ -17,6 +17,10 @@ class SurfaceTokens {
|
||||
required this.globalBorder,
|
||||
required this.globalFocus,
|
||||
required this.globalTextMuted,
|
||||
// chrome (hat bar, sidebar, status bar, spines — frame surfaces)
|
||||
required this.chromeBackground,
|
||||
required this.chromeForeground,
|
||||
required this.chromeBorder,
|
||||
// panel
|
||||
required this.panelBackground,
|
||||
required this.panelBorder,
|
||||
@@ -94,6 +98,10 @@ class SurfaceTokens {
|
||||
final Color globalFocus;
|
||||
final Color globalTextMuted;
|
||||
|
||||
final Color chromeBackground;
|
||||
final Color chromeForeground;
|
||||
final Color chromeBorder;
|
||||
|
||||
final Color panelBackground;
|
||||
final Color panelBorder;
|
||||
final Color panelActiveBorder;
|
||||
@@ -179,6 +187,11 @@ abstract class TokenKeys {
|
||||
static const globalFocus = 'global.focus';
|
||||
static const globalTextMuted = 'global.textMuted';
|
||||
|
||||
// chrome
|
||||
static const chromeBackground = 'chrome.background';
|
||||
static const chromeForeground = 'chrome.foreground';
|
||||
static const chromeBorder = 'chrome.border';
|
||||
|
||||
// panel
|
||||
static const panelBackground = 'panel.background';
|
||||
static const panelBorder = 'panel.border';
|
||||
@@ -266,6 +279,9 @@ abstract class TokenKeys {
|
||||
globalBorder,
|
||||
globalFocus,
|
||||
globalTextMuted,
|
||||
chromeBackground,
|
||||
chromeForeground,
|
||||
chromeBorder,
|
||||
panelBackground,
|
||||
panelBorder,
|
||||
panelActiveBorder,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class ToolCheck extends ChangeNotifier {
|
||||
bool ptycOk = false;
|
||||
bool pqlOk = false;
|
||||
bool tmuxOk = false;
|
||||
bool gitOk = false;
|
||||
bool checked = false;
|
||||
|
||||
bool get allOk => ptycOk && pqlOk && tmuxOk && gitOk;
|
||||
|
||||
List<String> get errors => [
|
||||
if (!ptycOk) 'ptyc not found',
|
||||
if (!pqlOk) 'pql not found',
|
||||
if (!tmuxOk) 'tmux not found',
|
||||
if (!gitOk) 'git not found',
|
||||
];
|
||||
|
||||
Future<void> check() async {
|
||||
final cwd = Directory.current.path;
|
||||
ptycOk = File('$cwd/native/linux-x64/ptyc').existsSync() ||
|
||||
File('$cwd/ptyc/bin/ptyc').existsSync() ||
|
||||
await _which('ptyc');
|
||||
pqlOk = await _which('pql');
|
||||
tmuxOk = await _which('tmux');
|
||||
gitOk = await _which('git');
|
||||
checked = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
static Future<bool> _which(String name) async {
|
||||
try {
|
||||
final r = await Process.run('which', [name]);
|
||||
return r.exitCode == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
enum ChromeStyle { seam, prompt, inline }
|
||||
|
||||
enum ResizeEdge { topLeft, top, topRight, left, right, bottomLeft, bottom, bottomRight }
|
||||
|
||||
class WindowControls extends ChangeNotifier {
|
||||
static const _channel = MethodChannel('clide/window');
|
||||
|
||||
ChromeStyle _style = ChromeStyle.seam;
|
||||
ChromeStyle get style => _style;
|
||||
|
||||
void setStyle(ChromeStyle s) {
|
||||
if (_style == s) return;
|
||||
_style = s;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> startResize(ResizeEdge edge) async {
|
||||
try {
|
||||
await _channel.invokeMethod('startResize', edge.index);
|
||||
} on MissingPluginException {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startDrag() async {
|
||||
try {
|
||||
await _channel.invokeMethod('startDrag');
|
||||
} on MissingPluginException {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> minimize() async {
|
||||
try {
|
||||
await _channel.invokeMethod('minimize');
|
||||
} on MissingPluginException {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleMaximize() async {
|
||||
try {
|
||||
await _channel.invokeMethod('maximize');
|
||||
} on MissingPluginException {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
try {
|
||||
await _channel.invokeMethod('close');
|
||||
} on MissingPluginException {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> isMaximized() async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod<bool>('isMaximized');
|
||||
return result ?? false;
|
||||
} on MissingPluginException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/app.dart';
|
||||
import 'package:clide/builtin/canvas/canvas.dart';
|
||||
import 'package:clide/builtin/claude/claude.dart';
|
||||
@@ -79,34 +81,41 @@ Future<void> main() async {
|
||||
// Register every built-in. Tier 0 activates only the four that do
|
||||
// real work; the rest compile in as stubs so the extensions-ui can
|
||||
// list them when Tier 6 lands.
|
||||
// Registration order = default icon rail order (left to right).
|
||||
// User can override via project.layout.sidebar.order in settings.
|
||||
services.extensions
|
||||
..register(DefaultLayoutExtension())
|
||||
..register(WelcomeExtension())
|
||||
..register(IpcStatusExtension())
|
||||
..register(ThemePickerExtension())
|
||||
..register(ClaudeExtension())
|
||||
..register(TerminalExtension())
|
||||
// Sidebar: tickets first, then decisions, files, git, pql, problems
|
||||
..register(TicketsExtension())
|
||||
..register(DecisionsExtension())
|
||||
..register(FilesExtension())
|
||||
..register(EditorExtension())
|
||||
..register(GrammarsCoreExtension())
|
||||
..register(MarkdownExtension())
|
||||
..register(DiffExtension())
|
||||
..register(GitExtension())
|
||||
..register(PqlExtension())
|
||||
..register(TodosExtension())
|
||||
..register(ProblemsExtension())
|
||||
// Workspace
|
||||
..register(ClaudeExtension())
|
||||
..register(TerminalExtension())
|
||||
..register(EditorExtension())
|
||||
..register(DiffExtension())
|
||||
// Format engines + stubs
|
||||
..register(GrammarsCoreExtension())
|
||||
..register(MarkdownExtension())
|
||||
..register(TodosExtension())
|
||||
..register(CanvasExtension())
|
||||
..register(GraphExtension())
|
||||
// UI extensions
|
||||
..register(SettingsUiExtension())
|
||||
..register(ExtensionsUiExtension())
|
||||
..register(KeybindingsUiExtension())
|
||||
..register(DecisionsExtension())
|
||||
..register(TicketsExtension())
|
||||
..register(ClaudeControlExtension());
|
||||
|
||||
await services.extensions.activateAll();
|
||||
|
||||
if (!kIsWeb) {
|
||||
unawaited(services.toolCheck.check());
|
||||
await services.project.loadRecents();
|
||||
var opened = await services.project.openLast();
|
||||
if (!opened) {
|
||||
@@ -154,6 +163,7 @@ Future<List<ThemeDefinition>> _loadBundledThemes() async {
|
||||
/// catalogs load lazily on activate in later tiers.
|
||||
String _resolvePtyc(String repoRoot) {
|
||||
final candidates = [
|
||||
'$repoRoot/native/linux-x64/ptyc',
|
||||
'$repoRoot/ptyc/bin/ptyc',
|
||||
'${Platform.environment['HOME']}/.local/bin/ptyc',
|
||||
'ptyc',
|
||||
@@ -166,7 +176,7 @@ String _resolvePtyc(String repoRoot) {
|
||||
|
||||
class _BusEventSink implements DaemonEventSink {
|
||||
_BusEventSink(this._bus);
|
||||
final EventBus _bus;
|
||||
final DaemonBus _bus;
|
||||
|
||||
@override
|
||||
void emit(IpcEvent event) {
|
||||
|
||||
@@ -125,6 +125,19 @@ void registerPqlCommands(DaemonDispatcher d, PqlClient pql) {
|
||||
}
|
||||
});
|
||||
|
||||
d.register('pql.decisions.read', (req) async {
|
||||
final id = req.args['id'] as String?;
|
||||
if (id == null || id.isEmpty) {
|
||||
return _userError(req.id, 'pql.decisions.read requires an id');
|
||||
}
|
||||
try {
|
||||
final result = await pql.decisionRead(id);
|
||||
return IpcResponse.ok(id: req.id, data: result);
|
||||
} on PqlException catch (e) {
|
||||
return _pqlError(req.id, e);
|
||||
}
|
||||
});
|
||||
|
||||
d.register('pql.decisions.show', (req) async {
|
||||
final id = req.args['id'] as String?;
|
||||
if (id == null || id.isEmpty) {
|
||||
|
||||