Compare commits
@@ -0,0 +1,193 @@
|
|||||||
|
---
|
||||||
|
name: pql
|
||||||
|
description: >
|
||||||
|
Query and plan against a markdown vault via the pql CLI. Two surfaces:
|
||||||
|
(1) structural queries — frontmatter, wikilinks, tags, headings, Bases,
|
||||||
|
DSL — use when the user asks about vault contents ("which notes…", "find
|
||||||
|
where…", "what tags", "who links to X", "run a Base", "query the vault");
|
||||||
|
(2) planning — decision records, tickets, project status — use when the
|
||||||
|
user asks about decisions, tickets, work items, or project planning
|
||||||
|
("sync decisions", "create a ticket", "what's the plan status", "show
|
||||||
|
D-5", "board"). Requires `pql` on PATH. JSON on stdout; exit 2 = zero
|
||||||
|
matches (not an error).
|
||||||
|
---
|
||||||
|
|
||||||
|
# pql — vault queries + project planning
|
||||||
|
|
||||||
|
`pql` indexes a vault into SQLite and exposes structural queries plus a
|
||||||
|
planning layer for decision records and tickets. One binary, two surfaces.
|
||||||
|
|
||||||
|
## Precondition
|
||||||
|
|
||||||
|
```bash
|
||||||
|
command -v pql
|
||||||
|
```
|
||||||
|
|
||||||
|
If absent, tell the user to install from
|
||||||
|
https://github.com/postmeridiem/pql/releases/latest. Don't install it
|
||||||
|
yourself. Don't fall back to grep unless the user explicitly asks.
|
||||||
|
|
||||||
|
## First touch: learn the vault
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pql schema
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns one row per frontmatter key with observed types and file counts.
|
||||||
|
Run once per session before writing queries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Surface 1: Vault queries
|
||||||
|
|
||||||
|
### Subcommands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `pql files [glob]` | List indexed files; optional glob filter |
|
||||||
|
| `pql tags [--sort count]` | Distinct tags with counts |
|
||||||
|
| `pql backlinks <path>` | Files linking TO a path |
|
||||||
|
| `pql outlinks <path>` | Links FROM a file |
|
||||||
|
| `pql meta <path>` | Frontmatter + tags + outlinks + headings for one file |
|
||||||
|
| `pql schema` | Typed frontmatter schema |
|
||||||
|
| `pql base <name>` | Execute an Obsidian .base file |
|
||||||
|
| `pql shell` | Interactive REPL (indexes once, then query per line) |
|
||||||
|
| `pql query "<DSL>"` | SQL-derived DSL for complex queries |
|
||||||
|
| `pql doctor` | Resolved vault/config/DB/index state |
|
||||||
|
|
||||||
|
### DSL examples
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT name, fm.date WHERE fm.type = 'meeting' ORDER BY fm.date DESC LIMIT 10
|
||||||
|
SELECT path WHERE 'project' IN tags ORDER BY path
|
||||||
|
SELECT name, fm.prior_job WHERE fm.type = 'council-member' ORDER BY name
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--file q.pql` or `--stdin` for long queries. Don't interpolate vault
|
||||||
|
content into the command line.
|
||||||
|
|
||||||
|
### Query cookbook
|
||||||
|
|
||||||
|
- **Files in folder** → `pql files 'sessions/*'`
|
||||||
|
- **Top tags** → `pql tags --sort count --limit 20`
|
||||||
|
- **What links to X?** → `pql backlinks members/vaasa/persona.md`
|
||||||
|
- **Date range** → `pql query "SELECT name, fm.date WHERE fm.date BETWEEN '2024-01-01' AND '2024-12-31'"`
|
||||||
|
- **Run a Base** → `pql base council-sessions`
|
||||||
|
- **Inspect one file** → `pql meta members/vaasa/persona.md --pretty`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Surface 2: Planning (decisions + tickets)
|
||||||
|
|
||||||
|
Planning state lives in `<vault>/.pql/pql.db` (user-authored state, not a
|
||||||
|
cache). Decision records come from `decisions/*.md`; tickets are
|
||||||
|
SQLite-native.
|
||||||
|
|
||||||
|
### Decision subcommands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `pql decisions sync` | Parse decisions/*.md → upsert into pql.db |
|
||||||
|
| `pql decisions validate` | Dry-run parse; exits non-zero on malformed records |
|
||||||
|
| `pql decisions claim <D\|Q\|R> <domain> "title"` | Print next available ID |
|
||||||
|
| `pql decisions list [--type X] [--domain X] [--status X]` | List decisions |
|
||||||
|
| `pql decisions show <id> [--with-refs] [--with-tickets]` | Show with joins |
|
||||||
|
| `pql decisions coverage` | Confirmed decisions without tickets |
|
||||||
|
| `pql decisions refs <id>` | Cross-references involving a decision |
|
||||||
|
|
||||||
|
Always `pql decisions sync` before querying if decisions/*.md may have changed.
|
||||||
|
|
||||||
|
### Ticket subcommands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `pql ticket new <type> "title" [--decision D-NNN] [--priority P]` | Create (emits T-NNN) |
|
||||||
|
| `pql ticket list [--status S] [--team T] [--assigned A] [--label L]` | List with filters |
|
||||||
|
| `pql ticket show <id> [--with-context] [--with-blockers]` | Show with joins (context = ancestors + decisions + children) |
|
||||||
|
| `pql ticket status <id[,id,...]> <new-status>` | Transition (batch via comma-separated IDs) |
|
||||||
|
| `pql ticket assign <id> <agent>` | Set assignee |
|
||||||
|
| `pql ticket block <id> --by <other>` | Add blocker |
|
||||||
|
| `pql ticket unblock <id> --from <other>` | Remove blocker |
|
||||||
|
| `pql ticket team <id> <team>` | Set team |
|
||||||
|
| `pql ticket label <id> add\|rm <label>` | Manage labels |
|
||||||
|
| `pql ticket board [--team T]` | Kanban board view |
|
||||||
|
|
||||||
|
Ticket types: initiative, epic, story, task, bug.
|
||||||
|
Status flow: backlog → ready → in_progress → review → done (also cancelled).
|
||||||
|
|
||||||
|
### Plan subcommands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `pql plan status` | Dashboard: decision counts, open Qs, ticket summary, coverage gaps |
|
||||||
|
| `pql plan export [--to FILE]` | Snapshot planning state to JSON (default: `pql-plan.json`) |
|
||||||
|
| `pql plan import [--from FILE]` | Restore planning state from a JSON snapshot |
|
||||||
|
|
||||||
|
### Versioning planning state
|
||||||
|
|
||||||
|
Planning state lives in `pql.db` (gitignored). To version it in git,
|
||||||
|
use `pql plan export` to write a committed JSON snapshot. pql does NOT
|
||||||
|
do this automatically — the user decides when and how to trigger it:
|
||||||
|
|
||||||
|
- Pre-push hook: `.githooks/pre-push` calls `pql plan export && git add pql-plan.json`
|
||||||
|
- Sprint close: a skill or script exports + commits on milestone
|
||||||
|
- Manual: run `pql plan export` before committing when state changed
|
||||||
|
|
||||||
|
On a fresh clone, `pql plan import` restores from the snapshot.
|
||||||
|
|
||||||
|
### Planning cookbook
|
||||||
|
|
||||||
|
- **Sync and list confirmed** → `pql decisions sync && pql decisions list --type confirmed`
|
||||||
|
- **Show with refs** → `pql decisions show D-5 --with-refs --pretty`
|
||||||
|
- **Create ticket** → `pql ticket new task "implement X" --decision D-5`
|
||||||
|
- **Batch close** → `pql ticket status T-1,T-2,T-3 done`
|
||||||
|
- **Coverage gaps** → `pql decisions coverage`
|
||||||
|
- **Dashboard** → `pql plan status --pretty`
|
||||||
|
- **Snapshot for git** → `pql plan export`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output contract (both surfaces)
|
||||||
|
|
||||||
|
- **stdout:** JSON array (default); `--jsonl` for one object/line; `--pretty`; `--limit N`.
|
||||||
|
- **stderr:** JSON diagnostics `{"level":"…","code":"pql.<phase>.<kind>","msg":"…"}`.
|
||||||
|
- **Exit codes:**
|
||||||
|
- `0` — success, ≥1 result
|
||||||
|
- `2` — zero matches (not an error — say "no matches", not "failed")
|
||||||
|
- `64` — bad flag
|
||||||
|
- `65` — parse/compile error (pass stderr back)
|
||||||
|
- `66` — vault/config not found
|
||||||
|
- `69` — unavailable
|
||||||
|
- `70` — internal error
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- Don't pipe to `jq` for simple projections — use `--limit`, `--pretty`, `--jsonl`.
|
||||||
|
- Don't chain `pql files` + `pql meta` — one `pql query` with WHERE.
|
||||||
|
- Don't parse errors — pass stderr diagnostics back directly.
|
||||||
|
- Don't forget `pql decisions sync` before querying decisions.
|
||||||
|
- Don't try to install or upgrade pql — instruct the user if missing.
|
||||||
|
|
||||||
|
## When NOT to use
|
||||||
|
|
||||||
|
- **Body text search** → `grep`/`rg`.
|
||||||
|
- **Reading file contents** → `Read` tool.
|
||||||
|
- **Code structure** → tree-sitter / LSP.
|
||||||
|
- **Modifying vault files** → `Write`/`Edit`. pql doesn't write to vault content.
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
The consuming project's `.claude/settings.json` should allow:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": ["Bash(pql)", "Bash(pql *)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating the skill
|
||||||
|
|
||||||
|
`pql skill status` reports drift. `pql skill install` writes/updates;
|
||||||
|
`--force` overrides hand-edits. `pql doctor` also surfaces skill state.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
name: testmode
|
||||||
|
description: >
|
||||||
|
Run and interpret the ClideTestApp platform integration harness.
|
||||||
|
Use after modifying toolchain resolution, IPC dispatch, extension
|
||||||
|
lifecycle, theme loading, or native platform code. Also use when
|
||||||
|
the user says "run testmode", "test the app", "smoke test", or
|
||||||
|
"verify the build". Triggers on changes to: lib/kernel/src/toolchain.dart,
|
||||||
|
lib/src/daemon/dispatcher.dart, lib/src/ipc/, lib/extension/,
|
||||||
|
lib/kernel/src/theme/, lib/builtin/*/src/extension.dart,
|
||||||
|
linux/CMakeLists.txt, macos/, Makefile (run targets), lib/main.dart.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testmode harness
|
||||||
|
|
||||||
|
`ClideTestApp` is a lightweight Flutter app that boots the real platform
|
||||||
|
layer, runs structured tests, prints results, and exits. It catches
|
||||||
|
regressions that unit tests cannot see: missing binaries, broken
|
||||||
|
subprocess wiring, IPC dispatch failures, extension activation order,
|
||||||
|
and theme parse errors.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run-testmode # all categories, 60s timeout
|
||||||
|
make run-testmode TESTMODE_CATEGORY=toolchain
|
||||||
|
make run-testmode TESTMODE_CATEGORY=ipc
|
||||||
|
make run-testmode TESTMODE_CATEGORY=extensions
|
||||||
|
make run-testmode TESTMODE_TIMEOUT=120 # longer timeout for slow builds
|
||||||
|
```
|
||||||
|
|
||||||
|
Results go to stdout **and** `/tmp/clide-testmode.log`. The last line
|
||||||
|
of testmode output is machine-readable:
|
||||||
|
|
||||||
|
```
|
||||||
|
[testmode:json] {"passed":33,"failed":0,"total":33,"failures":[]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit code is non-zero when any test fails. The Makefile verifies
|
||||||
|
`"failed":0` in the log via `grep -q`.
|
||||||
|
|
||||||
|
## When to run which category
|
||||||
|
|
||||||
|
| Changed area | Category | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Toolchain, PATH, dugite, ptyc, shell | `toolchain` | Binary resolution + exec |
|
||||||
|
| IPC envelope, dispatcher, schema | `ipc` | Round-trip + error contract |
|
||||||
|
| Extension manifest, activate, contributions | `extensions` | Register + activate lifecycle |
|
||||||
|
| Theme YAML, loader, palette | `extensions` | Theme parse is in this category |
|
||||||
|
| Platform config (CMakeLists, pbxproj, Makefile) | `all` | Full rebuild validates everything |
|
||||||
|
| Any doubt | `all` | ~30s, cheap insurance |
|
||||||
|
|
||||||
|
## Interpreting output
|
||||||
|
|
||||||
|
- `[testmode] exec | ... | OK` — subprocess ran, exit 0 or 1.
|
||||||
|
- `[testmode] PASS | ...` — assertion passed (IPC / extension tests).
|
||||||
|
- `[testmode] FAIL | ...` — assertion failed. The output column says why.
|
||||||
|
- `[testmode] exec | ... | EXCEPTION` — binary not found or not executable.
|
||||||
|
- `[testmode] exec | ... | TIMEOUT` — subprocess hung (5s limit).
|
||||||
|
|
||||||
|
If the app never prints `[testmode]` lines, the testmode gate in
|
||||||
|
`main.dart` didn't fire — check that `CLIDE_TESTMODE` is set to a
|
||||||
|
non-empty string (not a bare bool).
|
||||||
|
|
||||||
|
## Adding tests
|
||||||
|
|
||||||
|
All test logic lives in `lib/test_app.dart`. Pattern:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// In the appropriate _run*Tests method:
|
||||||
|
await _testExec('label', binary, ['args'], workDir);
|
||||||
|
// or
|
||||||
|
_addResult('label', boolCondition, 'detail string');
|
||||||
|
```
|
||||||
|
|
||||||
|
New categories: add a `runFoo` bool from the `category` string,
|
||||||
|
gate a new `_runFooTests` method, and add the category name to
|
||||||
|
the Makefile help comment and this skill's table above.
|
||||||
|
|
||||||
|
Extension tests must include transitive dependencies — e.g.
|
||||||
|
`GitExtension` requires `DiffExtension` to activate first.
|
||||||
|
|
||||||
|
## Source files
|
||||||
|
|
||||||
|
- `lib/test_app.dart` — all test logic and UI
|
||||||
|
- `lib/main.dart:54` — testmode gate (`String.fromEnvironment`)
|
||||||
|
- `Makefile:42–56` — `run-testmode` target with category variable
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
---
|
||||||
|
name: theme-ui
|
||||||
|
description: >-
|
||||||
|
Token selection guide for clide UI development. Use when building or
|
||||||
|
modifying widgets, panels, pane chrome, status indicators, icons, 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,
|
||||||
|
adding or looking up Phosphor icons, icon codepoints.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
## Icons — Phosphor Icons
|
||||||
|
|
||||||
|
The app bundles Phosphor Icons (v2.0.8, MIT) as TTF fonts at
|
||||||
|
`assets/fonts/phosphor/` (regular, bold, fill weights).
|
||||||
|
|
||||||
|
**Codepoint reference:** `assets/fonts/phosphor/codepoints.csv` —
|
||||||
|
full mapping of all 1512 icon codepoints to kebab-case and PascalCase
|
||||||
|
names. Read this file to look up any icon by name or codepoint.
|
||||||
|
|
||||||
|
**Adding an icon:** find the codepoint in `codepoints.csv`, then add
|
||||||
|
a `static const` entry to `PhosphorIcons` in
|
||||||
|
`lib/widgets/src/icons/phosphor.dart`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
static const arrowClockwise = PhosphorIconPainter(0xe036);
|
||||||
|
```
|
||||||
|
|
||||||
|
Only add icons we actually use — don't bulk-import the full set.
|
||||||
|
|
||||||
|
**Using an icon:** `ClideIcon(PhosphorIcons.arrowClockwise, size: 13)`
|
||||||
|
or as a `TabContribution` icon field: `icon: PhosphorIcons.lightbulb`.
|
||||||
|
|
||||||
|
**Bold weight:** pass `family: 'Phosphor-Bold'` to `PhosphorIconPainter`.
|
||||||
|
Fill weight: `family: 'Phosphor-Fill'`.
|
||||||
|
|
||||||
|
## 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`
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
# -- macOS sandbox (machine-specific paths; template is committed) ------
|
||||||
|
/macos/Runner/DebugProfile.entitlements
|
||||||
|
|
||||||
# -- Flutter / Dart (single package at repo root) -----------------------
|
# -- Flutter / Dart (single package at repo root) -----------------------
|
||||||
/.dart_tool/
|
/.dart_tool/
|
||||||
/.packages
|
/.packages
|
||||||
@@ -27,6 +30,9 @@ tools/ui/.serve.pid
|
|||||||
/ptyc/bin/
|
/ptyc/bin/
|
||||||
/ptyc/*.o
|
/ptyc/*.o
|
||||||
|
|
||||||
|
# -- dugite-native (bundled git, downloaded at build time) ---------------
|
||||||
|
/native/dugite/
|
||||||
|
|
||||||
# -- Test, coverage, profile output ------------------------------------
|
# -- Test, coverage, profile output ------------------------------------
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
@@ -41,7 +47,8 @@ coverage.*
|
|||||||
test/goldens/failures/
|
test/goldens/failures/
|
||||||
|
|
||||||
# -- pql per-repo state (index + planning DB, rebuilt from markdown) ----
|
# -- pql per-repo state (index + planning DB, rebuilt from markdown) ----
|
||||||
/.pql/
|
/.pql/*
|
||||||
|
!/.pql/pql-plan.json
|
||||||
|
|
||||||
# -- SQLite index files (defensive; should never land at repo root) ----
|
# -- SQLite index files (defensive; should never land at repo root) ----
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|||||||
@@ -16,54 +16,180 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.0.0] — 2026-05-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- PTY FFI constants now platform-dispatched: `TIOCSWINSZ` (`0x80087467`
|
||||||
|
macOS / `0x5414` Linux), `O_NONBLOCK` (`0x0004` / `0x0800`), and
|
||||||
|
`MsghdrDarwin` struct with correct 4-byte field widths for macOS
|
||||||
|
`recvmsg()`.
|
||||||
|
|
||||||
|
- App settings directory uses `~/Library/Application Support/clide` on
|
||||||
|
macOS instead of `~/.config/clide`.
|
||||||
|
|
||||||
|
- Removed hardcoded `TERMINFO=/usr/share/terminfo` from pane spawn
|
||||||
|
environment — let the system resolve terminfo per platform.
|
||||||
|
|
||||||
|
- Panel `tabsFor()` now sorts by contribution priority when no user
|
||||||
|
order is set.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Canonical upstream moved from Gitea to GitHub
|
||||||
|
(`github.com/postmeridiem/clide`).
|
||||||
|
|
||||||
|
- README rewritten to reflect current single-process Flutter
|
||||||
|
architecture, built-in extensions, and build commands.
|
||||||
|
|
||||||
|
- Renamed desktop binary from `clide_app` to `clide` (Linux + macOS).
|
||||||
|
|
||||||
|
- macOS app icon uses the black-circle variant matching the Linux
|
||||||
|
desktop icon.
|
||||||
|
|
||||||
|
- ClideTestApp expanded to three test categories (toolchain, ipc,
|
||||||
|
extensions) with JSON summary, non-zero exit on failure, and
|
||||||
|
`make run-testmode TESTMODE_CATEGORY=<cat>` for selective runs.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- `make install` / `make uninstall` — builds the release bundle and
|
||||||
|
installs it to `~/.local/` with XDG desktop entry, icon registration
|
||||||
|
at seven sizes, and icon cache refresh. macOS installs to
|
||||||
|
`~/Applications/clide.app`.
|
||||||
|
|
||||||
|
- Backend isolate — all subprocess and file I/O runs in a dedicated
|
||||||
|
isolate, keeping the merged UI/platform thread on macOS free for
|
||||||
|
rendering. Communicates via SendPort using the existing IPC protocol.
|
||||||
|
Two-phase boot: resolve toolchain on spawn, initialize services on
|
||||||
|
project open. Scheduler ticker only runs while a project is active.
|
||||||
|
|
||||||
|
- Toolchain — centralized binary resolution replacing five ad-hoc
|
||||||
|
mechanisms. Resolves git, pql, tmux, ptyc, shell once at boot via
|
||||||
|
background isolate. Status bar reads `toolchain.missing` directly.
|
||||||
|
|
||||||
|
- GitClient — typed Dart API wrapping all git operations. Every
|
||||||
|
subprocess call goes through `_run()` with toolchain-resolved path
|
||||||
|
and environment. Replaces scattered `Process.run('git', ...)` calls.
|
||||||
|
|
||||||
|
- Native directory picker — macOS NSOpenPanel via method channel in
|
||||||
|
AppDelegate, GTK file chooser on Linux. Falls back to text-input
|
||||||
|
dialog on web. Shows "No git repo found" dialog on invalid selection.
|
||||||
|
|
||||||
|
- macOS desktop target — OS-detecting Makefile (`make run` works on
|
||||||
|
macOS/Linux/Windows), 1280x720 default window, squared app icons,
|
||||||
|
sandbox entitlements with SBPL exceptions, `_DARWIN_C_SOURCE` for
|
||||||
|
ptyc compilation, native traffic dots skipped (macOS titlebar owns
|
||||||
|
them), expanded PATH for Homebrew and `~/.local/bin` on GUI apps.
|
||||||
|
|
||||||
|
- 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
|
- 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
|
codifying layout invariants, chrome budget, editor mode, context
|
||||||
auto-behavior, collapse spine, focus mode, state persistence, and
|
auto-behavior, collapse spine, focus mode, state persistence, and
|
||||||
the canonical keyboard map.
|
the canonical keyboard map.
|
||||||
|
|
||||||
- Panel collapse spine — collapsed side panels render as a 12px
|
- Panel collapse spine — collapsed side panels render as a 12px
|
||||||
vertical spine with rotated label, hover highlight, and badge dot
|
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;
|
- Focus mode — `Ctrl+.` takes the active panel full-window;
|
||||||
`Escape` restores the prior layout with collapse states and
|
`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
|
- Canonical keyboard shortcuts from the interaction model: collapse
|
||||||
toggles (`Ctrl+Shift+1/3`), panel focus (`Ctrl+1/2/3`), sidebar
|
toggles (`Ctrl+Shift+1/3`), panel focus (`Ctrl+1/2/3`), sidebar
|
||||||
section switching (`Alt+1–5`), focus mode, and `Escape` dismiss
|
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
|
- 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
|
- Editor-above-Claude mode — `Ctrl+E` opens the editor as a split
|
||||||
above Claude in the middle column with a draggable divider;
|
above Claude in the middle column with a draggable divider;
|
||||||
`Ctrl+W` or `Escape` closes it. Prompt bar Y stays fixed
|
`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
|
- Layout state persists across sessions — collapse state, sidebar
|
||||||
and context panel sizes, active sections, and editor split ratio
|
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
|
- Phosphor Icons font (v2.0.8, MIT) — regular, bold, and fill
|
||||||
weights. Replaces hand-painted CustomPaint icons in sidebar and
|
weights. Replaces hand-painted CustomPaint icons in sidebar and
|
||||||
context panel icon rails.
|
context panel icon rails.
|
||||||
|
|
||||||
- Decisions panel in sidebar — lists confirmed D-records from
|
- 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`
|
- 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
|
- 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`.
|
||||||
|
|
||||||
|
- `files.read` IPC command for reading file content by path.
|
||||||
|
|
||||||
|
- pql sidebar restructured: Search tab is the default left tab with
|
||||||
|
ranked text search (debounced, scored results with score bar) and
|
||||||
|
a DSL toggle for raw PQL query mode; Markdown tab (filtered to
|
||||||
|
`.md` files) on the right. Clicking a result opens it in the
|
||||||
|
context panel markdown viewer with bidirectional focus highlighting.
|
||||||
|
|
||||||
|
- Kernel scheduler service with tiered timers (1min, 10min, 15min,
|
||||||
|
1hr, midnight) running on a background isolate. Emits `SchedulerTick`
|
||||||
|
events on the `DaemonBus`. Extensions subscribe by tier (T-61).
|
||||||
|
|
||||||
|
- Auto-refresh for sidebar panels: decisions refresh on file changes
|
||||||
|
to `decisions/*.md` and on 1-minute scheduler tick; tickets refresh
|
||||||
|
on 1-minute tick and on status change events (T-62).
|
||||||
|
|
||||||
|
- Manual refresh button (arrowClockwise icon) in decisions, tickets,
|
||||||
|
and pql markdown panels (T-63).
|
||||||
|
|
||||||
|
- `ClideAccordion` shared widget — extracted from tickets and
|
||||||
|
decisions views. Supports optional `leading` widget (color dot).
|
||||||
|
Pin/focus accordion logic: manually toggled sections are pinned;
|
||||||
|
the focused item's section auto-opens; unpinned sections without
|
||||||
|
focus auto-collapse.
|
||||||
|
|
||||||
|
- Ticket status buttons wired to `pql ticket status` — clicking a
|
||||||
|
status in the detail view transitions the ticket, refreshes the
|
||||||
|
sidebar list, and scrolls the ticket into its new section.
|
||||||
|
|
||||||
|
- `pql.tickets.status` IPC command accepting a list of IDs for
|
||||||
|
batch status transitions.
|
||||||
|
|
||||||
|
- Ticket sidebar sections split into individual statuses: IN
|
||||||
|
PROGRESS, REVIEW, READY, BACKLOG, DONE, CANCELLED (was four
|
||||||
|
coarse groups).
|
||||||
|
|
||||||
|
- Phosphor Icons codepoint reference CSV at
|
||||||
|
`assets/fonts/phosphor/codepoints.csv` — full mapping of all
|
||||||
|
1512 icon glyphs to kebab-case and PascalCase names.
|
||||||
|
|
||||||
- Graph view in context panel — lists files with inbound/outbound
|
- 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
|
- Welcome screen redesigned as full-screen overlay with two-column
|
||||||
layout: START actions (open folder, clone, Claude session) with
|
layout: START actions (open folder, clone, Claude session) with
|
||||||
@@ -78,13 +204,42 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Workspace renders Claude as the always-visible primary surface
|
- 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.
|
split overlay, not a tab.
|
||||||
|
|
||||||
- Syntax highlighting via tree-sitter (dart:ffi to vendored
|
- Syntax highlighting via tree-sitter (dart:ffi to vendored
|
||||||
libtree-sitter.so with embedded wasmtime). 48 grammar WASM files,
|
libtree-sitter.so with embedded wasmtime). 48 grammar WASM files,
|
||||||
48 highlight queries. Colors map to theme syntax tokens.
|
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). Context panel max raised
|
||||||
|
to 1000px.
|
||||||
|
|
||||||
|
- Ticket detail loading uses `pql ticket show --with-context` for
|
||||||
|
a single-call fetch of ancestors, decisions, and children.
|
||||||
|
Replaces N+1 parent-chain walk. `--with-decision` and
|
||||||
|
`--with-children` flags consolidated into `--with-context`.
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
- TreeSitterService is now a shared singleton — eliminates native
|
||||||
|
double-free crashes from multiple WASM engine instances.
|
||||||
|
|
||||||
|
- Code block syntax highlighting fixes overlapping tree-sitter spans
|
||||||
|
that caused duplicated text (e.g. `makemake` instead of `make`).
|
||||||
|
|
||||||
|
- 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
|
- POLICY.md — project-wide rules for runtime behavior, dependency
|
||||||
vetting, vendored binary management, telemetry, and licensing.
|
vetting, vendored binary management, telemetry, and licensing.
|
||||||
|
|
||||||
@@ -92,7 +247,7 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
- Line length set to 160 across .editorconfig and dart formatter.
|
- 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
|
are frame infrastructure only; content extensions are bundled but
|
||||||
architecturally removable.
|
architecturally removable.
|
||||||
|
|
||||||
@@ -114,7 +269,7 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
`.claude/settings.json`.
|
`.claude/settings.json`.
|
||||||
|
|
||||||
- pql daemon subsystem (`lib/src/pql/`). `PqlClient` wraps the pql
|
- 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
|
| tags | schema | query | doctor | decisions.sync | decisions.list
|
||||||
| decisions.show | decisions.coverage | tickets.list | tickets.show
|
| decisions.show | decisions.coverage | tickets.list | tickets.show
|
||||||
| tickets.board | plan.status`. 15 new core tests.
|
| tickets.board | plan.status`. 15 new core tests.
|
||||||
@@ -164,7 +319,7 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
echo-suppression guard avoids clobbering the caret when the
|
echo-suppression guard avoids clobbering the caret when the
|
||||||
daemon's authoritative edit echo comes back. Text surface is
|
daemon's authoritative edit echo comes back. Text surface is
|
||||||
Flutter's `EditableText` primitive — no `TextField` / Material —
|
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
|
JetBrainsMono via the shared `clideMonoFamily` constants, cursor
|
||||||
+ selection colours bind to the theme.
|
+ selection colours bind to the theme.
|
||||||
|
|
||||||
@@ -197,7 +352,7 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
dispatcher round-trips.
|
dispatcher round-trips.
|
||||||
|
|
||||||
- `builtin.claude` — Tier-1 stub upgraded to the real Claude pane per
|
- `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
|
spawns `tmux new-session -A -s clide-claude-<hash> -- claude` via
|
||||||
IPC `pane.spawn`, with `<hash>` derived from the git root path so
|
IPC `pane.spawn`, with `<hash>` derived from the git root path so
|
||||||
reopening the app re-attaches to the running conversation. Primary
|
reopening the app re-attaches to the running conversation. Primary
|
||||||
@@ -205,8 +360,8 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
Command `claude.new-secondary` is registered for the palette wiring
|
Command `claude.new-secondary` is registered for the palette wiring
|
||||||
that's coming next. If tmux isn't on PATH, falls back to spawning
|
that's coming next. If tmux isn't on PATH, falls back to spawning
|
||||||
`claude` directly and surfaces "no-tmux · fresh every launch" in
|
`claude` directly and surfaces "no-tmux · fresh every launch" in
|
||||||
the header subtitle. Accompanied by D-041 in
|
the header subtitle. Accompanied by D-41 in
|
||||||
[`decisions/architecture.md`](decisions/architecture.md#d-041-claude-panes-one-primary-per-repo-tmux-backed).
|
[`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
|
- `builtin.files` — workspace filesystem panel in the sidebar. Lazy
|
||||||
tree rooted at the git root, expand/collapse, click-to-open plumbed
|
tree rooted at the git root, expand/collapse, click-to-open plumbed
|
||||||
@@ -216,7 +371,7 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
with ignore-file filtering. Ignore set composes clide's built-in
|
with ignore-file filtering. Ignore set composes clide's built-in
|
||||||
hide list (`.git/`, `.pql/`, `.clide/`, `.dart_tool/`, `build/`,
|
hide list (`.git/`, `.pql/`, `.clide/`, `.dart_tool/`, `build/`,
|
||||||
`node_modules/`) with `.gitignore` / `.clideignore` at the root per
|
`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
|
comments, anchored / directory-only / negated forms, and `**` across
|
||||||
directories. 11 new unit tests on the matcher; 5 new dispatcher
|
directories. 11 new unit tests on the matcher; 5 new dispatcher
|
||||||
tests; 171 app tests still green.
|
tests; 171 app tests still green.
|
||||||
@@ -229,7 +384,7 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
`pane.resize` on viewport change. `initState` → spawn;
|
`pane.resize` on viewport change. `initState` → spawn;
|
||||||
`dispose` → `pane.close`. Error-state surface for "daemon not
|
`dispose` → `pane.close`. Error-state surface for "daemon not
|
||||||
connected" / "shell exited." No Claude-specific behaviour — that
|
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
|
- Shared pane widgets under `app/lib/widgets/`: `ClidePtyView` wraps
|
||||||
`xterm.dart` with clide-theme token bindings, JetBrains Mono as the
|
`xterm.dart` with clide-theme token bindings, JetBrains Mono as the
|
||||||
@@ -241,15 +396,15 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
no IPC coupling.
|
no IPC coupling.
|
||||||
|
|
||||||
- `xterm: 4.0.0` Dart dependency on the Flutter app — MIT, listed in
|
- `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.
|
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;
|
a workspace on another host). Local-first stays the Tier-1 target;
|
||||||
this records the constraint so the daemon / IPC / extension seams
|
this records the constraint so the daemon / IPC / extension seams
|
||||||
don't unknowingly accrete local-only assumptions.
|
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.spawn | list | focus | close | write | resize | tail`. Events:
|
||||||
`pane.spawned`, `pane.output` (base64-framed), `pane.exit`,
|
`pane.spawned`, `pane.output` (base64-framed), `pane.exit`,
|
||||||
`pane.resized`, `pane.focused`, `pane.closed`. `PaneRegistry` owns
|
`pane.resized`, `pane.focused`, `pane.closed`. `PaneRegistry` owns
|
||||||
@@ -274,7 +429,7 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
verification, idempotent close.
|
verification, idempotent close.
|
||||||
|
|
||||||
- `ffi: 2.1.3` as a runtime dependency on the Dart core — justified
|
- `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`,
|
by `lib/src/pty/ffi/` for `socketpair`, `recvmsg` with `SCM_RIGHTS`,
|
||||||
`read`/`write` on raw fds, and `ioctl(TIOCSWINSZ)`.
|
`read`/`write` on raw fds, and `ioctl(TIOCSWINSZ)`.
|
||||||
|
|
||||||
@@ -305,7 +460,7 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
`license_file` pointer, and a one-line purpose. Bundled alongside
|
`license_file` pointer, and a one-line purpose. Bundled alongside
|
||||||
the per-dep license texts. The About screen (Tier 6) will render
|
the per-dep license texts. The About screen (Tier 6) will render
|
||||||
this file verbatim. Accompanied by
|
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`
|
adding a dep is a two-step commit (artefact + `licenses.yaml`
|
||||||
entry in the same changeset).
|
entry in the same changeset).
|
||||||
|
|
||||||
@@ -313,10 +468,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
- CLAUDE.md "Dependencies & supply chain" section gains the
|
- CLAUDE.md "Dependencies & supply chain" section gains the
|
||||||
"document every bundled dependency" rule, pointing at
|
"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
|
- `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
|
One-shot, libc-only, ~400 LOC. Reads a JSON request on stdin
|
||||||
(`argv`, optional `cwd`/`env`/`cols`/`rows`), does
|
(`argv`, optional `cwd`/`env`/`cols`/`rows`), does
|
||||||
`posix_openpt` + `fork` + `execvp`, and hands the master fd back
|
`posix_openpt` + `fork` + `execvp`, and hands the master fd back
|
||||||
@@ -329,10 +484,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
`ptyc-build` / `ptyc-clean`.
|
`ptyc-build` / `ptyc-clean`.
|
||||||
|
|
||||||
- Migrated the `docs/ADRs/` content into `decisions/` as D/R records:
|
- Migrated the `docs/ADRs/` content into `decisions/` as D/R records:
|
||||||
ADR 0001 → `D-001`, ADR 0002 → `R-002` (superseded by `D-005`), ADR
|
ADR 0001 → `D-1`, ADR 0002 → `R-2` (superseded by `D-5`), ADR
|
||||||
0003 → `D-003`, ADR 0004 → `D-004`, ADR 0005 → `D-005`, ADR 0006 →
|
0003 → `D-3`, ADR 0004 → `D-4`, ADR 0005 → `D-5`, ADR 0006 →
|
||||||
`D-006`. Titles preserved; ADR 0006's trailing open questions moved
|
`D-6`. Titles preserved; ADR 0006's trailing open questions moved
|
||||||
to `questions-architecture.md` as `Q-001` / `Q-002` / `Q-003`. The
|
to `questions-architecture.md` as `Q-1` / `Q-2` / `Q-3`. The
|
||||||
originals are preserved in git history.
|
originals are preserved in git history.
|
||||||
|
|
||||||
- `decisions/` at the repo root — Q&D record system ported from
|
- `decisions/` at the repo root — Q&D record system ported from
|
||||||
@@ -357,8 +512,8 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
(`tools/scripts/plan ` → `pql `). Ported from settled-reach with
|
(`tools/scripts/plan ` → `pql `). Ported from settled-reach with
|
||||||
the Scrum layer stripped; ticket IDs are `T-NNN` (TEXT PKs) and
|
the Scrum layer stripped; ticket IDs are `T-NNN` (TEXT PKs) and
|
||||||
there's no `sprints` table. Time-limited per
|
there's no `sprints` table. Time-limited per
|
||||||
[`D-040`](decisions/process.md#d-040-python-stopgap-under-toolsscriptsplan)
|
[`D-40`](decisions/process.md#d-40-python-stopgap-under-toolsscriptsplan)
|
||||||
/ [`R-011`](decisions/rejected.md#r-011-permanent-stopgap).
|
/ [`R-11`](decisions/rejected.md#r-11-permanent-stopgap).
|
||||||
|
|
||||||
- `make decisions-validate` — cheap parser dry-run wired into
|
- `make decisions-validate` — cheap parser dry-run wired into
|
||||||
`push-check`. Catches malformed records before push.
|
`push-check`. Catches malformed records before push.
|
||||||
@@ -366,7 +521,7 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
- Reserved extension slots — `builtin.decisions`, `builtin.tickets`,
|
- Reserved extension slots — `builtin.decisions`, `builtin.tickets`,
|
||||||
`builtin.claude-control`. Id-reserving stubs under
|
`builtin.claude-control`. Id-reserving stubs under
|
||||||
`app/lib/builtin/` with no contributions yet. Implementations land
|
`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
|
resolves (decisions + tickets) or when the claude-control tier
|
||||||
arrives (`.claude/` first-class surface — distinct from the
|
arrives (`.claude/` first-class surface — distinct from the
|
||||||
existing `builtin.claude` PTY-pane stub).
|
existing `builtin.claude` PTY-pane stub).
|
||||||
@@ -393,9 +548,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
- `tools/scripts/plan` — Python stopgap planning scripts, superseded
|
- `tools/scripts/plan` — Python stopgap planning scripts, superseded
|
||||||
by `pql` 1.0 native `decisions` and `ticket` subcommands. Sunset
|
by `pql` 1.0 native `decisions` and `ticket` subcommands. Sunset
|
||||||
condition from
|
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
|
met; deletion per
|
||||||
[`R-011`](decisions/rejected.md#r-011-permanent-stopgap).
|
[`R-11`](decisions/rejected.md#r-11-permanent-stopgap).
|
||||||
|
|
||||||
### Removed
|
### 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.
|
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.
|
- **[`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).
|
- **`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/).
|
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.
|
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.
|
- **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).
|
- **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-001`](decisions/architecture.md#d-001-cli-first-not-mcp).
|
- **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-005`](decisions/architecture.md#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended by D-056).
|
- **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.
|
- **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).
|
- **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-003`](decisions/architecture.md#d-003-pql-as-supporter-tool-clide-wraps-never-duplicates).
|
- **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.
|
- **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).
|
- **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
|
## Repo layout
|
||||||
@@ -55,7 +55,7 @@ legacy/ # Python Textual clide v1.2 (frozen)
|
|||||||
## Dependencies & supply chain
|
## 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.
|
- **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.
|
- **`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
|
## Commands
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
See [`decisions/README.md`](decisions/README.md).
|
|
||||||
@@ -5,10 +5,20 @@
|
|||||||
|
|
||||||
INSTALL_DIR ?= $(HOME)/.local/bin
|
INSTALL_DIR ?= $(HOME)/.local/bin
|
||||||
|
|
||||||
VERSION_BASE ?= $(shell awk -F': *' '/^version:/ {gsub(/[" ]/,"",$$2); print $$2; exit}' pubspec.yaml)
|
# -- OS detection ------------------------------------------------------------
|
||||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
# Maps uname to Flutter device/build targets: linux, macos, windows.
|
||||||
DIRTY := $(shell git diff --quiet HEAD 2>/dev/null || echo .dirty)
|
|
||||||
VERSION ?= $(VERSION_BASE)+$(COMMIT)$(DIRTY)
|
UNAME_S := $(shell uname -s)
|
||||||
|
ifeq ($(UNAME_S),Linux)
|
||||||
|
FLUTTER_OS := linux
|
||||||
|
else ifeq ($(UNAME_S),Darwin)
|
||||||
|
FLUTTER_OS := macos
|
||||||
|
else
|
||||||
|
# Windows (MSYS2, Git Bash, Cygwin all report variants with "MINGW"/"MSYS"/"CYGWIN")
|
||||||
|
FLUTTER_OS := windows
|
||||||
|
endif
|
||||||
|
|
||||||
|
VERSION ?= $(shell awk -F': *' '/^version:/ {gsub(/[" ]/,"",$$2); print $$2; exit}' pubspec.yaml)
|
||||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
|
||||||
.PHONY: help
|
.PHONY: help
|
||||||
@@ -19,12 +29,48 @@ help: ## Show this help.
|
|||||||
|
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
run: ## Launch the Flutter desktop app.
|
run: ## Launch the Flutter desktop app.
|
||||||
LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter run -d linux
|
ifeq ($(FLUTTER_OS),linux)
|
||||||
|
GDK_BACKEND=x11 LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter run -d linux --dart-define=CLIDE_PROJECT=$(CURDIR)
|
||||||
|
else
|
||||||
|
flutter run -d $(FLUTTER_OS) --dart-define=CLIDE_PROJECT=$(CURDIR)
|
||||||
|
endif
|
||||||
|
|
||||||
|
TESTMODE_CATEGORY ?= all
|
||||||
|
TESTMODE_TIMEOUT ?= 60
|
||||||
|
|
||||||
|
.PHONY: run-testmode
|
||||||
|
run-testmode: ## Launch ClideTestApp (TESTMODE_CATEGORY=toolchain|ipc|extensions|all).
|
||||||
|
ifeq ($(FLUTTER_OS),linux)
|
||||||
|
@GDK_BACKEND=x11 LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} \
|
||||||
|
flutter run -d linux \
|
||||||
|
--dart-define=CLIDE_PROJECT=$(CURDIR) \
|
||||||
|
--dart-define=CLIDE_TESTMODE=$(TESTMODE_CATEGORY) 2>&1 \
|
||||||
|
| tee /tmp/clide-testmode.log & PID=$$!; \
|
||||||
|
(sleep $(TESTMODE_TIMEOUT) && kill $$PID 2>/dev/null) & TIMER=$$!; \
|
||||||
|
wait $$PID 2>/dev/null; kill $$TIMER 2>/dev/null; \
|
||||||
|
grep -q '"failed":0' /tmp/clide-testmode.log
|
||||||
|
else
|
||||||
|
@flutter run -d $(FLUTTER_OS) \
|
||||||
|
--dart-define=CLIDE_PROJECT=$(CURDIR) \
|
||||||
|
--dart-define=CLIDE_TESTMODE=$(TESTMODE_CATEGORY) 2>&1 \
|
||||||
|
| tee /tmp/clide-testmode.log & PID=$$!; \
|
||||||
|
(sleep $(TESTMODE_TIMEOUT) && kill $$PID 2>/dev/null) & TIMER=$$!; \
|
||||||
|
wait $$PID 2>/dev/null; kill $$TIMER 2>/dev/null; \
|
||||||
|
grep -q '"failed":0' /tmp/clide-testmode.log
|
||||||
|
endif
|
||||||
|
|
||||||
.PHONY: pubget
|
.PHONY: pubget
|
||||||
pubget: ## flutter pub get.
|
pubget: ## flutter pub get.
|
||||||
flutter pub get
|
flutter pub get
|
||||||
|
|
||||||
|
.PHONY: build-check
|
||||||
|
build-check: ## Verify native + Dart build compiles (no run).
|
||||||
|
ifeq ($(FLUTTER_OS),linux)
|
||||||
|
LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter build linux
|
||||||
|
else
|
||||||
|
flutter build $(FLUTTER_OS)
|
||||||
|
endif
|
||||||
|
|
||||||
.PHONY: analyze
|
.PHONY: analyze
|
||||||
analyze: ## flutter analyze.
|
analyze: ## flutter analyze.
|
||||||
flutter analyze
|
flutter analyze
|
||||||
@@ -81,6 +127,10 @@ ui-smoke: ## Build + serve + run Playwright smoke + stop.
|
|||||||
tools/ui/serve.sh
|
tools/ui/serve.sh
|
||||||
@sh -c 'trap "tools/ui/stop.sh >/dev/null 2>&1" EXIT; cd tools/ui && npx playwright test smoke.spec.ts'
|
@sh -c 'trap "tools/ui/stop.sh >/dev/null 2>&1" EXIT; cd tools/ui && npx playwright test smoke.spec.ts'
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build: ## flutter build for the current OS.
|
||||||
|
flutter build $(FLUTTER_OS)
|
||||||
|
|
||||||
.PHONY: build-linux
|
.PHONY: build-linux
|
||||||
build-linux: ## flutter build linux (desktop bundle).
|
build-linux: ## flutter build linux (desktop bundle).
|
||||||
flutter build linux
|
flutter build linux
|
||||||
@@ -89,6 +139,100 @@ build-linux: ## flutter build linux (desktop bundle).
|
|||||||
build-macos: ## flutter build macos (desktop bundle).
|
build-macos: ## flutter build macos (desktop bundle).
|
||||||
flutter build macos
|
flutter build macos
|
||||||
|
|
||||||
|
# -- install / uninstall -----------------------------------------------------
|
||||||
|
|
||||||
|
# Install prefix. Bundle lands at $(INSTALL_PREFIX)/clide/ with a
|
||||||
|
# symlink at $(INSTALL_DIR)/clide pointing into it.
|
||||||
|
INSTALL_PREFIX ?= $(HOME)/.local/lib
|
||||||
|
|
||||||
|
ifeq ($(FLUTTER_OS),linux)
|
||||||
|
BUNDLE_DIR := build/linux/x64/release/bundle
|
||||||
|
else ifeq ($(FLUTTER_OS),macos)
|
||||||
|
BUNDLE_DIR := build/macos/Build/Products/Release/clide.app
|
||||||
|
endif
|
||||||
|
|
||||||
|
ICON_SIZES := 16 32 48 128 192 256 512
|
||||||
|
|
||||||
|
.PHONY: install
|
||||||
|
install: build ## Build + install clide to ~/.local (INSTALL_PREFIX, INSTALL_DIR).
|
||||||
|
ifeq ($(FLUTTER_OS),linux)
|
||||||
|
@mkdir -p $(INSTALL_PREFIX) $(INSTALL_DIR)
|
||||||
|
rm -rf $(INSTALL_PREFIX)/clide
|
||||||
|
cp -a $(BUNDLE_DIR) $(INSTALL_PREFIX)/clide
|
||||||
|
ln -sf $(INSTALL_PREFIX)/clide/clide $(INSTALL_DIR)/clide
|
||||||
|
@for size in $(ICON_SIZES); do \
|
||||||
|
dir=$(HOME)/.local/share/icons/hicolor/$${size}x$${size}/apps; \
|
||||||
|
mkdir -p $$dir; \
|
||||||
|
cp assets/logo/appicon-$${size}.png $$dir/clide.png; \
|
||||||
|
done
|
||||||
|
@mkdir -p $(HOME)/.local/share/applications
|
||||||
|
@sed 's|Exec=clide|Exec=$(INSTALL_PREFIX)/clide/clide|' linux/clide.desktop \
|
||||||
|
> $(HOME)/.local/share/applications/clide.desktop
|
||||||
|
@gtk-update-icon-cache -f -t $(HOME)/.local/share/icons/hicolor 2>/dev/null || true
|
||||||
|
@update-desktop-database $(HOME)/.local/share/applications 2>/dev/null || true
|
||||||
|
@echo "installed: $(INSTALL_DIR)/clide -> $(INSTALL_PREFIX)/clide/clide"
|
||||||
|
@echo "desktop: ~/.local/share/applications/clide.desktop"
|
||||||
|
@echo "version: $(VERSION)"
|
||||||
|
else ifeq ($(FLUTTER_OS),macos)
|
||||||
|
@mkdir -p $(HOME)/Applications
|
||||||
|
rm -rf $(HOME)/Applications/clide.app
|
||||||
|
cp -a $(BUNDLE_DIR) $(HOME)/Applications/clide.app
|
||||||
|
@echo "installed: ~/Applications/clide.app"
|
||||||
|
@echo "version: $(VERSION)"
|
||||||
|
else
|
||||||
|
@echo "install not yet supported on $(FLUTTER_OS)"
|
||||||
|
@exit 1
|
||||||
|
endif
|
||||||
|
|
||||||
|
.PHONY: uninstall
|
||||||
|
uninstall: ## Remove installed clide.
|
||||||
|
ifeq ($(FLUTTER_OS),linux)
|
||||||
|
rm -f $(INSTALL_DIR)/clide
|
||||||
|
rm -rf $(INSTALL_PREFIX)/clide
|
||||||
|
rm -f $(HOME)/.local/share/applications/clide.desktop
|
||||||
|
@for size in $(ICON_SIZES); do \
|
||||||
|
rm -f $(HOME)/.local/share/icons/hicolor/$${size}x$${size}/apps/clide.png; \
|
||||||
|
done
|
||||||
|
@gtk-update-icon-cache -f -t $(HOME)/.local/share/icons/hicolor 2>/dev/null || true
|
||||||
|
@update-desktop-database $(HOME)/.local/share/applications 2>/dev/null || true
|
||||||
|
@echo "uninstalled"
|
||||||
|
else ifeq ($(FLUTTER_OS),macos)
|
||||||
|
rm -rf $(HOME)/Applications/clide.app
|
||||||
|
@echo "uninstalled"
|
||||||
|
endif
|
||||||
|
|
||||||
|
# -- dugite-native (bundled git) ------------------------------------------
|
||||||
|
|
||||||
|
DUGITE_VERSION := v2.53.0-3
|
||||||
|
DUGITE_COMMIT := f49d009
|
||||||
|
|
||||||
|
ifeq ($(FLUTTER_OS),macos)
|
||||||
|
ifeq ($(shell uname -m),arm64)
|
||||||
|
DUGITE_PLATFORM := macOS-arm64
|
||||||
|
else
|
||||||
|
DUGITE_PLATFORM := macOS-x64
|
||||||
|
endif
|
||||||
|
else ifeq ($(FLUTTER_OS),linux)
|
||||||
|
DUGITE_PLATFORM := ubuntu-x64
|
||||||
|
else
|
||||||
|
DUGITE_PLATFORM := windows-x64
|
||||||
|
endif
|
||||||
|
|
||||||
|
DUGITE_TAR := dugite-native-v2.53.0-$(DUGITE_COMMIT)-$(DUGITE_PLATFORM).tar.gz
|
||||||
|
DUGITE_URL := https://github.com/desktop/dugite-native/releases/download/$(DUGITE_VERSION)/$(DUGITE_TAR)
|
||||||
|
DUGITE_DIR := native/dugite
|
||||||
|
|
||||||
|
.PHONY: dugite-fetch
|
||||||
|
dugite-fetch: ## Download and extract the dugite-native git distribution.
|
||||||
|
@if [ -f $(DUGITE_DIR)/bin/git ]; then echo "dugite already present at $(DUGITE_DIR)/bin/git"; exit 0; fi
|
||||||
|
@mkdir -p $(DUGITE_DIR)
|
||||||
|
curl -sL "$(DUGITE_URL)" | tar xz -C $(DUGITE_DIR)
|
||||||
|
@echo "dugite-native $(DUGITE_VERSION) ($(DUGITE_PLATFORM)) extracted to $(DUGITE_DIR)/"
|
||||||
|
|
||||||
|
.PHONY: dugite-clean
|
||||||
|
dugite-clean: ## Remove the dugite-native directory.
|
||||||
|
rm -rf $(DUGITE_DIR)
|
||||||
|
|
||||||
# -- ptyc (C supporter tool) ---------------------------------------------
|
# -- ptyc (C supporter tool) ---------------------------------------------
|
||||||
|
|
||||||
PTYC_PRESENT := $(shell test -f ptyc/Makefile && echo yes || echo no)
|
PTYC_PRESENT := $(shell test -f ptyc/Makefile && echo yes || echo no)
|
||||||
|
|||||||
@@ -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 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.
|
- **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.
|
- **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
|
### Every dependency is audited
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,47 @@
|
|||||||
# clide
|
# clide
|
||||||
|
|
||||||
A Flutter desktop IDE for Claude Code. Markdown-first content, pql-powered queries, canvas and graph surfaces, a Go sidecar handling PTYs / subprocesses / git / pql. Claude drives the UI through a `clide` CLI.
|
A Flutter desktop IDE for Claude Code. Native rendering, terminal-first interaction, pql-powered queries, canvas and graph surfaces. Linux and macOS.
|
||||||
|
|
||||||
Currently pre-v2.0, scaffolding. The north-star design lives in [`docs/initial-plan.md`](docs/initial-plan.md); architectural decisions are captured in [`docs/ADRs/`](docs/ADRs/). The Python Textual v1.2.0 implementation is archived under [`legacy/`](legacy/) for reference.
|
## Architecture
|
||||||
|
|
||||||
## Three surfaces, one tool
|
Single Flutter package at the repo root. The app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), and the extension framework. tmux owns Claude session persistence.
|
||||||
|
|
||||||
- **`app/`** — Flutter desktop application (Linux / macOS; Windows is a stretch).
|
- **`lib/`** — all Dart code. Kernel services (theme, i18n, settings, panels, commands, focus), UI widgets, built-in extensions, and the extension contract.
|
||||||
- **`sidecar/`** — Go sidecar/CLI, single binary with two modes: `clide <subcommand>` (one-shot for Claude) and `clide --daemon` (long-running sidecar for the app). Owns PTYs, subprocesses, file watchers, git, pql invocations.
|
- **`ptyc/`** — small C helper. Spawns a PTY + child and hands the master fd back over `SCM_RIGHTS`. Every pane (shell, tmux, claude, LSP, debug adapter) goes through it.
|
||||||
- **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. clide wraps it for every query surface; never re-implements it.
|
- **[pql](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it.
|
||||||
|
|
||||||
## Why the rebuild?
|
Claude drives the UI through a `clide` CLI surface (Bash, not MCP). Every CLI subcommand has a UI affordance and every UI action has a CLI equivalent.
|
||||||
|
|
||||||
The Python Textual implementation (`legacy/`) proved the pane model but capped at terminal-only rendering. A short-lived experiment (`claudian`, April 2026) explored an Obsidian-plugin approach and was abandoned because Obsidian is Electron, and the user's history with terminal-in-Electron ruled that host out.
|
## Built-in extensions
|
||||||
|
|
||||||
Flutter desktop resolves both constraints: native Skia rendering avoids Electron's failure modes, `xterm.dart` gives us a solid terminal surface, and the pane model can be rebuilt natively. The Obsidian ideas worth keeping (canvas, graph) fold in; everything else (vault concept, inline query tables, plugin ecosystem inheritance) doesn't.
|
canvas, claude, claude_control, decisions, diff, editor, extensions_ui, files, git, graph, grammars_core, ipc_status, keybindings_ui, markdown, pql, problems, settings_ui, terminal, theme_picker, tickets, todos, welcome.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Requires Flutter (stable channel) on the host. One-time setup:
|
||||||
|
|
||||||
|
```
|
||||||
|
make hooks && flutter pub get
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```
|
||||||
|
make run # launch the desktop app
|
||||||
|
make test # fast suite: analyze + format + unit + widget + golden
|
||||||
|
make test-core # core subsystem tests (IPC, PTY, git, pane registry)
|
||||||
|
make test-integration # real app boot integration tests
|
||||||
|
make build-linux # flutter build linux
|
||||||
|
make build-macos # flutter build macos
|
||||||
|
make ptyc-build # build the ptyc PTY-spawn helper
|
||||||
|
make push-check # pre-push gate: decisions + core + fast tests
|
||||||
|
```
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
No build yet beyond the repo scaffold. Tier 0 acceptance: sidecar daemon runs, Flutter app connects, empty IDE shell appears with placeholders.
|
Pre-v2.0 (`2.0.0-dev`). Interaction model and panel system landed. The Python Textual v1.2.0 predecessor is archived under [`legacy/`](legacy/).
|
||||||
|
|
||||||
|
Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Architectural decisions: [`decisions/`](decisions/).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ schema_version: 1
|
|||||||
self:
|
self:
|
||||||
name: clide
|
name: clide
|
||||||
version: "2.0.0-dev"
|
version: "2.0.0-dev"
|
||||||
homepage: https://git.schweitz.net/jpmschweitzer/clide
|
homepage: https://github.com/postmeridiem/clide
|
||||||
license: MIT
|
license: MIT
|
||||||
license_file: assets/LICENSE
|
license_file: assets/LICENSE
|
||||||
copyright: "© 2026 Jeroen Schweitzer"
|
copyright: "© 2026 Jeroen Schweitzer"
|
||||||
@@ -109,6 +109,18 @@ dependencies:
|
|||||||
the core PTY wrapper for libc bindings (socketpair, recvmsg with
|
the core PTY wrapper for libc bindings (socketpair, recvmsg with
|
||||||
SCM_RIGHTS, ioctl). Dart-team maintained; zero transitive deps.
|
SCM_RIGHTS, ioctl). Dart-team maintained; zero transitive deps.
|
||||||
|
|
||||||
|
- name: dugite-native (Git)
|
||||||
|
kind: native-binary
|
||||||
|
version: "2.53.0"
|
||||||
|
homepage: https://github.com/desktop/dugite-native
|
||||||
|
license: GPL-2.0 (Git), MIT (dugite-native build scripts)
|
||||||
|
purpose: >-
|
||||||
|
Self-contained, relocatable Git distribution for macOS/Linux/Windows.
|
||||||
|
Bundled so clide can run git inside the macOS app sandbox without
|
||||||
|
depending on Homebrew or Xcode Command Line Tools. Same approach
|
||||||
|
used by GitHub Desktop and Tower. Binary not committed — downloaded
|
||||||
|
at build time via `make dugite-fetch`.
|
||||||
|
|
||||||
- name: tree-sitter
|
- name: tree-sitter
|
||||||
kind: native-shared-lib
|
kind: native-shared-lib
|
||||||
version: "0.26.8"
|
version: "0.26.8"
|
||||||
@@ -142,6 +154,39 @@ dependencies:
|
|||||||
wasmtime engine. Source repos are tree-sitter/tree-sitter-<lang>
|
wasmtime engine. Source repos are tree-sitter/tree-sitter-<lang>
|
||||||
on GitHub; sqlite is dhcmrlchtdj/tree-sitter-sqlite (MIT).
|
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
|
# Build-time-only dependencies — test runners, mocks, lints. Tracked
|
||||||
# here for audit completeness; NOT rendered in the About screen.
|
# here for audit completeness; NOT rendered in the About screen.
|
||||||
dev_dependencies:
|
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">
|
<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">
|
<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="89,55 66,80 89,105" stroke="#5691a1" stroke-width="8"/>
|
||||||
<polyline points="111,55 134,80 111,105" stroke="#33404c" 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"/>
|
<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 |
@@ -13,6 +13,8 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:clide/clide.dart';
|
import 'package:clide/clide.dart';
|
||||||
|
import 'package:clide/kernel/src/toolchain.dart';
|
||||||
|
import 'package:clide/src/git/client.dart';
|
||||||
// Daemon-only deep imports — these pull in dart:ffi (PTY) and
|
// Daemon-only deep imports — these pull in dart:ffi (PTY) and
|
||||||
// daemon-subsystem wiring that the Flutter app doesn't need and
|
// daemon-subsystem wiring that the Flutter app doesn't need and
|
||||||
// can't compile for web. See lib/clide.dart for the barrel split.
|
// can't compile for web. See lib/clide.dart for the barrel split.
|
||||||
@@ -142,20 +144,22 @@ Future<void> _runDaemon(List<String> args) async {
|
|||||||
socketPath: socketPath,
|
socketPath: socketPath,
|
||||||
dispatch: dispatcher.dispatch,
|
dispatch: dispatcher.dispatch,
|
||||||
);
|
);
|
||||||
final ptycPath = _resolvePtycPath();
|
final toolchain = Toolchain();
|
||||||
|
toolchain.applyResolved(Toolchain.resolvePaths(workspaceRoot: Directory.current.path));
|
||||||
|
|
||||||
final events = _ServerEventSink(server);
|
final events = _ServerEventSink(server);
|
||||||
final registry = PaneRegistry(events: events);
|
final registry = PaneRegistry(events: events);
|
||||||
registerPaneCommands(dispatcher, registry, defaultPtycPath: ptycPath);
|
registerPaneCommands(dispatcher, registry);
|
||||||
|
|
||||||
final files = FilesService.atCwd(events: events);
|
final files = FilesService.atCwd(events: events);
|
||||||
registerFilesCommands(dispatcher, files);
|
registerFilesCommands(dispatcher, files);
|
||||||
|
|
||||||
final editor = EditorRegistry(events: events, workspaceRoot: files.root);
|
final editor = EditorRegistry(events: events, workspaceRoot: files.root);
|
||||||
registerEditorCommands(dispatcher, editor);
|
registerEditorCommands(dispatcher, editor);
|
||||||
|
final gitClient = GitClient(toolchain: toolchain, workDir: files.root);
|
||||||
|
registerGitCommands(dispatcher, gitClient, events);
|
||||||
|
|
||||||
registerGitCommands(dispatcher, files.root, events);
|
final pql = PqlClient(workDir: files.root, toolchain: toolchain);
|
||||||
|
|
||||||
final pql = PqlClient(workDir: files.root);
|
|
||||||
registerPqlCommands(dispatcher, pql);
|
registerPqlCommands(dispatcher, pql);
|
||||||
|
|
||||||
final stopping = Completer<void>();
|
final stopping = Completer<void>();
|
||||||
@@ -238,10 +242,7 @@ Future<void> _runCliArgs(
|
|||||||
// Responses come back on the same socket. Events may be interleaved
|
// Responses come back on the same socket. Events may be interleaved
|
||||||
// (the daemon broadcasts), so we skip events until we see the
|
// (the daemon broadcasts), so we skip events until we see the
|
||||||
// response whose id matches our request.
|
// response whose id matches our request.
|
||||||
final lines = socket
|
final lines = socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter());
|
||||||
.cast<List<int>>()
|
|
||||||
.transform(utf8.decoder)
|
|
||||||
.transform(const LineSplitter());
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await for (final line in lines) {
|
await for (final line in lines) {
|
||||||
@@ -342,11 +343,14 @@ Future<void> _runGit(List<String> args) async {
|
|||||||
final rest = args.sublist(1);
|
final rest = args.sublist(1);
|
||||||
final setUpstream = rest.contains('-u');
|
final setUpstream = rest.contains('-u');
|
||||||
final positional = rest.where((a) => a != '-u').toList();
|
final positional = rest.where((a) => a != '-u').toList();
|
||||||
await _runCliArgs('git.push', {
|
await _runCliArgs(
|
||||||
if (setUpstream) 'setUpstream': true,
|
'git.push',
|
||||||
if (positional.isNotEmpty) 'remote': positional[0],
|
{
|
||||||
if (positional.length > 1) 'branch': positional[1],
|
if (setUpstream) 'setUpstream': true,
|
||||||
}, exitOnOk: true);
|
if (positional.isNotEmpty) 'remote': positional[0],
|
||||||
|
if (positional.length > 1) 'branch': positional[1],
|
||||||
|
},
|
||||||
|
exitOnOk: true);
|
||||||
default:
|
default:
|
||||||
_die('unknown git verb: ${args.first}');
|
_die('unknown git verb: ${args.first}');
|
||||||
}
|
}
|
||||||
@@ -386,13 +390,11 @@ Future<void> _runTail(List<String> args) async {
|
|||||||
void quit() {
|
void quit() {
|
||||||
unawaited(socket.close());
|
unawaited(socket.close());
|
||||||
}
|
}
|
||||||
|
|
||||||
ProcessSignal.sigint.watch().listen((_) => quit());
|
ProcessSignal.sigint.watch().listen((_) => quit());
|
||||||
ProcessSignal.sigterm.watch().listen((_) => quit());
|
ProcessSignal.sigterm.watch().listen((_) => quit());
|
||||||
|
|
||||||
final lines = socket
|
final lines = socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter());
|
||||||
.cast<List<int>>()
|
|
||||||
.transform(utf8.decoder)
|
|
||||||
.transform(const LineSplitter());
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await for (final line in lines) {
|
await for (final line in lines) {
|
||||||
@@ -424,20 +426,6 @@ void _emitError({
|
|||||||
stderr.writeln(jsonEncode(err.toJson()));
|
stderr.writeln(jsonEncode(err.toJson()));
|
||||||
}
|
}
|
||||||
|
|
||||||
String _resolvePtycPath() {
|
|
||||||
final self = Platform.resolvedExecutable;
|
|
||||||
final binDir = File(self).parent.path;
|
|
||||||
final candidates = [
|
|
||||||
'$binDir/../ptyc/bin/ptyc',
|
|
||||||
'$binDir/ptyc',
|
|
||||||
'ptyc',
|
|
||||||
];
|
|
||||||
for (final c in candidates) {
|
|
||||||
if (File(c).existsSync()) return c;
|
|
||||||
}
|
|
||||||
return 'ptyc';
|
|
||||||
}
|
|
||||||
|
|
||||||
Never _die(String msg) {
|
Never _die(String msg) {
|
||||||
_emitError(
|
_emitError(
|
||||||
code: IpcExitCode.userError,
|
code: IpcExitCode.userError,
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ set -euo pipefail
|
|||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
BUNDLE="app/build/linux/x64/release/bundle/clide_app"
|
BUNDLE="build/linux/x64/release/bundle/clide"
|
||||||
|
|
||||||
echo "==> build linux release bundle"
|
echo "==> build linux release bundle"
|
||||||
(cd app && flutter build linux --release)
|
flutter build linux --release
|
||||||
|
|
||||||
if [[ ! -x "$BUNDLE" ]]; then
|
if [[ ! -x "$BUNDLE" ]]; then
|
||||||
echo "smoke: bundle not found at $BUNDLE" >&2
|
echo "smoke: bundle not found at $BUNDLE" >&2
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Fast test layer — unit + widget + golden. Runs in <60s on a warm
|
# Fast test layer — analyze + format + unit + widget + golden.
|
||||||
# cache. Called from `make test` and the pre-push hook.
|
# Runs in <60s on a warm cache. Called from `make test` and the
|
||||||
|
# pre-push hook.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
echo "==> dart analyze (root package)"
|
echo "==> flutter analyze"
|
||||||
dart analyze
|
flutter analyze --no-fatal-infos
|
||||||
|
|
||||||
echo "==> dart format (whole tree)"
|
echo "==> dart format (whole tree)"
|
||||||
dart format --set-exit-if-changed .
|
dart format --set-exit-if-changed .
|
||||||
|
|
||||||
echo "==> dart test (root package)"
|
echo "==> dart test (forkpty — incompatible with flutter test runner)"
|
||||||
dart test
|
dart test --tags forkpty test/pty/session_test.dart
|
||||||
|
|
||||||
echo "==> flutter analyze (app)"
|
echo "==> flutter test (unit + widget + golden)"
|
||||||
(cd app && flutter analyze)
|
flutter test --exclude-tags forkpty
|
||||||
|
|
||||||
echo "==> flutter test (app unit + widget + golden)"
|
|
||||||
(cd app && flutter test)
|
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ set -euo pipefail
|
|||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
echo "==> a11y suite"
|
echo "==> a11y suite"
|
||||||
(cd app && flutter test test/a11y/)
|
flutter test test/a11y/
|
||||||
|
|||||||
@@ -33,9 +33,11 @@ TIMEOUT_SECONDS=${TIMEOUT_SECONDS:-120}
|
|||||||
# Run dart test in its own process group so we can kill descendants on
|
# Run dart test in its own process group so we can kill descendants on
|
||||||
# timeout. `setsid` starts a new session; `timeout --kill-after` SIGKILLs
|
# timeout. `setsid` starts a new session; `timeout --kill-after` SIGKILLs
|
||||||
# after SIGTERM if the test ignores it.
|
# after SIGTERM if the test ignores it.
|
||||||
echo "test-core: dart test test/ (timeout ${TIMEOUT_SECONDS}s)"
|
CORE_DIRS="test/ipc test/pty test/daemon test/git test/panes test/files test/editor test/cli test/pql"
|
||||||
|
|
||||||
|
echo "test-core: dart test ${CORE_DIRS} (timeout ${TIMEOUT_SECONDS}s)"
|
||||||
if ! timeout --kill-after=5s "${TIMEOUT_SECONDS}s" \
|
if ! timeout --kill-after=5s "${TIMEOUT_SECONDS}s" \
|
||||||
setsid --wait dart test test/ ; then
|
setsid --wait dart test $CORE_DIRS ; then
|
||||||
rc=$?
|
rc=$?
|
||||||
if [[ $rc -eq 124 ]]; then
|
if [[ $rc -eq 124 ]]; then
|
||||||
echo "test-core: TIMEOUT — killing descendants" >&2
|
echo "test-core: TIMEOUT — killing descendants" >&2
|
||||||
|
|||||||
@@ -6,12 +6,9 @@ set -euo pipefail
|
|||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
echo "==> flutter test --coverage"
|
echo "==> flutter test --coverage"
|
||||||
(cd app && flutter test --coverage)
|
flutter test --coverage
|
||||||
|
|
||||||
echo "==> root dart coverage (skipped until dart_test + coverage integrate)"
|
|
||||||
# dart test doesn't emit lcov natively; wire package:coverage later.
|
|
||||||
|
|
||||||
if command -v lcov >/dev/null 2>&1; then
|
if command -v lcov >/dev/null 2>&1; then
|
||||||
echo "==> lcov summary"
|
echo "==> lcov summary"
|
||||||
lcov --summary app/coverage/lcov.info
|
lcov --summary coverage/lcov.info
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
cd app
|
|
||||||
for f in integration_test/*_test.dart; do
|
for f in integration_test/*_test.dart; do
|
||||||
echo "==> integration_test: $f"
|
echo "==> integration_test: $f"
|
||||||
flutter test "$f"
|
flutter test "$f"
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ Domain-specific fields (`Kill switch:`, `Evaluation reports:`,
|
|||||||
`Amendment:`, `Cross-reference:`) are additive. Amendments are inline
|
`Amendment:`, `Cross-reference:`) are additive. Amendments are inline
|
||||||
and dated: `**Amendment (YYYY-MM-DD):** …`. Cross-references use
|
and dated: `**Amendment (YYYY-MM-DD):** …`. Cross-references use
|
||||||
markdown anchor links with the full slug:
|
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`):
|
Open questions (`Q-NNN`):
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ Rejected alternatives (`R-NNN`):
|
|||||||
|
|
||||||
## Claiming an ID
|
## 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` /
|
claim IDs by inspecting the highest existing `D-NNN` / `Q-NNN` /
|
||||||
`R-NNN` in the target file and incrementing.
|
`R-NNN` in the target file and incrementing.
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ Common queries:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pql decisions list --type confirmed --domain architecture
|
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 coverage # D-records without tickets
|
||||||
pql decisions validate # pre-push parser gate
|
pql decisions validate # pre-push parser gate
|
||||||
pql ticket board # kanban view of tickets
|
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
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
- **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.
|
- **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.
|
- **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
|
- **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.
|
- **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.
|
- **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-003](rejected.md#r-003-materialapp-root) and [R-007](rejected.md#r-007-cupertinoapp-root).
|
- **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.
|
- **Raised by:** 2026-04-21 planning.
|
||||||
|
|
||||||
### D-008: Feature-first folder layout
|
### D-8: Feature-first folder layout
|
||||||
- **Date:** 2026-04-21
|
- **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`).
|
- **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).
|
- **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.
|
- **Cost:** Imports cross features only via the barrel — enforce by review, no automated check yet.
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **Raised by:** 2026-04-21 planning.
|
||||||
|
|
||||||
### D-009: Three-tier theme pipeline
|
### D-9: Three-tier theme pipeline
|
||||||
- **Date:** 2026-04-21
|
- **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).
|
- **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.
|
- **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.
|
- **Raised by:** 2026-04-21 planning.
|
||||||
|
|
||||||
### D-010: State management — `ChangeNotifier` + `ListenableBuilder`
|
### D-10: State management — `ChangeNotifier` + `ListenableBuilder`
|
||||||
- **Date:** 2026-04-21
|
- **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.
|
- **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.
|
- **Cost:** No codegen ergonomics; manual `notifyListeners()` discipline. The `ListenableBuilder.listenable` contract rejects rebuilds outside the subscribed notifier — intentional.
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **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
|
- **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.
|
- **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.
|
- **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).
|
- **Cost:** More kernel surface up-front; pays back at Tier 5 (canvas) and Tier 6 (extension-contributed layouts).
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **Cost:** Adding Mercurial support later costs a real refactor, not just a plugin. Acceptable.
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **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
|
- **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).
|
- **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.
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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)
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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)
|
- **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.
|
- **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.
|
- **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.
|
- **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)
|
- **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.
|
- **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.
|
- **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).
|
- **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.
|
- **Raised by:** Ported from the claudian lineage.
|
||||||
|
|
||||||
### D-005: Dart core; sidecar dissolved; `ptyc` as pql-peer
|
### D-5: Dart core; sidecar dissolved; `ptyc` as pql-peer
|
||||||
- **Date:** 2026-04-20 (was ADR 0005; supersedes [R-002](rejected.md#r-002-go-sidecar))
|
- **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-056](#d-056-dissolve-daemon-process-flutter-app-hosts-ipc-server). Dart-core and ptyc-as-peer principles survive; the daemon binary does not.
|
- **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.
|
- **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.
|
- **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`).
|
- **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).
|
- **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)
|
- **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).
|
- **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.
|
- **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-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.
|
- **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-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.
|
- **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.
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
- **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/`.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
- **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.
|
- **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.
|
- **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-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).
|
- **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).
|
- **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
|
- **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)).
|
- **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-049](#d-049-editor-mode-inline-above-claude-viewer-swap)), not a primary one. The fuzzy finder (`⌘P`) replaces all navigation chrome.
|
- **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-022 (multi-buffer editor tabs) as rejected in favour of this approach.
|
- **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-047](#d-047-interaction-model-claude-is-home-layout), [D-049](#d-049-editor-mode-inline-above-claude-viewer-swap).
|
- **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).
|
- **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
|
- **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.).
|
- **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.
|
- **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-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)).
|
- **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-047](#d-047-interaction-model-claude-is-home-layout), [D-048](#d-048-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first).
|
- **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).
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **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).
|
- **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
|
- **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.
|
- **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.
|
- **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)).
|
- **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-047](#d-047-interaction-model-claude-is-home-layout), [D-050](#d-050-context-auto-behavior-right-panel-reacts-to-claude).
|
- **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).
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **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).
|
- **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
|
- **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`).
|
- **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.
|
- **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.
|
- **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).
|
- **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
|
- **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)).
|
- **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-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").
|
- **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-017](extensions.md#d-017-panels-are-extension-shaped-from-day-one)) allows user override.
|
- **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-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).
|
- **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).
|
- **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
|
- **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).
|
- **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-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.
|
- **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-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.
|
- **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.
|
- **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.
|
- **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
|
- **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.
|
- **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-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.
|
- **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:**
|
- **Repo layout after dissolution:**
|
||||||
- `/pubspec.yaml` — single Flutter package (was `app/pubspec.yaml`)
|
- `/pubspec.yaml` — single Flutter package (was `app/pubspec.yaml`)
|
||||||
- `/lib/` — all Dart code: kernel, extensions, widgets, subsystem handlers
|
- `/lib/` — all Dart code: kernel, extensions, widgets, subsystem handlers
|
||||||
@@ -223,7 +223,16 @@ Core, rendering, IPC, kernel, panel manager.
|
|||||||
- `/decisions/`, `/docs/`, `/legacy/` — unchanged
|
- `/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.
|
- **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.
|
- **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.
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **Cost:** Extension authors make a taste call about grouping; disagreements go to review.
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **Cost:** Every panel goes through the manifest/registration path even when it's trivial. Price paid once.
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **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
|
- **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.
|
- **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.
|
- **Cost:** Two parsers in the tree. `yaml: 3.1.3` is exact-pinned.
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **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
|
- **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).
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
- **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.
|
- **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
|
*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),
|
questions ([Q-15](questions-process.md#q-15-editor-tab-full-lsp-vs-tree-sitter-only),
|
||||||
[Q-016](questions-process.md#q-016-tree-sitter-dart-grammar-maintenance)).*
|
[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
|
- **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).
|
- **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.
|
- **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`).
|
- **Cost:** One more directory to maintain. A learning curve for contributors (tiny: read `decisions/README.md`).
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **Raised by:** 2026-04-21 planning.
|
||||||
|
|
||||||
### D-035: Kanban / waterfall, not Scrum
|
### D-35: Kanban / waterfall, not Scrum
|
||||||
- **Date:** 2026-04-21
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **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."
|
- **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
|
- **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).
|
- **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.
|
- **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.
|
- **Cost:** Contributors with Conventional Commits muscle memory adjust.
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **Cost:** One extra edit per user-visible commit; zero if the change is invisible.
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **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
|
- **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.
|
- **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-009](rejected.md#r-009-port-planning-tooling-into-clide). pql is already the CLI, already universal, already wrapped by clide.
|
- **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-040](#d-040-python-stopgap-under-toolsscriptsplan). Gated by [Q-021](questions-process.md#q-021-pql-absorbs-planning-vs-keeps-separate).
|
- **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.
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
- **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)?
|
- **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.
|
- **Context:** Today the driver clicks `flt-semantics-placeholder` to activate. For user-facing builds we need semantics-always-on.
|
||||||
- **Source:** 2026-04-21 planning.
|
- **Source:** 2026-04-21 planning.
|
||||||
|
|
||||||
### Q-014: i18n plurals / gender / date-format tooling
|
### Q-14: i18n plurals / gender / date-format tooling
|
||||||
- **Status:** Open
|
- **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).
|
- **Context:** Probably becomes painful at Tier 3 (git panel, problem counts) and Tier 4 (pql results).
|
||||||
- **Source:** 2026-04-21 planning.
|
- **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
|
- **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?
|
- **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.
|
- **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
|
- **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?
|
- **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.
|
- **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
|
- **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?
|
- **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.
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **Source:** CLAUDE.md "Open questions" footer.
|
||||||
|
|
||||||
### Q-005: IPC wire-format stability + `schema_version:`
|
### Q-5: IPC wire-format stability + `schema_version:`
|
||||||
- **Status:** Open
|
- **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?
|
- **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.
|
- **Source:** CLAUDE.md "Open questions" footer.
|
||||||
|
|
||||||
### Q-006: Window chrome — native frame vs frameless custom
|
### Q-6: Window chrome — native frame vs frameless custom
|
||||||
- **Status:** Open
|
- **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?
|
- **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.
|
- **Source:** 2026-04-21 planning.
|
||||||
|
|
||||||
### Q-007: macOS app bundle signing / notarisation
|
### Q-7: macOS app bundle signing / notarisation
|
||||||
- **Status:** Open
|
- **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?
|
- **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.
|
- **Context:** Linux is primary; macOS is a stretch target. Notarisation is a separate cost from the Flutter build.
|
||||||
- **Source:** 2026-04-21 planning.
|
- **Source:** 2026-04-21 planning.
|
||||||
|
|
||||||
### Q-021: Pql absorbs planning vs keeps separate
|
### Q-21: Pql absorbs planning vs keeps separate
|
||||||
- **Status:** Open
|
- **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.
|
- **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-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).
|
- **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.
|
- **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
|
- **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.
|
- **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).
|
- **Source:** 2026-04-22 planning (user-raised).
|
||||||
|
|
||||||
### Q-022: Ticket persistence strategy
|
### Q-22: Ticket persistence strategy
|
||||||
- **Status:** Open
|
- **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.
|
- **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.
|
- **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
|
- **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?
|
- **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.
|
- **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.
|
- **Source:** 2026-04-22 design handoff review.
|
||||||
|
|
||||||
### Q-026: Small screen layout (< 1000px)
|
### Q-26: Small screen layout (< 1000px)
|
||||||
- **Status:** Open
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
- **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.
|
- **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.
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
- **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?
|
- **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.
|
- **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.
|
- **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
|
- **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.
|
- **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.
|
- **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
|
- **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.
|
- **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.
|
- **Source:** CLAUDE.md "Open questions" footer.
|
||||||
|
|
||||||
### Q-009: Lua runtime vendoring
|
### Q-9: Lua runtime vendoring
|
||||||
- **Status:** Open
|
- **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.
|
- **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.
|
- **Source:** 2026-04-21 planning.
|
||||||
|
|
||||||
### Q-010: Extension manifest `schema_version:`
|
### Q-10: Extension manifest `schema_version:`
|
||||||
- **Status:** Open
|
- **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.
|
- **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.
|
- **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
|
- **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?
|
- **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."
|
- **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.
|
- **Source:** CLAUDE.md "Open questions" footer.
|
||||||
|
|
||||||
### Q-016: `tree-sitter-dart` grammar maintenance
|
### Q-16: `tree-sitter-dart` grammar maintenance
|
||||||
- **Status:** Open
|
- **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?
|
- **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.
|
- **Source:** 2026-04-21 planning.
|
||||||
|
|
||||||
### Q-017: Icon set growth
|
### Q-17: Icon set growth
|
||||||
- **Status:** Open
|
- **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)?
|
- **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.
|
- **Source:** 2026-04-21 planning.
|
||||||
|
|
||||||
### Q-018: Theme hot-reload in release builds
|
### Q-18: Theme hot-reload in release builds
|
||||||
- **Status:** Open
|
- **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?
|
- **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.
|
- **Context:** Hot-reload is powerful for theme authoring but opens a file-watch + re-parse path in release code.
|
||||||
- **Source:** 2026-04-21 planning.
|
- **Source:** 2026-04-21 planning.
|
||||||
|
|
||||||
### Q-019: (withdrawn)
|
### Q-19: (withdrawn)
|
||||||
- **Status:** Resolved → n/a
|
- **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
|
- **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.
|
- **Context:** Some extensions (tickets, canvas, graph) naturally want relational storage. K/V gets awkward fast.
|
||||||
- **Source:** 2026-04-21 planning.
|
- **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
|
- **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)?
|
- **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.
|
- **Context:** Hard thresholds force tests-for-coverage-sake; soft reporting gets ignored.
|
||||||
- **Source:** 2026-04-21 planning.
|
- **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
|
- **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.
|
- **Context:** axe-core is JS; runs in the browser against the rendered tree. Extra CI time; extra signal.
|
||||||
- **Source:** 2026-04-21 planning.
|
- **Source:** 2026-04-21 planning.
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ open questions with one-line summaries.
|
|||||||
|
|
||||||
## Load-bearing questions (gate other work)
|
## 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-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-022](questions-process.md#q-022-ticket-persistence-strategy)** — Ticket persistence: per-dev only / milestone-committed / markdown-mirrored. Shapes multi-contributor story.
|
- **[Q-22](questions-process.md#q-22-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-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-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-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
|
### R-2: Go sidecar
|
||||||
- **Rejected:** 2026-04-20 (was ADR 0002; superseded by [D-005](architecture.md#d-005-dart-core-ptyc-peer))
|
- **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.
|
- **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
|
- **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."
|
- **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-007](architecture.md#d-007-app-root-is-bare-widgetsapp)
|
- **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
|
- **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.
|
- **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
|
- **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.
|
- **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-026](testing.md#d-026-web-driver-raw-playwright-plus-flutter-semantics)
|
- **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
|
- **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).
|
- **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
|
- **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).
|
- **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-007](architecture.md#d-007-app-root-is-bare-widgetsapp)
|
- **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
|
- **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.
|
- **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-010](architecture.md#d-010-state-management-changenotifier)
|
- **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
|
- **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.
|
- **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-039](process.md#d-039-planning-tooling-lives-in-pql)
|
- **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
|
- **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.
|
- **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-040](process.md#d-040-python-stopgap-under-toolsscriptsplan)
|
- **Cross-reference:** [D-40](process.md#d-40-python-stopgap-under-toolsscriptsplan)
|
||||||
|
|
||||||
### R-011: Permanent stopgap
|
### R-11: Permanent stopgap
|
||||||
- **Rejected:** 2026-04-21
|
- **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.
|
- **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
|
- **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.
|
- **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-043](architecture.md#d-043-design-handoff-adopt-token-palettes-reject-material-wrapper)
|
- **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
|
- **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).
|
- **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).
|
- **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.
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **Cost:** Goldens have zero real text; layouts rely on widget tests. Acceptable.
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **Cost:** Roughly 20 lines per fake. Rounds out to less code than configuring a mocktail whenCall chain.
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **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
|
- **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).
|
- **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`.
|
- **Cost:** Driver has to know Flutter's sibling-merging behaviour — documented in `docs/testing/claude-ui-workflow.md`.
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **Raised by:** 2026-04-21 planning.
|
||||||
|
|
||||||
### D-027: Startup regression gate
|
### D-27: Startup regression gate
|
||||||
- **Date:** 2026-04-21
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **Cost:** Large feature folders mirror into large test folders. Acceptable.
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **Raised by:** 2026-04-21 planning.
|
||||||
|
|
||||||
### D-030: Tests are client-side only
|
### D-30: Tests are client-side only
|
||||||
- **Date:** 2026-04-21
|
- **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.
|
- **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.
|
- **Rationale:** Network-dependent tests flake; flaky tests get quarantined; quarantined tests get deleted. Client-side-only makes CI deterministic offline.
|
||||||
|
|||||||
@@ -4,32 +4,51 @@ Toolchain, supply chain, CI, ignore strategy.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### D-031: Prefer-zero-deps, exact-pin
|
### D-31: Prefer-zero-deps, exact-pin
|
||||||
- **Date:** 2026-04-21
|
- **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.
|
- **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."
|
- **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.
|
- **Cost:** Longer PR descriptions for deps; occasional reinvention of a convenience. Accepted.
|
||||||
- **Raised by:** 2026-04-21 planning; reinforced by user feedback memory.
|
- **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
|
- **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.
|
- **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.
|
- **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.
|
- **Cost:** PRs don't run CI yet; `make push-check` is the gate until activation.
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **Raised by:** 2026-04-21 planning.
|
||||||
|
|
||||||
### D-042: Dependencies documented in `licenses.yaml`
|
### D-42: Dependencies documented in `licenses.yaml`
|
||||||
- **Date:** 2026-04-22
|
- **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.
|
- **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.
|
- **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).
|
- **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
|
- **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.
|
- **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).
|
- **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.
|
- **Cost:** Script names have a convention to follow.
|
||||||
- **Raised by:** 2026-04-21 planning (caught during commit rehearsal).
|
- **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.
|
||||||
|
|
||||||
|
### D-59: Bundled git via dugite-native
|
||||||
|
- **Date:** 2026-04-25
|
||||||
|
- **Decision:** Ship a self-contained Git binary from [dugite-native](https://github.com/desktop/dugite-native) (the same distribution GitHub Desktop bundles). Downloaded at build time via `make dugite-fetch`, stored under `native/dugite/`, gitignored. The `Toolchain` class resolves to the bundled binary first, falling back to system git on PATH.
|
||||||
|
- **Rationale:** The macOS app sandbox blocks execution of Homebrew-installed git (symlinks resolve to Cellar paths that SBPL cannot match without freezing rendering). `/usr/bin/git` is an xcrun shim that refuses to run inside a sandbox. Bundling dugite-native makes clide self-contained — no dependency on Homebrew, Xcode CLT, or system git. The approach is proven: GitHub Desktop, Tower, and other git GUI apps all bundle their own git for the same reason.
|
||||||
|
- **Alternatives rejected:** (R) libgit2 via FFI — missing porcelain commands (pull/push/rebase), no hooks, would require rewriting GitClient. (R) Build git from source — dugite-native already does this with better infra. (R) SBPL exceptions for Homebrew — `(subpath "/opt/homebrew")` for process-exec freezes Flutter rendering on macOS 26.
|
||||||
|
- **Cost:** ~57 MB download (~199 MB unpacked, stripped at build time). Must track dugite-native releases for security updates. GPL-2.0 (git binary) applies to the bundled artefact, not to clide's MIT code.
|
||||||
|
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml).
|
||||||
|
- **Raised by:** 2026-04-25 macOS sandbox investigation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -11,171 +11,201 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
class ClideTheme {
|
class ClideTheme {
|
||||||
// ─── palette ──────────────────────────────────────────────────────────
|
// ─── palette ──────────────────────────────────────────────────────────
|
||||||
static const _bg = Color(0xFF20202C); // page / editor canvas
|
static const _bg = Color(0xFF20202C); // page / editor canvas
|
||||||
static const _bgSunken = Color(0xFF1A1A24); // sidebar, gutters
|
static const _bgSunken = Color(0xFF1A1A24); // sidebar, gutters
|
||||||
static const _surface = Color(0xFF242838); // cards, table header, pills
|
static const _surface = Color(0xFF242838); // cards, table header, pills
|
||||||
static const _surfaceHi = Color(0xFF2C3046); // hover, selected row
|
static const _surfaceHi = Color(0xFF2C3046); // hover, selected row
|
||||||
static const _border = Color(0xFF343850);
|
static const _border = Color(0xFF343850);
|
||||||
static const _borderHi = Color(0xFF3C445C);
|
static const _borderHi = Color(0xFF3C445C);
|
||||||
|
|
||||||
static const _textHi = Color(0xFFE6E8F2); // primary text
|
static const _textHi = Color(0xFFE6E8F2); // primary text
|
||||||
static const _text = Color(0xFFB1BBE3); // section titles, labels
|
static const _text = Color(0xFFB1BBE3); // section titles, labels
|
||||||
static const _textDim = Color(0xFF78809C); // secondary
|
static const _textDim = Color(0xFF78809C); // secondary
|
||||||
static const _textMute = Color(0xFF545C84); // small-caps labels
|
static const _textMute = Color(0xFF545C84); // small-caps labels
|
||||||
|
|
||||||
static const _accent = Color(0xFF78A0F8); // periwinkle primary
|
static const _accent = Color(0xFF78A0F8); // periwinkle primary
|
||||||
static const _accentPress = Color(0xFF6C90DC);
|
static const _accentPress = Color(0xFF6C90DC);
|
||||||
static const _accentSoft = Color(0x2278A0F8); // 13% accent for fills
|
static const _accentSoft = Color(0x2278A0F8); // 13% accent for fills
|
||||||
|
|
||||||
static const _ok = Color(0xFF7DD3A8);
|
static const _ok = Color(0xFF7DD3A8);
|
||||||
static const _warn = Color(0xFFE6C370);
|
static const _warn = Color(0xFFE6C370);
|
||||||
static const _err = Color(0xFFE87D7D);
|
static const _err = Color(0xFFE87D7D);
|
||||||
static const _info = _accent;
|
static const _info = _accent;
|
||||||
|
|
||||||
// syntax (Dart-biased)
|
// syntax (Dart-biased)
|
||||||
static const _synKeyword = Color(0xFFC792EA); // class, const, final
|
static const _synKeyword = Color(0xFFC792EA); // class, const, final
|
||||||
static const _synType = Color(0xFF78A0F8); // Widget, BuildContext
|
static const _synType = Color(0xFF78A0F8); // Widget, BuildContext
|
||||||
static const _synString = Color(0xFFA8D99B);
|
static const _synString = Color(0xFFA8D99B);
|
||||||
static const _synNumber = Color(0xFFE6C370);
|
static const _synNumber = Color(0xFFE6C370);
|
||||||
static const _synComment = Color(0xFF545C84);
|
static const _synComment = Color(0xFF545C84);
|
||||||
static const _synMethod = Color(0xFF82B1FF);
|
static const _synMethod = Color(0xFF82B1FF);
|
||||||
static const _synPunct = Color(0xFF78809C);
|
static const _synPunct = Color(0xFF78809C);
|
||||||
|
|
||||||
// ─── exposed tokens ───────────────────────────────────────────────────
|
// ─── exposed tokens ───────────────────────────────────────────────────
|
||||||
static const tokens = ClideTokens(
|
static const tokens = ClideTokens(
|
||||||
bg: _bg, bgSunken: _bgSunken, surface: _surface, surfaceHi: _surfaceHi,
|
bg: _bg,
|
||||||
border: _border, borderHi: _borderHi,
|
bgSunken: _bgSunken,
|
||||||
textHi: _textHi, text: _text, textDim: _textDim, textMute: _textMute,
|
surface: _surface,
|
||||||
accent: _accent, accentPress: _accentPress, accentSoft: _accentSoft,
|
surfaceHi: _surfaceHi,
|
||||||
ok: _ok, warn: _warn, err: _err, info: _info,
|
border: _border,
|
||||||
synKeyword: _synKeyword, synType: _synType, synString: _synString,
|
borderHi: _borderHi,
|
||||||
synNumber: _synNumber, synComment: _synComment, synMethod: _synMethod,
|
textHi: _textHi,
|
||||||
|
text: _text,
|
||||||
|
textDim: _textDim,
|
||||||
|
textMute: _textMute,
|
||||||
|
accent: _accent,
|
||||||
|
accentPress: _accentPress,
|
||||||
|
accentSoft: _accentSoft,
|
||||||
|
ok: _ok,
|
||||||
|
warn: _warn,
|
||||||
|
err: _err,
|
||||||
|
info: _info,
|
||||||
|
synKeyword: _synKeyword,
|
||||||
|
synType: _synType,
|
||||||
|
synString: _synString,
|
||||||
|
synNumber: _synNumber,
|
||||||
|
synComment: _synComment,
|
||||||
|
synMethod: _synMethod,
|
||||||
synPunct: _synPunct,
|
synPunct: _synPunct,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── ThemeData ────────────────────────────────────────────────────────
|
// ─── ThemeData ────────────────────────────────────────────────────────
|
||||||
static ThemeData get data => ThemeData(
|
static ThemeData get data => ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
scaffoldBackgroundColor: _bg,
|
scaffoldBackgroundColor: _bg,
|
||||||
canvasColor: _bg,
|
canvasColor: _bg,
|
||||||
|
|
||||||
colorScheme: const ColorScheme.dark(
|
colorScheme: const ColorScheme.dark(
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
primary: _accent,
|
primary: _accent,
|
||||||
onPrimary: Color(0xFF0D1020),
|
onPrimary: Color(0xFF0D1020),
|
||||||
secondary: _synKeyword,
|
secondary: _synKeyword,
|
||||||
onSecondary: Color(0xFF0D1020),
|
onSecondary: Color(0xFF0D1020),
|
||||||
surface: _surface,
|
surface: _surface,
|
||||||
onSurface: _textHi,
|
onSurface: _textHi,
|
||||||
surfaceContainerHighest: _surfaceHi,
|
surfaceContainerHighest: _surfaceHi,
|
||||||
outline: _border,
|
outline: _border,
|
||||||
outlineVariant: _borderHi,
|
outlineVariant: _borderHi,
|
||||||
error: _err,
|
error: _err,
|
||||||
onError: Color(0xFF0D1020),
|
onError: Color(0xFF0D1020),
|
||||||
),
|
),
|
||||||
|
|
||||||
textTheme: const TextTheme(
|
textTheme: const TextTheme(
|
||||||
// Josefin Sans Light for display; JetBrains Mono for code/body.
|
// Josefin Sans Light for display; JetBrains Mono for code/body.
|
||||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, height: 1.1, letterSpacing: 0.2, color: _textHi),
|
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, height: 1.1, letterSpacing: 0.2, color: _textHi),
|
||||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, height: 1.15, letterSpacing: 0.2, color: _textHi),
|
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, height: 1.15, letterSpacing: 0.2, color: _textHi),
|
||||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, height: 1.2, letterSpacing: 0.2, color: _textHi),
|
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, height: 1.2, letterSpacing: 0.2, color: _textHi),
|
||||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, height: 1.3, letterSpacing: 0.3, color: _text),
|
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, height: 1.3, letterSpacing: 0.3, color: _text),
|
||||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, height: 1.2, letterSpacing: 1.0, color: _textMute),
|
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, height: 1.2, letterSpacing: 1.0, color: _textMute),
|
||||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, height: 1.4, color: _text),
|
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, height: 1.4, color: _text),
|
||||||
),
|
),
|
||||||
|
|
||||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||||
|
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
color: _surface,
|
color: _surface,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
side: const BorderSide(color: _border),
|
side: const BorderSide(color: _border),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
|
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
isDense: true,
|
isDense: true,
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: _bgSunken,
|
fillColor: _bgSunken,
|
||||||
hintStyle: const TextStyle(color: _textMute),
|
hintStyle: const TextStyle(color: _textMute),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
borderSide: const BorderSide(color: _border),
|
borderSide: const BorderSide(color: _border),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
borderSide: const BorderSide(color: _border),
|
borderSide: const BorderSide(color: _border),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
borderSide: const BorderSide(color: _accent, width: 1.5),
|
borderSide: const BorderSide(color: _accent, width: 1.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: _accent,
|
backgroundColor: _accent,
|
||||||
foregroundColor: const Color(0xFF0D1020),
|
foregroundColor: const Color(0xFF0D1020),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w500),
|
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
textButtonTheme: TextButtonThemeData(
|
textButtonTheme: TextButtonThemeData(
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: _accent,
|
foregroundColor: _accent,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12),
|
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Pill-style chips (matches the Projects row in the dashboard).
|
// Pill-style chips (matches the Projects row in the dashboard).
|
||||||
chipTheme: ChipThemeData(
|
chipTheme: ChipThemeData(
|
||||||
backgroundColor: _surface,
|
backgroundColor: _surface,
|
||||||
side: const BorderSide(color: _borderHi),
|
side: const BorderSide(color: _borderHi),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||||
labelStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _text),
|
labelStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _text),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
),
|
),
|
||||||
|
|
||||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||||
|
|
||||||
tooltipTheme: TooltipThemeData(
|
tooltipTheme: TooltipThemeData(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _surfaceHi,
|
color: _surfaceHi,
|
||||||
border: Border.all(color: _borderHi),
|
border: Border.all(color: _borderHi),
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
),
|
),
|
||||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _textHi),
|
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _textHi),
|
||||||
),
|
),
|
||||||
|
|
||||||
scrollbarTheme: ScrollbarThemeData(
|
scrollbarTheme: ScrollbarThemeData(
|
||||||
thumbColor: WidgetStatePropertyAll(_border),
|
thumbColor: WidgetStatePropertyAll(_border),
|
||||||
thickness: const WidgetStatePropertyAll(6),
|
thickness: const WidgetStatePropertyAll(6),
|
||||||
radius: const Radius.circular(3),
|
radius: const Radius.circular(3),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Raw color tokens — use when Material widgets can't carry the meaning.
|
/// Raw color tokens — use when Material widgets can't carry the meaning.
|
||||||
class ClideTokens {
|
class ClideTokens {
|
||||||
const ClideTokens({
|
const ClideTokens({
|
||||||
required this.bg, required this.bgSunken,
|
required this.bg,
|
||||||
required this.surface, required this.surfaceHi,
|
required this.bgSunken,
|
||||||
required this.border, required this.borderHi,
|
required this.surface,
|
||||||
required this.textHi, required this.text,
|
required this.surfaceHi,
|
||||||
required this.textDim, required this.textMute,
|
required this.border,
|
||||||
required this.accent, required this.accentPress, required this.accentSoft,
|
required this.borderHi,
|
||||||
required this.ok, required this.warn, required this.err, required this.info,
|
required this.textHi,
|
||||||
required this.synKeyword, required this.synType, required this.synString,
|
required this.text,
|
||||||
required this.synNumber, required this.synComment,
|
required this.textDim,
|
||||||
required this.synMethod, required this.synPunct,
|
required this.textMute,
|
||||||
|
required this.accent,
|
||||||
|
required this.accentPress,
|
||||||
|
required this.accentSoft,
|
||||||
|
required this.ok,
|
||||||
|
required this.warn,
|
||||||
|
required this.err,
|
||||||
|
required this.info,
|
||||||
|
required this.synKeyword,
|
||||||
|
required this.synType,
|
||||||
|
required this.synString,
|
||||||
|
required this.synNumber,
|
||||||
|
required this.synComment,
|
||||||
|
required this.synMethod,
|
||||||
|
required this.synPunct,
|
||||||
});
|
});
|
||||||
final Color bg, bgSunken, surface, surfaceHi;
|
final Color bg, bgSunken, surface, surfaceHi;
|
||||||
final Color border, borderHi;
|
final Color border, borderHi;
|
||||||
|
|||||||
@@ -6,69 +6,90 @@ import 'package:flutter/material.dart';
|
|||||||
import 'clide_theme.dart' show ClideTokens;
|
import 'clide_theme.dart' show ClideTokens;
|
||||||
|
|
||||||
class MidnightTheme {
|
class MidnightTheme {
|
||||||
static const _bg = Color(0xFF1E1E1E);
|
static const _bg = Color(0xFF1E1E1E);
|
||||||
static const _bgSunken = Color(0xFF181818);
|
static const _bgSunken = Color(0xFF181818);
|
||||||
static const _surface = Color(0xFF252526);
|
static const _surface = Color(0xFF252526);
|
||||||
static const _surfaceHi = Color(0xFF2D2D2E);
|
static const _surfaceHi = Color(0xFF2D2D2E);
|
||||||
static const _border = Color(0xFF333333);
|
static const _border = Color(0xFF333333);
|
||||||
static const _borderHi = Color(0xFF3F3F3F);
|
static const _borderHi = Color(0xFF3F3F3F);
|
||||||
|
|
||||||
static const _textHi = Color(0xFFD4D4D4);
|
static const _textHi = Color(0xFFD4D4D4);
|
||||||
static const _text = Color(0xFFBBBBBB);
|
static const _text = Color(0xFFBBBBBB);
|
||||||
static const _textDim = Color(0xFF858585);
|
static const _textDim = Color(0xFF858585);
|
||||||
static const _textMute = Color(0xFF6A6A6A);
|
static const _textMute = Color(0xFF6A6A6A);
|
||||||
|
|
||||||
static const _accent = Color(0xFF569CD6); // muted azure
|
static const _accent = Color(0xFF569CD6); // muted azure
|
||||||
static const _accentPress = Color(0xFF4785BD);
|
static const _accentPress = Color(0xFF4785BD);
|
||||||
static const _accentSoft = Color(0x22569CD6);
|
static const _accentSoft = Color(0x22569CD6);
|
||||||
|
|
||||||
static const _ok = Color(0xFF89D185);
|
static const _ok = Color(0xFF89D185);
|
||||||
static const _warn = Color(0xFFD7BA7D);
|
static const _warn = Color(0xFFD7BA7D);
|
||||||
static const _err = Color(0xFFF48771);
|
static const _err = Color(0xFFF48771);
|
||||||
static const _info = _accent;
|
static const _info = _accent;
|
||||||
|
|
||||||
static const _synKeyword = Color(0xFFC586C0);
|
static const _synKeyword = Color(0xFFC586C0);
|
||||||
static const _synType = Color(0xFF4EC9B0);
|
static const _synType = Color(0xFF4EC9B0);
|
||||||
static const _synString = Color(0xFFCE9178);
|
static const _synString = Color(0xFFCE9178);
|
||||||
static const _synNumber = Color(0xFFB5CEA8);
|
static const _synNumber = Color(0xFFB5CEA8);
|
||||||
static const _synComment = Color(0xFF6A9955);
|
static const _synComment = Color(0xFF6A9955);
|
||||||
static const _synMethod = Color(0xFFDCDCAA);
|
static const _synMethod = Color(0xFFDCDCAA);
|
||||||
static const _synPunct = Color(0xFF858585);
|
static const _synPunct = Color(0xFF858585);
|
||||||
|
|
||||||
static const tokens = ClideTokens(
|
static const tokens = ClideTokens(
|
||||||
bg: _bg, bgSunken: _bgSunken, surface: _surface, surfaceHi: _surfaceHi,
|
bg: _bg,
|
||||||
border: _border, borderHi: _borderHi,
|
bgSunken: _bgSunken,
|
||||||
textHi: _textHi, text: _text, textDim: _textDim, textMute: _textMute,
|
surface: _surface,
|
||||||
accent: _accent, accentPress: _accentPress, accentSoft: _accentSoft,
|
surfaceHi: _surfaceHi,
|
||||||
ok: _ok, warn: _warn, err: _err, info: _info,
|
border: _border,
|
||||||
synKeyword: _synKeyword, synType: _synType, synString: _synString,
|
borderHi: _borderHi,
|
||||||
synNumber: _synNumber, synComment: _synComment, synMethod: _synMethod,
|
textHi: _textHi,
|
||||||
|
text: _text,
|
||||||
|
textDim: _textDim,
|
||||||
|
textMute: _textMute,
|
||||||
|
accent: _accent,
|
||||||
|
accentPress: _accentPress,
|
||||||
|
accentSoft: _accentSoft,
|
||||||
|
ok: _ok,
|
||||||
|
warn: _warn,
|
||||||
|
err: _err,
|
||||||
|
info: _info,
|
||||||
|
synKeyword: _synKeyword,
|
||||||
|
synType: _synType,
|
||||||
|
synString: _synString,
|
||||||
|
synNumber: _synNumber,
|
||||||
|
synComment: _synComment,
|
||||||
|
synMethod: _synMethod,
|
||||||
synPunct: _synPunct,
|
synPunct: _synPunct,
|
||||||
);
|
);
|
||||||
|
|
||||||
static ThemeData get data => ThemeData(
|
static ThemeData get data => ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
scaffoldBackgroundColor: _bg,
|
scaffoldBackgroundColor: _bg,
|
||||||
canvasColor: _bg,
|
canvasColor: _bg,
|
||||||
colorScheme: const ColorScheme.dark(
|
colorScheme: const ColorScheme.dark(
|
||||||
primary: _accent, onPrimary: Color(0xFF0B1220),
|
primary: _accent,
|
||||||
secondary: _synKeyword, onSecondary: Color(0xFF0B1220),
|
onPrimary: Color(0xFF0B1220),
|
||||||
surface: _surface, onSurface: _textHi,
|
secondary: _synKeyword,
|
||||||
surfaceContainerHighest: _surfaceHi,
|
onSecondary: Color(0xFF0B1220),
|
||||||
outline: _border, outlineVariant: _borderHi,
|
surface: _surface,
|
||||||
error: _err, onError: Color(0xFF0B1220),
|
onSurface: _textHi,
|
||||||
),
|
surfaceContainerHighest: _surfaceHi,
|
||||||
textTheme: const TextTheme(
|
outline: _border,
|
||||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
outlineVariant: _borderHi,
|
||||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
error: _err,
|
||||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
onError: Color(0xFF0B1220),
|
||||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
),
|
||||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
|
textTheme: const TextTheme(
|
||||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||||
),
|
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
|
||||||
);
|
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||||
|
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||||
|
),
|
||||||
|
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||||
|
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,70 +7,91 @@ import 'package:flutter/material.dart';
|
|||||||
import 'clide_theme.dart' show ClideTokens;
|
import 'clide_theme.dart' show ClideTokens;
|
||||||
|
|
||||||
class PaperTheme {
|
class PaperTheme {
|
||||||
static const _bg = Color(0xFFF4F1EA); // paper
|
static const _bg = Color(0xFFF4F1EA); // paper
|
||||||
static const _bgSunken = Color(0xFFECE7DB); // paper-2
|
static const _bgSunken = Color(0xFFECE7DB); // paper-2
|
||||||
static const _surface = Color(0xFFFBF8F1);
|
static const _surface = Color(0xFFFBF8F1);
|
||||||
static const _surfaceHi = Color(0xFFECE7DB);
|
static const _surfaceHi = Color(0xFFECE7DB);
|
||||||
static const _border = Color(0xFF1A1A1A);
|
static const _border = Color(0xFF1A1A1A);
|
||||||
static const _borderHi = Color(0xFF4A4A4A);
|
static const _borderHi = Color(0xFF4A4A4A);
|
||||||
|
|
||||||
static const _textHi = Color(0xFF1A1A1A); // ink
|
static const _textHi = Color(0xFF1A1A1A); // ink
|
||||||
static const _text = Color(0xFF4A4A4A); // ink-2
|
static const _text = Color(0xFF4A4A4A); // ink-2
|
||||||
static const _textDim = Color(0xFF8A8A82); // ink-3
|
static const _textDim = Color(0xFF8A8A82); // ink-3
|
||||||
static const _textMute = Color(0xFFA8A89E);
|
static const _textMute = Color(0xFFA8A89E);
|
||||||
|
|
||||||
static const _accent = Color(0xFFC14B2A); // red pencil
|
static const _accent = Color(0xFFC14B2A); // red pencil
|
||||||
static const _accentPress = Color(0xFFA03D20);
|
static const _accentPress = Color(0xFFA03D20);
|
||||||
static const _accentSoft = Color(0x22C14B2A);
|
static const _accentSoft = Color(0x22C14B2A);
|
||||||
|
|
||||||
static const _ok = Color(0xFF2D8A52);
|
static const _ok = Color(0xFF2D8A52);
|
||||||
static const _warn = Color(0xFFB88A2A);
|
static const _warn = Color(0xFFB88A2A);
|
||||||
static const _err = Color(0xFFB03A2A);
|
static const _err = Color(0xFFB03A2A);
|
||||||
static const _info = Color(0xFF2A6FC1);
|
static const _info = Color(0xFF2A6FC1);
|
||||||
|
|
||||||
// Syntax tuned for cream paper — desaturated so code reads like print.
|
// Syntax tuned for cream paper — desaturated so code reads like print.
|
||||||
static const _synKeyword = Color(0xFF7B3F8C);
|
static const _synKeyword = Color(0xFF7B3F8C);
|
||||||
static const _synType = Color(0xFF2A6FC1);
|
static const _synType = Color(0xFF2A6FC1);
|
||||||
static const _synString = Color(0xFF2D8A52);
|
static const _synString = Color(0xFF2D8A52);
|
||||||
static const _synNumber = Color(0xFFB88A2A);
|
static const _synNumber = Color(0xFFB88A2A);
|
||||||
static const _synComment = Color(0xFF8A8A82);
|
static const _synComment = Color(0xFF8A8A82);
|
||||||
static const _synMethod = Color(0xFF1E5D9E);
|
static const _synMethod = Color(0xFF1E5D9E);
|
||||||
static const _synPunct = Color(0xFF4A4A4A);
|
static const _synPunct = Color(0xFF4A4A4A);
|
||||||
|
|
||||||
static const tokens = ClideTokens(
|
static const tokens = ClideTokens(
|
||||||
bg: _bg, bgSunken: _bgSunken, surface: _surface, surfaceHi: _surfaceHi,
|
bg: _bg,
|
||||||
border: _border, borderHi: _borderHi,
|
bgSunken: _bgSunken,
|
||||||
textHi: _textHi, text: _text, textDim: _textDim, textMute: _textMute,
|
surface: _surface,
|
||||||
accent: _accent, accentPress: _accentPress, accentSoft: _accentSoft,
|
surfaceHi: _surfaceHi,
|
||||||
ok: _ok, warn: _warn, err: _err, info: _info,
|
border: _border,
|
||||||
synKeyword: _synKeyword, synType: _synType, synString: _synString,
|
borderHi: _borderHi,
|
||||||
synNumber: _synNumber, synComment: _synComment, synMethod: _synMethod,
|
textHi: _textHi,
|
||||||
|
text: _text,
|
||||||
|
textDim: _textDim,
|
||||||
|
textMute: _textMute,
|
||||||
|
accent: _accent,
|
||||||
|
accentPress: _accentPress,
|
||||||
|
accentSoft: _accentSoft,
|
||||||
|
ok: _ok,
|
||||||
|
warn: _warn,
|
||||||
|
err: _err,
|
||||||
|
info: _info,
|
||||||
|
synKeyword: _synKeyword,
|
||||||
|
synType: _synType,
|
||||||
|
synString: _synString,
|
||||||
|
synNumber: _synNumber,
|
||||||
|
synComment: _synComment,
|
||||||
|
synMethod: _synMethod,
|
||||||
synPunct: _synPunct,
|
synPunct: _synPunct,
|
||||||
);
|
);
|
||||||
|
|
||||||
static ThemeData get data => ThemeData(
|
static ThemeData get data => ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
scaffoldBackgroundColor: _bg,
|
scaffoldBackgroundColor: _bg,
|
||||||
canvasColor: _bg,
|
canvasColor: _bg,
|
||||||
colorScheme: const ColorScheme.light(
|
colorScheme: const ColorScheme.light(
|
||||||
primary: _accent, onPrimary: Color(0xFFFBF8F1),
|
primary: _accent,
|
||||||
secondary: _info, onSecondary: Color(0xFFFBF8F1),
|
onPrimary: Color(0xFFFBF8F1),
|
||||||
surface: _surface, onSurface: _textHi,
|
secondary: _info,
|
||||||
surfaceContainerHighest: _surfaceHi,
|
onSecondary: Color(0xFFFBF8F1),
|
||||||
outline: _border, outlineVariant: _borderHi,
|
surface: _surface,
|
||||||
error: _err, onError: Color(0xFFFBF8F1),
|
onSurface: _textHi,
|
||||||
),
|
surfaceContainerHighest: _surfaceHi,
|
||||||
textTheme: const TextTheme(
|
outline: _border,
|
||||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
outlineVariant: _borderHi,
|
||||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
error: _err,
|
||||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
onError: Color(0xFFFBF8F1),
|
||||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
),
|
||||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textDim),
|
textTheme: const TextTheme(
|
||||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||||
),
|
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||||
iconTheme: const IconThemeData(color: _text, size: 14),
|
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textDim),
|
||||||
);
|
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||||
|
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||||
|
),
|
||||||
|
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||||
|
iconTheme: const IconThemeData(color: _text, size: 14),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,71 +7,92 @@ import 'package:flutter/material.dart';
|
|||||||
import 'clide_theme.dart' show ClideTokens;
|
import 'clide_theme.dart' show ClideTokens;
|
||||||
|
|
||||||
class TerminalTheme {
|
class TerminalTheme {
|
||||||
static const _bg = Color(0xFF0A0A0A);
|
static const _bg = Color(0xFF0A0A0A);
|
||||||
static const _bgSunken = Color(0xFF000000);
|
static const _bgSunken = Color(0xFF000000);
|
||||||
static const _surface = Color(0xFF111111);
|
static const _surface = Color(0xFF111111);
|
||||||
static const _surfaceHi = Color(0xFF181818);
|
static const _surfaceHi = Color(0xFF181818);
|
||||||
static const _border = Color(0xFF242424);
|
static const _border = Color(0xFF242424);
|
||||||
static const _borderHi = Color(0xFF2E2E2E);
|
static const _borderHi = Color(0xFF2E2E2E);
|
||||||
|
|
||||||
static const _textHi = Color(0xFFE6E6E6);
|
static const _textHi = Color(0xFFE6E6E6);
|
||||||
static const _text = Color(0xFFBDBDBD);
|
static const _text = Color(0xFFBDBDBD);
|
||||||
static const _textDim = Color(0xFF7A7A7A);
|
static const _textDim = Color(0xFF7A7A7A);
|
||||||
static const _textMute = Color(0xFF4A4A4A);
|
static const _textMute = Color(0xFF4A4A4A);
|
||||||
|
|
||||||
static const _accent = Color(0xFFE0B050); // amber
|
static const _accent = Color(0xFFE0B050); // amber
|
||||||
static const _accentPress = Color(0xFFC29438);
|
static const _accentPress = Color(0xFFC29438);
|
||||||
static const _accentSoft = Color(0x22E0B050);
|
static const _accentSoft = Color(0x22E0B050);
|
||||||
|
|
||||||
static const _ok = Color(0xFF8FDC9B);
|
static const _ok = Color(0xFF8FDC9B);
|
||||||
static const _warn = Color(0xFFE0B050);
|
static const _warn = Color(0xFFE0B050);
|
||||||
static const _err = Color(0xFFE05050);
|
static const _err = Color(0xFFE05050);
|
||||||
static const _info = Color(0xFFA3C4FF);
|
static const _info = Color(0xFFA3C4FF);
|
||||||
|
|
||||||
// Classic 16-color palette feel
|
// Classic 16-color palette feel
|
||||||
static const _synKeyword = Color(0xFFE05050);
|
static const _synKeyword = Color(0xFFE05050);
|
||||||
static const _synType = Color(0xFFE0B050);
|
static const _synType = Color(0xFFE0B050);
|
||||||
static const _synString = Color(0xFF8FDC9B);
|
static const _synString = Color(0xFF8FDC9B);
|
||||||
static const _synNumber = Color(0xFFC792EA);
|
static const _synNumber = Color(0xFFC792EA);
|
||||||
static const _synComment = Color(0xFF4A4A4A);
|
static const _synComment = Color(0xFF4A4A4A);
|
||||||
static const _synMethod = Color(0xFFA3C4FF);
|
static const _synMethod = Color(0xFFA3C4FF);
|
||||||
static const _synPunct = Color(0xFF7A7A7A);
|
static const _synPunct = Color(0xFF7A7A7A);
|
||||||
|
|
||||||
static const tokens = ClideTokens(
|
static const tokens = ClideTokens(
|
||||||
bg: _bg, bgSunken: _bgSunken, surface: _surface, surfaceHi: _surfaceHi,
|
bg: _bg,
|
||||||
border: _border, borderHi: _borderHi,
|
bgSunken: _bgSunken,
|
||||||
textHi: _textHi, text: _text, textDim: _textDim, textMute: _textMute,
|
surface: _surface,
|
||||||
accent: _accent, accentPress: _accentPress, accentSoft: _accentSoft,
|
surfaceHi: _surfaceHi,
|
||||||
ok: _ok, warn: _warn, err: _err, info: _info,
|
border: _border,
|
||||||
synKeyword: _synKeyword, synType: _synType, synString: _synString,
|
borderHi: _borderHi,
|
||||||
synNumber: _synNumber, synComment: _synComment, synMethod: _synMethod,
|
textHi: _textHi,
|
||||||
|
text: _text,
|
||||||
|
textDim: _textDim,
|
||||||
|
textMute: _textMute,
|
||||||
|
accent: _accent,
|
||||||
|
accentPress: _accentPress,
|
||||||
|
accentSoft: _accentSoft,
|
||||||
|
ok: _ok,
|
||||||
|
warn: _warn,
|
||||||
|
err: _err,
|
||||||
|
info: _info,
|
||||||
|
synKeyword: _synKeyword,
|
||||||
|
synType: _synType,
|
||||||
|
synString: _synString,
|
||||||
|
synNumber: _synNumber,
|
||||||
|
synComment: _synComment,
|
||||||
|
synMethod: _synMethod,
|
||||||
synPunct: _synPunct,
|
synPunct: _synPunct,
|
||||||
);
|
);
|
||||||
|
|
||||||
static ThemeData get data => ThemeData(
|
static ThemeData get data => ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
scaffoldBackgroundColor: _bg,
|
scaffoldBackgroundColor: _bg,
|
||||||
canvasColor: _bg,
|
canvasColor: _bg,
|
||||||
colorScheme: const ColorScheme.dark(
|
colorScheme: const ColorScheme.dark(
|
||||||
primary: _accent, onPrimary: Color(0xFF000000),
|
primary: _accent,
|
||||||
secondary: _info, onSecondary: Color(0xFF000000),
|
onPrimary: Color(0xFF000000),
|
||||||
surface: _surface, onSurface: _textHi,
|
secondary: _info,
|
||||||
surfaceContainerHighest: _surfaceHi,
|
onSecondary: Color(0xFF000000),
|
||||||
outline: _border, outlineVariant: _borderHi,
|
surface: _surface,
|
||||||
error: _err, onError: Color(0xFF000000),
|
onSurface: _textHi,
|
||||||
),
|
surfaceContainerHighest: _surfaceHi,
|
||||||
textTheme: const TextTheme(
|
outline: _border,
|
||||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
outlineVariant: _borderHi,
|
||||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
error: _err,
|
||||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
onError: Color(0xFF000000),
|
||||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
),
|
||||||
// Terminal mockups go full-mono even for display.
|
textTheme: const TextTheme(
|
||||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
|
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||||
),
|
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
// Terminal mockups go full-mono even for display.
|
||||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
|
||||||
);
|
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||||
|
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||||
|
),
|
||||||
|
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||||
|
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,33 +72,33 @@ class ClideThemes {
|
|||||||
dark: true,
|
dark: true,
|
||||||
subtitle: 'cool near-black + periwinkle · default',
|
subtitle: 'cool near-black + periwinkle · default',
|
||||||
palette: ClidePalette(
|
palette: ClidePalette(
|
||||||
bg: Color(0xFF20202C),
|
bg: Color(0xFF20202C),
|
||||||
bgSunken: Color(0xFF1A1A24),
|
bgSunken: Color(0xFF1A1A24),
|
||||||
surface: Color(0xFF242838),
|
surface: Color(0xFF242838),
|
||||||
surfaceHi: Color(0xFF2C3046),
|
surfaceHi: Color(0xFF2C3046),
|
||||||
border: Color(0xFF343850),
|
border: Color(0xFF343850),
|
||||||
borderHi: Color(0xFF3C445C),
|
borderHi: Color(0xFF3C445C),
|
||||||
textHi: Color(0xFFE6E8F2),
|
textHi: Color(0xFFE6E8F2),
|
||||||
text: Color(0xFFB1BBE3),
|
text: Color(0xFFB1BBE3),
|
||||||
textDim: Color(0xFF78809C),
|
textDim: Color(0xFF78809C),
|
||||||
textMute: Color(0xFF545C84),
|
textMute: Color(0xFF545C84),
|
||||||
accent: Color(0xFF78A0F8),
|
accent: Color(0xFF78A0F8),
|
||||||
accentPress: Color(0xFF6C90DC),
|
accentPress: Color(0xFF6C90DC),
|
||||||
accentSoft: Color(0x2178A0F8),
|
accentSoft: Color(0x2178A0F8),
|
||||||
onAccent: Color(0xFF0D1020),
|
onAccent: Color(0xFF0D1020),
|
||||||
ok: Color(0xFF7DD3A8),
|
ok: Color(0xFF7DD3A8),
|
||||||
warn: Color(0xFFE6C370),
|
warn: Color(0xFFE6C370),
|
||||||
err: Color(0xFFE87D7D),
|
err: Color(0xFFE87D7D),
|
||||||
info: Color(0xFF78A0F8),
|
info: Color(0xFF78A0F8),
|
||||||
),
|
),
|
||||||
syntax: ClideSyntax(
|
syntax: ClideSyntax(
|
||||||
keyword: Color(0xFFC792EA),
|
keyword: Color(0xFFC792EA),
|
||||||
type: Color(0xFF78A0F8),
|
type: Color(0xFF78A0F8),
|
||||||
string: Color(0xFFA8D99B),
|
string: Color(0xFFA8D99B),
|
||||||
number: Color(0xFFE6C370),
|
number: Color(0xFFE6C370),
|
||||||
comment: Color(0xFF545C84),
|
comment: Color(0xFF545C84),
|
||||||
method: Color(0xFF82B1FF),
|
method: Color(0xFF82B1FF),
|
||||||
punct: Color(0xFF78809C),
|
punct: Color(0xFF78809C),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -108,33 +108,33 @@ class ClideThemes {
|
|||||||
dark: true,
|
dark: true,
|
||||||
subtitle: 'VS Code-adjacent muted dark',
|
subtitle: 'VS Code-adjacent muted dark',
|
||||||
palette: ClidePalette(
|
palette: ClidePalette(
|
||||||
bg: Color(0xFF1E1E1E),
|
bg: Color(0xFF1E1E1E),
|
||||||
bgSunken: Color(0xFF181818),
|
bgSunken: Color(0xFF181818),
|
||||||
surface: Color(0xFF252526),
|
surface: Color(0xFF252526),
|
||||||
surfaceHi: Color(0xFF2D2D2E),
|
surfaceHi: Color(0xFF2D2D2E),
|
||||||
border: Color(0xFF333333),
|
border: Color(0xFF333333),
|
||||||
borderHi: Color(0xFF3F3F3F),
|
borderHi: Color(0xFF3F3F3F),
|
||||||
textHi: Color(0xFFD4D4D4),
|
textHi: Color(0xFFD4D4D4),
|
||||||
text: Color(0xFFBBBBBB),
|
text: Color(0xFFBBBBBB),
|
||||||
textDim: Color(0xFF858585),
|
textDim: Color(0xFF858585),
|
||||||
textMute: Color(0xFF6A6A6A),
|
textMute: Color(0xFF6A6A6A),
|
||||||
accent: Color(0xFF569CD6),
|
accent: Color(0xFF569CD6),
|
||||||
accentPress: Color(0xFF4785BD),
|
accentPress: Color(0xFF4785BD),
|
||||||
accentSoft: Color(0x21569CD6),
|
accentSoft: Color(0x21569CD6),
|
||||||
onAccent: Color(0xFF0B1220),
|
onAccent: Color(0xFF0B1220),
|
||||||
ok: Color(0xFF89D185),
|
ok: Color(0xFF89D185),
|
||||||
warn: Color(0xFFD7BA7D),
|
warn: Color(0xFFD7BA7D),
|
||||||
err: Color(0xFFF48771),
|
err: Color(0xFFF48771),
|
||||||
info: Color(0xFF569CD6),
|
info: Color(0xFF569CD6),
|
||||||
),
|
),
|
||||||
syntax: ClideSyntax(
|
syntax: ClideSyntax(
|
||||||
keyword: Color(0xFFC586C0),
|
keyword: Color(0xFFC586C0),
|
||||||
type: Color(0xFF4EC9B0),
|
type: Color(0xFF4EC9B0),
|
||||||
string: Color(0xFFCE9178),
|
string: Color(0xFFCE9178),
|
||||||
number: Color(0xFFB5CEA8),
|
number: Color(0xFFB5CEA8),
|
||||||
comment: Color(0xFF6A9955),
|
comment: Color(0xFF6A9955),
|
||||||
method: Color(0xFFDCDCAA),
|
method: Color(0xFFDCDCAA),
|
||||||
punct: Color(0xFF858585),
|
punct: Color(0xFF858585),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -144,33 +144,33 @@ class ClideThemes {
|
|||||||
dark: false,
|
dark: false,
|
||||||
subtitle: 'drafting sheet · red-pencil accent · light',
|
subtitle: 'drafting sheet · red-pencil accent · light',
|
||||||
palette: ClidePalette(
|
palette: ClidePalette(
|
||||||
bg: Color(0xFFF4F1EA),
|
bg: Color(0xFFF4F1EA),
|
||||||
bgSunken: Color(0xFFECE7DB),
|
bgSunken: Color(0xFFECE7DB),
|
||||||
surface: Color(0xFFFBF8F1),
|
surface: Color(0xFFFBF8F1),
|
||||||
surfaceHi: Color(0xFFECE7DB),
|
surfaceHi: Color(0xFFECE7DB),
|
||||||
border: Color(0xFF1A1A1A),
|
border: Color(0xFF1A1A1A),
|
||||||
borderHi: Color(0xFF4A4A4A),
|
borderHi: Color(0xFF4A4A4A),
|
||||||
textHi: Color(0xFF1A1A1A),
|
textHi: Color(0xFF1A1A1A),
|
||||||
text: Color(0xFF4A4A4A),
|
text: Color(0xFF4A4A4A),
|
||||||
textDim: Color(0xFF8A8A82),
|
textDim: Color(0xFF8A8A82),
|
||||||
textMute: Color(0xFFA8A89E),
|
textMute: Color(0xFFA8A89E),
|
||||||
accent: Color(0xFFC14B2A),
|
accent: Color(0xFFC14B2A),
|
||||||
accentPress: Color(0xFFA03D20),
|
accentPress: Color(0xFFA03D20),
|
||||||
accentSoft: Color(0x21C14B2A),
|
accentSoft: Color(0x21C14B2A),
|
||||||
onAccent: Color(0xFFFBF8F1),
|
onAccent: Color(0xFFFBF8F1),
|
||||||
ok: Color(0xFF2D8A52),
|
ok: Color(0xFF2D8A52),
|
||||||
warn: Color(0xFFB88A2A),
|
warn: Color(0xFFB88A2A),
|
||||||
err: Color(0xFFB03A2A),
|
err: Color(0xFFB03A2A),
|
||||||
info: Color(0xFF2A6FC1),
|
info: Color(0xFF2A6FC1),
|
||||||
),
|
),
|
||||||
syntax: ClideSyntax(
|
syntax: ClideSyntax(
|
||||||
keyword: Color(0xFF7B3F8C),
|
keyword: Color(0xFF7B3F8C),
|
||||||
type: Color(0xFF2A6FC1),
|
type: Color(0xFF2A6FC1),
|
||||||
string: Color(0xFF2D8A52),
|
string: Color(0xFF2D8A52),
|
||||||
number: Color(0xFFB88A2A),
|
number: Color(0xFFB88A2A),
|
||||||
comment: Color(0xFF8A8A82),
|
comment: Color(0xFF8A8A82),
|
||||||
method: Color(0xFF1E5D9E),
|
method: Color(0xFF1E5D9E),
|
||||||
punct: Color(0xFF4A4A4A),
|
punct: Color(0xFF4A4A4A),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -180,33 +180,33 @@ class ClideThemes {
|
|||||||
dark: true,
|
dark: true,
|
||||||
subtitle: 'near-black + amber · tmux feel',
|
subtitle: 'near-black + amber · tmux feel',
|
||||||
palette: ClidePalette(
|
palette: ClidePalette(
|
||||||
bg: Color(0xFF0A0A0A),
|
bg: Color(0xFF0A0A0A),
|
||||||
bgSunken: Color(0xFF000000),
|
bgSunken: Color(0xFF000000),
|
||||||
surface: Color(0xFF111111),
|
surface: Color(0xFF111111),
|
||||||
surfaceHi: Color(0xFF181818),
|
surfaceHi: Color(0xFF181818),
|
||||||
border: Color(0xFF242424),
|
border: Color(0xFF242424),
|
||||||
borderHi: Color(0xFF2E2E2E),
|
borderHi: Color(0xFF2E2E2E),
|
||||||
textHi: Color(0xFFE6E6E6),
|
textHi: Color(0xFFE6E6E6),
|
||||||
text: Color(0xFFBDBDBD),
|
text: Color(0xFFBDBDBD),
|
||||||
textDim: Color(0xFF7A7A7A),
|
textDim: Color(0xFF7A7A7A),
|
||||||
textMute: Color(0xFF4A4A4A),
|
textMute: Color(0xFF4A4A4A),
|
||||||
accent: Color(0xFFE0B050),
|
accent: Color(0xFFE0B050),
|
||||||
accentPress: Color(0xFFC29438),
|
accentPress: Color(0xFFC29438),
|
||||||
accentSoft: Color(0x21E0B050),
|
accentSoft: Color(0x21E0B050),
|
||||||
onAccent: Color(0xFF000000),
|
onAccent: Color(0xFF000000),
|
||||||
ok: Color(0xFF8FDC9B),
|
ok: Color(0xFF8FDC9B),
|
||||||
warn: Color(0xFFE0B050),
|
warn: Color(0xFFE0B050),
|
||||||
err: Color(0xFFE05050),
|
err: Color(0xFFE05050),
|
||||||
info: Color(0xFFA3C4FF),
|
info: Color(0xFFA3C4FF),
|
||||||
),
|
),
|
||||||
syntax: ClideSyntax(
|
syntax: ClideSyntax(
|
||||||
keyword: Color(0xFFE05050),
|
keyword: Color(0xFFE05050),
|
||||||
type: Color(0xFFE0B050),
|
type: Color(0xFFE0B050),
|
||||||
string: Color(0xFF8FDC9B),
|
string: Color(0xFF8FDC9B),
|
||||||
number: Color(0xFFC792EA),
|
number: Color(0xFFC792EA),
|
||||||
comment: Color(0xFF4A4A4A),
|
comment: Color(0xFF4A4A4A),
|
||||||
method: Color(0xFFA3C4FF),
|
method: Color(0xFFA3C4FF),
|
||||||
punct: Color(0xFF7A7A7A),
|
punct: Color(0xFF7A7A7A),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -332,8 +332,8 @@ node; Claude can add a node by `clide canvas node …`.
|
|||||||
|
|
||||||
- **Flutter Desktop on Linux + macOS** is the primary target;
|
- **Flutter Desktop on Linux + macOS** is the primary target;
|
||||||
Windows is a stretch goal. Mobile is not a goal.
|
Windows is a stretch goal. Mobile is not a goal.
|
||||||
- **Gitea remote** (`git.schweitz.net/jpmschweitzer/clide`) remains
|
- **GitHub remote** (`github.com/postmeridiem/clide`) is the
|
||||||
the canonical host.
|
canonical host.
|
||||||
- **`pql` stays untouched** for Tiers 0–2; upstream pql work
|
- **`pql` stays untouched** for Tiers 0–2; upstream pql work
|
||||||
happens in pql's repo during Tiers 3–5.
|
happens in pql's repo during Tiers 3–5.
|
||||||
- **tmux** is optional plumbing the sidecar can compose for team
|
- **tmux** is optional plumbing the sidecar can compose for team
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# macOS PTY Problem — Diagnosis Complete
|
||||||
|
|
||||||
|
## Status: Root cause found
|
||||||
|
|
||||||
|
The PTY master fd is valid (`isatty=1`), SCM_RIGHTS transfer is correct, all struct layouts are correct. The problem is **timing**: the reader isolate starts too late and the shell has already exited by the time `read()` is called on the master fd. macOS returns EOF (n=0) immediately when the slave side is closed — unlike Linux which buffers data.
|
||||||
|
|
||||||
|
## The timing sequence (what happens)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Dart: socketpair()
|
||||||
|
2. Dart: Process.start(ptyc)
|
||||||
|
3. ptyc: posix_openpt + grantpt + unlockpt + open(slave)
|
||||||
|
4. ptyc: fork() → child starts zsh
|
||||||
|
5. ptyc: sendmsg(SCM_RIGHTS, master_fd) ← master fd sent
|
||||||
|
6. ptyc: printf(ok) + exit (or block if diagnostic)
|
||||||
|
7. Dart: recvmsg() → receives master_fd ← fd is valid, isatty=1
|
||||||
|
8. Dart: PtySession._() constructor
|
||||||
|
9. Dart: _startReader() → Isolate.spawn() ← ASYNC, does not start immediately
|
||||||
|
10. ... event loop yields ...
|
||||||
|
11. Reader isolate starts, calls read(masterFd)
|
||||||
|
12. But zsh already exited → slave closed → read() returns 0 (EOF)
|
||||||
|
```
|
||||||
|
|
||||||
|
The gap between step 9 (Isolate.spawn scheduled) and step 11 (reader actually runs) is where the data is lost. On macOS, the kernel doesn't buffer PTY master data after the slave closes.
|
||||||
|
|
||||||
|
## Why is zsh exiting?
|
||||||
|
|
||||||
|
zsh (`/bin/zsh`) is an interactive shell. It should NOT exit immediately — it should show a prompt and wait for input. But in the test, it does exit. Possible reasons:
|
||||||
|
|
||||||
|
1. **No controlling terminal at spawn time.** ptyc's child does `setsid() + TIOCSCTTY + dup2(slave, 0/1/2)` — this should work. But if the slave fd is already closed or invalid by the time dup2 runs, zsh gets no tty and exits.
|
||||||
|
|
||||||
|
2. **Environment.** The `env` passed to ptyc may be empty or missing `TERM`, `HOME`, `SHELL` etc. Without `TERM`, zsh may fail to initialize and exit.
|
||||||
|
|
||||||
|
3. **The `PTYC_SOCK_FD` inherits open fds.** When ptyc forks, the child inherits ALL open fds (master, slave, socket, exec-failure pipe). ptyc closes master and pipe in the child, but the socket fd stays open. This shouldn't cause an exit, but it's worth checking.
|
||||||
|
|
||||||
|
4. **stdin is connected to the slave.** After dup2(slave, 0), zsh reads from the PTY slave. If Dart hasn't written anything to the master AND there's no PTY echo (because the master isn't being read), zsh might get SIGHUP or detect a broken pipe.
|
||||||
|
|
||||||
|
## Verified facts
|
||||||
|
|
||||||
|
| Test | Result |
|
||||||
|
|------|--------|
|
||||||
|
| ptyc compiles on macOS | ✓ |
|
||||||
|
| socketpair fd inherited by ptyc | ✓ (verified with test binary) |
|
||||||
|
| ptyc creates PTY and forks | ✓ (`{"ok":true,"pid":N}`) |
|
||||||
|
| SCM_RIGHTS transfer | ✓ (correct cmsg layout) |
|
||||||
|
| cmsghdr struct (Dart) | ✓ (CmsghdrDarwin, 12 bytes, correct offsets) |
|
||||||
|
| msghdr struct (Dart) | ✓ (MsghdrDarwin, correct field sizes) |
|
||||||
|
| SOL_SOCKET | ✓ (0xffff on macOS) |
|
||||||
|
| TIOCSWINSZ | ✓ (0x80087467 on macOS) |
|
||||||
|
| Received fd is a tty | ✓ (isatty=1) |
|
||||||
|
| Reader isolate starts | ✓ (prints "started, fd=N") |
|
||||||
|
| Reader gets data | ✗ — EOF immediately (n=0) |
|
||||||
|
| Keeping ptyc alive helps | ✗ — still EOF |
|
||||||
|
| close(master) in ptyc is the cause | ✗ — disproven |
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
1. **Investigate why zsh exits immediately.** Add logging to ptyc's child process to verify it reaches execvp. Check if the child gets a signal (SIGHUP, SIGTERM) right after exec.
|
||||||
|
|
||||||
|
2. **Check the environment passed to ptyc.** If `env` is empty, the child shell has no `TERM`, `HOME`, etc. and may exit immediately.
|
||||||
|
|
||||||
|
3. **Try a long-running command** instead of zsh — e.g. `sleep 10` — to rule out shell-specific init failures.
|
||||||
|
|
||||||
|
4. **Consider `forkpty()` on macOS** — eliminates the timing gap entirely. `forkpty()` creates the PTY, forks, and returns the master fd all in one call from the same process. The reader can start before the child is even exec'd.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- macOS 26.3.1 (Darwin 25.3.0), Apple Silicon (arm64)
|
||||||
|
- Flutter 3.41.7, Developer ID signed (no sandbox)
|
||||||
|
- pql 1.4.4, dugite-native git 2.53.0
|
||||||
|
- ptyc compiled with: `cc -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 -D_FORTIFY_SOURCE=2 -D_DARWIN_C_SOURCE`
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# PTY on macOS: A New Diagnostic and Resolution Plan
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
Previous attempts to fix the PTY functionality on macOS have failed, even after correcting a deadlock in the Dart code. The core of the problem appears to be a fundamental mismatch in how the C helper (`ptyc`) constructs a control message and how the Dart FFI layer is trying to parse it.
|
||||||
|
|
||||||
|
This document proposes a new, systematic plan to diagnose and resolve this issue by focusing on a key unresolved contradiction: the length of the ancillary data message (`cmsg_len`).
|
||||||
|
|
||||||
|
## 2. The Unresolved Contradiction
|
||||||
|
|
||||||
|
The primary blocker is a discrepancy in the expected length of the `SCM_RIGHTS` control message:
|
||||||
|
|
||||||
|
- **Observed Behavior:** The raw byte dump provided in `docs/macos-pty-problem.md` from the Dart `recvmsg` call shows the `cmsg_len` field as **16 bytes**.
|
||||||
|
- **Code Analysis:** An analysis of the POSIX `CMSG_LEN` macro, as used in `ptyc.c`, suggests the length should be **20 bytes** on a 64-bit macOS system.
|
||||||
|
|
||||||
|
This contradiction means we cannot be certain about the memory layout of the data we are parsing in Dart. Without resolving this, any attempt to fix the data offset is guesswork.
|
||||||
|
|
||||||
|
## 3. Proposed Investigation and Resolution Plan
|
||||||
|
|
||||||
|
This plan will definitively resolve the `cmsg_len` discrepancy and lead to a correct implementation.
|
||||||
|
|
||||||
|
### Step 1: Instrument `ptyc` to Reveal Ground Truth
|
||||||
|
|
||||||
|
The first step is to get definitive data from the source. We will modify `ptyc.c` to log the exact values it's using.
|
||||||
|
|
||||||
|
- **Action:** Add `fprintf(stderr, ...)` statements in `ptyc.c` right before the `sendmsg` call.
|
||||||
|
- **Data to Log:**
|
||||||
|
1. The calculated value of `CMSG_LEN(sizeof(int))`.
|
||||||
|
2. The value of `CMSG_SPACE(sizeof(int))`.
|
||||||
|
3. The value of `sizeof(struct cmsghdr)`.
|
||||||
|
- **Expected Outcome:** This will give us the "ground truth" of the control message structure as constructed by `ptyc` in the actual build environment, resolving the 16-vs-20-byte mystery.
|
||||||
|
|
||||||
|
### Step 2: Correctly Implement the Dart FFI Parser
|
||||||
|
|
||||||
|
With the true `cmsg_len` and memory layout confirmed, we can correctly implement the Dart-side parser.
|
||||||
|
|
||||||
|
- **Action:** Modify `lib/src/pty/ffi/scm_rights.dart`.
|
||||||
|
- **Logic:**
|
||||||
|
- If the logged `cmsg_len` from Step 1 confirms there is alignment padding (i.e., data starts at offset 16), the `dataOffset` calculation will be updated to `16`.
|
||||||
|
- If the logged data shows no padding (i.e., data starts at offset 12), the `dataOffset` will be confirmed as `12`, and we will know the issue lies elsewhere.
|
||||||
|
- **Expected Outcome:** A Dart parser (`recvFd`) that correctly reads the file descriptor from the ancillary data based on empirical evidence, not theoretical calculation.
|
||||||
|
|
||||||
|
### Step 3: Ensure Asynchronous Operation
|
||||||
|
|
||||||
|
The previously identified deadlock, while not the root cause of this specific failure, is still a critical bug.
|
||||||
|
|
||||||
|
- **Action:** Ensure the fix in `lib/src/pty/session.dart` is applied, where the blocking `scm.recvFd` call is replaced with its asynchronous counterpart, `_recvFdAsync`.
|
||||||
|
- **Expected Outcome:** The Dart event loop is not blocked during PTY creation, preventing deadlocks.
|
||||||
|
|
||||||
|
### Step 4: Verification
|
||||||
|
|
||||||
|
With the above changes in place, we will verify the complete solution.
|
||||||
|
|
||||||
|
- **Action:** Run the existing test suite via `flutter test test/pty/session_test.dart`.
|
||||||
|
- **Success Criteria:** All tests in `session_test.dart` must pass, indicating that a PTY can be spawned, its output can be read, and data can be written to it.
|
||||||
|
|
||||||
|
This methodical approach replaces guesswork with a data-driven diagnosis, providing a clear and direct path to resolving the long-standing macOS PTY issue.
|
||||||
@@ -21,9 +21,7 @@ import '../test/helpers/fake_ipc.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
testWidgets(
|
testWidgets('clide app boots with classic 3-column layout + welcome + statusbar', (tester) async {
|
||||||
'clide app boots with classic 3-column layout + welcome + statusbar',
|
|
||||||
(tester) async {
|
|
||||||
final themes = [
|
final themes = [
|
||||||
await const ThemeLoader().fromAsset(
|
await const ThemeLoader().fromAsset(
|
||||||
rootBundle,
|
rootBundle,
|
||||||
@@ -40,8 +38,7 @@ void main() {
|
|||||||
'builtin.theme-picker',
|
'builtin.theme-picker',
|
||||||
'builtin.default-layout',
|
'builtin.default-layout',
|
||||||
],
|
],
|
||||||
daemonClientFactory: (log, events) =>
|
daemonClientFactory: (log, events) => FakeDaemonClient(log: log, events: events),
|
||||||
FakeDaemonClient(log: log, events: events),
|
|
||||||
autoStartDaemonClient: false,
|
autoStartDaemonClient: false,
|
||||||
);
|
);
|
||||||
services.extensions
|
services.extensions
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import '../test/helpers/fake_ipc.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
testWidgets('disable + re-enable an extension mounts/unmounts its UI',
|
testWidgets('disable + re-enable an extension mounts/unmounts its UI', (tester) async {
|
||||||
(tester) async {
|
|
||||||
final themes = [
|
final themes = [
|
||||||
await const ThemeLoader().fromAsset(
|
await const ThemeLoader().fromAsset(
|
||||||
rootBundle,
|
rootBundle,
|
||||||
@@ -31,8 +30,7 @@ void main() {
|
|||||||
'builtin.ipc-status',
|
'builtin.ipc-status',
|
||||||
'builtin.default-layout',
|
'builtin.default-layout',
|
||||||
],
|
],
|
||||||
daemonClientFactory: (log, events) =>
|
daemonClientFactory: (log, events) => FakeDaemonClient(log: log, events: events),
|
||||||
FakeDaemonClient(log: log, events: events),
|
|
||||||
autoStartDaemonClient: false,
|
autoStartDaemonClient: false,
|
||||||
);
|
);
|
||||||
services.extensions
|
services.extensions
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import '../test/helpers/fake_ipc.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
testWidgets('theme.pick command opens modal; selecting dismisses it',
|
testWidgets('theme.pick command opens modal; selecting dismisses it', (tester) async {
|
||||||
(tester) async {
|
|
||||||
final themes = [
|
final themes = [
|
||||||
await const ThemeLoader().fromAsset(
|
await const ThemeLoader().fromAsset(
|
||||||
rootBundle,
|
rootBundle,
|
||||||
@@ -31,8 +30,7 @@ void main() {
|
|||||||
'builtin.theme-picker',
|
'builtin.theme-picker',
|
||||||
'builtin.default-layout',
|
'builtin.default-layout',
|
||||||
],
|
],
|
||||||
daemonClientFactory: (log, events) =>
|
daemonClientFactory: (log, events) => FakeDaemonClient(log: log, events: events),
|
||||||
FakeDaemonClient(log: log, events: events),
|
|
||||||
autoStartDaemonClient: false,
|
autoStartDaemonClient: false,
|
||||||
);
|
);
|
||||||
services.extensions
|
services.extensions
|
||||||
|
|||||||
@@ -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/builtin/welcome/src/welcome_view.dart';
|
||||||
import 'package:clide/extension/src/contribution.dart';
|
import 'package:clide/extension/src/contribution.dart';
|
||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
import 'package:clide/widgets/widgets.dart';
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
@@ -51,6 +55,11 @@ class _RootShell extends StatefulWidget {
|
|||||||
|
|
||||||
class _RootShellState extends State<_RootShell> {
|
class _RootShellState extends State<_RootShell> {
|
||||||
late final FocusNode _keyFocus;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -71,24 +80,38 @@ class _RootShellState extends State<_RootShell> {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: tokens.globalForeground,
|
color: tokens.globalForeground,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
|
height: clideLineHeight,
|
||||||
fontWeight: clideUiDefaultWeight,
|
fontWeight: clideUiDefaultWeight,
|
||||||
fontFamily: clideUiFamily,
|
fontFamily: clideUiFamily,
|
||||||
fontFamilyFallback: clideUiFamilyFallback,
|
fontFamilyFallback: clideUiFamilyFallback,
|
||||||
),
|
),
|
||||||
child: KeyboardListener(
|
child: MediaQuery(
|
||||||
focusNode: _keyFocus,
|
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(_textScale)),
|
||||||
autofocus: true,
|
child: KeyboardListener(
|
||||||
onKeyEvent: _onKey,
|
focusNode: _keyFocus,
|
||||||
child: ColoredBox(
|
autofocus: true,
|
||||||
color: tokens.globalBackground,
|
onKeyEvent: _onKey,
|
||||||
child: DialogHost(
|
child: ColoredBox(
|
||||||
router: widget.services.dialog,
|
color: tokens.globalBackground,
|
||||||
child: Stack(
|
child: ClideResizeBorder(
|
||||||
children: [
|
windowControls: widget.services.window,
|
||||||
const Positioned.fill(child: RootLayout()),
|
child: Column(
|
||||||
const ClidePalette(),
|
children: [
|
||||||
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()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -97,6 +120,23 @@ class _RootShellState extends State<_RootShell> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onKey(KeyEvent event) {
|
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(
|
final binding = KeybindingResolver.fromKeyEvent(
|
||||||
event,
|
event,
|
||||||
HardwareKeyboard.instance,
|
HardwareKeyboard.instance,
|
||||||
@@ -123,8 +163,8 @@ class RootLayout extends StatelessWidget {
|
|||||||
final contextVisible = a.isVisible(Slots.contextPanel);
|
final contextVisible = a.isVisible(Slots.contextPanel);
|
||||||
final contextCollapsed = a.isCollapsed(Slots.contextPanel);
|
final contextCollapsed = a.isCollapsed(Slots.contextPanel);
|
||||||
final statusVisible = a.isVisible(Slots.statusbar);
|
final statusVisible = a.isVisible(Slots.statusbar);
|
||||||
final sidebarSize = a.sizeOf(Slots.sidebar) ?? 240;
|
final sidebarSize = a.sizeOf(Slots.sidebar) ?? 400;
|
||||||
final contextSize = a.sizeOf(Slots.contextPanel) ?? 280;
|
final contextSize = a.sizeOf(Slots.contextPanel) ?? 420;
|
||||||
final statusHeight = a.sizeOf(Slots.statusbar) ?? 26;
|
final statusHeight = a.sizeOf(Slots.statusbar) ?? 26;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
@@ -173,7 +213,7 @@ class RootLayout extends StatelessWidget {
|
|||||||
if (statusVisible)
|
if (statusVisible)
|
||||||
Container(
|
Container(
|
||||||
height: statusHeight,
|
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(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -206,6 +246,479 @@ 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();
|
||||||
|
// On macOS the native titlebar draws traffic lights; skip duplicates.
|
||||||
|
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 _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() async {
|
||||||
|
widget.onDismiss();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final picked = await widget.kernel.window.pickDirectory();
|
||||||
|
if (picked != null) {
|
||||||
|
final ok = await widget.kernel.project.open(picked);
|
||||||
|
if (ok) {
|
||||||
|
widget.kernel.panels.activateTab(Slots.workspace, 'claude.primary');
|
||||||
|
} else {
|
||||||
|
widget.kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(
|
||||||
|
path: picked,
|
||||||
|
onDismiss: () => dismiss(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Fall through to text dialog.
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 480,
|
||||||
|
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: Platform.isMacOS ? '⌘O' : 'Ctrl+O', tokens: tokens, onTap: _openFolder),
|
||||||
|
_ActionRow(label: 'New Window', shortcut: Platform.isMacOS ? '⌘⇧N' : 'Ctrl+Shift+N', tokens: tokens, onTap: _newWindow),
|
||||||
|
if (widget.kernel.project.isOpen) _ActionRow(label: 'Close Project', 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 _NotARepoDialog extends StatelessWidget {
|
||||||
|
const _NotARepoDialog({required this.path, required this.onDismiss});
|
||||||
|
final String path;
|
||||||
|
final VoidCallback onDismiss;
|
||||||
|
|
||||||
|
@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('No git repo found', fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ClideText(path, muted: true, fontSize: 13),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const ClideText(
|
||||||
|
'A clide project root requires a git repository.',
|
||||||
|
muted: true,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
ClideButton(label: 'OK', onPressed: () => onDismiss()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class SlotHost extends StatelessWidget {
|
class SlotHost extends StatelessWidget {
|
||||||
const SlotHost({super.key, required this.slot});
|
const SlotHost({super.key, required this.slot});
|
||||||
final SlotId slot;
|
final SlotId slot;
|
||||||
@@ -298,8 +811,9 @@ class _SidebarSlot extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
return Container(
|
return Container(
|
||||||
color: tokens.sidebarBackground,
|
color: tokens.chromeBackground,
|
||||||
alignment: Alignment.topLeft,
|
alignment: Alignment.topLeft,
|
||||||
|
padding: const EdgeInsets.fromLTRB(2, 2, 0, 0),
|
||||||
child: active.build(context),
|
child: active.build(context),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -416,6 +930,7 @@ class _ContextSlot extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
color: tokens.panelBackground,
|
color: tokens.panelBackground,
|
||||||
alignment: Alignment.topLeft,
|
alignment: Alignment.topLeft,
|
||||||
|
padding: const EdgeInsets.only(right: 2),
|
||||||
child: active.build(context),
|
child: active.build(context),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -433,10 +948,10 @@ class _BottomRail extends StatelessWidget {
|
|||||||
listenable: kernel.panels,
|
listenable: kernel.panels,
|
||||||
builder: (ctx, _) {
|
builder: (ctx, _) {
|
||||||
final tabs = kernel.panels.tabsFor(slot);
|
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;
|
final activeId = kernel.panels.activeTabIn(slot) ?? tabs.first.id;
|
||||||
return Container(
|
return Container(
|
||||||
color: tokens.statusBarBackground,
|
color: tokens.chromeBackground,
|
||||||
child: ClideIconRail(
|
child: ClideIconRail(
|
||||||
items: [
|
items: [
|
||||||
for (final t in tabs)
|
for (final t in tabs)
|
||||||
@@ -490,7 +1005,7 @@ class StatusbarHost extends StatelessWidget {
|
|||||||
final left = items.where((i) => i.priority < 100).toList();
|
final left = items.where((i) => i.priority < 100).toList();
|
||||||
final right = items.where((i) => i.priority >= 100).toList();
|
final right = items.where((i) => i.priority >= 100).toList();
|
||||||
return Container(
|
return Container(
|
||||||
color: tokens.statusBarBackground,
|
color: tokens.chromeBackground,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ class ClaudePane extends StatefulWidget {
|
|||||||
this.isPrimary = true,
|
this.isPrimary = true,
|
||||||
this.secondaryIndex,
|
this.secondaryIndex,
|
||||||
this.showChrome = true,
|
this.showChrome = true,
|
||||||
}) : assert(isPrimary || secondaryIndex != null,
|
}) : assert(isPrimary || secondaryIndex != null, 'secondary panes need an index');
|
||||||
'secondary panes need an index');
|
|
||||||
|
|
||||||
final bool isPrimary;
|
final bool isPrimary;
|
||||||
final bool showChrome;
|
final bool showChrome;
|
||||||
@@ -48,20 +47,26 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
late final Terminal _terminal;
|
late final Terminal _terminal;
|
||||||
StreamSubscription<DaemonEvent>? _eventSub;
|
StreamSubscription<DaemonEvent>? _eventSub;
|
||||||
String? _paneId;
|
String? _paneId;
|
||||||
|
String? _sessionName;
|
||||||
String? _error;
|
String? _error;
|
||||||
String _statusLine = 'attaching…';
|
String _statusLine = 'attaching…';
|
||||||
|
|
||||||
|
bool _spawned = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_terminal = Terminal(maxLines: _maxLines);
|
_terminal = Terminal(maxLines: _maxLines);
|
||||||
_terminal.onOutput = _onOutput;
|
_terminal.onOutput = _onOutput;
|
||||||
_terminal.onResize = _onResize;
|
_terminal.onResize = _onResize;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _spawn());
|
// Don't spawn here — wait for the first onResize from TerminalView
|
||||||
|
// so the PTY gets real dimensions, not 80x24 defaults.
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_resizeTimer?.cancel();
|
||||||
|
_flushTimer?.cancel();
|
||||||
_eventSub?.cancel();
|
_eventSub?.cancel();
|
||||||
_eventSub = null;
|
_eventSub = null;
|
||||||
final id = _paneId;
|
final id = _paneId;
|
||||||
@@ -78,6 +83,25 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _spawnWhenReady() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
final kernel = ClideKernel.of(context);
|
||||||
|
if (!kernel.project.isOpen) {
|
||||||
|
// Wait for a project to open before spawning.
|
||||||
|
final c = Completer<void>();
|
||||||
|
late final StreamSubscription<ProjectOpened> sub;
|
||||||
|
sub = kernel.events.on<ProjectOpened>().listen((_) {
|
||||||
|
sub.cancel();
|
||||||
|
if (!c.isCompleted) c.complete();
|
||||||
|
});
|
||||||
|
await c.future.timeout(const Duration(seconds: 10), onTimeout: () {
|
||||||
|
sub.cancel();
|
||||||
|
});
|
||||||
|
if (!mounted) return;
|
||||||
|
}
|
||||||
|
return _spawn();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _spawn() async {
|
Future<void> _spawn() async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final ipc = _ipc();
|
final ipc = _ipc();
|
||||||
@@ -95,28 +119,32 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
repoRoot = (rootResp.data['path'] as String?) ?? repoRoot;
|
repoRoot = (rootResp.data['path'] as String?) ?? repoRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
final sessionName = widget.isPrimary
|
_sessionName = widget.isPrimary ? primarySessionName(repoRoot) : secondarySessionName(repoRoot, widget.secondaryIndex!);
|
||||||
? primarySessionName(repoRoot)
|
|
||||||
: secondarySessionName(repoRoot, widget.secondaryIndex!);
|
|
||||||
|
|
||||||
// Try tmux-wrapped first (persistence). Fall back to direct claude
|
// tmux-wrapped session for persistence (D-041).
|
||||||
// if tmux spawn errors.
|
// -x/-y set the initial window size; without them tmux defaults
|
||||||
|
// to a huge size when running inside a PTY without a real terminal.
|
||||||
|
final cols = _terminal.viewWidth;
|
||||||
|
final rows = _terminal.viewHeight;
|
||||||
var argv = <String>[
|
var argv = <String>[
|
||||||
'tmux',
|
'tmux',
|
||||||
'new-session',
|
'new-session',
|
||||||
'-A',
|
'-A',
|
||||||
'-s',
|
'-s',
|
||||||
sessionName,
|
_sessionName!,
|
||||||
'--',
|
'-x',
|
||||||
'claude',
|
'$cols',
|
||||||
|
'-y',
|
||||||
|
'$rows',
|
||||||
];
|
];
|
||||||
|
print('[spawn] cols=${_terminal.viewWidth} rows=${_terminal.viewHeight}');
|
||||||
var resp = await ipc.request('pane.spawn', args: {
|
var resp = await ipc.request('pane.spawn', args: {
|
||||||
'argv': argv,
|
'argv': argv,
|
||||||
'kind': PaneKind.claude.wire,
|
'kind': PaneKind.claude.wire,
|
||||||
'cwd': repoRoot,
|
'cwd': repoRoot,
|
||||||
'cols': _terminal.viewWidth,
|
'cols': _terminal.viewWidth,
|
||||||
'rows': _terminal.viewHeight,
|
'rows': _terminal.viewHeight,
|
||||||
'title': sessionName,
|
'title': _sessionName,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
@@ -129,7 +157,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
'cwd': repoRoot,
|
'cwd': repoRoot,
|
||||||
'cols': _terminal.viewWidth,
|
'cols': _terminal.viewWidth,
|
||||||
'rows': _terminal.viewHeight,
|
'rows': _terminal.viewHeight,
|
||||||
'title': sessionName,
|
'title': _sessionName,
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -139,7 +167,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
}
|
}
|
||||||
setState(() => _statusLine = 'no-tmux · fresh every launch');
|
setState(() => _statusLine = 'no-tmux · fresh every launch');
|
||||||
} else {
|
} else {
|
||||||
setState(() => _statusLine = 'tmux · $sessionName');
|
setState(() => _statusLine = 'tmux · $_sessionName');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -149,6 +177,16 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final _outputBuf = StringBuffer();
|
||||||
|
Timer? _flushTimer;
|
||||||
|
|
||||||
|
void _flushOutput() {
|
||||||
|
_flushTimer = null;
|
||||||
|
if (_outputBuf.isEmpty) return;
|
||||||
|
_terminal.write(_outputBuf.toString());
|
||||||
|
_outputBuf.clear();
|
||||||
|
}
|
||||||
|
|
||||||
void _subscribe() {
|
void _subscribe() {
|
||||||
final kernel = _kernel();
|
final kernel = _kernel();
|
||||||
if (kernel == null) return;
|
if (kernel == null) return;
|
||||||
@@ -158,7 +196,14 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
case 'pane.output':
|
case 'pane.output':
|
||||||
final b64 = e.data['bytes_b64'];
|
final b64 = e.data['bytes_b64'];
|
||||||
if (b64 is String) {
|
if (b64 is String) {
|
||||||
_terminal.write(utf8.decode(base64Decode(b64), allowMalformed: true));
|
_outputBuf.write(utf8.decode(base64Decode(b64), allowMalformed: true));
|
||||||
|
// Batch all output from the current event loop turn into one
|
||||||
|
// terminal.write() call. scheduleMicrotask runs after all
|
||||||
|
// pending events but before the next frame, so split escape
|
||||||
|
// sequences within the same event batch are reunited.
|
||||||
|
if (_flushTimer == null) {
|
||||||
|
_flushTimer = Timer(Duration.zero, _flushOutput);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case 'pane.exit':
|
case 'pane.exit':
|
||||||
if (widget.isPrimary) {
|
if (widget.isPrimary) {
|
||||||
@@ -181,10 +226,29 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
_ipc()?.request('pane.write', args: {'id': id, 'text': text});
|
_ipc()?.request('pane.write', args: {'id': id, 'text': text});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer? _resizeTimer;
|
||||||
|
|
||||||
void _onResize(int cols, int rows, int _, int __) {
|
void _onResize(int cols, int rows, int _, int __) {
|
||||||
final id = _paneId;
|
print('[onResize] cols=$cols rows=$rows spawned=$_spawned paneId=$_paneId');
|
||||||
if (id == null) return;
|
if (!_spawned) {
|
||||||
_ipc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
|
// First resize — TerminalView has real dimensions now.
|
||||||
|
_spawned = true;
|
||||||
|
_spawnWhenReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Debounce resize — rapid SIGWINCH during window drag corrupts
|
||||||
|
// the terminal rendering. Wait for the resize to settle.
|
||||||
|
_resizeTimer?.cancel();
|
||||||
|
_resizeTimer = Timer(const Duration(milliseconds: 150), () {
|
||||||
|
final id = _paneId;
|
||||||
|
if (id == null) return;
|
||||||
|
_ipc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
|
||||||
|
// tmux sizes windows by client, not PTY winsize. Explicitly
|
||||||
|
// resize the tmux window to match the TerminalView dimensions.
|
||||||
|
if (_sessionName != null) {
|
||||||
|
Process.run('tmux', ['resize-window', '-t', _sessionName!, '-x', '$cols', '-y', '$rows']);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
DaemonClient? _ipc() => _kernel()?.ipc;
|
DaemonClient? _ipc() => _kernel()?.ipc;
|
||||||
@@ -199,9 +263,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final title = widget.isPrimary
|
final title = widget.isPrimary ? 'claude — primary' : 'claude — secondary ${widget.secondaryIndex}';
|
||||||
? 'claude — primary'
|
|
||||||
: 'claude — secondary ${widget.secondaryIndex}';
|
|
||||||
final body = _error != null
|
final body = _error != null
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|||||||
@@ -109,7 +109,13 @@ class _TabRow extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: tokens.dividerColor))),
|
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: tokens.dividerColor))),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < sessions.length; i++) _Tab(session: sessions[i], active: i == activeIndex, tokens: tokens, onTap: () => onSelect(i), onClose: sessions[i].isPrimary ? null : () => onClose(i)),
|
for (var i = 0; i < sessions.length; i++)
|
||||||
|
_Tab(
|
||||||
|
session: sessions[i],
|
||||||
|
active: i == activeIndex,
|
||||||
|
tokens: tokens,
|
||||||
|
onTap: () => onSelect(i),
|
||||||
|
onClose: sessions[i].isPrimary ? null : () => onClose(i)),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
_AddButton(tokens: tokens, onTap: onAdd),
|
_AddButton(tokens: tokens, onTap: onAdd),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
@@ -120,7 +126,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});
|
const _Tab({required this.session, required this.active, required this.tokens, required this.onTap, this.onClose});
|
||||||
final _Session session;
|
final _Session session;
|
||||||
final bool active;
|
final bool active;
|
||||||
@@ -128,75 +134,52 @@ class _Tab extends StatefulWidget {
|
|||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final VoidCallback? onClose;
|
final VoidCallback? onClose;
|
||||||
|
|
||||||
@override
|
|
||||||
State<_Tab> createState() => _TabState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TabState extends State<_Tab> {
|
|
||||||
bool _hovered = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MouseRegion(
|
return ClideTappable(
|
||||||
cursor: SystemMouseCursors.click,
|
onTap: onTap,
|
||||||
onEnter: (_) => setState(() => _hovered = true),
|
builder: (context, hovered, _) => Container(
|
||||||
onExit: (_) => setState(() => _hovered = false),
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
child: GestureDetector(
|
decoration: BoxDecoration(
|
||||||
onTap: widget.onTap,
|
color: hovered && !active ? tokens.tabInactive : null,
|
||||||
child: Container(
|
border: Border(bottom: BorderSide(color: active ? tokens.tabActiveBorder : const Color(0x00000000), width: 2)),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
),
|
||||||
decoration: BoxDecoration(
|
child: Row(
|
||||||
color: _hovered && !widget.active ? widget.tokens.tabInactive : null,
|
mainAxisSize: MainAxisSize.min,
|
||||||
border: Border(bottom: BorderSide(color: widget.active ? widget.tokens.tabActiveBorder : const Color(0x00000000), width: 2)),
|
children: [
|
||||||
),
|
ClideText(
|
||||||
child: Row(
|
session.label,
|
||||||
mainAxisSize: MainAxisSize.min,
|
fontSize: 12,
|
||||||
children: [
|
color: active ? tokens.tabActiveForeground : tokens.tabInactiveForeground,
|
||||||
ClideText(
|
fontFamily: clideMonoFamily,
|
||||||
widget.session.label,
|
),
|
||||||
fontSize: 12,
|
if (onClose != null) ...[
|
||||||
color: widget.active ? widget.tokens.tabActiveForeground : widget.tokens.tabInactiveForeground,
|
const SizedBox(width: 6),
|
||||||
fontFamily: clideMonoFamily,
|
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});
|
const _AddButton({required this.tokens, required this.onTap});
|
||||||
final SurfaceTokens tokens;
|
final SurfaceTokens tokens;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
|
||||||
State<_AddButton> createState() => _AddButtonState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AddButtonState extends State<_AddButton> {
|
|
||||||
bool _hovered = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MouseRegion(
|
return ClideTappable(
|
||||||
cursor: SystemMouseCursors.click,
|
onTap: onTap,
|
||||||
onEnter: (_) => setState(() => _hovered = true),
|
tooltip: 'New session',
|
||||||
onExit: (_) => setState(() => _hovered = false),
|
builder: (context, hovered, _) => Padding(
|
||||||
child: GestureDetector(
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||||
onTap: widget.onTap,
|
child: ClideText('+', fontSize: 14, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
|
||||||
child: ClideText('+', fontSize: 14, color: _hovered ? widget.tokens.globalForeground : widget.tokens.globalTextMuted),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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:async';
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
|
import 'package:clide/builtin/decisions/src/decision_colors.dart';
|
||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
import 'package:clide/widgets/widgets.dart';
|
import 'package:clide/widgets/widgets.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
@@ -16,19 +16,54 @@ class _DecisionsViewState extends State<DecisionsView> {
|
|||||||
List<_DecisionEntry> _decisions = [];
|
List<_DecisionEntry> _decisions = [];
|
||||||
String? _error;
|
String? _error;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
String _filter = '';
|
||||||
|
String? _focusedId;
|
||||||
|
final _focusedKey = GlobalKey();
|
||||||
|
final Set<String> _pinned = {'confirmed'};
|
||||||
|
StreamSubscription<Message>? _focusSub;
|
||||||
|
StreamSubscription<DaemonEvent>? _fileSub;
|
||||||
|
StreamSubscription<SchedulerTick>? _schedulerSub;
|
||||||
|
bool _refreshing = false;
|
||||||
|
bool _pendingRefresh = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
|
if (_focusSub == null) {
|
||||||
|
final kernel = ClideKernel.of(context);
|
||||||
|
_focusSub = kernel.messages.subscribe(publisher: 'builtin.decisions', channel: 'focus').listen(_onFocus);
|
||||||
|
_fileSub = kernel.events
|
||||||
|
.on<DaemonEvent>()
|
||||||
|
.where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && _isDecisionPath(e.data['path'] as String? ?? ''))
|
||||||
|
.listen((_) => _refresh());
|
||||||
|
_schedulerSub = kernel.events.on<SchedulerTick>().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh());
|
||||||
|
}
|
||||||
if (!_loading || _decisions.isNotEmpty) return;
|
if (!_loading || _decisions.isNotEmpty) return;
|
||||||
unawaited(_load());
|
unawaited(_load());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool _isDecisionPath(String path) => path.startsWith('decisions/') && path.endsWith('.md');
|
||||||
|
|
||||||
|
Future<void> _refresh() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_refreshing) {
|
||||||
|
_pendingRefresh = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_refreshing = true;
|
||||||
|
_pendingRefresh = false;
|
||||||
|
await _load();
|
||||||
|
_refreshing = false;
|
||||||
|
if (_pendingRefresh && mounted) {
|
||||||
|
_pendingRefresh = false;
|
||||||
|
unawaited(_refresh());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
final kernel = ClideKernel.of(context);
|
final kernel = ClideKernel.of(context);
|
||||||
final resp = await kernel.ipc.request('pql.exec', args: {
|
await kernel.ipc.request('pql.decisions.sync');
|
||||||
'argv': ['decisions', 'list', '--type', 'confirmed'],
|
final resp = await kernel.ipc.request('pql.decisions.list');
|
||||||
});
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -37,90 +72,216 @@ class _DecisionsViewState extends State<DecisionsView> {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final raw = resp.data['stdout'] as String? ?? '[]';
|
final raw = resp.data['decisions'];
|
||||||
try {
|
if (raw is List) {
|
||||||
final list = (jsonDecode(raw) as List).cast<Map<String, dynamic>>();
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_decisions = list.map(_DecisionEntry.fromJson).toList();
|
_decisions = [for (final e in raw) _DecisionEntry.fromJson((e as Map).cast<String, dynamic>())];
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setState(() {
|
|
||||||
_error = 'parse error: $e';
|
|
||||||
_loading = false;
|
_loading = false;
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_focusSub?.cancel();
|
||||||
|
_fileSub?.cancel();
|
||||||
|
_schedulerSub?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isSectionExpanded(String type) {
|
||||||
|
if (_pinned.contains(type)) return true;
|
||||||
|
if (_focusedId == null) return false;
|
||||||
|
final entry = _decisions.where((d) => d.id == _focusedId).firstOrNull;
|
||||||
|
return (entry?.type ?? 'confirmed') == type;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFocus(Message msg) {
|
||||||
|
final id = msg.data['id'] as String?;
|
||||||
|
if (id == null || id == _focusedId) return;
|
||||||
|
setState(() => _focusedId = id);
|
||||||
|
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 (_pinned.contains(key)) {
|
||||||
|
_pinned.remove(key);
|
||||||
|
} else {
|
||||||
|
_pinned.add(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
if (_loading) {
|
if (_loading) return const Center(child: ClideText('Loading decisions...', muted: true));
|
||||||
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)
|
||||||
if (_error != null) {
|
return const Padding(padding: EdgeInsets.all(12), child: ClideText('No decisions found.\nRun `pql decisions sync` to index.', muted: true));
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(12),
|
final lf = _filter.toLowerCase();
|
||||||
child: ClideText(_error!, muted: true),
|
final hasFilter = lf.isNotEmpty;
|
||||||
);
|
final filtered = hasFilter
|
||||||
}
|
? _decisions
|
||||||
if (_decisions.isEmpty) {
|
.where((d) =>
|
||||||
return const Padding(
|
d.id.toLowerCase().contains(lf) ||
|
||||||
padding: EdgeInsets.all(12),
|
d.title.toLowerCase().contains(lf) ||
|
||||||
child: ClideText('No decisions found.\nRun `pql decisions sync` to index.', muted: true),
|
(d.domain ?? '').toLowerCase().contains(lf) ||
|
||||||
);
|
(d.type ?? '').contains(lf))
|
||||||
}
|
.toList()
|
||||||
return ListView.builder(
|
: _decisions;
|
||||||
itemCount: _decisions.length,
|
|
||||||
itemBuilder: (ctx, i) {
|
final confirmed = filtered.where((d) => d.type == 'confirmed').toList();
|
||||||
final d = _decisions[i];
|
final questions = filtered.where((d) => d.type == 'question').toList();
|
||||||
return _DecisionRow(entry: d, tokens: tokens);
|
final rejected = filtered.where((d) => d.type == 'rejected').toList();
|
||||||
},
|
|
||||||
|
final isDark = ClideTheme.of(context).dark;
|
||||||
|
final typeColors = DecisionTypeColors.forTheme(dark: isDark);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: ClideFilterBox(hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v))),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: ClideTappable(
|
||||||
|
onTap: _refreshing ? null : _refresh,
|
||||||
|
tooltip: 'Refresh decisions',
|
||||||
|
builder: (ctx, hovered, _) =>
|
||||||
|
ClideIcon(PhosphorIcons.arrowClockwise, size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (confirmed.isNotEmpty)
|
||||||
|
ClideAccordion(
|
||||||
|
label: 'CONFIRMED',
|
||||||
|
count: confirmed.length,
|
||||||
|
leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.confirmed, shape: BoxShape.circle)),
|
||||||
|
expanded: hasFilter || _isSectionExpanded('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)
|
||||||
|
ClideAccordion(
|
||||||
|
label: 'QUESTIONS',
|
||||||
|
count: questions.length,
|
||||||
|
leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.question, shape: BoxShape.circle)),
|
||||||
|
expanded: hasFilter || _isSectionExpanded('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)
|
||||||
|
ClideAccordion(
|
||||||
|
label: 'REJECTED',
|
||||||
|
count: rejected.length,
|
||||||
|
leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.rejected, shape: BoxShape.circle)),
|
||||||
|
expanded: hasFilter || _isSectionExpanded('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 {
|
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 id;
|
||||||
final String title;
|
final String title;
|
||||||
|
final String? type;
|
||||||
final String? domain;
|
final String? domain;
|
||||||
final String? status;
|
final String? status;
|
||||||
|
|
||||||
factory _DecisionEntry.fromJson(Map<String, dynamic> json) => _DecisionEntry(
|
factory _DecisionEntry.fromJson(Map<String, dynamic> json) => _DecisionEntry(
|
||||||
id: json['id'] as String? ?? '',
|
id: json['id'] as String? ?? '',
|
||||||
title: json['title'] as String? ?? '',
|
title: json['title'] as String? ?? '',
|
||||||
|
type: json['type'] as String?,
|
||||||
domain: json['domain'] as String?,
|
domain: json['domain'] as String?,
|
||||||
status: json['status'] as String?,
|
status: json['status'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DecisionRow extends StatefulWidget {
|
class _DecisionCard extends StatelessWidget {
|
||||||
const _DecisionRow({required this.entry, required this.tokens});
|
const _DecisionCard({required this.entry, required this.tokens, required this.typeColors, this.focused = false, this.focusKey});
|
||||||
final _DecisionEntry entry;
|
final _DecisionEntry entry;
|
||||||
final SurfaceTokens tokens;
|
final SurfaceTokens tokens;
|
||||||
|
final DecisionTypeColors typeColors;
|
||||||
@override
|
final bool focused;
|
||||||
State<_DecisionRow> createState() => _DecisionRowState();
|
final GlobalKey? focusKey;
|
||||||
}
|
|
||||||
|
|
||||||
class _DecisionRowState extends State<_DecisionRow> {
|
|
||||||
bool _hovered = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MouseRegion(
|
final typeColor = typeColors.forType(entry.type);
|
||||||
onEnter: (_) => setState(() => _hovered = true),
|
return Padding(
|
||||||
onExit: (_) => setState(() => _hovered = false),
|
key: focusKey,
|
||||||
child: Container(
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
color: _hovered ? widget.tokens.listItemHoverBackground : null,
|
child: ClideTappable(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
onTap: () => ClideKernel.of(context).messages.publish('builtin.decisions', 'selection', {'id': entry.id}),
|
||||||
child: Row(
|
builder: (ctx, hovered, _) => Container(
|
||||||
children: [
|
padding: const EdgeInsets.all(8),
|
||||||
ClideText(widget.entry.id, color: widget.tokens.globalTextMuted, fontSize: 12),
|
decoration: BoxDecoration(
|
||||||
const SizedBox(width: 8),
|
color: hovered ? tokens.sidebarItemHover : (focused ? tokens.sidebarItemSelected : tokens.panelBackground),
|
||||||
Expanded(child: ClideText(widget.entry.title, fontSize: 13)),
|
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/builtin/decisions/src/decisions_view.dart';
|
||||||
import 'package:clide/extension/extension.dart';
|
import 'package:clide/extension/extension.dart';
|
||||||
import 'package:clide/kernel/kernel.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 {
|
class DecisionsExtension extends ClideExtension {
|
||||||
@override
|
@override
|
||||||
@@ -8,10 +13,34 @@ class DecisionsExtension extends ClideExtension {
|
|||||||
@override
|
@override
|
||||||
String get title => 'Decisions';
|
String get title => 'Decisions';
|
||||||
@override
|
@override
|
||||||
String get version => '0.1.0';
|
String get version => '0.6.0';
|
||||||
@override
|
@override
|
||||||
List<String> get dependsOn => const [];
|
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.arrangement.setVisible(Slots.contextPanel, true);
|
||||||
|
ctx.arrangement.setCollapsed(Slots.contextPanel, false);
|
||||||
|
ctx.panels.activateTab(Slots.contextPanel, 'decisions.detail');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deactivate() async => _sub?.cancel();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<ContributionPoint> get contributions => [
|
List<ContributionPoint> get contributions => [
|
||||||
TabContribution(
|
TabContribution(
|
||||||
@@ -20,8 +49,15 @@ class DecisionsExtension extends ClideExtension {
|
|||||||
title: 'Decisions',
|
title: 'Decisions',
|
||||||
titleKey: 'tab.title',
|
titleKey: 'tab.title',
|
||||||
i18nNamespace: id,
|
i18nNamespace: id,
|
||||||
priority: -20,
|
icon: PhosphorIcons.lightbulb,
|
||||||
build: (_) => const DecisionsView(),
|
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) {
|
void _restoreLayout(ClideExtensionContext ctx) {
|
||||||
final s = ctx.settings;
|
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);
|
final sidebarCollapsed = s.get<bool>(_kSidebarCollapsed);
|
||||||
if (sidebarCollapsed != null) {
|
if (sidebarCollapsed != null) {
|
||||||
ctx.arrangement.setCollapsed(Slots.sidebar, sidebarCollapsed);
|
ctx.arrangement.setCollapsed(Slots.sidebar, sidebarCollapsed);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class DiffController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final DaemonClient ipc;
|
final DaemonClient ipc;
|
||||||
final EventBus events;
|
final DaemonBus events;
|
||||||
|
|
||||||
StreamSubscription<DaemonEvent>? _eventSub;
|
StreamSubscription<DaemonEvent>? _eventSub;
|
||||||
|
|
||||||
|
|||||||
@@ -68,9 +68,7 @@ class _DiffViewState extends State<DiffView> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: ClideText(
|
child: ClideText(
|
||||||
c.showStaged
|
c.showStaged ? 'No staged changes.' : 'No unstaged changes.',
|
||||||
? 'No staged changes.'
|
|
||||||
: 'No unstaged changes.',
|
|
||||||
muted: true,
|
muted: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -81,8 +79,7 @@ class _DiffViewState extends State<DiffView> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
for (final diff in c.diffs)
|
for (final diff in c.diffs) _FileDiff(diff: diff, controller: c),
|
||||||
_FileDiff(diff: diff, controller: c),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -118,9 +115,7 @@ class _DiffToolbar extends StatelessWidget {
|
|||||||
child: ClideText(
|
child: ClideText(
|
||||||
'Unstaged',
|
'Unstaged',
|
||||||
fontSize: clideFontCaption,
|
fontSize: clideFontCaption,
|
||||||
color: controller.showStaged
|
color: controller.showStaged ? tokens.globalTextMuted : tokens.globalForeground,
|
||||||
? tokens.globalTextMuted
|
|
||||||
: tokens.globalForeground,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -134,9 +129,7 @@ class _DiffToolbar extends StatelessWidget {
|
|||||||
child: ClideText(
|
child: ClideText(
|
||||||
'Staged',
|
'Staged',
|
||||||
fontSize: clideFontCaption,
|
fontSize: clideFontCaption,
|
||||||
color: controller.showStaged
|
color: controller.showStaged ? tokens.globalForeground : tokens.globalTextMuted,
|
||||||
? tokens.globalForeground
|
|
||||||
: tokens.globalTextMuted,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -188,12 +181,8 @@ class _FileDiff extends StatelessWidget {
|
|||||||
color: tokens.panelHeaderForeground,
|
color: tokens.panelHeaderForeground,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (additions > 0)
|
if (additions > 0) ClideText('+$additions ', fontSize: clideFontCaption, color: tokens.statusSuccess),
|
||||||
ClideText('+$additions ', fontSize: clideFontCaption,
|
if (removals > 0) ClideText('-$removals', fontSize: clideFontCaption, color: tokens.statusError),
|
||||||
color: tokens.statusSuccess),
|
|
||||||
if (removals > 0)
|
|
||||||
ClideText('-$removals', fontSize: clideFontCaption,
|
|
||||||
color: tokens.statusError),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,14 +17,13 @@ import 'package:clide/kernel/kernel.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
class EditorController extends ChangeNotifier {
|
class EditorController extends ChangeNotifier {
|
||||||
EditorController({required this.ipc, required EventBus events})
|
EditorController({required this.ipc, required DaemonBus events}) : _events = events {
|
||||||
: _events = events {
|
|
||||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
final DaemonClient ipc;
|
final DaemonClient ipc;
|
||||||
// ignore: unused_field — kept for future subscription changes
|
// ignore: unused_field — kept for future subscription changes
|
||||||
final EventBus _events;
|
final DaemonBus _events;
|
||||||
|
|
||||||
StreamSubscription<DaemonEvent>? _eventSub;
|
StreamSubscription<DaemonEvent>? _eventSub;
|
||||||
|
|
||||||
@@ -77,9 +76,7 @@ class EditorController extends ChangeNotifier {
|
|||||||
_activePath = r.data['path']! as String;
|
_activePath = r.data['path']! as String;
|
||||||
_content = (r.data['content'] as String?) ?? '';
|
_content = (r.data['content'] as String?) ?? '';
|
||||||
final sel = r.data['selection'];
|
final sel = r.data['selection'];
|
||||||
_selection = sel is Map
|
_selection = sel is Map ? Selection.fromJson(sel.cast<String, Object?>()) : const Selection.collapsed(0);
|
||||||
? Selection.fromJson(sel.cast<String, Object?>())
|
|
||||||
: const Selection.collapsed(0);
|
|
||||||
_dirty = (r.data['dirty'] as bool?) ?? false;
|
_dirty = (r.data['dirty'] as bool?) ?? false;
|
||||||
_error = null;
|
_error = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class EditorView extends StatefulWidget {
|
|||||||
|
|
||||||
class _EditorViewState extends State<EditorView> {
|
class _EditorViewState extends State<EditorView> {
|
||||||
EditorController? _controller;
|
EditorController? _controller;
|
||||||
final TreeSitterService _syntax = TreeSitterService();
|
final TreeSitterService _syntax = TreeSitterService.shared;
|
||||||
late final SyntaxTextController _text;
|
late final SyntaxTextController _text;
|
||||||
late final FocusNode _focus;
|
late final FocusNode _focus;
|
||||||
String? _lastRemoteContent;
|
String? _lastRemoteContent;
|
||||||
@@ -45,8 +45,7 @@ class _EditorViewState extends State<EditorView> {
|
|||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
if (_controller != null) return;
|
if (_controller != null) return;
|
||||||
final kernel = ClideKernel.of(context);
|
final kernel = ClideKernel.of(context);
|
||||||
_controller = EditorController(ipc: kernel.ipc, events: kernel.events)
|
_controller = EditorController(ipc: kernel.ipc, events: kernel.events)..addListener(_onControllerChanged);
|
||||||
..addListener(_onControllerChanged);
|
|
||||||
unawaited(_controller!.hydrate());
|
unawaited(_controller!.hydrate());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +56,6 @@ class _EditorViewState extends State<EditorView> {
|
|||||||
_focus.dispose();
|
_focus.dispose();
|
||||||
_controller?.removeListener(_onControllerChanged);
|
_controller?.removeListener(_onControllerChanged);
|
||||||
_controller?.dispose();
|
_controller?.dispose();
|
||||||
_syntax.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,29 +79,22 @@ class _EditorViewState extends State<EditorView> {
|
|||||||
final c = _controller;
|
final c = _controller;
|
||||||
if (c == null || c.activeId == null) return;
|
if (c == null || c.activeId == null) return;
|
||||||
final value = _text.value;
|
final value = _text.value;
|
||||||
if (value.text == c.content &&
|
if (value.text == c.content && value.selection.baseOffset == c.selection.start && value.selection.extentOffset == c.selection.end) {
|
||||||
value.selection.baseOffset == c.selection.start &&
|
|
||||||
value.selection.extentOffset == c.selection.end) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_lastRemoteContent = value.text;
|
_lastRemoteContent = value.text;
|
||||||
c.pushLocalEdit(
|
c.pushLocalEdit(
|
||||||
newContent: value.text,
|
newContent: value.text,
|
||||||
newSelection: Selection(
|
newSelection: Selection(
|
||||||
start: value.selection.start < 0
|
start: value.selection.start < 0 ? value.text.length : value.selection.start,
|
||||||
? value.text.length
|
end: value.selection.end < 0 ? value.text.length : value.selection.end,
|
||||||
: value.selection.start,
|
|
||||||
end: value.selection.end < 0
|
|
||||||
? value.text.length
|
|
||||||
: value.selection.end,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||||
final isCmd = HardwareKeyboard.instance.isMetaPressed ||
|
final isCmd = HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed;
|
||||||
HardwareKeyboard.instance.isControlPressed;
|
|
||||||
if (isCmd && event.logicalKey == LogicalKeyboardKey.keyS) {
|
if (isCmd && event.logicalKey == LogicalKeyboardKey.keyS) {
|
||||||
unawaited(_controller?.save());
|
unawaited(_controller?.save());
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import 'package:clide/kernel/src/theme/tokens.dart';
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
class SyntaxTextController extends TextEditingController {
|
class SyntaxTextController extends TextEditingController {
|
||||||
SyntaxTextController({required TreeSitterService syntax})
|
SyntaxTextController({required TreeSitterService syntax}) : _syntax = syntax;
|
||||||
: _syntax = syntax;
|
|
||||||
|
|
||||||
final TreeSitterService _syntax;
|
final TreeSitterService _syntax;
|
||||||
|
|
||||||
@@ -74,8 +73,7 @@ class SyntaxTextController extends TextEditingController {
|
|||||||
|
|
||||||
// Convert byte offsets to character offsets.
|
// Convert byte offsets to character offsets.
|
||||||
// Build a byte-to-char map only up to the max byte we need.
|
// Build a byte-to-char map only up to the max byte we need.
|
||||||
final spans = _spans.where((s) => s.end <= sourceBytes.length).toList()
|
final spans = _spans.where((s) => s.end <= sourceBytes.length).toList()..sort((a, b) => a.start != b.start ? a.start - b.start : a.end - b.end);
|
||||||
..sort((a, b) => a.start != b.start ? a.start - b.start : a.end - b.end);
|
|
||||||
|
|
||||||
if (spans.isEmpty) {
|
if (spans.isEmpty) {
|
||||||
return TextSpan(text: text, style: style);
|
return TextSpan(text: text, style: style);
|
||||||
@@ -121,9 +119,7 @@ class SyntaxTextController extends TextEditingController {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final spanCharStart = byteToChar[span.start];
|
final spanCharStart = byteToChar[span.start];
|
||||||
final spanCharEnd = span.end <= maxByte
|
final spanCharEnd = span.end <= maxByte ? byteToChar[span.end] : source.length;
|
||||||
? byteToChar[span.end]
|
|
||||||
: source.length;
|
|
||||||
|
|
||||||
if (spanCharStart < charPos) continue;
|
if (spanCharStart < charPos) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:clide/builtin/files/src/file_tree_view.dart';
|
import 'package:clide/builtin/files/src/file_tree_view.dart';
|
||||||
import 'package:clide/extension/extension.dart';
|
import 'package:clide/extension/extension.dart';
|
||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
|
||||||
/// Workspace filesystem panel. Contributes a sidebar tab that renders
|
/// Workspace filesystem panel. Contributes a sidebar tab that renders
|
||||||
/// the workspace file tree rooted at the git root, powered by the
|
/// the workspace file tree rooted at the git root, powered by the
|
||||||
@@ -22,6 +23,7 @@ class FilesExtension extends ClideExtension {
|
|||||||
id: 'files.tree',
|
id: 'files.tree',
|
||||||
slot: Slots.sidebar,
|
slot: Slots.sidebar,
|
||||||
title: 'Files',
|
title: 'Files',
|
||||||
|
icon: PhosphorIcons.folder,
|
||||||
titleKey: 'tab.title',
|
titleKey: 'tab.title',
|
||||||
i18nNamespace: id,
|
i18nNamespace: id,
|
||||||
priority: -100,
|
priority: -100,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class FileTreeController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final DaemonClient ipc;
|
final DaemonClient ipc;
|
||||||
final EventBus events;
|
final DaemonBus events;
|
||||||
|
|
||||||
StreamSubscription<DaemonEvent>? _eventSub;
|
StreamSubscription<DaemonEvent>? _eventSub;
|
||||||
|
|
||||||
@@ -38,6 +38,14 @@ class FileTreeController extends ChangeNotifier {
|
|||||||
final Map<String, List<FileEntry>> _entries = {};
|
final Map<String, List<FileEntry>> _entries = {};
|
||||||
List<FileEntry>? entriesFor(String path) => _entries[path];
|
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,
|
/// Initial boot: resolve the workspace root, load the root dir,
|
||||||
/// subscribe to `files.changed` events.
|
/// subscribe to `files.changed` events.
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import 'package:clide/kernel/kernel.dart';
|
|||||||
import 'package:clide/widgets/widgets.dart';
|
import 'package:clide/widgets/widgets.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import 'package:clide/src/files/listing.dart' show FileEntry;
|
||||||
|
|
||||||
import 'file_tree_controller.dart';
|
import 'file_tree_controller.dart';
|
||||||
|
|
||||||
/// Sidebar panel rendering the workspace file tree.
|
/// Sidebar panel rendering the workspace file tree.
|
||||||
@@ -24,6 +26,7 @@ class FileTreeView extends StatefulWidget {
|
|||||||
|
|
||||||
class _FileTreeViewState extends State<FileTreeView> {
|
class _FileTreeViewState extends State<FileTreeView> {
|
||||||
FileTreeController? _controller;
|
FileTreeController? _controller;
|
||||||
|
String _filter = '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
@@ -61,31 +64,45 @@ class _FileTreeViewState extends State<FileTreeView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final rootName = root.split(Platform.pathSeparator).last;
|
final rootName = root.split(Platform.pathSeparator).last;
|
||||||
return Semantics(
|
return Column(
|
||||||
label: 'file tree — $rootName',
|
children: [
|
||||||
container: true,
|
ClideFilterBox(hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)),
|
||||||
explicitChildNodes: true,
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: Semantics(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
label: 'file tree — $rootName',
|
||||||
child: Column(
|
container: true,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
explicitChildNodes: true,
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: SingleChildScrollView(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
_DirRow(
|
child: Column(
|
||||||
name: rootName,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
path: '',
|
mainAxisSize: MainAxisSize.min,
|
||||||
controller: c,
|
children: [
|
||||||
depth: 0,
|
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 {
|
class _Children extends StatelessWidget {
|
||||||
@@ -119,8 +136,7 @@ class _Children extends StatelessWidget {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
depth: depth,
|
depth: depth,
|
||||||
),
|
),
|
||||||
if (controller.isExpanded(e.path))
|
if (controller.isExpanded(e.path)) _Children(path: e.path, controller: controller, depth: depth + 1),
|
||||||
_Children(path: e.path, controller: controller, depth: depth + 1),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@@ -208,7 +224,7 @@ class _FileRow extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Row extends StatefulWidget {
|
class _Row extends StatelessWidget {
|
||||||
const _Row({
|
const _Row({
|
||||||
required this.depth,
|
required this.depth,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
@@ -223,49 +239,57 @@ class _Row extends StatefulWidget {
|
|||||||
final Widget? leading;
|
final Widget? leading;
|
||||||
final bool rotateLeading;
|
final bool rotateLeading;
|
||||||
|
|
||||||
@override
|
|
||||||
State<_Row> createState() => _RowState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RowState extends State<_Row> {
|
|
||||||
bool _hover = false;
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
final leftPadding = 8.0 + (widget.depth * 14.0);
|
final leftPadding = 8.0 + (depth * 14.0);
|
||||||
return MouseRegion(
|
return ClideTappable(
|
||||||
cursor: SystemMouseCursors.click,
|
onTap: onTap,
|
||||||
onEnter: (_) => setState(() => _hover = true),
|
builder: (context, hovered, _) => Container(
|
||||||
onExit: (_) => setState(() => _hover = false),
|
color: hovered ? tokens.sidebarItemHover : null,
|
||||||
child: GestureDetector(
|
padding: EdgeInsets.only(left: leftPadding, right: 8, top: 3, bottom: 3),
|
||||||
behavior: HitTestBehavior.opaque,
|
child: Row(
|
||||||
onTap: widget.onTap,
|
children: [
|
||||||
child: Container(
|
if (leading != null) ...[
|
||||||
color: _hover ? tokens.sidebarItemHover : null,
|
Transform.rotate(
|
||||||
padding: EdgeInsets.only(left: leftPadding, right: 8, top: 3, bottom: 3),
|
angle: rotateLeading ? 1.5708 : 0, // 90° when expanded
|
||||||
child: Row(
|
child: leading,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
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/builtin/git/src/git_status_item.dart';
|
||||||
import 'package:clide/extension/extension.dart';
|
import 'package:clide/extension/extension.dart';
|
||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
|
||||||
class GitExtension extends ClideExtension {
|
class GitExtension extends ClideExtension {
|
||||||
@override
|
@override
|
||||||
@@ -19,6 +20,7 @@ class GitExtension extends ClideExtension {
|
|||||||
id: 'git.panel',
|
id: 'git.panel',
|
||||||
slot: Slots.sidebar,
|
slot: Slots.sidebar,
|
||||||
title: 'Git',
|
title: 'Git',
|
||||||
|
icon: PhosphorIcons.gitBranch,
|
||||||
titleKey: 'tab.title',
|
titleKey: 'tab.title',
|
||||||
i18nNamespace: id,
|
i18nNamespace: id,
|
||||||
priority: -80,
|
priority: -80,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class GitController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final DaemonClient ipc;
|
final DaemonClient ipc;
|
||||||
final EventBus events;
|
final DaemonBus events;
|
||||||
|
|
||||||
StreamSubscription<DaemonEvent>? _eventSub;
|
StreamSubscription<DaemonEvent>? _eventSub;
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ class _GitPanelViewState extends State<GitPanelView> {
|
|||||||
GitController? _controller;
|
GitController? _controller;
|
||||||
final TextEditingController _commitMsg = TextEditingController();
|
final TextEditingController _commitMsg = TextEditingController();
|
||||||
final FocusNode _commitFocus = FocusNode();
|
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
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
@@ -66,91 +73,95 @@ class _GitPanelViewState extends State<GitPanelView> {
|
|||||||
label: 'git panel',
|
label: 'git panel',
|
||||||
container: true,
|
container: true,
|
||||||
explicitChildNodes: true,
|
explicitChildNodes: true,
|
||||||
child: SingleChildScrollView(
|
child: Column(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
children: [
|
||||||
child: Column(
|
ClideFilterBox(hint: 'Filter changes…', onChanged: (v) => setState(() => _filter = v)),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
Expanded(
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: SingleChildScrollView(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
_BranchHeader(controller: c),
|
child: Column(
|
||||||
if (c.error != null)
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
Padding(
|
mainAxisSize: MainAxisSize.min,
|
||||||
padding: const EdgeInsets.symmetric(
|
children: [
|
||||||
horizontal: 12, vertical: 4),
|
_BranchHeader(controller: c),
|
||||||
child: ClideText(
|
if (c.error != null)
|
||||||
c.error!,
|
Padding(
|
||||||
color: tokens.statusError,
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
fontSize: clideFontCaption,
|
child: ClideText(
|
||||||
maxLines: 3,
|
c.error!,
|
||||||
),
|
color: tokens.statusError,
|
||||||
),
|
fontSize: clideFontCaption,
|
||||||
if (c.loading && c.isClean)
|
maxLines: 3,
|
||||||
const Padding(
|
),
|
||||||
padding: EdgeInsets.all(12),
|
),
|
||||||
child: ClideText('Loading…', muted: true),
|
if (c.loading && c.isClean)
|
||||||
),
|
const Padding(
|
||||||
if (!c.loading && c.isClean && c.error == null)
|
padding: EdgeInsets.all(12),
|
||||||
const Padding(
|
child: ClideText('Loading…', muted: true),
|
||||||
padding: EdgeInsets.all(12),
|
),
|
||||||
child: ClideText('Nothing to commit, working tree clean.',
|
if (!c.loading && c.isClean && c.error == null)
|
||||||
muted: true),
|
const Padding(
|
||||||
),
|
padding: EdgeInsets.all(12),
|
||||||
if (c.conflicted.isNotEmpty)
|
child: ClideText('Nothing to commit, working tree clean.', muted: true),
|
||||||
_FileGroup(
|
),
|
||||||
label: 'Merge conflicts',
|
if (c.conflicted.isNotEmpty)
|
||||||
entries: c.conflicted,
|
_FileGroup(
|
||||||
actions: const [],
|
label: 'Merge conflicts',
|
||||||
),
|
entries: _applyFilter(c.conflicted),
|
||||||
if (c.staged.isNotEmpty) ...[
|
actions: const [],
|
||||||
_FileGroup(
|
),
|
||||||
label: 'Staged',
|
if (c.staged.isNotEmpty) ...[
|
||||||
entries: c.staged,
|
_FileGroup(
|
||||||
actions: [
|
label: 'Staged',
|
||||||
_GroupAction(
|
entries: _applyFilter(c.staged),
|
||||||
label: 'Unstage all',
|
actions: [
|
||||||
onTap: () => unawaited(c.unstage(const [])),
|
_GroupAction(
|
||||||
|
label: 'Unstage all',
|
||||||
|
onTap: () => unawaited(c.unstage(const [])),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onUnstage: (path) => unawaited(c.unstage([path])),
|
||||||
|
),
|
||||||
|
_CommitInput(
|
||||||
|
commitMsg: _commitMsg,
|
||||||
|
commitFocus: _commitFocus,
|
||||||
|
controller: c,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onUnstage: (path) => unawaited(c.unstage([path])),
|
if (c.unstaged.isNotEmpty)
|
||||||
),
|
_FileGroup(
|
||||||
_CommitInput(
|
label: 'Changes',
|
||||||
commitMsg: _commitMsg,
|
entries: _applyFilter(c.unstaged),
|
||||||
commitFocus: _commitFocus,
|
actions: [
|
||||||
controller: c,
|
_GroupAction(
|
||||||
),
|
label: 'Stage all',
|
||||||
],
|
onTap: () => unawaited(c.stageAll()),
|
||||||
if (c.unstaged.isNotEmpty)
|
),
|
||||||
_FileGroup(
|
],
|
||||||
label: 'Changes',
|
onStage: (path) => unawaited(c.stage([path])),
|
||||||
entries: c.unstaged,
|
onDiscard: (path) => _confirmDiscard(context, c, path),
|
||||||
actions: [
|
|
||||||
_GroupAction(
|
|
||||||
label: 'Stage all',
|
|
||||||
onTap: () => unawaited(c.stageAll()),
|
|
||||||
),
|
),
|
||||||
],
|
if (c.untracked.isNotEmpty)
|
||||||
onStage: (path) => unawaited(c.stage([path])),
|
_FileGroup(
|
||||||
onDiscard: (path) => _confirmDiscard(context, c, path),
|
label: 'Untracked',
|
||||||
),
|
entries: _applyFilter(c.untracked),
|
||||||
if (c.untracked.isNotEmpty)
|
actions: [
|
||||||
_FileGroup(
|
_GroupAction(
|
||||||
label: 'Untracked',
|
label: 'Stage all',
|
||||||
entries: c.untracked,
|
onTap: () {
|
||||||
actions: [
|
final paths = [
|
||||||
_GroupAction(
|
for (final e in c.untracked) e['path'] as String,
|
||||||
label: 'Stage all',
|
];
|
||||||
onTap: () {
|
unawaited(c.stage(paths));
|
||||||
final paths = [
|
},
|
||||||
for (final e in c.untracked) e['path'] as String,
|
),
|
||||||
];
|
],
|
||||||
unawaited(c.stage(paths));
|
onStage: (path) => unawaited(c.stage([path])),
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onStage: (path) => unawaited(c.stage([path])),
|
),
|
||||||
),
|
)),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -324,7 +335,7 @@ class _FileGroup extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GitFileRow extends StatefulWidget {
|
class _GitFileRow extends StatelessWidget {
|
||||||
const _GitFileRow({
|
const _GitFileRow({
|
||||||
required this.entry,
|
required this.entry,
|
||||||
this.onStage,
|
this.onStage,
|
||||||
@@ -337,78 +348,64 @@ class _GitFileRow extends StatefulWidget {
|
|||||||
final void Function(String path)? onUnstage;
|
final void Function(String path)? onUnstage;
|
||||||
final void Function(String path)? onDiscard;
|
final void Function(String path)? onDiscard;
|
||||||
|
|
||||||
@override
|
|
||||||
State<_GitFileRow> createState() => _GitFileRowState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _GitFileRowState extends State<_GitFileRow> {
|
|
||||||
bool _hover = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
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 name = path.split('/').last;
|
||||||
final indexState = widget.entry['indexState'] as String?;
|
final indexState = entry['indexState'] as String?;
|
||||||
final workTreeState = widget.entry['workTreeState'] as String?;
|
final workTreeState = entry['workTreeState'] as String?;
|
||||||
final state = indexState ?? workTreeState ?? '';
|
final state = indexState ?? workTreeState ?? '';
|
||||||
final stateLabel = _stateLabel(state);
|
final stateLabel = _stateLabel(state);
|
||||||
|
|
||||||
return MouseRegion(
|
return Semantics(
|
||||||
cursor: SystemMouseCursors.click,
|
button: true,
|
||||||
onEnter: (_) => setState(() => _hover = true),
|
label: '$name $stateLabel',
|
||||||
onExit: (_) => setState(() => _hover = false),
|
child: ClideTappable(
|
||||||
child: GestureDetector(
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final kernel = ClideKernel.of(context);
|
final kernel = ClideKernel.of(context);
|
||||||
unawaited(kernel.ipc.request('editor.open', args: {'path': path}));
|
unawaited(kernel.ipc.request('editor.open', args: {'path': path}));
|
||||||
},
|
},
|
||||||
child: Semantics(
|
builder: (context, hovered, _) => Container(
|
||||||
button: true,
|
color: hovered ? tokens.sidebarItemHover : null,
|
||||||
label: '$name $stateLabel',
|
padding: const EdgeInsets.only(left: 20, right: 8, top: 2, bottom: 2),
|
||||||
child: Container(
|
child: Row(
|
||||||
color: _hover ? tokens.sidebarItemHover : null,
|
children: [
|
||||||
padding: const EdgeInsets.only(
|
ClideText(
|
||||||
left: 20, right: 8, top: 2, bottom: 2),
|
_stateIndicator(state),
|
||||||
child: Row(
|
fontSize: clideFontCaption,
|
||||||
children: [
|
color: _stateColor(state, tokens),
|
||||||
ClideText(
|
),
|
||||||
_stateIndicator(state),
|
const SizedBox(width: 6),
|
||||||
fontSize: clideFontCaption,
|
Expanded(
|
||||||
color: _stateColor(state, tokens),
|
child: ClideText(
|
||||||
|
name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
color: tokens.sidebarForeground,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
),
|
||||||
Expanded(
|
if (hovered) ...[
|
||||||
child: ClideText(
|
if (onStage != null)
|
||||||
name,
|
_SmallAction(
|
||||||
maxLines: 1,
|
label: '+',
|
||||||
overflow: TextOverflow.ellipsis,
|
semanticsLabel: 'stage $name',
|
||||||
color: tokens.sidebarForeground,
|
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -140,8 +140,7 @@ class _BranchPickerState extends State<_BranchPicker> {
|
|||||||
_loading = false;
|
_loading = false;
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
_branches = [
|
_branches = [
|
||||||
for (final b in (r.data['branches'] as List? ?? const []))
|
for (final b in (r.data['branches'] as List? ?? const [])) (b as Map).cast<String, Object?>(),
|
||||||
(b as Map).cast<String, Object?>(),
|
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
_error = r.error?.message ?? 'failed to load branches';
|
_error = r.error?.message ?? 'failed to load branches';
|
||||||
@@ -197,10 +196,8 @@ class _BranchPickerState extends State<_BranchPicker> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_loading)
|
if (_loading) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
|
||||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
|
if (_error != null) Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true)),
|
||||||
if (_error != null)
|
|
||||||
Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true)),
|
|
||||||
if (!_loading && _error == null && _branches.isEmpty)
|
if (!_loading && _error == null && _branches.isEmpty)
|
||||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('No branches found.', muted: true)),
|
const Padding(padding: EdgeInsets.all(12), child: ClideText('No branches found.', muted: true)),
|
||||||
if (_branches.isNotEmpty)
|
if (_branches.isNotEmpty)
|
||||||
@@ -228,7 +225,7 @@ class _BranchPickerState extends State<_BranchPicker> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BranchRow extends StatefulWidget {
|
class _BranchRow extends StatelessWidget {
|
||||||
const _BranchRow({
|
const _BranchRow({
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.current,
|
required this.current,
|
||||||
@@ -239,51 +236,37 @@ class _BranchRow extends StatefulWidget {
|
|||||||
final bool current;
|
final bool current;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
@override
|
|
||||||
State<_BranchRow> createState() => _BranchRowState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BranchRowState extends State<_BranchRow> {
|
|
||||||
bool _hover = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
return MouseRegion(
|
return ClideTappable(
|
||||||
cursor:
|
onTap: onTap,
|
||||||
widget.onTap != null ? SystemMouseCursors.click : MouseCursor.defer,
|
cursor: onTap != null ? SystemMouseCursors.click : MouseCursor.defer,
|
||||||
onEnter: (_) => setState(() => _hover = true),
|
builder: (context, hovered, _) => Container(
|
||||||
onExit: (_) => setState(() => _hover = false),
|
color: hovered ? tokens.listItemHoverBackground : null,
|
||||||
child: GestureDetector(
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
onTap: widget.onTap,
|
child: Row(
|
||||||
child: Container(
|
children: [
|
||||||
color: _hover ? tokens.listItemHoverBackground : null,
|
if (current)
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
Padding(
|
||||||
child: Row(
|
padding: const EdgeInsets.only(right: 8),
|
||||||
children: [
|
child: ClideIcon(
|
||||||
if (widget.current)
|
const CheckIcon(),
|
||||||
Padding(
|
size: 12,
|
||||||
padding: const EdgeInsets.only(right: 8),
|
color: tokens.statusSuccess,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
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/extension/extension.dart';
|
||||||
import 'package:clide/kernel/kernel.dart';
|
|
||||||
|
|
||||||
class GraphExtension extends ClideExtension {
|
class GraphExtension extends ClideExtension {
|
||||||
@override
|
@override
|
||||||
@@ -13,15 +11,5 @@ class GraphExtension extends ClideExtension {
|
|||||||
List<String> get dependsOn => const ['builtin.pql'];
|
List<String> get dependsOn => const ['builtin.pql'];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<ContributionPoint> get contributions => [
|
List<ContributionPoint> get contributions => const [];
|
||||||
TabContribution(
|
|
||||||
id: 'graph.view',
|
|
||||||
slot: Slots.contextPanel,
|
|
||||||
title: 'Graph',
|
|
||||||
titleKey: 'tab.graph',
|
|
||||||
i18nNamespace: id,
|
|
||||||
priority: -80,
|
|
||||||
build: (_) => const GraphView(),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,30 +90,21 @@ class _GraphNode {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _NodeRow extends StatefulWidget {
|
class _NodeRow extends StatelessWidget {
|
||||||
const _NodeRow({required this.node, required this.tokens});
|
const _NodeRow({required this.node, required this.tokens});
|
||||||
final _GraphNode node;
|
final _GraphNode node;
|
||||||
final SurfaceTokens tokens;
|
final SurfaceTokens tokens;
|
||||||
|
|
||||||
@override
|
|
||||||
State<_NodeRow> createState() => _NodeRowState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NodeRowState extends State<_NodeRow> {
|
|
||||||
bool _hovered = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MouseRegion(
|
return ClideTappable(
|
||||||
onEnter: (_) => setState(() => _hovered = true),
|
builder: (context, hovered, _) => Container(
|
||||||
onExit: (_) => setState(() => _hovered = false),
|
color: hovered ? tokens.listItemHoverBackground : null,
|
||||||
child: Container(
|
|
||||||
color: _hovered ? widget.tokens.listItemHoverBackground : null,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: ClideText(widget.node.path, fontSize: 13)),
|
Expanded(child: ClideText(node.path, fontSize: clideFontCaption)),
|
||||||
ClideText('${widget.node.inbound}in ${widget.node.outbound}out', color: widget.tokens.globalTextMuted, fontSize: 11),
|
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/kernel/kernel.dart';
|
||||||
import 'package:clide/widgets/widgets.dart';
|
import 'package:clide/widgets/widgets.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
class ToolStatusItem extends StatefulWidget {
|
class ToolStatusItem extends StatelessWidget {
|
||||||
const ToolStatusItem({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final kernel = ClideKernel.of(context);
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
if (!_checked) return const SizedBox.shrink();
|
return ListenableBuilder(
|
||||||
|
listenable: kernel.toolchain,
|
||||||
|
builder: (ctx, _) {
|
||||||
|
final tc = kernel.toolchain;
|
||||||
|
if (!tc.resolved) return const SizedBox.shrink();
|
||||||
|
if (tc.allOk) {
|
||||||
|
return _chip('application ok', tokens.statusSuccess, tokens);
|
||||||
|
}
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < tc.missing.length; i++) ...[
|
||||||
|
if (i > 0) const SizedBox(width: 10),
|
||||||
|
_chip('${tc.missing[i]} not found', tokens.statusWarning, tokens),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _chip(String label, Color color, SurfaceTokens tokens) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_Indicator(label: 'ptyc', ok: _ptycOk, tokens: tokens),
|
Container(width: 7, height: 7, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 6),
|
||||||
_Indicator(label: 'pql', ok: _pqlOk, tokens: tokens),
|
ClideText(label, fontSize: clideFontCaption, color: color),
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:clide/builtin/markdown/src/markdown_viewer.dart';
|
import 'package:clide/builtin/markdown/src/markdown_viewer.dart';
|
||||||
import 'package:clide/extension/extension.dart';
|
import 'package:clide/extension/extension.dart';
|
||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
class MarkdownExtension extends ClideExtension {
|
class MarkdownExtension extends ClideExtension {
|
||||||
@override
|
@override
|
||||||
@@ -8,20 +12,35 @@ class MarkdownExtension extends ClideExtension {
|
|||||||
@override
|
@override
|
||||||
String get title => 'Markdown';
|
String get title => 'Markdown';
|
||||||
@override
|
@override
|
||||||
String get version => '0.1.0';
|
String get version => '0.3.0';
|
||||||
@override
|
@override
|
||||||
List<String> get dependsOn => const ['builtin.editor'];
|
List<String> get dependsOn => const [];
|
||||||
|
|
||||||
|
StreamSubscription<Message>? _sub;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<ContributionPoint> get contributions => [
|
List<ContributionPoint> get contributions => [
|
||||||
TabContribution(
|
TabContribution(
|
||||||
id: 'markdown.viewer',
|
id: 'markdown.viewer',
|
||||||
slot: Slots.contextPanel,
|
slot: Slots.contextPanel,
|
||||||
title: 'Viewer',
|
title: 'Markdown',
|
||||||
titleKey: 'tab.viewer',
|
icon: PhosphorIcons.fileText,
|
||||||
i18nNamespace: id,
|
|
||||||
priority: -100,
|
|
||||||
build: (_) => const MarkdownViewer(),
|
build: (_) => const MarkdownViewer(),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> activate(ClideExtensionContext ctx) async {
|
||||||
|
_sub = ctx.messages.subscribe(publisher: id, channel: 'selection').listen((msg) {
|
||||||
|
final path = msg.data['path'] as String?;
|
||||||
|
if (path == null) return;
|
||||||
|
ctx.panels.activateTab(Slots.contextPanel, 'markdown.viewer');
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
ctx.messages.publish(id, 'load', {'path': path});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deactivate() async => _sub?.cancel();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,19 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
|
|||||||
String? _path;
|
String? _path;
|
||||||
String? _content;
|
String? _content;
|
||||||
String? _error;
|
String? _error;
|
||||||
StreamSubscription<DaemonEvent>? _eventSub;
|
StreamSubscription<Message>? _selectionSub;
|
||||||
|
StreamSubscription<DaemonEvent>? _editorSub;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
if (_eventSub != null) return;
|
if (_selectionSub != null) return;
|
||||||
final kernel = ClideKernel.of(context);
|
final kernel = ClideKernel.of(context);
|
||||||
_eventSub = kernel.events.on<DaemonEvent>().listen((e) {
|
_selectionSub = kernel.messages.subscribe(publisher: 'builtin.markdown', channel: 'load').listen((msg) {
|
||||||
|
final path = msg.data['path'] as String?;
|
||||||
|
if (path != null) _loadFile(path);
|
||||||
|
});
|
||||||
|
_editorSub = kernel.events.on<DaemonEvent>().listen((e) {
|
||||||
if (e.kind == 'editor.buffer_activated') {
|
if (e.kind == 'editor.buffer_activated') {
|
||||||
final path = e.data['path'] as String?;
|
final path = e.data['path'] as String?;
|
||||||
if (path != null && path.endsWith('.md')) {
|
if (path != null && path.endsWith('.md')) {
|
||||||
@@ -30,33 +35,21 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
final activeTab = kernel.panels.activeTabIn(Slots.workspace);
|
|
||||||
if (activeTab == 'editor.active') {
|
|
||||||
unawaited(_loadActiveBuffer());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_eventSub?.cancel();
|
_selectionSub?.cancel();
|
||||||
|
_editorSub?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadActiveBuffer() async {
|
|
||||||
final kernel = ClideKernel.of(context);
|
|
||||||
final resp = await kernel.ipc.request('editor.active');
|
|
||||||
if (!mounted || !resp.ok) return;
|
|
||||||
final path = resp.data['path'] as String?;
|
|
||||||
if (path != null && path.endsWith('.md')) {
|
|
||||||
await _loadFile(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadFile(String path) async {
|
Future<void> _loadFile(String path) async {
|
||||||
final kernel = ClideKernel.of(context);
|
final kernel = ClideKernel.of(context);
|
||||||
final resp = await kernel.ipc.request('files.read', args: {'path': path});
|
final resp = await kernel.ipc.request('files.read', args: {'path': path});
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
|
kernel.messages.publish('builtin.markdown', 'focus', {'path': path});
|
||||||
setState(() {
|
setState(() {
|
||||||
_path = path;
|
_path = path;
|
||||||
_content = resp.data['content'] as String? ?? '';
|
_content = resp.data['content'] as String? ?? '';
|
||||||
@@ -67,6 +60,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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_error != null) {
|
if (_error != null) {
|
||||||
@@ -75,19 +77,15 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
|
|||||||
if (_content == null) {
|
if (_content == null) {
|
||||||
return const Padding(
|
return const Padding(
|
||||||
padding: EdgeInsets.all(12),
|
padding: EdgeInsets.all(12),
|
||||||
child: ClideText('Open a .md file to preview it here.', muted: true),
|
child: ClideText('Select a .md file to preview it here.', muted: true),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final tokens = ClideTheme.of(context).surface;
|
|
||||||
return ClidePaneChrome(
|
return ClidePaneChrome(
|
||||||
title: _path ?? 'viewer',
|
title: _path ?? 'viewer',
|
||||||
subtitle: '${_content!.split('\n').length} lines',
|
subtitle: '${_content!.split('\n').length} lines',
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Text(
|
child: ClideMarkdown(_content!, onRecordTap: (id) => _navigateToRecord(context, id)),
|
||||||
_content!,
|
|
||||||
style: TextStyle(color: tokens.globalForeground, fontSize: 13, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class BacklinksController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final DaemonClient ipc;
|
final DaemonClient ipc;
|
||||||
final EventBus events;
|
final DaemonBus events;
|
||||||
|
|
||||||
StreamSubscription<DaemonEvent>? _eventSub;
|
StreamSubscription<DaemonEvent>? _eventSub;
|
||||||
|
|
||||||
|
|||||||
@@ -61,8 +61,7 @@ class _BacklinksViewState extends State<BacklinksView> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
horizontal: 12, vertical: 4),
|
|
||||||
child: ClideText(
|
child: ClideText(
|
||||||
c.activePath!.split('/').last,
|
c.activePath!.split('/').last,
|
||||||
color: tokens.globalForeground,
|
color: tokens.globalForeground,
|
||||||
@@ -70,8 +69,7 @@ class _BacklinksViewState extends State<BacklinksView> {
|
|||||||
),
|
),
|
||||||
if (c.error != null)
|
if (c.error != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
horizontal: 12, vertical: 4),
|
|
||||||
child: ClideText(
|
child: ClideText(
|
||||||
c.error!,
|
c.error!,
|
||||||
color: tokens.statusError,
|
color: tokens.statusError,
|
||||||
@@ -120,8 +118,7 @@ class _LinkGroup extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding:
|
padding: const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 2),
|
||||||
const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 2),
|
|
||||||
child: ClideText(
|
child: ClideText(
|
||||||
'$label (${links.length})',
|
'$label (${links.length})',
|
||||||
fontSize: clideFontCaption,
|
fontSize: clideFontCaption,
|
||||||
@@ -133,60 +130,42 @@ class _LinkGroup extends StatelessWidget {
|
|||||||
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 2),
|
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 2),
|
||||||
child: ClideText('None', fontSize: clideFontCaption, muted: true),
|
child: ClideText('None', fontSize: clideFontCaption, muted: true),
|
||||||
),
|
),
|
||||||
for (final link in links)
|
for (final link in links) _LinkRow(link: link, pathKey: pathKey),
|
||||||
_LinkRow(link: link, pathKey: pathKey),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LinkRow extends StatefulWidget {
|
class _LinkRow extends StatelessWidget {
|
||||||
const _LinkRow({required this.link, required this.pathKey});
|
const _LinkRow({required this.link, required this.pathKey});
|
||||||
final Map<String, Object?> link;
|
final Map<String, Object?> link;
|
||||||
final String pathKey;
|
final String pathKey;
|
||||||
|
|
||||||
@override
|
|
||||||
State<_LinkRow> createState() => _LinkRowState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LinkRowState extends State<_LinkRow> {
|
|
||||||
bool _hover = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
final target = widget.link[widget.pathKey] as String? ?? '';
|
final target = link[pathKey] as String? ?? '';
|
||||||
final alias = widget.link['alias'] as String?;
|
final alias = link['alias'] as String?;
|
||||||
final display = alias ?? target;
|
final display = alias ?? target;
|
||||||
|
|
||||||
return MouseRegion(
|
return Semantics(
|
||||||
cursor: SystemMouseCursors.click,
|
button: true,
|
||||||
onEnter: (_) => setState(() => _hover = true),
|
label: target,
|
||||||
onExit: (_) => setState(() => _hover = false),
|
child: ClideTappable(
|
||||||
child: GestureDetector(
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (!target.startsWith('http')) {
|
if (!target.startsWith('http')) {
|
||||||
final kernel = ClideKernel.of(context);
|
final kernel = ClideKernel.of(context);
|
||||||
unawaited(
|
unawaited(kernel.ipc.request('editor.open', args: {'path': target}));
|
||||||
kernel.ipc.request('editor.open', args: {'path': target}));
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Semantics(
|
builder: (context, hovered, _) => Container(
|
||||||
button: true,
|
color: hovered ? tokens.sidebarItemHover : null,
|
||||||
label: target,
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
|
||||||
child: Container(
|
child: ClideText(
|
||||||
color: _hover ? tokens.sidebarItemHover : null,
|
display,
|
||||||
padding:
|
maxLines: 1,
|
||||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
|
overflow: TextOverflow.ellipsis,
|
||||||
child: ClideText(
|
color: target.startsWith('http') ? tokens.statusInfo : tokens.sidebarForeground,
|
||||||
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/builtin/pql/src/pql_panel_view.dart';
|
||||||
import 'package:clide/extension/extension.dart';
|
import 'package:clide/extension/extension.dart';
|
||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
|
||||||
class PqlExtension extends ClideExtension {
|
class PqlExtension extends ClideExtension {
|
||||||
@override
|
@override
|
||||||
@@ -9,7 +10,7 @@ class PqlExtension extends ClideExtension {
|
|||||||
@override
|
@override
|
||||||
String get title => 'pql';
|
String get title => 'pql';
|
||||||
@override
|
@override
|
||||||
String get version => '0.1.0';
|
String get version => '0.3.0';
|
||||||
@override
|
@override
|
||||||
List<String> get dependsOn => const [];
|
List<String> get dependsOn => const [];
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ class PqlExtension extends ClideExtension {
|
|||||||
title: 'pql',
|
title: 'pql',
|
||||||
titleKey: 'tab.title',
|
titleKey: 'tab.title',
|
||||||
i18nNamespace: id,
|
i18nNamespace: id,
|
||||||
|
icon: PhosphorIcons.magnifyingGlass,
|
||||||
priority: -60,
|
priority: -60,
|
||||||
build: (_) => const PqlPanelView(),
|
build: (_) => const PqlPanelView(),
|
||||||
),
|
),
|
||||||
@@ -28,8 +30,7 @@ class PqlExtension extends ClideExtension {
|
|||||||
id: 'pql.backlinks',
|
id: 'pql.backlinks',
|
||||||
slot: Slots.contextPanel,
|
slot: Slots.contextPanel,
|
||||||
title: 'Links',
|
title: 'Links',
|
||||||
titleKey: 'tab.links',
|
icon: PhosphorIcons.link,
|
||||||
i18nNamespace: id,
|
|
||||||
priority: -80,
|
priority: -80,
|
||||||
build: (_) => const BacklinksView(),
|
build: (_) => const BacklinksView(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/// State model for the pql sidebar panel.
|
/// State model for the pql sidebar panel.
|
||||||
///
|
///
|
||||||
/// Manages schema cache, query execution, file listing, and
|
/// Manages search, DSL query execution, and markdown file listing.
|
||||||
/// decision/ticket views. All data comes through pql.* IPC verbs.
|
/// All data comes through pql.* IPC verbs.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
@@ -9,16 +9,21 @@ import 'dart:async';
|
|||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
enum PqlView { files, query, decisions, tickets }
|
enum PqlView { query, markdown }
|
||||||
|
|
||||||
|
enum SearchMode { search, dsl }
|
||||||
|
|
||||||
class PqlController extends ChangeNotifier {
|
class PqlController extends ChangeNotifier {
|
||||||
PqlController({required this.ipc});
|
PqlController({required this.ipc});
|
||||||
|
|
||||||
final DaemonClient ipc;
|
final DaemonClient ipc;
|
||||||
|
|
||||||
PqlView _view = PqlView.files;
|
PqlView _view = PqlView.query;
|
||||||
PqlView get view => _view;
|
PqlView get view => _view;
|
||||||
|
|
||||||
|
SearchMode _searchMode = SearchMode.search;
|
||||||
|
SearchMode get searchMode => _searchMode;
|
||||||
|
|
||||||
String? _error;
|
String? _error;
|
||||||
String? get error => _error;
|
String? get error => _error;
|
||||||
|
|
||||||
@@ -38,23 +43,53 @@ class PqlController extends ChangeNotifier {
|
|||||||
_error = null;
|
_error = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
switch (v) {
|
switch (v) {
|
||||||
case PqlView.files:
|
case PqlView.markdown:
|
||||||
unawaited(loadFiles());
|
unawaited(loadMarkdownFiles());
|
||||||
case PqlView.decisions:
|
|
||||||
unawaited(loadDecisions());
|
|
||||||
case PqlView.tickets:
|
|
||||||
unawaited(loadTickets());
|
|
||||||
case PqlView.query:
|
case PqlView.query:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadFiles({String? glob}) async {
|
void toggleSearchMode() {
|
||||||
|
_searchMode = _searchMode == SearchMode.search ? SearchMode.dsl : SearchMode.search;
|
||||||
|
_results = const [];
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> search(String terms) async {
|
||||||
|
if (terms.trim().isEmpty) {
|
||||||
|
_results = const [];
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
final r = await ipc.request('pql.search', args: {
|
||||||
|
'terms': terms,
|
||||||
|
'limit': 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
_loading = false;
|
||||||
|
if (!r.ok) {
|
||||||
|
_error = r.error?.message;
|
||||||
|
_results = const [];
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_results = _castList(r.data['results']);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadMarkdownFiles({String? glob}) async {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
final r = await ipc.request('pql.files', args: {
|
final r = await ipc.request('pql.files', args: {
|
||||||
if (glob != null) 'glob': glob,
|
'glob': glob ?? '**/*.md',
|
||||||
'limit': 200,
|
'limit': 200,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,41 +126,6 @@ class PqlController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
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 {
|
Future<void> loadPlanStatus() async {
|
||||||
final r = await ipc.request('pql.plan.status');
|
final r = await ipc.request('pql.plan.status');
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
/// Sidebar panel for pql — file listing, DSL query input,
|
/// Sidebar panel for pql — ranked search, DSL query, and markdown file listing.
|
||||||
/// decisions list, and ticket board views.
|
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
@@ -19,8 +18,10 @@ class PqlPanelView extends StatefulWidget {
|
|||||||
|
|
||||||
class _PqlPanelViewState extends State<PqlPanelView> {
|
class _PqlPanelViewState extends State<PqlPanelView> {
|
||||||
PqlController? _controller;
|
PqlController? _controller;
|
||||||
final TextEditingController _queryInput = TextEditingController();
|
String? _focusedPath;
|
||||||
final FocusNode _queryFocus = FocusNode();
|
final _focusedKey = GlobalKey();
|
||||||
|
StreamSubscription<Message>? _focusSub;
|
||||||
|
StreamSubscription<DaemonEvent>? _fileSub;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
@@ -28,14 +29,35 @@ class _PqlPanelViewState extends State<PqlPanelView> {
|
|||||||
if (_controller != null) return;
|
if (_controller != null) return;
|
||||||
final kernel = ClideKernel.of(context);
|
final kernel = ClideKernel.of(context);
|
||||||
_controller = PqlController(ipc: kernel.ipc);
|
_controller = PqlController(ipc: kernel.ipc);
|
||||||
unawaited(_controller!.loadFiles());
|
_focusSub = kernel.messages.subscribe(publisher: 'builtin.markdown', channel: 'focus').listen((msg) {
|
||||||
|
final path = msg.data['path'] as String?;
|
||||||
|
if (path == null || path == _focusedPath) return;
|
||||||
|
setState(() {
|
||||||
|
_focusedPath = path;
|
||||||
|
if (_controller!.view != PqlView.markdown) {
|
||||||
|
_controller!.switchView(PqlView.markdown);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final ctx = _focusedKey.currentContext;
|
||||||
|
if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
_fileSub = kernel.events
|
||||||
|
.on<DaemonEvent>()
|
||||||
|
.where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && (e.data['path'] as String? ?? '').endsWith('.md'))
|
||||||
|
.listen((_) {
|
||||||
|
if (_controller?.view == PqlView.markdown) {
|
||||||
|
unawaited(_controller!.loadMarkdownFiles());
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_focusSub?.cancel();
|
||||||
|
_fileSub?.cancel();
|
||||||
_controller?.dispose();
|
_controller?.dispose();
|
||||||
_queryInput.dispose();
|
|
||||||
_queryFocus.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,62 +69,49 @@ class _PqlPanelViewState extends State<PqlPanelView> {
|
|||||||
listenable: c,
|
listenable: c,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
return Semantics(
|
return Column(
|
||||||
label: 'pql panel',
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
container: true,
|
children: [
|
||||||
explicitChildNodes: true,
|
_ViewTabs(controller: c),
|
||||||
child: Column(
|
if (c.view == PqlView.query) ...[
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
_SearchInput(controller: c),
|
||||||
children: [
|
],
|
||||||
_ViewTabs(controller: c),
|
if (c.view == PqlView.markdown)
|
||||||
if (c.view == PqlView.query)
|
ClideFilterBox(
|
||||||
_QueryInput(
|
hint: 'Filter markdown…',
|
||||||
input: _queryInput,
|
onChanged: (v) => unawaited(c.loadMarkdownFiles(glob: v.isEmpty ? null : '**/*$v*.md')),
|
||||||
focus: _queryFocus,
|
),
|
||||||
controller: c,
|
if (c.error != null)
|
||||||
),
|
Padding(
|
||||||
if (c.error != null)
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
Padding(
|
child: ClideText(c.error!, color: tokens.statusError, fontSize: clideFontCaption, maxLines: 3),
|
||||||
padding: const EdgeInsets.symmetric(
|
),
|
||||||
horizontal: 12, vertical: 4),
|
if (c.loading && c.results.isEmpty) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
|
||||||
child: ClideText(
|
if (!c.loading && c.results.isEmpty && c.error == null && c.view == PqlView.markdown)
|
||||||
c.error!,
|
const Padding(padding: EdgeInsets.all(12), child: ClideText('No markdown files found.', muted: true)),
|
||||||
color: tokens.statusError,
|
Expanded(
|
||||||
fontSize: clideFontCaption,
|
child: SingleChildScrollView(
|
||||||
maxLines: 3,
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
),
|
child: Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
if (c.loading && c.results.isEmpty)
|
mainAxisSize: MainAxisSize.min,
|
||||||
const Padding(
|
children: [
|
||||||
padding: EdgeInsets.all(12),
|
if (c.view == PqlView.markdown)
|
||||||
child: ClideText('Loading…', muted: true),
|
for (final f in c.results)
|
||||||
),
|
_FileRow(
|
||||||
if (!c.loading && c.results.isEmpty && c.error == null)
|
entry: f,
|
||||||
const Padding(
|
focused: (f['path'] as String?) == _focusedPath,
|
||||||
padding: EdgeInsets.all(12),
|
focusKey: (f['path'] as String?) == _focusedPath ? _focusedKey : null,
|
||||||
child: ClideText('No results.', muted: true),
|
),
|
||||||
),
|
if (c.view == PqlView.query && c.searchMode == SearchMode.search)
|
||||||
Expanded(
|
for (final r in c.results) _SearchResultRow(entry: r),
|
||||||
child: SingleChildScrollView(
|
if (c.view == PqlView.query && c.searchMode == SearchMode.dsl)
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
for (final r in c.results) _QueryResultRow(entry: r),
|
||||||
child: Column(
|
],
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (c.view == PqlView.files)
|
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -126,21 +135,14 @@ class _ViewTabs extends StatelessWidget {
|
|||||||
for (final v in PqlView.values)
|
for (final v in PqlView.values)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: Semantics(
|
child: GestureDetector(
|
||||||
button: true,
|
onTap: () => controller.switchView(v),
|
||||||
toggled: controller.view == v,
|
child: MouseRegion(
|
||||||
label: v.name,
|
cursor: SystemMouseCursors.click,
|
||||||
child: GestureDetector(
|
child: ClideText(
|
||||||
onTap: () => controller.switchView(v),
|
_tabLabel(v),
|
||||||
child: MouseRegion(
|
fontSize: clideFontCaption,
|
||||||
cursor: SystemMouseCursors.click,
|
color: controller.view == v ? tokens.globalForeground : tokens.globalTextMuted,
|
||||||
child: ClideText(
|
|
||||||
_tabLabel(v),
|
|
||||||
fontSize: clideFontCaption,
|
|
||||||
color: controller.view == v
|
|
||||||
? tokens.globalForeground
|
|
||||||
: tokens.globalTextMuted,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -151,50 +153,92 @@ class _ViewTabs extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static String _tabLabel(PqlView v) => switch (v) {
|
static String _tabLabel(PqlView v) => switch (v) {
|
||||||
PqlView.files => 'Files',
|
PqlView.query => 'Search',
|
||||||
PqlView.query => 'Query',
|
PqlView.markdown => 'Markdown',
|
||||||
PqlView.decisions => 'Decisions',
|
|
||||||
PqlView.tickets => 'Tickets',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class _QueryInput extends StatelessWidget {
|
class _SearchInput extends StatelessWidget {
|
||||||
const _QueryInput({
|
const _SearchInput({required this.controller});
|
||||||
required this.input,
|
|
||||||
required this.focus,
|
|
||||||
required this.controller,
|
|
||||||
});
|
|
||||||
|
|
||||||
final TextEditingController input;
|
|
||||||
final FocusNode focus;
|
|
||||||
final PqlController controller;
|
final PqlController controller;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
return Padding(
|
final isDsl = controller.searchMode == SearchMode.dsl;
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
return Column(
|
||||||
child: Semantics(
|
mainAxisSize: MainAxisSize.min,
|
||||||
label: 'pql query',
|
children: [
|
||||||
textField: true,
|
ClideFilterBox(
|
||||||
child: Container(
|
hint: isDsl ? 'PQL query…' : 'Search vault…',
|
||||||
decoration: BoxDecoration(
|
debounce: isDsl ? Duration.zero : const Duration(milliseconds: 300),
|
||||||
border: Border.all(color: tokens.globalBorder),
|
onChanged: isDsl ? (_) {} : (v) => unawaited(controller.search(v)),
|
||||||
|
onSubmitted: isDsl ? (v) => unawaited(controller.runQuery(v)) : (v) => unawaited(controller.search(v)),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: controller.toggleSearchMode,
|
||||||
|
child: MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDsl ? tokens.globalFocus.withAlpha(0x30) : null,
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
border: Border.all(color: isDsl ? tokens.globalFocus : tokens.panelBorder),
|
||||||
|
),
|
||||||
|
child: ClideText('DSL', fontSize: clideFontBadge, color: isDsl ? tokens.globalFocus : tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
ClideText(
|
||||||
|
isDsl ? 'SQL-like query mode' : 'ranked text search',
|
||||||
|
fontSize: clideFontBadge,
|
||||||
|
muted: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
),
|
||||||
child: EditableText(
|
],
|
||||||
controller: input,
|
);
|
||||||
focusNode: focus,
|
}
|
||||||
style: TextStyle(
|
}
|
||||||
fontFamily: clideMonoFamily,
|
|
||||||
fontSize: clideFontMono,
|
class _SearchResultRow extends StatelessWidget {
|
||||||
color: tokens.globalForeground,
|
const _SearchResultRow({required this.entry});
|
||||||
),
|
final Map<String, Object?> entry;
|
||||||
cursorColor: tokens.globalFocus,
|
|
||||||
backgroundCursorColor: tokens.globalFocus,
|
@override
|
||||||
maxLines: 1,
|
Widget build(BuildContext context) {
|
||||||
onSubmitted: (_) =>
|
final tokens = ClideTheme.of(context).surface;
|
||||||
unawaited(controller.runQuery(input.text)),
|
final path = entry['path'] as String? ?? '';
|
||||||
|
final score = (entry['score'] as num?)?.toDouble() ?? 0;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||||
|
child: ClideTappable(
|
||||||
|
onTap: () {
|
||||||
|
if (path.endsWith('.md')) {
|
||||||
|
ClideKernel.of(context).messages.publish('builtin.markdown', 'selection', {'path': path});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (context, hovered, _) => Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: hovered ? tokens.sidebarItemHover : null,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ClideText(path, fontSize: clideFontCaption, color: tokens.sidebarForeground, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
_ScoreBar(score: score, tokens: tokens),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -202,45 +246,65 @@ class _QueryInput extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FileRow extends StatefulWidget {
|
class _ScoreBar extends StatelessWidget {
|
||||||
const _FileRow({required this.entry});
|
const _ScoreBar({required this.score, required this.tokens});
|
||||||
final Map<String, Object?> entry;
|
final double score;
|
||||||
|
final SurfaceTokens tokens;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_FileRow> createState() => _FileRowState();
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 60,
|
||||||
|
height: 3,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(1.5),
|
||||||
|
child: ColoredBox(
|
||||||
|
color: tokens.panelBorder,
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
widthFactor: score.clamp(0, 1),
|
||||||
|
child: ColoredBox(color: tokens.globalFocus),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
ClideText('${(score * 100).round()}%', fontSize: clideFontBadge, muted: true, fontFamily: clideMonoFamily),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FileRowState extends State<_FileRow> {
|
class _FileRow extends StatelessWidget {
|
||||||
bool _hover = false;
|
const _FileRow({required this.entry, this.focused = false, this.focusKey});
|
||||||
|
final Map<String, Object?> entry;
|
||||||
|
final bool focused;
|
||||||
|
final GlobalKey? focusKey;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
final path = widget.entry['path'] as String? ?? '';
|
final path = entry['path'] as String? ?? '';
|
||||||
final name = widget.entry['name'] as String? ?? path.split('/').last;
|
return Padding(
|
||||||
return MouseRegion(
|
key: focusKey,
|
||||||
cursor: SystemMouseCursors.click,
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||||
onEnter: (_) => setState(() => _hover = true),
|
child: ClideTappable(
|
||||||
onExit: (_) => setState(() => _hover = false),
|
onTap: () => ClideKernel.of(context).messages.publish('builtin.markdown', 'selection', {'path': path}),
|
||||||
child: GestureDetector(
|
builder: (context, hovered, _) => Container(
|
||||||
behavior: HitTestBehavior.opaque,
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
onTap: () {
|
decoration: BoxDecoration(
|
||||||
final kernel = ClideKernel.of(context);
|
color: hovered ? tokens.sidebarItemHover : (focused ? tokens.sidebarItemSelected : null),
|
||||||
unawaited(
|
borderRadius: BorderRadius.circular(4),
|
||||||
kernel.ipc.request('editor.open', args: {'path': path}));
|
border: focused ? Border.all(color: tokens.globalFocus, width: 1) : null,
|
||||||
},
|
),
|
||||||
child: Semantics(
|
child: ClideText(
|
||||||
button: true,
|
path,
|
||||||
label: 'Open $name',
|
maxLines: 1,
|
||||||
child: Container(
|
overflow: TextOverflow.ellipsis,
|
||||||
color: _hover ? tokens.sidebarItemHover : null,
|
fontSize: clideFontCaption,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
|
color: tokens.sidebarForeground,
|
||||||
child: ClideText(
|
|
||||||
path,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
color: tokens.sidebarForeground,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -256,10 +320,7 @@ class _QueryResultRow extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
final name = entry['name'] as String? ?? entry['path'] as String? ?? '';
|
final name = entry['name'] as String? ?? entry['path'] as String? ?? '';
|
||||||
final values = entry.entries
|
final values = entry.entries.where((e) => e.key != 'name' && e.key != 'path').map((e) => '${e.key}: ${e.value}').join(' · ');
|
||||||
.where((e) => e.key != 'name' && e.key != 'path')
|
|
||||||
.map((e) => '${e.key}: ${e.value}')
|
|
||||||
.join(' · ');
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -267,110 +328,9 @@ class _QueryResultRow extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ClideText(name, color: tokens.sidebarForeground),
|
ClideText(name, color: tokens.sidebarForeground),
|
||||||
if (values.isNotEmpty)
|
if (values.isNotEmpty) ClideText(values, fontSize: clideFontCaption, muted: true, maxLines: 2),
|
||||||
ClideText(values, fontSize: clideFontCaption, muted: true, maxLines: 2),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||