Compare commits
60
Commits
eeb2ee4b82
..
v2.1.0
@@ -0,0 +1,23 @@
|
||||
# Clide Claude-Code skills
|
||||
|
||||
Skills are reusable instruction packs Claude Code loads on demand. Each one
|
||||
lives in its own directory with a `SKILL.md` (frontmatter + body) and any
|
||||
helper scripts. This index is for humans skimming what's available; Claude
|
||||
discovers skills automatically from the directory structure.
|
||||
|
||||
| Skill | Purpose |
|
||||
|---|---|
|
||||
| [`d2-diagram`](d2-diagram/SKILL.md) | Generate technical diagrams from `.d2` source with the d2 CLI; renders to PNG. |
|
||||
| [`frame0-wireframe`](frame0-wireframe/SKILL.md) | Author UI wireframes as local JSON and sync to the Frame0 desktop app for rendering + export. |
|
||||
| [`git-commit`](git-commit/SKILL.md) | Commit conventions for this repo — message style, CHANGELOG discipline (40/60 word cap), attribution trailer, safety rules. |
|
||||
| [`pql`](pql/SKILL.md) | Query and plan against the markdown vault via the `pql` CLI (decisions, tickets, structural queries). |
|
||||
| [`skill-create`](skill-create/SKILL.md) | Guidance for creating new skills — SKILL.md structure, bundling scripts, packaging. |
|
||||
| [`testmode`](testmode/SKILL.md) | Run and interpret the `ClideTestApp` platform integration harness; smoke-test after toolchain / IPC / theme / native changes. |
|
||||
| [`ui-design`](ui-design/SKILL.md) | Visual design guide — surface tokens, control geometry, Phosphor icons. |
|
||||
| [`whats-next`](whats-next/SKILL.md) | Dependency-driven batch selection against the pql backlog. Walks the initiative/epic tree, filters unblocked work, refines, optionally activates. |
|
||||
|
||||
## Adding a skill
|
||||
|
||||
Use the `skill-create` skill (or follow its SKILL.md by hand). Add a row to
|
||||
the table above so the inventory stays accurate; the index is otherwise just
|
||||
a directory listing.
|
||||
@@ -53,6 +53,35 @@ This repo follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/)
|
||||
|
||||
Entries should be short imperative phrases that describe user-facing impact — not implementation detail. "Added sidecar PTY support for terminal pane" beats "Added `internal/pty/session.go`."
|
||||
|
||||
### Be concise — this is the rule, not a suggestion
|
||||
|
||||
CHANGELOG entries must be **one or two short sentences**. Hard cap: **60 words per bullet** (enforced by `ci/changelog_gate.sh`). Aim for 30 or under; if you can't say it in one line wrapped at ~75 columns, you're writing the wrong document.
|
||||
|
||||
The CHANGELOG is read by humans scanning for what changed between two versions. It is **not** the place for the rationale, the probe results, the implementation detail, the behavior-change deep dive, or the "see also" cross-references. Those belong in:
|
||||
|
||||
- the **commit message body** — explain *why*, list evidence, name the trade-offs;
|
||||
- a **D-record / decision document** — durable architectural rationale;
|
||||
- the **ticket / PR description** — work-context and review notes.
|
||||
|
||||
Hard rules:
|
||||
|
||||
- **No multi-paragraph bullets.** One paragraph max. If you reach for a blank line inside a bullet, stop and split or trim.
|
||||
- **No "Behavior change:" / "Side benefit:" / "Note:" sub-headers inside a bullet.** Those are essay structure; put them in the commit message.
|
||||
- **No probe numbers, latency stats, or %-coverage deltas in entries.** ("hit 95% target" is fine; "0 hangs in 300 spawns vs ~5% before" is commit-body material.)
|
||||
- **No nested function/file lists inside parentheses.** If you find yourself writing `(foo, bar, baz, …)` for more than 3 items, just say "several X" and trust the diff.
|
||||
- **Don't restate the title in the body.** A bullet is its own title.
|
||||
|
||||
Calibration — match the **existing entries** in `CHANGELOG.md`. Open it, look at five recent bullets, write to that length. If your draft is visibly bigger than its neighbors, trim until it isn't.
|
||||
|
||||
Good:
|
||||
> - Mouse wheel scrolling in Claude pane — converts scroll events to PgUp/PgDown so TUI apps scroll naturally.
|
||||
|
||||
Bad (verbose; commit-body material leaked in):
|
||||
> - **PTY spawning switched from `forkpty()` to `posix_openpt()` + `posix_spawn()`** (T-96). `forkpty` calls `fork()` underneath, which is unsafe in the multithreaded Dart VM: about 5% of spawns deadlocked in the child before `execve` because libc locks held by ghost-threads remained "locked forever" in the forked child. `posix_spawn` uses `vfork` (glibc/musl/macOS), keeping the parent suspended until `execve` completes — no Dart code runs in the child. Probed: zero hangs in 300 sequential spawns vs ~5% before. **Behavior change:** missing executable / missing workingDirectory now surface as a `PtyException`…
|
||||
|
||||
Better:
|
||||
> - PTY spawning uses `posix_openpt` + `posix_spawn` instead of `forkpty` — closes a ~5% deadlock window in the multithreaded Dart VM (T-96). Missing exe/cwd now throw `PtyException` at spawn time.
|
||||
|
||||
**What skips the changelog:** pure bookkeeping commits that have no user-visible effect (typo fix in internal comment, `.gitignore` tweak, lint config change, reformatting). When in doubt, add an entry — the harm of an extra line is zero.
|
||||
|
||||
When a commit spans multiple entries (e.g. a feature that adds one thing and fixes another), add a line under each applicable subsection rather than cramming both into one.
|
||||
|
||||
@@ -1663,3 +1663,153 @@ The `PanGestureRecognizer` in `TerminalGestureDetector` is registered with `supp
|
||||
|
||||
**Cross-references:** T-91 (epic parent), T-93 (same shape on `onTapUp`), T-89 (coverage epic).
|
||||
', NULL, '2026-05-08 11:01:01', '2026-05-08 11:01:01', '2026-05-08 11:01:01', NULL, '8088d054dd8c7946075ed6325724dfb3', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-96', 'description', 'The reader isolate spawned by `NativePty._spawnReaderAsync` (`lib/src/pty/native_pty.dart`) hangs intermittently — about 5% of fresh spawns — with poll() on the master fd never returning POLLIN or POLLHUP, even when the child has clearly written and exited. Probed: 2 hangs out of 30 sequential `/bin/sh -c "printf hello-pty"` spawns over a 30s/spawn timeout.
|
||||
|
||||
**Symptom:** test sees zero `pane.output` events and no `pane.exit`; the reader isolate sits in `poll(pfd, 1, 100)` returning 0 forever. The hang is per-spawn; a fresh `NativePty.start` recovers cleanly.
|
||||
|
||||
**Workaround in place:** `retry: 2` on every PTY-output-dependent test in `test/pty/session_test.dart` and `test/panes/registry_test.dart`. Three combined attempts at ~5% per-spawn fail rate ≈ 99.99% success. Comments at the test sites point back here.
|
||||
|
||||
**Acceptance:**
|
||||
- Identify why a fresh master fd sometimes never delivers ready events. Candidates to investigate: a race between `forkpty()` and `Isolate.spawn` (the new isolate may see an FD table snapshot from an awkward moment), a missing fcntl flag on the master fd, lazy `DynamicLibrary.process()` symbol resolution in a freshly-spawned dart isolate, GC interference, or wasmtime/tree-sitter signal handlers (unlikely — the probe ran in a clean dart isolate with no wasmtime).
|
||||
- Fix the underlying race so a single spawn delivers reliably.
|
||||
- Remove the `retry: 2` decorations and the explanatory comments in both test files.
|
||||
|
||||
**Out of scope:** changing the PTY threading model or the reader-isolate architecture. The fix should keep `Isolate.spawn(_readLoop, ...)` as the I/O primitive.
|
||||
|
||||
**Cross-references:** D-5 (Dart core + sidecar dissolution; PTY is owned via FFI), `lib/src/pty/native_pty.dart#L235-262` (`_spawnReaderAsync`), `lib/src/pty/native_pty.dart#L265-293` (`_readLoop`).', 'The reader isolate spawned by `NativePty._spawnReaderAsync` (`lib/src/pty/native_pty.dart`) hangs intermittently — about 5% of fresh spawns — with poll() on the master fd never returning POLLIN or POLLHUP, even when the child has clearly written and exited. Probed: 2 hangs out of 30 sequential `/bin/sh -c "printf hello-pty"` spawns over a 30s/spawn timeout.
|
||||
|
||||
**Root cause — found 2026-05-17 via forensic probe:**
|
||||
|
||||
The "child" never reaches `execve`. `/proc/<child-pid>/stat` reports comm `(DartWorker)` (the Dart VM worker thread name) instead of `sh`, with state `S` (sleeping). The pty master fd is a real pty (tty-index assigned, fcntl flags 0x8002 = O_RDWR|O_NOCTTY), but `poll(fd, 500ms)` from the main isolate returns 0 — nothing was ever written.
|
||||
|
||||
This is **`fork()` in a multithreaded process** — a textbook async-signal-safety violation. The Dart VM runs multiple worker threads that hold libc locks (notably `malloc`). When `forkpty()` calls `fork()`, only the calling thread survives in the child, but the locks held by ghost-threads remain "locked forever." The child deadlocks before it can complete its post-fork → pre-execve setup.
|
||||
|
||||
Why not always? Lock state at fork() time is timing-dependent. ~95% of the time no Dart worker happens to hold a problematic lock, and execve proceeds. ~5% of the time it deadlocks.
|
||||
|
||||
**Workaround in place (test-only):** `retry: 2` on every PTY-output-dependent test in `test/pty/session_test.dart` and `test/panes/registry_test.dart`. Three combined attempts at ~5% per-spawn fail rate ≈ 99.99% combined success. Comments at the test sites point back here.
|
||||
|
||||
**Proper fix:** replace `forkpty()` with `posix_openpt()` + `unlockpt()` + `grantpt()` + `posix_spawn()` (with file actions wiring the pty slave to stdin/stdout/stderr). `posix_spawn` uses `vfork()` under glibc/musl, which keeps the parent suspended until execve completes — no Dart code ever runs in the child, no lock-deadlock possible. Linux and macOS both support this API.
|
||||
|
||||
**Immediate hardening alternative (Linux-only, simpler):** after spawn, sample `/proc/<pid>/comm` after ~250ms. If it equals the parent process''s comm, execve never ran → kill the child, surface `PtyException(''execve-deadlock'', ...)`, let the caller retry. This catches the deadlocked state deterministically instead of waiting for a poll timeout. It doesn''t fix the bug, but turns silent hangs into reportable errors.
|
||||
|
||||
**Acceptance for closing this ticket:**
|
||||
1. Replace forkpty path with posix_openpt + posix_spawn (or implement the comm-check hardening as an interim).
|
||||
2. Probe (200 sequential `printf hello`-and-exit spawns) reports zero hangs.
|
||||
3. Remove the `retry: 2` decorations + explanatory comments in `test/pty/session_test.dart` (6 tests) and `test/panes/registry_test.dart` (1 test).
|
||||
|
||||
**Out of scope:** changing the reader-isolate architecture itself. `Isolate.spawn(_readLoop, ...)` for output stays.
|
||||
|
||||
**Cross-references:**
|
||||
- D-5 (Dart core + sidecar dissolution; PTY is owned via FFI)
|
||||
- `lib/src/pty/native_pty.dart:200` (the forkpty call site)
|
||||
- `lib/src/pty/native_pty.dart:170-191` (child-side post-fork code that deadlocks)
|
||||
- `lib/src/pty/native_pty.dart:235-262` (reader spawn — not the bug; downstream symptom)
|
||||
- glibc posix_spawn docs: https://www.gnu.org/software/libc/manual/html_node/Process-Creation-Example.html
|
||||
', NULL, '2026-05-17 18:05:33', '2026-05-17 18:05:33', '2026-05-17 18:05:33', NULL, '56d9ba7c49068a498a003e900cde416f', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-96', 'status', 'backlog', 'done', NULL, '2026-05-17 18:11:18', '2026-05-17 18:11:18', '2026-05-17 18:11:18', NULL, '3215fc763c4f1dce75ea9e0bb2b9bbb6', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-98', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 18:51:25', '2026-05-17 18:51:25', '2026-05-17 18:51:25', NULL, '0369c8264ec2c67b3bf3f28057404ec0', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-98', 'status', 'in_progress', 'done', NULL, '2026-05-17 18:55:57', '2026-05-17 18:55:57', '2026-05-17 18:55:57', NULL, 'cb0ae539d5665bd9720b327d60a2cbed', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-101', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 18:58:56', '2026-05-17 18:58:56', '2026-05-17 18:58:56', NULL, '55d14b4493a159695d4b40bbd19fff23', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-102', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 18:58:56', '2026-05-17 18:58:56', '2026-05-17 18:58:56', NULL, '7fa075d7232df5302ffdfb5d1c3d79f2', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-101', 'status', 'in_progress', 'done', NULL, '2026-05-17 19:01:39', '2026-05-17 19:01:39', '2026-05-17 19:01:39', NULL, '56e8da656c5fc53af241900e6313805e', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-102', 'status', 'in_progress', 'done', NULL, '2026-05-17 19:01:39', '2026-05-17 19:01:39', '2026-05-17 19:01:39', NULL, '7f09d2a1501d3d072e071822c191f253', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-103', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 19:03:18', '2026-05-17 19:03:18', '2026-05-17 19:03:18', NULL, '2635d907f94402ec506d3b56dfc25b21', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-106', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 19:03:18', '2026-05-17 19:03:18', '2026-05-17 19:03:18', NULL, 'cc39b3fd718e99423823e640950ccf1b', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-113', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 19:03:18', '2026-05-17 19:03:18', '2026-05-17 19:03:18', NULL, 'cc8605833ba5cb97e068eb5a6d57d8f4', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-106', 'status', 'in_progress', 'done', NULL, '2026-05-17 19:18:43', '2026-05-17 19:18:43', '2026-05-17 19:18:43', NULL, '8dccc83b93c5005db744a100a5b3baea', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-113', 'status', 'in_progress', 'done', NULL, '2026-05-17 19:18:43', '2026-05-17 19:18:43', '2026-05-17 19:18:43', NULL, '8f7812831e564005eb796708543c54b3', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-103', 'status', 'in_progress', 'done', NULL, '2026-05-17 19:18:43', '2026-05-17 19:18:43', '2026-05-17 19:18:43', NULL, 'c7d1b76cb6854efb7e9990cc7204d899', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-100', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 19:23:24', '2026-05-17 19:23:24', '2026-05-17 19:23:24', NULL, '607b82f096b5d694fb75b7daf64dc2e0', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-117', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 19:28:31', '2026-05-17 19:28:31', '2026-05-17 19:28:31', NULL, 'b5c56dff8a2c7d6f14ecf19231b63268', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-110', 'description', '`app.dart:90-148` routes all shortcuts through one root `KeyboardListener` — no per-context scoping; will conflict with text-input fields once any pane wants to capture keys. `KeybindingResolver.fromKeyEvent` keys off `logicalKey.keyLabel` which is layout-dependent (US-QWERTY `Ctrl+/` differs from AZERTY `Ctrl+:`).
|
||||
|
||||
**Fix:**
|
||||
1. Replace the root `KeyboardListener` with scoped `Shortcuts`/`Actions` per slot.
|
||||
2. Move `KeybindingResolver` to `physicalKey` or a stable mapping layer.
|
||||
|
||||
Coordinate with T-100 (Focus/Shortcuts wrapper for ClideTappable) and T-105 (focus traversal).
|
||||
|
||||
Source: consultants.md "UX — Findings — [Major]".', '**Superseded by T-117 — Done.**
|
||||
|
||||
The consultant findings here — single root `KeyboardListener` will conflict with text-input fields; `KebindingResolver.fromKeyEvent` keys off layout-dependent `logicalKey.keyLabel` — are both addressed by the keystroke mapper layer (T-117):
|
||||
|
||||
- Keymap-driven dispatch uses `LogicalKeyboardKey.keyId` (stable across layouts), not `keyLabel`.
|
||||
- Root `KeyboardListener` now hands events straight to `KeymapService.resolveEvent`, which dispatches resolved Intents via `Actions.maybeInvoke` against the focused context — Actions providers per feature (palette, editor, etc.) handle their own intents; the root only handles global ones (text scale, generic command bridge). This is the scoped Shortcuts/Actions model the consultant prescribed.
|
||||
|
||||
Remaining cleanup (deletion of the now-vestigial `KeybindingResolver` class + the legacy `KeyboardListener` wrap once Flutter Shortcuts widget integration is on every feature) is small and lands as part of T-100 or its own follow-up.
|
||||
|
||||
Original text: Replace root KeyboardListener with scoped Shortcuts/Actions; move KebindingResolver off layout-dependent keyLabel.', NULL, '2026-05-17 19:39:08', '2026-05-17 19:39:08', '2026-05-17 19:39:08', NULL, '6364d644fbbfa903390ee78b5b402367', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-110', 'status', 'backlog', 'done', NULL, '2026-05-17 19:39:12', '2026-05-17 19:39:12', '2026-05-17 19:39:12', NULL, '6073b1bbc12001914d78d4cb419705dc', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-23', 'description', 'D-48 names `⌘P` (fuzzy file open) and `⌘⇧P` (command palette) as the canonical keyboard navigation. The command palette overlay/widget exists; the keybinding is not yet wired.
|
||||
|
||||
**Acceptance:**
|
||||
- `⌘⇧P` (`Ctrl+Shift+P` on Linux, follows the kernel keymap normalization) opens the command palette overlay over the active workspace.
|
||||
- Esc dismisses; Enter runs the highlighted command; arrow keys move the highlight.
|
||||
- Commands listed are everything registered via `CommandContribution` across all activated extensions.
|
||||
- Fuzzy match against command title; recent / pinned commands float to the top.
|
||||
|
||||
**Implementation hints:**
|
||||
- Slot exists: `Slots.commandPalette` is reserved (lib/kernel/src/panels/slot_id.dart).
|
||||
- Keybinding goes in `lib/kernel/src/commands/keybindings.dart` per the D-54 keymap.
|
||||
- The overlay should not shift layout (D-48 chrome budget — no layout shift on palette open).', 'D-48 names `⌘P` (fuzzy file open) and `⌘⇧P` (command palette) as the canonical keyboard navigation. The command palette overlay/widget exists; the keybinding is not yet wired.
|
||||
|
||||
**Progress (2026-05-17, T-117):** The keymap layer now binds `ctrl+shift+p` / `meta+shift+p` to `PaletteOpenIntent` in `assets/keymaps/default.yaml`. The intent resolves end-to-end through `KeymapService.resolveEvent` → `Actions.maybeInvoke`. **Still pending**: an `Actions` provider somewhere in the tree that handles `PaletteOpenIntent` by calling `kernel.palette.open()`, plus the arrow-key / Escape / Enter handlers on `ClidePalette` itself. Those land as part of T-100 (palette keyboard nav).
|
||||
|
||||
**Acceptance:**
|
||||
- `⌘⇧P` (`Ctrl+Shift+P` on Linux, follows the kernel keymap normalization) opens the command palette overlay over the active workspace.
|
||||
- Esc dismisses; Enter runs the highlighted command; arrow keys move the highlight.
|
||||
- Commands listed are everything registered via `CommandContribution` across all activated extensions.
|
||||
- Fuzzy match against command title; recent / pinned commands float to the top.
|
||||
|
||||
**Implementation hints:**
|
||||
- Slot exists: `Slots.commandPalette` is reserved (lib/kernel/src/panels/slot_id.dart).
|
||||
- Bindings live in the keymap (T-117) — not in `lib/kernel/src/commands/keybindings.dart` (that file is legacy).
|
||||
- The overlay should not shift layout (D-48 chrome budget — no layout shift on palette open).', NULL, '2026-05-17 19:39:23', '2026-05-17 19:39:23', '2026-05-17 19:39:23', NULL, '50203b7db93490ac779fe496865cf3a0', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-64', 'description', 'Ship a VS Code-compatible keybinding preset that maps standard VS Code shortcuts to clide commands. Users select it in settings. Covers file navigation, editor actions, panel toggles, search, and terminal.', 'Ship a VS Code-compatible keybinding preset that maps standard VS Code shortcuts to clide commands. Users select it in settings. Covers file navigation, editor actions, panel toggles, search, and terminal.
|
||||
|
||||
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is now in place. Implementation is now just authoring `assets/keymaps/vscode.yaml` against the typed Intents in `lib/kernel/src/keymap/intents.dart` (plus `command:<id>` bindings for VS-Code-specific commands the preset wants to bind to clide commands). Users will switch presets via `app.keymap.preset = vscode` once a settings UI exists, or directly via the setting today.
|
||||
|
||||
**Acceptance:**
|
||||
1. `assets/keymaps/vscode.yaml` ships covering the documented VS Code default keybindings.
|
||||
2. `KeymapService.setPreset("vscode")` activates the preset and all asserted bindings resolve as expected.
|
||||
3. The preset uses when-clauses where VS Code does (`editor.focused`, `inputFocused`, `palette.open`, …).
|
||||
4. A regression test loads the preset and asserts a representative subset (e.g. ctrl+p → quick-open command, ctrl+shift+p → palette).
|
||||
|
||||
**Out of scope:** clide commands that have no VS Code analogue (those keep their default-preset bindings).', NULL, '2026-05-17 19:39:37', '2026-05-17 19:39:37', '2026-05-17 19:39:37', NULL, 'a97758128bd490f603f11a68883a7193', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-65', 'description', 'Ship a Vim-compatible keybinding preset with modal editing support (normal/insert/visual modes). Maps Vim motions and commands to clide editor and navigation actions. Users select it in settings.', 'Ship a Vim-compatible keybinding preset with modal editing support (normal/insert/visual modes). Maps Vim motions and commands to clide editor and navigation actions. Users select it in settings.
|
||||
|
||||
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place; modal Vim presets are more involved than the VS Code preset (T-64) because modes need to be expressed as scope flags (`vim.normal`, `vim.insert`, `vim.visual`) that the when-clause grammar can branch on. Implementation work:
|
||||
|
||||
1. Author `assets/keymaps/vim.yaml` using the typed Intents + `command:<id>` bindings.
|
||||
2. Add a small mode-tracking service that publishes `vim.<mode>` scope flags via `KeymapService.setScopeFlag`.
|
||||
3. Bind `Esc` to mode-reset → normal; `i` (when `vim.normal`) → enter insert; etc.
|
||||
|
||||
**Acceptance:**
|
||||
1. `assets/keymaps/vim.yaml` ships covering the documented Vim default keybindings for editor / navigation / panes.
|
||||
2. `KeymapService.setPreset("vim")` + the mode-tracking service together produce correct mode transitions.
|
||||
3. A regression test exercises a representative motion (`j` → cursor down) and a mode change (`i` → insert).', NULL, '2026-05-17 19:39:37', '2026-05-17 19:39:37', '2026-05-17 19:39:37', NULL, 'bf10f0149ccaa1e02050280b28305816', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-66', 'description', 'Ship a JetBrains/IntelliJ-compatible keybinding preset mapping standard JetBrains shortcuts to clide commands. Covers navigation, refactoring, search, run/debug, and tool windows.', 'Ship a JetBrains/IntelliJ-compatible keybinding preset mapping standard JetBrains shortcuts to clide commands. Covers navigation, refactoring, search, run/debug, and tool windows.
|
||||
|
||||
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place. Implementation is authoring `assets/keymaps/jetbrains.yaml` against the typed Intents + `command:<id>` bindings, plus when-clauses for the contexts JetBrains presets typically scope to (`editor.focused`, `inputFocused`, etc.).
|
||||
|
||||
**Acceptance:**
|
||||
1. `assets/keymaps/jetbrains.yaml` ships covering the documented IntelliJ default keybindings.
|
||||
2. `KeymapService.setPreset("jetbrains")` activates the preset and all asserted bindings resolve.
|
||||
3. A regression test exercises a representative subset (e.g. shift+shift → quick-open command — see Q-9 if the search-everywhere overlay needs its own intent).', NULL, '2026-05-17 19:39:37', '2026-05-17 19:39:37', '2026-05-17 19:39:37', NULL, 'c1129f4cb3454b99e1cbf645ca56bea5', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-117', 'status', 'in_progress', 'done', NULL, '2026-05-17 19:39:40', '2026-05-17 19:39:40', '2026-05-17 19:39:40', NULL, '75b2fd14ceb7daf28645ccf84cb041f1', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-100', 'status', 'in_progress', 'in_progress', NULL, '2026-05-17 19:40:44', '2026-05-17 19:40:44', '2026-05-17 19:40:44', NULL, '65600862f5787501dcd057756cdb4313', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-100', 'status', 'in_progress', 'done', NULL, '2026-05-17 19:47:50', '2026-05-17 19:47:50', '2026-05-17 19:47:50', NULL, '7d3c0ad4c47d3271ac35c5159e6e9d0e', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-105', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 19:52:39', '2026-05-17 19:52:39', '2026-05-17 19:52:39', NULL, '129acb5d6b1882cb6ed4c9be46fd3e81', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-105', 'status', 'in_progress', 'done', NULL, '2026-05-17 19:56:28', '2026-05-17 19:56:28', '2026-05-17 19:56:28', NULL, '62dfdadc31b2c231afd837fb46f0a114', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-108', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 19:58:13', '2026-05-17 19:58:13', '2026-05-17 19:58:13', NULL, 'a5dbfe5881e152e82538181b0fe56609', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-108', 'status', 'in_progress', 'done', NULL, '2026-05-17 20:05:38', '2026-05-17 20:05:38', '2026-05-17 20:05:38', NULL, '6815472fbfaa6d7740945b42d4c87f57', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-112', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 20:06:30', '2026-05-17 20:06:30', '2026-05-17 20:06:30', NULL, '52271781bf987a984d9f219956034d1f', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-112', 'status', 'in_progress', 'done', NULL, '2026-05-17 20:11:16', '2026-05-17 20:11:16', '2026-05-17 20:11:16', NULL, '6d51964a3ad7f1948f54a7c769336841', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-114', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 20:13:36', '2026-05-17 20:13:36', '2026-05-17 20:13:36', NULL, '0bad40fa7f165f828bb0c0757044491e', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-118', 'status', 'backlog', 'done', NULL, '2026-05-17 20:53:41', '2026-05-17 20:53:41', '2026-05-17 20:53:41', NULL, 'ff0126655805fed0ebe45f313c5ceff7', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-114', 'status', 'in_progress', 'done', NULL, '2026-05-18 07:04:38', '2026-05-18 07:04:38', '2026-05-18 07:04:38', NULL, '8cf198f8c824fa8f46277b7e22e68f0c', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-111', 'status', 'in_progress', 'done', NULL, '2026-05-18 07:43:45', '2026-05-18 07:43:45', '2026-05-18 07:43:45', NULL, '792efcf7c772de7cda7069ea3fc3ac0a', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-104', 'status', 'backlog', 'in_progress', NULL, '2026-05-18 07:49:44', '2026-05-18 07:49:44', '2026-05-18 07:49:44', NULL, 'bf7f2746c252f8b8893f4fdc06d459b8', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-104', 'status', 'in_progress', 'done', NULL, '2026-05-18 07:53:10', '2026-05-18 07:53:10', '2026-05-18 07:53:10', NULL, 'ce2b66658977edc3fddc78079d7852e5', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-109', 'status', 'backlog', 'in_progress', NULL, '2026-05-18 08:02:06', '2026-05-18 08:02:06', '2026-05-18 08:02:06', NULL, 'ee75fea173ae492a6ad8a1d888a2142d', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-109', 'status', 'in_progress', 'done', NULL, '2026-05-18 08:07:23', '2026-05-18 08:07:23', '2026-05-18 08:07:23', NULL, '9dd61b76946909aa44edaf9d1c1741a0', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -1414,3 +1414,484 @@ The `PanGestureRecognizer` in `TerminalGestureDetector` is registered with `supp
|
||||
|
||||
**Cross-references:** T-91 (epic parent), T-93 (same shape on `onTapUp`), T-89 (coverage epic).
|
||||
', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-08 10:48:59', '2026-05-08 11:01:01', NULL, '91cf3f81c256df191ddfcb3ebc6cefb1', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-96', 'bug', NULL, 'NativePty reader-isolate hangs ~5% of spawns', 'The reader isolate spawned by `NativePty._spawnReaderAsync` (`lib/src/pty/native_pty.dart`) hangs intermittently — about 5% of fresh spawns — with poll() on the master fd never returning POLLIN or POLLHUP, even when the child has clearly written and exited. Probed: 2 hangs out of 30 sequential `/bin/sh -c "printf hello-pty"` spawns over a 30s/spawn timeout.
|
||||
|
||||
**Root cause — found 2026-05-17 via forensic probe:**
|
||||
|
||||
The "child" never reaches `execve`. `/proc/<child-pid>/stat` reports comm `(DartWorker)` (the Dart VM worker thread name) instead of `sh`, with state `S` (sleeping). The pty master fd is a real pty (tty-index assigned, fcntl flags 0x8002 = O_RDWR|O_NOCTTY), but `poll(fd, 500ms)` from the main isolate returns 0 — nothing was ever written.
|
||||
|
||||
This is **`fork()` in a multithreaded process** — a textbook async-signal-safety violation. The Dart VM runs multiple worker threads that hold libc locks (notably `malloc`). When `forkpty()` calls `fork()`, only the calling thread survives in the child, but the locks held by ghost-threads remain "locked forever." The child deadlocks before it can complete its post-fork → pre-execve setup.
|
||||
|
||||
Why not always? Lock state at fork() time is timing-dependent. ~95% of the time no Dart worker happens to hold a problematic lock, and execve proceeds. ~5% of the time it deadlocks.
|
||||
|
||||
**Workaround in place (test-only):** `retry: 2` on every PTY-output-dependent test in `test/pty/session_test.dart` and `test/panes/registry_test.dart`. Three combined attempts at ~5% per-spawn fail rate ≈ 99.99% combined success. Comments at the test sites point back here.
|
||||
|
||||
**Proper fix:** replace `forkpty()` with `posix_openpt()` + `unlockpt()` + `grantpt()` + `posix_spawn()` (with file actions wiring the pty slave to stdin/stdout/stderr). `posix_spawn` uses `vfork()` under glibc/musl, which keeps the parent suspended until execve completes — no Dart code ever runs in the child, no lock-deadlock possible. Linux and macOS both support this API.
|
||||
|
||||
**Immediate hardening alternative (Linux-only, simpler):** after spawn, sample `/proc/<pid>/comm` after ~250ms. If it equals the parent process''s comm, execve never ran → kill the child, surface `PtyException(''execve-deadlock'', ...)`, let the caller retry. This catches the deadlocked state deterministically instead of waiting for a poll timeout. It doesn''t fix the bug, but turns silent hangs into reportable errors.
|
||||
|
||||
**Acceptance for closing this ticket:**
|
||||
1. Replace forkpty path with posix_openpt + posix_spawn (or implement the comm-check hardening as an interim).
|
||||
2. Probe (200 sequential `printf hello`-and-exit spawns) reports zero hangs.
|
||||
3. Remove the `retry: 2` decorations + explanatory comments in `test/pty/session_test.dart` (6 tests) and `test/panes/registry_test.dart` (1 test).
|
||||
|
||||
**Out of scope:** changing the reader-isolate architecture itself. `Isolate.spawn(_readLoop, ...)` for output stays.
|
||||
|
||||
**Cross-references:**
|
||||
- D-5 (Dart core + sidecar dissolution; PTY is owned via FFI)
|
||||
- `lib/src/pty/native_pty.dart:200` (the forkpty call site)
|
||||
- `lib/src/pty/native_pty.dart:170-191` (child-side post-fork code that deadlocks)
|
||||
- `lib/src/pty/native_pty.dart:235-262` (reader spawn — not the bug; downstream symptom)
|
||||
- glibc posix_spawn docs: https://www.gnu.org/software/libc/manual/html_node/Process-Creation-Example.html
|
||||
', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 17:16:01', '2026-05-17 18:11:18', NULL, '0451ff710500ecd4d62d8910a46386d1', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-97', 'epic', NULL, 'Address 2026-05-14 consultant review', 'Six-reviewer external assessment found four critical gaps (workspace RCE, IPC server unimplemented, keyboard not operable, onboarding docs describe a dissolved architecture) plus ten major items and a handful of quick wins. Source: consultants.md (committed alongside this epic). Children are filed individually so they can land independently.
|
||||
|
||||
Acceptance: all critical findings resolved or formally rejected with a D-record; all major findings either shipped or moved to a follow-up epic with rationale; quick-win batches closed.', 'backlog', 'high', NULL, NULL, NULL, '2026-05-17 18:47:08', '2026-05-17 18:47:08', NULL, 'b52cffa8155835713fece00ecc86f86c', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-98', 'bug', 'T-97', 'fix untrusted-workspace RCE in dugite git resolution', '`toolchain_paths.dart:79` builds `''$workspaceRoot/native/dugite/bin''` and runs `_firstExisting([''$dugite/git''])` — if that file exists in the open workspace it becomes the git binary for all GitClient calls, before falling back to PATH. A malicious repo commits an executable at `native/dugite/bin/git`; clide runs it on the first auto-fired `git.status`. Arbitrary code execution from merely opening a repo.
|
||||
|
||||
**Fix:** resolve `native/dugite` against `Platform.resolvedExecutable`''s directory (or a known install root), never `workspaceRoot`.
|
||||
|
||||
**Acceptance:**
|
||||
1. Toolchain resolution does not look at any workspace path.
|
||||
2. Test that a planted `native/dugite/bin/git` in the temp-dir workspace is ignored.
|
||||
3. Existing dugite-in-install-dir behavior preserved.
|
||||
|
||||
Source: consultants.md "Security — Findings — [Critical]".', 'backlog', 'critical', NULL, NULL, NULL, '2026-05-17 18:47:13', '2026-05-17 18:47:13', NULL, 'ead632bad678df9637914f52791ea8fe', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-99', 'task', 'T-97', 'implement (or amend) the IPC socket server per D-1/D-6/D-56/D-68', 'D-56 specifies the Flutter app hosts an in-process IPC server reachable by a thin C client over a Unix socket; D-1/D-6 commit to CLI-first. Today no `ServerSocket` for unix-domain exists anywhere in `lib/`. Three IPC clients coexist (`DaemonClient` socket, `InProcessClient`, `IsolateClient` + `Backend`/`backend_entry.dart`); two service-wiring sites duplicate registration. The load-bearing CLI-first guardrail has no runtime path. D-68 (filed in this session) commits to the dual-surface plan.
|
||||
|
||||
**Pick one path:**
|
||||
- **(a)** Implement the socket server per D-56 + the C `clide` client. Delete `InProcessClient` and `IsolateClient` + `backend_entry.dart`. Wire MCP server alongside per D-68.
|
||||
- **(b)** Amend D-56 to declare in-process direct dispatch as the design; delete `DaemonClient`''s socket code, `IsolateClient`, `Backend`, and `backend_entry.dart`. Re-evaluate D-1''s CLI-first claim and D-68''s dual-surface plan.
|
||||
|
||||
**Acceptance:**
|
||||
1. Exactly one IPC model exists in code.
|
||||
2. `clide <subcommand>` shells from a terminal can drive a running clide app (or the contract is formally rescinded).
|
||||
3. Service registration happens in one place.
|
||||
4. D-records updated to match reality.
|
||||
|
||||
Source: consultants.md "Architecture — Findings — [Critical] No IPC socket server exists".', 'backlog', 'high', NULL, NULL, NULL, '2026-05-17 18:47:18', '2026-05-17 18:47:18', NULL, '0cdad0173d2328b613c390aae0d31b39', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-100', 'story', 'T-97', 'make ClideTappable keyboard-operable; add palette arrow-keys + Escape', '`ClideTappable` (`lib/widgets/src/clide_tappable.dart`) is the base of nearly every interactive widget — `ClideButton`, `_WinBtn`, `_RecentProjectRow`, `_ActionRow`, most builtin list items — and it is `MouseRegion` + `GestureDetector` only. No `Focus`, no Enter/Space handler, no focus ring. None of these widgets can be reached by Tab or activated from the keyboard. The keyboard-traversal test passes only because it externally wraps in a `Focus` node — it tests non-blocking, not operability.
|
||||
|
||||
`ClidePalette` (`lib/widgets/src/clide_palette.dart`) is similarly broken: `onSubmitted` only ever invokes `filtered.first`; no up/down handling, no selected index, no selection highlight, no Escape handler.
|
||||
|
||||
**Fix:**
|
||||
1. Wrap `ClideTappable`''s child in `Focus` + `Shortcuts`/`Actions` so Tab focuses it and Enter/Space invokes `onTap`. Render a focus ring via the token system.
|
||||
2. Add arrow-key navigation + selected-index + Escape + Enter-on-selected to `ClidePalette` (model after `_ProjectSwitcherDropdown.onKeyEvent` at `app.dart:446-452`).
|
||||
3. Extend the a11y test layer to assert operability (Tab + Enter actually invokes), not just Semantics presence.
|
||||
|
||||
Source: consultants.md "UX — Findings — [Critical]".', 'backlog', 'high', NULL, NULL, NULL, '2026-05-17 18:47:23', '2026-05-17 18:47:23', NULL, '9e8f220cb4fd6df1ac349dbe47977e67', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-101', 'task', 'T-97', 'rewrite README + docs/initial-plan to match current architecture', '`README.md` and `docs/initial-plan.md` describe an architecture that no longer exists. README documents `ptyc/` as a live component (`README.md:10`) and `make ptyc-build` (`:36`) — `ptyc/` is gone (D-5 amendment 2026-05-07) and the Makefile has no such target. `README.md:44` links to a `decisions/` directory that has moved to `governance/`. `docs/initial-plan.md` still describes a Go sidecar, separate `clide --daemon` long-running process, `app/` subdirectory, and `project.yaml` — all contradicted by D-5, D-56, and the single-package-at-root reality. A new contributor''s first read builds a wrong mental model.
|
||||
|
||||
**Fix:**
|
||||
1. Rewrite `README.md`''s `ptyc/` sections; remove `make ptyc-build` references.
|
||||
2. Fix the `decisions/` link → `governance/decisions/`.
|
||||
3. Either banner `docs/initial-plan.md` as historical (preserved as a snapshot) or extract a current `docs/architecture.md` that matches today''s code.
|
||||
4. Spot-check other doc references to dissolved components.
|
||||
|
||||
Source: consultants.md "Docs — Findings — [Critical]".', 'backlog', 'high', NULL, NULL, NULL, '2026-05-17 18:47:29', '2026-05-17 18:47:29', NULL, 'b7279d92c6980c0617c66678531eeaa0', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-102', 'bug', 'T-97', 'add symlink re-resolution + containment re-check to files.read / files.ls', '`path_safety.dart:35-51` explicitly does not resolve symlinks; the filesystem layer (`files_commands.dart:81-85`) doesn''t either. A repo symlink `config -> /etc/shadow` passes the containment check (the link path is under root) and clide reads the target.
|
||||
|
||||
**Fix:** after `resolveUnderRoot`, call `resolveSymbolicLinksSync()` and re-verify containment against the workspace root.
|
||||
|
||||
**Acceptance:** test that a symlink pointing outside the workspace is rejected by both `files.read` and `files.ls`.
|
||||
|
||||
Source: consultants.md "Security — Findings — [Major] Path-safety does not defend against symlink escape".', 'backlog', 'high', NULL, NULL, NULL, '2026-05-17 18:47:33', '2026-05-17 18:47:33', NULL, '775f4fe49033e20f20acc844d95ecc39', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-103', 'task', 'T-97', 'add test-integration + smoke-bundle to make push-check', '`make push-check` runs `decisions-validate test-core test test-a11y coverage-gate changelog-gate`. `test-integration` and `smoke-bundle` are omitted — a boot-order regression sails through the pre-push gate.
|
||||
|
||||
**Acceptance:** `make push-check` runs the integration test layer + smoke-bundle. Wall-clock budget acceptable for pre-push (target <2 min total). If they''re too slow, gate them behind a separate `make push-check-full` and document.
|
||||
|
||||
Source: consultants.md "Tests — Findings — [Major] make push-check does not run integration tests".', 'backlog', 'high', NULL, NULL, NULL, '2026-05-17 18:47:39', '2026-05-17 18:47:39', NULL, 'a8fd3058339ed8100113ba03afb28b49', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-104', 'task', 'T-97', 'schema-validate IPC argument surface; reject -prefixed values', '`DaemonDispatcher.dispatch` (`dispatcher.dart:26`) and `IpcRequest.fromJson` (`envelope.dart:49`) do no schema validation. No size limit on `files.read`, no count cap on `git.log`. `git diff`/`stage` use `--` separators (good), but `checkout(branch)` and `push(remote, branch)` (`git_commands.dart:240`) do not — argument injection (e.g. `git checkout --upload-pack=...`) is possible.
|
||||
|
||||
**Fix:**
|
||||
1. Typed schema per IPC command (`branch`, `remote`, `path`, etc. with regex / charset constraints).
|
||||
2. Size + count bounds on `files.read`, `git.log`, `git.diff`.
|
||||
3. Either reject `-`-prefixed values for branch/remote/path args, or use `--` everywhere (including `checkout` and `push`).
|
||||
|
||||
**Acceptance:** unit tests cover the rejection path for each injection vector listed.
|
||||
|
||||
Source: consultants.md "Security — Findings — [Major]".', 'backlog', 'high', NULL, NULL, NULL, '2026-05-17 18:47:42', '2026-05-17 18:47:42', NULL, '253bf788aab85afcaad22d5983ecb7be', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-105', 'story', 'T-97', 'establish focus-traversal model; integrate FocusTracker with Flutter focus', 'No `FocusScope`/`FocusTraversalGroup` wiring across panels. `FocusTracker` (`lib/kernel/src/focus.dart`) tracks an active contribution id for the `clide active` CLI but doesn''t plug into Flutter''s focus system — it parallels it. Tab order across sidebar → workspace → context is undefined.
|
||||
|
||||
**Fix:**
|
||||
1. Per-slot `FocusTraversalGroup` so each panel manages its own traversal.
|
||||
2. A documented top-level keybinding (e.g. `Ctrl+Shift+Tab`-style) that moves focus to the next panel.
|
||||
3. Integrate `FocusTracker` with Flutter focus rather than maintaining a parallel registry.
|
||||
|
||||
Depends on T-100 (ClideTappable accepting focus).
|
||||
|
||||
Source: consultants.md "UX — Findings — [Major] No focus-traversal wiring".', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-17 18:47:47', '2026-05-17 18:47:47', NULL, 'eaadb20b91bb06cb6b9b4ef96930b773', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-106', 'bug', 'T-97', 'fix SchedulerService._startTicker isolate-spawn race', '`scheduler.dart:71`: `Isolate.spawn(_isolateEntry, _port!.sendPort).then((iso) => _isolate = iso)`. If `_stopTicker()` runs before the spawn future completes, `_isolate` is still null, nothing is killed, and the just-spawned isolate (with its `Timer.periodic`) leaks forever.
|
||||
|
||||
Same race shape we just fixed in `NativePty` via `_readerReady` (T-96).
|
||||
|
||||
**Fix:** track the spawn future as `_isolateReady`; `_stopTicker` awaits it before killing.
|
||||
|
||||
Source: consultants.md "Code quality — Findings — [Major]".', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-17 18:47:51', '2026-05-17 18:47:51', NULL, '24ccc6ff379afaa61621764cdc105a46', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-107', 'task', 'T-97', 'decide lib/src/terminal/ status: formally vendor or clean to project bar', '~7k LOC forked from xterm.dart in an undeclared middle state. Carries commented-out `print()` debugging (`custom_text_edit.dart:244-275`), dangling TODOs (`parser.dart:110-113`, `keytab.dart:91`), a 1137-line `parser.dart`, and the only `// ignore: invalid_use_of_protected_member` in the repo (`terminal_view.dart:363`). Memory says "code under `lib/` is owned, not vendored."
|
||||
|
||||
**Decide one path and execute:**
|
||||
- **(a)** Formally vendor: relocate to `native/` or document as frozen in a D-record + `licenses.yaml`. Coverage carve-out documented.
|
||||
- **(b)** Clean to the project bar: remove debug prints, resolve TODOs, justify or remove the protected-member suppression, consider splitting `parser.dart`.
|
||||
|
||||
Source: consultants.md "Code quality — Findings — [Major]".', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-17 18:47:55', '2026-05-17 18:47:55', NULL, '0a3cd3ee07488b0d76c648222cec249c', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-108', 'task', 'T-97', 'replace wall-clock sleeps in watcher_test / session_test with event-driven waits', '`test/files/watcher_test.dart:67-82` uses fixed `Future.delayed`. `test/pty/session_test.dart` polls 50×100ms and uses a bare `500ms` settle. `session_test.dart`''s `timeout(5s, onTimeout: () {})` swallows the timeout — a never-producing PTY proceeds to a confusing assertion failure rather than a clear timeout.
|
||||
|
||||
**Fix:**
|
||||
1. Replace fixed sleeps with `expectLater(stream, emits(...))` or equivalent event-driven waits.
|
||||
2. Replace swallowed `onTimeout: () {}` with `onTimeout: () => fail(''...'')`.
|
||||
|
||||
Source: consultants.md "Tests — Findings — [Major]".', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-17 18:47:59', '2026-05-17 18:47:59', NULL, '5eacdfdce7733026d43070d103e5e67d', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-109', 'task', 'T-97', 'write CONTRIBUTING.md; cut interim release to drain Unreleased; merge duplicate changelog headings', 'Three docs items:
|
||||
|
||||
1. **No `CONTRIBUTING.md`.** Build/test story is scattered across `CLAUDE.md` (Claude-addressed), `README.md` (partly wrong — see T-101), and Makefile help. Write a human-addressed contributor guide: clone → `make hooks && flutter pub get` → `make test` → DQR workflow → commit conventions.
|
||||
2. **CHANGELOG has duplicate subsection headings in `[Unreleased]`** — three `### Changed`, two `### Fixed`, two `### Removed` in the 2.0.0 section. Keep a Changelog 1.1.0 expects one of each per release. Merge.
|
||||
3. **~80-commit Unreleased backlog** undermines the format. Cut an interim release (`vX.Y.Z`) to drain it before continuing.
|
||||
|
||||
Order matters: 2 → 3 → 1 (clean changelog, release, then doc the workflow).
|
||||
|
||||
Source: consultants.md "Docs — Findings".', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-17 18:48:03', '2026-05-17 18:48:03', NULL, 'e5983cfe63a6f472a76389954c16727c', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-110', 'story', 'T-97', 'replace root KeyboardListener with scoped Shortcuts/Actions; move off keyLabel', '`app.dart:90-148` routes all shortcuts through one root `KeyboardListener` — no per-context scoping; will conflict with text-input fields once any pane wants to capture keys. `KeybindingResolver.fromKeyEvent` keys off `logicalKey.keyLabel` which is layout-dependent (US-QWERTY `Ctrl+/` differs from AZERTY `Ctrl+:`).
|
||||
|
||||
**Fix:**
|
||||
1. Replace the root `KeyboardListener` with scoped `Shortcuts`/`Actions` per slot.
|
||||
2. Move `KeybindingResolver` to `physicalKey` or a stable mapping layer.
|
||||
|
||||
Coordinate with T-100 (Focus/Shortcuts wrapper for ClideTappable) and T-105 (focus traversal).
|
||||
|
||||
Source: consultants.md "UX — Findings — [Major]".', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-17 18:48:09', '2026-05-17 18:48:09', NULL, '5035c2e9d1c4c043045e5fcd1e7995bb', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-111', 'story', 'T-97', 'drag-resize keyboard parity + clide panel resize CLI', '`drag_resize.dart` and `app.dart:870-912` are pure `Listener` pointer handlers — no keyboard equivalent, no Semantics node. Per the user/Claude parity rule (D-6), panel sizing should have a CLI affordance. Neither exists.
|
||||
|
||||
**Fix:**
|
||||
1. Add Semantics to drag-resize handles + keyboard activation (focus → arrow keys adjust width).
|
||||
2. Add `clide panel resize <slot> <delta-or-absolute>` CLI verb.
|
||||
|
||||
Coordinate with T-99 (IPC implementation) if the CLI verb needs new dispatch wiring.
|
||||
|
||||
Source: consultants.md "UX — Findings — [Major] Drag-resize handles".', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-17 18:48:14', '2026-05-17 18:48:14', NULL, '7d936b1c6561bcd6d2981786a17cc486', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-112', 'task', 'T-97', 'quick-wins: code-quality cleanups', 'Batch of small consultant findings that share a domain (code quality):
|
||||
|
||||
1. Replace silent `catch (_)` in `tree_sitter_ffi.dart:197,206` with a logged last-error — `DynamicLibrary.open` failures are currently discarded.
|
||||
2. Delete `Cmsghdr` alias in `libc.dart:201-202` ("backward compatibility" — CLAUDE.md forbids in a solo repo).
|
||||
3. Remove `// ignore: unused_field` in `editor_controller.dart:25` (speculative retention; fix not silence).
|
||||
4. Consume `libc.dart` constants / `PosixErrno` in `native_pty.dart` instead of inline hex (`0x0001`, `28`, `4`, `9`).
|
||||
5. `ExtensionManager.activate` (`extensions_manager.dart:141-143`) swallows exceptions — surface a "degraded" state for the UI.
|
||||
6. Verify `file_tree_view.dart:8` barrel leak (commit `5cad982` may already cover this).
|
||||
7. Move `test_app.dart` out of production `main.dart` import graph (gate behind separate entrypoint or `kDebugMode`).
|
||||
|
||||
Source: consultants.md (Code Quality + Architecture Findings — Minor).', 'backlog', 'low', NULL, NULL, NULL, '2026-05-17 18:48:20', '2026-05-17 18:48:20', NULL, '22c6b816bbc90c6c535d041e6a5ba3d6', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-113', 'task', 'T-97', 'quick-wins: governance + docs reconciliation', 'Batch of small consultant findings that share a domain (governance/docs):
|
||||
|
||||
1. **Amend D-66** to match current floor location/value: the floor lives in `pubspec.yaml` `coverage_floor:` (single source of truth as of this session), not `coverage/floor.txt`. Add a dated amendment line.
|
||||
2. **Reconcile `licenses.yaml` vs `pubspec.yaml`** — `test` version drift (`1.25.8` vs `1.30.0`), phantom `lints 5.0.0` not in pubspec. Two-step-commit rule from D-42 is being violated.
|
||||
3. **Triage stale Q-records** — Q-1/Q-2/Q-3 ("defer until Tier 1 is in real use") are due now that Tier 1 has shipped. Q-25 (body text face) is de facto resolved by D-43/D-44.
|
||||
4. **Drop `--no-fatal-infos` from `ci/test.sh`** now that the import queue is clear (post test-sweep this session).
|
||||
5. Add a `.claude/skills/README.md` inventory.
|
||||
|
||||
Source: consultants.md (Docs + Tests + Security Findings — Minor).', 'backlog', 'low', NULL, NULL, NULL, '2026-05-17 18:48:24', '2026-05-17 18:48:24', NULL, 'a917f3d6c5c83c0f95959c24de0a5d9f', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-114', 'task', 'T-97', 'quick-wins: a11y + contrast gate expansion', 'Batch of small consultant findings that share a domain (a11y / visual):
|
||||
|
||||
1. Expand contrast gate `canonicalPairs` (`test/a11y/contrast_test.dart`) to cover `globalTextMuted` (muted text is everywhere), the `status*` foregrounds, syntax tokens on `panelBackground`, and `panelActiveBorder`.
|
||||
2. Tokenize the ~43 hardcoded-color sites the UX reviewer flagged — at minimum the modal/palette shadow and window-control colors that don''t adapt to the `paper` light theme.
|
||||
3. Add a focus-ring rendering at the token level so every focusable widget gets it for free (depends on T-100).
|
||||
4. Surface text-zoom (Ctrl +/-/0) in the palette so it''s discoverable.
|
||||
|
||||
Source: consultants.md "UX — Findings — [Minor]" + Strengths section.', 'backlog', 'low', NULL, NULL, NULL, '2026-05-17 18:48:28', '2026-05-17 18:48:28', NULL, 'e749bba718321bcc202cc51c55a59ee4', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-98', 'bug', 'T-97', 'fix untrusted-workspace RCE in dugite git resolution', '`toolchain_paths.dart:79` builds `''$workspaceRoot/native/dugite/bin''` and runs `_firstExisting([''$dugite/git''])` — if that file exists in the open workspace it becomes the git binary for all GitClient calls, before falling back to PATH. A malicious repo commits an executable at `native/dugite/bin/git`; clide runs it on the first auto-fired `git.status`. Arbitrary code execution from merely opening a repo.
|
||||
|
||||
**Fix:** resolve `native/dugite` against `Platform.resolvedExecutable`''s directory (or a known install root), never `workspaceRoot`.
|
||||
|
||||
**Acceptance:**
|
||||
1. Toolchain resolution does not look at any workspace path.
|
||||
2. Test that a planted `native/dugite/bin/git` in the temp-dir workspace is ignored.
|
||||
3. Existing dugite-in-install-dir behavior preserved.
|
||||
|
||||
Source: consultants.md "Security — Findings — [Critical]".', 'done', 'critical', NULL, NULL, NULL, '2026-05-17 18:47:13', '2026-05-17 18:55:57', NULL, '40e1ed24c3cce28e2420150f1fac8fbd', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-102', 'bug', 'T-97', 'add symlink re-resolution + containment re-check to files.read / files.ls', '`path_safety.dart:35-51` explicitly does not resolve symlinks; the filesystem layer (`files_commands.dart:81-85`) doesn''t either. A repo symlink `config -> /etc/shadow` passes the containment check (the link path is under root) and clide reads the target.
|
||||
|
||||
**Fix:** after `resolveUnderRoot`, call `resolveSymbolicLinksSync()` and re-verify containment against the workspace root.
|
||||
|
||||
**Acceptance:** test that a symlink pointing outside the workspace is rejected by both `files.read` and `files.ls`.
|
||||
|
||||
Source: consultants.md "Security — Findings — [Major] Path-safety does not defend against symlink escape".', 'done', 'high', NULL, NULL, NULL, '2026-05-17 18:47:33', '2026-05-17 19:01:39', NULL, '937111bd0e00620e7faf68859018619a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-101', 'task', 'T-97', 'rewrite README + docs/initial-plan to match current architecture', '`README.md` and `docs/initial-plan.md` describe an architecture that no longer exists. README documents `ptyc/` as a live component (`README.md:10`) and `make ptyc-build` (`:36`) — `ptyc/` is gone (D-5 amendment 2026-05-07) and the Makefile has no such target. `README.md:44` links to a `decisions/` directory that has moved to `governance/`. `docs/initial-plan.md` still describes a Go sidecar, separate `clide --daemon` long-running process, `app/` subdirectory, and `project.yaml` — all contradicted by D-5, D-56, and the single-package-at-root reality. A new contributor''s first read builds a wrong mental model.
|
||||
|
||||
**Fix:**
|
||||
1. Rewrite `README.md`''s `ptyc/` sections; remove `make ptyc-build` references.
|
||||
2. Fix the `decisions/` link → `governance/decisions/`.
|
||||
3. Either banner `docs/initial-plan.md` as historical (preserved as a snapshot) or extract a current `docs/architecture.md` that matches today''s code.
|
||||
4. Spot-check other doc references to dissolved components.
|
||||
|
||||
Source: consultants.md "Docs — Findings — [Critical]".', 'done', 'high', NULL, NULL, NULL, '2026-05-17 18:47:29', '2026-05-17 19:01:39', NULL, 'a7d378267119361f2b2188977881a666', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-115', 'story', NULL, 'startup project picker; "always open this project" override', 'Startup flow change: on launch, always show the project picker screen unless the user has explicitly checked an "always open this project" box for a specific project (sticky default).
|
||||
|
||||
**Acceptance:**
|
||||
1. Default startup: project picker is the first screen, regardless of last-opened state.
|
||||
2. Picker has an "always open this project on launch" checkbox per project entry.
|
||||
3. If exactly one project has that flag set, startup skips the picker and opens that project directly.
|
||||
4. The flag is per-project, persisted in app settings (probably under `app.startupProject`).
|
||||
5. The checkbox can be unticked from the welcome view to restore picker-first behavior.
|
||||
|
||||
**Notes:** today the app auto-opens `app.lastProject` on boot (kernel/src/project.dart `openLast`). The new flow inverts the default — picker is the steady state, sticky-open is opt-in per project.
|
||||
|
||||
**Out of scope:** multi-project workspaces, project groups, recent-project ordering changes.
|
||||
|
||||
**Source:** user request 2026-05-17.', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-17 19:10:21', '2026-05-17 19:10:21', NULL, 'ce156fb92fbad8ba9bf860a4eb71d1ac', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-116', 'bug', 'T-97', 'integration_test/theme_picker_test.dart hangs pumpAndSettle on theme.pick', 'When running `flutter test integration_test/theme_picker_test.dart` against the real boot path, calling `services.commands.execute(''theme.pick'')` followed by `await tester.pumpAndSettle()` does not settle — the test hangs for 60+ seconds and never reaches the `Select theme` / `Cancel` assertions. Likely an animation loop or a never-completing Future in the modal mount path.
|
||||
|
||||
Blocks T-103 from adding integration tests to `make push-check` (the other two integration tests pass after viewport + assertion fixes).
|
||||
|
||||
**Acceptance:**
|
||||
1. `flutter test integration_test/theme_picker_test.dart` completes in <10s.
|
||||
2. Both assertions (`Select theme` visible after `theme.pick`; gone after `Cancel`) pass.
|
||||
3. T-103''s `push-check-full` can include integration tests without the theme-picker hanging.
|
||||
|
||||
Source: surfaced 2026-05-17 during T-103 work.', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-17 19:14:31', '2026-05-17 19:14:31', NULL, 'df0c6bdd610410d4bd27bfeb2be9acbc', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-113', 'task', 'T-97', 'quick-wins: governance + docs reconciliation', 'Batch of small consultant findings that share a domain (governance/docs):
|
||||
|
||||
1. **Amend D-66** to match current floor location/value: the floor lives in `pubspec.yaml` `coverage_floor:` (single source of truth as of this session), not `coverage/floor.txt`. Add a dated amendment line.
|
||||
2. **Reconcile `licenses.yaml` vs `pubspec.yaml`** — `test` version drift (`1.25.8` vs `1.30.0`), phantom `lints 5.0.0` not in pubspec. Two-step-commit rule from D-42 is being violated.
|
||||
3. **Triage stale Q-records** — Q-1/Q-2/Q-3 ("defer until Tier 1 is in real use") are due now that Tier 1 has shipped. Q-25 (body text face) is de facto resolved by D-43/D-44.
|
||||
4. **Drop `--no-fatal-infos` from `ci/test.sh`** now that the import queue is clear (post test-sweep this session).
|
||||
5. Add a `.claude/skills/README.md` inventory.
|
||||
|
||||
Source: consultants.md (Docs + Tests + Security Findings — Minor).', 'done', 'low', NULL, NULL, NULL, '2026-05-17 18:48:24', '2026-05-17 19:18:43', NULL, '6988f892ed906cb397bea5068fdcd30e', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-106', 'bug', 'T-97', 'fix SchedulerService._startTicker isolate-spawn race', '`scheduler.dart:71`: `Isolate.spawn(_isolateEntry, _port!.sendPort).then((iso) => _isolate = iso)`. If `_stopTicker()` runs before the spawn future completes, `_isolate` is still null, nothing is killed, and the just-spawned isolate (with its `Timer.periodic`) leaks forever.
|
||||
|
||||
Same race shape we just fixed in `NativePty` via `_readerReady` (T-96).
|
||||
|
||||
**Fix:** track the spawn future as `_isolateReady`; `_stopTicker` awaits it before killing.
|
||||
|
||||
Source: consultants.md "Code quality — Findings — [Major]".', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 18:47:51', '2026-05-17 19:18:43', NULL, 'b529416bab7c66fde471f990671f3b25', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-103', 'task', 'T-97', 'add test-integration + smoke-bundle to make push-check', '`make push-check` runs `decisions-validate test-core test test-a11y coverage-gate changelog-gate`. `test-integration` and `smoke-bundle` are omitted — a boot-order regression sails through the pre-push gate.
|
||||
|
||||
**Acceptance:** `make push-check` runs the integration test layer + smoke-bundle. Wall-clock budget acceptable for pre-push (target <2 min total). If they''re too slow, gate them behind a separate `make push-check-full` and document.
|
||||
|
||||
Source: consultants.md "Tests — Findings — [Major] make push-check does not run integration tests".', 'done', 'high', NULL, NULL, NULL, '2026-05-17 18:47:39', '2026-05-17 19:18:43', NULL, 'cbae1915f1c89a8a2b05da71148d77dc', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-100', 'story', 'T-97', 'make ClideTappable keyboard-operable; add palette arrow-keys + Escape', '`ClideTappable` (`lib/widgets/src/clide_tappable.dart`) is the base of nearly every interactive widget — `ClideButton`, `_WinBtn`, `_RecentProjectRow`, `_ActionRow`, most builtin list items — and it is `MouseRegion` + `GestureDetector` only. No `Focus`, no Enter/Space handler, no focus ring. None of these widgets can be reached by Tab or activated from the keyboard. The keyboard-traversal test passes only because it externally wraps in a `Focus` node — it tests non-blocking, not operability.
|
||||
|
||||
`ClidePalette` (`lib/widgets/src/clide_palette.dart`) is similarly broken: `onSubmitted` only ever invokes `filtered.first`; no up/down handling, no selected index, no selection highlight, no Escape handler.
|
||||
|
||||
**Fix:**
|
||||
1. Wrap `ClideTappable`''s child in `Focus` + `Shortcuts`/`Actions` so Tab focuses it and Enter/Space invokes `onTap`. Render a focus ring via the token system.
|
||||
2. Add arrow-key navigation + selected-index + Escape + Enter-on-selected to `ClidePalette` (model after `_ProjectSwitcherDropdown.onKeyEvent` at `app.dart:446-452`).
|
||||
3. Extend the a11y test layer to assert operability (Tab + Enter actually invokes), not just Semantics presence.
|
||||
|
||||
Source: consultants.md "UX — Findings — [Critical]".', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-17 18:47:23', '2026-05-17 19:23:24', NULL, '45607823ef1229d43dcfd0bc327a1031', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-110', 'story', 'T-97', 'replace root KeyboardListener with scoped Shortcuts/Actions; move off keyLabel', '**Superseded by T-117 — Done.**
|
||||
|
||||
The consultant findings here — single root `KeyboardListener` will conflict with text-input fields; `KebindingResolver.fromKeyEvent` keys off layout-dependent `logicalKey.keyLabel` — are both addressed by the keystroke mapper layer (T-117):
|
||||
|
||||
- Keymap-driven dispatch uses `LogicalKeyboardKey.keyId` (stable across layouts), not `keyLabel`.
|
||||
- Root `KeyboardListener` now hands events straight to `KeymapService.resolveEvent`, which dispatches resolved Intents via `Actions.maybeInvoke` against the focused context — Actions providers per feature (palette, editor, etc.) handle their own intents; the root only handles global ones (text scale, generic command bridge). This is the scoped Shortcuts/Actions model the consultant prescribed.
|
||||
|
||||
Remaining cleanup (deletion of the now-vestigial `KeybindingResolver` class + the legacy `KeyboardListener` wrap once Flutter Shortcuts widget integration is on every feature) is small and lands as part of T-100 or its own follow-up.
|
||||
|
||||
Original text: Replace root KeyboardListener with scoped Shortcuts/Actions; move KebindingResolver off layout-dependent keyLabel.', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 18:48:09', '2026-05-17 19:39:12', NULL, '33850630ffa4bf1f1794fd94db8a62c3', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-23', 'task', 'T-4', 'wire command palette keybinding', 'D-48 names `⌘P` (fuzzy file open) and `⌘⇧P` (command palette) as the canonical keyboard navigation. The command palette overlay/widget exists; the keybinding is not yet wired.
|
||||
|
||||
**Progress (2026-05-17, T-117):** The keymap layer now binds `ctrl+shift+p` / `meta+shift+p` to `PaletteOpenIntent` in `assets/keymaps/default.yaml`. The intent resolves end-to-end through `KeymapService.resolveEvent` → `Actions.maybeInvoke`. **Still pending**: an `Actions` provider somewhere in the tree that handles `PaletteOpenIntent` by calling `kernel.palette.open()`, plus the arrow-key / Escape / Enter handlers on `ClidePalette` itself. Those land as part of T-100 (palette keyboard nav).
|
||||
|
||||
**Acceptance:**
|
||||
- `⌘⇧P` (`Ctrl+Shift+P` on Linux, follows the kernel keymap normalization) opens the command palette overlay over the active workspace.
|
||||
- Esc dismisses; Enter runs the highlighted command; arrow keys move the highlight.
|
||||
- Commands listed are everything registered via `CommandContribution` across all activated extensions.
|
||||
- Fuzzy match against command title; recent / pinned commands float to the top.
|
||||
|
||||
**Implementation hints:**
|
||||
- Slot exists: `Slots.commandPalette` is reserved (lib/kernel/src/panels/slot_id.dart).
|
||||
- Bindings live in the keymap (T-117) — not in `lib/kernel/src/commands/keybindings.dart` (that file is legacy).
|
||||
- The overlay should not shift layout (D-48 chrome budget — no layout shift on palette open).', 'backlog', 'medium', NULL, NULL, 'D-6', '2026-04-22 14:08:40', '2026-05-17 19:39:23', NULL, 'c355fdc971b62e0aedbfb99ed4134678', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-66', 'story', NULL, 'JetBrains keybinding preset', 'Ship a JetBrains/IntelliJ-compatible keybinding preset mapping standard JetBrains shortcuts to clide commands. Covers navigation, refactoring, search, run/debug, and tool windows.
|
||||
|
||||
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place. Implementation is authoring `assets/keymaps/jetbrains.yaml` against the typed Intents + `command:<id>` bindings, plus when-clauses for the contexts JetBrains presets typically scope to (`editor.focused`, `inputFocused`, etc.).
|
||||
|
||||
**Acceptance:**
|
||||
1. `assets/keymaps/jetbrains.yaml` ships covering the documented IntelliJ default keybindings.
|
||||
2. `KeymapService.setPreset("jetbrains")` activates the preset and all asserted bindings resolve.
|
||||
3. A regression test exercises a representative subset (e.g. shift+shift → quick-open command — see Q-9 if the search-everywhere overlay needs its own intent).', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-05-17 19:39:37', NULL, '633ba8ffaee17af0b6ca6d2cc2a7549d', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-64', 'story', NULL, 'VS Code keybinding preset', 'Ship a VS Code-compatible keybinding preset that maps standard VS Code shortcuts to clide commands. Users select it in settings. Covers file navigation, editor actions, panel toggles, search, and terminal.
|
||||
|
||||
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is now in place. Implementation is now just authoring `assets/keymaps/vscode.yaml` against the typed Intents in `lib/kernel/src/keymap/intents.dart` (plus `command:<id>` bindings for VS-Code-specific commands the preset wants to bind to clide commands). Users will switch presets via `app.keymap.preset = vscode` once a settings UI exists, or directly via the setting today.
|
||||
|
||||
**Acceptance:**
|
||||
1. `assets/keymaps/vscode.yaml` ships covering the documented VS Code default keybindings.
|
||||
2. `KeymapService.setPreset("vscode")` activates the preset and all asserted bindings resolve as expected.
|
||||
3. The preset uses when-clauses where VS Code does (`editor.focused`, `inputFocused`, `palette.open`, …).
|
||||
4. A regression test loads the preset and asserts a representative subset (e.g. ctrl+p → quick-open command, ctrl+shift+p → palette).
|
||||
|
||||
**Out of scope:** clide commands that have no VS Code analogue (those keep their default-preset bindings).', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-05-17 19:39:37', NULL, '660a4871f59f940fbc2296b36177e0d8', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-65', 'story', NULL, 'Vim keybinding preset', 'Ship a Vim-compatible keybinding preset with modal editing support (normal/insert/visual modes). Maps Vim motions and commands to clide editor and navigation actions. Users select it in settings.
|
||||
|
||||
**Unblocked by T-117 (2026-05-17):** the keystroke mapper layer is in place; modal Vim presets are more involved than the VS Code preset (T-64) because modes need to be expressed as scope flags (`vim.normal`, `vim.insert`, `vim.visual`) that the when-clause grammar can branch on. Implementation work:
|
||||
|
||||
1. Author `assets/keymaps/vim.yaml` using the typed Intents + `command:<id>` bindings.
|
||||
2. Add a small mode-tracking service that publishes `vim.<mode>` scope flags via `KeymapService.setScopeFlag`.
|
||||
3. Bind `Esc` to mode-reset → normal; `i` (when `vim.normal`) → enter insert; etc.
|
||||
|
||||
**Acceptance:**
|
||||
1. `assets/keymaps/vim.yaml` ships covering the documented Vim default keybindings for editor / navigation / panes.
|
||||
2. `KeymapService.setPreset("vim")` + the mode-tracking service together produce correct mode transitions.
|
||||
3. A regression test exercises a representative motion (`j` → cursor down) and a mode change (`i` → insert).', 'backlog', 'medium', NULL, NULL, NULL, '2026-04-24 06:34:16', '2026-05-17 19:39:37', NULL, '8db44365ff24e322ccc4bf145e31c5c5', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-117', 'task', 'T-97', 'keystroke mapper layer — intents + presets + when-clauses + overlay', 'Build the keymap mechanism that all keyboard-driven work plugs into. Lands before T-100 so widget wiring (ClideTappable, ClidePalette, focus traversal) goes through typed Intents, not raw key handlers. Closes T-110 (consultant: scoped Shortcuts/Actions; move off layout-dependent keyLabel). Unblocks T-64/T-65/T-66 (vim/vscode/jetbrains preset data).
|
||||
|
||||
## Design (locked 2026-05-17)
|
||||
|
||||
- **Action vocabulary**: typed `ClideIntent` subclasses (`ActivateIntent`, `PaletteSelectNextIntent`, …) each with a stable id used in YAML.
|
||||
- **Keys**: `LogicalKeyboardKey.keyId` (layout-independent, not the locale-aware keyLabel the consultant flagged) + modifier set.
|
||||
- **When-clauses**: VS-Code-style boolean expressions over a named context bag (`palette.open && !textInputFocused`). Tiny recursive-descent parser.
|
||||
- **Preset format**: YAML under `assets/keymaps/<preset>.yaml`. Ships `default.yaml`; vim/vscode/jetbrains land as separate tickets.
|
||||
- **Layering**: preset (asset) → user file (`~/.clide/keybindings.yaml`) → settings JSON overlay (`app.keymap.overrides`). Later layers replace bindings with the same (chord, when) tuple.
|
||||
- **Service**: `KeymapService` kernel service holding active layered keymap, scope context, resolver, and a Shortcuts/Actions wrapper.
|
||||
- **Settings keys**: `app.keymap.preset` (default `default`); `app.keymap.overrides` (list).
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. `KeymapService` registered as a kernel service.
|
||||
2. `assets/keymaps/default.yaml` ships and parses; preset switching changes effective bindings.
|
||||
3. When-clause parser handles `a`, `!a`, `a && b`, `a || b`, parens, with unit tests covering precedence + identifier resolution.
|
||||
4. Root `KeyboardListener` in `app.dart` removed in favor of `Shortcuts`/`Actions` driven by the service (closes T-110).
|
||||
5. Existing `KeybindingResolver` callers migrate to the new path; old class either deleted or marked deprecated with a removal date.
|
||||
6. Tests: preset YAML round-trip, layering precedence, when-clause evaluator, scope-context updates, resolver picks the correct intent for chord+context.
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
- Vim/VSCode/JetBrains preset *data* (T-64/T-65/T-66 — this ticket lands the mechanism).
|
||||
- A settings UI for editing bindings (file editing + preset switching is enough for v1).
|
||||
- ClideTappable/Palette widget integration (T-100 — comes immediately after this lands).
|
||||
|
||||
Source: 2026-05-17 design conversation; supersedes T-110.', 'done', 'high', NULL, NULL, NULL, '2026-05-17 19:28:28', '2026-05-17 19:39:40', NULL, '92d68025f0ee7c4d133dca65e97c97b8', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-100', 'story', 'T-97', 'make ClideTappable keyboard-operable; add palette arrow-keys + Escape', '`ClideTappable` (`lib/widgets/src/clide_tappable.dart`) is the base of nearly every interactive widget — `ClideButton`, `_WinBtn`, `_RecentProjectRow`, `_ActionRow`, most builtin list items — and it is `MouseRegion` + `GestureDetector` only. No `Focus`, no Enter/Space handler, no focus ring. None of these widgets can be reached by Tab or activated from the keyboard. The keyboard-traversal test passes only because it externally wraps in a `Focus` node — it tests non-blocking, not operability.
|
||||
|
||||
`ClidePalette` (`lib/widgets/src/clide_palette.dart`) is similarly broken: `onSubmitted` only ever invokes `filtered.first`; no up/down handling, no selected index, no selection highlight, no Escape handler.
|
||||
|
||||
**Fix:**
|
||||
1. Wrap `ClideTappable`''s child in `Focus` + `Shortcuts`/`Actions` so Tab focuses it and Enter/Space invokes `onTap`. Render a focus ring via the token system.
|
||||
2. Add arrow-key navigation + selected-index + Escape + Enter-on-selected to `ClidePalette` (model after `_ProjectSwitcherDropdown.onKeyEvent` at `app.dart:446-452`).
|
||||
3. Extend the a11y test layer to assert operability (Tab + Enter actually invokes), not just Semantics presence.
|
||||
|
||||
Source: consultants.md "UX — Findings — [Critical]".', 'done', 'high', NULL, NULL, NULL, '2026-05-17 18:47:23', '2026-05-17 19:47:50', NULL, 'f6f0c34a700b133bc84c6d8d7ea450a3', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-105', 'story', 'T-97', 'establish focus-traversal model; integrate FocusTracker with Flutter focus', 'No `FocusScope`/`FocusTraversalGroup` wiring across panels. `FocusTracker` (`lib/kernel/src/focus.dart`) tracks an active contribution id for the `clide active` CLI but doesn''t plug into Flutter''s focus system — it parallels it. Tab order across sidebar → workspace → context is undefined.
|
||||
|
||||
**Fix:**
|
||||
1. Per-slot `FocusTraversalGroup` so each panel manages its own traversal.
|
||||
2. A documented top-level keybinding (e.g. `Ctrl+Shift+Tab`-style) that moves focus to the next panel.
|
||||
3. Integrate `FocusTracker` with Flutter focus rather than maintaining a parallel registry.
|
||||
|
||||
Depends on T-100 (ClideTappable accepting focus).
|
||||
|
||||
Source: consultants.md "UX — Findings — [Major] No focus-traversal wiring".', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 18:47:47', '2026-05-17 19:56:28', NULL, '0715d1ded0d12083622f8a46d570f4d9', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-108', 'task', 'T-97', 'replace wall-clock sleeps in watcher_test / session_test with event-driven waits', '`test/files/watcher_test.dart:67-82` uses fixed `Future.delayed`. `test/pty/session_test.dart` polls 50×100ms and uses a bare `500ms` settle. `session_test.dart`''s `timeout(5s, onTimeout: () {})` swallows the timeout — a never-producing PTY proceeds to a confusing assertion failure rather than a clear timeout.
|
||||
|
||||
**Fix:**
|
||||
1. Replace fixed sleeps with `expectLater(stream, emits(...))` or equivalent event-driven waits.
|
||||
2. Replace swallowed `onTimeout: () {}` with `onTimeout: () => fail(''...'')`.
|
||||
|
||||
Source: consultants.md "Tests — Findings — [Major]".', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 18:47:59', '2026-05-17 20:05:38', NULL, '33e15d84caed686e88fc42f1f4a3586c', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-112', 'task', 'T-97', 'quick-wins: code-quality cleanups', 'Batch of small consultant findings that share a domain (code quality):
|
||||
|
||||
1. Replace silent `catch (_)` in `tree_sitter_ffi.dart:197,206` with a logged last-error — `DynamicLibrary.open` failures are currently discarded.
|
||||
2. Delete `Cmsghdr` alias in `libc.dart:201-202` ("backward compatibility" — CLAUDE.md forbids in a solo repo).
|
||||
3. Remove `// ignore: unused_field` in `editor_controller.dart:25` (speculative retention; fix not silence).
|
||||
4. Consume `libc.dart` constants / `PosixErrno` in `native_pty.dart` instead of inline hex (`0x0001`, `28`, `4`, `9`).
|
||||
5. `ExtensionManager.activate` (`extensions_manager.dart:141-143`) swallows exceptions — surface a "degraded" state for the UI.
|
||||
6. Verify `file_tree_view.dart:8` barrel leak (commit `5cad982` may already cover this).
|
||||
7. Move `test_app.dart` out of production `main.dart` import graph (gate behind separate entrypoint or `kDebugMode`).
|
||||
|
||||
Source: consultants.md (Code Quality + Architecture Findings — Minor).', 'done', 'low', NULL, NULL, NULL, '2026-05-17 18:48:20', '2026-05-17 20:11:16', NULL, 'fccc783ff24be83f6d5a498e936c4af9', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-114', 'task', 'T-97', 'quick-wins: a11y + contrast gate expansion', 'Batch of small consultant findings that share a domain (a11y / visual):
|
||||
|
||||
1. Expand contrast gate `canonicalPairs` (`test/a11y/contrast_test.dart`) to cover `globalTextMuted` (muted text is everywhere), the `status*` foregrounds, syntax tokens on `panelBackground`, and `panelActiveBorder`.
|
||||
2. Tokenize the ~43 hardcoded-color sites the UX reviewer flagged — at minimum the modal/palette shadow and window-control colors that don''t adapt to the `paper` light theme.
|
||||
3. Add a focus-ring rendering at the token level so every focusable widget gets it for free (depends on T-100).
|
||||
4. Surface text-zoom (Ctrl +/-/0) in the palette so it''s discoverable.
|
||||
|
||||
Source: consultants.md "UX — Findings — [Minor]" + Strengths section.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-17 18:48:28', '2026-05-17 20:13:36', NULL, '1a2776ce1e3dd335966d738f80784a0a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-118', 'task', 'T-97', 'add high-contrast + colour-blind theme variants', 'Per D-69, the four named themes (clide, midnight, paper, terminal) keep
|
||||
their published palettes and only pass the baseline canonicalPairs
|
||||
gate. The stricter extendedPairs set in
|
||||
lib/kernel/src/theme/contrast.dart (muted text, status chips, syntax
|
||||
tokens on panel surfaces, panel focus border) is gated against any
|
||||
theme whose name ends in -hc or -cb — but no such themes exist yet.
|
||||
|
||||
Scope:
|
||||
|
||||
1. Ship clide-hc.yaml, midnight-hc.yaml, paper-hc.yaml,
|
||||
terminal-hc.yaml — sibling theme files whose palettes shift only
|
||||
enough to clear extendedPairs (4.5:1 for chips / syntax / muted
|
||||
body, 3:1 for the focus border).
|
||||
2. Optional follow-up: *-cb.yaml colour-blind variants if/when we
|
||||
pick a palette policy (deutan vs protan vs tritan, or a single
|
||||
colour-name-replacement strategy).
|
||||
3. Register the new themes in lib/main.dart alongside the existing
|
||||
four.
|
||||
4. The contrast gate at test/a11y/contrast_test.dart already covers
|
||||
them — adding the files is enough.
|
||||
|
||||
Sources: D-22, D-69, consultants.md ("UX — Findings — [Minor]").', 'done', 'low', NULL, NULL, 'D-69', '2026-05-17 20:51:47', '2026-05-17 20:53:41', NULL, 'd163731890022856585e9f33432462f6', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-114', 'task', 'T-97', 'quick-wins: a11y + contrast gate expansion', 'Batch of small consultant findings that share a domain (a11y / visual):
|
||||
|
||||
1. Expand contrast gate `canonicalPairs` (`test/a11y/contrast_test.dart`) to cover `globalTextMuted` (muted text is everywhere), the `status*` foregrounds, syntax tokens on `panelBackground`, and `panelActiveBorder`.
|
||||
2. Tokenize the ~43 hardcoded-color sites the UX reviewer flagged — at minimum the modal/palette shadow and window-control colors that don''t adapt to the `paper` light theme.
|
||||
3. Add a focus-ring rendering at the token level so every focusable widget gets it for free (depends on T-100).
|
||||
4. Surface text-zoom (Ctrl +/-/0) in the palette so it''s discoverable.
|
||||
|
||||
Source: consultants.md "UX — Findings — [Minor]" + Strengths section.', 'done', 'low', NULL, NULL, NULL, '2026-05-17 18:48:28', '2026-05-18 07:04:38', NULL, '70c65844ee3e52bfcd6220b77ab8957a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-111', 'story', 'T-97', 'drag-resize keyboard parity + clide panel resize CLI', '`drag_resize.dart` and `app.dart:870-912` are pure `Listener` pointer handlers — no keyboard equivalent, no Semantics node. Per the user/Claude parity rule (D-6), panel sizing should have a CLI affordance. Neither exists.
|
||||
|
||||
**Fix:**
|
||||
1. Add Semantics to drag-resize handles + keyboard activation (focus → arrow keys adjust width).
|
||||
2. Add `clide panel resize <slot> <delta-or-absolute>` CLI verb.
|
||||
|
||||
Coordinate with T-99 (IPC implementation) if the CLI verb needs new dispatch wiring.
|
||||
|
||||
Source: consultants.md "UX — Findings — [Major] Drag-resize handles".', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 18:48:14', '2026-05-18 07:43:45', NULL, '594d1cbcc8daa115246e2398d9ba9506', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-119', 'task', 'T-99', 'clide panel resize CLI verb (split from T-111)', 'Once T-99 lands an IPC dispatch path, register a ''panel.resize'' command that takes <slot> and <delta-or-absolute> args and calls LayoutArrangement.setSize / setEditorRatio. Keyboard parity already landed in T-111; this completes user/Claude parity per D-6.', 'backlog', 'low', NULL, NULL, NULL, '2026-05-18 07:43:50', '2026-05-18 07:43:50', NULL, '29df32cde1a30168a27b763f5e6ce81d', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-120', 'task', 'T-97', 'typed IPC command schema framework (split from T-104)', 'T-104 covered the spot-fixes (argv-injection rejection, size/count caps on specific commands). What remains is the framework piece: a typed schema per IPC command — branch/remote/path/etc. with regex/charset constraints — applied at DaemonDispatcher dispatch time rather than scattered through individual handlers.
|
||||
|
||||
This needs design before code:
|
||||
- Where the schema lives (per-handler? a central registry?)
|
||||
- How it composes with the IpcRequest envelope
|
||||
- Whether it generates the existing handler boilerplate or wraps it
|
||||
- Coordination with T-99''s IPC architecture decision (if we go socket-server, the schema becomes the wire contract)
|
||||
|
||||
Source: consultants.md "Security — Findings — [Major]" item 1.', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-18 07:53:08', '2026-05-18 07:53:08', NULL, 'c2673e06e042bb418de9a99e8ab21c2b', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-104', 'task', 'T-97', 'schema-validate IPC argument surface; reject -prefixed values', '`DaemonDispatcher.dispatch` (`dispatcher.dart:26`) and `IpcRequest.fromJson` (`envelope.dart:49`) do no schema validation. No size limit on `files.read`, no count cap on `git.log`. `git diff`/`stage` use `--` separators (good), but `checkout(branch)` and `push(remote, branch)` (`git_commands.dart:240`) do not — argument injection (e.g. `git checkout --upload-pack=...`) is possible.
|
||||
|
||||
**Fix:**
|
||||
1. Typed schema per IPC command (`branch`, `remote`, `path`, etc. with regex / charset constraints).
|
||||
2. Size + count bounds on `files.read`, `git.log`, `git.diff`.
|
||||
3. Either reject `-`-prefixed values for branch/remote/path args, or use `--` everywhere (including `checkout` and `push`).
|
||||
|
||||
**Acceptance:** unit tests cover the rejection path for each injection vector listed.
|
||||
|
||||
Source: consultants.md "Security — Findings — [Major]".', 'done', 'high', NULL, NULL, NULL, '2026-05-17 18:47:42', '2026-05-18 07:53:10', NULL, '688f888ba89cefe636ece09f32bea8ab', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-109', 'task', 'T-97', 'write CONTRIBUTING.md; cut interim release to drain Unreleased; merge duplicate changelog headings', 'Three docs items:
|
||||
|
||||
1. **No `CONTRIBUTING.md`.** Build/test story is scattered across `CLAUDE.md` (Claude-addressed), `README.md` (partly wrong — see T-101), and Makefile help. Write a human-addressed contributor guide: clone → `make hooks && flutter pub get` → `make test` → DQR workflow → commit conventions.
|
||||
2. **CHANGELOG has duplicate subsection headings in `[Unreleased]`** — three `### Changed`, two `### Fixed`, two `### Removed` in the 2.0.0 section. Keep a Changelog 1.1.0 expects one of each per release. Merge.
|
||||
3. **~80-commit Unreleased backlog** undermines the format. Cut an interim release (`vX.Y.Z`) to drain it before continuing.
|
||||
|
||||
Order matters: 2 → 3 → 1 (clean changelog, release, then doc the workflow).
|
||||
|
||||
Source: consultants.md "Docs — Findings".', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-17 18:48:03', '2026-05-18 08:02:06', NULL, '01396c3a11fb562a859cdf76f933824c', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-121', 'task', 'T-97', 'cut interim release to drain Unreleased (split from T-109)', 'Item 3 of T-109. With CONTRIBUTING.md landed and [Unreleased] consolidated, the remaining piece is cutting an actual release so the ~80-commit Unreleased backlog drains into a dated heading.
|
||||
|
||||
Per .claude/skills/git-commit/SKILL.md "Cutting a release":
|
||||
1. Move every [Unreleased] entry under a new `## [X.Y.Z] — YYYY-MM-DD` heading.
|
||||
2. Leave an empty [Unreleased] skeleton.
|
||||
3. Bump pubspec.yaml version: (drop the -dev suffix for the tag).
|
||||
4. Commit subject: `release vX.Y.Z`.
|
||||
|
||||
Version pick is the user''s call — likely 2.1.0 given the scope since 2.0.0 (D-66 95% coverage gate, T-117 keymap, contrast-gate split, IPC hardening, panel-keyboard parity, etc.).
|
||||
|
||||
Source: consultants.md "Docs — Findings" item 3.', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-18 08:07:23', '2026-05-18 08:07:23', NULL, '74aa71a8c832231c1debb66c767cf490', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-109', 'task', 'T-97', 'write CONTRIBUTING.md; cut interim release to drain Unreleased; merge duplicate changelog headings', 'Three docs items:
|
||||
|
||||
1. **No `CONTRIBUTING.md`.** Build/test story is scattered across `CLAUDE.md` (Claude-addressed), `README.md` (partly wrong — see T-101), and Makefile help. Write a human-addressed contributor guide: clone → `make hooks && flutter pub get` → `make test` → DQR workflow → commit conventions.
|
||||
2. **CHANGELOG has duplicate subsection headings in `[Unreleased]`** — three `### Changed`, two `### Fixed`, two `### Removed` in the 2.0.0 section. Keep a Changelog 1.1.0 expects one of each per release. Merge.
|
||||
3. **~80-commit Unreleased backlog** undermines the format. Cut an interim release (`vX.Y.Z`) to drain it before continuing.
|
||||
|
||||
Order matters: 2 → 3 → 1 (clean changelog, release, then doc the workflow).
|
||||
|
||||
Source: consultants.md "Docs — Findings".', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 18:48:03', '2026-05-18 08:07:23', NULL, '7c1f64a5aef6e3e92157691a2b192beb', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
+182
-134
@@ -16,14 +16,142 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Deprecated
|
||||
|
||||
### Removed
|
||||
|
||||
- **`bin/clide.dart` + `DaemonServer`** — completing the D-56 dissolution.
|
||||
The separate daemon process was dissolved on 2026-04-23 but the entry
|
||||
point and socket server class were never actually deleted. Gone now,
|
||||
along with orphaned tests (`test/cli/`, `test/daemon/subprocess_test`,
|
||||
`test/daemon/in_process_test`), stale i18n strings, and "start
|
||||
`clide --daemon`" error messages.
|
||||
### Fixed
|
||||
|
||||
### Security
|
||||
|
||||
## [2.1.0] — 2026-05-18
|
||||
|
||||
### Added
|
||||
|
||||
- Contrast gate split — baseline `canonicalPairs` every theme passes,
|
||||
strict `extendedPairs` (muted/status/syntax/focus-border) gated to
|
||||
`-hc` / `-cb` variants. Ships `clide-hc`, `midnight-hc`, `paper-hc`,
|
||||
`terminal-hc` siblings of the named themes (D-69, T-114, T-118).
|
||||
- Pre-push coverage gate — `make push-check` runs `ci/coverage_gate.sh`,
|
||||
which fails if total line coverage drops below `coverage_floor:` in
|
||||
`pubspec.yaml`. Floor ratchets up only; target 95% (D-66).
|
||||
- Pre-push changelog gate — `ci/changelog_gate.sh` fails on any
|
||||
`## [Unreleased]` bullet over 60 words. Enforces the Keep-a-Changelog
|
||||
conciseness rule in the git-commit skill.
|
||||
- Keymap layer (`KeymapService`) — typed Intents, YAML presets,
|
||||
VS-Code-style when-clauses, layered preset → user file → settings
|
||||
overlay. Default preset ships; vim/vscode/jetbrains unblocked
|
||||
(T-117, supersedes T-110).
|
||||
- Keyboard operability — `ClideTappable` is now Tab-focusable with a
|
||||
focus ring and Enter/Space activation; `ClidePalette` adds arrow
|
||||
nav, Escape dismiss, and selection highlight (T-100).
|
||||
- Panel-to-panel focus traversal — each `SlotHost` wraps in a
|
||||
`FocusScope` + `FocusTraversalGroup`; `F6` / `Shift+F6` cycle
|
||||
sidebar → workspace → context. `FocusTracker` integrates with
|
||||
Flutter focus rather than paralleling it (T-105).
|
||||
- Event-driven test waits — PTY + watcher tests await stream events
|
||||
instead of fixed sleeps; `onTimeout` callbacks now `fail()` loudly
|
||||
with diagnostic context. `RecordingEventSink` exposes a broadcast
|
||||
stream for the same pattern (T-108).
|
||||
- Code-quality cleanups — `TreeSitterLib` exposes a last-error
|
||||
diagnostic instead of swallowing dlopen failures; `ExtensionManager`
|
||||
surfaces a `failedExtensions` map for UI degradation; PTY constants
|
||||
consolidated in `libc.dart` + `PosixErrno`; `test_app.dart` gated
|
||||
behind `kDebugMode` (T-112).
|
||||
- Test sweep — `keybindings`, `toolchain_paths`, and several
|
||||
`widgets/src/` primitives (tooltip, palette, multitab, markdown).
|
||||
- `tree_sitter_service` sweep — fake-FFI + real-library smoke,
|
||||
17% → 96%. Crosses the 95% global target (T-91).
|
||||
- Staged `dart doc` CI job — generates and uploads an HTML API
|
||||
reference for the public `lib/` surface. The step wraps
|
||||
`dart doc --validate-links` and grep-fails the build on any warning.
|
||||
Inert with the rest of the workflow until Gitea Actions activates.
|
||||
- Mouse wheel scrolling in Claude pane — converts scroll events to
|
||||
PgUp/PgDown so TUI apps scroll their history naturally.
|
||||
- Welcome screen Tips card — six common keybindings shown below the
|
||||
START / RECENT row when the viewport is tall enough.
|
||||
- `MultitabPane` widget + `MultitabController` for panes that host
|
||||
N runtime tab instances of the same kind. Generic over a payload
|
||||
type, supports pinned/non-closeable tabs, drag-reorder, close × on
|
||||
hover, and an optional `+` add button.
|
||||
- `MultitabPane.keepAlive` mode — entry bodies stay mounted via
|
||||
IndexedStack so switching tabs preserves their state (PTY
|
||||
connections, scroll position, etc.).
|
||||
- `CONTRIBUTING.md` — human-addressed contributor guide covering
|
||||
clone / build / test / DQR / tickets / commit conventions. The
|
||||
`[Unreleased]` section is reorganised to one subsection per kind
|
||||
per Keep a Changelog 1.1.0 (T-109).
|
||||
- `make verify` — no-tests sweep (analyze + format + decisions +
|
||||
changelog gate). For mid-edit checks; `make push-check` stays the
|
||||
full pre-push pipeline.
|
||||
|
||||
### Changed
|
||||
|
||||
- Window-control close-button red, white close glyph, and palette
|
||||
ambient shadow are now tokens (`windowControl.closeHover*`,
|
||||
`shadow.ambient`) instead of hard-coded hex. Light themes get a
|
||||
softer ink-tinted shadow (T-114).
|
||||
- Text-zoom (Ctrl +/-/0) is now a kernel `TextZoom` service and shows
|
||||
up in the palette as `View: Zoom In/Out/Reset Zoom` (T-114).
|
||||
- Panel splitters (sidebar / context / editor-split) are tab-focusable;
|
||||
arrow keys nudge by 10 px, Shift+arrow by 50 px (2% / 10% for the
|
||||
editor split). Exposed as slider Semantics nodes so screen readers
|
||||
announce the current size. CLI verb deferred to T-99 (T-111).
|
||||
- Changelog gate is binary — dropped the soft 40-word warning, kept
|
||||
the 60-word hard cap. Warnings that never blocked just normalised
|
||||
drift.
|
||||
- PTY spawning uses `posix_openpt` + `posix_spawn` instead of
|
||||
`forkpty` — closes a ~5% deadlock window in the multithreaded Dart
|
||||
VM (T-96, D-5 amended). Missing exe/cwd now throw `PtyException` at
|
||||
spawn time. Drops the `libutil.so.1` dependency.
|
||||
- Coverage floor ratcheted to 95% — D-66 target hit.
|
||||
- `TreeSitterService` and `TreeSitterLib` accept injectable FFI + asset
|
||||
loaders for fake-driven tests; production paths unchanged.
|
||||
- Tidied test imports flagged by `unnecessary_import`.
|
||||
- `README.md` rewritten to match current architecture;
|
||||
`docs/initial-plan.md` bannered as historical; new
|
||||
`docs/architecture.md` describes today's shape (T-101).
|
||||
- `SchedulerService._stopTicker` now awaits the in-flight isolate spawn
|
||||
before killing — closes the same race shape we fixed in PTY (T-106).
|
||||
- `make push-check-full` added — runs `push-check` plus integration +
|
||||
smoke for pre-release checks. Integration tests skip the hanging
|
||||
theme_picker case until that's fixed (T-103, T-116).
|
||||
- Governance bookkeeping: D-66 amended (floor at `coverage_floor:` in
|
||||
`pubspec.yaml`); `licenses.yaml` reconciled with `pubspec.yaml`;
|
||||
Q-1/Q-2/Q-3/Q-25 triaged; `.claude/skills/README.md` inventory
|
||||
added; `--no-fatal-infos` dropped from `ci/test.sh` (T-113).
|
||||
- Terminal panes render bold attributes with a real bold weight —
|
||||
bundled JetBrainsMono Bold + BoldItalic registered with the
|
||||
`JetBrainsMono` family at `weight: 700`. The painter's bold
|
||||
suppression workaround is gone.
|
||||
- Claude pane uses `MultitabPane` for primary + secondaries — drops
|
||||
~100 lines of bespoke tab-strip code, gains drag-to-reorder.
|
||||
- UI spacing constants live in `lib/widgets/src/spacing.dart` —
|
||||
`clideInset*` for paddings, `clideGap*` for sibling distances,
|
||||
`clideIcon*` / `clideControlHeight` for control sizes.
|
||||
- Tagline reads "IDE for Claude Code CLI" everywhere (welcome
|
||||
subtitle, README, CLAUDE.md, pubspec, web manifest, CLI banner).
|
||||
- Inline terminal emulator based on xterm.dart v4.0.0 — replaces the
|
||||
pub.dev dependency with owned code under `lib/src/terminal/`. Drops
|
||||
three transitive dependencies (xterm, quiver, zmodem).
|
||||
- Bundle clide-specific tmux.conf for Claude pane sessions: no status
|
||||
bar, 50k scrollback, mouse on, zero escape delay, isolated socket.
|
||||
- Claude pane spawns `claude` directly inside tmux with
|
||||
`CLAUDE_CODE_NO_FLICKER=1` to enable Claude's fullscreen TUI mode.
|
||||
- PTY read buffer increased from 4 KB to 64 KB.
|
||||
- Terminal view 2 px padding on all sides.
|
||||
|
||||
### Removed
|
||||
|
||||
- **`bin/clide.dart` + `DaemonServer`** — completing the D-56
|
||||
dissolution. The separate daemon process was dissolved on 2026-04-23
|
||||
but the entry point and socket server class were never deleted.
|
||||
Gone now, along with orphaned tests, stale i18n strings, and
|
||||
"start `clide --daemon`" error messages.
|
||||
- **`ptyc/` source tree + `PtySession` + `scm_rights.dart`** — PTY
|
||||
spawning migrated to Dart FFI `forkpty()` (`NativePty`) but the old
|
||||
C helper and its Dart wiring were never cleaned up. Removed from
|
||||
@@ -32,153 +160,73 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
D-5 amended to record the retirement.
|
||||
- CI golden images (`test/goldens/goldens/ci/`) — Skia anti-aliasing
|
||||
of geometric shapes differs between macOS and Linux even with the
|
||||
Ahem font, so a single set of CI goldens can't serve both platforms.
|
||||
Replaced with platform-keyed goldens (`goldens/linux/`,
|
||||
`goldens/macos/`), each only compared on its own OS.
|
||||
|
||||
### Added
|
||||
|
||||
- Pre-push coverage gate — `make push-check` (and the
|
||||
`.githooks/pre-push` hook that calls it) now runs
|
||||
`ci/coverage_gate.sh`, which fails if total line coverage drops
|
||||
below the `coverage_floor:` value in `pubspec.yaml`. The floor
|
||||
starts at 34% (today's measured floor) and only ratchets up; the
|
||||
end target is 95% (D-66). `ci/test.sh` now writes
|
||||
`coverage/lcov.info` as a side effect of the unit/widget/golden
|
||||
run so the gate adds no extra test invocation.
|
||||
- Staged `dart doc` CI job — generates and uploads an HTML API
|
||||
reference for the public `lib/` surface. The step wraps
|
||||
`dart doc --validate-links` and grep-fails the build on any warning,
|
||||
so broken doc refs and dangling links can't accumulate. Inert with
|
||||
the rest of the workflow until Gitea Actions activates.
|
||||
- Mouse wheel scrolling in Claude pane — converts scroll events to
|
||||
PgUp/PgDown so Claude Code (and other TUI apps) scroll their
|
||||
history naturally.
|
||||
- Welcome screen Tips card — six common keybindings shown below the
|
||||
START / RECENT row when the viewport is tall enough.
|
||||
- `MultitabPane` widget + `MultitabController` for panes that host
|
||||
N runtime tab instances of the same kind. Generic over a payload
|
||||
type, supports pinned/non-closeable tabs (primary), drag-reorder,
|
||||
close × on hover, and an optional `+` add button. Used by the
|
||||
Claude pane to render primary + secondaries.
|
||||
- `MultitabPane.keepAlive` mode — when set, all entry bodies stay
|
||||
mounted via IndexedStack so switching tabs preserves their state
|
||||
(PTY connections, scroll position, etc.).
|
||||
Ahem font. Replaced with platform-keyed goldens (`goldens/linux/`,
|
||||
`goldens/macos/`).
|
||||
- Bold JetBrains Mono font registration that prevented glyph-width
|
||||
mismatch in terminal rendering — superseded by the Bold/BoldItalic
|
||||
re-registration above.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `TerminalView.onTapUp` callbacks now actually fire on a primary
|
||||
tap. The parameter was wired to a code path that nothing in the
|
||||
gesture-detector chain ever invoked — the documented
|
||||
"Callback for when the user taps on the terminal" was a no-op
|
||||
for every caller. Routes through `TerminalGestureHandler.onSingleTapUp`
|
||||
now, with the resolved cell offset (T-93). Dead `onTapUp` surface
|
||||
on `TerminalGestureHandler` and `TerminalGestureDetector` removed
|
||||
in the same change.
|
||||
- `TerminalView.onTapUp` now actually fires on primary tap — was
|
||||
wired to a dead code path (T-93). Dead `onTapUp` surface on
|
||||
`TerminalGestureHandler` / `TerminalGestureDetector` removed.
|
||||
- `BufferLine.eraseRange` no longer panics when called with `end == 0`.
|
||||
The right-side wide-char guard read `_data[-1]` via `getWidth(-1)`,
|
||||
which threw a `RangeError`. Real trigger path: `Terminal.eraseDisplayAbove`
|
||||
with the cursor at column 0 — common after `ESC[H\x1b[1J`
|
||||
(home + erase-above) sequences that many TUIs emit on redraw.
|
||||
- Terminal selections no longer silently disappear when the terminal is
|
||||
resized narrower. Reflow's tail-anchor handler used to reparent
|
||||
anchors past the source's trimmed-content range onto a builder line
|
||||
that was never emitted, leaving them detached from the visible
|
||||
buffer; the selection controller's `extent.attached` check then
|
||||
returned null and the highlight vanished. Common trigger paths:
|
||||
Ctrl+A (select-all) followed by a width change, and mouse drag
|
||||
selections that extended past the end of a partially-filled line
|
||||
(T-92).
|
||||
Real trigger: `Terminal.eraseDisplayAbove` with the cursor at
|
||||
column 0 — common after `ESC[H\x1b[1J` (home + erase-above).
|
||||
- Terminal selections no longer vanish when resizing narrower —
|
||||
reflow's tail-anchor handler left anchors detached past the
|
||||
trimmed range. Common triggers: Ctrl+A then resize, drag past a
|
||||
partially-filled line (T-92).
|
||||
- `BufferLine.removeCells` / `insertCells` / `dispose` no longer skip
|
||||
anchors due to concurrent list modification during iteration —
|
||||
surfaced by unit tests added under T-91. Anchors disposed inside
|
||||
the loop were unhooking themselves from the same list the loop was
|
||||
iterating, causing later anchors to be silently skipped (no
|
||||
reposition, no dispose) and leaving the buffer in an inconsistent
|
||||
state. Iteration now snapshots the list first.
|
||||
|
||||
### Changed
|
||||
|
||||
- Terminal panes now render bold attributes with a real bold weight —
|
||||
bundled JetBrainsMono Bold + BoldItalic are registered with the
|
||||
`JetBrainsMono` family at `weight: 700`. The painter's bold
|
||||
suppression workaround (added when only Regular + Italic were wired
|
||||
and Flutter's synthetic bold drifted advance widths) is gone.
|
||||
- Claude pane uses `MultitabPane` for primary + secondaries — drops
|
||||
~100 lines of bespoke tab-strip code, gains drag-to-reorder.
|
||||
- UI spacing constants live in `lib/widgets/src/spacing.dart` —
|
||||
`clideInset*` for paddings, `clideGap*` for sibling distances,
|
||||
`clideIcon*` / `clideControlHeight` for control sizes. Inline
|
||||
pixel literals replaced where they were repeated.
|
||||
|
||||
### Changed
|
||||
|
||||
- Tagline reads "IDE for Claude Code CLI" everywhere (welcome
|
||||
subtitle, README, CLAUDE.md, pubspec, web manifest, CLI banner).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Closing a secondary Claude pane tab now kills its tmux session
|
||||
on the clide socket, honouring D-41's "closing a secondary kills
|
||||
that tmux session" lifecycle. Previously `pane.close` only killed
|
||||
the ptyc-spawned tmux client and the server-side session leaked.
|
||||
iteration now snapshots the list first (T-91).
|
||||
- Closing a secondary Claude pane tab now kills its tmux session on
|
||||
the clide socket, honouring D-41's lifecycle. Previously
|
||||
`pane.close` only killed the ptyc-spawned tmux client.
|
||||
- Cold-start reap: every clide launch kills any leftover secondary
|
||||
tmux sessions for the current repo before spawning new ones, so
|
||||
D-41's "secondary numbering resets between runs" holds even after
|
||||
an abrupt previous exit (kill -9, crash, force-quit).
|
||||
D-41's "secondary numbering resets between runs" holds.
|
||||
- `claude.kill-all-sessions` command now actually kills the
|
||||
server-side tmux sessions for the repo, not just the panes.
|
||||
- Terminal cell grid no longer drifts on bold text — bold rendering
|
||||
is suppressed at the painter level since synthetic bold (with no
|
||||
Bold.ttf registered) shifts glyph advance widths.
|
||||
- PTY surfaces errno on `forkpty`, `write`, and `ioctl` failures
|
||||
instead of swallowing them. `execve` failures in the spawned
|
||||
child now write a diagnostic line to the slave PTY before
|
||||
`_exit`, so the parent's reader sees the cause instead of an
|
||||
immediate EOF that looked indistinguishable from clean exit.
|
||||
PTY `write` loops on short writes; both `NativePty.write` and
|
||||
`PtySession.write` now throw `PtyException` on hard errors.
|
||||
- PTY teardown order fixed — kill the child first so the master
|
||||
fd returns EOF, await the reader isolate exit, then close the
|
||||
fd. Previously closing the fd while the isolate still polled it
|
||||
could briefly target a reused fd. Reader isolate spawn errors
|
||||
in both `NativePty` and `PtySession` are now surfaced via the
|
||||
output stream instead of silently dropped. `_recvFdAsync` no
|
||||
longer leaks the `ReceivePort` when `Isolate.spawn` throws, and
|
||||
`PtySession.spawn` closes the master fd if any post-receive
|
||||
step fails.
|
||||
- PTY surfaces errno on `forkpty` / `write` / `ioctl` failures
|
||||
instead of swallowing. `execve` failures write a diagnostic to the
|
||||
slave before `_exit`. `NativePty.write` and `PtySession.write`
|
||||
loop on short writes; both throw `PtyException` on hard errors.
|
||||
- PTY teardown order fixed — kill child first so the master fd
|
||||
returns EOF, await reader isolate exit, then close the fd.
|
||||
- Reader isolate spawn errors in `NativePty` / `PtySession` are now
|
||||
surfaced via the output stream instead of silently dropped.
|
||||
`_recvFdAsync` no longer leaks the `ReceivePort` on spawn throw.
|
||||
- IPC server hardening: per-request 60s timeout (configurable),
|
||||
broadcast/response write failures logged instead of swallowed,
|
||||
client dropped on response-write failure, and the stale-socket
|
||||
retry now probes for a live daemon before unlinking the socket
|
||||
(refusing to start if one answers).
|
||||
- `pane.spawn` and `editor.open` now map POSIX errno values to
|
||||
actionable IPC error kinds. ENOENT → `not_found`, EACCES/EPERM
|
||||
→ `user_error` with a permissions hint, EISDIR/ENOTDIR/EEXIST
|
||||
→ distinct user-error/conflict, EMFILE/ENFILE → `tool_error`
|
||||
with a "fd limit hit" hint. Previously every spawn/open failure
|
||||
was an indistinguishable `tool_error`.
|
||||
retry now probes for a live daemon before unlinking the socket.
|
||||
- `pane.spawn` and `editor.open` map POSIX errno values to actionable
|
||||
IPC error kinds (ENOENT → `not_found`, EACCES/EPERM → `user_error`,
|
||||
EISDIR/ENOTDIR/EEXIST → distinct kinds, EMFILE/ENFILE →
|
||||
`tool_error` with an fd-limit hint).
|
||||
|
||||
### Security
|
||||
|
||||
- `files.read` and `files.ls` now reject paths that resolve outside
|
||||
the workspace root. Previously a relative path containing `..`
|
||||
could read arbitrary files via path traversal.
|
||||
|
||||
### Changed
|
||||
|
||||
- Inline terminal emulator based on xterm.dart v4.0.0 — replaces the
|
||||
pub.dev dependency with owned code under `lib/src/terminal/`. Drops
|
||||
three transitive dependencies (xterm, quiver, zmodem).
|
||||
- Bundle clide-specific tmux.conf for Claude pane sessions: no status
|
||||
bar, 50k scrollback, mouse on, zero escape delay, isolated socket.
|
||||
- Claude pane spawns `claude` directly inside tmux with
|
||||
`CLAUDE_CODE_NO_FLICKER=1` to enable Claude's fullscreen TUI mode
|
||||
(input box pinned at the bottom).
|
||||
- PTY read buffer increased from 4KB to 64KB.
|
||||
- Terminal view 2px padding on all sides.
|
||||
- Remove bold JetBrains Mono font registration to prevent glyph width
|
||||
mismatch in terminal rendering.
|
||||
- IPC: `git.checkout`, `git.push` reject branch/remote args starting
|
||||
with `-` (closes the `--upload-pack=...` argv-injection vector).
|
||||
`files.read` rejects files over 10 MB. `git.log` caps `count` at
|
||||
1000; `git.diff` / `git.stage` cap paths at 256 (T-104).
|
||||
- Toolchain no longer resolves the dugite git binary against the open
|
||||
workspace — a malicious repo could plant `native/dugite/bin/git`
|
||||
and clide would run it on auto-fired `git.status`. Dugite now
|
||||
resolves against the install dir + `CLIDE_DUGITE_DIR` env override
|
||||
only (T-98).
|
||||
- `files.read` and `files.ls` reject symlinks whose targets live
|
||||
outside the workspace — closes a path-safety bypass via in-repo
|
||||
symlinks (T-102).
|
||||
- `files.read` and `files.ls` reject paths that resolve outside the
|
||||
workspace root. Previously a relative path containing `..` could
|
||||
read arbitrary files via path traversal.
|
||||
|
||||
## [2.0.0] — 2026-05-03
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
An IDE for Claude Code CLI. Single Flutter package at the repo root.
|
||||
|
||||
- **`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). PTY spawning uses Dart FFI `forkpty()` directly.
|
||||
- **`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). PTY spawning uses Dart FFI `posix_openpt()` + `posix_spawn()` directly.
|
||||
- **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it.
|
||||
|
||||
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.
|
||||
@@ -20,7 +20,7 @@ These are load-bearing. Violating any means the design is wrong, not the rule.
|
||||
- **Flutter desktop is the host. No Electron, ever.** Web target may work as a happy accident — don't compromise desktop fidelity for it. If we ship a web build at all, prefer Flutter's **WebAssembly (CanvasKit/Skwasm) compile** over the JS/HTML renderer. `xterm.dart` is the terminal renderer; markdown, canvas, graph are custom `CustomPaint`/widget components.
|
||||
- **Single process.** The Flutter app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), extensions. No separate daemon binary (D-56 dissolved it).
|
||||
- **CLI-first, not MCP.** Claude talks via Bash (`clide ...`), matching pql's contract. See [`D-1`](governance/decisions/architecture.md#d-1-cli-first-not-mcp).
|
||||
- **Dart is the core; pql fills the query gap.** PTY spawning is native Dart FFI (`forkpty`). `pql` (Go) handles vault queries. No second "core language." See [`D-5`](governance/decisions/architecture.md#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended by D-56).
|
||||
- **Dart is the core; pql fills the query gap.** PTY spawning is native Dart FFI (`posix_openpt` + `posix_spawn`). `pql` (Go) handles vault queries. No second "core language." See [`D-5`](governance/decisions/architecture.md#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended by D-56).
|
||||
- **Own the rendering stack.** PTY (via Dart FFI), 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-6`](governance/decisions/architecture.md#d-6-cli-and-event-surface-contract).
|
||||
- **pql: wrap, don't duplicate.** Pql logic only lives in `lib/src/pql/` (pure shell-outs). Clide owns pql's `ignore_files:` config key; it never touches pql's `.pql/` index/cache data. See [`D-3`](governance/decisions/architecture.md#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates).
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
# Contributing to clide
|
||||
|
||||
clide is an IDE for the Claude Code CLI, built as a single Flutter
|
||||
package at the repo root. This guide is for people working on clide
|
||||
itself.
|
||||
|
||||
For day-to-day model-driven work in the codebase, see
|
||||
[`CLAUDE.md`](CLAUDE.md) — it documents the same guardrails from the
|
||||
agent's point of view and is the place to look for "why is the code
|
||||
shaped like this?"
|
||||
|
||||
## One-time setup
|
||||
|
||||
```
|
||||
git clone git@github.com:postmeridiem/clide.git
|
||||
cd clide
|
||||
make hooks && flutter pub get
|
||||
```
|
||||
|
||||
`make hooks` installs the repo's git hooks (pre-commit + post-merge).
|
||||
Flutter must be on the stable channel and on `$PATH`.
|
||||
|
||||
If you haven't used [pql](https://github.com/postmeridiem/pql) before,
|
||||
install it and run `pql init` once in the repo root. pql is a hard
|
||||
dependency for the governance + ticket workflow described below.
|
||||
|
||||
## The five commands you'll actually use
|
||||
|
||||
```
|
||||
make run # launch the desktop app
|
||||
make verify # no-tests sweep — analyze + format + decisions + changelog gate
|
||||
make test # fast suite — analyze + format + unit + widget + golden
|
||||
make test-a11y # WCAG-AA contrast + keyboard traversal contracts
|
||||
make push-check # the pre-push gate; what CI runs
|
||||
make build-linux # release artefact for the host platform
|
||||
```
|
||||
|
||||
`make verify` is the lightweight "are the gates green?" check for
|
||||
mid-edit iteration. `make push-check` is the full pre-push pipeline
|
||||
(verify + every test suite + coverage gate).
|
||||
|
||||
`make` with no target prints the full list. `make test-integration`
|
||||
boots a real app process and is slow; reserve it for the rare change
|
||||
that touches startup wiring.
|
||||
|
||||
The pre-push hook runs `make push-check` automatically. Don't bypass
|
||||
it with `--no-verify` — fix the underlying issue and create a new
|
||||
commit. Pre-push includes:
|
||||
|
||||
- `flutter analyze` (zero warnings)
|
||||
- `dart format --set-exit-if-changed`
|
||||
- the fast unit/widget/golden suites
|
||||
- accessibility contract tests
|
||||
- a coverage floor read from `coverage_floor:` in `pubspec.yaml`
|
||||
(currently 95 %; ratchets up only — see
|
||||
[D-66](governance/decisions/testing.md#d-66))
|
||||
- `CHANGELOG.md` `[Unreleased]` bullets ≤ 60 words each
|
||||
|
||||
## Decisions, questions, rejected (DQR)
|
||||
|
||||
clide tracks architectural commitments as durable records under
|
||||
[`governance/`](governance/):
|
||||
|
||||
- `decisions/<domain>.md` — confirmed decisions (`D-NNN`)
|
||||
- `questions/<domain>.md` — open questions (`Q-NNN`)
|
||||
- `rejected/<domain>.md` — rejected proposals (`R-NNN`)
|
||||
|
||||
When you make a non-trivial architectural choice, write it down:
|
||||
|
||||
```
|
||||
pql decisions claim D <domain> "short title"
|
||||
```
|
||||
|
||||
This reserves a fresh ID and tells you where to add the record. The
|
||||
[governance/README.md](governance/README.md) explains the format and
|
||||
the recommended domain list.
|
||||
|
||||
Pre-push validates that every `D-NNN` / `Q-NNN` / `R-NNN` link in
|
||||
the docs and code resolves to an actual record (`pql decisions
|
||||
validate`). Broken references fail the build.
|
||||
|
||||
## Tickets
|
||||
|
||||
All non-trivial work is tracked in `pql ticket`:
|
||||
|
||||
```
|
||||
pql ticket list --status in_progress
|
||||
pql ticket show T-NNN[,T-NNN…] # batch form on pql 1.4.33+
|
||||
pql ticket new task "title" --parent T-NNN --priority medium
|
||||
pql ticket status T-NNN in_progress
|
||||
pql ticket status T-NNN done
|
||||
```
|
||||
|
||||
A ticket exists for any change a reviewer might want to ask "why?"
|
||||
about. Bug fixes, refactors, and consultant findings all become
|
||||
tickets before the diff lands. Trivial typo fixes don't need one.
|
||||
|
||||
## Commit conventions
|
||||
|
||||
See [D-37](governance/decisions/process.md#d-37) and the bundled
|
||||
[`git-commit` skill](.claude/skills/git-commit/SKILL.md). In short:
|
||||
|
||||
- Imperative subject ≤ 70 chars, no Conventional Commits prefix
|
||||
(this isn't a Conventional Commits repo — the archived Python
|
||||
predecessor under [`legacy/`](legacy/) is, but the rebuild isn't).
|
||||
- One logical change per commit. If the subject needs "and", split it.
|
||||
- Every user-visible commit adds an entry to `CHANGELOG.md` under
|
||||
`[Unreleased]` in the right subsection (Added, Changed, Deprecated,
|
||||
Removed, Fixed, Security). Keep entries to one or two short
|
||||
sentences — the 60-word cap is enforced by `ci/changelog_gate.sh`.
|
||||
- Co-author trailer:
|
||||
`Co-Authored-By: Claude <noreply@anthropic.com>` when Claude wrote
|
||||
any of the diff.
|
||||
|
||||
Never `--amend` a commit unless explicitly asked. Never force-push
|
||||
to `main`. Never `git add -A` / `git add .` when staging — name
|
||||
files explicitly so stray secrets or build artefacts don't sneak in.
|
||||
|
||||
## Cutting a release
|
||||
|
||||
A release is a single commit:
|
||||
|
||||
1. Move all `## [Unreleased]` entries under a new `## [X.Y.Z] —
|
||||
YYYY-MM-DD` heading.
|
||||
2. Leave an empty `## [Unreleased]` skeleton at the top.
|
||||
3. Bump `pubspec.yaml` `version:` to `X.Y.Z` (no `-dev` suffix on
|
||||
the release tag; add it back on the next development commit if
|
||||
you like).
|
||||
4. Commit subject: `release vX.Y.Z`.
|
||||
|
||||
`pubspec.yaml` is the single source of truth for the version — the
|
||||
Makefile reads it for ldflag stamping and the app reads it for build
|
||||
info.
|
||||
|
||||
## What goes where
|
||||
|
||||
- **Bug, feature, sweep:** file/claim a ticket, branch, code, test,
|
||||
commit, push. Push triggers `make push-check`.
|
||||
- **Architectural decision:** `pql decisions claim`, write the
|
||||
record, then file the implementation ticket linked via
|
||||
`decision_ref`.
|
||||
- **Open question:** drop a `Q-NNN` under
|
||||
`governance/questions/<domain>.md`. Triage later.
|
||||
- **Rejected proposal:** drop an `R-NNN` under
|
||||
`governance/rejected/<domain>.md`. Future-you (or a reviewer)
|
||||
will be glad it's written down.
|
||||
|
||||
## Reporting issues
|
||||
|
||||
The public issue tracker lives at
|
||||
<https://github.com/postmeridiem/clide/issues>. The Gitea mirror is
|
||||
read-only.
|
||||
@@ -79,6 +79,9 @@ analyze: ## flutter analyze.
|
||||
format: ## dart format --set-exit-if-changed.
|
||||
dart format --set-exit-if-changed .
|
||||
|
||||
.PHONY: verify
|
||||
verify: analyze format decisions-validate changelog-gate ## No-tests sweep — analyze + format + decisions-validate + changelog-gate. For mid-edit "are the gates green?" checks; `push-check` is the full pre-push pipeline.
|
||||
|
||||
.PHONY: test
|
||||
test: ## Fast: analyze + format + unit + widget + golden (<60s).
|
||||
ci/test.sh
|
||||
@@ -106,6 +109,10 @@ test-all: test-core test test-a11y test-integration test-e2e ## Everything, sequ
|
||||
coverage-gate: ## Coverage gate — fails if total line % < pubspec.yaml `coverage_floor:` (D-66). Assumes `make test` ran first.
|
||||
ci/coverage_gate.sh
|
||||
|
||||
.PHONY: changelog-gate
|
||||
changelog-gate: ## Changelog concision gate — fails on `## [Unreleased]` bullets over 60 words.
|
||||
ci/changelog_gate.sh
|
||||
|
||||
.PHONY: smoke-bundle
|
||||
smoke-bundle: ## Build Linux release bundle and run it under xvfb for 5s.
|
||||
ci/smoke_bundle.sh
|
||||
@@ -247,7 +254,10 @@ decisions-validate: ## Parser dry-run over governance/{decisions,questions,rejec
|
||||
pql decisions validate
|
||||
|
||||
.PHONY: push-check
|
||||
push-check: decisions-validate test-core test test-a11y coverage-gate ## Pre-push gate.
|
||||
push-check: decisions-validate test-core test test-a11y coverage-gate changelog-gate ## Pre-push gate (fast — <2 min target).
|
||||
|
||||
.PHONY: push-check-full
|
||||
push-check-full: push-check test-integration smoke-bundle ## Pre-release gate (push-check + integration + smoke; slower; skips theme_picker per T-116).
|
||||
|
||||
.PHONY: hooks
|
||||
hooks: ## Install the repo's git hooks.
|
||||
|
||||
@@ -4,13 +4,14 @@ An IDE for Claude Code CLI. Native rendering, terminal-first interaction, pql-po
|
||||
|
||||
## Architecture
|
||||
|
||||
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.
|
||||
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 (D-41).
|
||||
|
||||
- **`lib/`** — all Dart code. Kernel services (theme, i18n, settings, panels, commands, focus), UI widgets, built-in extensions, and the extension contract.
|
||||
- **`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.
|
||||
- **`lib/`** — all Dart code. Core subsystems (`lib/src/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), the extension framework (`lib/extension/`).
|
||||
- **PTY** — `lib/src/pty/` spawns child processes via Dart FFI `posix_openpt()` + `posix_spawn()` directly; no external helper binary.
|
||||
- **`native/`** — vendored native libraries (`libtree-sitter.so` with wasmtime embedded). Linux only today.
|
||||
- **[pql](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it.
|
||||
|
||||
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.
|
||||
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 (D-6).
|
||||
|
||||
## Built-in extensions
|
||||
|
||||
@@ -30,19 +31,25 @@ 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-a11y # accessibility contract tests
|
||||
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
|
||||
make push-check # pre-push gate: decisions + core + fast + a11y + coverage + changelog
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Pre-v2.0 (`2.0.0-dev`). Interaction model and panel system landed. The Python Textual v1.2.0 predecessor is archived under [`legacy/`](https://github.com/postmeridiem/clide/tree/main/legacy).
|
||||
|
||||
Design doc: [`docs/initial-plan.md`](https://github.com/postmeridiem/clide/blob/main/docs/initial-plan.md). Architectural decisions: [`decisions/`](https://github.com/postmeridiem/clide/tree/main/decisions).
|
||||
## Documentation
|
||||
|
||||
- [`docs/architecture.md`](docs/architecture.md) — current architecture (read this first).
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — how to clone, build, test, file tickets, and write D-records.
|
||||
- [`docs/initial-plan.md`](docs/initial-plan.md) — historical design doc; preserved as a snapshot of the 2026-04 plan, much of it now superseded.
|
||||
- [`governance/decisions/`](governance/decisions/) — confirmed decisions (`D-NNN`), open questions (`Q-NNN`), rejected alternatives (`R-NNN`).
|
||||
- [`CLAUDE.md`](CLAUDE.md) — Claude-addressed working notes (guardrails, repo layout).
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [`LICENSE`](https://github.com/postmeridiem/clide/blob/main/LICENSE).
|
||||
MIT. See [`LICENSE`](LICENSE).
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# clide default keymap.
|
||||
#
|
||||
# This is the baseline preset. Vim / VSCode / JetBrains presets are
|
||||
# their own files (T-64/T-65/T-66) and replace bindings via the same
|
||||
# YAML shape.
|
||||
#
|
||||
# Each binding:
|
||||
# intent: <stable intent id> # see kernel/src/keymap/intents.dart
|
||||
# keys: <chord> | [<chord>, ...] # `ctrl+shift+p`, `cmd+enter`, ...
|
||||
# when: <when-clause> # optional; VS-Code-style boolean expr
|
||||
#
|
||||
# Identifiers in `when:` are scope flags published by producing
|
||||
# services (e.g. `palette.open` when ClidePalette is mounted +
|
||||
# visible). Missing flags evaluate to false.
|
||||
|
||||
name: default
|
||||
|
||||
bindings:
|
||||
# -- Activation / focus -----------------------------------------------
|
||||
# ActivateIntent has no when-clause: it dispatches via Actions.maybeInvoke
|
||||
# against the focused context, so only widgets that opt in (ClideTappable's
|
||||
# Actions wrapper) actually catch it. Text-input widgets handle Enter
|
||||
# themselves first.
|
||||
- intent: activate
|
||||
keys: [enter, space]
|
||||
- intent: dismiss
|
||||
keys: escape
|
||||
- intent: focus.next
|
||||
keys: tab
|
||||
- intent: focus.previous
|
||||
keys: shift+tab
|
||||
|
||||
# Panel-to-panel cycling (sidebar → workspace → context).
|
||||
# VS Code convention.
|
||||
- intent: focus.nextPanel
|
||||
keys: f6
|
||||
- intent: focus.previousPanel
|
||||
keys: shift+f6
|
||||
|
||||
# -- Command palette --------------------------------------------------
|
||||
- intent: palette.open
|
||||
keys: [ctrl+shift+p, meta+shift+p]
|
||||
- intent: palette.selectNext
|
||||
keys: [down, ctrl+n]
|
||||
when: palette.open
|
||||
- intent: palette.selectPrevious
|
||||
keys: [up, ctrl+p]
|
||||
when: palette.open
|
||||
- intent: palette.accept
|
||||
keys: enter
|
||||
when: palette.open
|
||||
- intent: dismiss
|
||||
keys: escape
|
||||
when: palette.open
|
||||
|
||||
# -- Text scale -------------------------------------------------------
|
||||
# On most layouts `+` is `shift+equal`; we bind both so users who
|
||||
# think of it as Ctrl+Plus and users who hit Ctrl+= both work.
|
||||
- intent: text.scaleIncrease
|
||||
keys: [ctrl+equal, ctrl+shift+equal, meta+equal, meta+shift+equal]
|
||||
- intent: text.scaleDecrease
|
||||
keys: [ctrl+minus, meta+minus]
|
||||
- intent: text.scaleReset
|
||||
keys: [ctrl+0, meta+0]
|
||||
+3
-13
@@ -216,21 +216,11 @@ dev_dependencies:
|
||||
license: BSD-3-Clause
|
||||
purpose: Flutter-team-recommended analyzer lint set (app/).
|
||||
|
||||
- name: lints
|
||||
kind: dart-package
|
||||
version: "5.0.0"
|
||||
homepage: https://pub.dev/packages/lints
|
||||
license: BSD-3-Clause
|
||||
purpose: >-
|
||||
Dart-team-recommended analyzer lint set for the Flutter-free
|
||||
core package at the repo root.
|
||||
|
||||
- name: test
|
||||
kind: dart-package
|
||||
version: "1.25.8"
|
||||
version: "1.30.0"
|
||||
homepage: https://pub.dev/packages/test
|
||||
license: BSD-3-Clause
|
||||
purpose: >-
|
||||
Dart test runner for the core package (Flutter-free; the app
|
||||
uses flutter_test from the Flutter SDK for widget + golden
|
||||
coverage).
|
||||
Dart test runner for the Flutter-free PTY tests under
|
||||
`dart test --tags forkpty` (flutter_test is used elsewhere).
|
||||
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# CHANGELOG concision gate — enforces a single 60-word per-bullet hard
|
||||
# cap on the `## [Unreleased]` section. Released sections are frozen
|
||||
# and skipped (don't penalize historical entries pre-dating the rule).
|
||||
#
|
||||
# A "bullet" is a markdown list item beginning with `- `, including any
|
||||
# indented continuation lines until the next bullet, blank line, or
|
||||
# heading. Word count is whitespace-tokenized.
|
||||
#
|
||||
# A soft 40-word warning was tried earlier and dropped — warnings that
|
||||
# never block a push just normalise drift, so the gate is now binary.
|
||||
#
|
||||
# Bypass: never. If the rule rejects something genuinely user-visible
|
||||
# that needs more context, the context belongs in the commit body or a
|
||||
# D-record — see .claude/skills/git-commit/SKILL.md.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
CHANGELOG=CHANGELOG.md
|
||||
HARD_CAP=60
|
||||
|
||||
if [[ ! -f "$CHANGELOG" ]]; then
|
||||
echo "==> changelog gate: $CHANGELOG missing" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
awk -v hard="$HARD_CAP" '
|
||||
BEGIN { in_unreleased = 0; bullet = ""; bullet_start = 0; fail = 0 }
|
||||
|
||||
function check_bullet() {
|
||||
if (bullet == "") return
|
||||
# Trim the leading "- " marker, then tokenize on whitespace.
|
||||
gsub(/^- +/, "", bullet)
|
||||
n = split(bullet, _words, /[[:space:]]+/)
|
||||
if (n > hard) {
|
||||
printf "FAIL line %d: bullet is %d words (cap %d)\n", bullet_start, n, hard
|
||||
printf " %s\n\n", substr(bullet, 1, 120) (length(bullet) > 120 ? "..." : "")
|
||||
fail++
|
||||
}
|
||||
bullet = ""
|
||||
bullet_start = 0
|
||||
}
|
||||
|
||||
/^## \[Unreleased\]/ { in_unreleased = 1; next }
|
||||
/^## \[/ && in_unreleased { check_bullet(); in_unreleased = 0; exit_loop = 1 }
|
||||
!in_unreleased { next }
|
||||
|
||||
# Within Unreleased.
|
||||
/^### / { check_bullet(); next } # subsection header
|
||||
/^[[:space:]]*$/ { check_bullet(); next } # blank line ends bullet
|
||||
/^- / { # new bullet
|
||||
check_bullet()
|
||||
bullet = $0
|
||||
bullet_start = NR
|
||||
next
|
||||
}
|
||||
/^[[:space:]]+/ && bullet != "" { # continuation of current bullet
|
||||
bullet = bullet " " $0
|
||||
next
|
||||
}
|
||||
|
||||
END {
|
||||
check_bullet()
|
||||
if (fail > 0) {
|
||||
printf "\n==> changelog gate FAIL: %d bullet(s) over %d words.\n", fail, hard
|
||||
printf " Trim them. Rationale, probe results, behavior-change deep dives\n"
|
||||
printf " belong in the commit body, a D-record, or the ticket — not here.\n"
|
||||
printf " See .claude/skills/git-commit/SKILL.md \"Be concise\".\n"
|
||||
exit 1
|
||||
}
|
||||
printf "==> changelog gate OK\n"
|
||||
}
|
||||
' "$CHANGELOG"
|
||||
+2
-2
@@ -6,13 +6,13 @@ set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "==> flutter analyze"
|
||||
flutter analyze --no-fatal-infos
|
||||
flutter analyze
|
||||
|
||||
echo "==> dart format (whole tree)"
|
||||
dart format --set-exit-if-changed .
|
||||
|
||||
echo "==> dart test (forkpty — incompatible with flutter test runner)"
|
||||
dart test --tags forkpty test/pty/session_test.dart
|
||||
dart test --tags forkpty test/pty/session_test.dart test/panes/registry_test.dart
|
||||
|
||||
echo "==> flutter test --coverage (unit + widget + golden)"
|
||||
flutter test --coverage --exclude-tags forkpty
|
||||
|
||||
@@ -3,10 +3,16 @@
|
||||
# start" regression gate. Flutter integration tests prefer one file at
|
||||
# a time on desktop; we iterate to avoid the "Unable to start the app"
|
||||
# error that hits when they run as a batch.
|
||||
#
|
||||
# Skips: theme_picker_test.dart — pumpAndSettle hangs on theme.pick
|
||||
# (T-116). Restore once that's fixed.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
for f in integration_test/*_test.dart; do
|
||||
case "$f" in
|
||||
integration_test/theme_picker_test.dart) echo "==> integration_test: $f (SKIPPED — T-116)"; continue ;;
|
||||
esac
|
||||
echo "==> integration_test: $f"
|
||||
flutter test "$f"
|
||||
done
|
||||
|
||||
+776
@@ -0,0 +1,776 @@
|
||||
# clide — External Consultant Review
|
||||
|
||||
**Date:** 2026-05-14
|
||||
**Scope:** Full-repository assessment of clide at `main` (commit `9030e56`).
|
||||
**Method:** Six independent specialist reviewers, each given read-only access and a
|
||||
brief covering best practice, clean code, architecture, usability, stability,
|
||||
expandability, style, consistency, and general quality. Reviewers did not see each
|
||||
other's findings; cross-cutting themes below are genuine independent agreement.
|
||||
|
||||
**Panel:**
|
||||
| Lens | Reviewer |
|
||||
|---|---|
|
||||
| Architecture | Software Architect |
|
||||
| Tests & quality gates | Test / QA Analyst |
|
||||
| UX & accessibility | UX & Accessibility Expert |
|
||||
| Code quality & craft | Senior Dart/Flutter Engineer |
|
||||
| Security & supply chain | Security Engineer |
|
||||
| Docs, governance & DX | TPM / Developer-Experience Consultant |
|
||||
|
||||
---
|
||||
|
||||
## Overall verdict
|
||||
|
||||
clide is, for a solo-dev pre-v2.0 project, **unusually disciplined** — every reviewer
|
||||
said so independently. The governance system is alive, the core subsystems are small
|
||||
and well-typed, the FFI/PTY layer shows real systems-programming care, and the quality
|
||||
gates are genuine rather than ornamental. The codebase is in good shape.
|
||||
|
||||
The weaknesses cluster into a handful of themes, and several are **load-bearing**: a
|
||||
central guardrail (CLI-first IPC) has no runtime implementation, keyboard operability —
|
||||
the core requirement of a power-user dev tool — is largely unbuilt, an untrusted
|
||||
workspace can achieve code execution, and the two primary onboarding documents describe
|
||||
an architecture that no longer exists.
|
||||
|
||||
None of these are fatal; all are fixable; most have quick-win first steps. But they
|
||||
should be addressed before a public v2.0.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting themes (independent agreement)
|
||||
|
||||
These were each flagged by **two or more** reviewers who did not coordinate:
|
||||
|
||||
1. **The IPC layer is mid-migration and contradicts itself.** The Architect found no
|
||||
Unix-socket *server* anywhere in `lib/` — D-56's "app hosts an in-process IPC server"
|
||||
and D-1's "CLI-first, not MCP" have no runtime path; three IPC clients
|
||||
(`DaemonClient`, `InProcessClient`, `IsolateClient`) coexist with two duplicated
|
||||
service-wiring sites. The Security reviewer independently noted `DaemonClient`'s
|
||||
socket code is still live as an unvalidated attack surface. **Pick one IPC model,
|
||||
implement or amend D-56, delete the other two.**
|
||||
|
||||
2. **The "no pre-existing excuse / clean board" guardrail is being violated right now.**
|
||||
`flutter analyze` reports 9 `unnecessary_import` issues in `test/`; `ci/test.sh` runs
|
||||
analyze with `--no-fatal-infos`, which silently tolerates them. Flagged by the
|
||||
Architect, Code Quality, and Test reviewers. The repo's own rules say fix-first.
|
||||
|
||||
3. **`lib/src/terminal/` is in an undeclared middle state.** ~7k LOC forked from
|
||||
xterm.dart, carrying commented-out `print`s, dangling TODOs, a 1137-line `parser.dart`,
|
||||
and the only `invalid_use_of_protected_member` suppression in the repo. MEMORY says
|
||||
"code under `lib/` is owned, not vendored" — so it must either be formally vendored
|
||||
(frozen, documented, decision-recorded) or cleaned to the project bar. Flagged by
|
||||
Code Quality; the Architect's "consistency" deduction points at the same seam.
|
||||
|
||||
4. **Documentation describes a dissolved architecture.** `README.md` and
|
||||
`docs/initial-plan.md` still describe a Go sidecar, `ptyc/` C helper, `app/`
|
||||
subdirectory, and a separate `clide --daemon` process — all removed by D-5, D-56, and
|
||||
the FFI pivot. A new contributor's first read builds a wrong mental model.
|
||||
|
||||
---
|
||||
|
||||
## Consolidated scorecard
|
||||
|
||||
Scores are each reviewer's, 1–5, on their own dimensions.
|
||||
|
||||
| Domain | Dimension | Score |
|
||||
|---|---|---|
|
||||
| **Architecture** | Layering & dependency direction | 4 |
|
||||
| | Separation of concerns | 4 |
|
||||
| | Expandability | 5 |
|
||||
| | Consistency | 4 |
|
||||
| | Guardrail adherence | 3 |
|
||||
| **Tests** | Coverage quality | 4 |
|
||||
| | Test reliability / flakiness | 3 |
|
||||
| | Gate trustworthiness | 3 |
|
||||
| | Test maintainability | 5 |
|
||||
| | Regression-catching power | 4 |
|
||||
| **UX / a11y** | Interaction model | 2 |
|
||||
| | Accessibility | 3 |
|
||||
| | Visual consistency | 4 |
|
||||
| | Discoverability | 2 |
|
||||
| | State coverage (loading/error/empty) | 3 |
|
||||
| **Code quality** | Idiomatic Dart | 4 |
|
||||
| | Error handling | 4 |
|
||||
| | Naming & readability | 4 |
|
||||
| | Consistency across subsystems | 3 |
|
||||
| | Resource / lifecycle safety | 4 |
|
||||
| **Security** | Subprocess safety | 2 |
|
||||
| | IPC input validation | 3 |
|
||||
| | Path / filesystem safety | 3 |
|
||||
| | Dependency / supply-chain hygiene | 3 |
|
||||
| | Secrets & sandboxing | 3 |
|
||||
| **Docs / governance** | Governance discipline | 4 |
|
||||
| | Documentation accuracy | 2 |
|
||||
| | Changelog hygiene | 3 |
|
||||
| | Contributor onboarding | 2 |
|
||||
| | Convention adherence | 4 |
|
||||
|
||||
**Highest marks:** expandability (5), test maintainability (5). The extension contract
|
||||
and test-helper design are genuine standouts.
|
||||
**Lowest marks:** interaction model (2), discoverability (2), subprocess safety (2),
|
||||
documentation accuracy (2), contributor onboarding (2).
|
||||
|
||||
---
|
||||
|
||||
## Prioritized action list
|
||||
|
||||
Synthesized across all six reviews. Severity is the highest any reviewer assigned.
|
||||
|
||||
### Critical — address before public v2.0
|
||||
|
||||
1. **Fix untrusted-workspace code execution.** `toolchain_paths.dart:79` resolves
|
||||
`native/dugite/bin/git` relative to the *workspace root*; a malicious repo can plant
|
||||
an executable there that clide runs on the first auto-fired `git.status`. Resolve
|
||||
`native/dugite` against `Platform.resolvedExecutable`'s directory, never the
|
||||
workspace. *(Security)*
|
||||
2. **Resolve the IPC story.** Either implement the in-process Unix-socket server per
|
||||
D-56 so the `clide` CLI / C client actually works, or amend D-56 to make in-process
|
||||
direct dispatch the design and delete `DaemonClient`'s socket code, `IsolateClient`,
|
||||
`Backend`, and `backend_entry.dart`. Today the code claims three models and runs one,
|
||||
and a load-bearing guardrail (D-1/D-6) is unmet. *(Architecture, Security)*
|
||||
3. **Make the tool keyboard-operable.** `ClideTappable` (base of nearly every
|
||||
interactive widget) is mouse-only — no `Focus`, no Enter/Space. The command palette
|
||||
has no arrow-key navigation and no Escape. For a keyboard-first dev tool this is a
|
||||
functional gap, not a polish item. *(UX)*
|
||||
4. **Fix the onboarding docs.** Rewrite `README.md`'s `ptyc/` / `make ptyc-build`
|
||||
sections, fix its dead `decisions/` link, and banner `docs/initial-plan.md` as
|
||||
historical (or split out a current `docs/architecture.md`). *(Docs)*
|
||||
|
||||
### Major — should land soon
|
||||
|
||||
5. Add symlink re-resolution + containment re-check in `files.read` / `files.ls` — a
|
||||
repo symlink `config -> /etc/shadow` currently passes path-safety. *(Security)*
|
||||
6. Add `test-integration` (and ideally `smoke-bundle`) to `make push-check` — the gate
|
||||
that catches "app won't boot" is currently omitted from the pre-push gate. *(Tests)*
|
||||
7. Schema-validate the IPC argument surface; reject `-`-prefixed `branch`/`remote`/`path`
|
||||
values; add size/count bounds. *(Security)*
|
||||
8. Establish a real focus-traversal model (`FocusTraversalGroup` per slot, a documented
|
||||
"focus next panel" keybinding) and integrate `FocusTracker` with Flutter's focus
|
||||
system instead of paralleling it. *(UX)*
|
||||
9. Fix the `SchedulerService._startTicker` isolate-spawn race — a `_stopTicker()` before
|
||||
the spawn future resolves leaks a forever-ticking isolate. Mirror `NativePty`'s
|
||||
`_readerReady` pattern. *(Code quality)*
|
||||
10. Decide the status of `lib/src/terminal/` — formally vendor (and decision-record) it,
|
||||
or do the cleanup sweep. *(Code quality)*
|
||||
11. Replace fixed wall-clock `Future.delayed` sleeps in `watcher_test.dart` /
|
||||
`session_test.dart` with event-driven waits; make swallowed `onTimeout` callbacks
|
||||
`fail()` loudly. *(Tests)*
|
||||
12. Write a human-facing `CONTRIBUTING.md`; cut an interim release to drain the ~80-commit
|
||||
`[Unreleased]` backlog; merge duplicate changelog subsection headings. *(Docs)*
|
||||
13. Single global `KeyboardListener` → scoped `Shortcuts`/`Actions`; move
|
||||
`KeybindingResolver` off layout-dependent `keyLabel`. *(UX)*
|
||||
|
||||
### Quick wins — hours each
|
||||
|
||||
- Clear the 9 `unnecessary_import` analyzer issues; drop `--no-fatal-infos` from
|
||||
`ci/test.sh`. *(Architecture, Tests, Code quality)*
|
||||
- Run the `forkpty` PTY tests with `--coverage` so `native_pty.dart` — the riskiest file
|
||||
— is honestly measured. *(Tests)*
|
||||
- Add a `Focus` + Enter/Space wrapper and a focus-ring inside `ClideTappable`; this fixes
|
||||
the keyboard gap for every button and list item at once. *(UX)*
|
||||
- Add arrow-key + Escape + selected-index to `ClidePalette` (copy the existing
|
||||
`_ProjectSwitcherDropdown` `onKeyEvent` pattern). *(UX)*
|
||||
- Amend D-66 to reflect the coverage floor's real location (`pubspec.yaml`), mechanism,
|
||||
and value (90%) — it currently disagrees with the changelog and the code. *(Docs)*
|
||||
- Reconcile `licenses.yaml` with `pubspec.yaml` (`test` version drift, phantom `lints`
|
||||
entry); add a `native/SHA256SUMS` manifest. *(Security, Docs)*
|
||||
- Replace silent `catch (_)` in `tree_sitter_ffi.dart` with a logged last-error.
|
||||
*(Code quality)*
|
||||
- Fix the `clide.dart` barrel leak in `file_tree_view.dart:8`; narrow the barrel (drop
|
||||
the `dispatcher.dart` export); move `test_app.dart` out of the production `main.dart`
|
||||
import graph. *(Architecture)*
|
||||
- Expand the contrast gate's `canonicalPairs` to cover `globalTextMuted`, the `status*`
|
||||
colors, and `panelActiveBorder`. *(UX)*
|
||||
- Triage stale governance Q-records (Q-1/2/3/25 overtaken by shipped Tier-1 work).
|
||||
*(Docs)*
|
||||
|
||||
---
|
||||
|
||||
# Full reviews
|
||||
|
||||
## 1. Architecture — Software Architect
|
||||
|
||||
### Executive summary
|
||||
|
||||
clide is an unusually disciplined solo-dev codebase. The governance system (67
|
||||
D-records, tracked Q/R) is real and largely honored in code, the kernel/extension split
|
||||
is coherent, and the feature-first layout with barrel files is consistently applied. The
|
||||
single biggest strength is the **extension contract**: every built-in — including layout
|
||||
itself — passes the same `ClideExtension` + `ContributionPoint` contract, which is the
|
||||
best possible proof the contract is usable. The single biggest risk is **architectural
|
||||
drift in the IPC layer**: D-56 mandates the Flutter app host an in-process IPC server
|
||||
reachable by a thin C client over a unix socket, but no socket server exists anywhere in
|
||||
`lib/` — the "CLI-first, not MCP" guardrail (D-1) has no runtime path today. Compounding
|
||||
this, three parallel IPC client implementations (`DaemonClient` socket,
|
||||
`InProcessClient`, `IsolateClient` + `Backend`) coexist with two competing
|
||||
service-wiring sites (`main.dart` and `backend_entry.dart`), suggesting an unfinished
|
||||
migration.
|
||||
|
||||
### Strengths
|
||||
|
||||
- **Extension contract is clean and scales** — `lib/extension/src/extension.dart` +
|
||||
`contribution.dart`: sealed `ContributionPoint` hierarchy, `ClideExtensionContext`
|
||||
lists services explicitly (deliberately avoiding a `KernelServices` import cycle —
|
||||
`extension.dart:50-52`). `ExtensionManager` does dependency topo-sort,
|
||||
dependency-gated activation, and contribution apply/remove symmetrically
|
||||
(`extensions_manager.dart:164-202`). Adding a pane = new extension file + one
|
||||
`register()` line in `main.dart`.
|
||||
- **Kernel admission rule is enforced, not aspirational** — D-12's "mandatory shared
|
||||
singleton" test visibly shaped `KernelServices` (`facade.dart:38-93`); ~25 services,
|
||||
each defensibly cross-cutting. The two-tier disable model (D-14) is honored:
|
||||
`default_layout` is itself an extension.
|
||||
- **Feature-first layout with barrel discipline** (D-8) is consistent — every
|
||||
`builtin/<name>/` and `kernel/` has a barrel; builtins import
|
||||
`package:clide/kernel/kernel.dart`, not deep paths. Only one leak found.
|
||||
- **Governance-to-code traceability is genuine** — `WidgetsApp` root (D-7) at
|
||||
`app.dart:38`, `ChangeNotifier`/`ListenableBuilder` state (D-10) everywhere, git
|
||||
hardcoded in toolchain/project loader (D-13), terminal correctly tagged
|
||||
`inlined-source` in `licenses.yaml` with modifications documented.
|
||||
- **Git subsystem cohesion** — `lib/src/git/` cleanly split into `client` / `status` /
|
||||
`diff` / `operations` (~250 lines each), each a single responsibility.
|
||||
|
||||
### Findings
|
||||
|
||||
- **[Critical] No IPC socket server exists** — D-56 specifies the app hosts an
|
||||
in-process IPC server with a C client connecting over a unix socket. `grep` for
|
||||
`ServerSocket`/unix-domain `bind` in `lib/` returns nothing. `DaemonClient._connect`
|
||||
(`client.dart:72-94`) *connects* to a socket, but nothing *serves* one. Today the only
|
||||
working path is `InProcessClient` (`in_process.dart`), which calls the dispatcher
|
||||
directly in-process. **Claude cannot drive clide via `clide ...` — the CLI-first
|
||||
guardrail (D-1, D-6) has no implementation.** This is the load-bearing contract of the
|
||||
whole project and it is absent.
|
||||
- **[Major] Three IPC clients + two wiring sites = unfinished migration** —
|
||||
`DaemonClient` (socket), `InProcessClient`, and `IsolateClient`+`Backend`/
|
||||
`backend_entry.dart` all coexist. `main.dart:76-113` wires subsystems via
|
||||
`buildDispatcher`; `backend_entry.dart:40-110` wires the *same* five subsystems again
|
||||
inside an isolate. `Backend.spawn` is referenced only by `facade.dart` but `main.dart`
|
||||
uses `autoStartDaemonClient: false` + `daemonClientFactory` (the in-process path).
|
||||
Dead-or-dormant isolate infrastructure with duplicated registration logic — pick one
|
||||
and delete the others.
|
||||
- **[Major] `main.dart` (production entry) imports `test_app.dart`** — `main.dart:2` and
|
||||
`:51-55`. The production binary carries the test harness and branches on
|
||||
`CLIDE_TESTMODE`. Test scaffolding should not be reachable from the shipping entry
|
||||
point; gate it behind a separate entrypoint or `kDebugMode`.
|
||||
- **[Minor] `flutter analyze` reports 9 issues** — all `unnecessary_import` in `test/`,
|
||||
but CLAUDE.md's "no pre-existing excuse" / "clean board" guardrail makes this a
|
||||
fix-first item.
|
||||
- **[Minor] Barrel leak** — `lib/builtin/files/src/file_tree_view.dart:8` imports
|
||||
`package:clide/src/files/listing.dart` directly instead of via
|
||||
`package:clide/clide.dart` (which already re-exports `FileEntry`).
|
||||
- **[Minor] `clide.dart` barrel exports the daemon dispatcher** — `clide.dart:15`
|
||||
exports `src/daemon/dispatcher.dart`. The barrel is described as "shared types"; the
|
||||
dispatcher is server-side machinery.
|
||||
- **[Minor] `ExtensionManager.activate` swallows exceptions** (`extensions_manager.dart:
|
||||
141-143`) — a failed `activate()` logs and continues, leaving the extension
|
||||
un-activated but `_known`, with no surfaced "degraded" state for the UI.
|
||||
|
||||
### Recommendations
|
||||
|
||||
**Quick wins:** clear the 9 analyzer issues; fix the `file_tree_view.dart` barrel leak
|
||||
(consider a CI grep gate for `package:clide/src/` imports outside their feature); move
|
||||
`test_app.dart` out of the production import graph; drop the `dispatcher.dart` export
|
||||
from `clide.dart`.
|
||||
|
||||
**Larger efforts:** resolve the IPC story (implement the socket server per D-56, or
|
||||
amend D-56 and delete `DaemonClient`/`IsolateClient`/`Backend`/`backend_entry.dart`);
|
||||
collapse subsystem wiring into one `registerAllSubsystems(...)` function; give
|
||||
`ExtensionManager` a surfaced failure state so the UI can show degraded built-ins.
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Dimension | Score | Justification |
|
||||
|---|---|---|
|
||||
| Layering & dependency direction | 4/5 | Kernel→extension direction clean, context-vs-aggregate split avoids cycles; docked for the `src/`↔`kernel/src/` barrel leak and the dispatcher export. |
|
||||
| Separation of concerns | 4/5 | Feature-first layout, single-responsibility subsystems; duplicated subsystem registration is the blemish. |
|
||||
| Expandability | 5/5 | New pane = one extension file + one `register()` line; sealed contribution hierarchy; layout itself is data and extension-shaped. |
|
||||
| Consistency | 4/5 | Barrels, naming, D-record back-references uniform; three coexisting IPC clients and 9 analyzer issues break the bar. |
|
||||
| Guardrail adherence | 3/5 | `WidgetsApp`, single-process, no-Material, governance, zero-deps all honored — but D-1/D-6/D-56 (CLI-first via socket server) have no runtime implementation. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Tests & quality gates — Test / QA Analyst
|
||||
|
||||
### Executive summary
|
||||
|
||||
The clide test suite is, for a solo-dev pre-2.0 project, in genuinely good shape. ~104
|
||||
test files against 276 lib files, ~92.75% line coverage, and — critically — the coverage
|
||||
was *not* bought with assertion-free filler. Even the alarmingly-named files
|
||||
(`coverage_trivials_test.dart`, `zero_coverage_widgets_test.dart`,
|
||||
`services_stubs_test.dart`, `mop_up_test.dart`) contain real behavioral assertions. The
|
||||
biggest strength is a sensibly layered pyramid with a real boot-path integration gate
|
||||
and a startup smoke test that catches the "tests pass but app won't launch" class. The
|
||||
biggest risk is **flakiness from wall-clock-dependent tests** — fixed `Future.delayed`
|
||||
sleeps in file-watcher and PTY tests will eventually produce intermittent CI failures,
|
||||
and the PTY tests are run via `dart test` so they are **excluded from the coverage
|
||||
measurement entirely**.
|
||||
|
||||
### Strengths
|
||||
|
||||
- **Test pyramid is sound.** Pure-Dart unit, widget tests with a shared harness, golden
|
||||
tests (Alchemist), an a11y contract layer, and 3 real-boot `integration_test/` files —
|
||||
correctly separated by runner (`ci/test.sh` vs `ci/test_core.sh` vs
|
||||
`ci/test_integration.sh`).
|
||||
- **Helpers are well-designed.** `test/helpers/kernel_fixture.dart` boots a real
|
||||
`KernelServices` with in-memory themes/i18n and `autoStartDaemonClient: false` — no
|
||||
real socket, temp-dir scoped, proper `dispose()`. `FakeDaemonClient` is a clean stub.
|
||||
- **Error-branch discipline.** `pql_commands_errors_test.dart` /
|
||||
`git_commands_errors_test.dart` deliberately point the toolchain at a non-existent
|
||||
binary to drive catch-branches the happy path can't reach — table-driven, with
|
||||
`reason:` tags.
|
||||
- **OS-dialog avoidance is handled correctly.** `welcome/dialog_test.dart` mocks the
|
||||
`clide/window` MethodChannel to throw `MissingPluginException`, exercising the fallback
|
||||
path *without* spawning a native file picker.
|
||||
- **Startup gate.** `ci/smoke_bundle.sh` builds the real release bundle and runs it
|
||||
under xvfb for 5s, correctly interpreting `timeout` exit codes (124/143 = healthy).
|
||||
- **Coverage gate is honest.** `ci/coverage_gate.sh` is a self-contained awk parser (no
|
||||
`lcov` dependency), ratchets only upward, and `exit 2` distinguishes "missing data"
|
||||
from "below floor."
|
||||
|
||||
### Findings
|
||||
|
||||
- **[Major] PTY tests are excluded from coverage.** `ci/test.sh:13` runs
|
||||
`flutter test --coverage --exclude-tags forkpty`; the `forkpty` tests run separately
|
||||
via `dart test` with no `--coverage`. So `lib/src/pty/native_pty.dart` — the
|
||||
highest-risk native code in the repo — is barely in the measured denominator. The
|
||||
92.75% number overstates coverage of the riskiest file.
|
||||
- **[Major] Wall-clock sleeps will flake.** `test/files/watcher_test.dart:67-82` uses
|
||||
fixed `Future.delayed`; `test/pty/session_test.dart:71` polls 50×100ms and `:65` uses a
|
||||
bare `500ms` settle. `session_test.dart`'s `timeout(5s, onTimeout: () {})` (`:48`)
|
||||
*swallows* the timeout — a never-producing PTY proceeds to a confusing assertion
|
||||
failure rather than a clear timeout.
|
||||
- **[Major] `make push-check` does not run integration tests.** `push-check:
|
||||
decisions-validate test-core test test-a11y coverage-gate` — `test-integration` and
|
||||
`smoke-bundle` are omitted. A boot-order regression sails through.
|
||||
- **[Minor] `flutter analyze --no-fatal-infos` in `ci/test.sh:9`** contradicts the
|
||||
stated "fail-on-warning, clean board" discipline.
|
||||
- **[Minor] Integration tests run one-file-at-a-time** to dodge a batch "Unable to start
|
||||
the app" error — each invocation re-boots the engine (slow), and the workaround masks
|
||||
whether the batch failure is environmental or a real teardown leak.
|
||||
- **[Minor] Golden CI config disabled.** Only platform goldens run; a Linux-only CI
|
||||
never validates the macOS goldens, and stale `test/goldens/failures/*.png` artifacts
|
||||
are committed to the repo.
|
||||
- **[Minor] `test_core.sh` timeout kill is best-effort** — the `pkill -9 -f` pattern
|
||||
match is redundant noise next to the real `setsid` + `timeout --kill-after` safety net.
|
||||
- **[Minor] `git/client_test.dart` depends on the ambient `git` binary**, not the
|
||||
vendored dugite — the suite passes/fails on the host git version.
|
||||
|
||||
### Recommendations
|
||||
|
||||
**Quick wins:** add `test-integration` (and `smoke-bundle`) to `push-check` — the single
|
||||
highest-value change; run the `forkpty` tests with `--coverage`; drop `--no-fatal-infos`;
|
||||
gitignore `test/goldens/failures/`; make `onTimeout` callbacks `fail()`.
|
||||
|
||||
**Larger efforts:** replace fixed sleeps with event-driven waits
|
||||
(`expectLater(stream, emits(...))`); add a macOS golden CI matrix entry or document
|
||||
goldens as advisory; consider a coverage-exclusion allowlist for genuinely-unreachable
|
||||
defensive branches rather than chasing the last lines with filler tests.
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Dimension | Score | Justification |
|
||||
|---|---|---|
|
||||
| Coverage quality | 4/5 | Tests are meaningful even in "mop-up" files; docked because PTY/FFI is outside the measured number. |
|
||||
| Test reliability / flakiness | 3/5 | Fixed wall-clock sleeps and a swallowed timeout are latent intermittent failures. |
|
||||
| Gate trustworthiness | 3/5 | Coverage gate and smoke bundle are well-built, but `push-check` omits integration tests. |
|
||||
| Test maintainability | 5/5 | Shared fixtures, consistent structure, table-driven error suites, clear doc comments. |
|
||||
| Regression-catching power | 4/5 | Real boot-path integration + smoke + a11y + goldens; weakened by single-OS goldens and PTY coverage gaps. |
|
||||
|
||||
---
|
||||
|
||||
## 3. UX & accessibility — UX & Accessibility Expert
|
||||
|
||||
### Executive summary
|
||||
|
||||
clide has an unusually disciplined *foundation* for a solo pre-v2.0 project: a coherent
|
||||
semantic design-token system, a WCAG-AA contrast gate wired into CI, and i18n/semantic
|
||||
contract tests. That foundation is the biggest strength. The biggest risk is that
|
||||
**keyboard operability is largely unimplemented below the foundation** — the project's
|
||||
own core interaction primitive (`ClideTappable`) is mouse-only, the command palette has
|
||||
no arrow-key navigation or Escape, and there is no focus-traversal wiring across panels.
|
||||
For a keyboard-first power-user dev tool, this is a critical gap that the a11y test suite
|
||||
does not catch because the tests assert *structural* presence (Semantics nodes exist)
|
||||
rather than *operability* (can you actually drive it from the keyboard).
|
||||
|
||||
### Strengths
|
||||
|
||||
- **Semantic token system is real and enforced.** `lib/kernel/src/theme/tokens.dart`
|
||||
defines ~65 named surface tokens; widgets consume `ClideTheme.of(context).surface`
|
||||
rather than raw colors. The resolver provides defaults so partial themes still produce
|
||||
a complete `SurfaceTokens`.
|
||||
- **Contrast gate is genuine WCAG math, run per-theme.** `lib/kernel/src/theme/
|
||||
contrast.dart` implements real relative-luminance ratio with alpha pre-compositing
|
||||
against neutral grey (`contrast.dart:31-37`) — semi-transparent tokens can't spuriously
|
||||
pass.
|
||||
- **Semantics are present on composed widgets.** `ClideButton` wraps
|
||||
`Semantics(button: true, enabled:, label:, hint:, onTap:)`; panels set
|
||||
`container: true, explicitChildNodes: true` with landmark labels.
|
||||
- **State coverage exists in data panels.** `git_panel_view.dart:86-104` handles error,
|
||||
loading, and empty ("working tree clean") states distinctly; `file_tree_view.dart`
|
||||
handles error + loading.
|
||||
- **Manual a11y discipline is documented.** `docs/testing/a11y-manual.md` prescribes a
|
||||
per-tier Orca/VoiceOver pass and is honest about why prose quality can't be automated.
|
||||
- **Disabled state is handled at the cursor level.** `clide_button.dart:41` switches to
|
||||
`SystemMouseCursors.forbidden` and drops the semantic `onTap` when `onPressed == null`.
|
||||
|
||||
### Findings
|
||||
|
||||
- **[Critical] `ClideTappable` is mouse-only — no `Focus`, no keyboard activation.**
|
||||
`lib/widgets/src/clide_tappable.dart:37-54` is `MouseRegion` + `GestureDetector` only.
|
||||
It is the base for `ClideButton`, `_WinBtn`, `_RecentProjectRow`, `_ActionRow`, and
|
||||
most builtin list items. None can receive Tab focus or be activated with Enter/Space.
|
||||
The keyboard-traversal test only passes because it manually wraps the button in an
|
||||
external `Focus` node — it tests that the widget doesn't *block* focus, not that it
|
||||
*accepts* it.
|
||||
- **[Critical] Command palette is not keyboard-navigable.** `clide_palette.dart` —
|
||||
`onSubmitted` only ever invokes `filtered.first` (`:77-80`); no up/down handling, no
|
||||
selected index, no selection highlight, no Escape handler.
|
||||
- **[Major] No focus-traversal wiring between panels.** `FocusTracker`
|
||||
(`lib/kernel/src/focus.dart`) tracks an active *contribution id* for the `clide active`
|
||||
CLI, but is not Flutter `FocusScope`/`FocusTraversalGroup` integration. Nothing
|
||||
establishes Tab order across sidebar → workspace → context.
|
||||
- **[Major] Drag-resize handles have no keyboard equivalent — parity gap.**
|
||||
`drag_resize.dart` and `app.dart:870-912` are pure `Listener` pointer handlers, with no
|
||||
Semantics node at all. Per "User/Claude parity", panel sizing should have a CLI
|
||||
affordance; none is evident.
|
||||
- **[Major] Single global `KeyboardListener` is a fragile keybinding architecture.**
|
||||
`app.dart:90-148` routes all shortcuts through one root `KeyboardListener` — no
|
||||
per-context scoping, will conflict with text-input fields.
|
||||
`KeybindingResolver.fromKeyEvent` keys off layout-dependent `logicalKey.keyLabel`.
|
||||
- **[Major] Text scale is the *only* in-app a11y accommodation, and it's hidden.**
|
||||
`app.dart:122-138` implements Ctrl +/-/0 text scaling but it's undiscoverable. No
|
||||
high-contrast toggle, no reduced-motion handling, no focus-ring rendering anywhere.
|
||||
- **[Minor] Contrast gate covers only 11 token pairs** — omits `globalTextMuted` (muted
|
||||
text is everywhere), the `status*` foregrounds, syntax tokens on `panelBackground`, and
|
||||
`panelActiveBorder`.
|
||||
- **[Minor] ~43 hardcoded-color sites bypass the token system** — some defensible (ANSI
|
||||
palette), but the modal/palette shadow and window-control colors won't adapt to the
|
||||
`paper` light theme.
|
||||
- **[Minor] Hover state is inconsistent and not paired with focus** — every interactive
|
||||
widget reimplements its own `_hover` bool; none render a focus indicator.
|
||||
- **[Minor] `_LeftHatContent` is dead code** — `app.dart:281-292` always returns
|
||||
`SizedBox.shrink()`.
|
||||
|
||||
### Recommendations
|
||||
|
||||
**Quick wins:** add a `Focus` + `Actions`/`Shortcuts` (Enter/Space → onTap) wrapper and
|
||||
a focus-ring inside `ClideTappable` — fixes the [Critical] for every button/list-item at
|
||||
once; add arrow-key + Escape + selected-index to `ClidePalette` (copy the existing
|
||||
`_ProjectSwitcherDropdown` `onKeyEvent` pattern at `app.dart:446-452`); expand
|
||||
`canonicalPairs`; surface text-zoom and theme switching in the palette; tokenize the
|
||||
modal shadow and window-control colors.
|
||||
|
||||
**Larger efforts:** establish a real focus-traversal model and integrate `FocusTracker`
|
||||
with Flutter's focus system; replace the root `KeyboardListener` with scoped
|
||||
`Shortcuts`/`Actions` and move off `keyLabel`; add keyboard operability + Semantics to
|
||||
drag-resize handles plus a `clide panel resize` CLI; add an a11y test tier that asserts
|
||||
*operability*, not just Semantics presence.
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Dimension | Score | Justification |
|
||||
|---|---|---|
|
||||
| Interaction model | 2/5 | Coherent slot/panel structure and good drag-resize *with a mouse*, but keyboard operability is largely unbuilt. |
|
||||
| Accessibility | 3/5 | Genuine contrast gate, Semantics on composed widgets, i18n contract tests — but keyboard operability and focus order are not implemented. |
|
||||
| Visual consistency | 4/5 | Strong semantic token system consumed consistently; a few hardcoded-color sites are real theme-adaptation bugs. |
|
||||
| Discoverability | 2/5 | Command palette isn't keyboard-navigable; accommodations are undiscoverable; no in-app keybinding reference. |
|
||||
| State coverage | 3/5 | Data panels and dialogs handle loading/error/empty; but no focus states anywhere and no reduced-motion handling. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Code quality & craft — Senior Dart/Flutter Engineer
|
||||
|
||||
### Executive summary
|
||||
|
||||
clide is, for a solo pre-v2.0 project, in genuinely good shape. The core subsystems (IPC
|
||||
envelope, daemon dispatch, git client, PTY) are small, single-responsibility,
|
||||
well-typed, and consistent. `flutter analyze` is clean for `lib/` — the 9 reported issues
|
||||
are all in `test/`, none are suppressions. The biggest strength is the FFI/PTY layer:
|
||||
`lib/src/pty/native_pty.dart` shows real systems-programming discipline (pre-fork
|
||||
allocation, errno captured before `free`, isolate-teardown ordering documented and
|
||||
correct). The biggest risk is concentrated in two places: a genuine isolate-leak race in
|
||||
`SchedulerService`, and the large vendored-but-owned `lib/src/terminal/` xterm.dart fork
|
||||
(~7k LOC) which carries a different style, commented-out `print`s, and dangling TODOs
|
||||
that the project's own "lib is owned, not vendored" rule says must be held to the same
|
||||
bar.
|
||||
|
||||
### Strengths
|
||||
|
||||
- **PTY/FFI layer is excellent.** `native_pty.dart:129-145` force-resolves FFI
|
||||
trampolines and pre-allocates *all* native memory before `forkpty()`.
|
||||
`native_pty.dart:171-177` captures `errno` before `_freeAll` because `free()` can
|
||||
clobber it. `close()` (367-397) documents and implements the kill→EOF→close ordering
|
||||
to avoid fd-reuse races. The child branch touches no Dart heap.
|
||||
- **IPC envelope is clean and idiomatic** — `lib/src/ipc/envelope.dart` uses a `sealed`
|
||||
class hierarchy, named constructors, a private unifying constructor, and conditional
|
||||
map keys. Decode is total over the type discriminant.
|
||||
- **Typed, meaningful errors.** `PtyException` carries `op` + optional `errno`;
|
||||
`GitException` carries `stderr`; `errnoToIpcError` maps POSIX errno to actionable IPC
|
||||
error kinds. Errors are values, not strings.
|
||||
- **Resource lifecycle is taken seriously across most subsystems.** `FileWatcher.stop()`
|
||||
cancels the subscription *and* closes the controller; `withBuffer`/`setWinsize` in
|
||||
`libc.dart` use `try/finally` around every native allocation. 45 files define
|
||||
`dispose`/`close`.
|
||||
- **The `DaemonEventSink` interface** keeps the dependency graph pointing the right way
|
||||
(server→subsystems) and is documented as such.
|
||||
- **The one `ignore_for_file` (`libc.dart:11-27`) is exemplary** — textbook FFI case,
|
||||
multi-paragraph justification exactly as CLAUDE.md requires.
|
||||
|
||||
### Findings
|
||||
|
||||
- **[Major] Isolate-leak race in `SchedulerService._startTicker`** — `scheduler.dart:71`:
|
||||
`Isolate.spawn(...).then((iso) => _isolate = iso)`. If `_stopTicker()` runs before the
|
||||
spawn future completes, `_isolate` is still null, nothing is killed, and the
|
||||
just-spawned isolate (with its `Timer.periodic`) leaks. `native_pty.dart` solved
|
||||
exactly this with `_readerReady`.
|
||||
- **[Major] `lib/src/terminal/` held below the project's own bar.** Carries
|
||||
commented-out `print()` debugging (`custom_text_edit.dart:244-275`), dangling TODOs
|
||||
(`parser.dart:110-113`, `keytab.dart:91`), a 1137-line `parser.dart`, and the only
|
||||
`// ignore: invalid_use_of_protected_member` in the repo (`terminal_view.dart:363`).
|
||||
Either it's genuinely vendored (belongs in `native/` or documented as frozen) or it's
|
||||
owned (needs the cleanup pass).
|
||||
- **[Minor] Empty `catch (_) {}` swallows in `tree_sitter_ffi.dart:197,206`** —
|
||||
`DynamicLibrary.open` failures silently discarded; caller gets a bare `null` with no
|
||||
diagnostic about *why*. Syntax highlighting silently not working is a support
|
||||
headache.
|
||||
- **[Minor] Empty `catch (_) {}` in `test_app.dart:271,311`** — `:271` swallows a
|
||||
theme-load failure the harness exists to detect.
|
||||
- **[Minor] Dead alias in `libc.dart:201-202`** — `typedef Cmsghdr = CmsghdrLinux;`
|
||||
flagged "backward compatibility"; CLAUDE.md forbids backwards-compat hacks in a solo
|
||||
repo.
|
||||
- **[Minor] `// ignore: unused_field` in `editor_controller.dart:25`** "kept for future
|
||||
subscription changes" — speculative retention; the no-suppression rule wants it fixed,
|
||||
not silenced.
|
||||
- **[Minor] Magic numbers in hot FFI paths.** `native_pty.dart` inlines `0x0001
|
||||
// POLLIN`, `28 /* SIGWINCH */`, `4 /* EINTR */`, `9 /* EBADF */` — but `libc.dart`
|
||||
already has a constants section and `errno_mapping.dart` has `PosixErrno.ebadf`.
|
||||
- **[Minor] `git_commands.dart` has ~16 near-identical handler bodies** — a
|
||||
`_guarded(req, () async {...})` helper would remove ~60 lines of structural
|
||||
duplication. Borderline.
|
||||
|
||||
### Recommendations
|
||||
|
||||
**Quick wins:** fix the `SchedulerService` spawn race (track the spawn future like
|
||||
`NativePty._readerReady`); replace the three silent `catch (_)` in `tree_sitter_ffi.dart`
|
||||
with a logged last-error; delete the `Cmsghdr` alias and the `unused_field` suppression;
|
||||
have the PTY layer consume `libc.dart` constants / `PosixErrno` instead of inline hex.
|
||||
|
||||
**Larger efforts:** decide the status of `lib/src/terminal/` — formally vendor it
|
||||
(freeze, document, decision-record) or do the cleanup sweep; optionally a `_guarded`
|
||||
helper for `git_commands.dart` (check whether `files_commands` / `editor_commands` share
|
||||
the shape).
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Dimension | Score | Justification |
|
||||
|---|---|---|
|
||||
| Idiomatic Dart | 4/5 | Sealed classes, named ctors, records, `const`, immutability used well; the vendored terminal tree pulls the average down. |
|
||||
| Error handling | 4/5 | Typed errors with context everywhere in core; a few silent `catch (_)` in the FFI loader and test harness cost the 5th point. |
|
||||
| Naming & readability | 4/5 | Clear, intention-revealing names; comments earn their place; inline magic numbers are the main blemish. |
|
||||
| Consistency across subsystems | 3/5 | IPC/git/files/pty are uniform; `lib/src/terminal/` is a different codebase in style; PTY duplicates constants `libc.dart` owns. |
|
||||
| Resource/lifecycle safety | 4/5 | `try/finally` around native allocs, controllers closed, subscriptions cancelled; the one real defect is the `SchedulerService` race. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Security & supply chain — Security Engineer
|
||||
|
||||
### Executive summary
|
||||
|
||||
clide's security posture is **above average for a solo pre-v2.0 project**. All
|
||||
subprocess calls use `Process.run`/`Process.start` with argument *lists* (no shell
|
||||
interpolation), the IPC transport is a per-user Unix socket (not a TCP port), and there
|
||||
is an explicit `path_safety` module with a containment check. The single biggest strength
|
||||
is the disciplined no-shell subprocess layer. The single biggest risk is
|
||||
**untrusted-workspace code execution via toolchain resolution**
|
||||
(`toolchain_paths.dart:79`): a malicious repo can ship a `native/dugite/bin/git`
|
||||
executable that clide will resolve and run. Secondary real issues: path-safety does not
|
||||
defend against symlink escape, and IPC command args are largely unvalidated/un-bounded.
|
||||
Supply-chain hygiene is mostly good but `licenses.yaml` has drifted from `pubspec.yaml`
|
||||
and native binaries are committed without SHA pinning.
|
||||
|
||||
### Strengths
|
||||
|
||||
- **No-shell subprocess execution.** `GitClient._run` (`client.dart:210`),
|
||||
`PqlClient._run` (`client.dart:165`), and the PTY layer all pass `List<String>` args
|
||||
directly. Classic command injection is structurally prevented.
|
||||
- **Toolchain uses resolved absolute paths** — git/pql/tmux resolved once to absolute
|
||||
paths and reused.
|
||||
- **Path containment check exists and is used.** `resolveUnderRoot`
|
||||
(`path_safety.dart:21`) collapses `..`/`.` without touching the filesystem and enforces
|
||||
a prefix check with a separator guard. `files.read`/`files.ls` both call it.
|
||||
- **IPC is a per-user Unix socket, not a network listener.** No `ServerSocket` over TCP
|
||||
anywhere; the default runtime path is in-process, eliminating the socket attack
|
||||
surface in the shipped app.
|
||||
- **PTY FFI memory discipline** — all native memory allocated before `forkpty()`, `errno`
|
||||
captured before `free()`, freed on every path.
|
||||
- **`pubspec.lock` is committed**, deps use exact pins (no carets), `licenses.yaml`
|
||||
exists with per-dep purpose/license.
|
||||
|
||||
### Findings
|
||||
|
||||
- **[Critical] Malicious workspace can plant a git binary that clide executes.**
|
||||
`toolchain_paths.dart:79-84` builds `'$workspaceRoot/native/dugite/bin'` and runs
|
||||
`_firstExisting(['$dugite/git'])`; if that file exists it becomes the git binary for
|
||||
all `GitClient` calls, **before** falling back to PATH. An attacker commits an
|
||||
executable at `native/dugite/bin/git`; clide runs it on the first `git.status` (which
|
||||
fires automatically on workspace open). Arbitrary code execution from merely opening a
|
||||
repo. The `native/dugite` convention should resolve relative to the *clide install
|
||||
dir*, never the workspace root.
|
||||
- **[Major] Path-safety does not defend against symlink escape.** `path_safety.dart:
|
||||
35-51` explicitly does not resolve symlinks, and the filesystem layer
|
||||
(`files_commands.dart:81-85`) never does either. A repo symlink `config -> /etc/shadow`
|
||||
passes the containment check (the *link path* is under root) and clide reads the
|
||||
target. Fix: after `resolveUnderRoot`, `resolveSymbolicLinksSync()` and re-verify
|
||||
containment.
|
||||
- **[Major] IPC command arguments are unvalidated and unbounded.**
|
||||
`DaemonDispatcher.dispatch` (`dispatcher.dart:26`) and `IpcRequest.fromJson`
|
||||
(`envelope.dart:49`) do no schema validation. No size limit on `files.read`, no count
|
||||
cap on `git.log`, no check that `git.checkout`'s `branch` (`git_commands.dart:240`)
|
||||
isn't a `-`-prefixed flag. `git diff`/`stage` use `--` separators (good), but
|
||||
`checkout(branch)` and `push(remote, branch)` do not — argument injection
|
||||
(`git checkout --upload-pack=...`) is possible.
|
||||
- **[Minor] macOS entitlements disable library validation.**
|
||||
`macos/Runner/Release.entitlements` sets `disable-library-validation` = true with no
|
||||
App Sandbox entitlement. Arguably needed for the `dlopen` of `libtree-sitter.so`, but
|
||||
combined with no sandbox a compromised process has full user-level filesystem access.
|
||||
- **[Minor] `licenses.yaml` has drifted from `pubspec.yaml`.** Lists dev-dep `test` at
|
||||
`1.25.8` but `pubspec.yaml:60` pins `1.30.0`; lists a `lints 5.0.0` not in
|
||||
`pubspec.yaml` at all. The two-step-commit guardrail is being violated.
|
||||
- **[Minor] Native binaries committed without SHA pinning.** `native/linux-x64/` has
|
||||
`libtree-sitter.so` (24 MB) and `ptyc` (22 KB) committed with no `SHA256SUMS` manifest.
|
||||
CLAUDE.md says native deps are "pinned by SHA"; that pinning is not evidenced.
|
||||
- **[Informational] No secrets service** — clide stores no tokens; git auth is delegated
|
||||
to the system credential helper. The right call; noted so the absence reads as
|
||||
deliberate.
|
||||
- **[Informational] Lua runtime is a stub** — `lib/lua/src/host.dart` is Tier-0. Design
|
||||
intent (strip `io`/`os.execute`/`package.loadlib`/`debug`) is sound; re-assess at Tier
|
||||
6 — sandbox-escape via FFI re-entry will be the concern.
|
||||
|
||||
### Recommendations
|
||||
|
||||
**Quick wins:** fix toolchain resolution to resolve `native/dugite` against
|
||||
`Platform.resolvedExecutable`'s directory, never `workspaceRoot` (closes the Critical);
|
||||
add symlink re-check in `files.read`/`files.ls`; reconcile `licenses.yaml` with
|
||||
`pubspec.yaml`; reject `-`-prefixed values for `branch`/`remote`/`path` args (or use
|
||||
`--` everywhere, including `checkout`).
|
||||
|
||||
**Larger efforts:** schema-validate the IPC surface with typed arg schemas + size/count
|
||||
bounds; add a committed `native/SHA256SUMS` verified by `make` and CI; revisit macOS
|
||||
sandboxing (App Sandbox with explicit exceptions); security-review the Lua FFI boundary
|
||||
and capability table before Tier 6 ships.
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Area | Rating | Justification |
|
||||
|---|---|---|
|
||||
| Subprocess safety | 2/5 | No-shell arg lists are excellent, but the workspace-relative dugite path is a real RCE; argument-injection on `checkout`/`push` unmitigated. |
|
||||
| IPC input validation | 3/5 | Per-user Unix socket + in-process default sharply limits exposure, but zero arg-schema validation and no size/count bounds. |
|
||||
| Path/filesystem safety | 3/5 | Real containment check that's actually wired in, undermined by the unhandled symlink-escape gap. |
|
||||
| Dependency/supply-chain hygiene | 3/5 | Exact pins, committed lockfile, documented deps — but `licenses.yaml` drift and missing SHA manifest for committed native binaries. |
|
||||
| Secrets & sandboxing | 3/5 | Correctly delegates secrets; Lua sandbox is only a stub; macOS runs with library validation off and no App Sandbox. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Docs, governance & DX — TPM / Developer-Experience Consultant
|
||||
|
||||
### Executive summary
|
||||
|
||||
clide runs an unusually disciplined governance system for a solo-dev pre-v2.0 project:
|
||||
67 decision records across six domains, with a parser-validated DQR structure, anchored
|
||||
cross-references, and a `make decisions-validate` gate wired into pre-push. The biggest
|
||||
strength is that the DQR system is genuinely *alive* — questions get resolved with dated
|
||||
amendments, superseded decisions are marked, and decisions cite the commits that
|
||||
implement them. The biggest risk is **documentation drift in the narrative docs**:
|
||||
`README.md` and `docs/initial-plan.md` describe an architecture (Go sidecar, `ptyc/` C
|
||||
helper, `app/` subdirectory, separate daemon) that three major decisions (D-5, D-56, the
|
||||
FFI pivot) have since dissolved. A new contributor reading the README first would build
|
||||
a wrong mental model.
|
||||
|
||||
### Strengths
|
||||
|
||||
- **DQR system is maintained, not ornamental.** Resolved questions carry dated
|
||||
resolution lines pointing to the deciding D-record (`questions/architecture.md:39`
|
||||
Q-6→D-57). D-40 carries a `[SUPERSEDED]` tag and an amendment line.
|
||||
- **Decisions are linked to code and commits.** D-67 (`decisions/process.md:61`) cites
|
||||
implementing commits `01a99ed`, `d162ba2`. D-66 references `ci/test.sh` by path.
|
||||
- **Governance migration was done cleanly** — the `decisions/` → `governance/`
|
||||
restructure updated cross-references and the auto-generated index.
|
||||
- **Commit discipline is real.** `git log` shows imperative subjects, no Conventional
|
||||
Commits prefixes, ticket refs, logical scoping — exactly what `git-commit/SKILL.md`
|
||||
prescribes.
|
||||
- **`licenses.yaml` is thorough** — all six runtime Dart deps present, plus fonts/native
|
||||
libs, with purpose justifications. *(Note: the Security reviewer found version drift
|
||||
in this file — see Finding above; the two reviewers examined different rows.)*
|
||||
- **Makefile is self-documenting** (`##` help annotations) and matches `CLAUDE.md`.
|
||||
|
||||
### Findings
|
||||
|
||||
- **[Critical] `docs/initial-plan.md` is badly stale.** The "north-star" doc (linked
|
||||
from `CLAUDE.md:14` and `README.md:44`) still describes a Go sidecar
|
||||
(`initial-plan.md:4,55,189`), `clide --daemon` long-running process (`:162-164`),
|
||||
`app/` subdirectory layout (`:184-204`), and `project.yaml` (`:172`) — all contradicted
|
||||
by D-5, D-56, and the single-package-at-root reality. Nothing flags it as historical.
|
||||
- **[Critical] `README.md` describes a dissolved architecture.** `README.md:10`
|
||||
documents `ptyc/` as a live component; `README.md:36` lists `make ptyc-build`. The
|
||||
`ptyc/` directory does not exist, the Makefile has no such target, and the CHANGELOG's
|
||||
own Unreleased section records ptyc's removal.
|
||||
- **[Major] `README.md:44` links to `decisions/`** — a directory that no longer exists
|
||||
(migrated to `governance/`). Dead link in the primary onboarding doc.
|
||||
- **[Major] CHANGELOG has duplicate subsection headings in `[Unreleased]`.** Three
|
||||
`### Changed` blocks (`CHANGELOG.md:100, 114, 168`), two `### Fixed`, two `### Removed`
|
||||
in the 2.0.0 section. Keep a Changelog 1.1.0 expects one of each per release.
|
||||
- **[Major] No `CONTRIBUTING.md` or onboarding doc.** For a project "intended to ship
|
||||
publicly to other developers," there is no contributor guide; the build/test story is
|
||||
scattered across `CLAUDE.md` (Claude-oriented), `README.md` (partly wrong), and
|
||||
Makefile help.
|
||||
- **[Major] Coverage-floor governance contradicts itself.** D-66 (`testing.md:65`) says
|
||||
the floor lives at `coverage/floor.txt` starting "≈35%"; `CHANGELOG.md:44-46` says it's
|
||||
in `pubspec.yaml` `coverage_floor:` starting at 34%; the latest commit is `9030e56
|
||||
hold coverage_floor fixed at 90`. Three sources, three mechanisms/values. D-66 was
|
||||
never amended.
|
||||
- **[Minor] `ci/release.sh` is a stub that still references goreleaser/sidecar** — Go
|
||||
tooling for a project with no Go.
|
||||
- **[Minor] CHANGELOG `[2.0.0] — 2026-05-03` dating** — the v2.0.0 tag is dated
|
||||
2026-05-03 but the enormous Unreleased section represents ~80 commits of post-tag work
|
||||
with no interim version.
|
||||
- **[Minor] Stale-ish open questions** — Q-25 (body text face) is de facto resolved by
|
||||
D-43/D-44 and the shipped impl; Q-1/Q-2/Q-3 ("defer until Tier 1 is in real use") are
|
||||
due for triage now that Tier 1 has shipped.
|
||||
- **[Minor] Skills system is coherent but undocumented as a set** — eight skills under
|
||||
`.claude/skills/`, no index.
|
||||
|
||||
### Recommendations
|
||||
|
||||
**Quick wins:** rewrite `README.md`'s `ptyc/` sections and fix the `decisions/` link;
|
||||
banner `docs/initial-plan.md` as historical (or split out a current
|
||||
`docs/architecture.md`); merge the duplicate changelog subsection headings; amend D-66 to
|
||||
reflect the floor's actual location/mechanism/value with a dated amendment line; triage
|
||||
Q-1/2/3/25.
|
||||
|
||||
**Larger efforts:** write a human-facing `CONTRIBUTING.md` (clone →
|
||||
`make hooks && flutter pub get` → `make test` → DQR workflow → commit conventions); cut
|
||||
an interim release to drain the ~80-commit Unreleased backlog; add a
|
||||
`.claude/skills/README.md` inventory; establish a periodic governance sweep (the repo
|
||||
even has a `clean-house` skill for exactly this).
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Area | Score | Justification |
|
||||
|---|---|---|
|
||||
| Governance discipline | 4/5 | DQR system genuinely maintained — but D-66 drift and untriaged Tier-1-era questions show the sweep cadence lags the code. |
|
||||
| Documentation accuracy | 2/5 | Both primary onboarding docs describe a dissolved Go-sidecar/ptyc/daemon architecture; `CLAUDE.md` is accurate by contrast. |
|
||||
| Changelog hygiene | 3/5 | Per-commit discipline is followed, but duplicate subsection headings violate the standard and an 80-commit Unreleased backlog undermines the format. |
|
||||
| Contributor onboarding | 2/5 | No `CONTRIBUTING.md`; build story split across three docs, one wrong; `CLAUDE.md` is Claude-addressed, not human-addressed. |
|
||||
| Convention adherence | 4/5 | Commit style, DQR claiming, `licenses.yaml` two-step rule demonstrably followed; docked for the changelog defects and the README gap. |
|
||||
|
||||
---
|
||||
|
||||
## Closing note
|
||||
|
||||
The recurring pattern across all six reviews: **clide's foundations are excellent and
|
||||
its finishing is incomplete.** The extension contract, test helpers, FFI discipline,
|
||||
governance system, and token system are all things most projects never get right. The
|
||||
gaps — IPC not wired, keyboard not operable, docs describing a dead architecture, a
|
||||
workspace-relative binary path — are all the kind of thing that happens when a fast-moving
|
||||
solo project's implementation outruns its connective tissue. They are concentrated, not
|
||||
diffuse, and the quick-win column above would close most of the critical ones in a few
|
||||
focused days.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Declares tags used by inline `test('...', tags: [...], ...)` calls so
|
||||
# that `flutter test --exclude-tags <name>` and `dart test --tags <name>`
|
||||
# both honor them. Undeclared tags are ignored by the runners, which
|
||||
# silently breaks selective excludes.
|
||||
tags:
|
||||
# Tests that call `forkpty()` via Dart FFI. Must run under `dart test`,
|
||||
# not `flutter test` — the latter's runner hosts a multi-threaded
|
||||
# Flutter engine in which forkpty produces a master fd that never
|
||||
# delivers output. See `test/pty/session_test.dart`.
|
||||
forkpty:
|
||||
@@ -0,0 +1,129 @@
|
||||
# clide — Architecture
|
||||
|
||||
Current as of 2026-05-17. Tracks the code on `main`. For the original
|
||||
design plan (much of it now superseded), see
|
||||
[`docs/initial-plan.md`](initial-plan.md). For decisions, see
|
||||
[`governance/decisions/`](../governance/decisions/).
|
||||
|
||||
## Shape
|
||||
|
||||
clide is a **single Flutter package at the repo root**. There is no
|
||||
sidecar process, no separate daemon, no Go binary. One `flutter run`
|
||||
boots the whole IDE.
|
||||
|
||||
```
|
||||
flutter desktop app (lib/main.dart)
|
||||
├── kernel services (lib/kernel/) — theme, i18n, settings, panels,
|
||||
│ commands, focus, scheduler, …
|
||||
├── core subsystems (lib/src/) — ipc, daemon dispatch, panes,
|
||||
│ │ pty, editor, files, git, pql
|
||||
│ └── PTY via Dart FFI posix_openpt + posix_spawn (T-96)
|
||||
├── widget primitives (lib/widgets/) — ClideButton, ClideText, … no
|
||||
│ Material/Cupertino
|
||||
├── built-in extensions (lib/builtin/) — claude, editor, files, git, terminal,
|
||||
│ welcome, … each a ClideExtension
|
||||
└── extension framework (lib/extension/) — contract + dependency-gated activation
|
||||
```
|
||||
|
||||
### Process model
|
||||
|
||||
One OS process. The Flutter app hosts:
|
||||
|
||||
- the IPC dispatcher (`DaemonDispatcher` in `lib/src/daemon/`),
|
||||
- every subsystem handler (pane/files/editor/git/pql),
|
||||
- the extension manager and all built-in extensions.
|
||||
|
||||
`tmux` is the only external long-lived process — it owns Claude
|
||||
session persistence so panes survive app restarts (D-41). The app
|
||||
re-attaches via `tmux new-session -A` on boot.
|
||||
|
||||
PTYs are spawned natively from Dart. `lib/src/pty/native_pty.dart`
|
||||
calls `posix_openpt()` + `posix_spawn()` via FFI; the child inherits
|
||||
the slave PTY as stdin/stdout/stderr. No C helper binary.
|
||||
|
||||
### Native dependencies
|
||||
|
||||
Vendored under [`native/`](../native/) with per-platform subdirectories
|
||||
(`linux-x64/` today). Currently:
|
||||
|
||||
- `libtree-sitter.so` — wasmtime-embedded tree-sitter for syntax
|
||||
highlighting. Loaded via `dart:ffi`. See
|
||||
`lib/kernel/src/syntax/tree_sitter_ffi.dart`.
|
||||
|
||||
Each entry is listed in [`assets/licenses.yaml`](../assets/licenses.yaml)
|
||||
with version + SHA expectation (D-42).
|
||||
|
||||
### External tools
|
||||
|
||||
clide shells out to two binaries at runtime:
|
||||
|
||||
- **`git`** — vendored as `dugite-native` if present at the install
|
||||
directory; otherwise `PATH` `git`. Resolution happens at
|
||||
`lib/kernel/src/toolchain_paths.dart`. **Never resolves against the
|
||||
open workspace** (T-98).
|
||||
- **`pql`** — the [pql](https://github.com/postmeridiem/pql) project
|
||||
query language; supporter tool, wrapped in `lib/src/pql/`. Clide
|
||||
never re-implements pql features (D-3).
|
||||
|
||||
## Surfaces
|
||||
|
||||
### Claude-facing — `clide` CLI
|
||||
|
||||
Per D-1 and D-6, Claude talks to clide exclusively via Bash. Every
|
||||
state-changing command emits one or more events on a long-lived
|
||||
event stream; every UI affordance has a matching CLI verb. See D-6
|
||||
for the subsystem/verb/event contract.
|
||||
|
||||
> **Caveat (2026-05):** the Unix-socket server that exposes the
|
||||
> dispatcher to a thin `clide` C client is currently unimplemented.
|
||||
> Today's working path is in-process direct dispatch. See **T-99**
|
||||
> (IPC server implementation) and **D-68** (dual integration surface
|
||||
> — Bash CLI primary, MCP secondary).
|
||||
|
||||
### User-facing — Flutter desktop
|
||||
|
||||
Three-column layout (sidebar / workspace / context) with collapsible
|
||||
panels, a custom title bar, and per-panel "hats" for branding +
|
||||
window controls. The interaction model is documented in D-47 and
|
||||
neighboring decisions.
|
||||
|
||||
## Subsystems at a glance
|
||||
|
||||
| Subsystem | Location | Owns |
|
||||
|---|---|---|
|
||||
| IPC envelope + dispatcher | `lib/src/ipc/`, `lib/src/daemon/` | request/response framing, command registration, broadcast events |
|
||||
| Pane registry | `lib/src/panes/` | spawn/list/write/resize/close, event emission |
|
||||
| PTY | `lib/src/pty/` | `posix_openpt` + `posix_spawn`, reader isolate, signal forwarding |
|
||||
| Editor | `lib/src/editor/` | buffer registry, open/save/setContent |
|
||||
| Files | `lib/src/files/` | ls / read / watch, path-safety containment check |
|
||||
| Git | `lib/src/git/` | client (no shell), status/diff/operations parsing |
|
||||
| Pql wrapper | `lib/src/pql/` | shell-out only; never re-implements pql |
|
||||
| Kernel services | `lib/kernel/src/` | theme, i18n, settings, panels, commands, focus, syntax |
|
||||
| Extensions | `lib/extension/`, `lib/builtin/` | dependency-gated activation, contribution points |
|
||||
|
||||
## Build + test
|
||||
|
||||
```
|
||||
make hooks && flutter pub get # one-time setup
|
||||
make run # launch app
|
||||
make test # analyze + format + unit + widget + golden
|
||||
make test-core # IPC / PTY / git / pane registry
|
||||
make test-a11y # accessibility contract
|
||||
make test-integration # real-app boot tests
|
||||
make push-check # the full pre-push gate
|
||||
```
|
||||
|
||||
The pre-push gate enforces a 95% line-coverage floor (D-66), a
|
||||
40-word soft / 60-word hard CHANGELOG bullet cap, all unit/widget
|
||||
tests, and the a11y contract.
|
||||
|
||||
## Governance
|
||||
|
||||
All architectural choices live in
|
||||
[`governance/decisions/<domain>.md`](../governance/decisions/) as
|
||||
`D-NNN` records, open questions as `Q-NNN`, rejected alternatives as
|
||||
`R-NNN`. Claim new IDs via `pql decisions claim D <domain> "title"`.
|
||||
The governance index lists everything: [`governance/README.md`](../governance/README.md).
|
||||
|
||||
The pql ticket backlog tracks in-flight work; `pql ticket board
|
||||
--pretty` for the live view.
|
||||
@@ -1,5 +1,24 @@
|
||||
# Clide — Initial Plan (Flutter rebuild)
|
||||
|
||||
> **⚠ HISTORICAL — preserved as a snapshot of the 2026-04 plan.**
|
||||
>
|
||||
> Several load-bearing choices in this document have since been
|
||||
> superseded by formal decisions:
|
||||
>
|
||||
> - **No Go sidecar.** Dart is the sole core language (D-5).
|
||||
> - **No separate daemon process.** The Flutter app hosts the IPC
|
||||
> server in-process (D-56).
|
||||
> - **No `ptyc/` C helper.** PTY spawning uses Dart FFI
|
||||
> `posix_openpt()` + `posix_spawn()` (D-5 amendments, T-96).
|
||||
> - **Single package at the repo root**, not `app/` + `lib/` + `bin/`.
|
||||
> - **No `project.yaml`.** Project metadata lives in `pubspec.yaml`.
|
||||
>
|
||||
> For the current architecture, see [`docs/architecture.md`](architecture.md).
|
||||
> For the decision trail, see [`governance/decisions/`](../governance/decisions/).
|
||||
> The text below is left intact for anyone tracing the design history.
|
||||
|
||||
---
|
||||
|
||||
**Working name:** clide (unchanged from the Python era). Repo root:
|
||||
`/var/mnt/data/projects/clide`. Flutter desktop app + Go sidecar/CLI.
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# macOS PTY Problem — Diagnosis Complete
|
||||
|
||||
> **⚠ HISTORICAL — `ptyc` is retired.** PTY spawning moved to
|
||||
> Dart FFI `posix_openpt()` + `posix_spawn()` (D-5 amendments,
|
||||
> T-96). The race described below was specific to the old C helper's
|
||||
> SCM_RIGHTS hand-off and no longer applies.
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# PTY on macOS: A New Diagnostic and Resolution Plan
|
||||
|
||||
> **⚠ HISTORICAL — `ptyc` is retired.** PTY spawning moved to
|
||||
> Dart FFI `posix_openpt()` + `posix_spawn()` (D-5 amendments,
|
||||
> T-96). This document captures the forensic investigation of the
|
||||
> SCM_RIGHTS control-message mismatch in the old C helper. Kept for
|
||||
> the diagnostic technique; the code it discusses no longer exists.
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -109,6 +109,8 @@ You might also want, project-permitting:
|
||||
- [D-65: License compatibility matrix](decisions/tooling.md#d-65-license-compatibility-matrix) — _tooling_
|
||||
- [D-66: Line coverage gate at 95%, ratcheted from current](decisions/testing.md#d-66-line-coverage-gate-at-95-ratcheted-from-current) — _testing_
|
||||
- [D-67: Pql changelog files are committed alongside code](decisions/process.md#d-67-pql-changelog-files-are-committed-alongside-code) — _process_
|
||||
- [D-68: Dual integration surface — Bash CLI primary, MCP secondary](decisions/architecture.md#d-68-dual-integration-surface--bash-cli-primary-mcp-secondary) — _architecture_
|
||||
- [D-69: published themes are user contracts; ship -hc variants for a11y](decisions/accessibility.md#d-69-published-themes-are-user-contracts-ship--hc-variants-for-a11y) — _accessibility_
|
||||
|
||||
## Open questions
|
||||
|
||||
@@ -142,6 +144,8 @@ You might also want, project-permitting:
|
||||
- [Q-29: Branch picker location](questions/architecture.md#q-29-branch-picker-location) — _architecture_
|
||||
- [Q-30: Focus behavior when editor is dirty and viewer is peeked](questions/architecture.md#q-30-focus-behavior-when-editor-is-dirty-and-viewer-is-peeked) — _architecture_
|
||||
- [Q-31: XWayland fallback for frameless — proper Wayland protocol needed](questions/architecture.md#q-31-xwayland-fallback-for-frameless--proper-wayland-protocol-needed) — _architecture_
|
||||
- [Q-32: MCP tool surface — minimum slash-ide or extended clide tools?](questions/architecture.md#q-32-mcp-tool-surface--minimum-slash-ide-or-extended-clide-tools) — _architecture_
|
||||
- [Q-33: MCP transport — SSE, WebSocket, stdio, or all?](questions/architecture.md#q-33-mcp-transport--sse-websocket-stdio-or-all) — _architecture_
|
||||
|
||||
## Rejected
|
||||
|
||||
|
||||
@@ -23,6 +23,13 @@ A11y + i18n are Tier-0 contracts, not Tier-6 polish.
|
||||
- **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.
|
||||
- **Cost:** Third-party themes (Tier 6) won't be gated until an extension-time test hook lands. Bundled themes are gated today.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
- **Raised by:** 2026-04-21 planning. Refined by [D-69](#d-69-published-themes-are-user-contracts-ship-hc-variants-for-a11y) — the gate's *strict* pair set only applies to high-contrast variants; named themes keep their published palettes.
|
||||
|
||||
### D-69: published themes are user contracts; ship -hc variants for a11y
|
||||
- **Date:** 2026-05-17
|
||||
- **Decision:** The four bundled themes that ship under a recognisable name — `clide`, `midnight`, `paper`, `terminal` — are user contracts. Their palette colours (including syntax tokens, status colours, and borderHi) MUST NOT be retuned to satisfy contrast gates. When a stricter contrast check would fail one of them, the fix is one of: (a) ship a sibling theme with `-hc` (high-contrast) or `-cb` (colour-blind) in the name and enforce the strict pair set only there, or (b) split `canonicalPairs` into a *baseline* set every theme must pass and an *extended* set that only the `-hc`/`-cb` variants must pass.
|
||||
- **Rationale:** Users pick `midnight` because it looks like VS Code, `paper` because it reads as a drafting sheet, `terminal` because of the amber-on-near-black tmux feel. Quietly darkening `paper`'s success/warning/info or boosting `midnight`'s `borderHi` to pass a WCAG-AA check changes what they got and what they signed up for. A11y is a Tier-0 contract ([D-20](#d-20-a11y-is-a-tier-0-contract)), but it's served by *offering* an accessible variant, not by overwriting the aesthetic ones. VS Code itself ships `Default Dark+` and a separate `Default High Contrast` for exactly this reason.
|
||||
- **Cost:** Two extra theme files per "named" theme when we add a11y variants. The bundled-theme contrast gate ([D-22](#d-22-wcag-aa-contrast-gate-on-bundled-themes)) needs a baseline/extended split so the named themes don't fail the strict pairs.
|
||||
- **Raised by:** 2026-05-17 — user intervened mid-T-114 when I had retuned `clide`/`midnight`/`paper`/`terminal` palette entries to satisfy the expanded `canonicalPairs`; reverted, decision written, T-114 will follow this rule.
|
||||
|
||||
---
|
||||
|
||||
@@ -73,6 +73,7 @@ Core, rendering, IPC, kernel, panel manager.
|
||||
|
||||
### D-1: CLI-first, not MCP
|
||||
- **Date:** 2026-04-20 (was ADR 0001; ported from the claudian lineage)
|
||||
- **Amendment (2026-05-15):** D-1's intent — the CLI is the *primary* agent-facing surface, with the same contract as pql — stands. An additional `/ide`-compatible MCP surface is added per [D-68](#d-68-dual-integration-surface-bash-cli-primary-mcp-secondary); both wrap the same in-process dispatcher. The escape-hatch line in this record's Cost ("nothing here precludes adding [MCP] later that shells out to the same CLI") is realised — the MCP server does not bypass the CLI's surface, it offers a second transport to it.
|
||||
- **Decision:** Claude talks to clide exclusively via Bash (`clide …`). No MCP server. No protocol layer in Claude's face. The CLI uses the same exit-code + stderr-JSON contract as pql.
|
||||
- **Context:** The two mainstream options for the agent-facing surface were an MCP server or a plain Bash CLI matching pql's contract.
|
||||
- **Rationale:** Same mental model as pql for the agent — one tool-use pattern covers both. No MCP runtime to host, authenticate, or keep in sync with client versions. User/Claude parity is easier to enforce: every CLI subcommand must have a UI affordance in the Flutter app and vice versa ([D-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.
|
||||
@@ -99,6 +100,7 @@ Core, rendering, IPC, kernel, panel manager.
|
||||
- **Date:** 2026-04-20 (was ADR 0005; supersedes [R-2](rejected.md#r-2-go-sidecar))
|
||||
- **Amendment (2026-04-23):** The separate daemon process and two-package layout are dissolved per [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server). Dart-core principle survives; the daemon binary does not.
|
||||
- **Amendment (2026-05-07):** `ptyc` retired. PTY spawning moved to Dart FFI `forkpty()` (`lib/src/pty/native_pty.dart`). The `ptyc/` source tree, `PtySession`, and `scm_rights.dart` are removed. `pql` remains the sole external supporter tool.
|
||||
- **Amendment (2026-05-17):** `forkpty()` replaced with `posix_openpt()` + `posix_spawn()` (T-96). `forkpty` calls `fork()` underneath, which is unsafe in the multithreaded Dart VM: ~5% of spawns deadlocked in the child before `execve` due to libc locks held by ghost-threads at fork time. `posix_spawn` uses `vfork` under glibc/musl/macOS, keeping the parent suspended until `execve` completes — no Dart code runs in the child. Side benefit: dropped the `libutil.so.1` dynamic dependency; PTY now resolves entirely against libc via `DynamicLibrary.process()`.
|
||||
- **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-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.
|
||||
@@ -244,4 +246,13 @@ Core, rendering, IPC, kernel, panel manager.
|
||||
- **Cross-reference:** [D-60](tooling.md#d-60-no-network-on-default-launch-path), `POLICY.md`.
|
||||
- **Raised by:** 2026-05-03 policy-to-decision migration (T-28).
|
||||
|
||||
### D-68: Dual integration surface — Bash CLI primary, MCP secondary
|
||||
- **Date:** 2026-05-15
|
||||
- **Decision:** clide exposes two integration surfaces over the same in-process `DaemonDispatcher`. **(1) Bash CLI over Unix socket — primary.** Per [D-1](#d-1-cli-first-not-mcp) and [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server), a thin C client (`clide …`) connects to a per-user Unix socket served in-process by the Flutter app, exchanges JSON-lines, and exits. This is the surface Claude-Code-in-a-pane uses; it is also the surface for human shell use, scripts, and external editor integrations. Full action surface — `pane.*`, `files.*`, `editor.*`, `git.*`, `pql.*`, …. **(2) `/ide`-compatible MCP server — secondary.** clide additionally serves an MCP endpoint compatible with Claude Code's `/ide` integration (the same protocol VS Code and JetBrains plugins serve). Minimum tools: `mcp__ide__getDiagnostics`, `mcp__ide__executeCode`. Optional `mcp__clide__*` namespace exposing high-leverage clide tools is deferred to [Q-32](../questions/architecture.md#q-32-mcp-tool-surface-minimum-slash-ide-or-extended-clide-tools). Transport choice deferred to [Q-33](../questions/architecture.md#q-33-mcp-transport-sse-websocket-stdio-or-all). The MCP server wraps the *same* `DaemonDispatcher`; there is no second source of truth.
|
||||
- **Context:** [D-1](#d-1-cli-first-not-mcp) chose CLI-first over MCP-only because MCP alone doesn't cover the action surface clide needs — Claude Code's `/ide` MCP exposes only two narrow tools (`getDiagnostics`, `executeCode`), enough for Claude to read diagnostics and run Jupyter cells but not enough to *drive* an IDE. The CLI surface gives full reach. But for users who run Claude Code *outside* clide and connect via `/ide`, MCP is the only path Claude Code knows; not serving it means clide is invisible to that workflow. The two surfaces are complementary, not alternatives. Reinforced by the [2026-05-14 consultant review](../../consultants.md): the architect flagged the absent socket server as the most critical drift; user confirmed the socket server (D-56 path a) plus an MCP companion.
|
||||
- **Rationale:** Both surfaces wrap the same dispatcher, so neither becomes a second source of truth. CLI remains the contract user/Claude parity ([D-6](#d-6-cli-and-event-surface-contract)) is enforced against. MCP is added because the `/ide` ecosystem is real and growing — VS Code, JetBrains, Cursor, Windsurf all serve compatible MCP — and clide should be a peer there. The implementation cost is a protocol adapter + tool definitions, not duplicate business logic.
|
||||
- **Cost:** Two transports to maintain. Mitigated by both wrapping the same dispatcher: the MCP adapter is the only thing that has to track `/ide` protocol evolution. If `mcp__clide__*` tools are added (pending Q-32), surface bloat is the obvious risk — every CLI verb invites an MCP twin; resist by default, justify on user need.
|
||||
- **Cross-reference:** [D-1](#d-1-cli-first-not-mcp) (amended — see amendment line there), [D-6](#d-6-cli-and-event-surface-contract), [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server), [Q-32](../questions/architecture.md#q-32-mcp-tool-surface-minimum-slash-ide-or-extended-clide-tools), [Q-33](../questions/architecture.md#q-33-mcp-transport-sse-websocket-stdio-or-all).
|
||||
- **Raised by:** 2026-05-15 — consultant review (`consultants.md`) flagged the absent socket server (D-56 unimplemented) as the highest architectural drift; user chose option (a) "implement the server" and asked for MCP coverage alongside.
|
||||
|
||||
---
|
||||
|
||||
@@ -62,6 +62,7 @@ Test pyramid, drivers, client-side constraint.
|
||||
|
||||
### D-66: Line coverage gate at 95%, ratcheted from current
|
||||
- **Date:** 2026-05-06
|
||||
- **Amendment (2026-05-17):** Floor location consolidated — the committed floor lives at `coverage_floor:` in `pubspec.yaml` (single source of truth); `coverage/floor.txt` is no longer used. The 95% target was reached on 2026-05-17; floor is **95** as of that date (T-91 closed). A pre-push **CHANGELOG concision gate** (`ci/changelog_gate.sh`) runs alongside the coverage gate; both live under `make push-check`. A separate `make push-check-full` adds `test-integration` + `smoke-bundle` for pre-release checks (T-103).
|
||||
- **Decision:** The pre-push gate runs `flutter test --coverage --exclude-tags forkpty`, parses `coverage/lcov.info`, and hard-fails if total line coverage drops below a committed floor at `coverage/floor.txt`. The floor starts at the actual current coverage (≈35%, dragged down by `lib/src/terminal/`'s 0.4%) and only ever ratchets up. The end target is 95%; getting there is tracked as a campaign of deliberate floor bumps under one epic ticket. **No carve-outs** — code under `lib/` is owned regardless of file-header attribution, including the terminal emulator port. Branch coverage is not gated (Dart's lcov output models it weakly). Lint suppressions to dodge the gate are never acceptable.
|
||||
- **Rationale:** A flat 95% threshold today blocks every push; an informational coverage report rots into noise. The committed-floor ratchet makes "don't make it worse" the durable rule and turns the journey to 95% into explicit, reviewed bumps rather than a single overnight cliff. Excluding `forkpty`-tagged tests matches `ci/test.sh` (forkpty + flutter test runner are incompatible — see `test/pty/session_test.dart`).
|
||||
- **Cost:** Pre-push wall time grows by `flutter test --coverage` (currently ≈11 s on this tree). Acceptable within D-29's < 90 s budget; reassess if it slips. Floor bumps require an explicit edit to `coverage/floor.txt` in the same commit that adds tests — so contributors can't silently raise it.
|
||||
|
||||
@@ -9,18 +9,21 @@ ticket persistence.
|
||||
- **Status:** Open
|
||||
- **Question:** The daemon's token auth is coarse (allow all / deny all). Do we need per-subsystem grants later (e.g. restrict `git push`), and if so, what's the model — capability tokens? An explicit grant table per client? Time-limited grants?
|
||||
- **Context:** Surfaced in the old ADR 0006 open-questions footer; deferred until Tier 1 is in real use.
|
||||
- **Triage (2026-05-17):** Still open. Tier 1 has shipped but the IPC socket server itself is unimplemented (T-99). Re-evaluate once the socket lands and external CLI clients exist.
|
||||
- **Source:** ADR 0006 (migrated to [D-6](architecture.md)).
|
||||
|
||||
### Q-2: Back-pressure on event streams
|
||||
- **Status:** Open
|
||||
- **Question:** A subscriber that falls behind on `pane.output` (a firehose) needs a policy: drop oldest, block producer, coalesce, or kill subscriber. Which?
|
||||
- **Context:** The event bus is in-memory; back-pressure policy is undefined. Defer until Tier 1 is in real use and we have a real firehose to measure against.
|
||||
- **Triage (2026-05-17):** Still open. PTY panes ship and produce real firehoses, but no subscriber has fallen behind in observed use. Re-evaluate when a multi-client IPC scenario (T-99) makes this measurable.
|
||||
- **Source:** ADR 0006 (migrated to [D-6](architecture.md)).
|
||||
|
||||
### Q-3: Event persistence + audit/undo
|
||||
- **Status:** Open
|
||||
- **Question:** Events are in-memory only in v1. If a future need (audit log, undo history) wants persistence, is it a property of the bus or a subsystem that subscribes and writes?
|
||||
- **Context:** ADR 0006 leaned "subsystem that subscribes and writes" but didn't commit.
|
||||
- **Triage (2026-05-17):** Still open; no concrete trigger yet. Revisit when the first persistence requirement lands (likely Tier-6 audit/undo).
|
||||
- **Source:** ADR 0006 (migrated to [D-6](architecture.md)).
|
||||
|
||||
### Q-4: `.canvas` schema compatibility with Obsidian
|
||||
@@ -69,6 +72,7 @@ ticket persistence.
|
||||
- **Status:** Open
|
||||
- **Question:** The design handoff uses JetBrains Mono for all UI text (tab labels, file paths, status bar, sidebar labels), reserving Josefin Sans only for display/title text. Our current implementation uses Josefin Sans as the ambient UI face with JetBrains Mono only for code/terminal/diff surfaces. Which direction?
|
||||
- **Context:** The design's "mono everywhere" rationale: clide is an IDE for people who like grids. The current Josefin Sans rationale: visual distinction between chrome text and code text, warmer feel. Both are valid — this is a feel decision, not a technical one.
|
||||
- **Triage (2026-05-17):** Still open. The Josefin-Sans-as-UI-face implementation has shipped and is the current default; the design's "mono everywhere" direction remains unrealised. Convert to a D-record when the design call is made.
|
||||
- **Source:** 2026-04-22 design handoff review.
|
||||
|
||||
### Q-26: Small screen layout (< 1000px)
|
||||
@@ -125,4 +129,16 @@ ticket persistence.
|
||||
|
||||
- **Source:** 2026-04-23 D-57 implementation.
|
||||
|
||||
### Q-32: MCP tool surface — minimum slash-ide or extended clide tools?
|
||||
- **Status:** Open
|
||||
- **Question:** [D-68](../decisions/architecture.md#d-68-dual-integration-surface-bash-cli-primary-mcp-secondary) commits clide to an `/ide`-compatible MCP server. The minimum surface is the two tools Claude Code's `/ide` integration currently expects: `mcp__ide__getDiagnostics` (lint/diagnostics for a file) and `mcp__ide__executeCode` (run code in a Jupyter kernel). Do we stop there, or also expose a `mcp__clide__*` namespace with higher-leverage tools (`open_file`, `goto_symbol`, `pql_query`, `pane_spawn`, `git_status`, …) so MCP clients other than Claude Code (Cursor, Windsurf, VS Code Copilot) can drive clide as a real backend?
|
||||
- **Context:** The minimum surface keeps clide a good citizen in the `/ide` ecosystem and avoids duplicating the CLI in MCP form. The extended surface would let non-Claude-Code MCP clients integrate richly, but invites surface bloat (every CLI verb tempted to gain an MCP twin) and a maintenance second front. Note that for *Claude-Code-in-a-clide-pane*, the CLI surface already covers this — extended MCP tools serve external MCP clients only.
|
||||
- **Source:** [D-68](../decisions/architecture.md#d-68-dual-integration-surface-bash-cli-primary-mcp-secondary).
|
||||
|
||||
### Q-33: MCP transport — SSE, WebSocket, stdio, or all?
|
||||
- **Status:** Open
|
||||
- **Question:** Claude Code's `/ide` integration connects via SSE-IDE or WS-IDE (URL passed at startup). MCP also supports stdio for process-spawn clients. Which transport(s) should clide's MCP server expose — SSE only (the most common `/ide` server pattern), SSE + WS (broader compatibility), or all three including stdio?
|
||||
- **Context:** Transport choice affects discovery and lifecycle. SSE/WS need a port and a published URL, which collides with the `XDG_RUNTIME_DIR` Unix-socket model used for the CLI; we'd likely publish the URL alongside the socket path (env var or `XDG_RUNTIME_DIR` discovery file). stdio is process-per-client and works for clients that prefer process-spawn over network. Decision interacts with [Q-32](#q-32-mcp-tool-surface-minimum-slash-ide-or-extended-clide-tools) — if the surface stays at the `/ide` minimum, SSE alone is sufficient.
|
||||
- **Source:** [D-68](../decisions/architecture.md#d-68-dual-integration-surface-bash-cli-primary-mcp-secondary).
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:clide/app.dart';
|
||||
import 'package:clide/builtin/default_layout/default_layout.dart';
|
||||
@@ -22,6 +23,16 @@ void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('clide app boots with classic 3-column layout + welcome + statusbar', (tester) async {
|
||||
// The welcome view's TIPS card overflows the default headless test
|
||||
// viewport (~800px); give it a desktop-sized window so layout is
|
||||
// representative of a real launch.
|
||||
tester.view.physicalSize = const Size(1600, 1000);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
final themes = [
|
||||
await const ThemeLoader().fromAsset(
|
||||
rootBundle,
|
||||
@@ -58,10 +69,15 @@ void main() {
|
||||
|
||||
// Welcome tab is mounted in the workspace.
|
||||
expect(find.text('clide'), findsWidgets);
|
||||
expect(find.text('Open project'), findsOneWidget);
|
||||
// The visible START row exposes "Open folder…"; the "Open project"
|
||||
// dialog title only appears after the user clicks through, so we
|
||||
// assert the on-boot label here.
|
||||
expect(find.text('Open folder…'), findsWidgets);
|
||||
|
||||
// IPC status indicator reports disconnected (fake client never connects).
|
||||
expect(find.text('disconnected'), findsOneWidget);
|
||||
// Welcome view's toolchain status shows "checking…" while the
|
||||
// backend hasn't reported resolution (FakeDaemonClient never does
|
||||
// — autoStartDaemonClient is false).
|
||||
expect(find.text('checking…'), findsWidgets);
|
||||
|
||||
await services.dispose();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:clide/app.dart';
|
||||
import 'package:clide/builtin/default_layout/default_layout.dart';
|
||||
@@ -15,6 +16,15 @@ void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('disable + re-enable an extension mounts/unmounts its UI', (tester) async {
|
||||
// Welcome view overflows the default headless viewport — give it a
|
||||
// desktop-sized window so layout is representative.
|
||||
tester.view.physicalSize = const Size(1600, 1000);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
final themes = [
|
||||
await const ThemeLoader().fromAsset(
|
||||
rootBundle,
|
||||
@@ -41,17 +51,21 @@ void main() {
|
||||
|
||||
await tester.pumpWidget(ClideApp(services: services));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('disconnected'), findsOneWidget);
|
||||
// The ipc-status extension contributes a ToolStatusItem to the
|
||||
// statusbar; we assert its presence by widget type so the test
|
||||
// doesn't depend on whichever status string (`application ok` /
|
||||
// `checking…` / `<tool> not found`) the toolchain happens to be in.
|
||||
expect(find.byType(ToolStatusItem), findsOneWidget);
|
||||
|
||||
// Disable ipc-status; status item should disappear.
|
||||
await services.extensions.setEnabled('builtin.ipc-status', false);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('disconnected'), findsNothing);
|
||||
expect(find.byType(ToolStatusItem), findsNothing);
|
||||
|
||||
// Re-enable; status item reappears.
|
||||
await services.extensions.setEnabled('builtin.ipc-status', true);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('disconnected'), findsOneWidget);
|
||||
expect(find.byType(ToolStatusItem), findsOneWidget);
|
||||
|
||||
await services.dispose();
|
||||
});
|
||||
|
||||
+253
-122
@@ -55,24 +55,23 @@ class _RootShell extends StatefulWidget {
|
||||
|
||||
class _RootShellState extends State<_RootShell> {
|
||||
late final FocusNode _keyFocus;
|
||||
double _textScale = 1.0;
|
||||
|
||||
static const double _scaleStep = 0.05;
|
||||
static const double _scaleMin = 0.6;
|
||||
static const double _scaleMax = 2.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_keyFocus = FocusNode()..requestFocus();
|
||||
widget.services.textZoom.addListener(_onZoom);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.services.textZoom.removeListener(_onZoom);
|
||||
_keyFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onZoom() => setState(() {});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
@@ -86,31 +85,77 @@ class _RootShellState extends State<_RootShell> {
|
||||
fontFamilyFallback: clideUiFamilyFallback,
|
||||
),
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(_textScale)),
|
||||
child: KeyboardListener(
|
||||
focusNode: _keyFocus,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: ColoredBox(
|
||||
color: tokens.globalBackground,
|
||||
child: ClideResizeBorder(
|
||||
windowControls: widget.services.window,
|
||||
child: Column(
|
||||
children: [
|
||||
_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()),
|
||||
],
|
||||
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(widget.services.textZoom.scale)),
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{
|
||||
TextScaleIncreaseIntent: CallbackAction<TextScaleIncreaseIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.textZoom.increase();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextScaleDecreaseIntent: CallbackAction<TextScaleDecreaseIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.textZoom.decrease();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextScaleResetIntent: CallbackAction<TextScaleResetIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.textZoom.reset();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
InvokeCommandIntent: CallbackAction<InvokeCommandIntent>(
|
||||
onInvoke: (intent) {
|
||||
widget.services.commands.execute(intent.commandId);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
PaletteOpenIntent: CallbackAction<PaletteOpenIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.palette.open();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
FocusNextPanelIntent: CallbackAction<FocusNextPanelIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.focus.focusNextSlot();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
FocusPreviousPanelIntent: CallbackAction<FocusPreviousPanelIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.focus.focusPreviousSlot();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: KeyboardListener(
|
||||
focusNode: _keyFocus,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: ColoredBox(
|
||||
color: tokens.globalBackground,
|
||||
child: ClideResizeBorder(
|
||||
windowControls: widget.services.window,
|
||||
child: Column(
|
||||
children: [
|
||||
_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()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -120,31 +165,14 @@ class _RootShellState extends State<_RootShell> {
|
||||
}
|
||||
|
||||
void _onKey(KeyEvent event) {
|
||||
if (event is KeyDownEvent || event is KeyRepeatEvent) {
|
||||
final ctrl = HardwareKeyboard.instance.isControlPressed;
|
||||
if (ctrl) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.equal || event.logicalKey == LogicalKeyboardKey.add) {
|
||||
setState(() => _textScale = (_textScale + _scaleStep).clamp(_scaleMin, _scaleMax));
|
||||
return;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.minus) {
|
||||
setState(() => _textScale = (_textScale - _scaleStep).clamp(_scaleMin, _scaleMax));
|
||||
return;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.digit0) {
|
||||
setState(() => _textScale = 1.0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
final binding = KeybindingResolver.fromKeyEvent(
|
||||
event,
|
||||
HardwareKeyboard.instance,
|
||||
);
|
||||
if (binding == null) return;
|
||||
final commandId = widget.services.keybindings.commandFor(binding);
|
||||
if (commandId == null) return;
|
||||
widget.services.commands.execute(commandId);
|
||||
final intent = widget.services.keymap.resolveEvent(event, HardwareKeyboard.instance);
|
||||
if (intent == null) return;
|
||||
// Dispatch the intent. Try the focused context first so feature
|
||||
// widgets (palette, editor, …) get a chance to handle their own
|
||||
// intents; fall back to the app root's Actions for global ones
|
||||
// (text scale, generic command bridge).
|
||||
final ctx = FocusManager.instance.primaryFocus?.context ?? context;
|
||||
Actions.maybeInvoke(ctx, intent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +345,7 @@ class _WinBtn extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hoverBg = isClose ? const Color(0xFFE81123) : tokens.listItemHoverBackground;
|
||||
final hoverBg = isClose ? tokens.windowControlCloseHoverBackground : tokens.listItemHoverBackground;
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
@@ -325,7 +353,11 @@ class _WinBtn extends StatelessWidget {
|
||||
height: hatHeight,
|
||||
color: hovered ? hoverBg : null,
|
||||
alignment: Alignment.center,
|
||||
child: ClideIcon(icon, size: 14, color: hovered && isClose ? const Color(0xFFFFFFFF) : tokens.chromeForeground),
|
||||
child: ClideIcon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: hovered && isClose ? tokens.windowControlCloseHoverForeground : tokens.chromeForeground,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -719,66 +751,122 @@ class _NotARepoDialog extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class SlotHost extends StatelessWidget {
|
||||
class SlotHost extends StatefulWidget {
|
||||
const SlotHost({super.key, required this.slot});
|
||||
final SlotId slot;
|
||||
|
||||
@override
|
||||
State<SlotHost> createState() => _SlotHostState();
|
||||
}
|
||||
|
||||
class _SlotHostState extends State<SlotHost> {
|
||||
late final FocusScopeNode _scope = FocusScopeNode(debugLabel: 'SlotScope:${widget.slot.value}');
|
||||
FocusTracker? _tracker;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final kernel = ClideKernel.of(context);
|
||||
if (!identical(_tracker, kernel.focus)) {
|
||||
_tracker?.unregisterSlotScope(widget.slot, _scope);
|
||||
_tracker = kernel.focus;
|
||||
_tracker!.registerSlotScope(widget.slot, _scope);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tracker?.unregisterSlotScope(widget.slot, _scope);
|
||||
_scope.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChange(bool hasFocus) {
|
||||
if (!hasFocus || _tracker == null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
final activeId = kernel.panels.activeTabIn(widget.slot);
|
||||
if (activeId != null) {
|
||||
_tracker!.setActive(slot: widget.slot, contributionId: activeId);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([kernel.panels, kernel.i18n]),
|
||||
builder: (ctx, _) {
|
||||
final tabs = kernel.panels.tabsFor(slot);
|
||||
if (tabs.isEmpty) {
|
||||
return Container(color: tokens.panelBackground);
|
||||
}
|
||||
final activeId = kernel.panels.activeTabIn(slot) ?? tabs.first.id;
|
||||
final active = tabs.firstWhere(
|
||||
(t) => t.id == activeId,
|
||||
orElse: () => tabs.first,
|
||||
);
|
||||
return FocusScope(
|
||||
node: _scope,
|
||||
onFocusChange: _onFocusChange,
|
||||
child: FocusTraversalGroup(
|
||||
child: ListenableBuilder(
|
||||
listenable: Listenable.merge([kernel.panels, kernel.i18n]),
|
||||
builder: (ctx, _) {
|
||||
final tabs = kernel.panels.tabsFor(widget.slot);
|
||||
if (tabs.isEmpty) {
|
||||
return Container(color: tokens.panelBackground);
|
||||
}
|
||||
final activeId = kernel.panels.activeTabIn(widget.slot) ?? tabs.first.id;
|
||||
final active = tabs.firstWhere(
|
||||
(t) => t.id == activeId,
|
||||
orElse: () => tabs.first,
|
||||
);
|
||||
return _SlotBody(slot: widget.slot, tabs: tabs, active: active, activeId: activeId);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (slot == Slots.sidebar) {
|
||||
return _SidebarSlot(
|
||||
tabs: tabs,
|
||||
active: active,
|
||||
activeId: activeId,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
);
|
||||
}
|
||||
class _SlotBody extends StatelessWidget {
|
||||
const _SlotBody({required this.slot, required this.tabs, required this.active, required this.activeId});
|
||||
final SlotId slot;
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
final String activeId;
|
||||
|
||||
if (slot == Slots.contextPanel) {
|
||||
return _ContextSlot(
|
||||
tabs: tabs,
|
||||
active: active,
|
||||
activeId: activeId,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
|
||||
if (slot == Slots.workspace) {
|
||||
return _WorkspaceSlot(tabs: tabs, active: active);
|
||||
}
|
||||
if (slot == Slots.sidebar) {
|
||||
return _SidebarSlot(
|
||||
tabs: tabs,
|
||||
active: active,
|
||||
activeId: activeId,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
color: tokens.panelBackground,
|
||||
child: Column(
|
||||
children: [
|
||||
ClideTabBar(
|
||||
items: [
|
||||
for (final t in tabs) ClideTabItem(id: t.id, title: _resolveTitle(ctx, t)),
|
||||
],
|
||||
activeId: active.id,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
),
|
||||
ClideDivider(),
|
||||
Expanded(child: active.build(ctx)),
|
||||
if (slot == Slots.contextPanel) {
|
||||
return _ContextSlot(
|
||||
tabs: tabs,
|
||||
active: active,
|
||||
activeId: activeId,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
);
|
||||
}
|
||||
|
||||
if (slot == Slots.workspace) {
|
||||
return _WorkspaceSlot(tabs: tabs, active: active);
|
||||
}
|
||||
|
||||
return Container(
|
||||
color: tokens.panelBackground,
|
||||
child: Column(
|
||||
children: [
|
||||
ClideTabBar(
|
||||
items: [
|
||||
for (final t in tabs) ClideTabItem(id: t.id, title: _resolveTitle(context, t)),
|
||||
],
|
||||
activeId: active.id,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
),
|
||||
);
|
||||
},
|
||||
ClideDivider(),
|
||||
Expanded(child: active.build(context)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -879,36 +967,79 @@ class _EditorDragHandle extends StatefulWidget {
|
||||
|
||||
class _EditorDragHandleState extends State<_EditorDragHandle> {
|
||||
bool _hovered = false;
|
||||
bool _focused = false;
|
||||
double? _dragStartRatio;
|
||||
double? _dragStartY;
|
||||
|
||||
// Editor split is a 0..1 fraction; the kernel clamps to 0.15..0.70.
|
||||
// 2% per fine step, 10% per Shift step keeps keyboard feel close to
|
||||
// the pixel-based DragResizeHandle.
|
||||
static const double _stepFine = 0.02;
|
||||
static const double _stepCoarse = 0.10;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeRow,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Listener(
|
||||
onPointerDown: (e) {
|
||||
_dragStartRatio = widget.arrangement.editorRatio;
|
||||
_dragStartY = e.position.dy;
|
||||
final lineColor = (_hovered || _focused) ? tokens.panelActiveBorder : tokens.panelBorder;
|
||||
|
||||
return Semantics(
|
||||
container: true,
|
||||
slider: true,
|
||||
label: 'Editor split',
|
||||
value: '${(widget.arrangement.editorRatio * 100).round()}%',
|
||||
onIncrease: () => _bump(_stepFine),
|
||||
onDecrease: () => _bump(-_stepFine),
|
||||
child: FocusableActionDetector(
|
||||
onShowFocusHighlight: (v) => setState(() => _focused = v),
|
||||
shortcuts: const <ShortcutActivator, Intent>{
|
||||
SingleActivator(LogicalKeyboardKey.arrowUp): _EditorBumpIntent(-_stepFine),
|
||||
SingleActivator(LogicalKeyboardKey.arrowDown): _EditorBumpIntent(_stepFine),
|
||||
SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): _EditorBumpIntent(-_stepCoarse),
|
||||
SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): _EditorBumpIntent(_stepCoarse),
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
final startR = _dragStartRatio;
|
||||
final startY = _dragStartY;
|
||||
if (startR == null || startY == null || widget.totalHeight <= 0) return;
|
||||
final deltaRatio = (e.position.dy - startY) / widget.totalHeight;
|
||||
widget.arrangement.setEditorRatio(startR + deltaRatio);
|
||||
actions: <Type, Action<Intent>>{
|
||||
_EditorBumpIntent: CallbackAction<_EditorBumpIntent>(
|
||||
onInvoke: (intent) {
|
||||
_bump(intent.delta);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
onPointerUp: (_) {
|
||||
_dragStartRatio = null;
|
||||
_dragStartY = null;
|
||||
},
|
||||
child: Container(height: 4, color: _hovered ? tokens.panelActiveBorder : tokens.panelBorder),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeRow,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Listener(
|
||||
onPointerDown: (e) {
|
||||
_dragStartRatio = widget.arrangement.editorRatio;
|
||||
_dragStartY = e.position.dy;
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
final startR = _dragStartRatio;
|
||||
final startY = _dragStartY;
|
||||
if (startR == null || startY == null || widget.totalHeight <= 0) return;
|
||||
final deltaRatio = (e.position.dy - startY) / widget.totalHeight;
|
||||
widget.arrangement.setEditorRatio(startR + deltaRatio);
|
||||
},
|
||||
onPointerUp: (_) {
|
||||
_dragStartRatio = null;
|
||||
_dragStartY = null;
|
||||
},
|
||||
child: Container(height: 4, color: lineColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _bump(double delta) {
|
||||
widget.arrangement.setEditorRatio(widget.arrangement.editorRatio + delta);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditorBumpIntent extends Intent {
|
||||
const _EditorBumpIntent(this.delta);
|
||||
final double delta;
|
||||
}
|
||||
|
||||
class _ContextSlot extends StatelessWidget {
|
||||
@@ -958,7 +1089,7 @@ class _BottomRail extends StatelessWidget {
|
||||
ClideIconRailItem(
|
||||
id: t.id,
|
||||
icon: _iconFor(slot, t),
|
||||
tooltip: SlotHost._resolveTitle(ctx, t),
|
||||
tooltip: _SlotBody._resolveTitle(ctx, t),
|
||||
),
|
||||
],
|
||||
activeId: activeId,
|
||||
|
||||
@@ -17,13 +17,11 @@ import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class EditorController extends ChangeNotifier {
|
||||
EditorController({required this.ipc, required DaemonBus events}) : _events = events {
|
||||
EditorController({required this.ipc, required DaemonBus events}) {
|
||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||
}
|
||||
|
||||
final DaemonClient ipc;
|
||||
// ignore: unused_field — kept for future subscription changes
|
||||
final DaemonBus _events;
|
||||
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart' show FileEntry;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:clide/src/files/listing.dart' show FileEntry;
|
||||
|
||||
import 'file_tree_controller.dart';
|
||||
|
||||
/// Sidebar panel rendering the workspace file tree.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
/// Surfaces view-level commands (currently the three text-zoom verbs)
|
||||
/// in the command palette so they're discoverable. The keybindings
|
||||
/// themselves are owned by the default keymap; commands here exist so
|
||||
/// users browsing `Ctrl+Shift+P` see the same actions.
|
||||
class ViewExtension extends ClideExtension {
|
||||
ViewExtension({required this.textZoom});
|
||||
|
||||
final TextZoom textZoom;
|
||||
|
||||
@override
|
||||
String get id => 'builtin.view';
|
||||
@override
|
||||
String get title => 'View';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {}
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
// Keybindings live in `assets/keymaps/default.yaml` against the
|
||||
// text.scale* intents — registering a `defaultBinding` here too
|
||||
// would shadow them. Palette discovery is the only goal.
|
||||
CommandContribution(
|
||||
id: 'view.zoomIn',
|
||||
command: 'view.zoomIn',
|
||||
title: 'View: Zoom In',
|
||||
run: (_) async {
|
||||
textZoom.increase();
|
||||
return IpcResponse.ok(id: '', data: {'scale': textZoom.scale});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'view.zoomOut',
|
||||
command: 'view.zoomOut',
|
||||
title: 'View: Zoom Out',
|
||||
run: (_) async {
|
||||
textZoom.decrease();
|
||||
return IpcResponse.ok(id: '', data: {'scale': textZoom.scale});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'view.zoomReset',
|
||||
command: 'view.zoomReset',
|
||||
title: 'View: Reset Zoom',
|
||||
run: (_) async {
|
||||
textZoom.reset();
|
||||
return IpcResponse.ok(id: '', data: {'scale': textZoom.scale});
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -22,6 +22,11 @@ export 'src/clipboard.dart';
|
||||
export 'src/commands/keybindings.dart';
|
||||
export 'src/commands/palette.dart';
|
||||
export 'src/commands/registry.dart';
|
||||
export 'src/keymap/intents.dart';
|
||||
export 'src/keymap/key_chord.dart';
|
||||
export 'src/keymap/keymap.dart';
|
||||
export 'src/keymap/keymap_service.dart';
|
||||
export 'src/keymap/when_clause.dart';
|
||||
export 'src/dialog.dart';
|
||||
export 'src/extensions_manager.dart';
|
||||
export 'src/files.dart';
|
||||
@@ -35,6 +40,7 @@ export 'src/os.dart';
|
||||
export 'src/panels/arrangement.dart';
|
||||
export 'src/project.dart';
|
||||
export 'src/scheduler.dart';
|
||||
export 'src/text_zoom.dart';
|
||||
export 'src/secrets.dart';
|
||||
export 'src/tray.dart';
|
||||
export 'src/panels/drag_resize.dart';
|
||||
|
||||
@@ -45,11 +45,10 @@ void backendEntry(BackendBootMessage boot) {
|
||||
late Toolchain toolchain;
|
||||
|
||||
// Phase 1: resolve toolchain — just find binaries, don't init services.
|
||||
// We need a project root for dugite paths. Use a sensible default;
|
||||
// the real project comes from project.open.
|
||||
final resolveRoot = boot.hintRoot ?? Platform.environment['HOME'] ?? '/tmp';
|
||||
// Dugite is resolved against the install dir; per T-98 the project
|
||||
// root is never inspected during toolchain resolution.
|
||||
toolchain = Toolchain();
|
||||
toolchain.applyResolved(resolveToolchainPaths(resolveRoot));
|
||||
toolchain.applyResolved(resolveToolchainPaths());
|
||||
|
||||
// Listen for messages from the frontend.
|
||||
requestPort.listen((message) async {
|
||||
@@ -77,10 +76,10 @@ void backendEntry(BackendBootMessage boot) {
|
||||
final projectPath = message['path'] as String;
|
||||
final workDir = Directory(projectPath);
|
||||
|
||||
// Re-resolve toolchain with the actual project root (finds
|
||||
// dugite in native/dugite/, etc.)
|
||||
// Re-resolve toolchain. Project path is not inspected (T-98);
|
||||
// dugite still comes from the install dir + env override.
|
||||
toolchain = Toolchain();
|
||||
toolchain.applyResolved(resolveToolchainPaths(projectPath));
|
||||
toolchain.applyResolved(resolveToolchainPaths());
|
||||
|
||||
// Clear existing handlers and re-register with new project.
|
||||
dispatcher.clear();
|
||||
|
||||
@@ -9,13 +9,26 @@ class PaletteController extends ChangeNotifier {
|
||||
|
||||
bool _open = false;
|
||||
String _filter = '';
|
||||
int _selectedIndex = 0;
|
||||
|
||||
bool get isOpen => _open;
|
||||
String get filter => _filter;
|
||||
|
||||
/// Index of the highlighted entry inside the currently-filtered
|
||||
/// list. Clamped to `[0, filtered().length - 1]` on read. Returns 0
|
||||
/// when the filter excludes everything.
|
||||
int get selectedIndex {
|
||||
final n = filtered().length;
|
||||
if (n == 0) return 0;
|
||||
if (_selectedIndex < 0) return 0;
|
||||
if (_selectedIndex >= n) return n - 1;
|
||||
return _selectedIndex;
|
||||
}
|
||||
|
||||
void open() {
|
||||
if (_open) return;
|
||||
_open = true;
|
||||
_selectedIndex = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -23,6 +36,7 @@ class PaletteController extends ChangeNotifier {
|
||||
if (!_open) return;
|
||||
_open = false;
|
||||
_filter = '';
|
||||
_selectedIndex = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -31,9 +45,35 @@ class PaletteController extends ChangeNotifier {
|
||||
void setFilter(String f) {
|
||||
if (_filter == f) return;
|
||||
_filter = f;
|
||||
_selectedIndex = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Highlight the next entry, wrapping at the end. No-op when the
|
||||
/// filtered list has fewer than 2 entries.
|
||||
void selectNext() {
|
||||
final n = filtered().length;
|
||||
if (n < 2) return;
|
||||
_selectedIndex = (selectedIndex + 1) % n;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Highlight the previous entry, wrapping at the start.
|
||||
void selectPrevious() {
|
||||
final n = filtered().length;
|
||||
if (n < 2) return;
|
||||
_selectedIndex = (selectedIndex - 1 + n) % n;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Invoke whatever's currently highlighted; no-op when the filter
|
||||
/// excludes everything.
|
||||
Future<void> acceptSelected() async {
|
||||
final list = filtered();
|
||||
if (list.isEmpty) return;
|
||||
await invoke(list[selectedIndex].command);
|
||||
}
|
||||
|
||||
List<CommandContribution> filtered() {
|
||||
if (_filter.isEmpty) return _registry.all.toList();
|
||||
final q = _filter.toLowerCase();
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:clide/kernel/src/files.dart';
|
||||
import 'package:clide/kernel/src/focus.dart';
|
||||
import 'package:clide/kernel/src/i18n/i18n.dart';
|
||||
import 'package:clide/kernel/src/ipc/client.dart';
|
||||
import 'package:clide/kernel/src/keymap/keymap_service.dart';
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/kernel/src/net.dart';
|
||||
import 'package:clide/kernel/src/notify.dart';
|
||||
@@ -40,6 +41,7 @@ class ExtensionManager extends ChangeNotifier {
|
||||
required this.commands,
|
||||
required this.palette,
|
||||
required this.keybindings,
|
||||
required this.keymap,
|
||||
required this.clipboard,
|
||||
required this.files,
|
||||
required this.notify,
|
||||
@@ -64,6 +66,7 @@ class ExtensionManager extends ChangeNotifier {
|
||||
final CommandRegistry commands;
|
||||
final PaletteController palette;
|
||||
final KeybindingResolver keybindings;
|
||||
final KeymapService keymap;
|
||||
final ClideClipboard clipboard;
|
||||
final FileServices files;
|
||||
final Notifications notify;
|
||||
@@ -78,6 +81,9 @@ class ExtensionManager extends ChangeNotifier {
|
||||
|
||||
final Map<String, ClideExtension> _known = {};
|
||||
final Set<String> _activated = {};
|
||||
// Extensions whose activate() / deactivate() threw — exposed so the
|
||||
// UI can show a "degraded" status next to them.
|
||||
final Map<String, Object> _failed = {};
|
||||
|
||||
void register(ClideExtension ext) {
|
||||
if (_known.containsKey(ext.id)) {
|
||||
@@ -91,6 +97,12 @@ class ExtensionManager extends ChangeNotifier {
|
||||
Iterable<ClideExtension> get all => _known.values;
|
||||
bool isActivated(String id) => _activated.contains(id);
|
||||
|
||||
/// Map of extension id → most recent activate/deactivate error.
|
||||
/// Cleared for an extension when it activates cleanly. UI surfaces
|
||||
/// read this for the "degraded" indicator.
|
||||
Map<String, Object> get failedExtensions => Map.unmodifiable(_failed);
|
||||
bool didFail(String id) => _failed.containsKey(id);
|
||||
|
||||
bool isEnabled(String id) {
|
||||
final v = settings.get<bool>('app.extensions.$id.enabled');
|
||||
return v ?? true;
|
||||
@@ -135,11 +147,14 @@ class ExtensionManager extends ChangeNotifier {
|
||||
_applyContribution(c);
|
||||
}
|
||||
_activated.add(id);
|
||||
_failed.remove(id);
|
||||
events.emit(ExtensionActivated(id: id));
|
||||
notifyListeners();
|
||||
log.info('extensions', 'activated $id');
|
||||
} catch (e, st) {
|
||||
_failed[id] = e;
|
||||
log.error('extensions', 'activate failed for $id', error: e, stackTrace: st);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +172,9 @@ class ExtensionManager extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
log.info('extensions', 'deactivated $id');
|
||||
} catch (e, st) {
|
||||
_failed[id] = e;
|
||||
log.error('extensions', 'deactivate failed for $id', error: e, stackTrace: st);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +188,11 @@ class ExtensionManager extends ChangeNotifier {
|
||||
commands.register(cmd);
|
||||
final binding = cmd.defaultBinding;
|
||||
if (binding != null) {
|
||||
// Legacy KeybindingResolver still wired for back-compat
|
||||
// until all callers migrate; the keymap layer is the
|
||||
// canonical home for chord → command bindings (T-117).
|
||||
keybindings.bind(Keybinding.parse(binding), cmd.command);
|
||||
keymap.registerCommandBinding(binding, cmd.command);
|
||||
}
|
||||
case TrayItemContribution t:
|
||||
tray.add(t);
|
||||
@@ -194,6 +215,7 @@ class ExtensionManager extends ChangeNotifier {
|
||||
if (binding != null) {
|
||||
keybindings.unbind(Keybinding.parse(binding));
|
||||
}
|
||||
keymap.unregisterCommandBindings(cmd.command);
|
||||
case TrayItemContribution t:
|
||||
tray.remove(t.id);
|
||||
case LayoutPresetContribution _:
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'dart:io';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/clipboard.dart';
|
||||
import 'package:clide/kernel/src/commands/keybindings.dart';
|
||||
import 'package:clide/kernel/src/keymap/keymap_service.dart';
|
||||
import 'package:clide/kernel/src/text_zoom.dart';
|
||||
import 'package:clide/kernel/src/commands/palette.dart';
|
||||
import 'package:clide/kernel/src/commands/registry.dart';
|
||||
import 'package:clide/kernel/src/dialog.dart';
|
||||
@@ -63,6 +65,8 @@ class KernelServices {
|
||||
required this.window,
|
||||
required this.toolchain,
|
||||
required this.scheduler,
|
||||
required this.keymap,
|
||||
required this.textZoom,
|
||||
});
|
||||
|
||||
final Logger log;
|
||||
@@ -91,6 +95,8 @@ class KernelServices {
|
||||
final WindowControls window;
|
||||
final Toolchain toolchain;
|
||||
final SchedulerService scheduler;
|
||||
final KeymapService keymap;
|
||||
final TextZoom textZoom;
|
||||
|
||||
static Future<KernelServices> boot({
|
||||
required Directory appDir,
|
||||
@@ -132,6 +138,8 @@ class KernelServices {
|
||||
final arrangement = LayoutArrangement();
|
||||
final commands = CommandRegistry();
|
||||
final keybindings = KeybindingResolver();
|
||||
final keymap = KeymapService(settings: settings, appDir: appDir);
|
||||
await keymap.load();
|
||||
final palette = PaletteController(commands);
|
||||
final clipboard = ClideClipboard();
|
||||
final files = FileServices(events);
|
||||
@@ -146,6 +154,7 @@ class KernelServices {
|
||||
final tc = toolchain ?? Toolchain();
|
||||
final scheduler = SchedulerService(events);
|
||||
scheduler.start();
|
||||
final textZoom = TextZoom();
|
||||
final project = ProjectManager(
|
||||
log: log,
|
||||
events: events,
|
||||
@@ -174,6 +183,7 @@ class KernelServices {
|
||||
commands: commands,
|
||||
palette: palette,
|
||||
keybindings: keybindings,
|
||||
keymap: keymap,
|
||||
clipboard: clipboard,
|
||||
files: files,
|
||||
notify: notify,
|
||||
@@ -218,6 +228,8 @@ class KernelServices {
|
||||
window: window,
|
||||
toolchain: tc,
|
||||
scheduler: scheduler,
|
||||
keymap: keymap,
|
||||
textZoom: textZoom,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -238,7 +250,9 @@ class KernelServices {
|
||||
focus.dispose();
|
||||
project.dispose();
|
||||
extensions.dispose();
|
||||
scheduler.dispose();
|
||||
await scheduler.dispose();
|
||||
keymap.dispose();
|
||||
textZoom.dispose();
|
||||
await log.dispose();
|
||||
messages.dispose();
|
||||
await events.dispose();
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
import 'package:clide/kernel/src/panels/slot_id.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Tracks the currently focused contribution (tab id + slot). Backs
|
||||
/// `clide active`; extensions that need "which tab does the user care
|
||||
/// about right now?" read from here instead of poking Flutter's
|
||||
/// FocusScope directly.
|
||||
/// Tracks the currently focused contribution (tab id + slot) and
|
||||
/// hosts a registry of per-slot [FocusScopeNode]s for panel-to-panel
|
||||
/// focus traversal.
|
||||
///
|
||||
/// Backs `clide active` (extensions read activeSlot / activeContributionId)
|
||||
/// and the `FocusNextPanelIntent` / `FocusPreviousPanelIntent` actions
|
||||
/// the keymap dispatches on F6 / Shift+F6.
|
||||
class FocusTracker extends ChangeNotifier {
|
||||
SlotId? _slot;
|
||||
String? _contributionId;
|
||||
final Map<SlotId, FocusScopeNode> _scopes = {};
|
||||
|
||||
/// Static order panels cycle through. Matches the visual left-to-right
|
||||
/// of the three-column layout (sidebar → workspace → context); the
|
||||
/// statusbar / toolbar aren't included because they don't host
|
||||
/// keyboard-active content.
|
||||
static const List<SlotId> traversalOrder = [
|
||||
Slots.sidebar,
|
||||
Slots.workspace,
|
||||
Slots.contextPanel,
|
||||
];
|
||||
|
||||
SlotId? get activeSlot => _slot;
|
||||
String? get activeContributionId => _contributionId;
|
||||
@@ -25,4 +39,44 @@ class FocusTracker extends ChangeNotifier {
|
||||
_contributionId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// SlotHost calls this in initState. Replaces any prior registration
|
||||
/// for the same slot (handles hot-reload + slot rebuild).
|
||||
void registerSlotScope(SlotId slot, FocusScopeNode scope) {
|
||||
_scopes[slot] = scope;
|
||||
}
|
||||
|
||||
/// SlotHost calls this in dispose. No-op if a newer scope already
|
||||
/// took the slot.
|
||||
void unregisterSlotScope(SlotId slot, FocusScopeNode scope) {
|
||||
if (identical(_scopes[slot], scope)) {
|
||||
_scopes.remove(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/// Request focus on [slot]'s registered scope. No-op when the slot
|
||||
/// has no registered scope (panel not mounted, layout doesn't
|
||||
/// include it, etc.).
|
||||
void focusSlot(SlotId slot) {
|
||||
final scope = _scopes[slot];
|
||||
if (scope == null) return;
|
||||
scope.requestFocus();
|
||||
}
|
||||
|
||||
/// Move focus to the next slot in [traversalOrder], wrapping at the
|
||||
/// end. No-op when fewer than two slots are registered.
|
||||
void focusNextSlot() => _cycleSlot(1);
|
||||
|
||||
/// Move focus to the previous slot, wrapping at the start.
|
||||
void focusPreviousSlot() => _cycleSlot(-1);
|
||||
|
||||
void _cycleSlot(int delta) {
|
||||
final registered = traversalOrder.where(_scopes.containsKey).toList();
|
||||
if (registered.length < 2) return;
|
||||
final from = registered.indexOf(_slot ?? registered.first);
|
||||
final start = from < 0 ? 0 : from;
|
||||
final next = (start + delta) % registered.length;
|
||||
final wrapped = next < 0 ? next + registered.length : next;
|
||||
focusSlot(registered[wrapped]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/// Typed [Intent]s the keymap dispatches.
|
||||
///
|
||||
/// Widgets bind Actions to Intent types via `Actions.handler`. Preset
|
||||
/// YAML files reference Intents by their string id (`activate`,
|
||||
/// `palette.selectNext`, …). The id stays stable across SDK reshapes;
|
||||
/// the Dart class name can move without invalidating user keymaps.
|
||||
///
|
||||
/// Two flavors of intents live here:
|
||||
/// 1. **Flutter-provided** — `ActivateIntent` and `DismissIntent`
|
||||
/// are first-class Flutter intents; we reuse them so the keymap
|
||||
/// integrates with anything else in the Flutter ecosystem that
|
||||
/// already dispatches those (focus traversal, modal scrims, …).
|
||||
/// They're mapped by id in [builtinIntents] but not declared
|
||||
/// here.
|
||||
/// 2. **Clide-specific** — palette navigation, text scale, the
|
||||
/// `InvokeCommandIntent` bridge. Each subclass extends [Intent]
|
||||
/// directly.
|
||||
library;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
// -- Panel-to-panel focus traversal -----------------------------------------
|
||||
|
||||
/// Move focus to the next panel in `FocusTracker.traversalOrder`
|
||||
/// (sidebar → workspace → context). Bound to F6 by default.
|
||||
class FocusNextPanelIntent extends Intent {
|
||||
const FocusNextPanelIntent();
|
||||
}
|
||||
|
||||
/// Move focus to the previous panel. Bound to Shift+F6 by default.
|
||||
class FocusPreviousPanelIntent extends Intent {
|
||||
const FocusPreviousPanelIntent();
|
||||
}
|
||||
|
||||
// -- Command palette --------------------------------------------------------
|
||||
|
||||
/// Open the command palette.
|
||||
class PaletteOpenIntent extends Intent {
|
||||
const PaletteOpenIntent();
|
||||
}
|
||||
|
||||
/// Highlight the next palette result.
|
||||
class PaletteSelectNextIntent extends Intent {
|
||||
const PaletteSelectNextIntent();
|
||||
}
|
||||
|
||||
/// Highlight the previous palette result.
|
||||
class PaletteSelectPreviousIntent extends Intent {
|
||||
const PaletteSelectPreviousIntent();
|
||||
}
|
||||
|
||||
/// Invoke the highlighted palette result.
|
||||
class PaletteAcceptIntent extends Intent {
|
||||
const PaletteAcceptIntent();
|
||||
}
|
||||
|
||||
// -- Text scale -------------------------------------------------------------
|
||||
|
||||
class TextScaleIncreaseIntent extends Intent {
|
||||
const TextScaleIncreaseIntent();
|
||||
}
|
||||
|
||||
class TextScaleDecreaseIntent extends Intent {
|
||||
const TextScaleDecreaseIntent();
|
||||
}
|
||||
|
||||
class TextScaleResetIntent extends Intent {
|
||||
const TextScaleResetIntent();
|
||||
}
|
||||
|
||||
// -- Command bridge ---------------------------------------------------------
|
||||
|
||||
/// Generic "invoke this CommandRegistry command id" intent. Used for
|
||||
/// bindings that target a contributed command rather than a typed
|
||||
/// intent. The keymap creates one per binding; the Actions handler
|
||||
/// dispatches to the [CommandRegistry].
|
||||
class InvokeCommandIntent extends Intent {
|
||||
const InvokeCommandIntent(this.commandId);
|
||||
final String commandId;
|
||||
}
|
||||
|
||||
// -- Lookup -----------------------------------------------------------------
|
||||
|
||||
/// Map from YAML id → factory. Preset files reference intents by id;
|
||||
/// the keymap loader uses this to instantiate them. Intents with a
|
||||
/// configurable payload (only `InvokeCommandIntent` today) are not in
|
||||
/// the map — the loader recognises the `command:` prefix and
|
||||
/// instantiates them inline.
|
||||
final Map<String, Intent Function()> builtinIntents = {
|
||||
'activate': () => const ActivateIntent(),
|
||||
'dismiss': () => const DismissIntent(),
|
||||
'focus.nextPanel': () => const FocusNextPanelIntent(),
|
||||
'focus.previousPanel': () => const FocusPreviousPanelIntent(),
|
||||
'palette.open': () => const PaletteOpenIntent(),
|
||||
'palette.selectNext': () => const PaletteSelectNextIntent(),
|
||||
'palette.selectPrevious': () => const PaletteSelectPreviousIntent(),
|
||||
'palette.accept': () => const PaletteAcceptIntent(),
|
||||
'text.scaleIncrease': () => const TextScaleIncreaseIntent(),
|
||||
'text.scaleDecrease': () => const TextScaleDecreaseIntent(),
|
||||
'text.scaleReset': () => const TextScaleResetIntent(),
|
||||
};
|
||||
|
||||
/// Parse an intent id into an [Intent]. Returns null if the id is
|
||||
/// unknown. Recognises:
|
||||
/// - any builtin intent by its stable id
|
||||
/// - `command:<command-id>` → [InvokeCommandIntent]
|
||||
Intent? parseIntentId(String id) {
|
||||
final builtin = builtinIntents[id];
|
||||
if (builtin != null) return builtin();
|
||||
if (id.startsWith('command:')) {
|
||||
return InvokeCommandIntent(id.substring('command:'.length));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/// Layout-independent representation of a single keystroke.
|
||||
///
|
||||
/// The consultant's note (T-110) flagged the old `KeybindingResolver`
|
||||
/// for keying off `LogicalKeyboardKey.keyLabel`, which is locale-aware
|
||||
/// (US-QWERTY `Ctrl+/` differs from AZERTY `Ctrl+:`). We key off
|
||||
/// `LogicalKeyboardKey.keyId` instead — a stable u32 that survives
|
||||
/// layout changes.
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// One of the four POSIX-style modifier keys. Order is the canonical
|
||||
/// presentation order in YAML and toString output.
|
||||
enum KeyModifier {
|
||||
ctrl,
|
||||
alt,
|
||||
shift,
|
||||
meta;
|
||||
|
||||
/// Lowercase short form used in YAML (`ctrl`, `alt`, `shift`, `meta`).
|
||||
String get yaml => name;
|
||||
|
||||
/// Display string used in palette + tooltip hints.
|
||||
String get display => switch (this) {
|
||||
KeyModifier.ctrl => 'Ctrl',
|
||||
KeyModifier.alt => 'Alt',
|
||||
KeyModifier.shift => 'Shift',
|
||||
KeyModifier.meta => 'Cmd',
|
||||
};
|
||||
}
|
||||
|
||||
/// A modifier-set + a single key, identified by layout-independent
|
||||
/// `LogicalKeyboardKey.keyId`. Canonicalised on construction
|
||||
/// (modifiers sorted by enum order) so equality + hashing work for
|
||||
/// lookup-keying.
|
||||
@immutable
|
||||
class KeyChord {
|
||||
factory KeyChord({Set<KeyModifier> modifiers = const {}, required LogicalKeyboardKey key}) {
|
||||
final sorted = modifiers.toList()..sort((a, b) => a.index.compareTo(b.index));
|
||||
return KeyChord._(List.unmodifiable(sorted), key);
|
||||
}
|
||||
|
||||
const KeyChord._(this.modifiers, this.key);
|
||||
|
||||
final List<KeyModifier> modifiers;
|
||||
final LogicalKeyboardKey key;
|
||||
|
||||
/// Build from a Flutter [KeyEvent]. Returns null for non-down events
|
||||
/// or events whose logical key has no meaningful id (e.g. a bare
|
||||
/// modifier press in isolation).
|
||||
static KeyChord? fromKeyEvent(KeyEvent event, HardwareKeyboard kb) {
|
||||
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return null;
|
||||
final logical = event.logicalKey;
|
||||
// Bare modifier presses don't form a chord on their own.
|
||||
if (_isBareModifier(logical)) return null;
|
||||
final mods = <KeyModifier>{
|
||||
if (kb.isControlPressed) KeyModifier.ctrl,
|
||||
if (kb.isAltPressed) KeyModifier.alt,
|
||||
if (kb.isShiftPressed) KeyModifier.shift,
|
||||
if (kb.isMetaPressed) KeyModifier.meta,
|
||||
};
|
||||
return KeyChord(modifiers: mods, key: logical);
|
||||
}
|
||||
|
||||
/// Parse a YAML chord spec like `ctrl+shift+p`, `cmd+enter`, `escape`.
|
||||
/// Whitespace tolerated. Throws [FormatException] on unknown tokens
|
||||
/// or empty input.
|
||||
static KeyChord parse(String spec) {
|
||||
final trimmed = spec.trim();
|
||||
if (trimmed.isEmpty) throw const FormatException('empty key chord');
|
||||
final parts = trimmed.split('+').map((s) => s.trim()).toList();
|
||||
final keyName = parts.removeLast();
|
||||
if (keyName.isEmpty) throw FormatException('missing key in chord: "$spec"');
|
||||
final mods = <KeyModifier>{};
|
||||
for (final m in parts) {
|
||||
final mod = _modByName(m);
|
||||
if (mod == null) throw FormatException('unknown modifier "$m" in chord: "$spec"');
|
||||
mods.add(mod);
|
||||
}
|
||||
final key = _keyByName(keyName);
|
||||
if (key == null) throw FormatException('unknown key "$keyName" in chord: "$spec"');
|
||||
return KeyChord(modifiers: mods, key: key);
|
||||
}
|
||||
|
||||
/// Canonical YAML form: `ctrl+shift+p`.
|
||||
String get canonical {
|
||||
final modPart = modifiers.map((m) => m.yaml).join('+');
|
||||
final keyPart = _keyName(key);
|
||||
return modPart.isEmpty ? keyPart : '$modPart+$keyPart';
|
||||
}
|
||||
|
||||
/// Display form for UI hints: `Ctrl+Shift+P`.
|
||||
String get display {
|
||||
final modPart = modifiers.map((m) => m.display).join('+');
|
||||
final keyPart = _keyName(key).toUpperCase();
|
||||
return modPart.isEmpty ? keyPart : '$modPart+$keyPart';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is KeyChord && other.key == key && listEquals(other.modifiers, modifiers);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(key, Object.hashAll(modifiers));
|
||||
|
||||
@override
|
||||
String toString() => 'KeyChord($canonical)';
|
||||
}
|
||||
|
||||
bool _isBareModifier(LogicalKeyboardKey k) =>
|
||||
k == LogicalKeyboardKey.control ||
|
||||
k == LogicalKeyboardKey.controlLeft ||
|
||||
k == LogicalKeyboardKey.controlRight ||
|
||||
k == LogicalKeyboardKey.alt ||
|
||||
k == LogicalKeyboardKey.altLeft ||
|
||||
k == LogicalKeyboardKey.altRight ||
|
||||
k == LogicalKeyboardKey.shift ||
|
||||
k == LogicalKeyboardKey.shiftLeft ||
|
||||
k == LogicalKeyboardKey.shiftRight ||
|
||||
k == LogicalKeyboardKey.meta ||
|
||||
k == LogicalKeyboardKey.metaLeft ||
|
||||
k == LogicalKeyboardKey.metaRight ||
|
||||
k == LogicalKeyboardKey.fn;
|
||||
|
||||
KeyModifier? _modByName(String name) {
|
||||
switch (name.toLowerCase()) {
|
||||
case 'ctrl':
|
||||
case 'control':
|
||||
return KeyModifier.ctrl;
|
||||
case 'alt':
|
||||
case 'option':
|
||||
return KeyModifier.alt;
|
||||
case 'shift':
|
||||
return KeyModifier.shift;
|
||||
case 'meta':
|
||||
case 'cmd':
|
||||
case 'command':
|
||||
case 'super':
|
||||
case 'win':
|
||||
return KeyModifier.meta;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -- Key name <-> LogicalKeyboardKey ----------------------------------------
|
||||
//
|
||||
// We map YAML names to LogicalKeyboardKey instances. The map covers
|
||||
// every key a binding can plausibly want; unknown names throw on parse.
|
||||
|
||||
const Map<String, LogicalKeyboardKey> _byName = {
|
||||
// Letters
|
||||
'a': LogicalKeyboardKey.keyA, 'b': LogicalKeyboardKey.keyB, 'c': LogicalKeyboardKey.keyC,
|
||||
'd': LogicalKeyboardKey.keyD, 'e': LogicalKeyboardKey.keyE, 'f': LogicalKeyboardKey.keyF,
|
||||
'g': LogicalKeyboardKey.keyG, 'h': LogicalKeyboardKey.keyH, 'i': LogicalKeyboardKey.keyI,
|
||||
'j': LogicalKeyboardKey.keyJ, 'k': LogicalKeyboardKey.keyK, 'l': LogicalKeyboardKey.keyL,
|
||||
'm': LogicalKeyboardKey.keyM, 'n': LogicalKeyboardKey.keyN, 'o': LogicalKeyboardKey.keyO,
|
||||
'p': LogicalKeyboardKey.keyP, 'q': LogicalKeyboardKey.keyQ, 'r': LogicalKeyboardKey.keyR,
|
||||
's': LogicalKeyboardKey.keyS, 't': LogicalKeyboardKey.keyT, 'u': LogicalKeyboardKey.keyU,
|
||||
'v': LogicalKeyboardKey.keyV, 'w': LogicalKeyboardKey.keyW, 'x': LogicalKeyboardKey.keyX,
|
||||
'y': LogicalKeyboardKey.keyY, 'z': LogicalKeyboardKey.keyZ,
|
||||
// Digits
|
||||
'0': LogicalKeyboardKey.digit0, '1': LogicalKeyboardKey.digit1, '2': LogicalKeyboardKey.digit2,
|
||||
'3': LogicalKeyboardKey.digit3, '4': LogicalKeyboardKey.digit4, '5': LogicalKeyboardKey.digit5,
|
||||
'6': LogicalKeyboardKey.digit6, '7': LogicalKeyboardKey.digit7, '8': LogicalKeyboardKey.digit8,
|
||||
'9': LogicalKeyboardKey.digit9,
|
||||
// Function keys
|
||||
'f1': LogicalKeyboardKey.f1, 'f2': LogicalKeyboardKey.f2, 'f3': LogicalKeyboardKey.f3,
|
||||
'f4': LogicalKeyboardKey.f4, 'f5': LogicalKeyboardKey.f5, 'f6': LogicalKeyboardKey.f6,
|
||||
'f7': LogicalKeyboardKey.f7, 'f8': LogicalKeyboardKey.f8, 'f9': LogicalKeyboardKey.f9,
|
||||
'f10': LogicalKeyboardKey.f10, 'f11': LogicalKeyboardKey.f11, 'f12': LogicalKeyboardKey.f12,
|
||||
// Arrows
|
||||
'left': LogicalKeyboardKey.arrowLeft,
|
||||
'right': LogicalKeyboardKey.arrowRight,
|
||||
'up': LogicalKeyboardKey.arrowUp,
|
||||
'down': LogicalKeyboardKey.arrowDown,
|
||||
// Common control keys
|
||||
'enter': LogicalKeyboardKey.enter,
|
||||
'return': LogicalKeyboardKey.enter,
|
||||
'escape': LogicalKeyboardKey.escape,
|
||||
'esc': LogicalKeyboardKey.escape,
|
||||
'tab': LogicalKeyboardKey.tab,
|
||||
'space': LogicalKeyboardKey.space,
|
||||
'backspace': LogicalKeyboardKey.backspace,
|
||||
'delete': LogicalKeyboardKey.delete,
|
||||
'home': LogicalKeyboardKey.home,
|
||||
'end': LogicalKeyboardKey.end,
|
||||
'pageup': LogicalKeyboardKey.pageUp,
|
||||
'pagedown': LogicalKeyboardKey.pageDown,
|
||||
'insert': LogicalKeyboardKey.insert,
|
||||
// Punctuation (US-QWERTY positions; preset authors can rely on these names).
|
||||
'minus': LogicalKeyboardKey.minus, '-': LogicalKeyboardKey.minus,
|
||||
'equal': LogicalKeyboardKey.equal, '=': LogicalKeyboardKey.equal,
|
||||
'comma': LogicalKeyboardKey.comma, ',': LogicalKeyboardKey.comma,
|
||||
'period': LogicalKeyboardKey.period, '.': LogicalKeyboardKey.period,
|
||||
'slash': LogicalKeyboardKey.slash, '/': LogicalKeyboardKey.slash,
|
||||
'backslash': LogicalKeyboardKey.backslash, r'\\': LogicalKeyboardKey.backslash,
|
||||
'semicolon': LogicalKeyboardKey.semicolon, ';': LogicalKeyboardKey.semicolon,
|
||||
'quote': LogicalKeyboardKey.quote, "'": LogicalKeyboardKey.quote,
|
||||
'bracketLeft': LogicalKeyboardKey.bracketLeft, '[': LogicalKeyboardKey.bracketLeft,
|
||||
'bracketRight': LogicalKeyboardKey.bracketRight, ']': LogicalKeyboardKey.bracketRight,
|
||||
'backquote': LogicalKeyboardKey.backquote, '`': LogicalKeyboardKey.backquote,
|
||||
};
|
||||
|
||||
LogicalKeyboardKey? _keyByName(String name) => _byName[name.toLowerCase()];
|
||||
|
||||
String _keyName(LogicalKeyboardKey key) {
|
||||
// Reverse lookup; prefer the canonical (first) name for each key.
|
||||
for (final entry in _byName.entries) {
|
||||
if (entry.value == key) return entry.key;
|
||||
}
|
||||
// Fallback: use the debugName-like representation.
|
||||
return key.keyLabel.isNotEmpty ? key.keyLabel.toLowerCase() : 'key(0x${key.keyId.toRadixString(16)})';
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/// In-memory representation of a layered keymap.
|
||||
///
|
||||
/// A [Keymap] is built from one or more [KeymapLayer]s (preset →
|
||||
/// user-file overlay → settings overlay). Each layer contributes
|
||||
/// [KeymapBinding]s; later layers replace earlier bindings with the
|
||||
/// same (chord, when-clause) tuple.
|
||||
///
|
||||
/// At resolve time, the [Keymap] walks the layered list once per
|
||||
/// (chord, scope) and returns the [Intent] bound by the highest-
|
||||
/// precedence matching layer.
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart' show Intent;
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
import 'intents.dart';
|
||||
import 'key_chord.dart';
|
||||
import 'when_clause.dart';
|
||||
|
||||
/// One row in a layer: a chord, an optional when-clause, and the
|
||||
/// intent to fire when the chord matches and the when-clause is true.
|
||||
@immutable
|
||||
class KeymapBinding {
|
||||
const KeymapBinding({
|
||||
required this.chord,
|
||||
required this.intent,
|
||||
this.when,
|
||||
});
|
||||
|
||||
final KeyChord chord;
|
||||
final Intent intent;
|
||||
final WhenExpr? when;
|
||||
|
||||
@override
|
||||
String toString() => 'Binding($chord → ${intent.runtimeType}${when == null ? '' : ' when $when'})';
|
||||
}
|
||||
|
||||
/// One source of bindings. Layers are merged in order — later layers
|
||||
/// take precedence on (chord, when) collisions.
|
||||
@immutable
|
||||
class KeymapLayer {
|
||||
const KeymapLayer({required this.name, required this.bindings});
|
||||
|
||||
final String name;
|
||||
final List<KeymapBinding> bindings;
|
||||
|
||||
/// Parse a YAML document into a layer. Expected shape:
|
||||
///
|
||||
/// ```yaml
|
||||
/// name: default
|
||||
/// bindings:
|
||||
/// - intent: activate
|
||||
/// keys: [enter, space]
|
||||
/// when: focused
|
||||
/// - intent: palette.selectNext
|
||||
/// keys: [down]
|
||||
/// when: palette.open
|
||||
/// ```
|
||||
///
|
||||
/// `keys:` may be a single chord string or a list. `when:` is
|
||||
/// optional. Unknown intent ids cause a [FormatException].
|
||||
factory KeymapLayer.fromYaml(String source, {String? nameOverride}) {
|
||||
final doc = loadYaml(source);
|
||||
if (doc is! YamlMap) {
|
||||
throw const FormatException('keymap YAML must be a map at top level');
|
||||
}
|
||||
final name = nameOverride ?? (doc['name'] as String? ?? 'unnamed');
|
||||
final raw = doc['bindings'];
|
||||
if (raw is! YamlList) {
|
||||
throw const FormatException('keymap YAML must define `bindings:` as a list');
|
||||
}
|
||||
final out = <KeymapBinding>[];
|
||||
for (final entry in raw) {
|
||||
if (entry is! YamlMap) {
|
||||
throw FormatException('binding entries must be maps; got $entry');
|
||||
}
|
||||
final intentId = entry['intent'] as String?;
|
||||
if (intentId == null) {
|
||||
throw FormatException('binding missing `intent:` — $entry');
|
||||
}
|
||||
final intent = parseIntentId(intentId);
|
||||
if (intent == null) {
|
||||
throw FormatException('unknown intent id "$intentId" — $entry');
|
||||
}
|
||||
final keysRaw = entry['keys'];
|
||||
final keySpecs = <String>[];
|
||||
if (keysRaw is String) {
|
||||
keySpecs.add(keysRaw);
|
||||
} else if (keysRaw is YamlList) {
|
||||
for (final k in keysRaw) {
|
||||
if (k is! String) throw FormatException('keys must be strings; got $k in $entry');
|
||||
keySpecs.add(k);
|
||||
}
|
||||
} else {
|
||||
throw FormatException('binding missing `keys:` (string or list of strings) — $entry');
|
||||
}
|
||||
final when = WhenExpr.tryParse(entry['when'] as String?);
|
||||
for (final spec in keySpecs) {
|
||||
out.add(KeymapBinding(chord: KeyChord.parse(spec), intent: intent, when: when));
|
||||
}
|
||||
}
|
||||
return KeymapLayer(name: name, bindings: out);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'KeymapLayer($name, ${bindings.length} binding${bindings.length == 1 ? '' : 's'})';
|
||||
}
|
||||
|
||||
/// A flattened keymap, ready for resolution.
|
||||
@immutable
|
||||
class Keymap {
|
||||
Keymap(this.layers) : _effective = _flatten(layers);
|
||||
|
||||
final List<KeymapLayer> layers;
|
||||
final List<KeymapBinding> _effective;
|
||||
|
||||
/// Resolve a [chord] against the current [context]. Returns the
|
||||
/// highest-precedence binding whose chord matches and whose when-
|
||||
/// clause (if any) evaluates true. Returns null if no match.
|
||||
Intent? resolve(KeyChord chord, Map<String, bool> context) {
|
||||
// Effective list is highest-precedence-first; first match wins.
|
||||
for (final b in _effective) {
|
||||
if (b.chord != chord) continue;
|
||||
if (b.when != null && !b.when!.evaluate(context)) continue;
|
||||
return b.intent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// All resolved bindings in effective-precedence order. Exposed for
|
||||
/// debug surfaces (keybindings UI, palette hints).
|
||||
List<KeymapBinding> get effectiveBindings => List.unmodifiable(_effective);
|
||||
|
||||
/// Concatenate layers in REVERSE order (last layer first). Later
|
||||
/// layers fully shadow earlier (chord, when) collisions: when we walk
|
||||
/// the list, the first matching entry wins, so highest-precedence
|
||||
/// must come first. We don't dedupe — a no-op match in a later layer
|
||||
/// just earns the first slot.
|
||||
static List<KeymapBinding> _flatten(List<KeymapLayer> layers) {
|
||||
return [
|
||||
for (final l in layers.reversed) ...l.bindings,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'Keymap(${layers.map((l) => l.name).join(' < ')})';
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/// Kernel service that owns the active [Keymap], scope context, and
|
||||
/// resolution surface.
|
||||
///
|
||||
/// Layering (lowest precedence → highest):
|
||||
/// 1. The active preset (asset under `assets/keymaps/<preset>.yaml`).
|
||||
/// Selected by the `app.keymap.preset` setting; defaults to
|
||||
/// `default`.
|
||||
/// 2. A user keymap file at `<appDir>/keybindings.yaml` (per-user
|
||||
/// power-user overrides).
|
||||
/// 3. A settings-stored JSON overlay at `app.keymap.overrides` —
|
||||
/// list of `{intent, keys, when?}` maps in the same shape as
|
||||
/// preset YAML.
|
||||
///
|
||||
/// Scope context is a `Map<String, bool>` keyed by named flags (e.g.
|
||||
/// `palette.open`, `editor.focused`). Producing services call
|
||||
/// [setScopeFlag] when their state changes; consumers reference the
|
||||
/// flag name in when-clauses.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart' show AssetBundle, KeyEvent, HardwareKeyboard, rootBundle;
|
||||
import 'package:flutter/widgets.dart' show Intent;
|
||||
|
||||
import '../settings.dart';
|
||||
import 'intents.dart';
|
||||
import 'key_chord.dart';
|
||||
import 'keymap.dart';
|
||||
import 'when_clause.dart';
|
||||
|
||||
/// Setting key for the active preset name.
|
||||
const String kKeymapPresetSetting = 'app.keymap.preset';
|
||||
|
||||
/// Setting key for the JSON overlay list.
|
||||
const String kKeymapOverridesSetting = 'app.keymap.overrides';
|
||||
|
||||
/// Filename for the user keymap file under the app dir.
|
||||
const String kKeymapUserFile = 'keybindings.yaml';
|
||||
|
||||
class KeymapService extends ChangeNotifier {
|
||||
KeymapService({
|
||||
required SettingsStore settings,
|
||||
required Directory appDir,
|
||||
AssetBundle? bundle,
|
||||
}) : _settings = settings,
|
||||
_appDir = appDir,
|
||||
_bundle = bundle ?? rootBundle;
|
||||
|
||||
final SettingsStore _settings;
|
||||
final Directory _appDir;
|
||||
final AssetBundle _bundle;
|
||||
|
||||
Keymap? _active;
|
||||
final Map<String, bool> _scope = {};
|
||||
|
||||
// The four layer slots, lowest to highest precedence. Held
|
||||
// separately so [registerCommandBinding] can refresh the
|
||||
// contributions layer without re-reading the preset / file /
|
||||
// settings.
|
||||
KeymapLayer? _preset;
|
||||
final List<KeymapBinding> _contributions = [];
|
||||
KeymapLayer? _userFile;
|
||||
KeymapLayer? _settingsOverlay;
|
||||
|
||||
/// The currently effective layered keymap. Null before [load] runs.
|
||||
Keymap? get keymap => _active;
|
||||
|
||||
/// Live read-only view of the scope context.
|
||||
Map<String, bool> get scope => Map.unmodifiable(_scope);
|
||||
|
||||
/// Read the preset from settings (default `default`), load all
|
||||
/// non-contribution layers, and rebuild the active keymap. Safe to
|
||||
/// call repeatedly. Contributions registered via
|
||||
/// [registerCommandBinding] are preserved across reloads.
|
||||
Future<void> load() async {
|
||||
final presetName = _settings.get<String>(kKeymapPresetSetting) ?? 'default';
|
||||
|
||||
// Preset (asset).
|
||||
try {
|
||||
final src = await _bundle.loadString('assets/keymaps/$presetName.yaml');
|
||||
_preset = KeymapLayer.fromYaml(src, nameOverride: presetName);
|
||||
} catch (_) {
|
||||
// A missing preset means we ship without a default. Tests can
|
||||
// inject a custom bundle. We don't surface this beyond an empty
|
||||
// active map.
|
||||
_preset = null;
|
||||
}
|
||||
|
||||
// User file overlay.
|
||||
final userFile = File('${_appDir.path}/$kKeymapUserFile');
|
||||
if (await userFile.exists()) {
|
||||
try {
|
||||
_userFile = KeymapLayer.fromYaml(await userFile.readAsString(), nameOverride: 'user-file');
|
||||
} on FormatException {
|
||||
_userFile = null;
|
||||
}
|
||||
} else {
|
||||
_userFile = null;
|
||||
}
|
||||
|
||||
// Settings overlay.
|
||||
final overlay = _settings.get<List<Object?>>(kKeymapOverridesSetting);
|
||||
if (overlay != null && overlay.isNotEmpty) {
|
||||
final asYaml = StringBuffer('name: settings-overlay\nbindings:\n');
|
||||
for (final entry in overlay) {
|
||||
if (entry is! Map) continue;
|
||||
asYaml.writeln(' - ${jsonEncode(entry)}');
|
||||
}
|
||||
try {
|
||||
_settingsOverlay = KeymapLayer.fromYaml(asYaml.toString(), nameOverride: 'settings-overlay');
|
||||
} on FormatException {
|
||||
_settingsOverlay = null;
|
||||
}
|
||||
} else {
|
||||
_settingsOverlay = null;
|
||||
}
|
||||
|
||||
_rebuildActive();
|
||||
}
|
||||
|
||||
/// Register a programmatic chord → command-id binding (typically
|
||||
/// from an extension's `defaultBinding`). Contributions form a
|
||||
/// layer between preset and user-file: extensions establish their
|
||||
/// defaults, the user can override either via the user file or
|
||||
/// settings overlay.
|
||||
void registerCommandBinding(String chordSpec, String commandId, {String? when}) {
|
||||
_contributions.add(KeymapBinding(
|
||||
chord: KeyChord.parse(chordSpec),
|
||||
intent: InvokeCommandIntent(commandId),
|
||||
when: WhenExpr.tryParse(when),
|
||||
));
|
||||
_rebuildActive();
|
||||
}
|
||||
|
||||
/// Remove all extension-contributed bindings for [commandId]. Used
|
||||
/// when an extension is disabled or unregistered.
|
||||
void unregisterCommandBindings(String commandId) {
|
||||
final before = _contributions.length;
|
||||
_contributions.removeWhere((b) {
|
||||
final i = b.intent;
|
||||
return i is InvokeCommandIntent && i.commandId == commandId;
|
||||
});
|
||||
if (_contributions.length != before) {
|
||||
_rebuildActive();
|
||||
}
|
||||
}
|
||||
|
||||
void _rebuildActive() {
|
||||
final layers = <KeymapLayer>[
|
||||
if (_preset != null) _preset!,
|
||||
KeymapLayer(name: 'contributions', bindings: List.unmodifiable(_contributions)),
|
||||
if (_userFile != null) _userFile!,
|
||||
if (_settingsOverlay != null) _settingsOverlay!,
|
||||
];
|
||||
_active = Keymap(layers);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Resolve a [KeyEvent] against the active keymap and current scope.
|
||||
/// Returns null when nothing matches.
|
||||
Intent? resolveEvent(KeyEvent event, HardwareKeyboard kb) {
|
||||
final km = _active;
|
||||
if (km == null) return null;
|
||||
final chord = KeyChord.fromKeyEvent(event, kb);
|
||||
if (chord == null) return null;
|
||||
return km.resolve(chord, _scope);
|
||||
}
|
||||
|
||||
/// Set a named scope flag. Producers should call this when their
|
||||
/// state changes so when-clauses re-evaluate correctly. Notifies
|
||||
/// listeners when the value actually changes.
|
||||
void setScopeFlag(String name, bool value) {
|
||||
if (_scope[name] == value) return;
|
||||
_scope[name] = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear a named scope flag.
|
||||
void clearScopeFlag(String name) {
|
||||
if (!_scope.containsKey(name)) return;
|
||||
_scope.remove(name);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Switch presets. Persists the new preset name to settings and
|
||||
/// re-loads the layered keymap.
|
||||
Future<void> setPreset(String name) async {
|
||||
await _settings.set<String>(kKeymapPresetSetting, name);
|
||||
await load();
|
||||
}
|
||||
|
||||
/// All effective bindings, highest-precedence first. Useful for
|
||||
/// debug surfaces and keybinding hints in the UI.
|
||||
List<KeymapBinding> get effectiveBindings => _active?.effectiveBindings ?? const [];
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/// Boolean "when:" expressions over a named context bag, VS-Code style.
|
||||
///
|
||||
/// Grammar:
|
||||
/// expr := or
|
||||
/// or := and ('||' and)*
|
||||
/// and := unary ('&&' unary)*
|
||||
/// unary := '!' unary | atom
|
||||
/// atom := IDENT | '(' expr ')'
|
||||
/// IDENT := [a-zA-Z_][a-zA-Z0-9._-]*
|
||||
///
|
||||
/// Identifiers resolve against a `Map<String, bool>` context. A missing
|
||||
/// identifier evaluates to `false` — bindings can assume any required
|
||||
/// scope flag is published by the producing service.
|
||||
///
|
||||
/// The grammar is intentionally small: no equality, no arithmetic, no
|
||||
/// string literals. If a binding needs more, the producing service
|
||||
/// should publish a richer named flag (e.g. `editor.dirty`).
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
sealed class WhenExpr {
|
||||
const WhenExpr();
|
||||
|
||||
/// Evaluate against [context]. Missing identifiers are `false`.
|
||||
bool evaluate(Map<String, bool> context);
|
||||
|
||||
/// Parse [source]. Throws [FormatException] on syntax errors.
|
||||
static WhenExpr parse(String source) => _Parser(source).parseAll();
|
||||
|
||||
/// Convenience: null on empty input, otherwise [parse].
|
||||
static WhenExpr? tryParse(String? source) {
|
||||
if (source == null || source.trim().isEmpty) return null;
|
||||
return parse(source);
|
||||
}
|
||||
}
|
||||
|
||||
class WhenIdent extends WhenExpr {
|
||||
const WhenIdent(this.name);
|
||||
final String name;
|
||||
@override
|
||||
bool evaluate(Map<String, bool> context) => context[name] ?? false;
|
||||
@override
|
||||
String toString() => name;
|
||||
}
|
||||
|
||||
class WhenNot extends WhenExpr {
|
||||
const WhenNot(this.child);
|
||||
final WhenExpr child;
|
||||
@override
|
||||
bool evaluate(Map<String, bool> context) => !child.evaluate(context);
|
||||
@override
|
||||
String toString() => '!$child';
|
||||
}
|
||||
|
||||
class WhenAnd extends WhenExpr {
|
||||
const WhenAnd(this.left, this.right);
|
||||
final WhenExpr left;
|
||||
final WhenExpr right;
|
||||
@override
|
||||
bool evaluate(Map<String, bool> context) => left.evaluate(context) && right.evaluate(context);
|
||||
@override
|
||||
String toString() => '($left && $right)';
|
||||
}
|
||||
|
||||
class WhenOr extends WhenExpr {
|
||||
const WhenOr(this.left, this.right);
|
||||
final WhenExpr left;
|
||||
final WhenExpr right;
|
||||
@override
|
||||
bool evaluate(Map<String, bool> context) => left.evaluate(context) || right.evaluate(context);
|
||||
@override
|
||||
String toString() => '($left || $right)';
|
||||
}
|
||||
|
||||
// -- Parser -----------------------------------------------------------------
|
||||
|
||||
class _Parser {
|
||||
_Parser(this._src);
|
||||
|
||||
final String _src;
|
||||
int _pos = 0;
|
||||
|
||||
WhenExpr parseAll() {
|
||||
_skip();
|
||||
final e = _or();
|
||||
_skip();
|
||||
if (_pos != _src.length) {
|
||||
throw FormatException('unexpected "${_src[_pos]}" at column ${_pos + 1} in when-clause: "$_src"');
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
WhenExpr _or() {
|
||||
var left = _and();
|
||||
while (_consume('||')) {
|
||||
final right = _and();
|
||||
left = WhenOr(left, right);
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
WhenExpr _and() {
|
||||
var left = _unary();
|
||||
while (_consume('&&')) {
|
||||
final right = _unary();
|
||||
left = WhenAnd(left, right);
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
WhenExpr _unary() {
|
||||
_skip();
|
||||
if (_consume('!')) {
|
||||
return WhenNot(_unary());
|
||||
}
|
||||
return _atom();
|
||||
}
|
||||
|
||||
WhenExpr _atom() {
|
||||
_skip();
|
||||
if (_consume('(')) {
|
||||
final inner = _or();
|
||||
_skip();
|
||||
if (!_consume(')')) {
|
||||
throw FormatException('expected ")" at column ${_pos + 1} in when-clause: "$_src"');
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
final ident = _ident();
|
||||
if (ident == null) {
|
||||
final at = _pos < _src.length ? '"${_src[_pos]}"' : 'end of input';
|
||||
throw FormatException('expected identifier at column ${_pos + 1} in when-clause: "$_src" (got $at)');
|
||||
}
|
||||
return WhenIdent(ident);
|
||||
}
|
||||
|
||||
String? _ident() {
|
||||
_skip();
|
||||
final start = _pos;
|
||||
if (_pos >= _src.length) return null;
|
||||
final first = _src.codeUnitAt(_pos);
|
||||
if (!_isIdentStart(first)) return null;
|
||||
_pos++;
|
||||
while (_pos < _src.length && _isIdentCont(_src.codeUnitAt(_pos))) {
|
||||
_pos++;
|
||||
}
|
||||
return _src.substring(start, _pos);
|
||||
}
|
||||
|
||||
bool _consume(String token) {
|
||||
_skip();
|
||||
if (_src.startsWith(token, _pos)) {
|
||||
_pos += token.length;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _skip() {
|
||||
while (_pos < _src.length && _isSpace(_src.codeUnitAt(_pos))) {
|
||||
_pos++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _isSpace(int c) => c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D;
|
||||
|
||||
bool _isIdentStart(int c) {
|
||||
// a-z | A-Z | _
|
||||
return (c >= 0x61 && c <= 0x7A) || (c >= 0x41 && c <= 0x5A) || c == 0x5F;
|
||||
}
|
||||
|
||||
bool _isIdentCont(int c) {
|
||||
// a-z | A-Z | 0-9 | _ . -
|
||||
return _isIdentStart(c) || (c >= 0x30 && c <= 0x39) || c == 0x2E || c == 0x2D;
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import 'package:clide/kernel/src/panels/arrangement.dart';
|
||||
import 'package:clide/kernel/src/panels/slot_id.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// A 4-px draggable splitter that adjusts the size of [slot] in the
|
||||
/// given [arrangement]. Slot hosts wrap this around their edges to make
|
||||
/// the three-column layout resizable.
|
||||
/// A 4-px splitter that adjusts the size of [slot] in [arrangement].
|
||||
/// Slot hosts wrap this around their edges to make the three-column
|
||||
/// layout resizable.
|
||||
///
|
||||
/// Drag with the mouse, or tab to it and use the arrow keys (Shift =
|
||||
/// coarse step). Exposes a `slider` Semantics node so screen readers
|
||||
/// announce the current width.
|
||||
class DragResizeHandle extends StatefulWidget {
|
||||
const DragResizeHandle({
|
||||
super.key,
|
||||
@@ -21,6 +26,8 @@ class DragResizeHandle extends StatefulWidget {
|
||||
final double thickness;
|
||||
|
||||
static const defaultThickness = 8.0;
|
||||
static const double stepFine = 10.0;
|
||||
static const double stepCoarse = 50.0;
|
||||
|
||||
@override
|
||||
State<DragResizeHandle> createState() => _DragResizeHandleState();
|
||||
@@ -28,32 +35,57 @@ class DragResizeHandle extends StatefulWidget {
|
||||
|
||||
class _DragResizeHandleState extends State<DragResizeHandle> {
|
||||
bool _hovered = false;
|
||||
bool _focused = false;
|
||||
double? _dragStartSize;
|
||||
Offset? _dragStartPointer;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final lineColor = _hovered ? tokens.panelActiveBorder : tokens.dividerColor;
|
||||
final lineColor = (_hovered || _focused) ? tokens.panelActiveBorder : tokens.dividerColor;
|
||||
|
||||
return MouseRegion(
|
||||
cursor: widget.axis == Axis.horizontal ? SystemMouseCursors.resizeColumn : SystemMouseCursors.resizeRow,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Listener(
|
||||
onPointerDown: _onDown,
|
||||
onPointerMove: _onMove,
|
||||
onPointerUp: _onUp,
|
||||
child: Container(
|
||||
width: widget.axis == Axis.horizontal ? widget.thickness : null,
|
||||
height: widget.axis == Axis.vertical ? widget.thickness : null,
|
||||
color: tokens.chromeBackground,
|
||||
child: Align(
|
||||
alignment: widget.slot == Slots.sidebar ? Alignment.centerRight : Alignment.centerLeft,
|
||||
final size = widget.arrangement.sizeOf(widget.slot);
|
||||
|
||||
return Semantics(
|
||||
container: true,
|
||||
slider: true,
|
||||
label: _semanticLabel(),
|
||||
value: size == null ? null : '${size.round()} pixels',
|
||||
increasedValue: size == null ? null : '${(size + DragResizeHandle.stepFine).round()} pixels',
|
||||
decreasedValue: size == null ? null : '${(size - DragResizeHandle.stepFine).round()} pixels',
|
||||
onIncrease: () => _bump(DragResizeHandle.stepFine),
|
||||
onDecrease: () => _bump(-DragResizeHandle.stepFine),
|
||||
child: FocusableActionDetector(
|
||||
onShowFocusHighlight: (v) => setState(() => _focused = v),
|
||||
shortcuts: _shortcuts(),
|
||||
actions: <Type, Action<Intent>>{
|
||||
_BumpIntent: CallbackAction<_BumpIntent>(
|
||||
onInvoke: (intent) {
|
||||
_bump(intent.delta);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: MouseRegion(
|
||||
cursor: widget.axis == Axis.horizontal ? SystemMouseCursors.resizeColumn : SystemMouseCursors.resizeRow,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Listener(
|
||||
onPointerDown: _onDown,
|
||||
onPointerMove: _onMove,
|
||||
onPointerUp: _onUp,
|
||||
child: Container(
|
||||
width: widget.axis == Axis.horizontal ? 1 : null,
|
||||
height: widget.axis == Axis.vertical ? 1 : null,
|
||||
color: lineColor,
|
||||
width: widget.axis == Axis.horizontal ? widget.thickness : null,
|
||||
height: widget.axis == Axis.vertical ? widget.thickness : null,
|
||||
color: tokens.chromeBackground,
|
||||
child: Align(
|
||||
alignment: widget.slot == Slots.sidebar ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: widget.axis == Axis.horizontal ? (_focused ? 2 : 1) : null,
|
||||
height: widget.axis == Axis.vertical ? (_focused ? 2 : 1) : null,
|
||||
color: lineColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -61,6 +93,38 @@ class _DragResizeHandleState extends State<DragResizeHandle> {
|
||||
);
|
||||
}
|
||||
|
||||
String _semanticLabel() {
|
||||
final axis = widget.axis == Axis.horizontal ? 'width' : 'height';
|
||||
if (widget.slot == Slots.sidebar) return 'Sidebar $axis';
|
||||
if (widget.slot == Slots.contextPanel) return 'Context panel $axis';
|
||||
return '${widget.slot.value} $axis';
|
||||
}
|
||||
|
||||
Map<ShortcutActivator, Intent> _shortcuts() {
|
||||
final horiz = widget.axis == Axis.horizontal;
|
||||
final fine = DragResizeHandle.stepFine;
|
||||
final coarse = DragResizeHandle.stepCoarse;
|
||||
return <ShortcutActivator, Intent>{
|
||||
if (horiz) ...{
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft): _BumpIntent(-fine),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight): _BumpIntent(fine),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): _BumpIntent(-coarse),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): _BumpIntent(coarse),
|
||||
} else ...{
|
||||
const SingleActivator(LogicalKeyboardKey.arrowUp): _BumpIntent(-fine),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown): _BumpIntent(fine),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): _BumpIntent(-coarse),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): _BumpIntent(coarse),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
void _bump(double rawDelta) {
|
||||
final current = widget.arrangement.sizeOf(widget.slot);
|
||||
if (current == null) return;
|
||||
widget.arrangement.setSize(widget.slot, bumpedSlotSize(slot: widget.slot, current: current, rawDelta: rawDelta));
|
||||
}
|
||||
|
||||
void _onDown(PointerDownEvent e) {
|
||||
_dragStartSize = widget.arrangement.sizeOf(widget.slot);
|
||||
_dragStartPointer = e.position;
|
||||
@@ -80,3 +144,22 @@ class _DragResizeHandleState extends State<DragResizeHandle> {
|
||||
_dragStartPointer = null;
|
||||
}
|
||||
}
|
||||
|
||||
class _BumpIntent extends Intent {
|
||||
const _BumpIntent(this.delta);
|
||||
final double delta;
|
||||
}
|
||||
|
||||
/// Apply a raw delta in the natural axis direction. Drag and arrow
|
||||
/// keys both call this so the keyboard mirrors the drag: positive
|
||||
/// delta = right/down. Context-panel sits on the right edge of the
|
||||
/// app, so we flip the sign there — right-arrow should *shrink* it,
|
||||
/// matching how dragging the left-edge handle rightward works.
|
||||
double bumpedSlotSize({
|
||||
required SlotId slot,
|
||||
required double current,
|
||||
required double rawDelta,
|
||||
}) {
|
||||
final delta = slot == Slots.contextPanel ? -rawDelta : rawDelta;
|
||||
return current + delta;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,13 @@ class SchedulerService {
|
||||
StreamSubscription<dynamic>? _sub;
|
||||
StreamSubscription<dynamic>? _projectSub;
|
||||
|
||||
/// Tracks the spawn future so [_stopTicker] can await it before
|
||||
/// killing — otherwise a stop racing a still-spawning isolate
|
||||
/// leaves `_isolate` null at kill time and the just-spawned isolate
|
||||
/// (with its `Timer.periodic`) leaks forever. Same race shape we
|
||||
/// fixed in `NativePty` via `_readerReady` (T-96).
|
||||
Future<Isolate?>? _isolateReady;
|
||||
|
||||
/// Listen for project lifecycle events. The periodic ticker only runs
|
||||
/// while a project is open — no wasted cycles on the welcome screen.
|
||||
void start() {
|
||||
@@ -47,8 +54,8 @@ class SchedulerService {
|
||||
|
||||
/// Start the periodic ticker and fire an immediate first cycle so
|
||||
/// all panels refresh without waiting for the first interval.
|
||||
void _startTicker() {
|
||||
_stopTicker();
|
||||
Future<void> _startTicker() async {
|
||||
await _stopTicker();
|
||||
|
||||
// Stagger the initial ticks to avoid a rebuild storm on project open.
|
||||
var delay = 0;
|
||||
@@ -68,14 +75,26 @@ class SchedulerService {
|
||||
_events.emit(SchedulerTick(tier: tier));
|
||||
}
|
||||
});
|
||||
Isolate.spawn(_isolateEntry, _port!.sendPort).then((iso) => _isolate = iso);
|
||||
_isolateReady = Isolate.spawn(_isolateEntry, _port!.sendPort);
|
||||
_isolateReady!.then((iso) => _isolate = iso).catchError((_) => null);
|
||||
}
|
||||
|
||||
void _stopTicker() {
|
||||
Future<void> _stopTicker() async {
|
||||
// Await any in-flight spawn so we never miss killing an isolate
|
||||
// that's mid-creation — see _isolateReady.
|
||||
if (_isolateReady != null) {
|
||||
try {
|
||||
final pending = await _isolateReady;
|
||||
_isolate ??= pending;
|
||||
} catch (_) {
|
||||
// Spawn failed; nothing to kill.
|
||||
}
|
||||
}
|
||||
_sub?.cancel();
|
||||
_port?.close();
|
||||
_isolate?.kill(priority: Isolate.immediate);
|
||||
_isolate = null;
|
||||
_isolateReady = null;
|
||||
_port = null;
|
||||
_sub = null;
|
||||
}
|
||||
@@ -97,8 +116,8 @@ class SchedulerService {
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
Future<void> dispose() async {
|
||||
_projectSub?.cancel();
|
||||
_stopTicker();
|
||||
await _stopTicker();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:ffi';
|
||||
import 'dart:io' show File, Platform;
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
// -- Opaque handles ----------------------------------------------------------
|
||||
|
||||
@@ -146,6 +147,60 @@ class TreeSitterLib {
|
||||
wasmEngineNew = lib.lookupFunction<_WasmEngineNew, DWasmEngineNew>('wasm_engine_new'),
|
||||
wasmEngineDelete = lib.lookupFunction<_WasmEngineDelete, DWasmEngineDelete>('wasm_engine_delete');
|
||||
|
||||
/// Constructs a [TreeSitterLib] from caller-supplied Dart closures. Used by
|
||||
/// tests to substitute the FFI surface without dlopen'ing the real library;
|
||||
/// each unspecified function defaults to a safe no-op (pointers return
|
||||
/// `nullptr`, ints return `0`, bools return `false`). Tests override the
|
||||
/// few entries they exercise.
|
||||
@visibleForTesting
|
||||
TreeSitterLib.testing({
|
||||
DTsParserNew? parserNew,
|
||||
DTsParserDelete? parserDelete,
|
||||
DTsParserSetLanguage? parserSetLanguage,
|
||||
DTsParserSetWasmStore? parserSetWasmStore,
|
||||
DTsParserParseString? parserParseString,
|
||||
DTsTreeDelete? treeDelete,
|
||||
DTsTreeRootNode? treeRootNode,
|
||||
DTsNodeStartByte? nodeStartByte,
|
||||
DTsNodeEndByte? nodeEndByte,
|
||||
DTsQueryNew? queryNew,
|
||||
DTsQueryDelete? queryDelete,
|
||||
DTsQueryCaptureCount? queryCaptureCount,
|
||||
DTsQueryCaptureNameForId? queryCaptureNameForId,
|
||||
DTsQueryCursorNew? queryCursorNew,
|
||||
DTsQueryCursorDelete? queryCursorDelete,
|
||||
DTsQueryCursorExec? queryCursorExec,
|
||||
DTsQueryCursorNextMatch? queryCursorNextMatch,
|
||||
DTsWasmStoreNew? wasmStoreNew,
|
||||
DTsWasmStoreDelete? wasmStoreDelete,
|
||||
DTsWasmStoreLoadLanguage? wasmStoreLoadLanguage,
|
||||
DWasmEngineNew? wasmEngineNew,
|
||||
DWasmEngineDelete? wasmEngineDelete,
|
||||
}) : parserNew = parserNew ?? (() => nullptr),
|
||||
parserDelete = parserDelete ?? ((_) {}),
|
||||
parserSetLanguage = parserSetLanguage ?? ((_, __) => false),
|
||||
parserSetWasmStore = parserSetWasmStore ?? ((_, __) {}),
|
||||
parserParseString = parserParseString ?? ((_, __, ___, ____) => nullptr),
|
||||
treeDelete = treeDelete ?? ((_) {}),
|
||||
// Leaks a zeroed TSNode allocation — only hit when the test supplies
|
||||
// a non-null parserParseString without also supplying treeRootNode.
|
||||
treeRootNode = treeRootNode ?? ((_) => calloc<TSNode>().ref),
|
||||
nodeStartByte = nodeStartByte ?? ((_) => 0),
|
||||
nodeEndByte = nodeEndByte ?? ((_) => 0),
|
||||
queryNew = queryNew ?? ((_, __, ___, ____, _____) => nullptr),
|
||||
queryDelete = queryDelete ?? ((_) {}),
|
||||
queryCaptureCount = queryCaptureCount ?? ((_) => 0),
|
||||
queryCaptureNameForId = queryCaptureNameForId ?? ((_, __, ___) => nullptr),
|
||||
queryCursorNew = queryCursorNew ?? (() => nullptr),
|
||||
queryCursorDelete = queryCursorDelete ?? ((_) {}),
|
||||
queryCursorExec = queryCursorExec ?? ((_, __, ___) {}),
|
||||
queryCursorNextMatch = queryCursorNextMatch ?? ((_, __) => false),
|
||||
wasmStoreNew = wasmStoreNew ?? ((_, __) => nullptr),
|
||||
wasmStoreDelete = wasmStoreDelete ?? ((_) {}),
|
||||
wasmStoreLoadLanguage = wasmStoreLoadLanguage ?? ((_, __, ___, ____, _____) => nullptr),
|
||||
wasmEngineNew = wasmEngineNew ?? (() => nullptr),
|
||||
wasmEngineDelete = wasmEngineDelete ?? ((_) {});
|
||||
|
||||
final DTsParserNew parserNew;
|
||||
final DTsParserDelete parserDelete;
|
||||
final DTsParserSetLanguage parserSetLanguage;
|
||||
@@ -181,6 +236,22 @@ class TreeSitterLib {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Constructs a [TreeSitterLib] from an already-loaded [DynamicLibrary].
|
||||
/// Used by the smoke test to dlopen the vendored `libtree-sitter.so`
|
||||
/// directly without going through the global `init()` / `_instance`
|
||||
/// dance, so the test stays isolated from the singleton.
|
||||
@visibleForTesting
|
||||
static TreeSitterLib fromDynamicLibrary(DynamicLibrary lib) => TreeSitterLib._(lib);
|
||||
|
||||
/// Most recent error raised while trying to dlopen the tree-sitter
|
||||
/// library, paired with the path that was attempted. Cleared on a
|
||||
/// successful open. Null means "no attempt failed yet."
|
||||
///
|
||||
/// Callers that observe a null `instance` after `init()` should read
|
||||
/// this for the diagnostic instead of guessing.
|
||||
static Object? lastOpenError;
|
||||
static String? lastOpenErrorPath;
|
||||
|
||||
static DynamicLibrary? _openLibrary() {
|
||||
final libName = Platform.isLinux
|
||||
? 'libtree-sitter.so'
|
||||
@@ -189,12 +260,22 @@ class TreeSitterLib {
|
||||
: Platform.isWindows
|
||||
? 'tree-sitter.dll'
|
||||
: null;
|
||||
if (libName == null) return null;
|
||||
if (libName == null) {
|
||||
lastOpenError = 'unsupported platform ${Platform.operatingSystem}';
|
||||
lastOpenErrorPath = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try standard dlopen path first (works when lib is in bundle/lib/).
|
||||
try {
|
||||
return DynamicLibrary.open(libName);
|
||||
} catch (_) {}
|
||||
final lib = DynamicLibrary.open(libName);
|
||||
lastOpenError = null;
|
||||
lastOpenErrorPath = null;
|
||||
return lib;
|
||||
} catch (e) {
|
||||
lastOpenError = e;
|
||||
lastOpenErrorPath = libName;
|
||||
}
|
||||
|
||||
// Try next to executable.
|
||||
final exe = File(Platform.resolvedExecutable).parent.path;
|
||||
@@ -202,8 +283,14 @@ class TreeSitterLib {
|
||||
final path = '$dir/$libName';
|
||||
if (File(path).existsSync()) {
|
||||
try {
|
||||
return DynamicLibrary.open(path);
|
||||
} catch (_) {}
|
||||
final lib = DynamicLibrary.open(path);
|
||||
lastOpenError = null;
|
||||
lastOpenErrorPath = null;
|
||||
return lib;
|
||||
} catch (e) {
|
||||
lastOpenError = e;
|
||||
lastOpenErrorPath = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -8,8 +8,17 @@ import 'package:clide/kernel/src/syntax/language_map.dart';
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
|
||||
/// Loads grammar WASM bytes for [language] (e.g. "dart" → `dart.wasm`).
|
||||
/// Throws on missing or unreadable assets.
|
||||
typedef GrammarBytesLoader = Future<Uint8List> Function(String language);
|
||||
|
||||
/// Loads the highlight query (`.scm` source) for [language], or returns
|
||||
/// null if no query is bundled for it.
|
||||
typedef GrammarQueryLoader = Future<String?> Function(String language);
|
||||
|
||||
class SyntaxSpan {
|
||||
const SyntaxSpan({
|
||||
required this.start,
|
||||
@@ -42,8 +51,37 @@ class _LoadedGrammar {
|
||||
}
|
||||
|
||||
class TreeSitterService {
|
||||
static final TreeSitterService shared = TreeSitterService._();
|
||||
TreeSitterService._();
|
||||
static final TreeSitterService shared = TreeSitterService();
|
||||
|
||||
/// Production constructor: uses the dlopen'd [TreeSitterLib.instance] and
|
||||
/// the Flutter [rootBundle]. Tests pass [lib] / [grammarBytes] /
|
||||
/// [grammarQuery] to substitute a fake FFI surface and in-memory assets.
|
||||
TreeSitterService({
|
||||
TreeSitterLib? lib,
|
||||
GrammarBytesLoader? grammarBytes,
|
||||
GrammarQueryLoader? grammarQuery,
|
||||
}) : _injectedLib = lib,
|
||||
_grammarBytes = grammarBytes ?? _defaultGrammarBytes,
|
||||
_grammarQuery = grammarQuery ?? _defaultGrammarQuery;
|
||||
|
||||
final TreeSitterLib? _injectedLib;
|
||||
final GrammarBytesLoader _grammarBytes;
|
||||
final GrammarQueryLoader _grammarQuery;
|
||||
|
||||
TreeSitterLib? get _lib => _injectedLib ?? TreeSitterLib.instance;
|
||||
|
||||
static Future<Uint8List> _defaultGrammarBytes(String language) async {
|
||||
final data = await rootBundle.load('assets/grammars/$language.wasm');
|
||||
return data.buffer.asUint8List();
|
||||
}
|
||||
|
||||
static Future<String?> _defaultGrammarQuery(String language) async {
|
||||
try {
|
||||
return await rootBundle.loadString('assets/queries/$language.scm');
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final Map<String, _LoadedGrammar> _grammars = {};
|
||||
final Set<String> _unavailable = {};
|
||||
@@ -58,7 +96,7 @@ class TreeSitterService {
|
||||
if (_initDone) return _parser != null;
|
||||
_initDone = true;
|
||||
|
||||
final lib = TreeSitterLib.instance;
|
||||
final lib = _lib;
|
||||
if (lib == null) return false;
|
||||
|
||||
final engine = lib.wasmEngineNew();
|
||||
@@ -92,12 +130,11 @@ class TreeSitterService {
|
||||
return null;
|
||||
}
|
||||
|
||||
final lib = TreeSitterLib.instance!;
|
||||
final lib = _lib!;
|
||||
|
||||
try {
|
||||
// Load grammar WASM bytes.
|
||||
final wasmData = await rootBundle.load('assets/grammars/$language.wasm');
|
||||
final wasmBytes = wasmData.buffer.asUint8List();
|
||||
final wasmBytes = await _grammarBytes(language);
|
||||
|
||||
// Load into WASM store.
|
||||
final nameNative = language.toNativeUtf8();
|
||||
@@ -126,10 +163,7 @@ class TreeSitterService {
|
||||
calloc.free(error);
|
||||
|
||||
// Load highlight query.
|
||||
String? querySource;
|
||||
try {
|
||||
querySource = await rootBundle.loadString('assets/queries/$language.scm');
|
||||
} catch (_) {}
|
||||
final querySource = await _grammarQuery(language);
|
||||
|
||||
Pointer<TSQuery> query = nullptr;
|
||||
List<String> captureNames = [];
|
||||
@@ -200,7 +234,7 @@ class TreeSitterService {
|
||||
return SyntaxResult.empty;
|
||||
}
|
||||
|
||||
final lib = TreeSitterLib.instance!;
|
||||
final lib = _lib!;
|
||||
final parser = _parser!;
|
||||
final cursor = _cursor!;
|
||||
|
||||
@@ -253,7 +287,7 @@ class TreeSitterService {
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
final lib = TreeSitterLib.instance;
|
||||
final lib = _lib;
|
||||
if (lib == null) return;
|
||||
|
||||
for (final grammar in _grammars.values) {
|
||||
@@ -273,6 +307,15 @@ class TreeSitterService {
|
||||
_unavailable.clear();
|
||||
}
|
||||
|
||||
/// Resets the service to a pre-init state. Tests use this to re-exercise
|
||||
/// `_init()` without constructing a new singleton; production code never
|
||||
/// needs it.
|
||||
@visibleForTesting
|
||||
void resetForTests() {
|
||||
dispose();
|
||||
_initDone = false;
|
||||
}
|
||||
|
||||
static Color colorForRole(String role, SurfaceTokens tokens) {
|
||||
return switch (role) {
|
||||
'keyword' || 'repeat' || 'conditional' || 'include' || 'exception' || 'operator' => tokens.syntaxKeyword,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Workspace-wide text zoom factor.
|
||||
///
|
||||
/// Owned by the kernel rather than the root widget so command-palette
|
||||
/// entries, the keymap layer, and any future menu/CLI surface can mutate
|
||||
/// the same number. The root `MediaQuery` listens via [ChangeNotifier].
|
||||
class TextZoom extends ChangeNotifier {
|
||||
TextZoom();
|
||||
|
||||
static const double minScale = 0.6;
|
||||
static const double maxScale = 2.0;
|
||||
static const double stepScale = 0.05;
|
||||
|
||||
double _scale = 1.0;
|
||||
double get scale => _scale;
|
||||
|
||||
void increase() => _setScale(_scale + stepScale);
|
||||
void decrease() => _setScale(_scale - stepScale);
|
||||
void reset() => _setScale(1.0);
|
||||
|
||||
void _setScale(double next) {
|
||||
final clamped = next.clamp(minScale, maxScale);
|
||||
if (clamped == _scale) return;
|
||||
_scale = clamped;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -39,9 +39,15 @@ double contrastRatio(Color a, Color b, {Color onto = const Color(0xFF808080)}) {
|
||||
/// Minimum ratio required for this pair per WCAG AA.
|
||||
double minimumRatio(ContrastPair pair) => pair.largeText ? 3.0 : 4.5;
|
||||
|
||||
/// Canonical set of token pairs each bundled theme must honour.
|
||||
/// Baseline token pairs every bundled theme must honour.
|
||||
///
|
||||
/// The a11y contrast test walks this list per-theme.
|
||||
/// Per D-22 every named theme passes this set; per D-69 the named
|
||||
/// themes (`clide`, `midnight`, `paper`, `terminal`) are user
|
||||
/// contracts whose palettes are not retuned to chase a contrast gate,
|
||||
/// so only the load-bearing pairs (primary text on its surface, chrome
|
||||
/// foregrounds, selected list item) sit here. Stricter coverage for
|
||||
/// muted text, status chips, syntax tokens, and the focus border lives
|
||||
/// in [extendedPairs], which only `-hc`/`-cb` variants must pass.
|
||||
List<ContrastPair> canonicalPairs(SurfaceTokens s) => [
|
||||
ContrastPair(
|
||||
name: 'global.text_on_background',
|
||||
@@ -100,10 +106,98 @@ List<ContrastPair> canonicalPairs(SurfaceTokens s) => [
|
||||
),
|
||||
];
|
||||
|
||||
/// Convenience for tests: returns the list of pairs that fail WCAG AA.
|
||||
List<ContrastFailure> failingPairs(SurfaceTokens tokens) {
|
||||
/// Stricter pair set — only the high-contrast (`-hc`) and colour-blind
|
||||
/// (`-cb`) theme variants must clear it. See D-69. These are the
|
||||
/// surfaces a UX consultant flagged in `consultants.md`: muted body
|
||||
/// text, status chip foregrounds, syntax tokens on the code-block
|
||||
/// surface, and the focus-indicating panel border.
|
||||
List<ContrastPair> extendedPairs(SurfaceTokens s) => [
|
||||
ContrastPair(
|
||||
name: 'global.text_muted_on_background',
|
||||
foreground: s.globalTextMuted,
|
||||
background: s.globalBackground,
|
||||
),
|
||||
ContrastPair(
|
||||
name: 'global.text_muted_on_panel',
|
||||
foreground: s.globalTextMuted,
|
||||
background: s.panelBackground,
|
||||
),
|
||||
ContrastPair(
|
||||
name: 'status.success_on_statusbar',
|
||||
foreground: s.statusSuccess,
|
||||
background: s.statusBarBackground,
|
||||
),
|
||||
ContrastPair(
|
||||
name: 'status.warning_on_statusbar',
|
||||
foreground: s.statusWarning,
|
||||
background: s.statusBarBackground,
|
||||
),
|
||||
ContrastPair(
|
||||
name: 'status.error_on_statusbar',
|
||||
foreground: s.statusError,
|
||||
background: s.statusBarBackground,
|
||||
),
|
||||
ContrastPair(
|
||||
name: 'status.info_on_statusbar',
|
||||
foreground: s.statusInfo,
|
||||
background: s.statusBarBackground,
|
||||
),
|
||||
ContrastPair(
|
||||
name: 'syntax.keyword_on_panel',
|
||||
foreground: s.syntaxKeyword,
|
||||
background: s.panelBackground,
|
||||
),
|
||||
ContrastPair(
|
||||
name: 'syntax.type_on_panel',
|
||||
foreground: s.syntaxType,
|
||||
background: s.panelBackground,
|
||||
),
|
||||
ContrastPair(
|
||||
name: 'syntax.string_on_panel',
|
||||
foreground: s.syntaxString,
|
||||
background: s.panelBackground,
|
||||
),
|
||||
ContrastPair(
|
||||
name: 'syntax.number_on_panel',
|
||||
foreground: s.syntaxNumber,
|
||||
background: s.panelBackground,
|
||||
),
|
||||
ContrastPair(
|
||||
name: 'syntax.comment_on_panel',
|
||||
foreground: s.syntaxComment,
|
||||
background: s.panelBackground,
|
||||
),
|
||||
ContrastPair(
|
||||
name: 'syntax.method_on_panel',
|
||||
foreground: s.syntaxMethod,
|
||||
background: s.panelBackground,
|
||||
),
|
||||
ContrastPair(
|
||||
name: 'syntax.punct_on_panel',
|
||||
foreground: s.syntaxPunct,
|
||||
background: s.panelBackground,
|
||||
),
|
||||
// WCAG 1.4.11 wants 3:1 for non-text UI components like a focus
|
||||
// border against the adjacent surface.
|
||||
ContrastPair(
|
||||
name: 'panel.active_border_on_background',
|
||||
foreground: s.panelActiveBorder,
|
||||
background: s.globalBackground,
|
||||
largeText: true,
|
||||
),
|
||||
];
|
||||
|
||||
/// Convenience for tests: returns the list of [canonicalPairs] that
|
||||
/// fail WCAG AA.
|
||||
List<ContrastFailure> failingPairs(SurfaceTokens tokens) => _failures(canonicalPairs(tokens));
|
||||
|
||||
/// Strict variant of [failingPairs] — walks [extendedPairs] instead.
|
||||
/// Intended for the `-hc` / `-cb` theme gate.
|
||||
List<ContrastFailure> failingExtendedPairs(SurfaceTokens tokens) => _failures(extendedPairs(tokens));
|
||||
|
||||
List<ContrastFailure> _failures(List<ContrastPair> pairs) {
|
||||
final out = <ContrastFailure>[];
|
||||
for (final p in canonicalPairs(tokens)) {
|
||||
for (final p in pairs) {
|
||||
final ratio = contrastRatio(p.foreground, p.background);
|
||||
final need = minimumRatio(p);
|
||||
if (ratio < need) {
|
||||
|
||||
@@ -96,6 +96,9 @@ class ThemeResolver {
|
||||
modalOverlayBackground: surface[TokenKeys.modalOverlayBackground]!,
|
||||
modalSurfaceBackground: surface[TokenKeys.modalSurfaceBackground]!,
|
||||
modalSurfaceBorder: surface[TokenKeys.modalSurfaceBorder]!,
|
||||
windowControlCloseHoverBackground: surface[TokenKeys.windowControlCloseHoverBackground]!,
|
||||
windowControlCloseHoverForeground: surface[TokenKeys.windowControlCloseHoverForeground]!,
|
||||
shadowAmbient: surface[TokenKeys.shadowAmbient]!,
|
||||
dividerColor: surface[TokenKeys.dividerColor]!,
|
||||
statusSuccess: surface[TokenKeys.statusSuccess]!,
|
||||
statusWarning: surface[TokenKeys.statusWarning]!,
|
||||
@@ -250,6 +253,14 @@ const Map<String, List<String>> _defaultSurfaceMap = {
|
||||
TokenKeys.modalOverlayBackground: ['#C0000000'],
|
||||
TokenKeys.modalSurfaceBackground: ['surface', 'semantic.mainchrome'],
|
||||
TokenKeys.modalSurfaceBorder: ['accent', 'semantic.focus'],
|
||||
// window controls — the Windows-style red close button is a hard
|
||||
// platform convention, not a theme accent; the foreground stays
|
||||
// white so the glyph remains visible across all themes.
|
||||
TokenKeys.windowControlCloseHoverBackground: ['#FFE81123'],
|
||||
TokenKeys.windowControlCloseHoverForeground: ['#FFFFFFFF'],
|
||||
// shadow — translucent black works for dark themes; light themes can
|
||||
// override this to a translucent ink (e.g. paper uses softer alpha).
|
||||
TokenKeys.shadowAmbient: ['#40000000'],
|
||||
// divider
|
||||
TokenKeys.dividerColor: ['border', 'semantic.surface'],
|
||||
// status
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# clide-hc — high-contrast sibling of `clide`
|
||||
#
|
||||
# Same periwinkle silhouette; muted text, status chips, syntax tokens,
|
||||
# and the focus border bumped to clear the strict contrast gate
|
||||
# (D-22 + D-69, `extendedPairs` in lib/kernel/src/theme/contrast.dart).
|
||||
|
||||
name: clide-hc
|
||||
display_name: Clide (high contrast)
|
||||
dark: true
|
||||
|
||||
palette:
|
||||
background: "#20202C"
|
||||
panel: "#1A1A24"
|
||||
surface: "#242838"
|
||||
muted: "#B1BBE3"
|
||||
foreground: "#E6E8F2"
|
||||
secondary: "#B1BBE3"
|
||||
primary: "#92B5FF"
|
||||
accent: "#92B5FF"
|
||||
success: "#A8E8C8"
|
||||
warning: "#FFD988"
|
||||
error: "#FF9F9F"
|
||||
info: "#A6C8FF"
|
||||
surfaceHi: "#2C3046"
|
||||
border: "#343850"
|
||||
borderHi: "#92B5FF"
|
||||
textDim: "#B1BBE3"
|
||||
textMute: "#9098C0"
|
||||
accentSoft: "#2192B5FF"
|
||||
|
||||
syntax:
|
||||
keyword: "#E0B5F2"
|
||||
type: "#A6C8FF"
|
||||
string: "#C2E8B0"
|
||||
number: "#FFD988"
|
||||
comment: "#9098C0"
|
||||
method: "#A6C8FF"
|
||||
punct: "#B1BBE3"
|
||||
@@ -0,0 +1,39 @@
|
||||
# midnight-hc — high-contrast sibling of `midnight`
|
||||
#
|
||||
# Same VS Code-adjacent silhouette; muted text, status chips, syntax
|
||||
# tokens, and the focus border bumped to clear the strict contrast
|
||||
# gate (D-22 + D-69, `extendedPairs` in lib/kernel/src/theme/contrast.dart).
|
||||
|
||||
name: midnight-hc
|
||||
display_name: Midnight (high contrast)
|
||||
dark: true
|
||||
|
||||
palette:
|
||||
background: "#1E1E1E"
|
||||
panel: "#181818"
|
||||
surface: "#252526"
|
||||
muted: "#BDBDBD"
|
||||
foreground: "#F0F0F0"
|
||||
secondary: "#D4D4D4"
|
||||
primary: "#82BFFF"
|
||||
accent: "#82BFFF"
|
||||
success: "#B5E2B0"
|
||||
warning: "#E9D89E"
|
||||
error: "#FFB3A5"
|
||||
info: "#9BC9F0"
|
||||
onAccent: "#0B1220"
|
||||
surfaceHi: "#2D2D2E"
|
||||
border: "#333333"
|
||||
borderHi: "#9C9C9C"
|
||||
textDim: "#BDBDBD"
|
||||
textMute: "#9A9A9A"
|
||||
accentSoft: "#2182BFFF"
|
||||
|
||||
syntax:
|
||||
keyword: "#D9A1D3"
|
||||
type: "#66D9C2"
|
||||
string: "#E9B299"
|
||||
number: "#C8E0B7"
|
||||
comment: "#99CB80"
|
||||
method: "#E5E5B5"
|
||||
punct: "#BDBDBD"
|
||||
@@ -0,0 +1,41 @@
|
||||
# paper-hc — high-contrast sibling of `paper`
|
||||
#
|
||||
# Drafting sheet, red-pencil accent — darker ink so muted text, status
|
||||
# chips, syntax tokens, and the focus border clear the strict contrast
|
||||
# gate (D-22 + D-69, `extendedPairs` in lib/kernel/src/theme/contrast.dart).
|
||||
|
||||
name: paper-hc
|
||||
display_name: Paper (high contrast)
|
||||
dark: false
|
||||
|
||||
palette:
|
||||
background: "#F4F1EA"
|
||||
panel: "#ECE7DB"
|
||||
surface: "#FBF8F1"
|
||||
muted: "#4A4A48"
|
||||
foreground: "#0E0E0E"
|
||||
secondary: "#1F1F1F"
|
||||
primary: "#8E2D10"
|
||||
accent: "#6E2410"
|
||||
success: "#1A4A2B"
|
||||
warning: "#6A4C00"
|
||||
error: "#7A1F12"
|
||||
info: "#154168"
|
||||
surfaceHi: "#ECE7DB"
|
||||
border: "#0E0E0E"
|
||||
borderHi: "#3A3A38"
|
||||
textDim: "#4A4A48"
|
||||
textMute: "#7A7A72"
|
||||
accentSoft: "#218E2D10"
|
||||
|
||||
surface:
|
||||
shadow.ambient: "#26000000"
|
||||
|
||||
syntax:
|
||||
keyword: "#52215C"
|
||||
type: "#154168"
|
||||
string: "#1A4A2B"
|
||||
number: "#6A4C00"
|
||||
comment: "#4A4A48"
|
||||
method: "#103E6F"
|
||||
punct: "#1F1F1F"
|
||||
@@ -25,6 +25,9 @@ palette:
|
||||
textMute: "#A8A89E"
|
||||
accentSoft: "#21C14B2A"
|
||||
|
||||
surface:
|
||||
shadow.ambient: "#26000000"
|
||||
|
||||
syntax:
|
||||
keyword: "#7B3F8C"
|
||||
type: "#2A6FC1"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# terminal-hc — high-contrast sibling of `terminal`
|
||||
#
|
||||
# Near-black + amber tmux feel; muted text, status chips, syntax tokens,
|
||||
# and the focus border bumped to clear the strict contrast gate
|
||||
# (D-22 + D-69, `extendedPairs` in lib/kernel/src/theme/contrast.dart).
|
||||
|
||||
name: terminal-hc
|
||||
display_name: Terminal (high contrast)
|
||||
dark: true
|
||||
|
||||
palette:
|
||||
background: "#0A0A0A"
|
||||
panel: "#000000"
|
||||
surface: "#111111"
|
||||
muted: "#B5B5B5"
|
||||
foreground: "#F0F0F0"
|
||||
secondary: "#D6D6D6"
|
||||
primary: "#FFC868"
|
||||
accent: "#FFC868"
|
||||
success: "#B5E2B0"
|
||||
warning: "#FFD988"
|
||||
error: "#FFA5A5"
|
||||
info: "#B0C8FF"
|
||||
surfaceHi: "#181818"
|
||||
border: "#242424"
|
||||
borderHi: "#888888"
|
||||
textDim: "#B5B5B5"
|
||||
textMute: "#9A9A9A"
|
||||
accentSoft: "#21FFC868"
|
||||
|
||||
syntax:
|
||||
keyword: "#FF8585"
|
||||
type: "#FFD27A"
|
||||
string: "#B5E2B0"
|
||||
number: "#DAA7FF"
|
||||
comment: "#9A9A9A"
|
||||
method: "#BCD8FF"
|
||||
punct: "#B5B5B5"
|
||||
@@ -74,6 +74,11 @@ class SurfaceTokens {
|
||||
required this.modalOverlayBackground,
|
||||
required this.modalSurfaceBackground,
|
||||
required this.modalSurfaceBorder,
|
||||
// window controls
|
||||
required this.windowControlCloseHoverBackground,
|
||||
required this.windowControlCloseHoverForeground,
|
||||
// shadow
|
||||
required this.shadowAmbient,
|
||||
// divider
|
||||
required this.dividerColor,
|
||||
// status
|
||||
@@ -155,6 +160,17 @@ class SurfaceTokens {
|
||||
final Color modalSurfaceBackground;
|
||||
final Color modalSurfaceBorder;
|
||||
|
||||
/// Hover background on the Windows-style close button. Conventionally
|
||||
/// the destructive-action red even on dark themes that don't otherwise
|
||||
/// surface red as a CTA.
|
||||
final Color windowControlCloseHoverBackground;
|
||||
final Color windowControlCloseHoverForeground;
|
||||
|
||||
/// Ambient drop-shadow color for floating surfaces (palette,
|
||||
/// dropdowns, modals). Typically a translucent black on dark themes
|
||||
/// and a translucent ink on light themes.
|
||||
final Color shadowAmbient;
|
||||
|
||||
final Color dividerColor;
|
||||
|
||||
final Color statusSuccess;
|
||||
@@ -255,6 +271,13 @@ abstract class TokenKeys {
|
||||
static const modalSurfaceBackground = 'modal.surfaceBackground';
|
||||
static const modalSurfaceBorder = 'modal.surfaceBorder';
|
||||
|
||||
// window controls
|
||||
static const windowControlCloseHoverBackground = 'windowControl.closeHoverBackground';
|
||||
static const windowControlCloseHoverForeground = 'windowControl.closeHoverForeground';
|
||||
|
||||
// shadow
|
||||
static const shadowAmbient = 'shadow.ambient';
|
||||
|
||||
// divider
|
||||
static const dividerColor = 'divider.color';
|
||||
|
||||
@@ -325,6 +348,9 @@ abstract class TokenKeys {
|
||||
modalOverlayBackground,
|
||||
modalSurfaceBackground,
|
||||
modalSurfaceBorder,
|
||||
windowControlCloseHoverBackground,
|
||||
windowControlCloseHoverForeground,
|
||||
shadowAmbient,
|
||||
dividerColor,
|
||||
statusSuccess,
|
||||
statusWarning,
|
||||
|
||||
+18
-140
@@ -3,31 +3,21 @@
|
||||
/// Resolution runs in a background isolate via [Toolchain.resolvePaths]
|
||||
/// to avoid blocking the merged UI/platform thread on macOS. The result
|
||||
/// is applied on the main thread via [Toolchain.applyResolved].
|
||||
///
|
||||
/// The Flutter-free data types ([ResolvedPaths], [ToolchainView]) and
|
||||
/// the isolate-side resolver ([resolveToolchainPaths]) live in
|
||||
/// `toolchain_paths.dart` and are re-exported here for convenience.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Serializable result of tool resolution (crosses isolate boundary).
|
||||
class ResolvedPaths {
|
||||
const ResolvedPaths({
|
||||
this.git,
|
||||
this.pql,
|
||||
this.tmux,
|
||||
this.shell,
|
||||
this.gitEnv,
|
||||
});
|
||||
import 'toolchain_paths.dart';
|
||||
|
||||
final String? git;
|
||||
final String? pql;
|
||||
final String? tmux;
|
||||
final String? shell;
|
||||
final Map<String, String>? gitEnv;
|
||||
}
|
||||
export 'toolchain_paths.dart';
|
||||
|
||||
class Toolchain extends ChangeNotifier {
|
||||
class Toolchain extends ChangeNotifier implements ToolchainView {
|
||||
String? _git;
|
||||
String? _pql;
|
||||
String? _tmux;
|
||||
@@ -35,17 +25,25 @@ class Toolchain extends ChangeNotifier {
|
||||
Map<String, String>? _gitEnv;
|
||||
bool _resolved = false;
|
||||
|
||||
@override
|
||||
String get git => _git ?? 'git';
|
||||
@override
|
||||
String get pql => _pql ?? 'pql';
|
||||
@override
|
||||
String get tmux => _tmux ?? 'tmux';
|
||||
@override
|
||||
String get shell => _shell ?? '/bin/bash';
|
||||
|
||||
/// Extra environment variables for git (e.g. GIT_EXEC_PATH for dugite).
|
||||
@override
|
||||
Map<String, String>? get gitEnv => _gitEnv;
|
||||
|
||||
@override
|
||||
bool get resolved => _resolved;
|
||||
@override
|
||||
bool get allOk => _resolved && missing.isEmpty;
|
||||
|
||||
@override
|
||||
List<String> get missing => [
|
||||
if (_git == null) 'git',
|
||||
if (_pql == null) 'pql',
|
||||
@@ -79,127 +77,7 @@ class Toolchain extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Pure function — runs in a background isolate. All file I/O happens
|
||||
/// here, off the main thread.
|
||||
static ResolvedPaths resolvePaths({required String workspaceRoot}) {
|
||||
final dugite = '$workspaceRoot/native/dugite/bin';
|
||||
|
||||
String? git;
|
||||
Map<String, String>? gitEnv;
|
||||
final dugiteGit = _firstExisting(['$dugite/git']);
|
||||
if (dugiteGit != null) {
|
||||
git = dugiteGit;
|
||||
final dugiteRoot = File(dugiteGit).parent.parent.path;
|
||||
gitEnv = {
|
||||
'GIT_EXEC_PATH': '$dugiteRoot/libexec/git-core',
|
||||
'GIT_TEMPLATE_DIR': '$dugiteRoot/share/git-core/templates',
|
||||
};
|
||||
} else {
|
||||
git = _findOnPath('git');
|
||||
}
|
||||
|
||||
final pql = _findOnPath('pql');
|
||||
final tmux = _findOnPath('tmux');
|
||||
final shell = _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash');
|
||||
|
||||
return ResolvedPaths(
|
||||
git: git,
|
||||
pql: pql,
|
||||
tmux: tmux,
|
||||
shell: shell,
|
||||
gitEnv: gitEnv,
|
||||
);
|
||||
}
|
||||
|
||||
static String? _findOnPath(String name) {
|
||||
for (final dir in _expandedPath().split(':')) {
|
||||
if (dir.isEmpty) continue;
|
||||
final f = File('$dir/$name');
|
||||
if (f.existsSync()) return f.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Build expanded PATH inline — must be self-contained for isolate use.
|
||||
static String _expandedPath() {
|
||||
final base = Platform.environment['PATH'] ?? '';
|
||||
if (!Platform.isMacOS) return base;
|
||||
final home = Platform.environment['HOME'] ?? '';
|
||||
final extras = <String>[
|
||||
if (home.isNotEmpty) '$home/.local/bin',
|
||||
'/opt/homebrew/bin',
|
||||
'/opt/homebrew/sbin',
|
||||
'/usr/local/bin',
|
||||
];
|
||||
final existing = base.split(':').toSet();
|
||||
final missing = extras.where((p) => !existing.contains(p));
|
||||
if (missing.isEmpty) return base;
|
||||
return [...missing, ...existing].join(':');
|
||||
}
|
||||
|
||||
static String? _firstExisting(List<String> candidates) {
|
||||
for (final c in candidates) {
|
||||
if (File(c).existsSync()) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level function for compute/isolate use. Takes a single String
|
||||
/// argument (the workspace root) and returns a plain-data result.
|
||||
ResolvedPaths resolveToolchainPaths(String workspaceRoot) {
|
||||
final dugite = '$workspaceRoot/native/dugite/bin';
|
||||
|
||||
String? git;
|
||||
Map<String, String>? gitEnv;
|
||||
final dugiteGit = _firstExistingStandalone(['$dugite/git']);
|
||||
if (dugiteGit != null) {
|
||||
git = dugiteGit;
|
||||
final dugiteRoot = File(dugiteGit).parent.parent.path;
|
||||
gitEnv = {
|
||||
'GIT_EXEC_PATH': '$dugiteRoot/libexec/git-core',
|
||||
'GIT_TEMPLATE_DIR': '$dugiteRoot/share/git-core/templates',
|
||||
};
|
||||
} else {
|
||||
git = _findOnPathStandalone('git');
|
||||
}
|
||||
|
||||
return ResolvedPaths(
|
||||
git: git,
|
||||
pql: _findOnPathStandalone('pql'),
|
||||
tmux: _findOnPathStandalone('tmux'),
|
||||
shell: _findOnPathStandalone(Platform.environment['SHELL']?.split('/').last ?? 'bash'),
|
||||
gitEnv: gitEnv,
|
||||
);
|
||||
}
|
||||
|
||||
String? _findOnPathStandalone(String name) {
|
||||
for (final dir in _expandedPathStandalone().split(':')) {
|
||||
if (dir.isEmpty) continue;
|
||||
final f = File('$dir/$name');
|
||||
if (f.existsSync()) return f.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _firstExistingStandalone(List<String> candidates) {
|
||||
for (final c in candidates) {
|
||||
if (File(c).existsSync()) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _expandedPathStandalone() {
|
||||
final base = Platform.environment['PATH'] ?? '';
|
||||
if (!Platform.isMacOS) return base;
|
||||
final home = Platform.environment['HOME'] ?? '';
|
||||
final extras = <String>[
|
||||
if (home.isNotEmpty) '$home/.local/bin',
|
||||
'/opt/homebrew/bin',
|
||||
'/opt/homebrew/sbin',
|
||||
'/usr/local/bin',
|
||||
];
|
||||
final existing = base.split(':').toSet();
|
||||
final missing = extras.where((p) => !existing.contains(p));
|
||||
if (missing.isEmpty) return base;
|
||||
return [...missing, ...existing].join(':');
|
||||
/// here, off the main thread. Delegates to the Flutter-free
|
||||
/// [resolveToolchainPaths]. Takes no workspace argument: see T-98.
|
||||
static ResolvedPaths resolvePaths() => resolveToolchainPaths();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/// Flutter-free toolchain data + resolution.
|
||||
///
|
||||
/// Split out of `toolchain.dart` so that pure-Dart consumers (the IPC
|
||||
/// subsystems exported through `package:clide/clide.dart`, e.g.
|
||||
/// [GitClient] and [PqlClient]) don't transitively pull in
|
||||
/// `package:flutter/foundation.dart`. The live, listenable `Toolchain`
|
||||
/// stays in `toolchain.dart`; everything here is plain Dart and runs
|
||||
/// fine under `dart test`.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
/// Serializable result of tool resolution (crosses isolate boundary).
|
||||
class ResolvedPaths {
|
||||
const ResolvedPaths({
|
||||
this.git,
|
||||
this.pql,
|
||||
this.tmux,
|
||||
this.shell,
|
||||
this.gitEnv,
|
||||
});
|
||||
|
||||
final String? git;
|
||||
final String? pql;
|
||||
final String? tmux;
|
||||
final String? shell;
|
||||
final Map<String, String>? gitEnv;
|
||||
}
|
||||
|
||||
/// Read-only view of resolved tool paths. The concrete `Toolchain`
|
||||
/// (in `toolchain.dart`) implements this on top of `ChangeNotifier`;
|
||||
/// pure-Dart clients depend on the interface so they stay Flutter-free.
|
||||
abstract class ToolchainView {
|
||||
/// A fixed, already-resolved view over [paths]. Flutter-free — handy
|
||||
/// for tests and isolate-side code that has a [ResolvedPaths] but no
|
||||
/// need for the listenable `Toolchain`.
|
||||
const factory ToolchainView.resolved(ResolvedPaths paths) = _StaticToolchain;
|
||||
|
||||
String get git;
|
||||
String get pql;
|
||||
String get tmux;
|
||||
String get shell;
|
||||
Map<String, String>? get gitEnv;
|
||||
bool get resolved;
|
||||
bool get allOk;
|
||||
List<String> get missing;
|
||||
}
|
||||
|
||||
class _StaticToolchain implements ToolchainView {
|
||||
const _StaticToolchain(this._paths);
|
||||
|
||||
final ResolvedPaths _paths;
|
||||
|
||||
@override
|
||||
String get git => _paths.git ?? 'git';
|
||||
@override
|
||||
String get pql => _paths.pql ?? 'pql';
|
||||
@override
|
||||
String get tmux => _paths.tmux ?? 'tmux';
|
||||
@override
|
||||
String get shell => _paths.shell ?? '/bin/bash';
|
||||
@override
|
||||
Map<String, String>? get gitEnv => _paths.gitEnv;
|
||||
@override
|
||||
bool get resolved => true;
|
||||
@override
|
||||
bool get allOk => missing.isEmpty;
|
||||
@override
|
||||
List<String> get missing => [
|
||||
if (_paths.git == null) 'git',
|
||||
if (_paths.pql == null) 'pql',
|
||||
if (_paths.tmux == null) 'tmux',
|
||||
];
|
||||
}
|
||||
|
||||
/// Top-level function for compute/isolate use. Returns a plain-data
|
||||
/// result with all tool paths resolved against trusted locations only.
|
||||
///
|
||||
/// Critically does NOT take a workspace path: per T-98, resolving the
|
||||
/// dugite-bundled git against the open workspace was a code-execution
|
||||
/// vector (a malicious repo could plant `native/dugite/bin/git`).
|
||||
/// Dugite is resolved against the install directory + an explicit env
|
||||
/// override; everything else comes from PATH.
|
||||
ResolvedPaths resolveToolchainPaths() {
|
||||
String? git;
|
||||
Map<String, String>? gitEnv;
|
||||
final dugiteGit = _resolveDugiteGit();
|
||||
if (dugiteGit != null) {
|
||||
git = dugiteGit;
|
||||
final dugiteRoot = File(dugiteGit).parent.parent.path;
|
||||
gitEnv = {
|
||||
'GIT_EXEC_PATH': '$dugiteRoot/libexec/git-core',
|
||||
'GIT_TEMPLATE_DIR': '$dugiteRoot/share/git-core/templates',
|
||||
};
|
||||
} else {
|
||||
git = _findOnPath('git');
|
||||
}
|
||||
|
||||
return ResolvedPaths(
|
||||
git: git,
|
||||
pql: _findOnPath('pql'),
|
||||
tmux: _findOnPath('tmux'),
|
||||
shell: _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash'),
|
||||
gitEnv: gitEnv,
|
||||
);
|
||||
}
|
||||
|
||||
/// Locate the dugite-bundled git binary in trusted install locations
|
||||
/// only. **Never inspects workspace-relative paths** — see T-98.
|
||||
///
|
||||
/// Search order:
|
||||
/// 1. `CLIDE_DUGITE_DIR` env var (dev override; points at a dugite
|
||||
/// root that contains `bin/git`).
|
||||
/// 2. `<exe-parent>/dugite/bin/git` — production bundle layout.
|
||||
/// 3. `<exe-parent>/lib/dugite/bin/git` — alternate bundle layout
|
||||
/// (mirrors Linux's INSTALL_BUNDLE_LIB_DIR convention).
|
||||
///
|
||||
/// Returns null if no dugite is found; caller falls back to PATH git.
|
||||
String? _resolveDugiteGit() {
|
||||
final candidates = <String>[];
|
||||
final envDir = Platform.environment['CLIDE_DUGITE_DIR'];
|
||||
if (envDir != null && envDir.isNotEmpty) {
|
||||
candidates.add('$envDir/bin/git');
|
||||
}
|
||||
final exeDir = File(Platform.resolvedExecutable).parent.path;
|
||||
candidates.add('$exeDir/dugite/bin/git');
|
||||
candidates.add('$exeDir/lib/dugite/bin/git');
|
||||
return _firstExisting(candidates);
|
||||
}
|
||||
|
||||
String? _findOnPath(String name) {
|
||||
for (final dir in _expandedPath().split(':')) {
|
||||
if (dir.isEmpty) continue;
|
||||
final f = File('$dir/$name');
|
||||
if (f.existsSync()) return f.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _firstExisting(List<String> candidates) {
|
||||
for (final c in candidates) {
|
||||
if (File(c).existsSync()) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Build expanded PATH inline — must be self-contained for isolate use.
|
||||
String _expandedPath() {
|
||||
final base = Platform.environment['PATH'] ?? '';
|
||||
if (!Platform.isMacOS) return base;
|
||||
final home = Platform.environment['HOME'] ?? '';
|
||||
final extras = <String>[
|
||||
if (home.isNotEmpty) '$home/.local/bin',
|
||||
'/opt/homebrew/bin',
|
||||
'/opt/homebrew/sbin',
|
||||
'/usr/local/bin',
|
||||
];
|
||||
final existing = base.split(':').toSet();
|
||||
final missing = extras.where((p) => !existing.contains(p));
|
||||
if (missing.isEmpty) return base;
|
||||
return [...missing, ...existing].join(':');
|
||||
}
|
||||
+16
-7
@@ -20,6 +20,7 @@ import 'package:clide/builtin/problems/problems.dart';
|
||||
import 'package:clide/builtin/settings_ui/settings_ui.dart';
|
||||
import 'package:clide/builtin/terminal/terminal.dart';
|
||||
import 'package:clide/builtin/theme_picker/theme_picker.dart';
|
||||
import 'package:clide/builtin/view/view.dart';
|
||||
import 'package:clide/builtin/tickets/tickets.dart';
|
||||
import 'package:clide/builtin/todos/todos.dart';
|
||||
import 'package:clide/builtin/welcome/welcome.dart';
|
||||
@@ -48,10 +49,15 @@ Future<void> main() async {
|
||||
final binding = WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Test mode: skip the full app, run the test harness instead.
|
||||
const testMode = String.fromEnvironment('CLIDE_TESTMODE');
|
||||
if (testMode.isNotEmpty) {
|
||||
runApp(const ClideTestApp());
|
||||
return;
|
||||
// Gated on kDebugMode so the release tree-shaker can elide both the
|
||||
// branch and the test_app import graph — production binaries don't
|
||||
// ship the harness.
|
||||
if (kDebugMode) {
|
||||
const testMode = String.fromEnvironment('CLIDE_TESTMODE');
|
||||
if (testMode.isNotEmpty) {
|
||||
runApp(const ClideTestApp());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
binding.ensureSemantics();
|
||||
@@ -65,9 +71,7 @@ Future<void> main() async {
|
||||
// With proper signing (Developer ID), no sandbox or isolate needed.
|
||||
final toolchain = Toolchain();
|
||||
if (!kIsWeb) {
|
||||
const workspace = String.fromEnvironment('CLIDE_PROJECT');
|
||||
final root = workspace.isNotEmpty ? workspace : Directory.current.path;
|
||||
toolchain.applyResolved(resolveToolchainPaths(root));
|
||||
toolchain.applyResolved(resolveToolchainPaths());
|
||||
}
|
||||
|
||||
InProcessClient? ipcClient;
|
||||
@@ -142,6 +146,7 @@ Future<void> main() async {
|
||||
..register(CanvasExtension())
|
||||
..register(GraphExtension())
|
||||
// UI extensions
|
||||
..register(ViewExtension(textZoom: services.textZoom))
|
||||
..register(SettingsUiExtension())
|
||||
..register(ExtensionsUiExtension())
|
||||
..register(KeybindingsUiExtension())
|
||||
@@ -204,6 +209,10 @@ Future<List<ThemeDefinition>> _loadBundledThemes() async {
|
||||
'lib/kernel/src/theme/themes/midnight.yaml',
|
||||
'lib/kernel/src/theme/themes/paper.yaml',
|
||||
'lib/kernel/src/theme/themes/terminal.yaml',
|
||||
'lib/kernel/src/theme/themes/clide-hc.yaml',
|
||||
'lib/kernel/src/theme/themes/midnight-hc.yaml',
|
||||
'lib/kernel/src/theme/themes/paper-hc.yaml',
|
||||
'lib/kernel/src/theme/themes/terminal-hc.yaml',
|
||||
];
|
||||
final out = <ThemeDefinition>[];
|
||||
for (final p in paths) {
|
||||
|
||||
@@ -13,6 +13,11 @@ import '../ipc/schema_v1.dart';
|
||||
import '../panes/event_sink.dart';
|
||||
import 'dispatcher.dart';
|
||||
|
||||
/// Cap on `files.read` response size. UI doesn't render multi-MB
|
||||
/// blobs usefully and a single uncapped call can OOM. Range/stream
|
||||
/// reads will land as a separate command (T-104 follow-up).
|
||||
const int _filesReadMaxBytes = 10 * 1024 * 1024;
|
||||
|
||||
/// Daemon-side state for the `files` subsystem. Holds one
|
||||
/// [FileWatcher] rooted at the workspace and a resolved [IgnoreSet].
|
||||
class FilesService {
|
||||
@@ -74,7 +79,9 @@ void registerFilesCommands(DaemonDispatcher d, FilesService files) {
|
||||
}
|
||||
final String absPath;
|
||||
try {
|
||||
absPath = resolveUnderRoot(files.root, path);
|
||||
// Follow symlinks + re-check containment so a `config -> /etc/shadow`
|
||||
// symlink under the workspace can't be read (T-102).
|
||||
absPath = resolveUnderRootFollowingSymlinks(files.root, path);
|
||||
} on PathOutsideRoot {
|
||||
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'path outside workspace: $path'));
|
||||
}
|
||||
@@ -82,6 +89,20 @@ void registerFilesCommands(DaemonDispatcher d, FilesService files) {
|
||||
if (!file.existsSync()) {
|
||||
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'file not found: $path'));
|
||||
}
|
||||
// Cap response size so a single IPC call can't OOM the UI on a
|
||||
// multi-gigabyte log file. Caller can paginate / stream via a
|
||||
// future range-read variant when that ships.
|
||||
final length = file.lengthSync();
|
||||
if (length > _filesReadMaxBytes) {
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'file too large: $path ($length bytes; cap $_filesReadMaxBytes)',
|
||||
),
|
||||
);
|
||||
}
|
||||
final content = file.readAsStringSync();
|
||||
return IpcResponse.ok(id: req.id, data: {'path': path, 'content': content});
|
||||
});
|
||||
@@ -90,7 +111,7 @@ void registerFilesCommands(DaemonDispatcher d, FilesService files) {
|
||||
final dir = (req.args['path'] as String?) ?? '';
|
||||
if (dir.isNotEmpty) {
|
||||
try {
|
||||
resolveUnderRoot(files.root, dir);
|
||||
resolveUnderRootFollowingSymlinks(files.root, dir);
|
||||
} on PathOutsideRoot {
|
||||
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'path outside workspace: $dir'));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,16 @@ import '../ipc/schema_v1.dart';
|
||||
import '../panes/event_sink.dart';
|
||||
import 'dispatcher.dart';
|
||||
|
||||
/// Cap on `git.log --count` to keep a single query from spinning git
|
||||
/// up on multi-million-commit repos. UI's history pane paginates;
|
||||
/// callers asking for more should be using ranges instead.
|
||||
const int _gitLogMaxCount = 1000;
|
||||
|
||||
/// Cap on `git.diff` and `git.stage` paths-list length so a single
|
||||
/// IPC request can't queue up an unbounded fan-out of subprocess
|
||||
/// arguments.
|
||||
const int _gitPathsMaxCount = 256;
|
||||
|
||||
void registerGitCommands(
|
||||
DaemonDispatcher d,
|
||||
GitClient git,
|
||||
@@ -30,6 +40,8 @@ void registerGitCommands(
|
||||
try {
|
||||
final staged = req.args['staged'] as bool? ?? false;
|
||||
final paths = _pathList(req.args['paths']);
|
||||
final tooMany = _tooManyPaths(req.id, paths);
|
||||
if (tooMany != null) return tooMany;
|
||||
final diffs = await git.diff(staged: staged, paths: paths);
|
||||
return IpcResponse.ok(id: req.id, data: {
|
||||
'staged': staged,
|
||||
@@ -53,6 +65,8 @@ void registerGitCommands(
|
||||
),
|
||||
);
|
||||
}
|
||||
final tooMany = _tooManyPaths(req.id, paths);
|
||||
if (tooMany != null) return tooMany;
|
||||
try {
|
||||
await git.stage(paths);
|
||||
_emitChanged(events);
|
||||
@@ -190,8 +204,18 @@ void registerGitCommands(
|
||||
});
|
||||
|
||||
d.register('git.log', (req) async {
|
||||
final count = (req.args['count'] as num?)?.toInt() ?? 20;
|
||||
if (count > _gitLogMaxCount) {
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.userError,
|
||||
kind: IpcErrorKind.userError,
|
||||
message: 'git.log count $count exceeds cap $_gitLogMaxCount',
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
final count = (req.args['count'] as num?)?.toInt() ?? 20;
|
||||
final entries = await git.log(count: count);
|
||||
return IpcResponse.ok(id: req.id, data: {
|
||||
'entries': [for (final e in entries) e.toJson()],
|
||||
@@ -264,6 +288,18 @@ List<String> _pathList(Object? raw) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
IpcResponse? _tooManyPaths(String id, List<String> paths) {
|
||||
if (paths.length <= _gitPathsMaxCount) return null;
|
||||
return IpcResponse.err(
|
||||
id: id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.userError,
|
||||
kind: IpcErrorKind.userError,
|
||||
message: 'paths length ${paths.length} exceeds cap $_gitPathsMaxCount',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _emitChanged(DaemonEventSink events) {
|
||||
events.emit(IpcEvent(
|
||||
subsystem: 'git',
|
||||
|
||||
@@ -18,6 +18,12 @@ class PathOutsideRoot implements Exception {
|
||||
/// Resolve [relative] against [root] and verify the result is
|
||||
/// contained within [root]. Returns the absolute, normalized path.
|
||||
/// Throws [PathOutsideRoot] on traversal attempts.
|
||||
///
|
||||
/// Path-layer check only — does NOT follow symlinks. Callers that
|
||||
/// read or list the filesystem should use [resolveUnderRootFollowingSymlinks]
|
||||
/// instead, which adds a second containment check against the real
|
||||
/// path. The two-step split exists so pure path math can be tested
|
||||
/// without touching disk (T-102).
|
||||
String resolveUnderRoot(Directory root, String relative) {
|
||||
final rootPath = _normalize(root.absolute.path);
|
||||
final joined = _normalize('$rootPath${Platform.pathSeparator}$relative');
|
||||
@@ -32,6 +38,33 @@ String resolveUnderRoot(Directory root, String relative) {
|
||||
return joined;
|
||||
}
|
||||
|
||||
/// Like [resolveUnderRoot] but also resolves any symlinks at the
|
||||
/// target and re-verifies containment against the real path. Use this
|
||||
/// for any operation that will read/list/write the filesystem — the
|
||||
/// path-layer check alone does not defend against a symlink under the
|
||||
/// workspace whose target lives outside (T-102, e.g. `config ->
|
||||
/// /etc/shadow`).
|
||||
///
|
||||
/// Returns the **resolved real path** (with symlinks followed) when
|
||||
/// the target exists. When the target does not exist, returns the
|
||||
/// path-layer result so callers surface a clean "not found" error from
|
||||
/// their filesystem op (rather than this layer throwing first).
|
||||
///
|
||||
/// Symlinks in the workspace root path itself are tolerated: both
|
||||
/// sides of the containment check are resolved.
|
||||
String resolveUnderRootFollowingSymlinks(Directory root, String relative) {
|
||||
final pathResolved = resolveUnderRoot(root, relative);
|
||||
if (FileSystemEntity.typeSync(pathResolved, followLinks: false) == FileSystemEntityType.notFound) {
|
||||
return pathResolved;
|
||||
}
|
||||
final realRoot = Directory(root.absolute.path).resolveSymbolicLinksSync();
|
||||
final realPath = File(pathResolved).resolveSymbolicLinksSync();
|
||||
if (realPath != realRoot && !realPath.startsWith('$realRoot${Platform.pathSeparator}')) {
|
||||
throw PathOutsideRoot(relative, realPath, realRoot);
|
||||
}
|
||||
return realPath;
|
||||
}
|
||||
|
||||
String _normalize(String path) {
|
||||
// Use Uri to collapse `..` and `.` segments without hitting the
|
||||
// filesystem (Directory(...).resolveSymbolicLinksSync would also
|
||||
|
||||
+13
-4
@@ -1,4 +1,4 @@
|
||||
/// Typed git client backed by [Toolchain].
|
||||
/// Typed git client backed by a [ToolchainView].
|
||||
///
|
||||
/// Every subprocess call goes through [_run] which uses the resolved
|
||||
/// absolute binary path from the toolchain. Parsing is delegated to
|
||||
@@ -7,15 +7,15 @@ library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import '../../kernel/src/toolchain.dart';
|
||||
import '../../kernel/src/toolchain_paths.dart';
|
||||
import 'diff.dart' show GitDiff, parseDiffOutput;
|
||||
import 'operations.dart' show GitException, GitLogEntry;
|
||||
import 'operations.dart' show GitException, GitLogEntry, validateGitRef;
|
||||
import 'status.dart';
|
||||
|
||||
class GitClient {
|
||||
GitClient({required this.toolchain, required this.workDir});
|
||||
|
||||
final Toolchain toolchain;
|
||||
final ToolchainView toolchain;
|
||||
final Directory workDir;
|
||||
|
||||
// -- queries --------------------------------------------------------------
|
||||
@@ -191,8 +191,13 @@ class GitClient {
|
||||
}
|
||||
|
||||
Future<String> push({String? remote, String? branch, bool setUpstream = false}) async {
|
||||
if (remote != null) validateGitRef(remote, kind: 'remote');
|
||||
if (branch != null) validateGitRef(branch, kind: 'branch');
|
||||
final args = ['push'];
|
||||
if (setUpstream) args.add('-u');
|
||||
// `--` terminates option parsing — belt-and-suspenders alongside
|
||||
// the ref validator above.
|
||||
args.add('--');
|
||||
if (remote != null) args.add(remote);
|
||||
if (branch != null) args.add(branch);
|
||||
final r = await _run(args);
|
||||
@@ -201,6 +206,10 @@ class GitClient {
|
||||
}
|
||||
|
||||
Future<void> checkout(String branch) async {
|
||||
// `git checkout -- name` means pathspec, not branch — see the
|
||||
// matching note in `operations.dart#gitCheckout`. validateGitRef
|
||||
// is the only defence here.
|
||||
validateGitRef(branch, kind: 'branch');
|
||||
final r = await _run(['checkout', branch]);
|
||||
if (r.exitCode != 0) throw GitException('git checkout failed', stderr: r.stderr as String);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,21 @@ class GitException implements Exception {
|
||||
String toString() => 'GitException: $message';
|
||||
}
|
||||
|
||||
/// Validate a string about to be passed to git as a branch name,
|
||||
/// remote name, or similar ref-shaped positional argument. Rejects
|
||||
/// empty values and anything starting with `-`, which would otherwise
|
||||
/// be parsed as an option flag by git (the classic
|
||||
/// `--upload-pack=evil` argv-injection vector). Throws [GitException]
|
||||
/// — callers convert it to the right IPC error kind.
|
||||
void validateGitRef(String? value, {required String kind}) {
|
||||
if (value == null || value.isEmpty) {
|
||||
throw GitException('$kind is required');
|
||||
}
|
||||
if (value.startsWith('-')) {
|
||||
throw GitException('$kind cannot start with "-" (looks like an option flag): $value');
|
||||
}
|
||||
}
|
||||
|
||||
class GitLogEntry {
|
||||
const GitLogEntry({
|
||||
required this.hash,
|
||||
@@ -204,8 +219,14 @@ Future<String> gitPush(
|
||||
String? branch,
|
||||
bool setUpstream = false,
|
||||
}) async {
|
||||
if (remote != null) validateGitRef(remote, kind: 'remote');
|
||||
if (branch != null) validateGitRef(branch, kind: 'branch');
|
||||
final args = ['push'];
|
||||
if (setUpstream) args.add('-u');
|
||||
// `--` terminates option parsing — belt-and-suspenders alongside
|
||||
// the ref validator above. Without it a future caller that bypasses
|
||||
// the validator could still inject `--upload-pack=...`.
|
||||
args.add('--');
|
||||
if (remote != null) args.add(remote);
|
||||
if (branch != null) args.add(branch);
|
||||
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
|
||||
@@ -236,7 +257,14 @@ Future<List<({String name, bool current})>> gitBranches(Directory workDir) async
|
||||
}
|
||||
|
||||
/// Checkout a branch.
|
||||
///
|
||||
/// `git checkout` overloads positionals: `-- <name>` means "restore
|
||||
/// pathspec `<name>`", not "checkout branch `<name>`". So this can't
|
||||
/// use `--` as an option terminator without changing semantics — the
|
||||
/// [validateGitRef] guard against `-`-prefixed values is the only
|
||||
/// argv-injection defence here. Use `gitSwitch` if/when we adopt it.
|
||||
Future<void> gitCheckout(Directory workDir, String branch) async {
|
||||
validateGitRef(branch, kind: 'branch');
|
||||
final r = await Process.run(
|
||||
gitBin,
|
||||
['checkout', branch],
|
||||
|
||||
@@ -15,6 +15,7 @@ abstract class PosixErrno {
|
||||
static const int eperm = 1;
|
||||
static const int enoent = 2;
|
||||
static const int esrch = 3;
|
||||
static const int eintr = 4;
|
||||
static const int eio = 5;
|
||||
static const int ebadf = 9;
|
||||
static const int eagain = 11;
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
/// (server depends on subsystems, not the other way round).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import '../ipc/envelope.dart';
|
||||
|
||||
abstract class DaemonEventSink {
|
||||
@@ -16,10 +18,20 @@ abstract class DaemonEventSink {
|
||||
/// In-memory recording sink for tests + for composing multi-sink
|
||||
/// scenarios (e.g. tee to both the wire and an audit log).
|
||||
class RecordingEventSink implements DaemonEventSink {
|
||||
RecordingEventSink();
|
||||
final List<IpcEvent> events = [];
|
||||
final _controller = StreamController<IpcEvent>.broadcast();
|
||||
|
||||
@override
|
||||
void emit(IpcEvent event) => events.add(event);
|
||||
void emit(IpcEvent event) {
|
||||
events.add(event);
|
||||
_controller.add(event);
|
||||
}
|
||||
|
||||
/// Live stream of every event emitted into this sink. Tests use
|
||||
/// `stream.firstWhere(...)` for event-driven waits instead of
|
||||
/// polling the [events] list with `Future.delayed`.
|
||||
Stream<IpcEvent> get stream => _controller.stream;
|
||||
|
||||
/// Convenience: filter to a single subsystem (`pane`, `git`, …).
|
||||
Iterable<IpcEvent> ofSubsystem(String subsystem) => events.where((e) => e.subsystem == subsystem);
|
||||
|
||||
@@ -8,7 +8,7 @@ library;
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../../kernel/src/toolchain.dart';
|
||||
import '../../kernel/src/toolchain_paths.dart';
|
||||
|
||||
class PqlException implements Exception {
|
||||
const PqlException(this.message, {this.exitCode = 1, this.stderr = ''});
|
||||
@@ -24,7 +24,7 @@ class PqlClient {
|
||||
PqlClient({required this.workDir, required this.toolchain});
|
||||
|
||||
final Directory workDir;
|
||||
final Toolchain toolchain;
|
||||
final ToolchainView toolchain;
|
||||
|
||||
Future<List<Map<String, Object?>>> files({String? glob, int? limit}) async {
|
||||
final args = ['files'];
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
/// core library stays Flutter-free per D-005.
|
||||
library;
|
||||
|
||||
/// A PTY operation failed. [op] identifies the step (`forkpty`,
|
||||
/// `read`, `ioctl`, etc.); [errno] is POSIX errno when the failure
|
||||
/// came from a syscall, otherwise `null`.
|
||||
/// A PTY operation failed. [op] identifies the step (`posix_openpt`,
|
||||
/// `posix_spawn`, `read`, `ioctl`, etc.); [errno] is POSIX errno when
|
||||
/// the failure came from a syscall, otherwise `null`.
|
||||
class PtyException implements Exception {
|
||||
const PtyException(this.op, this.message, {this.errno});
|
||||
|
||||
|
||||
@@ -47,6 +47,19 @@ const int fSetFl = 4;
|
||||
|
||||
final int tiocswinsz = Platform.isMacOS ? 0x80087467 : 0x5414;
|
||||
|
||||
// poll() event bits (POSIX — same numeric values on Linux + macOS).
|
||||
const int pollin = 0x0001;
|
||||
const int pollerr = 0x0008;
|
||||
const int pollhup = 0x0010;
|
||||
const int pollnval = 0x0020;
|
||||
const int pollAnyErr = pollerr | pollhup | pollnval;
|
||||
|
||||
// Signal numbers used from the PTY layer (POSIX standard; identical
|
||||
// across Linux + macOS for the entries we touch).
|
||||
const int sighup = 1;
|
||||
const int sigkill = 9;
|
||||
const int sigwinch = 28;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typedefs
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -198,9 +211,6 @@ final class CmsghdrDarwin extends ffi.Struct {
|
||||
external int cmsg_type;
|
||||
}
|
||||
|
||||
// Alias for backward compatibility — callers use Cmsghdr.
|
||||
typedef Cmsghdr = CmsghdrLinux;
|
||||
|
||||
/// POSIX `struct winsize` for `TIOCSWINSZ`.
|
||||
final class Winsize extends ffi.Struct {
|
||||
@ffi.Uint16()
|
||||
|
||||
+207
-134
@@ -1,11 +1,19 @@
|
||||
/// Native PTY via forkpty().
|
||||
/// Native PTY via posix_openpt() + posix_spawn().
|
||||
///
|
||||
/// Uses Dart FFI to call forkpty() directly. The master fd stays
|
||||
/// in-process. The reader isolate uses poll() for clean shutdown.
|
||||
/// Originally used `forkpty()`, which calls `fork()` underneath. `fork()`
|
||||
/// in a multithreaded process is unsafe: only the calling thread survives
|
||||
/// in the child, but libc locks (notably `malloc`) held by other threads
|
||||
/// remain "locked forever." With the multi-threaded Dart VM as the
|
||||
/// parent, ~5% of spawns deadlocked in the child before `execve` (see
|
||||
/// T-96).
|
||||
///
|
||||
/// Based on the pty-spike proof-of-concept. Platform-aware:
|
||||
/// macOS: forkpty in libSystem (DynamicLibrary.process)
|
||||
/// Linux: forkpty in libutil.so.1
|
||||
/// `posix_spawn()` uses `vfork()` on glibc/musl/macOS, which keeps the
|
||||
/// parent suspended until `execve` completes — no Dart code runs in the
|
||||
/// child, so the lock-deadlock window is closed. The pty is created via
|
||||
/// the POSIX-standard `posix_openpt` / `grantpt` / `unlockpt` /
|
||||
/// `ptsname` sequence instead of the BSD `forkpty` wrapper.
|
||||
///
|
||||
/// All symbols live in libc (resolved via `DynamicLibrary.process()`).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
@@ -17,6 +25,7 @@ import 'dart:typed_data';
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
||||
import 'errors.dart';
|
||||
import '../ipc/errno_mapping.dart' show PosixErrno;
|
||||
import 'ffi/libc.dart' as libc;
|
||||
|
||||
// -- structs ----------------------------------------------------------------
|
||||
@@ -43,39 +52,62 @@ final class _Pollfd extends ffi.Struct {
|
||||
|
||||
// -- FFI bindings -----------------------------------------------------------
|
||||
|
||||
final ffi.DynamicLibrary _dl = _openLib();
|
||||
final ffi.DynamicLibrary _dl = ffi.DynamicLibrary.process();
|
||||
|
||||
ffi.DynamicLibrary _openLib() {
|
||||
if (Platform.isMacOS) return ffi.DynamicLibrary.process();
|
||||
// Linux: forkpty lives in libutil
|
||||
return ffi.DynamicLibrary.open('libutil.so.1');
|
||||
}
|
||||
// pty open/setup (POSIX).
|
||||
final _posixOpenpt = _dl.lookupFunction<ffi.Int32 Function(ffi.Int32), int Function(int)>('posix_openpt');
|
||||
final _grantpt = _dl.lookupFunction<ffi.Int32 Function(ffi.Int32), int Function(int)>('grantpt');
|
||||
final _unlockpt = _dl.lookupFunction<ffi.Int32 Function(ffi.Int32), int Function(int)>('unlockpt');
|
||||
final _ptsname = _dl.lookupFunction<ffi.Pointer<Utf8> Function(ffi.Int32), ffi.Pointer<Utf8> Function(int)>('ptsname');
|
||||
|
||||
final _forkpty = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Int32>, ffi.Pointer<ffi.Char>, ffi.Pointer<ffi.Void>, ffi.Pointer<_Winsize>),
|
||||
int Function(ffi.Pointer<ffi.Int32>, ffi.Pointer<ffi.Char>, ffi.Pointer<ffi.Void>, ffi.Pointer<_Winsize>)>('forkpty');
|
||||
// posix_spawn family. The attr + file_actions structs are opaque to us
|
||||
// and platform-sized — we allocate a generous fixed buffer (8 KiB, far
|
||||
// larger than any documented platform layout) and pass it as Pointer<Void>.
|
||||
// init() writes the real layout into our memory; destroy() releases any
|
||||
// internal nested allocations.
|
||||
final _posixSpawn = _dl.lookupFunction<
|
||||
ffi.Int32 Function(ffi.Pointer<ffi.Int32>, ffi.Pointer<Utf8>, ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Pointer<Utf8>>,
|
||||
ffi.Pointer<ffi.Pointer<Utf8>>),
|
||||
int Function(ffi.Pointer<ffi.Int32>, ffi.Pointer<Utf8>, ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Pointer<Utf8>>,
|
||||
ffi.Pointer<ffi.Pointer<Utf8>>)>('posix_spawn');
|
||||
|
||||
final _execve = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Char>, ffi.Pointer<ffi.Pointer<ffi.Char>>, ffi.Pointer<ffi.Pointer<ffi.Char>>),
|
||||
int Function(ffi.Pointer<ffi.Char>, ffi.Pointer<ffi.Pointer<ffi.Char>>, ffi.Pointer<ffi.Pointer<ffi.Char>>)>('execve');
|
||||
final _spawnattrInit = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>), int Function(ffi.Pointer<ffi.Void>)>('posix_spawnattr_init');
|
||||
final _spawnattrDestroy = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>), int Function(ffi.Pointer<ffi.Void>)>('posix_spawnattr_destroy');
|
||||
final _spawnattrSetflags =
|
||||
_dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Int16), int Function(ffi.Pointer<ffi.Void>, int)>('posix_spawnattr_setflags');
|
||||
|
||||
final _nativeWrite = ffi.DynamicLibrary.process()
|
||||
.lookupFunction<ffi.IntPtr Function(ffi.Int32, ffi.Pointer<ffi.Void>, ffi.IntPtr), int Function(int, ffi.Pointer<ffi.Void>, int)>('write');
|
||||
final _faInit = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>), int Function(ffi.Pointer<ffi.Void>)>('posix_spawn_file_actions_init');
|
||||
final _faDestroy = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>), int Function(ffi.Pointer<ffi.Void>)>('posix_spawn_file_actions_destroy');
|
||||
final _faAddopen = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Int32, ffi.Pointer<Utf8>, ffi.Int32, ffi.Uint32),
|
||||
int Function(ffi.Pointer<ffi.Void>, int, ffi.Pointer<Utf8>, int, int)>('posix_spawn_file_actions_addopen');
|
||||
final _faAdddup2 = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Int32, ffi.Int32), int Function(ffi.Pointer<ffi.Void>, int, int)>(
|
||||
'posix_spawn_file_actions_adddup2');
|
||||
final _faAddclose =
|
||||
_dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Int32), int Function(ffi.Pointer<ffi.Void>, int)>('posix_spawn_file_actions_addclose');
|
||||
// glibc 2.29+ / macOS 10.15+. Both ship the `_np` suffix.
|
||||
final _faAddchdir = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Pointer<Utf8>), int Function(ffi.Pointer<ffi.Void>, ffi.Pointer<Utf8>)>(
|
||||
'posix_spawn_file_actions_addchdir_np');
|
||||
|
||||
final _nativeClose = ffi.DynamicLibrary.process().lookupFunction<ffi.Int32 Function(ffi.Int32), int Function(int)>('close');
|
||||
|
||||
final _ioctl = ffi.DynamicLibrary.process()
|
||||
.lookupFunction<ffi.Int32 Function(ffi.Int32, ffi.UnsignedLong, ffi.Pointer<_Winsize>), int Function(int, int, ffi.Pointer<_Winsize>)>('ioctl');
|
||||
|
||||
final _nativeKill = ffi.DynamicLibrary.process().lookupFunction<ffi.Int32 Function(ffi.Int32, ffi.Int32), int Function(int, int)>('kill');
|
||||
|
||||
final _waitpid = ffi.DynamicLibrary.process()
|
||||
.lookupFunction<ffi.Int32 Function(ffi.Int32, ffi.Pointer<ffi.Int32>, ffi.Int32), int Function(int, ffi.Pointer<ffi.Int32>, int)>('waitpid');
|
||||
|
||||
final _chdir = ffi.DynamicLibrary.process().lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Char>), int Function(ffi.Pointer<ffi.Char>)>('chdir');
|
||||
|
||||
final _exit_ = ffi.DynamicLibrary.process().lookupFunction<ffi.Void Function(ffi.Int32), void Function(int)>('_exit');
|
||||
// libc primitives shared with the reader isolate / lifecycle.
|
||||
final _nativeWrite =
|
||||
_dl.lookupFunction<ffi.IntPtr Function(ffi.Int32, ffi.Pointer<ffi.Void>, ffi.IntPtr), int Function(int, ffi.Pointer<ffi.Void>, int)>('write');
|
||||
final _nativeClose = _dl.lookupFunction<ffi.Int32 Function(ffi.Int32), int Function(int)>('close');
|
||||
final _ioctl =
|
||||
_dl.lookupFunction<ffi.Int32 Function(ffi.Int32, ffi.UnsignedLong, ffi.Pointer<_Winsize>), int Function(int, int, ffi.Pointer<_Winsize>)>('ioctl');
|
||||
final _nativeKill = _dl.lookupFunction<ffi.Int32 Function(ffi.Int32, ffi.Int32), int Function(int, int)>('kill');
|
||||
final _waitpid =
|
||||
_dl.lookupFunction<ffi.Int32 Function(ffi.Int32, ffi.Pointer<ffi.Int32>, ffi.Int32), int Function(int, ffi.Pointer<ffi.Int32>, int)>('waitpid');
|
||||
|
||||
// Constants — all duplicated from <fcntl.h>, <sys/ioctl.h>, <spawn.h>.
|
||||
final int _kTiocsWinsz = Platform.isMacOS ? 0x80087467 : 0x5414;
|
||||
const _kSighup = 1;
|
||||
const int _kORdwr = 0x0002;
|
||||
final int _kONoctty = Platform.isMacOS ? 0x20000 : 0x0100;
|
||||
// POSIX_SPAWN_SETSID — glibc 2.26+ (0x80), macOS 10.15+ (0x400).
|
||||
final int _kSpawnSetsid = Platform.isMacOS ? 0x400 : 0x80;
|
||||
// Opaque struct buffer size: ample headroom above every documented
|
||||
// platform layout (glibc posix_spawnattr_t is 336 B; macOS even smaller).
|
||||
const int _kSpawnStructBytes = 8192;
|
||||
|
||||
const _kWnohang = 1;
|
||||
|
||||
// -- NativePty --------------------------------------------------------------
|
||||
@@ -104,7 +136,11 @@ class NativePty {
|
||||
/// Spawn a new PTY running [executable] with [arguments].
|
||||
///
|
||||
/// [environment] must be the complete environment — it goes straight
|
||||
/// to execve's envp. Merge Platform.environment before calling.
|
||||
/// to the spawn's envp. Merge `Platform.environment` before calling.
|
||||
///
|
||||
/// Uses `posix_openpt` + `posix_spawn` (via libc's `vfork`-backed
|
||||
/// implementation) so no Dart code runs between fork and execve —
|
||||
/// see the library docstring and T-96.
|
||||
static NativePty start({
|
||||
required String executable,
|
||||
List<String> arguments = const ['-l'],
|
||||
@@ -113,7 +149,9 @@ class NativePty {
|
||||
String? workingDirectory,
|
||||
Map<String, String> environment = const {},
|
||||
}) {
|
||||
// Resolve bare command names via PATH (execve doesn't search PATH).
|
||||
// Resolve bare command names via PATH (posix_spawn requires an absolute
|
||||
// or relative path — posix_spawnp would search PATH for us but we want
|
||||
// resolution to be visible/debuggable from Dart).
|
||||
if (!executable.contains('/')) {
|
||||
final path = environment['PATH'] ?? Platform.environment['PATH'] ?? '';
|
||||
for (final dir in path.split(':')) {
|
||||
@@ -126,106 +164,136 @@ class NativePty {
|
||||
}
|
||||
}
|
||||
|
||||
// Force-resolve FFI functions that run in the child process.
|
||||
// Top-level finals are lazy; touching them here ensures the FFI
|
||||
// trampolines are compiled before fork() clones the process.
|
||||
final execve = _execve;
|
||||
final chdir = _chdir;
|
||||
final exit = _exit_;
|
||||
final writeFn = _nativeWrite;
|
||||
|
||||
// Pre-allocate error envelopes the child will write to its stdout
|
||||
// (slave PTY → parent's master fd) before _exit, so the parent's
|
||||
// reader sees a real diagnostic instead of an indistinguishable EOF.
|
||||
final chdirErr = 'clide: chdir failed: $workingDirectory\n'.toNativeUtf8(allocator: malloc);
|
||||
final chdirErrLen = chdirErr.length;
|
||||
final execveErr = 'clide: exec failed: $executable\n'.toNativeUtf8(allocator: malloc);
|
||||
final execveErrLen = execveErr.length;
|
||||
|
||||
// Allocate ALL native memory before fork.
|
||||
final shellN = executable.toNativeUtf8(allocator: malloc).cast<ffi.Char>();
|
||||
// ---- Open the pty master ------------------------------------------
|
||||
final masterFd = _posixOpenpt(_kORdwr | _kONoctty);
|
||||
if (masterFd < 0) {
|
||||
throw PtyException('posix_openpt', 'posix_openpt failed', errno: libc.errno);
|
||||
}
|
||||
if (_grantpt(masterFd) != 0) {
|
||||
final err = libc.errno;
|
||||
_nativeClose(masterFd);
|
||||
throw PtyException('grantpt', 'grantpt failed', errno: err);
|
||||
}
|
||||
if (_unlockpt(masterFd) != 0) {
|
||||
final err = libc.errno;
|
||||
_nativeClose(masterFd);
|
||||
throw PtyException('unlockpt', 'unlockpt failed', errno: err);
|
||||
}
|
||||
final slavePtr = _ptsname(masterFd);
|
||||
if (slavePtr == ffi.nullptr) {
|
||||
_nativeClose(masterFd);
|
||||
throw PtyException('ptsname', 'ptsname returned null');
|
||||
}
|
||||
// ptsname returns a pointer into a static (or thread-local) libc
|
||||
// buffer; copy to a Dart-owned native string before any other libc
|
||||
// call that might overwrite it.
|
||||
final slavePath = slavePtr.toDartString().toNativeUtf8(allocator: malloc);
|
||||
|
||||
// ---- Marshal argv + envp -----------------------------------------
|
||||
final exeN = executable.toNativeUtf8(allocator: malloc);
|
||||
final allArgs = [executable, ...arguments];
|
||||
final argvN = malloc<ffi.Pointer<ffi.Char>>(allArgs.length + 1);
|
||||
final argvN = malloc<ffi.Pointer<Utf8>>(allArgs.length + 1);
|
||||
for (var i = 0; i < allArgs.length; i++) {
|
||||
argvN[i] = allArgs[i].toNativeUtf8(allocator: malloc).cast();
|
||||
argvN[i] = allArgs[i].toNativeUtf8(allocator: malloc);
|
||||
}
|
||||
argvN[allArgs.length] = ffi.nullptr;
|
||||
|
||||
final envList = environment.entries.toList();
|
||||
final envpN = malloc<ffi.Pointer<ffi.Char>>(envList.length + 1);
|
||||
final envpN = malloc<ffi.Pointer<Utf8>>(envList.length + 1);
|
||||
for (var i = 0; i < envList.length; i++) {
|
||||
envpN[i] = '${envList[i].key}=${envList[i].value}'.toNativeUtf8(allocator: malloc).cast();
|
||||
envpN[i] = '${envList[i].key}=${envList[i].value}'.toNativeUtf8(allocator: malloc);
|
||||
}
|
||||
envpN[envList.length] = ffi.nullptr;
|
||||
|
||||
final wdN = (workingDirectory ?? '/').toNativeUtf8(allocator: malloc).cast<ffi.Char>();
|
||||
final fdOut = calloc<ffi.Int32>();
|
||||
final wdN = workingDirectory == null ? ffi.nullptr : workingDirectory.toNativeUtf8(allocator: malloc);
|
||||
|
||||
// ---- Build file_actions ------------------------------------------
|
||||
// Allocate as Uint8 so calloc treats it as a byte buffer; cast to
|
||||
// Pointer<Void> when handing off to the FFI calls.
|
||||
final fa = calloc<ffi.Uint8>(_kSpawnStructBytes).cast<ffi.Void>();
|
||||
final attr = calloc<ffi.Uint8>(_kSpawnStructBytes).cast<ffi.Void>();
|
||||
final pidOut = calloc<ffi.Int32>();
|
||||
|
||||
void freeAllInputs() {
|
||||
malloc.free(exeN);
|
||||
for (var i = 0; i < allArgs.length; i++) {
|
||||
malloc.free(argvN[i]);
|
||||
}
|
||||
malloc.free(argvN);
|
||||
for (var i = 0; i < envList.length; i++) {
|
||||
malloc.free(envpN[i]);
|
||||
}
|
||||
malloc.free(envpN);
|
||||
if (wdN != ffi.nullptr) malloc.free(wdN);
|
||||
malloc.free(slavePath);
|
||||
calloc.free(fa);
|
||||
calloc.free(attr);
|
||||
calloc.free(pidOut);
|
||||
}
|
||||
|
||||
if (_faInit(fa) != 0) {
|
||||
final err = libc.errno;
|
||||
_nativeClose(masterFd);
|
||||
freeAllInputs();
|
||||
throw PtyException('spawn_fa_init', 'posix_spawn_file_actions_init failed', errno: err);
|
||||
}
|
||||
if (_spawnattrInit(attr) != 0) {
|
||||
final err = libc.errno;
|
||||
_faDestroy(fa);
|
||||
_nativeClose(masterFd);
|
||||
freeAllInputs();
|
||||
throw PtyException('spawnattr_init', 'posix_spawnattr_init failed', errno: err);
|
||||
}
|
||||
|
||||
int rc = 0;
|
||||
rc |= _spawnattrSetflags(attr, _kSpawnSetsid);
|
||||
// Open the slave on fd 0 WITHOUT O_NOCTTY so it becomes the child's
|
||||
// controlling tty (the child is a fresh session leader courtesy of
|
||||
// POSIX_SPAWN_SETSID).
|
||||
rc |= _faAddopen(fa, 0, slavePath, _kORdwr, 0);
|
||||
rc |= _faAdddup2(fa, 0, 1);
|
||||
rc |= _faAdddup2(fa, 0, 2);
|
||||
// Don't leak the master fd into the child.
|
||||
rc |= _faAddclose(fa, masterFd);
|
||||
if (wdN != ffi.nullptr) {
|
||||
rc |= _faAddchdir(fa, wdN);
|
||||
}
|
||||
if (rc != 0) {
|
||||
_faDestroy(fa);
|
||||
_spawnattrDestroy(attr);
|
||||
_nativeClose(masterFd);
|
||||
freeAllInputs();
|
||||
throw PtyException('spawn_fa_setup', 'failed to compose posix_spawn actions', errno: libc.errno);
|
||||
}
|
||||
|
||||
// ---- Spawn -------------------------------------------------------
|
||||
final spawnRc = _posixSpawn(pidOut, exeN, fa, attr, argvN, envpN);
|
||||
final pid = pidOut.value;
|
||||
|
||||
_faDestroy(fa);
|
||||
_spawnattrDestroy(attr);
|
||||
|
||||
if (spawnRc != 0) {
|
||||
// posix_spawn returns the errno directly (does NOT set errno).
|
||||
_nativeClose(masterFd);
|
||||
freeAllInputs();
|
||||
throw PtyException('posix_spawn', 'posix_spawn failed', errno: spawnRc);
|
||||
}
|
||||
|
||||
// ---- Set initial winsize on the master ---------------------------
|
||||
final ws = calloc<_Winsize>()
|
||||
..ref.wsRow = rows
|
||||
..ref.wsCol = columns;
|
||||
_ioctl(masterFd, _kTiocsWinsz, ws);
|
||||
calloc.free(ws);
|
||||
|
||||
// Fork.
|
||||
final pid = _forkpty(fdOut, ffi.nullptr, ffi.nullptr, ws);
|
||||
freeAllInputs();
|
||||
|
||||
if (pid == -1) {
|
||||
// Capture errno BEFORE _freeAll — free() can clobber errno.
|
||||
final err = libc.errno;
|
||||
_freeAll(shellN, argvN, allArgs.length, envpN, envList.length, wdN, fdOut, ws);
|
||||
malloc.free(chdirErr);
|
||||
malloc.free(execveErr);
|
||||
throw PtyException('forkpty', 'forkpty() failed', errno: err);
|
||||
}
|
||||
|
||||
if (pid == 0) {
|
||||
// CHILD — only pre-resolved FFI calls, no Dart heap.
|
||||
// After forkpty(), fd 1 is the slave PTY connected back to the
|
||||
// parent's master fd, so write(1, ...) lands as readable output.
|
||||
if (chdir(wdN) != 0) {
|
||||
writeFn(1, chdirErr.cast(), chdirErrLen);
|
||||
exit(1);
|
||||
}
|
||||
execve(shellN, argvN, envpN);
|
||||
// execve only returns on failure.
|
||||
writeFn(1, execveErr.cast(), execveErrLen);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// PARENT
|
||||
final fd = fdOut.value;
|
||||
_freeAll(shellN, argvN, allArgs.length, envpN, envList.length, wdN, fdOut, ws);
|
||||
malloc.free(chdirErr);
|
||||
malloc.free(execveErr);
|
||||
|
||||
final pty = NativePty._(fd, pid);
|
||||
final pty = NativePty._(masterFd, pid);
|
||||
pty._spawnReader();
|
||||
return pty;
|
||||
}
|
||||
|
||||
static void _freeAll(
|
||||
ffi.Pointer shell,
|
||||
ffi.Pointer<ffi.Pointer<ffi.Char>> argv,
|
||||
int argc,
|
||||
ffi.Pointer<ffi.Pointer<ffi.Char>> envp,
|
||||
int envc,
|
||||
ffi.Pointer wd,
|
||||
ffi.Pointer fdOut,
|
||||
ffi.Pointer ws,
|
||||
) {
|
||||
malloc.free(shell);
|
||||
for (var i = 0; i < argc; i++) {
|
||||
malloc.free(argv[i]);
|
||||
}
|
||||
malloc.free(argv);
|
||||
for (var i = 0; i < envc; i++) {
|
||||
malloc.free(envp[i]);
|
||||
}
|
||||
malloc.free(envp);
|
||||
malloc.free(wd);
|
||||
calloc.free(fdOut);
|
||||
calloc.free(ws);
|
||||
}
|
||||
|
||||
// -- I/O ------------------------------------------------------------------
|
||||
|
||||
void _spawnReader() {
|
||||
@@ -236,6 +304,21 @@ class NativePty {
|
||||
final rp = ReceivePort();
|
||||
_readerPort = rp;
|
||||
_readerExited = Completer<void>();
|
||||
// Register the listener BEFORE spawning the isolate. ReceivePort buffers
|
||||
// messages until a listener attaches, but registering first removes any
|
||||
// ambiguity if the listen() call ever moves further away from spawn (and
|
||||
// sidesteps a real race we saw 1-in-10 in CI where output never arrived).
|
||||
rp.listen((msg) {
|
||||
if (msg == null) {
|
||||
if (!_out.isClosed) _out.close();
|
||||
rp.close();
|
||||
_readerPort = null;
|
||||
if (!_readerExited!.isCompleted) _readerExited!.complete();
|
||||
_reap();
|
||||
} else {
|
||||
if (!_out.isClosed) _out.add(msg as Uint8List);
|
||||
}
|
||||
});
|
||||
try {
|
||||
_readerIsolate = await Isolate.spawn(_readLoop, (rp.sendPort, _fd));
|
||||
} catch (e) {
|
||||
@@ -248,17 +331,6 @@ class NativePty {
|
||||
if (!_readerExited!.isCompleted) _readerExited!.complete();
|
||||
return;
|
||||
}
|
||||
rp.listen((msg) {
|
||||
if (msg == null) {
|
||||
if (!_out.isClosed) _out.close();
|
||||
rp.close();
|
||||
_readerPort = null;
|
||||
if (!_readerExited!.isCompleted) _readerExited!.complete();
|
||||
_reap();
|
||||
} else {
|
||||
if (!_out.isClosed) _out.add(msg as Uint8List);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Isolate entry — polls then reads until EOF/error/fd-closed.
|
||||
@@ -271,14 +343,16 @@ class NativePty {
|
||||
final buf = malloc<ffi.Uint8>(65536);
|
||||
final pfd = calloc<_Pollfd>();
|
||||
pfd.ref.fd = fd;
|
||||
pfd.ref.events = 0x0001; // POLLIN
|
||||
pfd.ref.events = libc.pollin;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
final ready = poll(pfd, 1, 100);
|
||||
if (ready < 0) break;
|
||||
if (ready == 0) continue;
|
||||
if (pfd.ref.revents & 0x0038 != 0 && pfd.ref.revents & 0x0001 == 0) {
|
||||
// Slave closed (POLLHUP / POLLERR / POLLNVAL) with no buffered
|
||||
// bytes left to read — caller loop exits and we send EOF.
|
||||
if (pfd.ref.revents & libc.pollAnyErr != 0 && pfd.ref.revents & libc.pollin == 0) {
|
||||
break;
|
||||
}
|
||||
final n = rd(fd, buf.cast(), 65536);
|
||||
@@ -311,8 +385,8 @@ class NativePty {
|
||||
);
|
||||
if (n < 0) {
|
||||
final err = libc.errno;
|
||||
if (err == 4 /* EINTR */) continue;
|
||||
if (err == 9 /* EBADF */ || err == 32 /* EPIPE */) _dead = true;
|
||||
if (err == PosixErrno.eintr) continue;
|
||||
if (err == PosixErrno.ebadf || err == PosixErrno.epipe) _dead = true;
|
||||
throw PtyException('write', 'write to PTY failed', errno: err);
|
||||
}
|
||||
if (n == 0) break;
|
||||
@@ -333,17 +407,16 @@ class NativePty {
|
||||
..ref.wsCol = cols;
|
||||
final rc = _ioctl(_fd, _kTiocsWinsz, ws);
|
||||
calloc.free(ws);
|
||||
if (rc < 0 && libc.errno == 9 /* EBADF */) {
|
||||
if (rc < 0 && libc.errno == PosixErrno.ebadf) {
|
||||
_dead = true;
|
||||
return;
|
||||
}
|
||||
// Explicitly signal the child to re-query its terminal size.
|
||||
// SIGWINCH = 28 on both macOS and Linux.
|
||||
_nativeKill(pid, 28);
|
||||
_nativeKill(pid, libc.sigwinch);
|
||||
}
|
||||
|
||||
/// Send a signal to the child.
|
||||
bool kill([int signal = _kSighup]) {
|
||||
bool kill([int signal = libc.sighup]) {
|
||||
if (_dead) return false;
|
||||
return _nativeKill(pid, signal) == 0;
|
||||
}
|
||||
@@ -372,7 +445,7 @@ class NativePty {
|
||||
// otherwise close() racing with start() leaves an orphan isolate.
|
||||
await _readerReady;
|
||||
|
||||
_nativeKill(pid, _kSighup);
|
||||
_nativeKill(pid, libc.sighup);
|
||||
_nativeKill(pid, 9);
|
||||
|
||||
// Wait for the isolate to send `null` (EOF) — confirms it has
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/// PTY subsystem — spawn child processes under a PTY via forkpty(),
|
||||
/// expose their master fd as a byte stream. Desktop IDE's pane model
|
||||
/// (terminal / Claude / future tmux wrappers) rides on this.
|
||||
/// PTY subsystem — spawn child processes under a PTY via posix_openpt()
|
||||
/// + posix_spawn(), expose their master fd as a byte stream. Desktop
|
||||
/// IDE's pane model (terminal / Claude / future tmux wrappers) rides on
|
||||
/// this.
|
||||
library;
|
||||
|
||||
export 'env.dart' show clidePtyEnvDefaults, mergePtyEnv;
|
||||
|
||||
+8
-4
@@ -86,7 +86,7 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
_say('');
|
||||
|
||||
final tc = Toolchain();
|
||||
tc.applyResolved(Toolchain.resolvePaths(workspaceRoot: workDir));
|
||||
tc.applyResolved(Toolchain.resolvePaths());
|
||||
|
||||
if (runToolchain) await _runToolchainTests(tc, workDir);
|
||||
if (runIpc) await _runIpcTests(workDir);
|
||||
@@ -159,12 +159,12 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
_say('--- boot sequence ---');
|
||||
|
||||
await _testAsync('compute(resolveToolchainPaths)', () async {
|
||||
final paths = await compute(resolveToolchainPaths, workDir);
|
||||
final paths = await compute((_) => resolveToolchainPaths(), null);
|
||||
return 'git=${paths.git} pql=${paths.pql}';
|
||||
});
|
||||
|
||||
await _testAsync('Isolate.run(resolveToolchainPaths)', () async {
|
||||
final paths = await Isolate.run(() => resolveToolchainPaths(workDir));
|
||||
final paths = await Isolate.run(resolveToolchainPaths);
|
||||
return 'git=${paths.git} pql=${paths.pql}';
|
||||
});
|
||||
|
||||
@@ -180,7 +180,7 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
});
|
||||
|
||||
await _testAsync('compute + immediate Process.run', () async {
|
||||
final paths = await compute(resolveToolchainPaths, workDir);
|
||||
final paths = await compute((_) => resolveToolchainPaths(), null);
|
||||
final tc2 = Toolchain();
|
||||
tc2.applyResolved(paths);
|
||||
final r = await Process.run(tc2.git, ['rev-parse', '--show-toplevel'], workingDirectory: workDir, environment: tc2.gitEnv);
|
||||
@@ -246,6 +246,10 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
'lib/kernel/src/theme/themes/midnight.yaml',
|
||||
'lib/kernel/src/theme/themes/paper.yaml',
|
||||
'lib/kernel/src/theme/themes/terminal.yaml',
|
||||
'lib/kernel/src/theme/themes/clide-hc.yaml',
|
||||
'lib/kernel/src/theme/themes/midnight-hc.yaml',
|
||||
'lib/kernel/src/theme/themes/paper-hc.yaml',
|
||||
'lib/kernel/src/theme/themes/terminal-hc.yaml',
|
||||
];
|
||||
for (final p in paths) {
|
||||
final name = p.split('/').last.replaceAll('.yaml', '');
|
||||
|
||||
@@ -108,7 +108,7 @@ class _WinButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hoverBg = isClose ? const Color(0xFFE81123) : tokens.listItemHoverBackground;
|
||||
final hoverBg = isClose ? tokens.windowControlCloseHoverBackground : tokens.listItemHoverBackground;
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
@@ -116,7 +116,11 @@ class _WinButton extends StatelessWidget {
|
||||
height: hatHeight,
|
||||
color: hovered ? hoverBg : null,
|
||||
alignment: Alignment.center,
|
||||
child: ClideIcon(icon, size: 14, color: hovered && isClose ? const Color(0xFFFFFFFF) : tokens.globalTextMuted),
|
||||
child: ClideIcon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: hovered && isClose ? tokens.windowControlCloseHoverForeground : tokens.globalTextMuted,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,21 +12,91 @@ class ClidePalette extends StatefulWidget {
|
||||
|
||||
class _ClidePaletteState extends State<ClidePalette> {
|
||||
final _input = TextEditingController();
|
||||
final _focus = FocusNode();
|
||||
final _focus = FocusNode(debugLabel: 'ClidePalette.input');
|
||||
final _itemKeys = <int, GlobalKey>{};
|
||||
|
||||
PaletteController? _palette;
|
||||
KeymapService? _keymap;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focus.requestFocus();
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final kernel = ClideKernel.of(context);
|
||||
if (!identical(_palette, kernel.palette)) {
|
||||
_palette?.removeListener(_onPaletteChanged);
|
||||
_palette = kernel.palette;
|
||||
_palette!.addListener(_onPaletteChanged);
|
||||
_syncFromController();
|
||||
}
|
||||
_keymap = kernel.keymap;
|
||||
// Sync the initial state: if the palette was opened before this
|
||||
// widget mounted (e.g., open()-then-pumpWidget in a test), no
|
||||
// listener fires for the "already open" condition. Mirror what
|
||||
// _onPaletteChanged would have done.
|
||||
final isOpen = _palette?.isOpen ?? false;
|
||||
_keymap?.setScopeFlag('palette.open', isOpen);
|
||||
if (isOpen && !_focus.hasFocus) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && (_palette?.isOpen ?? false)) _focus.requestFocus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_palette?.removeListener(_onPaletteChanged);
|
||||
_keymap?.clearScopeFlag('palette.open');
|
||||
_input.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onPaletteChanged() {
|
||||
final isOpen = _palette?.isOpen ?? false;
|
||||
_keymap?.setScopeFlag('palette.open', isOpen);
|
||||
if (isOpen) _focus.requestFocus();
|
||||
_syncFromController();
|
||||
}
|
||||
|
||||
void _syncFromController() {
|
||||
final f = _palette?.filter ?? '';
|
||||
if (_input.text != f) {
|
||||
_input.value = TextEditingValue(text: f, selection: TextSelection.collapsed(offset: f.length));
|
||||
}
|
||||
}
|
||||
|
||||
Object? _selectNext(PaletteSelectNextIntent _) {
|
||||
_palette?.selectNext();
|
||||
_scrollSelectedIntoView();
|
||||
return null;
|
||||
}
|
||||
|
||||
Object? _selectPrev(PaletteSelectPreviousIntent _) {
|
||||
_palette?.selectPrevious();
|
||||
_scrollSelectedIntoView();
|
||||
return null;
|
||||
}
|
||||
|
||||
Object? _accept(PaletteAcceptIntent _) {
|
||||
_palette?.acceptSelected();
|
||||
_input.clear();
|
||||
return null;
|
||||
}
|
||||
|
||||
Object? _dismiss(DismissIntent _) {
|
||||
_palette?.close();
|
||||
return null;
|
||||
}
|
||||
|
||||
void _scrollSelectedIntoView() {
|
||||
final idx = _palette?.selectedIndex;
|
||||
if (idx == null) return;
|
||||
final key = _itemKeys[idx];
|
||||
final ctx = key?.currentContext;
|
||||
if (ctx == null) return;
|
||||
Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 120), alignment: 0.5);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
@@ -36,71 +106,84 @@ class _ClidePaletteState extends State<ClidePalette> {
|
||||
builder: (ctx, _) {
|
||||
if (!kernel.palette.isOpen) return const SizedBox.shrink();
|
||||
final filtered = kernel.palette.filtered();
|
||||
final selected = kernel.palette.selectedIndex;
|
||||
return Positioned(
|
||||
top: 60,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 480,
|
||||
constraints: const BoxConstraints(maxHeight: 360),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.dropdownBackground,
|
||||
border: Border.all(color: tokens.dropdownBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x40000000),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: EditableText(
|
||||
controller: _input,
|
||||
focusNode: _focus,
|
||||
style: TextStyle(
|
||||
fontFamily: clideMonoFamily,
|
||||
fontSize: clideFontMono,
|
||||
color: tokens.dropdownForeground,
|
||||
),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalFocus,
|
||||
maxLines: 1,
|
||||
onChanged: (v) => kernel.palette.setFilter(v),
|
||||
onSubmitted: (_) {
|
||||
if (filtered.isNotEmpty) {
|
||||
kernel.palette.invoke(filtered.first.command);
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{
|
||||
PaletteSelectNextIntent: CallbackAction<PaletteSelectNextIntent>(onInvoke: _selectNext),
|
||||
PaletteSelectPreviousIntent: CallbackAction<PaletteSelectPreviousIntent>(onInvoke: _selectPrev),
|
||||
PaletteAcceptIntent: CallbackAction<PaletteAcceptIntent>(onInvoke: _accept),
|
||||
DismissIntent: CallbackAction<DismissIntent>(onInvoke: _dismiss),
|
||||
},
|
||||
child: Container(
|
||||
width: 480,
|
||||
constraints: const BoxConstraints(maxHeight: 360),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.dropdownBackground,
|
||||
border: Border.all(color: tokens.dropdownBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: tokens.shadowAmbient,
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: EditableText(
|
||||
controller: _input,
|
||||
focusNode: _focus,
|
||||
style: TextStyle(
|
||||
fontFamily: clideMonoFamily,
|
||||
fontSize: clideFontMono,
|
||||
color: tokens.dropdownForeground,
|
||||
),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalFocus,
|
||||
maxLines: 1,
|
||||
onChanged: (v) => kernel.palette.setFilter(v),
|
||||
// Enter on the input forwards to the palette
|
||||
// accept intent — keeps the legacy single-key
|
||||
// submit working alongside arrow-driven nav.
|
||||
onSubmitted: (_) {
|
||||
kernel.palette.acceptSelected();
|
||||
_input.clear();
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final cmd = filtered[i];
|
||||
return _PaletteItem(
|
||||
title: cmd.title ?? cmd.command,
|
||||
command: cmd.command,
|
||||
binding: cmd.defaultBinding,
|
||||
onTap: () {
|
||||
kernel.palette.invoke(cmd.command);
|
||||
_input.clear();
|
||||
},
|
||||
);
|
||||
},
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final cmd = filtered[i];
|
||||
final key = _itemKeys.putIfAbsent(i, () => GlobalKey());
|
||||
return _PaletteItem(
|
||||
key: key,
|
||||
title: cmd.title ?? cmd.command,
|
||||
command: cmd.command,
|
||||
binding: cmd.defaultBinding,
|
||||
highlighted: i == selected,
|
||||
onTap: () {
|
||||
kernel.palette.invoke(cmd.command);
|
||||
_input.clear();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -112,15 +195,18 @@ class _ClidePaletteState extends State<ClidePalette> {
|
||||
|
||||
class _PaletteItem extends StatefulWidget {
|
||||
const _PaletteItem({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.command,
|
||||
required this.onTap,
|
||||
required this.highlighted,
|
||||
this.binding,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String command;
|
||||
final String? binding;
|
||||
final bool highlighted;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
@@ -133,6 +219,7 @@ class _PaletteItemState extends State<_PaletteItem> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final selected = widget.highlighted;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
@@ -140,14 +227,18 @@ class _PaletteItemState extends State<_PaletteItem> {
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
color: _hover ? tokens.listItemHoverBackground : null,
|
||||
color: selected
|
||||
? tokens.listItemSelectedBackground
|
||||
: _hover
|
||||
? tokens.listItemHoverBackground
|
||||
: null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
widget.title,
|
||||
color: tokens.listItemForeground,
|
||||
color: selected ? tokens.listItemSelectedForeground : tokens.listItemForeground,
|
||||
),
|
||||
),
|
||||
if (widget.binding != null)
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import 'package:clide/kernel/kernel.dart' show ClideTheme;
|
||||
import 'package:clide/widgets/src/clide_tooltip.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Mouse + keyboard activatable surface. Wraps the [builder] child in
|
||||
/// a `Focus` so Tab traversal reaches it; an `Actions` provider that
|
||||
/// handles [ActivateIntent] by invoking [onTap] (the keymap binds
|
||||
/// Enter / Space to ActivateIntent by default); and a focus ring
|
||||
/// rendered via `tokens.globalFocus`.
|
||||
///
|
||||
/// Hover + pressed state still feed [builder] for visual feedback.
|
||||
/// Disabled state (`onTap == null`) blocks focus traversal too.
|
||||
class ClideTappable extends StatefulWidget {
|
||||
const ClideTappable({
|
||||
super.key,
|
||||
@@ -10,6 +19,8 @@ class ClideTappable extends StatefulWidget {
|
||||
this.onPressChanged,
|
||||
this.cursor = SystemMouseCursors.click,
|
||||
this.tooltip,
|
||||
this.focusNode,
|
||||
this.autofocus = false,
|
||||
});
|
||||
|
||||
final Widget Function(BuildContext context, bool hovered, bool pressed) builder;
|
||||
@@ -18,6 +29,8 @@ class ClideTappable extends StatefulWidget {
|
||||
final ValueChanged<bool>? onPressChanged;
|
||||
final MouseCursor cursor;
|
||||
final String? tooltip;
|
||||
final FocusNode? focusNode;
|
||||
final bool autofocus;
|
||||
|
||||
@override
|
||||
State<ClideTappable> createState() => _ClideTappableState();
|
||||
@@ -26,6 +39,16 @@ class ClideTappable extends StatefulWidget {
|
||||
class _ClideTappableState extends State<ClideTappable> {
|
||||
bool _hover = false;
|
||||
bool _pressed = false;
|
||||
bool _focused = false;
|
||||
FocusNode? _internalFocus;
|
||||
|
||||
FocusNode get _effectiveFocus => widget.focusNode ?? (_internalFocus ??= FocusNode(debugLabel: 'ClideTappable'));
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_internalFocus?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _setPressed(bool v) {
|
||||
if (_pressed == v) return;
|
||||
@@ -33,10 +56,22 @@ class _ClideTappableState extends State<ClideTappable> {
|
||||
widget.onPressChanged?.call(v);
|
||||
}
|
||||
|
||||
void _setFocused(bool v) {
|
||||
if (_focused == v) return;
|
||||
setState(() => _focused = v);
|
||||
}
|
||||
|
||||
Object? _activate(ActivateIntent _) {
|
||||
widget.onTap?.call();
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final enabled = widget.onTap != null;
|
||||
Widget child = MouseRegion(
|
||||
cursor: widget.cursor,
|
||||
cursor: enabled ? widget.cursor : SystemMouseCursors.forbidden,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) {
|
||||
setState(() => _hover = false);
|
||||
@@ -46,12 +81,46 @@ class _ClideTappableState extends State<ClideTappable> {
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onTap,
|
||||
onLongPress: widget.onLongPress,
|
||||
onTapDown: (_) => _setPressed(true),
|
||||
onTapUp: (_) => _setPressed(false),
|
||||
onTapCancel: () => _setPressed(false),
|
||||
onTapDown: enabled ? (_) => _setPressed(true) : null,
|
||||
onTapUp: enabled ? (_) => _setPressed(false) : null,
|
||||
onTapCancel: enabled ? () => _setPressed(false) : null,
|
||||
child: widget.builder(context, _hover, _pressed),
|
||||
),
|
||||
);
|
||||
|
||||
// Focus ring — 2 px outer outline in the global focus token. Drawn
|
||||
// as a wrapping decoration so it sits outside the child's content
|
||||
// without shifting layout (the same DecoratedBox always paints;
|
||||
// border color falls through to transparent when unfocused).
|
||||
child = DecoratedBox(
|
||||
position: DecorationPosition.foreground,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: _focused ? tokens.globalFocus : const Color(0x00000000),
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
// Actions wraps Focus: dispatching ActivateIntent from the focused
|
||||
// context (the node held by Focus) walks UP and finds this Actions
|
||||
// provider. The reverse nesting would leave Actions as a descendant
|
||||
// of the focused context — unreachable.
|
||||
child = Actions(
|
||||
actions: <Type, Action<Intent>>{
|
||||
ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: _activate),
|
||||
},
|
||||
child: Focus(
|
||||
focusNode: _effectiveFocus,
|
||||
canRequestFocus: enabled,
|
||||
autofocus: widget.autofocus,
|
||||
onFocusChange: _setFocused,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.tooltip != null) {
|
||||
child = ClideTooltip(message: widget.tooltip!, child: child);
|
||||
}
|
||||
|
||||
+3
-2
@@ -13,12 +13,12 @@ description: >-
|
||||
subsystem handlers (pane, files, editor, git, pql), and the
|
||||
extension framework.
|
||||
publish_to: none
|
||||
version: 2.0.0
|
||||
version: 2.1.0
|
||||
repository: https://github.com/postmeridiem/clide
|
||||
|
||||
# Pre-push line-coverage floor. Ratchets up only — see D-66.
|
||||
# Reading: `awk -F: '/^coverage_floor:/ {gsub(/ /,"",$2); print $2}' pubspec.yaml`.
|
||||
coverage_floor: 74
|
||||
coverage_floor: 95
|
||||
|
||||
# Project metadata (was project.yaml, folded in per D-056).
|
||||
# version: above is the single source of truth. The Makefile reads
|
||||
@@ -67,6 +67,7 @@ flutter:
|
||||
assets:
|
||||
- lib/kernel/src/theme/themes/
|
||||
- lib/kernel/src/i18n/catalog/
|
||||
- assets/keymaps/
|
||||
- assets/licenses.yaml
|
||||
- assets/LICENSE
|
||||
- assets/fonts/jetbrains_mono/OFL.txt
|
||||
|
||||
@@ -6,6 +6,10 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
/// [canonicalPairs]) must clear 4.5:1 for normal text / 3:1 for large
|
||||
/// text. Failing pairs are printed with their computed ratio so a
|
||||
/// theme-token regression shows exactly which pair broke.
|
||||
///
|
||||
/// Themes whose name ends in `-hc` (high-contrast) or `-cb`
|
||||
/// (colour-blind) additionally have to clear the stricter
|
||||
/// [extendedPairs] set — D-69.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
@@ -15,6 +19,10 @@ void main() {
|
||||
'lib/kernel/src/theme/themes/midnight.yaml',
|
||||
'lib/kernel/src/theme/themes/paper.yaml',
|
||||
'lib/kernel/src/theme/themes/terminal.yaml',
|
||||
'lib/kernel/src/theme/themes/clide-hc.yaml',
|
||||
'lib/kernel/src/theme/themes/midnight-hc.yaml',
|
||||
'lib/kernel/src/theme/themes/paper-hc.yaml',
|
||||
'lib/kernel/src/theme/themes/terminal-hc.yaml',
|
||||
];
|
||||
|
||||
for (final path in bundledPaths) {
|
||||
@@ -34,6 +42,16 @@ void main() {
|
||||
'${failures.map((f) => ' - $f').join('\n')}',
|
||||
);
|
||||
}
|
||||
final isStrict = def.name.endsWith('-hc') || def.name.endsWith('-cb');
|
||||
if (isStrict) {
|
||||
final extended = failingExtendedPairs(tokens);
|
||||
if (extended.isNotEmpty) {
|
||||
fail(
|
||||
'Extended contrast failures in ${def.name} (strict gate):\n'
|
||||
'${extended.map((f) => ' - $f').join('\n')}',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -45,5 +45,114 @@ void main() {
|
||||
hasLength(1),
|
||||
);
|
||||
});
|
||||
|
||||
test('all commands return not-activated errors before activate', () async {
|
||||
final ext = DefaultLayoutExtension();
|
||||
final cmds = ext.contributions.whereType<CommandContribution>().toList();
|
||||
// 12+ commands including the 5 sidebar.section.N variants.
|
||||
expect(cmds.length, greaterThan(10));
|
||||
for (final cmd in cmds) {
|
||||
final r = await cmd.run(const []);
|
||||
expect(r.ok, isFalse, reason: 'expected ${cmd.command} to fail pre-activate');
|
||||
expect(r.error!.message, contains('not activated'));
|
||||
}
|
||||
});
|
||||
|
||||
test('palette.toggle flips palette open state', () async {
|
||||
f.services.extensions.register(DefaultLayoutExtension());
|
||||
await f.services.extensions.activateAll();
|
||||
expect(f.services.palette.isOpen, isFalse);
|
||||
final r1 = await f.services.commands.execute('palette.toggle');
|
||||
expect(r1.ok, isTrue);
|
||||
expect(r1.data['open'], isTrue);
|
||||
expect(f.services.palette.isOpen, isTrue);
|
||||
final r2 = await f.services.commands.execute('palette.toggle');
|
||||
expect(r2.data['open'], isFalse);
|
||||
});
|
||||
|
||||
test('sidebar.collapse + context.collapse toggle their respective slots', () async {
|
||||
f.services.extensions.register(DefaultLayoutExtension());
|
||||
await f.services.extensions.activateAll();
|
||||
expect(f.services.arrangement.isCollapsed(Slots.sidebar), isFalse);
|
||||
await f.services.commands.execute('sidebar.collapse');
|
||||
expect(f.services.arrangement.isCollapsed(Slots.sidebar), isTrue);
|
||||
await f.services.commands.execute('sidebar.collapse');
|
||||
expect(f.services.arrangement.isCollapsed(Slots.sidebar), isFalse);
|
||||
await f.services.commands.execute('context.collapse');
|
||||
expect(f.services.arrangement.isCollapsed(Slots.contextPanel), isTrue);
|
||||
});
|
||||
|
||||
test('panel.focus.{left,middle,right} expand collapsed sides + set focus', () async {
|
||||
f.services.extensions.register(DefaultLayoutExtension());
|
||||
await f.services.extensions.activateAll();
|
||||
// Collapse both side panels first.
|
||||
f.services.arrangement.setCollapsed(Slots.sidebar, true);
|
||||
f.services.arrangement.setCollapsed(Slots.contextPanel, true);
|
||||
// Focus left: re-expands sidebar.
|
||||
await f.services.commands.execute('panel.focus.left');
|
||||
expect(f.services.arrangement.isCollapsed(Slots.sidebar), isFalse);
|
||||
// Focus right: re-expands context.
|
||||
await f.services.commands.execute('panel.focus.right');
|
||||
expect(f.services.arrangement.isCollapsed(Slots.contextPanel), isFalse);
|
||||
// Focus middle: no expansion needed, just sets focus.
|
||||
final r = await f.services.commands.execute('panel.focus.middle');
|
||||
expect(r.ok, isTrue);
|
||||
});
|
||||
|
||||
test('panel.focusMode toggles focus mode on the active slot', () async {
|
||||
f.services.extensions.register(DefaultLayoutExtension());
|
||||
await f.services.extensions.activateAll();
|
||||
expect(f.services.arrangement.isInFocusMode, isFalse);
|
||||
await f.services.commands.execute('panel.focusMode');
|
||||
expect(f.services.arrangement.isInFocusMode, isTrue);
|
||||
});
|
||||
|
||||
test('panel.focusMode.exit unwinds focus-mode → editor → palette', () async {
|
||||
f.services.extensions.register(DefaultLayoutExtension());
|
||||
await f.services.extensions.activateAll();
|
||||
// Focus mode: exit clears it.
|
||||
f.services.arrangement.toggleFocusMode(Slots.workspace);
|
||||
expect(f.services.arrangement.isInFocusMode, isTrue);
|
||||
await f.services.commands.execute('panel.focusMode.exit');
|
||||
expect(f.services.arrangement.isInFocusMode, isFalse);
|
||||
// Editor open: exit closes it.
|
||||
f.services.arrangement.openEditor();
|
||||
expect(f.services.arrangement.editorOpen, isTrue);
|
||||
await f.services.commands.execute('panel.focusMode.exit');
|
||||
expect(f.services.arrangement.editorOpen, isFalse);
|
||||
// Palette open: exit closes it.
|
||||
f.services.palette.toggle();
|
||||
expect(f.services.palette.isOpen, isTrue);
|
||||
await f.services.commands.execute('panel.focusMode.exit');
|
||||
expect(f.services.palette.isOpen, isFalse);
|
||||
// Nothing to exit: returns ok with empty data.
|
||||
final noop = await f.services.commands.execute('panel.focusMode.exit');
|
||||
expect(noop.ok, isTrue);
|
||||
});
|
||||
|
||||
test('editor.open and editor.close toggle the editor split', () async {
|
||||
f.services.extensions.register(DefaultLayoutExtension());
|
||||
await f.services.extensions.activateAll();
|
||||
expect(f.services.arrangement.editorOpen, isFalse);
|
||||
await f.services.commands.execute('editor.open');
|
||||
expect(f.services.arrangement.editorOpen, isTrue);
|
||||
await f.services.commands.execute('editor.close');
|
||||
expect(f.services.arrangement.editorOpen, isFalse);
|
||||
// editor.close when already closed → ok no-op.
|
||||
final noop = await f.services.commands.execute('editor.close');
|
||||
expect(noop.ok, isTrue);
|
||||
});
|
||||
|
||||
test('sidebar.section.N activates the Nth sidebar tab', () async {
|
||||
f.services.extensions.register(DefaultLayoutExtension());
|
||||
await f.services.extensions.activateAll();
|
||||
// Collapse sidebar so we exercise the re-expand branch.
|
||||
f.services.arrangement.setCollapsed(Slots.sidebar, true);
|
||||
// No-op when no tabs are contributed.
|
||||
final r = await f.services.commands.execute('sidebar.section.1');
|
||||
expect(r.ok, isTrue);
|
||||
// Sidebar auto-expanded.
|
||||
expect(f.services.arrangement.isCollapsed(Slots.sidebar), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:clide/builtin/theme_picker/theme_picker.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/// Tests for the welcome view's open-project + not-a-repo dialogs.
|
||||
/// Uses a custom harness that wraps the standard widget harness in a
|
||||
/// DialogHost so kernel.dialog.show calls actually render their
|
||||
/// builder into the tree.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/welcome/src/welcome_view.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
/// Wraps [harness] in a DialogHost rooted on the fixture's dialog
|
||||
/// router so kernel.dialog.show(...) calls render into the tree.
|
||||
Widget _harness(KernelFixture f, Widget child) {
|
||||
return harness(
|
||||
f,
|
||||
DialogHost(router: f.services.dialog, child: child),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('WelcomeView dialogs', () {
|
||||
late KernelFixture f;
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create(
|
||||
i18nCatalogs: {
|
||||
'builtin.welcome': {
|
||||
const Locale('en', 'US'): const {
|
||||
'title': {'translation': 'clide'},
|
||||
'subtitle': {'translation': 'IDE'},
|
||||
'open-project': {'translation': 'Open project'},
|
||||
'open-project.hint': {'translation': 'Pick a git repository'},
|
||||
'tab.title': {'translation': 'Welcome'},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
tearDown(() async => f.dispose());
|
||||
|
||||
testWidgets('MissingPluginException path renders the OpenProjectDialog', (tester) async {
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('clide/window'),
|
||||
(call) async {
|
||||
if (call.method == 'pickDirectory') {
|
||||
throw MissingPluginException();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
tester.view.physicalSize = const Size(1200, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(_harness(f, const WelcomeView()));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Open folder…'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Open project'), findsOneWidget);
|
||||
expect(find.text('Enter the path to a git repository.'), findsOneWidget);
|
||||
expect(find.text('Cancel'), findsOneWidget);
|
||||
expect(find.text('Open'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('OpenProjectDialog Cancel dismisses the modal', (tester) async {
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('clide/window'),
|
||||
(call) async => throw MissingPluginException(),
|
||||
);
|
||||
tester.view.physicalSize = const Size(1200, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(_harness(f, const WelcomeView()));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Open folder…'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Open project'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('OpenProjectDialog Open with empty path is a no-op', (tester) async {
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('clide/window'),
|
||||
(call) async => throw MissingPluginException(),
|
||||
);
|
||||
tester.view.physicalSize = const Size(1200, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(_harness(f, const WelcomeView()));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Open folder…'));
|
||||
await tester.pumpAndSettle();
|
||||
// Press Open with the text field empty.
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
// Dialog stays.
|
||||
expect(find.text('Open project'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('OpenProjectDialog Open with a non-repo path keeps the dialog (project.open returns false)', (tester) async {
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('clide/window'),
|
||||
(call) async => throw MissingPluginException(),
|
||||
);
|
||||
tester.view.physicalSize = const Size(1200, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(_harness(f, const WelcomeView()));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Open folder…'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(EditableText).last, '/tmp/clide-not-a-repo-${DateTime.now().microsecondsSinceEpoch}');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
// project.open returns false → dialog stays open, "Opening…" no
|
||||
// longer shows.
|
||||
expect(find.text('Open project'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import 'dart:ui';
|
||||
|
||||
import 'package:clide/builtin/welcome/welcome.dart';
|
||||
import 'package:clide/builtin/welcome/src/welcome_view.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -47,5 +49,107 @@ void main() {
|
||||
expect(find.text('IDE for Claude Code CLI'), findsOneWidget);
|
||||
expect(find.text('Open folder…'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('TIPS card renders when the viewport is tall enough', (tester) async {
|
||||
tester.view.physicalSize = const Size(1200, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(harness(f, const WelcomeView()));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('TIPS'), findsOneWidget);
|
||||
expect(find.text('Quick open'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('TIPS card is hidden when the viewport is short', (tester) async {
|
||||
tester.view.physicalSize = const Size(1200, 500);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(harness(f, const WelcomeView()));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('TIPS'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('status line shows "checking…" before toolchain resolution', (tester) async {
|
||||
await tester.pumpWidget(harness(f, const WelcomeView()));
|
||||
expect(find.text('checking…'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('status line shows "application ok" when all tools resolved', (tester) async {
|
||||
f.services.toolchain.applyResolved(const ResolvedPaths(
|
||||
git: '/usr/bin/git',
|
||||
pql: '/usr/bin/pql',
|
||||
tmux: '/usr/bin/tmux',
|
||||
shell: '/bin/bash',
|
||||
));
|
||||
await tester.pumpWidget(harness(f, const WelcomeView()));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('application ok'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('status line lists missing tools when some are absent', (tester) async {
|
||||
tester.view.physicalSize = const Size(1600, 900);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
f.services.toolchain.applyResolved(const ResolvedPaths(
|
||||
pql: '/usr/bin/pql',
|
||||
));
|
||||
await tester.pumpWidget(harness(f, const WelcomeView()));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('git not found'), findsOneWidget);
|
||||
expect(find.textContaining('tmux not found'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('theme-name link fires the theme.pick command when tapped', (tester) async {
|
||||
var invocations = 0;
|
||||
f.services.commands.register(CommandContribution(
|
||||
id: 'theme.pick',
|
||||
command: 'theme.pick',
|
||||
title: 'Theme: Pick',
|
||||
run: (_) async {
|
||||
invocations++;
|
||||
return IpcResponse.ok(id: '', data: const {});
|
||||
},
|
||||
));
|
||||
await tester.pumpWidget(harness(f, const WelcomeView()));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.textContaining('theme:'));
|
||||
await tester.pump();
|
||||
expect(invocations, 1);
|
||||
});
|
||||
|
||||
testWidgets('Open folder tap kicks off the picker flow without throwing', (tester) async {
|
||||
await tester.pumpWidget(harness(f, const WelcomeView()));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Open folder…'));
|
||||
await tester.pumpAndSettle();
|
||||
// In the test harness, the platform channel returns null (no native
|
||||
// picker, no MissingPluginException), so the function returns
|
||||
// early without raising. The point is just to exercise the path.
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('Open folder opens the fallback dialog when the picker throws MissingPluginException', (tester) async {
|
||||
// Pre-register a mock that throws — emulating a platform without
|
||||
// native picker support.
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('clide/window'),
|
||||
(call) async {
|
||||
if (call.method == 'pickDirectory') {
|
||||
throw MissingPluginException();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
await tester.pumpWidget(harness(f, const WelcomeView()));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Open folder…'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(f.services.dialog.isOpen, isTrue);
|
||||
f.services.dialog.dismiss();
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/// Unit tests for the daemon's command dispatcher.
|
||||
library;
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
IpcRequest _req(String cmd, {String id = '1', Map<String, Object?> args = const {}}) {
|
||||
return IpcRequest(id: id, cmd: cmd, args: args);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('DaemonDispatcher', () {
|
||||
test('ping is registered by default and returns pong + version', () async {
|
||||
final d = DaemonDispatcher();
|
||||
final r = await d.dispatch(_req('ping'));
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['pong'], isTrue);
|
||||
expect(r.data['version'], isNotNull);
|
||||
expect(r.data['ts'], isA<String>());
|
||||
});
|
||||
|
||||
test('version returns the bundled version string', () async {
|
||||
final d = DaemonDispatcher();
|
||||
final r = await d.dispatch(_req('version'));
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['version'], clideVersion);
|
||||
});
|
||||
|
||||
test('dispatching an unknown command produces a not-found error', () async {
|
||||
final d = DaemonDispatcher();
|
||||
final r = await d.dispatch(_req('nonsense'));
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.notFound);
|
||||
expect(r.error?.message, contains('unknown command'));
|
||||
});
|
||||
|
||||
test('register routes a new handler', () async {
|
||||
final d = DaemonDispatcher();
|
||||
d.register('echo', (req) async {
|
||||
return IpcResponse.ok(id: req.id, data: {'echo': req.args['text']});
|
||||
});
|
||||
final r = await d.dispatch(_req('echo', args: {'text': 'hi'}));
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['echo'], 'hi');
|
||||
});
|
||||
|
||||
test('isEmpty is true for a fresh dispatcher (ping + version only)', () {
|
||||
final d = DaemonDispatcher();
|
||||
expect(d.isEmpty, isTrue);
|
||||
d.register('something', (req) async => IpcResponse.ok(id: req.id, data: const {}));
|
||||
expect(d.isEmpty, isFalse);
|
||||
});
|
||||
|
||||
test('clear removes user handlers but keeps ping + version', () async {
|
||||
final d = DaemonDispatcher();
|
||||
d.register('extra', (req) async => IpcResponse.ok(id: req.id, data: const {}));
|
||||
expect(d.isEmpty, isFalse);
|
||||
d.clear();
|
||||
expect(d.isEmpty, isTrue);
|
||||
// ping still works
|
||||
final r = await d.dispatch(_req('ping'));
|
||||
expect(r.ok, isTrue);
|
||||
// 'extra' is gone
|
||||
final r2 = await d.dispatch(_req('extra'));
|
||||
expect(r2.ok, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -107,4 +107,108 @@ void main() {
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.code, IpcExitCode.notFound);
|
||||
});
|
||||
|
||||
test('editor.open returns toolError for an unreadable path', () async {
|
||||
// Create a file then chmod 000 so reading fails with a FileSystemException.
|
||||
final unreadable = File('${sandbox.path}/locked.md');
|
||||
await unreadable.writeAsString('x');
|
||||
await Process.run('chmod', ['000', unreadable.path]);
|
||||
addTearDown(() async {
|
||||
await Process.run('chmod', ['644', unreadable.path]);
|
||||
});
|
||||
final r = await call('editor.open', {'path': 'locked.md'});
|
||||
expect(r.ok, isFalse);
|
||||
// Either errno-mapped or toolError — either is acceptable.
|
||||
expect(r.error!.code, isNot(IpcExitCode.notFound));
|
||||
});
|
||||
|
||||
test('editor.active returns null when no buffer is open', () async {
|
||||
final r = await call('editor.active');
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['active'], isNull);
|
||||
});
|
||||
|
||||
test('editor.activate requires id and validates it', () async {
|
||||
final missing = await call('editor.activate');
|
||||
expect(missing.ok, isFalse);
|
||||
expect(missing.error!.kind, 'user_error');
|
||||
final unknown = await call('editor.activate', {'id': 'b_404'});
|
||||
expect(unknown.ok, isFalse);
|
||||
expect(unknown.error!.kind, 'not_found');
|
||||
});
|
||||
|
||||
test('editor.activate flips the active buffer to the requested one', () async {
|
||||
await File('${sandbox.path}/a.md').writeAsString('a');
|
||||
await File('${sandbox.path}/b.md').writeAsString('b');
|
||||
final a = await call('editor.open', {'path': 'a.md'});
|
||||
await call('editor.open', {'path': 'b.md'});
|
||||
final r = await call('editor.activate', {'id': a.data['id']});
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['active'], a.data['id']);
|
||||
});
|
||||
|
||||
test('editor.read with no active buffer and no id returns not-found', () async {
|
||||
final r = await call('editor.read');
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, 'not_found');
|
||||
});
|
||||
|
||||
test('editor.read with an unknown id returns not-found', () async {
|
||||
final r = await call('editor.read', {'id': 'b_404'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, 'not_found');
|
||||
});
|
||||
|
||||
test('editor.set-selection clamps and applies', () async {
|
||||
await call('editor.open', {'path': 'doc.md'});
|
||||
final r = await call('editor.set-selection', {
|
||||
'selection': {'start': 0, 'end': 3}
|
||||
});
|
||||
expect(r.ok, isTrue);
|
||||
});
|
||||
|
||||
test('editor.set-selection without an id or active buffer returns not-found', () async {
|
||||
final r = await call('editor.set-selection', {
|
||||
'selection': {'start': 0, 'end': 1}
|
||||
});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, 'not_found');
|
||||
});
|
||||
|
||||
test('editor.set-content overwrites the buffer (with and without selection)', () async {
|
||||
await call('editor.open', {'path': 'doc.md'});
|
||||
final r1 = await call('editor.set-content', {'text': 'replaced'});
|
||||
expect(r1.ok, isTrue);
|
||||
expect(r1.data['length'], 'replaced'.length);
|
||||
final read1 = await call('editor.read');
|
||||
expect(read1.data['content'], 'replaced');
|
||||
final r2 = await call('editor.set-content', {
|
||||
'text': 'short',
|
||||
'selection': {'start': 1, 'end': 99}
|
||||
});
|
||||
expect(r2.ok, isTrue);
|
||||
});
|
||||
|
||||
test('editor.save with no active buffer returns not-found', () async {
|
||||
final r = await call('editor.save');
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, 'not_found');
|
||||
});
|
||||
|
||||
test('editor.close requires id and validates it', () async {
|
||||
final missing = await call('editor.close');
|
||||
expect(missing.ok, isFalse);
|
||||
expect(missing.error!.kind, 'user_error');
|
||||
final unknown = await call('editor.close', {'id': 'b_404'});
|
||||
expect(unknown.ok, isFalse);
|
||||
expect(unknown.error!.kind, 'not_found');
|
||||
});
|
||||
|
||||
test('insert / replace / set-content / save with no active buffer all return not-found', () async {
|
||||
for (final cmd in ['editor.insert', 'editor.replace-selection', 'editor.set-content', 'editor.save']) {
|
||||
final r = await call(cmd, {'text': 'x'});
|
||||
expect(r.ok, isFalse, reason: cmd);
|
||||
expect(r.error!.kind, 'not_found', reason: cmd);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,4 +78,122 @@ void main() {
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['subscribed'], isTrue);
|
||||
});
|
||||
|
||||
test('files.read returns the file content', () async {
|
||||
final r = await call('files.read', const {'path': 'README.md'});
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['content'], 'hi');
|
||||
expect(r.data['path'], 'README.md');
|
||||
});
|
||||
|
||||
test('files.read without a path returns toolError', () async {
|
||||
final r = await call('files.read', const {});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, IpcErrorKind.toolError);
|
||||
expect(r.error!.message, contains('path'));
|
||||
});
|
||||
|
||||
test('files.read with an empty string path returns toolError', () async {
|
||||
final r = await call('files.read', const {'path': ''});
|
||||
expect(r.ok, isFalse);
|
||||
});
|
||||
|
||||
test('files.read with a path outside the root is rejected', () async {
|
||||
final r = await call('files.read', const {'path': '../escape.txt'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.message, contains('outside workspace'));
|
||||
});
|
||||
|
||||
test('files.read returns toolError for a missing file', () async {
|
||||
final r = await call('files.read', const {'path': 'does-not-exist.md'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.message, contains('not found'));
|
||||
});
|
||||
|
||||
test('files.read rejects a file over the size cap', () async {
|
||||
// Cap is 10 MB; write 11 MB of zeros and confirm rejection rather
|
||||
// than reading it into memory.
|
||||
final big = File('${sandbox.path}/huge.bin');
|
||||
final chunk = List<int>.filled(1024 * 1024, 0);
|
||||
final sink = big.openWrite();
|
||||
for (var i = 0; i < 11; i++) {
|
||||
sink.add(chunk);
|
||||
}
|
||||
await sink.flush();
|
||||
await sink.close();
|
||||
final r = await call('files.read', const {'path': 'huge.bin'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.message, contains('too large'));
|
||||
});
|
||||
|
||||
test('files.ls with a path outside the root is rejected', () async {
|
||||
final r = await call('files.ls', const {'path': '../escape'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.message, contains('outside workspace'));
|
||||
});
|
||||
|
||||
test('files.read rejects a symlink whose target is outside the workspace (T-102)', () async {
|
||||
final outside = await Directory.systemTemp.createTemp('clide_t102_read_');
|
||||
addTearDown(() async {
|
||||
if (await outside.exists()) await outside.delete(recursive: true);
|
||||
});
|
||||
File('${outside.path}/secret.txt').writeAsStringSync('payload');
|
||||
Link('${sandbox.path}/leak').createSync('${outside.path}/secret.txt');
|
||||
|
||||
final r = await call('files.read', const {'path': 'leak'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.message, contains('outside workspace'));
|
||||
});
|
||||
|
||||
test('files.ls rejects a symlinked subdir whose target is outside (T-102)', () async {
|
||||
final outside = await Directory.systemTemp.createTemp('clide_t102_ls_');
|
||||
addTearDown(() async {
|
||||
if (await outside.exists()) await outside.delete(recursive: true);
|
||||
});
|
||||
Link('${sandbox.path}/leak-dir').createSync(outside.path);
|
||||
|
||||
final r = await call('files.ls', const {'path': 'leak-dir'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.message, contains('outside workspace'));
|
||||
});
|
||||
|
||||
test('files.watch is idempotent: a second call still acks subscription', () async {
|
||||
final r1 = await call('files.watch', const {});
|
||||
final r2 = await call('files.watch', const {});
|
||||
expect(r1.ok, isTrue);
|
||||
expect(r2.ok, isTrue);
|
||||
});
|
||||
|
||||
test('FilesService.atCwd resolves a workspace root', () {
|
||||
final svc = FilesService.atCwd(events: RecordingEventSink());
|
||||
expect(svc.root.existsSync(), isTrue);
|
||||
addTearDown(svc.shutdown);
|
||||
});
|
||||
|
||||
test('files.watch emits files.changed when a file is created under root', () async {
|
||||
final ack = await call('files.watch', const {});
|
||||
expect(ack.ok, isTrue);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
await File('${sandbox.path}/created.txt').writeAsString('x');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
// FilesService.startWatching wires watcher.stream → events.emit;
|
||||
// exercising the emit branch is the goal — the consumer-side
|
||||
// assertion is covered in test/files/watcher_test.dart.
|
||||
});
|
||||
|
||||
test('FilesService.atCwd walks parent dirs looking for .git, falls back to CWD if none', () async {
|
||||
final deepNoGit = await Directory.systemTemp.createTemp('clide-no-git-');
|
||||
addTearDown(() => deepNoGit.deleteSync(recursive: true));
|
||||
final nested = Directory('${deepNoGit.path}/a/b/c')..createSync(recursive: true);
|
||||
final saved = Directory.current;
|
||||
try {
|
||||
Directory.current = nested;
|
||||
final svc = FilesService.atCwd(events: RecordingEventSink());
|
||||
// No .git anywhere on the walk → root falls back to CWD.
|
||||
expect(svc.root.absolute.path, nested.absolute.path);
|
||||
await svc.shutdown();
|
||||
} finally {
|
||||
Directory.current = saved;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/// Drives every git.* daemon handler with a GitClient whose toolchain
|
||||
/// points at a non-existent binary. Each underlying call throws
|
||||
/// GitException, exercising the catch branches in
|
||||
/// `lib/src/daemon/git_commands.dart` that the happy-path suite can't
|
||||
/// reach.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/toolchain_paths.dart';
|
||||
import 'package:clide/src/daemon/git_commands.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
late DaemonDispatcher dispatcher;
|
||||
late Directory sandbox;
|
||||
|
||||
setUp(() async {
|
||||
sandbox = await Directory.systemTemp.createTemp('clide-git-cmd-err-');
|
||||
final toolchain = ToolchainView.resolved(const ResolvedPaths(git: '/tmp/clide-no-such-git-binary'));
|
||||
final git = GitClient(toolchain: toolchain, workDir: sandbox);
|
||||
dispatcher = DaemonDispatcher();
|
||||
final sink = RecordingEventSink();
|
||||
registerGitCommands(dispatcher, git, sink);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (sandbox.existsSync()) sandbox.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
Future<IpcResponse> call(String cmd, [Map<String, Object?> args = const {}]) {
|
||||
return dispatcher.dispatch(IpcRequest(id: '1', cmd: cmd, args: args));
|
||||
}
|
||||
|
||||
final cases = [
|
||||
('git.status', const <String, Object?>{}),
|
||||
('git.diff', const <String, Object?>{}),
|
||||
('git.log', const <String, Object?>{}),
|
||||
(
|
||||
'git.stage',
|
||||
const {
|
||||
'paths': ['file.txt']
|
||||
}
|
||||
),
|
||||
('git.stage-all', const <String, Object?>{}),
|
||||
('git.unstage', const <String, Object?>{}),
|
||||
// git.stage-hunk / git.unstage-hunk go through GitClient._applyPatch
|
||||
// which uses Process.start (not Process.run) — that throws
|
||||
// ProcessException directly without wrapping in GitException.
|
||||
// Leaving them out so the fault-injection harness stays clean;
|
||||
// separate ticket if we ever want to catch + rewrap there.
|
||||
(
|
||||
'git.discard',
|
||||
const {
|
||||
'paths': ['file.txt']
|
||||
}
|
||||
),
|
||||
('git.commit', const {'message': 'hi'}),
|
||||
('git.stash', const <String, Object?>{}),
|
||||
('git.stash-pop', const <String, Object?>{}),
|
||||
('git.pull', const <String, Object?>{}),
|
||||
('git.push', const <String, Object?>{}),
|
||||
('git.branches', const <String, Object?>{}),
|
||||
('git.checkout', const {'branch': 'main'}),
|
||||
];
|
||||
|
||||
for (final (cmd, args) in cases) {
|
||||
test('$cmd surfaces GitException as a toolError', () async {
|
||||
final r = await call(cmd, args);
|
||||
// Some commands have happy fallbacks (git.diff returns empty on
|
||||
// non-zero exit; git.log similar) — those return ok=true with
|
||||
// empty data. Only assert ok=false for the ones that throw.
|
||||
if (!r.ok) {
|
||||
expect(r.error?.kind, IpcErrorKind.toolError, reason: cmd);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
import 'package:clide/kernel/src/toolchain_paths.dart';
|
||||
import 'package:clide/src/daemon/git_commands.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
@@ -33,8 +33,7 @@ void main() {
|
||||
|
||||
sink = RecordingEventSink();
|
||||
dispatcher = DaemonDispatcher();
|
||||
final toolchain = Toolchain();
|
||||
toolchain.applyResolved(Toolchain.resolvePaths(workspaceRoot: sandbox.path));
|
||||
final toolchain = ToolchainView.resolved(resolveToolchainPaths());
|
||||
final gitClient = GitClient(toolchain: toolchain, workDir: sandbox);
|
||||
registerGitCommands(dispatcher, gitClient, sink);
|
||||
});
|
||||
@@ -188,4 +187,152 @@ void main() {
|
||||
content = await File('${sandbox.path}/file.txt').readAsString();
|
||||
expect(content, 'stash-me');
|
||||
});
|
||||
|
||||
test('git.diff with explicit paths narrows the result', () async {
|
||||
await File('${sandbox.path}/file.txt').writeAsString('hello\nworld\n');
|
||||
await File('${sandbox.path}/other.txt').writeAsString('o');
|
||||
final r = await call('git.diff', {
|
||||
'paths': ['file.txt']
|
||||
});
|
||||
expect(r.ok, isTrue);
|
||||
final diffs = r.data['diffs'] as List;
|
||||
expect(diffs, hasLength(1));
|
||||
});
|
||||
|
||||
test('git.stage-hunk requires a non-empty patch', () async {
|
||||
final missing = await call('git.stage-hunk');
|
||||
expect(missing.ok, isFalse);
|
||||
expect(missing.error?.kind, IpcErrorKind.userError);
|
||||
final empty = await call('git.stage-hunk', {'patch': ''});
|
||||
expect(empty.ok, isFalse);
|
||||
});
|
||||
|
||||
test('git.unstage-hunk requires a non-empty patch', () async {
|
||||
final missing = await call('git.unstage-hunk');
|
||||
expect(missing.ok, isFalse);
|
||||
expect(missing.error?.kind, IpcErrorKind.userError);
|
||||
});
|
||||
|
||||
test('git.stage-hunk + git.unstage-hunk round-trip a real patch', () async {
|
||||
await File('${sandbox.path}/file.txt').writeAsString('hello\nworld\n');
|
||||
final p = await Process.run('git', ['diff', '-U0'], workingDirectory: sandbox.path);
|
||||
final patch = p.stdout as String;
|
||||
final staged = await call('git.stage-hunk', {'patch': patch});
|
||||
expect(staged.ok, isTrue);
|
||||
final unstaged = await call('git.unstage-hunk', {'patch': patch});
|
||||
expect(unstaged.ok, isTrue);
|
||||
});
|
||||
|
||||
test('git.stage-hunk surfaces GitException as a tool error', () async {
|
||||
final r = await call('git.stage-hunk', {'patch': 'not a valid patch\n'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.toolError);
|
||||
});
|
||||
|
||||
test('git.branches lists the local branches', () async {
|
||||
await Process.run('git', ['branch', 'feature/a'], workingDirectory: sandbox.path);
|
||||
final r = await call('git.branches');
|
||||
expect(r.ok, isTrue);
|
||||
final branches = r.data['branches'] as List;
|
||||
expect(branches.map((b) => (b as Map)['name']), containsAll(['feature/a']));
|
||||
});
|
||||
|
||||
test('git.checkout requires a branch name', () async {
|
||||
final missing = await call('git.checkout');
|
||||
expect(missing.ok, isFalse);
|
||||
expect(missing.error?.kind, IpcErrorKind.userError);
|
||||
final empty = await call('git.checkout', {'branch': ''});
|
||||
expect(empty.ok, isFalse);
|
||||
});
|
||||
|
||||
test('git.checkout switches branches', () async {
|
||||
await Process.run('git', ['branch', 'next'], workingDirectory: sandbox.path);
|
||||
final r = await call('git.checkout', {'branch': 'next'});
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['branch'], 'next');
|
||||
});
|
||||
|
||||
test('git.checkout to an unknown branch surfaces a tool error', () async {
|
||||
final r = await call('git.checkout', {'branch': 'no-such-branch'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.toolError);
|
||||
});
|
||||
|
||||
test('git.log accepts an explicit count', () async {
|
||||
final r = await call('git.log', {'count': 5});
|
||||
expect(r.ok, isTrue);
|
||||
final entries = r.data['entries'] as List;
|
||||
expect(entries, isNotEmpty);
|
||||
});
|
||||
|
||||
test('git.push to no remote surfaces a tool error', () async {
|
||||
final r = await call('git.push');
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.toolError);
|
||||
});
|
||||
|
||||
test('git.pull with no remote surfaces a tool error', () async {
|
||||
final r = await call('git.pull');
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.toolError);
|
||||
});
|
||||
|
||||
test('git.push + git.pull against a local bare remote return output', () async {
|
||||
final remote = await Directory.systemTemp.createTemp('clide-git-cmd-remote-');
|
||||
addTearDown(() => remote.deleteSync(recursive: true));
|
||||
await Process.run('git', ['init', '--bare'], workingDirectory: remote.path);
|
||||
await Process.run('git', ['remote', 'add', 'origin', remote.path], workingDirectory: sandbox.path);
|
||||
final pushed = await call('git.push', {'remote': 'origin', 'branch': 'HEAD', 'setUpstream': true});
|
||||
expect(pushed.ok, isTrue);
|
||||
final pulled = await call('git.pull');
|
||||
expect(pulled.ok, isTrue);
|
||||
});
|
||||
|
||||
test('git.stage accepts a string single-path arg', () async {
|
||||
await File('${sandbox.path}/new.txt').writeAsString('x');
|
||||
// _pathList accepts a String, wrapping it as a singleton.
|
||||
final r = await call('git.stage', {'paths': 'new.txt'});
|
||||
expect(r.ok, isTrue);
|
||||
});
|
||||
|
||||
test('git.checkout rejects a -prefixed branch (argv-injection guard)', () async {
|
||||
final r = await call('git.checkout', {'branch': '--upload-pack=evil'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.message, contains('branch'));
|
||||
});
|
||||
|
||||
test('git.push rejects a -prefixed remote', () async {
|
||||
final r = await call('git.push', {'remote': '--upload-pack=evil', 'branch': 'main'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.message, contains('remote'));
|
||||
});
|
||||
|
||||
test('git.push rejects a -prefixed branch', () async {
|
||||
final r = await call('git.push', {'remote': 'origin', 'branch': '--exec=evil'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.message, contains('branch'));
|
||||
});
|
||||
|
||||
test('git.log over the count cap fails as userError', () async {
|
||||
final r = await call('git.log', {'count': 100000});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
expect(r.error?.message, contains('exceeds cap'));
|
||||
});
|
||||
|
||||
test('git.diff with too many paths fails as userError', () async {
|
||||
final paths = [for (var i = 0; i < 300; i++) 'file_$i.txt'];
|
||||
final r = await call('git.diff', {'paths': paths});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
expect(r.error?.message, contains('exceeds cap'));
|
||||
});
|
||||
|
||||
test('git.stage with too many paths fails as userError', () async {
|
||||
final paths = [for (var i = 0; i < 300; i++) 'file_$i.txt'];
|
||||
final r = await call('git.stage', {'paths': paths});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error?.kind, IpcErrorKind.userError);
|
||||
expect(r.error?.message, contains('exceeds cap'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,5 +111,86 @@ void main() {
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['subscribed'], isTrue);
|
||||
});
|
||||
|
||||
test('pane.spawn rejects non-string argv entries', () async {
|
||||
final r = await call('pane.spawn', const {
|
||||
'argv': ['/bin/sh', 42]
|
||||
});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.message, contains('strings'));
|
||||
});
|
||||
|
||||
test('pane.spawn rejects an unknown kind', () async {
|
||||
final r = await call('pane.spawn', const {
|
||||
'argv': ['/bin/cat'],
|
||||
'kind': 'no-such-kind',
|
||||
});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, 'user_error');
|
||||
});
|
||||
|
||||
test('pane.spawn passes env through as strings', () async {
|
||||
final r = await call('pane.spawn', {
|
||||
'argv': const ['/bin/sh', '-c', 'env'],
|
||||
'env': const {'FOO': 'bar'},
|
||||
});
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
});
|
||||
|
||||
test('pane.close requires id and validates it', () async {
|
||||
final missing = await call('pane.close', const {});
|
||||
expect(missing.ok, isFalse);
|
||||
expect(missing.error!.kind, 'user_error');
|
||||
final unknown = await call('pane.close', const {'id': 'p_404'});
|
||||
expect(unknown.ok, isFalse);
|
||||
expect(unknown.error!.kind, 'not_found');
|
||||
});
|
||||
|
||||
test('pane.write requires id and validates it', () async {
|
||||
final missing = await call('pane.write', const {'text': 'x'});
|
||||
expect(missing.ok, isFalse);
|
||||
expect(missing.error!.kind, 'user_error');
|
||||
});
|
||||
|
||||
test('pane.write requires bytes_b64 or text', () async {
|
||||
final spawn = await call('pane.spawn', {
|
||||
'argv': const ['/bin/cat'],
|
||||
});
|
||||
final id = spawn.data['id']! as String;
|
||||
final r = await call('pane.write', {'id': id});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.message, contains('bytes_b64 or text'));
|
||||
});
|
||||
|
||||
test('pane.write rejects malformed base64', () async {
|
||||
final spawn = await call('pane.spawn', {
|
||||
'argv': const ['/bin/cat'],
|
||||
});
|
||||
final id = spawn.data['id']! as String;
|
||||
final r = await call('pane.write', {'id': id, 'bytes_b64': 'not-base64!!!'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.message, contains('base64'));
|
||||
});
|
||||
|
||||
test('pane.resize requires all three of id / cols / rows', () async {
|
||||
final r = await call('pane.resize', const {'id': 'p_1'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, 'user_error');
|
||||
});
|
||||
|
||||
test('pane.resize on unknown id is not-found', () async {
|
||||
final r = await call('pane.resize', const {'id': 'p_404', 'cols': 80, 'rows': 24});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.code, IpcExitCode.notFound);
|
||||
});
|
||||
|
||||
test('pane.focus requires id and validates it', () async {
|
||||
final missing = await call('pane.focus', const {});
|
||||
expect(missing.ok, isFalse);
|
||||
expect(missing.error!.kind, 'user_error');
|
||||
final unknown = await call('pane.focus', const {'id': 'p_404'});
|
||||
expect(unknown.ok, isFalse);
|
||||
expect(unknown.error!.kind, 'not_found');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/// Drives every pql.* daemon handler with a PqlClient whose toolchain
|
||||
/// points at a non-existent binary. Each underlying call throws
|
||||
/// PqlException, exercising the catch branches in
|
||||
/// `lib/src/daemon/pql_commands.dart` that the happy-path suite can't
|
||||
/// reach.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/toolchain_paths.dart';
|
||||
import 'package:clide/src/daemon/pql_commands.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
late DaemonDispatcher dispatcher;
|
||||
|
||||
setUp(() {
|
||||
final toolchain = ToolchainView.resolved(const ResolvedPaths(pql: '/tmp/clide-no-such-pql-binary'));
|
||||
final pql = PqlClient(workDir: Directory.current, toolchain: toolchain);
|
||||
dispatcher = DaemonDispatcher();
|
||||
registerPqlCommands(dispatcher, pql);
|
||||
});
|
||||
|
||||
Future<IpcResponse> call(String cmd, [Map<String, Object?> args = const {}]) {
|
||||
return dispatcher.dispatch(IpcRequest(id: '1', cmd: cmd, args: args));
|
||||
}
|
||||
|
||||
// Each pql.* command, when the underlying binary is missing, should
|
||||
// surface a toolError with the pql operation name in the message.
|
||||
final commandsWithSimpleArgs = [
|
||||
('pql.files', const <String, Object?>{}),
|
||||
('pql.meta', const {'path': 'CLAUDE.md'}),
|
||||
('pql.backlinks', const {'path': 'CLAUDE.md'}),
|
||||
('pql.outlinks', const {'path': 'CLAUDE.md'}),
|
||||
('pql.tags', const <String, Object?>{}),
|
||||
('pql.schema', const <String, Object?>{}),
|
||||
('pql.query', const {'query': 'SELECT name'}),
|
||||
('pql.search', const {'terms': 'clide'}),
|
||||
('pql.doctor', const <String, Object?>{}),
|
||||
('pql.decisions.sync', const <String, Object?>{}),
|
||||
('pql.decisions.list', const <String, Object?>{}),
|
||||
('pql.decisions.read', const {'id': 'D-1'}),
|
||||
('pql.decisions.show', const {'id': 'D-1'}),
|
||||
('pql.tickets.list', const <String, Object?>{}),
|
||||
('pql.tickets.show', const {'id': 'T-1'}),
|
||||
(
|
||||
'pql.tickets.status',
|
||||
const {
|
||||
'ids': ['T-1'],
|
||||
'status': 'done',
|
||||
}
|
||||
),
|
||||
('pql.tickets.board', const <String, Object?>{}),
|
||||
('pql.plan.status', const <String, Object?>{}),
|
||||
];
|
||||
|
||||
for (final (cmd, args) in commandsWithSimpleArgs) {
|
||||
test('$cmd surfaces PqlException as a toolError', () async {
|
||||
final r = await call(cmd, args);
|
||||
expect(r.ok, isFalse, reason: cmd);
|
||||
expect(r.error?.kind, IpcErrorKind.toolError, reason: cmd);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
import 'package:clide/kernel/src/toolchain_paths.dart';
|
||||
import 'package:clide/src/daemon/pql_commands.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
@@ -10,8 +10,7 @@ void main() {
|
||||
late PqlClient pql;
|
||||
|
||||
setUp(() {
|
||||
final toolchain = Toolchain();
|
||||
toolchain.applyResolved(Toolchain.resolvePaths(workspaceRoot: Directory.current.path));
|
||||
final toolchain = ToolchainView.resolved(resolveToolchainPaths());
|
||||
pql = PqlClient(workDir: Directory.current, toolchain: toolchain);
|
||||
dispatcher = DaemonDispatcher();
|
||||
registerPqlCommands(dispatcher, pql);
|
||||
@@ -120,4 +119,108 @@ void main() {
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, 'user_error');
|
||||
});
|
||||
|
||||
test('pql.files with glob + limit narrows the result', () async {
|
||||
final r = await call('pql.files', {'glob': 'CLAUDE.md', 'limit': 1});
|
||||
expect(r.ok, isTrue);
|
||||
final files = r.data['files'] as List;
|
||||
expect(files.length, lessThanOrEqualTo(1));
|
||||
});
|
||||
|
||||
test('pql.backlinks with a path returns links list', () async {
|
||||
final r = await call('pql.backlinks', {'path': 'CLAUDE.md'});
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['links'], isA<List>());
|
||||
});
|
||||
|
||||
test('pql.outlinks without path returns user_error', () async {
|
||||
final r = await call('pql.outlinks');
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, 'user_error');
|
||||
});
|
||||
|
||||
test('pql.tags returns a tag list (with limit)', () async {
|
||||
final r = await call('pql.tags', {'limit': 5});
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['tags'], isA<List>());
|
||||
});
|
||||
|
||||
test('pql.query with a DSL string returns results', () async {
|
||||
final r = await call('pql.query', {'query': 'SELECT name', 'limit': 2});
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['results'], isA<List>());
|
||||
});
|
||||
|
||||
test('pql.search with terms returns hits; missing terms is user_error', () async {
|
||||
final missing = await call('pql.search');
|
||||
expect(missing.ok, isFalse);
|
||||
expect(missing.error!.kind, 'user_error');
|
||||
final hit = await call('pql.search', {'terms': 'clide', 'limit': 2});
|
||||
expect(hit.ok, isTrue);
|
||||
});
|
||||
|
||||
test('pql.decisions.read requires id; happy path returns the body', () async {
|
||||
final missing = await call('pql.decisions.read');
|
||||
expect(missing.ok, isFalse);
|
||||
expect(missing.error!.kind, 'user_error');
|
||||
final ok = await call('pql.decisions.read', {'id': 'D-1'});
|
||||
expect(ok.ok, isTrue);
|
||||
expect(ok.data['id'], 'D-1');
|
||||
});
|
||||
|
||||
test('pql.decisions.show forwards withRefs / withTickets', () async {
|
||||
final r = await call('pql.decisions.show', {
|
||||
'id': 'D-1',
|
||||
'withRefs': true,
|
||||
'withTickets': true,
|
||||
});
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['id'], 'D-1');
|
||||
});
|
||||
|
||||
test('pql.decisions.list with a domain filter narrows', () async {
|
||||
final r = await call('pql.decisions.list', {'domain': 'architecture'});
|
||||
expect(r.ok, isTrue);
|
||||
final decisions = r.data['decisions'] as List;
|
||||
expect(decisions.every((d) => (d as Map)['domain'] == 'architecture'), isTrue);
|
||||
});
|
||||
|
||||
test('pql.tickets.list with filters narrows + shows status', () async {
|
||||
final r = await call('pql.tickets.list', {
|
||||
'status': 'done',
|
||||
'team': 'whatever',
|
||||
'assigned': 'no-one',
|
||||
'decision': 'D-1',
|
||||
});
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['tickets'], isA<List>());
|
||||
});
|
||||
|
||||
test('pql.tickets.show requires id; happy path returns the row', () async {
|
||||
final missing = await call('pql.tickets.show');
|
||||
expect(missing.ok, isFalse);
|
||||
expect(missing.error!.kind, 'user_error');
|
||||
final ok = await call('pql.tickets.show', {
|
||||
'id': 'T-1',
|
||||
'withContext': true,
|
||||
'withBlockers': true,
|
||||
});
|
||||
expect(ok.ok, isTrue);
|
||||
});
|
||||
|
||||
test('pql.tickets.status requires ids + status', () async {
|
||||
final missing = await call('pql.tickets.status');
|
||||
expect(missing.ok, isFalse);
|
||||
expect(missing.error!.kind, 'user_error');
|
||||
// Accept both List and String for ids.
|
||||
final justOne = await call('pql.tickets.status', {'ids': 'T-1'});
|
||||
expect(justOne.ok, isFalse); // status still missing
|
||||
expect(justOne.error!.kind, 'user_error');
|
||||
});
|
||||
|
||||
test('pql.tickets.board with team filter', () async {
|
||||
final r = await call('pql.tickets.board', {'team': 'whatever'});
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['columns'], isA<List>());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -93,4 +93,62 @@ void main() {
|
||||
expect(buf.content, isEmpty);
|
||||
expect(buf.dirty, isFalse);
|
||||
});
|
||||
|
||||
test('activate(unknown id) is a no-op; activate(known) flips the active buffer', () async {
|
||||
final a = await reg.open('README.md');
|
||||
final b = await reg.open('NEW.md');
|
||||
// b is currently active.
|
||||
expect(reg.active, same(b));
|
||||
sink.events.clear();
|
||||
reg.activate('does-not-exist'); // no-op, no emit
|
||||
expect(sink.ofKind('editor.active-changed'), isEmpty);
|
||||
reg.activate(a.id);
|
||||
expect(reg.active, same(a));
|
||||
expect(sink.ofKind('editor.active-changed'), hasLength(1));
|
||||
});
|
||||
|
||||
test('setContent with explicit selection clamps + emits editor.edited replace', () async {
|
||||
final buf = await reg.open('README.md');
|
||||
sink.events.clear();
|
||||
reg.setContent(buf.id, 'short', selection: const Selection(start: 1, end: 99));
|
||||
expect(buf.content, 'short');
|
||||
// 99 clamped to content.length (5).
|
||||
expect(buf.selection.end, 5);
|
||||
expect(buf.dirty, isTrue);
|
||||
final emitted = sink.ofKind('editor.edited').single;
|
||||
expect(emitted.data['kind'], 'replace');
|
||||
expect(emitted.data['length'], 5);
|
||||
});
|
||||
|
||||
test('setContent without selection clamps the existing selection to the new content', () async {
|
||||
final buf = await reg.open('README.md');
|
||||
reg.setSelection(buf.id, const Selection(start: 6, end: 8));
|
||||
expect(buf.selection.start, 6);
|
||||
reg.setContent(buf.id, 'XY'); // shorter than the selection's offsets
|
||||
expect(buf.content, 'XY');
|
||||
expect(buf.selection.start, 2);
|
||||
expect(buf.selection.end, 2);
|
||||
});
|
||||
|
||||
test('setContent on a missing id is a silent no-op', () {
|
||||
sink.events.clear();
|
||||
reg.setContent('no-such-id', 'whatever');
|
||||
expect(sink.events, isEmpty);
|
||||
});
|
||||
|
||||
test('contentFromArgs decodes content_b64 when text is absent', () {
|
||||
expect(EditorRegistry.contentFromArgs({'text': 'plain'}), 'plain');
|
||||
expect(
|
||||
EditorRegistry.contentFromArgs({'content_b64': 'aGVsbG8='}),
|
||||
'hello',
|
||||
);
|
||||
expect(EditorRegistry.contentFromArgs(const {}), '');
|
||||
});
|
||||
|
||||
test('Selection hashCode + toString round-trip and serialise', () {
|
||||
const s = Selection(start: 3, end: 7);
|
||||
expect(s.hashCode, const Selection(start: 3, end: 7).hashCode);
|
||||
expect(s.hashCode, isNot(equals(const Selection(start: 3, end: 8).hashCode)));
|
||||
expect(s.toString(), 'Selection(3-7)');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,5 +76,23 @@ void main() {
|
||||
expect(s.isIgnored('docs/a.draft.md', isDirectory: false), isTrue);
|
||||
expect(s.isIgnored('other/a.draft.md', isDirectory: false), isFalse);
|
||||
});
|
||||
|
||||
test('trailing /** matches every descendant', () {
|
||||
final s = IgnoreSet.parse(['build/**']);
|
||||
expect(s.isIgnored('build/a/b/c.txt', isDirectory: false), isTrue);
|
||||
expect(s.isIgnored('src/a.txt', isDirectory: false), isFalse);
|
||||
});
|
||||
|
||||
test('bare ** (not adjacent to /) matches across path separators', () {
|
||||
final s = IgnoreSet.parse(['a**z']);
|
||||
expect(s.isIgnored('axyz', isDirectory: false), isTrue);
|
||||
expect(s.isIgnored('amiddlez', isDirectory: false), isTrue);
|
||||
});
|
||||
|
||||
test('? matches exactly one non-slash character', () {
|
||||
final s = IgnoreSet.parse(['a?c']);
|
||||
expect(s.isIgnored('abc', isDirectory: false), isTrue);
|
||||
expect(s.isIgnored('a/c', isDirectory: false), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,5 +58,70 @@ void main() {
|
||||
if (twin.existsSync()) twin.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('PathOutsideRoot.toString embeds requested + resolved + root', () {
|
||||
final e = PathOutsideRoot('r', '/abs', '/root');
|
||||
expect(e.toString(), allOf(contains('r'), contains('/abs'), contains('/root')));
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveUnderRootFollowingSymlinks (T-102)', () {
|
||||
test('plain non-symlink file passes through with the resolved real path', () {
|
||||
final f = File('${root.path}/plain.txt')..writeAsStringSync('hello');
|
||||
final out = resolveUnderRootFollowingSymlinks(root, 'plain.txt');
|
||||
// Real-path may differ from root.path on hosts where systemTemp
|
||||
// is itself a symlink (macOS /tmp -> /private/tmp). Compare via
|
||||
// resolveSymbolicLinksSync on both sides.
|
||||
expect(out, f.resolveSymbolicLinksSync());
|
||||
});
|
||||
|
||||
test('non-existent target returns the path-layer result (caller surfaces not-found)', () {
|
||||
final out = resolveUnderRootFollowingSymlinks(root, 'never-existed.txt');
|
||||
expect(out, endsWith('/never-existed.txt'));
|
||||
});
|
||||
|
||||
test('rejects a symlink under the workspace whose target lives outside', () async {
|
||||
// Create an outside file the symlink will point at.
|
||||
final outside = await Directory.systemTemp.createTemp('clide_t102_outside_');
|
||||
addTearDown(() async {
|
||||
if (await outside.exists()) await outside.delete(recursive: true);
|
||||
});
|
||||
final secret = File('${outside.path}/secret.txt')..writeAsStringSync('payload');
|
||||
|
||||
// Plant a symlink inside the workspace that targets the outside file.
|
||||
final link = Link('${root.path}/leak')..createSync(secret.path);
|
||||
expect(link.existsSync(), isTrue);
|
||||
|
||||
expect(
|
||||
() => resolveUnderRootFollowingSymlinks(root, 'leak'),
|
||||
throwsA(isA<PathOutsideRoot>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('tolerates symlinks in the workspace root path itself', () {
|
||||
// Where systemTemp is itself a symlink (macOS), the realPath of a
|
||||
// file under root won't startWith root.absolute.path — but
|
||||
// resolveUnderRootFollowingSymlinks resolves the root too, so
|
||||
// the containment check still passes.
|
||||
File('${root.path}/under-root.txt').writeAsStringSync('ok');
|
||||
// No throw is the assertion.
|
||||
resolveUnderRootFollowingSymlinks(root, 'under-root.txt');
|
||||
});
|
||||
|
||||
test('rejects a symlink-to-symlink chain whose final target is outside', () async {
|
||||
final outside = await Directory.systemTemp.createTemp('clide_t102_chain_');
|
||||
addTearDown(() async {
|
||||
if (await outside.exists()) await outside.delete(recursive: true);
|
||||
});
|
||||
final secret = File('${outside.path}/secret.txt')..writeAsStringSync('payload');
|
||||
// a -> b (under root) -> /outside/secret.txt
|
||||
Link('${root.path}/b').createSync(secret.path);
|
||||
Link('${root.path}/a').createSync('${root.path}/b');
|
||||
|
||||
expect(
|
||||
() => resolveUnderRootFollowingSymlinks(root, 'a'),
|
||||
throwsA(isA<PathOutsideRoot>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/// Tests for `lib/src/files/watcher.dart`. Drives a real
|
||||
/// Directory.watch against a tempdir.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/src/files/ignore.dart';
|
||||
import 'package:clide/src/files/watcher.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('FileChangeKind', () {
|
||||
test('fromEvent maps every FileSystemEvent.type to a wire kind', () {
|
||||
final dir = Directory.systemTemp;
|
||||
FileSystemEvent ev(int type) => switch (type) {
|
||||
FileSystemEvent.create => FileSystemCreateEvent('${dir.path}/f', false),
|
||||
FileSystemEvent.delete => FileSystemDeleteEvent('${dir.path}/f', false),
|
||||
FileSystemEvent.modify => FileSystemModifyEvent('${dir.path}/f', false, false),
|
||||
FileSystemEvent.move => FileSystemMoveEvent('${dir.path}/f', false, '${dir.path}/g'),
|
||||
_ => FileSystemModifyEvent('${dir.path}/f', false, false),
|
||||
};
|
||||
expect(FileChangeKind.fromEvent(ev(FileSystemEvent.create)), FileChangeKind.created);
|
||||
expect(FileChangeKind.fromEvent(ev(FileSystemEvent.delete)), FileChangeKind.deleted);
|
||||
expect(FileChangeKind.fromEvent(ev(FileSystemEvent.modify)), FileChangeKind.modified);
|
||||
expect(FileChangeKind.fromEvent(ev(FileSystemEvent.move)), FileChangeKind.renamed);
|
||||
});
|
||||
|
||||
test('wire getter is the enum name', () {
|
||||
expect(FileChangeKind.created.wire, 'created');
|
||||
expect(FileChangeKind.deleted.wire, 'deleted');
|
||||
expect(FileChangeKind.modified.wire, 'modified');
|
||||
expect(FileChangeKind.renamed.wire, 'renamed');
|
||||
});
|
||||
});
|
||||
|
||||
group('FileChange', () {
|
||||
test('toJson encodes kind / path / isDirectory', () {
|
||||
const c = FileChange(kind: FileChangeKind.modified, path: 'a/b.txt', isDirectory: false);
|
||||
final j = c.toJson();
|
||||
expect(j['kind'], 'modified');
|
||||
expect(j['path'], 'a/b.txt');
|
||||
expect(j['isDirectory'], isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('FileWatcher', () {
|
||||
late Directory sandbox;
|
||||
late FileWatcher watcher;
|
||||
|
||||
setUp(() async {
|
||||
sandbox = await Directory.systemTemp.createTemp('clide-watcher-');
|
||||
watcher = FileWatcher(root: sandbox, ignore: IgnoreSet.builtin());
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await watcher.stop();
|
||||
if (sandbox.existsSync()) sandbox.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('emits a created event when a file is added under root', () async {
|
||||
await watcher.start();
|
||||
// Wait for the specific change rather than sleeping a fixed
|
||||
// amount. firstWhere completes on the first matching event;
|
||||
// the timeout fails the test with a clear message if inotify
|
||||
// never delivers (instead of asserting on an empty list).
|
||||
final saw = watcher.stream.firstWhere(
|
||||
(c) => c.path == 'new.txt',
|
||||
orElse: () => throw StateError('stream closed before new.txt arrived'),
|
||||
);
|
||||
await File('${sandbox.path}/new.txt').writeAsString('hi');
|
||||
final change = await saw.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => fail('no `new.txt` event within 5s'),
|
||||
);
|
||||
expect(change.path, 'new.txt');
|
||||
});
|
||||
|
||||
test('filters ignored paths', () async {
|
||||
await watcher.start();
|
||||
final received = <FileChange>[];
|
||||
final sub = watcher.stream.listen(received.add);
|
||||
addTearDown(sub.cancel);
|
||||
|
||||
// Two-phase: a pre-marker proves inotify is delivering at all
|
||||
// (warm-up), then create the ignored entry sandwiched between
|
||||
// an actionable post-marker. When the post-marker arrives we
|
||||
// know inotify has caught up to operations performed earlier
|
||||
// in the same tick. Failing loudly with `fail()` beats the old
|
||||
// fixed `Future.delayed(200)` that pretended a quiet stream was
|
||||
// proof of filtering.
|
||||
await File('${sandbox.path}/pre.txt').writeAsString('p');
|
||||
await _expectReceived(received, (c) => c.path == 'pre.txt');
|
||||
|
||||
Directory('${sandbox.path}/.dart_tool').createSync();
|
||||
await File('${sandbox.path}/.dart_tool/hidden').writeAsString('x');
|
||||
await File('${sandbox.path}/post.txt').writeAsString('q');
|
||||
await _expectReceived(received, (c) => c.path == 'post.txt');
|
||||
|
||||
expect(received.any((c) => c.path.startsWith('.dart_tool')), isFalse);
|
||||
});
|
||||
|
||||
test('second start() is a no-op (idempotent)', () async {
|
||||
await watcher.start();
|
||||
await watcher.start();
|
||||
});
|
||||
|
||||
test('stop() cancels the subscription and closes the stream', () async {
|
||||
await watcher.start();
|
||||
await watcher.stop();
|
||||
// A second stop is also safe.
|
||||
await watcher.stop();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Wait until [received] satisfies [predicate]. Polls the list (it
|
||||
/// gets mutated by the listener subscription) every 25 ms with a
|
||||
/// generous 8 s ceiling; fails loudly on miss instead of silently
|
||||
/// continuing as the old fixed-sleep tests did.
|
||||
Future<void> _expectReceived<T>(List<T> received, bool Function(T) predicate) async {
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 8));
|
||||
while (!received.any(predicate)) {
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
fail('expected event never arrived within 8s; received=${received.length} entries');
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 25));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/// Integration tests for `lib/src/git/client.dart` — drives a real
|
||||
/// git binary against a temp sandbox, covering the GitClient public
|
||||
/// API + the parseLog parser.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/kernel/src/toolchain_paths.dart';
|
||||
import 'package:clide/src/git/client.dart';
|
||||
import 'package:clide/src/git/operations.dart' show GitException;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
ToolchainView _toolchain() => ToolchainView.resolved(resolveToolchainPaths());
|
||||
|
||||
Future<Directory> _newRepo({String filename = 'file.txt', String contents = 'hello\n'}) async {
|
||||
final dir = await Directory.systemTemp.createTemp('clide-git-client-');
|
||||
await Process.run('git', ['init', '-b', 'main'], workingDirectory: dir.path);
|
||||
await Process.run('git', ['config', 'user.email', 'test@test.com'], workingDirectory: dir.path);
|
||||
await Process.run('git', ['config', 'user.name', 'Test'], workingDirectory: dir.path);
|
||||
await File('${dir.path}/$filename').writeAsString(contents);
|
||||
await Process.run('git', ['add', '.'], workingDirectory: dir.path);
|
||||
await Process.run('git', ['commit', '-m', 'init'], workingDirectory: dir.path);
|
||||
return dir;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('GitClient — queries', () {
|
||||
late Directory sandbox;
|
||||
late GitClient git;
|
||||
|
||||
setUp(() async {
|
||||
sandbox = await _newRepo();
|
||||
git = GitClient(toolchain: _toolchain(), workDir: sandbox);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (sandbox.existsSync()) sandbox.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('status returns branch + entries on a clean repo', () async {
|
||||
final s = await git.status();
|
||||
expect(s.branch, 'main');
|
||||
expect(s.entries, isEmpty);
|
||||
});
|
||||
|
||||
test('status with an upstream parses ahead/behind counts', () async {
|
||||
// Set up a bare repo as a remote and track it.
|
||||
final remote = await Directory.systemTemp.createTemp('clide-git-remote-');
|
||||
addTearDown(() => remote.deleteSync(recursive: true));
|
||||
await Process.run('git', ['init', '--bare'], workingDirectory: remote.path);
|
||||
await Process.run('git', ['remote', 'add', 'origin', remote.path], workingDirectory: sandbox.path);
|
||||
await Process.run('git', ['push', '-u', 'origin', 'main'], workingDirectory: sandbox.path);
|
||||
// Add an uncommitted divergence to make ahead/behind interesting.
|
||||
await File('${sandbox.path}/extra.txt').writeAsString('x');
|
||||
await Process.run('git', ['add', '.'], workingDirectory: sandbox.path);
|
||||
await Process.run('git', ['commit', '-m', 'second'], workingDirectory: sandbox.path);
|
||||
final s = await git.status();
|
||||
expect(s.branch, 'main');
|
||||
expect(s.upstream, contains('origin/main'));
|
||||
expect(s.ahead, 1);
|
||||
expect(s.behind, 0);
|
||||
});
|
||||
|
||||
test('diff with explicit paths narrows the diff to those files', () async {
|
||||
await File('${sandbox.path}/file.txt').writeAsString('hello\nworld\n');
|
||||
await File('${sandbox.path}/other.txt').writeAsString('other');
|
||||
final scoped = await git.diff(paths: ['file.txt']);
|
||||
expect(scoped, hasLength(1));
|
||||
expect(scoped.first.path, 'file.txt');
|
||||
});
|
||||
|
||||
test('log returns commit entries', () async {
|
||||
final entries = await git.log(count: 5);
|
||||
expect(entries, isNotEmpty);
|
||||
expect(entries.first.subject, 'init');
|
||||
});
|
||||
|
||||
test('currentBranch returns the current branch name', () async {
|
||||
expect(await git.currentBranch(), 'main');
|
||||
});
|
||||
|
||||
test('branches lists local branches and marks the current one', () async {
|
||||
await Process.run('git', ['branch', 'feature/a'], workingDirectory: sandbox.path);
|
||||
final all = await git.branches();
|
||||
final names = all.map((b) => b.name).toList();
|
||||
expect(names, containsAll(['main', 'feature/a']));
|
||||
final main = all.firstWhere((b) => b.name == 'main');
|
||||
expect(main.current, isTrue);
|
||||
});
|
||||
|
||||
test('repoRoot returns the repo path for an in-repo dir', () async {
|
||||
final root = await git.repoRoot(sandbox.path);
|
||||
expect(root, isNotNull);
|
||||
// Resolve symlinks both ways: realpath may differ from sandbox.path.
|
||||
expect(File(root!).existsSync() || Directory(root).existsSync(), isTrue);
|
||||
});
|
||||
|
||||
test('repoRoot returns null for a non-git directory', () async {
|
||||
final notGit = await Directory.systemTemp.createTemp('clide-not-git-');
|
||||
addTearDown(() => notGit.deleteSync(recursive: true));
|
||||
expect(await git.repoRoot(notGit.path), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('GitClient — mutations', () {
|
||||
late Directory sandbox;
|
||||
late GitClient git;
|
||||
|
||||
setUp(() async {
|
||||
sandbox = await _newRepo();
|
||||
git = GitClient(toolchain: _toolchain(), workDir: sandbox);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (sandbox.existsSync()) sandbox.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('unstage with explicit paths resets only those', () async {
|
||||
await File('${sandbox.path}/new.txt').writeAsString('x');
|
||||
await git.stage(['new.txt']);
|
||||
await git.unstage(['new.txt']);
|
||||
final r = await Process.run('git', ['diff', '--cached', '--name-only'], workingDirectory: sandbox.path);
|
||||
expect((r.stdout as String).trim(), isEmpty);
|
||||
});
|
||||
|
||||
test('commit creates a commit and returns the HEAD hash', () async {
|
||||
await File('${sandbox.path}/c.txt').writeAsString('commit me');
|
||||
await git.stage(['c.txt']);
|
||||
final hash = await git.commit('add c');
|
||||
expect(hash, hasLength(40));
|
||||
});
|
||||
|
||||
test('discard restores tracked files to HEAD', () async {
|
||||
await File('${sandbox.path}/file.txt').writeAsString('mutated');
|
||||
await git.discard(['file.txt']);
|
||||
final contents = await File('${sandbox.path}/file.txt').readAsString();
|
||||
expect(contents, 'hello\n');
|
||||
});
|
||||
|
||||
test('discard with an empty list is a no-op', () async {
|
||||
await git.discard(const []);
|
||||
});
|
||||
|
||||
test('stash + stashPop save and restore working-tree changes', () async {
|
||||
await File('${sandbox.path}/file.txt').writeAsString('mutated\n');
|
||||
await git.stash(message: 'wip', includeUntracked: true);
|
||||
// After stash, the working tree is restored to HEAD.
|
||||
expect(await File('${sandbox.path}/file.txt').readAsString(), 'hello\n');
|
||||
await git.stashPop();
|
||||
expect(await File('${sandbox.path}/file.txt').readAsString(), 'mutated\n');
|
||||
});
|
||||
|
||||
test('checkout switches to an existing branch', () async {
|
||||
await Process.run('git', ['branch', 'feature/x'], workingDirectory: sandbox.path);
|
||||
await git.checkout('feature/x');
|
||||
expect(await git.currentBranch(), 'feature/x');
|
||||
});
|
||||
|
||||
test('stageHunk applies a patch to the index; unstageHunk reverses it', () async {
|
||||
// Modify file.txt and produce a patch for the change.
|
||||
await File('${sandbox.path}/file.txt').writeAsString('hello\nworld\n');
|
||||
final patchProc = await Process.run('git', ['diff', '-U0'], workingDirectory: sandbox.path);
|
||||
final patch = patchProc.stdout as String;
|
||||
await git.stageHunk(patch);
|
||||
// After stageHunk, the change is in the index.
|
||||
final cached = await Process.run('git', ['diff', '--cached', '--name-only'], workingDirectory: sandbox.path);
|
||||
expect((cached.stdout as String).trim(), 'file.txt');
|
||||
await git.unstageHunk(patch);
|
||||
// After unstageHunk, the index is clean again.
|
||||
final cleared = await Process.run('git', ['diff', '--cached', '--name-only'], workingDirectory: sandbox.path);
|
||||
expect((cleared.stdout as String).trim(), isEmpty);
|
||||
});
|
||||
|
||||
test('mutations throw GitException on non-zero exit', () async {
|
||||
// commit with nothing staged → non-zero exit.
|
||||
try {
|
||||
await git.commit('nothing');
|
||||
fail('expected GitException');
|
||||
} on GitException catch (_) {}
|
||||
});
|
||||
|
||||
test('_applyPatch surfaces stderr in the GitException on bad patch', () async {
|
||||
try {
|
||||
await git.stageHunk('not a valid patch\n');
|
||||
fail('expected GitException');
|
||||
} on GitException catch (e) {
|
||||
expect(e.toString(), contains('apply'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('GitClient — error surface', () {
|
||||
test('a bad git binary path makes _run throw GitException', () async {
|
||||
final t = ToolchainView.resolved(const ResolvedPaths(git: '/tmp/clide-no-such-git-binary'));
|
||||
final dir = await Directory.systemTemp.createTemp('clide-git-bad-');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final git = GitClient(toolchain: t, workDir: dir);
|
||||
try {
|
||||
await git.commit('whatever');
|
||||
fail('expected GitException');
|
||||
} on GitException catch (e) {
|
||||
expect(e.toString(), contains('git'));
|
||||
}
|
||||
});
|
||||
|
||||
test('queries return empty fallbacks when git exits non-zero', () async {
|
||||
// workDir is a temp dir that's NOT a git repo — every command exits
|
||||
// non-zero. Each query method returns its empty fallback.
|
||||
final dir = await Directory.systemTemp.createTemp('clide-not-git-');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final git = GitClient(toolchain: _toolchain(), workDir: dir);
|
||||
expect(await git.diff(), isEmpty);
|
||||
expect(await git.log(), isEmpty);
|
||||
expect(await git.currentBranch(), isNull);
|
||||
expect(await git.branches(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseLog — pure parser', () {
|
||||
test('empty input returns an empty list', () {
|
||||
expect(parseLog(''), isEmpty);
|
||||
expect(parseLog(' \n'), isEmpty);
|
||||
});
|
||||
|
||||
test('skips records with fewer than 5 fields', () {
|
||||
// Only 3 fields between record separators.
|
||||
expect(parseLog('abc\x00def\x00ghi\x01'), isEmpty);
|
||||
});
|
||||
|
||||
test('parses a full record with a body', () {
|
||||
const record = 'fullhash\x00short\x00subject line\x00Author\x002026-05-13\x00body text\x01';
|
||||
final r = parseLog(record);
|
||||
expect(r, hasLength(1));
|
||||
expect(r.first.hash, 'fullhash');
|
||||
expect(r.first.subject, 'subject line');
|
||||
expect(r.first.body, 'body text');
|
||||
});
|
||||
|
||||
test('handles records without a body (5 fields exactly)', () {
|
||||
const record = 'fullhash\x00short\x00subject\x00Author\x002026-05-13\x01';
|
||||
final r = parseLog(record);
|
||||
expect(r, hasLength(1));
|
||||
expect(r.first.body, '');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -207,5 +207,96 @@ index abc..def 100644
|
||||
final diffs = await gitDiff(sandbox);
|
||||
expect(diffs, isEmpty);
|
||||
});
|
||||
|
||||
test('paths argument narrows the diff to that file', () async {
|
||||
await File('${sandbox.path}/file.txt').writeAsString('hello\nworld\n');
|
||||
await File('${sandbox.path}/other.txt').writeAsString('o\n');
|
||||
final scoped = await gitDiff(sandbox, paths: ['file.txt']);
|
||||
expect(scoped, hasLength(1));
|
||||
expect(scoped.first.path, 'file.txt');
|
||||
});
|
||||
|
||||
test('returns empty on a non-git directory (exit != 0)', () async {
|
||||
final notGit = await Directory.systemTemp.createTemp('clide-diff-not-');
|
||||
addTearDown(() => notGit.deleteSync(recursive: true));
|
||||
expect(await gitDiff(notGit), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('GitHunk shape', () {
|
||||
test('toPatch round-trips header + every line kind', () {
|
||||
const hunk = GitHunk(
|
||||
header: '@@ -1,3 +1,4 @@ ctx',
|
||||
oldStart: 1,
|
||||
oldCount: 3,
|
||||
newStart: 1,
|
||||
newCount: 4,
|
||||
lines: [
|
||||
DiffLine(kind: DiffLineKind.context, text: 'line1', oldLineNo: 1, newLineNo: 1),
|
||||
DiffLine(kind: DiffLineKind.removal, text: 'line2', oldLineNo: 2),
|
||||
DiffLine(kind: DiffLineKind.addition, text: 'line2-new', newLineNo: 2),
|
||||
DiffLine(kind: DiffLineKind.header, text: r'\ No newline at end of file'),
|
||||
],
|
||||
);
|
||||
final patch = hunk.toPatch();
|
||||
expect(patch, contains('@@ -1,3 +1,4 @@ ctx'));
|
||||
expect(patch, contains(' line1'));
|
||||
expect(patch, contains('-line2'));
|
||||
expect(patch, contains('+line2-new'));
|
||||
expect(patch, contains(r'\ No newline at end of file'));
|
||||
});
|
||||
|
||||
test('GitDiff.toJson serialises with optional oldPath only when set', () {
|
||||
const withOld = GitDiff(path: 'b.txt', oldPath: 'a.txt', hunks: [], isRenamed: true);
|
||||
expect(withOld.toJson().containsKey('oldPath'), isTrue);
|
||||
const noOld = GitDiff(path: 'b.txt', hunks: []);
|
||||
expect(noOld.toJson().containsKey('oldPath'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseDiffOutput — edge cases', () {
|
||||
test('skips lines outside any diff --git block', () {
|
||||
const output = '''pre-diff garbage
|
||||
another non-diff line
|
||||
diff --git a/f.txt b/f.txt
|
||||
--- a/f.txt
|
||||
+++ b/f.txt
|
||||
@@ -1 +1 @@
|
||||
-old
|
||||
+new
|
||||
''';
|
||||
final diffs = parseDiffOutput(output);
|
||||
expect(diffs, hasLength(1));
|
||||
});
|
||||
|
||||
test('captures the "\\ No newline at end of file" marker as a header line', () {
|
||||
const output = '''diff --git a/f.txt b/f.txt
|
||||
--- a/f.txt
|
||||
+++ b/f.txt
|
||||
@@ -1,1 +1,1 @@
|
||||
-old
|
||||
+new
|
||||
\\ No newline at end of file
|
||||
''';
|
||||
final diffs = parseDiffOutput(output);
|
||||
expect(diffs, hasLength(1));
|
||||
final hunk = diffs.first.hunks.single;
|
||||
expect(hunk.lines.any((l) => l.kind == DiffLineKind.header), isTrue);
|
||||
});
|
||||
|
||||
test('skips a malformed @@ header (parseHunk returns null)', () {
|
||||
const output = '''diff --git a/f.txt b/f.txt
|
||||
--- a/f.txt
|
||||
+++ b/f.txt
|
||||
@@ not a valid hunk header @@
|
||||
@@ -1 +1 @@
|
||||
-old
|
||||
+new
|
||||
''';
|
||||
final diffs = parseDiffOutput(output);
|
||||
expect(diffs, hasLength(1));
|
||||
// Only the valid @@ produces a hunk.
|
||||
expect(diffs.first.hunks, hasLength(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,4 +104,204 @@ void main() {
|
||||
final branch = await gitCurrentBranch(sandbox);
|
||||
expect(branch, isNotNull);
|
||||
});
|
||||
|
||||
test('GitException.toString includes the message', () {
|
||||
const e = GitException('boom');
|
||||
expect(e.toString(), contains('boom'));
|
||||
});
|
||||
|
||||
test('GitLogEntry.toJson serialises every field (body omitted when empty)', () {
|
||||
const a = GitLogEntry(
|
||||
hash: 'h',
|
||||
shortHash: 's',
|
||||
subject: 'sub',
|
||||
author: 'a',
|
||||
date: 'd',
|
||||
);
|
||||
expect(a.toJson().containsKey('body'), isFalse);
|
||||
const b = GitLogEntry(
|
||||
hash: 'h',
|
||||
shortHash: 's',
|
||||
subject: 'sub',
|
||||
author: 'a',
|
||||
date: 'd',
|
||||
body: 'bd',
|
||||
);
|
||||
expect(b.toJson()['body'], 'bd');
|
||||
});
|
||||
|
||||
test('gitStage with a bogus path throws GitException', () async {
|
||||
try {
|
||||
await gitStage(sandbox, ['no-such-file-here']);
|
||||
fail('expected GitException');
|
||||
} on GitException catch (_) {}
|
||||
});
|
||||
|
||||
test('gitUnstage with no paths unstages everything', () async {
|
||||
await File('${sandbox.path}/a.txt').writeAsString('x');
|
||||
await File('${sandbox.path}/b.txt').writeAsString('y');
|
||||
await gitStage(sandbox, ['a.txt', 'b.txt']);
|
||||
await gitUnstage(sandbox, const []);
|
||||
final r = await Process.run(
|
||||
'git',
|
||||
['diff', '--cached', '--name-only'],
|
||||
workingDirectory: sandbox.path,
|
||||
);
|
||||
expect((r.stdout as String).trim(), isEmpty);
|
||||
});
|
||||
|
||||
test('gitStageHunk + gitUnstageHunk apply a patch via _applyPatch', () async {
|
||||
await File('${sandbox.path}/file.txt').writeAsString('hello\nworld\n');
|
||||
final patchResult = await Process.run(
|
||||
'git',
|
||||
['diff', '-U0'],
|
||||
workingDirectory: sandbox.path,
|
||||
);
|
||||
final patch = patchResult.stdout as String;
|
||||
await gitStageHunk(sandbox, patch);
|
||||
final cached = await Process.run(
|
||||
'git',
|
||||
['diff', '--cached', '--name-only'],
|
||||
workingDirectory: sandbox.path,
|
||||
);
|
||||
expect((cached.stdout as String).trim(), 'file.txt');
|
||||
await gitUnstageHunk(sandbox, patch);
|
||||
final cleared = await Process.run(
|
||||
'git',
|
||||
['diff', '--cached', '--name-only'],
|
||||
workingDirectory: sandbox.path,
|
||||
);
|
||||
expect((cleared.stdout as String).trim(), isEmpty);
|
||||
});
|
||||
|
||||
test('_applyPatch surfaces stderr in the GitException on a bad patch', () async {
|
||||
try {
|
||||
await gitStageHunk(sandbox, 'not a valid patch\n');
|
||||
fail('expected GitException');
|
||||
} on GitException catch (e) {
|
||||
expect(e.stderr, isNotEmpty);
|
||||
}
|
||||
});
|
||||
|
||||
test('gitBranches lists branches and marks the current one', () async {
|
||||
await Process.run('git', ['branch', 'feature/a'], workingDirectory: sandbox.path);
|
||||
final branches = await gitBranches(sandbox);
|
||||
final names = branches.map((b) => b.name).toList();
|
||||
expect(names, containsAll(['feature/a']));
|
||||
expect(branches.any((b) => b.current), isTrue);
|
||||
});
|
||||
|
||||
test('gitBranches returns empty on a non-git directory', () async {
|
||||
final notGit = await Directory.systemTemp.createTemp('clide-git-not-');
|
||||
addTearDown(() => notGit.deleteSync(recursive: true));
|
||||
expect(await gitBranches(notGit), isEmpty);
|
||||
});
|
||||
|
||||
test('gitCheckout switches branches; an unknown branch throws', () async {
|
||||
await Process.run('git', ['branch', 'next'], workingDirectory: sandbox.path);
|
||||
await gitCheckout(sandbox, 'next');
|
||||
expect(await gitCurrentBranch(sandbox), 'next');
|
||||
try {
|
||||
await gitCheckout(sandbox, 'does-not-exist');
|
||||
fail('expected GitException');
|
||||
} on GitException catch (_) {}
|
||||
});
|
||||
|
||||
test('gitPull + gitPush round-trip against a local bare remote', () async {
|
||||
final remote = await Directory.systemTemp.createTemp('clide-git-remote-');
|
||||
addTearDown(() => remote.deleteSync(recursive: true));
|
||||
await Process.run('git', ['init', '--bare'], workingDirectory: remote.path);
|
||||
await Process.run('git', ['remote', 'add', 'origin', remote.path], workingDirectory: sandbox.path);
|
||||
final pushOut = await gitPush(sandbox, remote: 'origin', branch: 'main', setUpstream: true);
|
||||
expect(pushOut, isNotEmpty);
|
||||
// Clone elsewhere and pull on the original. Cheaper: just call gitPull
|
||||
// and confirm it doesn't throw (already up-to-date).
|
||||
final pullOut = await gitPull(sandbox);
|
||||
expect(pullOut, isA<String>());
|
||||
});
|
||||
|
||||
test('gitPush against no remote throws GitException', () async {
|
||||
try {
|
||||
await gitPush(sandbox);
|
||||
fail('expected GitException');
|
||||
} on GitException catch (_) {}
|
||||
});
|
||||
|
||||
test('gitPush rejects a -prefixed remote (argv-injection guard)', () async {
|
||||
try {
|
||||
await gitPush(sandbox, remote: '--upload-pack=evil', branch: 'main');
|
||||
fail('expected GitException');
|
||||
} on GitException catch (e) {
|
||||
expect(e.message, contains('remote'));
|
||||
}
|
||||
});
|
||||
|
||||
test('gitPush rejects a -prefixed branch', () async {
|
||||
try {
|
||||
await gitPush(sandbox, remote: 'origin', branch: '--exec=evil');
|
||||
fail('expected GitException');
|
||||
} on GitException catch (e) {
|
||||
expect(e.message, contains('branch'));
|
||||
}
|
||||
});
|
||||
|
||||
test('gitCheckout rejects a -prefixed branch', () async {
|
||||
try {
|
||||
await gitCheckout(sandbox, '--upload-pack=evil');
|
||||
fail('expected GitException');
|
||||
} on GitException catch (e) {
|
||||
expect(e.message, contains('branch'));
|
||||
}
|
||||
});
|
||||
|
||||
test('gitCheckout rejects an empty branch', () async {
|
||||
try {
|
||||
await gitCheckout(sandbox, '');
|
||||
fail('expected GitException');
|
||||
} on GitException catch (e) {
|
||||
expect(e.message, contains('branch'));
|
||||
}
|
||||
});
|
||||
|
||||
test('validateGitRef accepts plain refs', () {
|
||||
expect(() => validateGitRef('main', kind: 'branch'), returnsNormally);
|
||||
expect(() => validateGitRef('feature/foo', kind: 'branch'), returnsNormally);
|
||||
expect(() => validateGitRef('origin', kind: 'remote'), returnsNormally);
|
||||
});
|
||||
|
||||
test('gitPull against no remote throws GitException', () async {
|
||||
try {
|
||||
await gitPull(sandbox);
|
||||
fail('expected GitException');
|
||||
} on GitException catch (_) {}
|
||||
});
|
||||
|
||||
test('gitLog returns empty on a non-git directory', () async {
|
||||
final notGit = await Directory.systemTemp.createTemp('clide-git-log-');
|
||||
addTearDown(() => notGit.deleteSync(recursive: true));
|
||||
expect(await gitLog(notGit), isEmpty);
|
||||
});
|
||||
|
||||
test('gitCurrentBranch returns null on a non-git directory', () async {
|
||||
final notGit = await Directory.systemTemp.createTemp('clide-git-cb-');
|
||||
addTearDown(() => notGit.deleteSync(recursive: true));
|
||||
expect(await gitCurrentBranch(notGit), isNull);
|
||||
});
|
||||
|
||||
test('gitStashPop on an empty stash throws GitException', () async {
|
||||
try {
|
||||
await gitStashPop(sandbox);
|
||||
fail('expected GitException');
|
||||
} on GitException catch (_) {}
|
||||
});
|
||||
|
||||
test('gitDiscard with an empty list returns without invoking git', () async {
|
||||
// Empty list short-circuits before the subprocess call; just verify
|
||||
// it doesn't throw.
|
||||
await gitDiscard(sandbox, const []);
|
||||
});
|
||||
|
||||
test('gitBin resolves to a usable binary path', () {
|
||||
expect(gitBin, isNotEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,4 +92,45 @@ void main() {
|
||||
expect(status.ahead, isZero);
|
||||
expect(status.behind, isZero);
|
||||
});
|
||||
|
||||
test('branch.upstream + branch.ab populate upstream/ahead/behind', () async {
|
||||
final remote = await Directory.systemTemp.createTemp('clide-status-remote-');
|
||||
addTearDown(() => remote.deleteSync(recursive: true));
|
||||
await Process.run('git', ['init', '--bare'], workingDirectory: remote.path);
|
||||
await Process.run('git', ['remote', 'add', 'origin', remote.path], workingDirectory: sandbox.path);
|
||||
await Process.run('git', ['push', '-u', 'origin', 'HEAD'], workingDirectory: sandbox.path);
|
||||
// Add a commit so we have ahead > 0.
|
||||
await File('${sandbox.path}/ahead.txt').writeAsString('x');
|
||||
await Process.run('git', ['add', '.'], workingDirectory: sandbox.path);
|
||||
await Process.run('git', ['commit', '-m', 'ahead'], workingDirectory: sandbox.path);
|
||||
final s = await gitStatus(sandbox);
|
||||
expect(s.upstream, contains('origin/'));
|
||||
expect(s.ahead, 1);
|
||||
expect(s.behind, 0);
|
||||
});
|
||||
|
||||
test('gitStatus on a non-git directory returns an empty branchless status', () async {
|
||||
final notGit = await Directory.systemTemp.createTemp('clide-status-not-');
|
||||
addTearDown(() => notGit.deleteSync(recursive: true));
|
||||
final s = await gitStatus(notGit);
|
||||
expect(s.entries, isEmpty);
|
||||
});
|
||||
|
||||
test('rename in porcelain output captures the original path', () async {
|
||||
await File('${sandbox.path}/a.txt').writeAsString('content\n');
|
||||
await Process.run('git', ['add', '.'], workingDirectory: sandbox.path);
|
||||
await Process.run('git', ['commit', '-m', 'add a'], workingDirectory: sandbox.path);
|
||||
await Process.run('git', ['mv', 'a.txt', 'renamed.txt'], workingDirectory: sandbox.path);
|
||||
final s = await gitStatus(sandbox);
|
||||
final renamed = s.entries.firstWhere((e) => e.path == 'renamed.txt');
|
||||
expect(renamed.origPath, 'a.txt');
|
||||
});
|
||||
|
||||
test('parsePorcelainV1 handles empty input + short / empty parts', () {
|
||||
expect(parsePorcelainV1(''), isEmpty);
|
||||
// 'X' is too short (< 4 chars), should be skipped.
|
||||
expect(parsePorcelainV1('X\x00'), isEmpty);
|
||||
// Empty token-only input — skipped.
|
||||
expect(parsePorcelainV1('\x00'), isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,5 +51,24 @@ void main() {
|
||||
expect(err.message, contains('kernel exploded'));
|
||||
expect(err.message, isNot(contains('errno=999')));
|
||||
});
|
||||
|
||||
test('ENOTDIR → user_error', () {
|
||||
final e = errnoToIpcError(errno: PosixErrno.enotdir, op: 'files.ls', target: '/x');
|
||||
expect(e.kind, IpcErrorKind.userError);
|
||||
expect(e.message, contains('not a directory'));
|
||||
});
|
||||
|
||||
test('ENOMEM → tool_error', () {
|
||||
final e = errnoToIpcError(errno: PosixErrno.enomem, op: 'pty.spawn');
|
||||
expect(e.kind, IpcErrorKind.toolError);
|
||||
expect(e.message, contains('out of memory'));
|
||||
});
|
||||
|
||||
test('EAGAIN → tool_error with retry hint', () {
|
||||
final e = errnoToIpcError(errno: PosixErrno.eagain, op: 'pty.read');
|
||||
expect(e.kind, IpcErrorKind.toolError);
|
||||
expect(e.message, contains('temporarily unavailable'));
|
||||
expect(e.hint, contains('retry'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('Keybinding.parse + equality', () {
|
||||
test('parses single-key bindings', () {
|
||||
final k = Keybinding.parse('g');
|
||||
@@ -20,10 +23,30 @@ void main() {
|
||||
expect(() => Keybinding.parse(''), throwsA(isA<ArgumentError>()));
|
||||
});
|
||||
|
||||
test('rejects spec ending in `+` (missing key)', () {
|
||||
expect(() => Keybinding.parse('ctrl+'), throwsA(isA<ArgumentError>()));
|
||||
});
|
||||
|
||||
test('canonical modifier order is deterministic', () {
|
||||
final k = Keybinding.parse('alt+ctrl+shift+x');
|
||||
expect(k.modifiers, ['alt', 'ctrl', 'shift']);
|
||||
});
|
||||
|
||||
test('canonical of modifier-free binding is just the key', () {
|
||||
expect(Keybinding.parse('escape').canonical, 'escape');
|
||||
});
|
||||
|
||||
test('hashCode matches for equal bindings, differs for distinct', () {
|
||||
final a = Keybinding.parse('ctrl+shift+g');
|
||||
final b = Keybinding.parse('Shift+Ctrl+G');
|
||||
final c = Keybinding.parse('ctrl+g');
|
||||
expect(a.hashCode, b.hashCode);
|
||||
expect(a.hashCode, isNot(c.hashCode));
|
||||
});
|
||||
|
||||
test('toString embeds canonical form', () {
|
||||
expect(Keybinding.parse('ctrl+k').toString(), 'Keybinding(ctrl+k)');
|
||||
});
|
||||
});
|
||||
|
||||
group('KeybindingResolver', () {
|
||||
@@ -41,5 +64,74 @@ void main() {
|
||||
r.unbind(k);
|
||||
expect(r.commandFor(k), isNull);
|
||||
});
|
||||
|
||||
test('entries exposes registered bindings', () {
|
||||
final r = KeybindingResolver();
|
||||
r.bind(Keybinding.parse('ctrl+p'), 'palette.open');
|
||||
r.bind(Keybinding.parse('ctrl+shift+p'), 'palette.commands');
|
||||
final commands = r.entries.map((e) => e.value).toSet();
|
||||
expect(commands, {'palette.open', 'palette.commands'});
|
||||
});
|
||||
});
|
||||
|
||||
group('KeybindingResolver.fromKeyEvent', () {
|
||||
late HardwareKeyboard kb;
|
||||
setUp(() => kb = HardwareKeyboard.instance);
|
||||
tearDown(() => kb.clearState());
|
||||
|
||||
test('returns null for KeyUpEvent', () {
|
||||
final up = KeyUpEvent(
|
||||
physicalKey: PhysicalKeyboardKey.keyG,
|
||||
logicalKey: LogicalKeyboardKey.keyG,
|
||||
timeStamp: Duration.zero,
|
||||
);
|
||||
expect(KeybindingResolver.fromKeyEvent(up, kb), isNull);
|
||||
});
|
||||
|
||||
test('returns null when logicalKey has no keyLabel', () {
|
||||
// A synthetic logical key with an unassigned id has an empty label.
|
||||
final unlabeled = LogicalKeyboardKey(0x1000fffff);
|
||||
final down = KeyDownEvent(
|
||||
physicalKey: PhysicalKeyboardKey.controlLeft,
|
||||
logicalKey: unlabeled,
|
||||
timeStamp: Duration.zero,
|
||||
);
|
||||
expect(KeybindingResolver.fromKeyEvent(down, kb), isNull);
|
||||
});
|
||||
|
||||
test('maps a plain KeyDownEvent to a modifier-free Keybinding', () {
|
||||
final down = KeyDownEvent(
|
||||
physicalKey: PhysicalKeyboardKey.keyG,
|
||||
logicalKey: LogicalKeyboardKey.keyG,
|
||||
timeStamp: Duration.zero,
|
||||
);
|
||||
final b = KeybindingResolver.fromKeyEvent(down, kb);
|
||||
expect(b, isNotNull);
|
||||
expect(b!.key, 'g');
|
||||
expect(b.modifiers, isEmpty);
|
||||
});
|
||||
|
||||
test('includes every held modifier in the resulting Keybinding', () {
|
||||
// Simulate ctrl+shift+alt+meta held, then a keyG down.
|
||||
_holdModifier(PhysicalKeyboardKey.controlLeft, LogicalKeyboardKey.controlLeft);
|
||||
_holdModifier(PhysicalKeyboardKey.shiftLeft, LogicalKeyboardKey.shiftLeft);
|
||||
_holdModifier(PhysicalKeyboardKey.altLeft, LogicalKeyboardKey.altLeft);
|
||||
_holdModifier(PhysicalKeyboardKey.metaLeft, LogicalKeyboardKey.metaLeft);
|
||||
|
||||
final down = KeyDownEvent(
|
||||
physicalKey: PhysicalKeyboardKey.keyG,
|
||||
logicalKey: LogicalKeyboardKey.keyG,
|
||||
timeStamp: Duration.zero,
|
||||
);
|
||||
final b = KeybindingResolver.fromKeyEvent(down, kb)!;
|
||||
expect(b.key, 'g');
|
||||
expect(b.modifiers.toSet(), {'ctrl', 'shift', 'alt', 'cmd'});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _holdModifier(PhysicalKeyboardKey physical, LogicalKeyboardKey logical) {
|
||||
HardwareKeyboard.instance.handleKeyEvent(
|
||||
KeyDownEvent(physicalKey: physical, logicalKey: logical, timeStamp: Duration.zero),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -184,5 +184,155 @@ void main() {
|
||||
await s1.cancel();
|
||||
await s2.cancel();
|
||||
});
|
||||
|
||||
test('TrayItemContribution lands in TrayRegistry; deactivate removes it', () async {
|
||||
f.services.extensions.register(_Ext(
|
||||
id: 'tray-ext',
|
||||
contributions: [
|
||||
const TrayItemContribution(
|
||||
id: 'tray-ext.item',
|
||||
label: 'Item',
|
||||
onSelected: _noop,
|
||||
),
|
||||
],
|
||||
));
|
||||
await f.services.extensions.activateAll();
|
||||
expect(f.services.tray.items.map((i) => i.id), contains('tray-ext.item'));
|
||||
await f.services.extensions.deactivate('tray-ext');
|
||||
expect(f.services.tray.items, isEmpty);
|
||||
});
|
||||
|
||||
test('StatusItem + ToolbarButton contributions activate and deactivate cleanly', () async {
|
||||
f.services.extensions.register(_Ext(
|
||||
id: 'status-and-toolbar',
|
||||
contributions: [
|
||||
StatusItemContribution(
|
||||
id: 'status-and-toolbar.status',
|
||||
priority: 1,
|
||||
build: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
ToolbarButtonContribution(
|
||||
id: 'status-and-toolbar.btn',
|
||||
label: 'B',
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
));
|
||||
await f.services.extensions.activateAll();
|
||||
// Both contributions register through PanelRegistry.contributionsFor.
|
||||
expect(f.services.panels.contributionsFor(Slots.statusbar).whereType<StatusItemContribution>(), hasLength(1));
|
||||
await f.services.extensions.deactivate('status-and-toolbar');
|
||||
expect(f.services.panels.contributionsFor(Slots.statusbar).whereType<StatusItemContribution>(), isEmpty);
|
||||
});
|
||||
|
||||
test('LayoutPresetContribution is accepted (no kernel-side wiring) and survives deactivate', () async {
|
||||
// LayoutPresetContribution is consumed by the default-layout
|
||||
// extension's own activate(); the manager's add/remove just hit
|
||||
// the no-op case branch.
|
||||
f.services.extensions.register(_Ext(
|
||||
id: 'preset-only',
|
||||
contributions: [
|
||||
const LayoutPresetContribution(
|
||||
id: 'preset-only.default',
|
||||
displayName: 'Preset only',
|
||||
slots: [],
|
||||
),
|
||||
],
|
||||
));
|
||||
await f.services.extensions.activateAll();
|
||||
await f.services.extensions.deactivate('preset-only');
|
||||
});
|
||||
|
||||
test('duplicate register() is a silent no-op (warns + skips)', () async {
|
||||
final ext = _Ext(id: 'dup');
|
||||
f.services.extensions.register(ext);
|
||||
f.services.extensions.register(ext); // second call → warn + skip
|
||||
expect(f.services.extensions.all.where((e) => e.id == 'dup'), hasLength(1));
|
||||
});
|
||||
|
||||
test('activate of an unknown id is a silent no-op (warns + returns)', () async {
|
||||
await f.services.extensions.activate('nope.no-such');
|
||||
expect(f.services.extensions.isActivated('nope.no-such'), isFalse);
|
||||
});
|
||||
|
||||
test('activate failure is caught and logged (extension survives)', () async {
|
||||
f.services.extensions.register(_Ext(
|
||||
id: 'throws-on-activate',
|
||||
onActivate: (_) async => throw StateError('kaboom'),
|
||||
));
|
||||
await f.services.extensions.activateAll();
|
||||
expect(f.services.extensions.isActivated('throws-on-activate'), isFalse);
|
||||
});
|
||||
|
||||
test('deactivate failure is caught and logged', () async {
|
||||
f.services.extensions.register(_Ext(
|
||||
id: 'throws-on-deactivate',
|
||||
onDeactivate: () async => throw StateError('kaboom'),
|
||||
));
|
||||
await f.services.extensions.activateAll();
|
||||
expect(f.services.extensions.isActivated('throws-on-deactivate'), isTrue);
|
||||
await f.services.extensions.deactivate('throws-on-deactivate');
|
||||
});
|
||||
|
||||
test('deactivating a CommandContribution removes its keybinding', () async {
|
||||
f.services.extensions.register(_Ext(
|
||||
id: 'with-bound-cmd',
|
||||
contributions: [
|
||||
CommandContribution(
|
||||
id: 'with-bound-cmd.cmd',
|
||||
command: 'bound.cmd',
|
||||
defaultBinding: 'ctrl+alt+j',
|
||||
run: (_) async => IpcResponse.ok(id: '', data: const {}),
|
||||
),
|
||||
],
|
||||
));
|
||||
await f.services.extensions.activateAll();
|
||||
expect(f.services.keybindings.commandFor(Keybinding.parse('ctrl+alt+j')), 'bound.cmd');
|
||||
await f.services.extensions.deactivate('with-bound-cmd');
|
||||
expect(f.services.keybindings.commandFor(Keybinding.parse('ctrl+alt+j')), isNull);
|
||||
});
|
||||
|
||||
test('all getter yields every registered extension', () async {
|
||||
f.services.extensions.register(_Ext(id: 'a-iter'));
|
||||
f.services.extensions.register(_Ext(id: 'b-iter'));
|
||||
final ids = f.services.extensions.all.map((e) => e.id).toSet();
|
||||
expect(ids, containsAll(['a-iter', 'b-iter']));
|
||||
});
|
||||
|
||||
test('extension context exposes every kernel service via passthrough getters', () async {
|
||||
ClideExtensionContext? captured;
|
||||
f.services.extensions.register(_Ext(
|
||||
id: 'ctx-capture',
|
||||
onActivate: (ctx) async {
|
||||
captured = ctx;
|
||||
},
|
||||
));
|
||||
await f.services.extensions.activateAll();
|
||||
final ctx = captured!;
|
||||
expect(ctx.id, 'ctx-capture');
|
||||
expect(ctx.log, same(f.services.log));
|
||||
expect(ctx.events, same(f.services.events));
|
||||
expect(ctx.messages, same(f.services.messages));
|
||||
expect(ctx.settings, same(f.services.settings));
|
||||
expect(ctx.theme, same(f.services.theme));
|
||||
expect(ctx.i18n, same(f.services.i18n));
|
||||
expect(ctx.panels, same(f.services.panels));
|
||||
expect(ctx.arrangement, same(f.services.arrangement));
|
||||
expect(ctx.commands, same(f.services.commands));
|
||||
expect(ctx.palette, same(f.services.palette));
|
||||
expect(ctx.clipboard, same(f.services.clipboard));
|
||||
expect(ctx.files, same(f.services.files));
|
||||
expect(ctx.notify, same(f.services.notify));
|
||||
expect(ctx.dialog, same(f.services.dialog));
|
||||
expect(ctx.tray, same(f.services.tray));
|
||||
expect(ctx.secrets, same(f.services.secrets));
|
||||
expect(ctx.os, same(f.services.os));
|
||||
expect(ctx.net, same(f.services.net));
|
||||
expect(ctx.focus, same(f.services.focus));
|
||||
expect(ctx.project, same(f.services.project));
|
||||
expect(ctx.ipc, same(f.services.ipc));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _noop() {}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user