10 Commits
Author SHA1 Message Date
jpmschweitzerandClaude 980f60f798 release v2.1.0
test / dart doc (lib API) (push) Failing after 1m0s
test / unit + widget + golden + a11y (push) Failing after 39s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Drains the post-2.0 Unreleased backlog: keymap layer (T-117), keyboard
operability + panel-traversal arc (T-100, T-105, T-111, T-114), 95%
coverage gate (D-66), IPC argv-injection + size/count hardening
(T-104), high-contrast theme variants (D-69, T-118), PTY rewrite via
posix_spawn (T-96), CONTRIBUTING.md (T-109), and ~60 smaller items.
Closes T-121.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 10:19:37 +02:00
jpmschweitzerandClaude 0160f5e018 write CONTRIBUTING.md; dedupe Unreleased; add make verify (T-109)
test / unit + widget + golden + a11y (push) Failing after 33s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m2s
Three docs sweeps from the consultant batch:

* CONTRIBUTING.md lands as a human-addressed guide — clone, build,
  test, DQR, tickets, commit conventions. README links to it.
* CHANGELOG `[Unreleased]` consolidated to one subsection per kind
  in Keep a Changelog order (Added, Changed, Removed, Fixed,
  Security). Released sections left frozen.
* `make verify` is the no-tests sweep: analyze + format +
  decisions-validate + changelog-gate. Bridges the gap between
  `make analyze` alone and the full `make push-check`.

Item 3 (cut an interim release) deferred — that's the user's call
on timing.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 10:06:03 +02:00
jpmschweitzerandClaude 31d40ad8ce harden IPC: reject -prefixed git refs, cap files.read / git.log (T-104)
test / unit + widget + golden + a11y (push) Failing after 31s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m0s
Three security fixes the consultant flagged:

* git.checkout, git.push now reject branch/remote arguments starting
  with `-` via a top-level validateGitRef helper. `git push` also
  gets a `--` option terminator; checkout can't use `--` without
  changing semantics (it would be parsed as a pathspec), so the
  validator is the only line of defence there.
* files.read caps responses at 10 MB so a single call can't OOM the
  UI on a multi-gigabyte log.
* git.log caps `count` at 1000; git.diff / git.stage cap paths at
  256. Excess is a userError rather than burning subprocess time.

The bigger typed-schema framework (item 1 in T-104) is split out as
T-120 since it needs design discussion alongside T-99.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 09:53:20 +02:00
jpmschweitzerandClaude 683c90d0af cover vertical-axis + custom-slot branches in drag_resize_test
test / unit + widget + golden + a11y (push) Failing after 38s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m2s
Two more structural assertions to claw back the coverage gap the
keyboard-wiring lines opened up — the vertical-axis shortcut map and
the slot-name fallback label branch were both uncovered.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 09:47:37 +02:00
jpmschweitzerandClaude 9249b1511d extract bumpedSlotSize for direct test coverage (T-111)
Pulled the slot-relative sign flip out of `_DragResizeHandleState._bump`
into a top-level `bumpedSlotSize` helper so the direction logic (the
bug-prone half) gets unit tests without piping through the keyboard
focus machinery. Adds a slot-label assertion for the context-panel
branch.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 09:45:47 +02:00
jpmschweitzerandClaude 5d1237501c panel splitters get keyboard parity and Semantics (T-111)
Drag handles for sidebar / context / editor split were pure
pointer-Listeners — no Tab focus, no arrow-key adjust, no Semantics.
Each now wraps in a FocusableActionDetector with arrow shortcuts (10
px fine / 50 px coarse for the column handles, 2% / 10% for the
editor split) and a slider Semantics node that announces the current
size. The CLI verb half is split out as T-119 and waits on T-99.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 09:43:56 +02:00
jpmschweitzerandClaude f113cb5efd re-flow text_zoom_test.dart per dart format
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m2s
Pre-push hook split the cascaded `..increase()..increase()` chain
onto separate lines; landing the formatter's choice.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 09:05:37 +02:00
jpmschweitzerandClaude 044d1b2ff1 lift text-zoom into kernel, surface it in the palette (T-114)
test / unit + widget + golden + a11y (push) Failing after 30s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m1s
Workspace text-zoom (Ctrl +/-/0) was local state on _RootShellState,
reachable only via the keymap intent path. Lifted to a kernel TextZoom
ChangeNotifier so the new `view.zoomIn/Out/Reset` palette commands
mutate the same number the keymap does — closing T-114's "discoverable
in the palette" item.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 09:04:43 +02:00
jpmschweitzerandClaude 6d4a642773 tokenise window-control colours + palette shadow
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m2s
Hard-coded Windows close-button red (#E81123), white close glyph, and
the palette's 0x40000000 drop shadow were the three colour sites the
UX consultant flagged as not adapting per theme. Now they're
`windowControl.closeHoverBackground` / `closeHoverForeground` /
`shadow.ambient`. Paper themes override the shadow to a softer ink so
it doesn't read as a CRT halo on cream.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 09:00:52 +02:00
jpmschweitzerandClaude 70c1bd3598 drop changelog gate's soft warning, keep 60-word hard cap
The 40-word warning never blocked a push, so eight bullets had drifted
over it. A warning the gate emits and the process ignores just
normalises drift, so it's gone — only the 60-word fail remains.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 08:59:42 +02:00
32 changed files with 1066 additions and 205 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ Entries should be short imperative phrases that describe user-facing impact —
### Be concise — this is the rule, not a suggestion ### Be concise — this is the rule, not a suggestion
CHANGELOG entries must be **one or two short sentences**. Strict ceiling: **40 words per bullet**, hard cap at 60. If you can't say it in one line wrapped at ~75 columns, you're writing the wrong document. 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 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:
@@ -1807,3 +1807,9 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
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-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-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-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;
+66
View File
@@ -1829,3 +1829,69 @@ Scope:
them — adding the files is enough. 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); 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);
+135 -123
View File
@@ -16,25 +16,19 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
## [Unreleased] ## [Unreleased]
### Added
### Changed
### Deprecated
### Removed ### Removed
- **`bin/clide.dart` + `DaemonServer`** — completing the D-56 dissolution. ### Fixed
The separate daemon process was dissolved on 2026-04-23 but the entry
point and socket server class were never actually deleted. Gone now, ### Security
along with orphaned tests (`test/cli/`, `test/daemon/subprocess_test`,
`test/daemon/in_process_test`), stale i18n strings, and "start ## [2.1.0] — 2026-05-18
`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
toolchain resolution, `ToolCheck` gate, backend serialization,
testmode harness, CI scripts, Makefile, and sandbox entitlements.
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 ### Added
@@ -46,8 +40,8 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
which fails if total line coverage drops below `coverage_floor:` in which fails if total line coverage drops below `coverage_floor:` in
`pubspec.yaml`. Floor ratchets up only; target 95% (D-66). `pubspec.yaml`. Floor ratchets up only; target 95% (D-66).
- Pre-push changelog gate — `ci/changelog_gate.sh` fails on any - Pre-push changelog gate — `ci/changelog_gate.sh` fails on any
`## [Unreleased]` bullet over 60 words; warns at 40. Enforces the `## [Unreleased]` bullet over 60 words. Enforces the Keep-a-Changelog
Keep-a-Changelog conciseness rule in the git-commit skill. conciseness rule in the git-commit skill.
- Keymap layer (`KeymapService`) — typed Intents, YAML presets, - Keymap layer (`KeymapService`) — typed Intents, YAML presets,
VS-Code-style when-clauses, layered preset → user file → settings VS-Code-style when-clauses, layered preset → user file → settings
overlay. Default preset ships; vim/vscode/jetbrains unblocked overlay. Default preset ships; vim/vscode/jetbrains unblocked
@@ -74,43 +68,42 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
17% → 96%. Crosses the 95% global target (T-91). 17% → 96%. Crosses the 95% global target (T-91).
- Staged `dart doc` CI job — generates and uploads an HTML API - Staged `dart doc` CI job — generates and uploads an HTML API
reference for the public `lib/` surface. The step wraps reference for the public `lib/` surface. The step wraps
`dart doc --validate-links` and grep-fails the build on any warning, `dart doc --validate-links` and grep-fails the build on any warning.
so broken doc refs and dangling links can't accumulate. Inert with Inert with the rest of the workflow until Gitea Actions activates.
the rest of the workflow until Gitea Actions activates.
- Mouse wheel scrolling in Claude pane — converts scroll events to - Mouse wheel scrolling in Claude pane — converts scroll events to
PgUp/PgDown so Claude Code (and other TUI apps) scroll their PgUp/PgDown so TUI apps scroll their history naturally.
history naturally.
- Welcome screen Tips card — six common keybindings shown below the - Welcome screen Tips card — six common keybindings shown below the
START / RECENT row when the viewport is tall enough. START / RECENT row when the viewport is tall enough.
- `MultitabPane` widget + `MultitabController` for panes that host - `MultitabPane` widget + `MultitabController` for panes that host
N runtime tab instances of the same kind. Generic over a payload N runtime tab instances of the same kind. Generic over a payload
type, supports pinned/non-closeable tabs (primary), drag-reorder, type, supports pinned/non-closeable tabs, drag-reorder, close × on
close × on hover, and an optional `+` add button. Used by the hover, and an optional `+` add button.
Claude pane to render primary + secondaries. - `MultitabPane.keepAlive` mode — entry bodies stay mounted via
- `MultitabPane.keepAlive` mode — when set, all entry bodies stay IndexedStack so switching tabs preserves their state (PTY
mounted via IndexedStack so switching tabs preserves their state connections, scroll position, etc.).
(PTY connections, scroll position, etc.). - `CONTRIBUTING.md` — human-addressed contributor guide covering
clone / build / test / DQR / tickets / commit conventions. The
### Fixed `[Unreleased]` section is reorganised to one subsection per kind
per Keep a Changelog 1.1.0 (T-109).
- `TerminalView.onTapUp` now actually fires on primary tap — was - `make verify` — no-tests sweep (analyze + format + decisions +
wired to a dead code path (T-93). Dead `onTapUp` surface on changelog gate). For mid-edit checks; `make push-check` stays the
`TerminalGestureHandler` / `TerminalGestureDetector` removed. full pre-push pipeline.
- `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 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 —
iteration now snapshots the list first (T-91).
### Changed ### 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 - PTY spawning uses `posix_openpt` + `posix_spawn` instead of
`forkpty` — closes a ~5% deadlock window in the multithreaded Dart `forkpty` — closes a ~5% deadlock window in the multithreaded Dart
VM (T-96, D-5 amended). Missing exe/cwd now throw `PtyException` at VM (T-96, D-5 amended). Missing exe/cwd now throw `PtyException` at
@@ -119,102 +112,121 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
- `TreeSitterService` and `TreeSitterLib` accept injectable FFI + asset - `TreeSitterService` and `TreeSitterLib` accept injectable FFI + asset
loaders for fake-driven tests; production paths unchanged. loaders for fake-driven tests; production paths unchanged.
- Tidied test imports flagged by `unnecessary_import`. - Tidied test imports flagged by `unnecessary_import`.
- `README.md` rewritten to match current architecture; `docs/initial-plan.md` - `README.md` rewritten to match current architecture;
bannered as historical; new `docs/architecture.md` describes today's `docs/initial-plan.md` bannered as historical; new
shape (T-101). `docs/architecture.md` describes today's shape (T-101).
- `SchedulerService._stopTicker` now awaits the in-flight isolate spawn - `SchedulerService._stopTicker` now awaits the in-flight isolate spawn
before killing — closes the same race shape we fixed in PTY (T-106). before killing — closes the same race shape we fixed in PTY (T-106).
- `make push-check-full` added — runs `push-check` plus integration + - `make push-check-full` added — runs `push-check` plus integration +
smoke for pre-release checks. Integration tests skip the hanging smoke for pre-release checks. Integration tests skip the hanging
theme_picker case (T-116) until that's fixed (T-103). theme_picker case until that's fixed (T-103, T-116).
- Governance bookkeeping: D-66 amended (floor at `coverage_floor:` in - Governance bookkeeping: D-66 amended (floor at `coverage_floor:` in
`pubspec.yaml`); `licenses.yaml` reconciled with `pubspec.yaml`; `pubspec.yaml`); `licenses.yaml` reconciled with `pubspec.yaml`;
Q-1/Q-2/Q-3/Q-25 triaged; `.claude/skills/README.md` inventory 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). added; `--no-fatal-infos` dropped from `ci/test.sh` (T-113).
- Terminal panes now render bold attributes with a real bold weight — - Terminal panes render bold attributes with a real bold weight —
bundled JetBrainsMono Bold + BoldItalic are registered with the bundled JetBrainsMono Bold + BoldItalic registered with the
`JetBrainsMono` family at `weight: 700`. The painter's bold `JetBrainsMono` family at `weight: 700`. The painter's bold
suppression workaround (added when only Regular + Italic were wired suppression workaround is gone.
and Flutter's synthetic bold drifted advance widths) is gone.
- Claude pane uses `MultitabPane` for primary + secondaries — drops - Claude pane uses `MultitabPane` for primary + secondaries — drops
~100 lines of bespoke tab-strip code, gains drag-to-reorder. ~100 lines of bespoke tab-strip code, gains drag-to-reorder.
- UI spacing constants live in `lib/widgets/src/spacing.dart` — - UI spacing constants live in `lib/widgets/src/spacing.dart` —
`clideInset*` for paddings, `clideGap*` for sibling distances, `clideInset*` for paddings, `clideGap*` for sibling distances,
`clideIcon*` / `clideControlHeight` for control sizes. Inline `clideIcon*` / `clideControlHeight` for control sizes.
pixel literals replaced where they were repeated.
### Changed
- Tagline reads "IDE for Claude Code CLI" everywhere (welcome - Tagline reads "IDE for Claude Code CLI" everywhere (welcome
subtitle, README, CLAUDE.md, pubspec, web manifest, CLI banner). 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.
- 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).
- `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` / `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.
Closing earlier could briefly target a reused 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;
`PtySession.spawn` closes the master fd if any post-receive step
fails.
- 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`.
### Security
- 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` now reject symlinks whose targets live
outside the workspace — closes a path-safety bypass via in-repo
symlinks (T-102).
- `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 - Inline terminal emulator based on xterm.dart v4.0.0 — replaces the
pub.dev dependency with owned code under `lib/src/terminal/`. Drops pub.dev dependency with owned code under `lib/src/terminal/`. Drops
three transitive dependencies (xterm, quiver, zmodem). three transitive dependencies (xterm, quiver, zmodem).
- Bundle clide-specific tmux.conf for Claude pane sessions: no status - Bundle clide-specific tmux.conf for Claude pane sessions: no status
bar, 50k scrollback, mouse on, zero escape delay, isolated socket. bar, 50k scrollback, mouse on, zero escape delay, isolated socket.
- Claude pane spawns `claude` directly inside tmux with - Claude pane spawns `claude` directly inside tmux with
`CLAUDE_CODE_NO_FLICKER=1` to enable Claude's fullscreen TUI mode `CLAUDE_CODE_NO_FLICKER=1` to enable Claude's fullscreen TUI mode.
(input box pinned at the bottom). - PTY read buffer increased from 4 KB to 64 KB.
- PTY read buffer increased from 4KB to 64KB. - Terminal view 2 px padding on all sides.
- Terminal view 2px padding on all sides.
- Remove bold JetBrains Mono font registration to prevent glyph width ### Removed
mismatch in terminal rendering.
- **`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
toolchain resolution, `ToolCheck` gate, backend serialization,
testmode harness, CI scripts, Makefile, and sandbox entitlements.
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. 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` 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`.
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 —
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.
- `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` / `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.
- `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
- 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 ## [2.0.0] — 2026-05-03
+152
View File
@@ -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.
+3
View File
@@ -79,6 +79,9 @@ analyze: ## flutter analyze.
format: ## dart format --set-exit-if-changed. format: ## dart format --set-exit-if-changed.
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 .PHONY: test
test: ## Fast: analyze + format + unit + widget + golden (<60s). test: ## Fast: analyze + format + unit + widget + golden (<60s).
ci/test.sh ci/test.sh
+1
View File
@@ -45,6 +45,7 @@ Pre-v2.0 (`2.0.0-dev`). Interaction model and panel system landed. The Python Te
## Documentation ## Documentation
- [`docs/architecture.md`](docs/architecture.md) — current architecture (read this first). - [`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. - [`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`). - [`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). - [`CLAUDE.md`](CLAUDE.md) — Claude-addressed working notes (guardrails, repo layout).
+10 -22
View File
@@ -1,15 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# CHANGELOG concision gate — enforces the per-bullet word caps from the # CHANGELOG concision gate — enforces a single 60-word per-bullet hard
# git-commit skill (40 soft, 60 hard) across the `## [Unreleased]` # cap on the `## [Unreleased]` section. Released sections are frozen
# section. Released sections are frozen and skipped (don't penalize # and skipped (don't penalize historical entries pre-dating the rule).
# historical entries pre-dating the rule).
# #
# A "bullet" is a markdown list item beginning with `- `, including any # A "bullet" is a markdown list item beginning with `- `, including any
# indented continuation lines until the next bullet, blank line, or # indented continuation lines until the next bullet, blank line, or
# heading. Word count is whitespace-tokenized. # heading. Word count is whitespace-tokenized.
# #
# Soft cap 40 → warn (non-zero exit only if HARD is also breached). # A soft 40-word warning was tried earlier and dropped — warnings that
# Hard cap 60 → fail. # never block a push just normalise drift, so the gate is now binary.
# #
# Bypass: never. If the rule rejects something genuinely user-visible # Bypass: never. If the rule rejects something genuinely user-visible
# that needs more context, the context belongs in the commit body or a # that needs more context, the context belongs in the commit body or a
@@ -18,7 +17,6 @@ set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
CHANGELOG=CHANGELOG.md CHANGELOG=CHANGELOG.md
SOFT_CAP=40
HARD_CAP=60 HARD_CAP=60
if [[ ! -f "$CHANGELOG" ]]; then if [[ ! -f "$CHANGELOG" ]]; then
@@ -26,24 +24,18 @@ if [[ ! -f "$CHANGELOG" ]]; then
exit 2 exit 2
fi fi
awk -v soft="$SOFT_CAP" -v hard="$HARD_CAP" ' awk -v hard="$HARD_CAP" '
BEGIN { in_unreleased = 0; bullet = ""; bullet_start = 0; fail = 0; warn = 0 } BEGIN { in_unreleased = 0; bullet = ""; bullet_start = 0; fail = 0 }
function check_bullet() { function check_bullet() {
if (bullet == "") return if (bullet == "") return
n = split(bullet, _words, /[[:space:]]+/) # Trim the leading "- " marker, then tokenize on whitespace.
# split() counts a trailing empty token when the string starts/ends with
# whitespace; trim the leading "- " marker too.
gsub(/^- +/, "", bullet) gsub(/^- +/, "", bullet)
n = split(bullet, _words, /[[:space:]]+/) n = split(bullet, _words, /[[:space:]]+/)
if (n > hard) { if (n > hard) {
printf "FAIL line %d: bullet is %d words (hard cap %d)\n", bullet_start, 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 ? "..." : "") printf " %s\n\n", substr(bullet, 1, 120) (length(bullet) > 120 ? "..." : "")
fail++ fail++
} else if (n > soft) {
printf "WARN line %d: bullet is %d words (soft cap %d)\n", bullet_start, n, soft
printf " %s\n\n", substr(bullet, 1, 120) (length(bullet) > 120 ? "..." : "")
warn++
} }
bullet = "" bullet = ""
bullet_start = 0 bullet_start = 0
@@ -76,10 +68,6 @@ awk -v soft="$SOFT_CAP" -v hard="$HARD_CAP" '
printf " See .claude/skills/git-commit/SKILL.md \"Be concise\".\n" printf " See .claude/skills/git-commit/SKILL.md \"Be concise\".\n"
exit 1 exit 1
} }
if (warn > 0) { printf "==> changelog gate OK\n"
printf "==> changelog gate OK with %d warning(s) over %d words.\n", warn, soft
} else {
printf "==> changelog gate OK\n"
}
} }
' "$CHANGELOG" ' "$CHANGELOG"
+76 -30
View File
@@ -55,24 +55,23 @@ class _RootShell extends StatefulWidget {
class _RootShellState extends State<_RootShell> { class _RootShellState extends State<_RootShell> {
late final FocusNode _keyFocus; late final FocusNode _keyFocus;
double _textScale = 1.0;
static const double _scaleStep = 0.05;
static const double _scaleMin = 0.6;
static const double _scaleMax = 2.0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_keyFocus = FocusNode()..requestFocus(); _keyFocus = FocusNode()..requestFocus();
widget.services.textZoom.addListener(_onZoom);
} }
@override @override
void dispose() { void dispose() {
widget.services.textZoom.removeListener(_onZoom);
_keyFocus.dispose(); _keyFocus.dispose();
super.dispose(); super.dispose();
} }
void _onZoom() => setState(() {});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface; final tokens = ClideTheme.of(context).surface;
@@ -86,24 +85,24 @@ class _RootShellState extends State<_RootShell> {
fontFamilyFallback: clideUiFamilyFallback, fontFamilyFallback: clideUiFamilyFallback,
), ),
child: MediaQuery( child: MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(_textScale)), data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(widget.services.textZoom.scale)),
child: Actions( child: Actions(
actions: <Type, Action<Intent>>{ actions: <Type, Action<Intent>>{
TextScaleIncreaseIntent: CallbackAction<TextScaleIncreaseIntent>( TextScaleIncreaseIntent: CallbackAction<TextScaleIncreaseIntent>(
onInvoke: (_) { onInvoke: (_) {
setState(() => _textScale = (_textScale + _scaleStep).clamp(_scaleMin, _scaleMax)); widget.services.textZoom.increase();
return null; return null;
}, },
), ),
TextScaleDecreaseIntent: CallbackAction<TextScaleDecreaseIntent>( TextScaleDecreaseIntent: CallbackAction<TextScaleDecreaseIntent>(
onInvoke: (_) { onInvoke: (_) {
setState(() => _textScale = (_textScale - _scaleStep).clamp(_scaleMin, _scaleMax)); widget.services.textZoom.decrease();
return null; return null;
}, },
), ),
TextScaleResetIntent: CallbackAction<TextScaleResetIntent>( TextScaleResetIntent: CallbackAction<TextScaleResetIntent>(
onInvoke: (_) { onInvoke: (_) {
setState(() => _textScale = 1.0); widget.services.textZoom.reset();
return null; return null;
}, },
), ),
@@ -346,7 +345,7 @@ class _WinBtn extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hoverBg = isClose ? const Color(0xFFE81123) : tokens.listItemHoverBackground; final hoverBg = isClose ? tokens.windowControlCloseHoverBackground : tokens.listItemHoverBackground;
return ClideTappable( return ClideTappable(
onTap: onTap, onTap: onTap,
builder: (context, hovered, _) => Container( builder: (context, hovered, _) => Container(
@@ -354,7 +353,11 @@ class _WinBtn extends StatelessWidget {
height: hatHeight, height: hatHeight,
color: hovered ? hoverBg : null, color: hovered ? hoverBg : null,
alignment: Alignment.center, 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,
),
), ),
); );
} }
@@ -964,36 +967,79 @@ class _EditorDragHandle extends StatefulWidget {
class _EditorDragHandleState extends State<_EditorDragHandle> { class _EditorDragHandleState extends State<_EditorDragHandle> {
bool _hovered = false; bool _hovered = false;
bool _focused = false;
double? _dragStartRatio; double? _dragStartRatio;
double? _dragStartY; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface; final tokens = ClideTheme.of(context).surface;
return MouseRegion( final lineColor = (_hovered || _focused) ? tokens.panelActiveBorder : tokens.panelBorder;
cursor: SystemMouseCursors.resizeRow,
onEnter: (_) => setState(() => _hovered = true), return Semantics(
onExit: (_) => setState(() => _hovered = false), container: true,
child: Listener( slider: true,
onPointerDown: (e) { label: 'Editor split',
_dragStartRatio = widget.arrangement.editorRatio; value: '${(widget.arrangement.editorRatio * 100).round()}%',
_dragStartY = e.position.dy; 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) { actions: <Type, Action<Intent>>{
final startR = _dragStartRatio; _EditorBumpIntent: CallbackAction<_EditorBumpIntent>(
final startY = _dragStartY; onInvoke: (intent) {
if (startR == null || startY == null || widget.totalHeight <= 0) return; _bump(intent.delta);
final deltaRatio = (e.position.dy - startY) / widget.totalHeight; return null;
widget.arrangement.setEditorRatio(startR + deltaRatio); },
),
}, },
onPointerUp: (_) { child: MouseRegion(
_dragStartRatio = null; cursor: SystemMouseCursors.resizeRow,
_dragStartY = null; onEnter: (_) => setState(() => _hovered = true),
}, onExit: (_) => setState(() => _hovered = false),
child: Container(height: 4, color: _hovered ? tokens.panelActiveBorder : tokens.panelBorder), 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 { class _ContextSlot extends StatelessWidget {
+57
View File
@@ -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});
},
),
];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+1
View File
@@ -40,6 +40,7 @@ export 'src/os.dart';
export 'src/panels/arrangement.dart'; export 'src/panels/arrangement.dart';
export 'src/project.dart'; export 'src/project.dart';
export 'src/scheduler.dart'; export 'src/scheduler.dart';
export 'src/text_zoom.dart';
export 'src/secrets.dart'; export 'src/secrets.dart';
export 'src/tray.dart'; export 'src/tray.dart';
export 'src/panels/drag_resize.dart'; export 'src/panels/drag_resize.dart';
+6
View File
@@ -5,6 +5,7 @@ import 'package:clide/clide.dart';
import 'package:clide/kernel/src/clipboard.dart'; import 'package:clide/kernel/src/clipboard.dart';
import 'package:clide/kernel/src/commands/keybindings.dart'; import 'package:clide/kernel/src/commands/keybindings.dart';
import 'package:clide/kernel/src/keymap/keymap_service.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/palette.dart';
import 'package:clide/kernel/src/commands/registry.dart'; import 'package:clide/kernel/src/commands/registry.dart';
import 'package:clide/kernel/src/dialog.dart'; import 'package:clide/kernel/src/dialog.dart';
@@ -65,6 +66,7 @@ class KernelServices {
required this.toolchain, required this.toolchain,
required this.scheduler, required this.scheduler,
required this.keymap, required this.keymap,
required this.textZoom,
}); });
final Logger log; final Logger log;
@@ -94,6 +96,7 @@ class KernelServices {
final Toolchain toolchain; final Toolchain toolchain;
final SchedulerService scheduler; final SchedulerService scheduler;
final KeymapService keymap; final KeymapService keymap;
final TextZoom textZoom;
static Future<KernelServices> boot({ static Future<KernelServices> boot({
required Directory appDir, required Directory appDir,
@@ -151,6 +154,7 @@ class KernelServices {
final tc = toolchain ?? Toolchain(); final tc = toolchain ?? Toolchain();
final scheduler = SchedulerService(events); final scheduler = SchedulerService(events);
scheduler.start(); scheduler.start();
final textZoom = TextZoom();
final project = ProjectManager( final project = ProjectManager(
log: log, log: log,
events: events, events: events,
@@ -225,6 +229,7 @@ class KernelServices {
toolchain: tc, toolchain: tc,
scheduler: scheduler, scheduler: scheduler,
keymap: keymap, keymap: keymap,
textZoom: textZoom,
); );
} }
@@ -247,6 +252,7 @@ class KernelServices {
extensions.dispose(); extensions.dispose();
await scheduler.dispose(); await scheduler.dispose();
keymap.dispose(); keymap.dispose();
textZoom.dispose();
await log.dispose(); await log.dispose();
messages.dispose(); messages.dispose();
await events.dispose(); await events.dispose();
+104 -21
View File
@@ -1,11 +1,16 @@
import 'package:clide/kernel/src/panels/arrangement.dart'; import 'package:clide/kernel/src/panels/arrangement.dart';
import 'package:clide/kernel/src/panels/slot_id.dart'; import 'package:clide/kernel/src/panels/slot_id.dart';
import 'package:clide/kernel/src/theme/controller.dart'; import 'package:clide/kernel/src/theme/controller.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
/// A 4-px draggable splitter that adjusts the size of [slot] in the /// A 4-px splitter that adjusts the size of [slot] in [arrangement].
/// given [arrangement]. Slot hosts wrap this around their edges to make /// Slot hosts wrap this around their edges to make the three-column
/// the three-column layout resizable. /// 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 { class DragResizeHandle extends StatefulWidget {
const DragResizeHandle({ const DragResizeHandle({
super.key, super.key,
@@ -21,6 +26,8 @@ class DragResizeHandle extends StatefulWidget {
final double thickness; final double thickness;
static const defaultThickness = 8.0; static const defaultThickness = 8.0;
static const double stepFine = 10.0;
static const double stepCoarse = 50.0;
@override @override
State<DragResizeHandle> createState() => _DragResizeHandleState(); State<DragResizeHandle> createState() => _DragResizeHandleState();
@@ -28,32 +35,57 @@ class DragResizeHandle extends StatefulWidget {
class _DragResizeHandleState extends State<DragResizeHandle> { class _DragResizeHandleState extends State<DragResizeHandle> {
bool _hovered = false; bool _hovered = false;
bool _focused = false;
double? _dragStartSize; double? _dragStartSize;
Offset? _dragStartPointer; Offset? _dragStartPointer;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface; final tokens = ClideTheme.of(context).surface;
final lineColor = _hovered ? tokens.panelActiveBorder : tokens.dividerColor; final lineColor = (_hovered || _focused) ? tokens.panelActiveBorder : tokens.dividerColor;
return MouseRegion( final size = widget.arrangement.sizeOf(widget.slot);
cursor: widget.axis == Axis.horizontal ? SystemMouseCursors.resizeColumn : SystemMouseCursors.resizeRow,
onEnter: (_) => setState(() => _hovered = true), return Semantics(
onExit: (_) => setState(() => _hovered = false), container: true,
child: Listener( slider: true,
onPointerDown: _onDown, label: _semanticLabel(),
onPointerMove: _onMove, value: size == null ? null : '${size.round()} pixels',
onPointerUp: _onUp, increasedValue: size == null ? null : '${(size + DragResizeHandle.stepFine).round()} pixels',
child: Container( decreasedValue: size == null ? null : '${(size - DragResizeHandle.stepFine).round()} pixels',
width: widget.axis == Axis.horizontal ? widget.thickness : null, onIncrease: () => _bump(DragResizeHandle.stepFine),
height: widget.axis == Axis.vertical ? widget.thickness : null, onDecrease: () => _bump(-DragResizeHandle.stepFine),
color: tokens.chromeBackground, child: FocusableActionDetector(
child: Align( onShowFocusHighlight: (v) => setState(() => _focused = v),
alignment: widget.slot == Slots.sidebar ? Alignment.centerRight : Alignment.centerLeft, 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( child: Container(
width: widget.axis == Axis.horizontal ? 1 : null, width: widget.axis == Axis.horizontal ? widget.thickness : null,
height: widget.axis == Axis.vertical ? 1 : null, height: widget.axis == Axis.vertical ? widget.thickness : null,
color: lineColor, 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) { void _onDown(PointerDownEvent e) {
_dragStartSize = widget.arrangement.sizeOf(widget.slot); _dragStartSize = widget.arrangement.sizeOf(widget.slot);
_dragStartPointer = e.position; _dragStartPointer = e.position;
@@ -80,3 +144,22 @@ class _DragResizeHandleState extends State<DragResizeHandle> {
_dragStartPointer = null; _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;
}
+28
View File
@@ -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();
}
}
+11
View File
@@ -96,6 +96,9 @@ class ThemeResolver {
modalOverlayBackground: surface[TokenKeys.modalOverlayBackground]!, modalOverlayBackground: surface[TokenKeys.modalOverlayBackground]!,
modalSurfaceBackground: surface[TokenKeys.modalSurfaceBackground]!, modalSurfaceBackground: surface[TokenKeys.modalSurfaceBackground]!,
modalSurfaceBorder: surface[TokenKeys.modalSurfaceBorder]!, modalSurfaceBorder: surface[TokenKeys.modalSurfaceBorder]!,
windowControlCloseHoverBackground: surface[TokenKeys.windowControlCloseHoverBackground]!,
windowControlCloseHoverForeground: surface[TokenKeys.windowControlCloseHoverForeground]!,
shadowAmbient: surface[TokenKeys.shadowAmbient]!,
dividerColor: surface[TokenKeys.dividerColor]!, dividerColor: surface[TokenKeys.dividerColor]!,
statusSuccess: surface[TokenKeys.statusSuccess]!, statusSuccess: surface[TokenKeys.statusSuccess]!,
statusWarning: surface[TokenKeys.statusWarning]!, statusWarning: surface[TokenKeys.statusWarning]!,
@@ -250,6 +253,14 @@ const Map<String, List<String>> _defaultSurfaceMap = {
TokenKeys.modalOverlayBackground: ['#C0000000'], TokenKeys.modalOverlayBackground: ['#C0000000'],
TokenKeys.modalSurfaceBackground: ['surface', 'semantic.mainchrome'], TokenKeys.modalSurfaceBackground: ['surface', 'semantic.mainchrome'],
TokenKeys.modalSurfaceBorder: ['accent', 'semantic.focus'], 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 // divider
TokenKeys.dividerColor: ['border', 'semantic.surface'], TokenKeys.dividerColor: ['border', 'semantic.surface'],
// status // status
@@ -28,6 +28,9 @@ palette:
textMute: "#7A7A72" textMute: "#7A7A72"
accentSoft: "#218E2D10" accentSoft: "#218E2D10"
surface:
shadow.ambient: "#26000000"
syntax: syntax:
keyword: "#52215C" keyword: "#52215C"
type: "#154168" type: "#154168"
+3
View File
@@ -25,6 +25,9 @@ palette:
textMute: "#A8A89E" textMute: "#A8A89E"
accentSoft: "#21C14B2A" accentSoft: "#21C14B2A"
surface:
shadow.ambient: "#26000000"
syntax: syntax:
keyword: "#7B3F8C" keyword: "#7B3F8C"
type: "#2A6FC1" type: "#2A6FC1"
+26
View File
@@ -74,6 +74,11 @@ class SurfaceTokens {
required this.modalOverlayBackground, required this.modalOverlayBackground,
required this.modalSurfaceBackground, required this.modalSurfaceBackground,
required this.modalSurfaceBorder, required this.modalSurfaceBorder,
// window controls
required this.windowControlCloseHoverBackground,
required this.windowControlCloseHoverForeground,
// shadow
required this.shadowAmbient,
// divider // divider
required this.dividerColor, required this.dividerColor,
// status // status
@@ -155,6 +160,17 @@ class SurfaceTokens {
final Color modalSurfaceBackground; final Color modalSurfaceBackground;
final Color modalSurfaceBorder; 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 dividerColor;
final Color statusSuccess; final Color statusSuccess;
@@ -255,6 +271,13 @@ abstract class TokenKeys {
static const modalSurfaceBackground = 'modal.surfaceBackground'; static const modalSurfaceBackground = 'modal.surfaceBackground';
static const modalSurfaceBorder = 'modal.surfaceBorder'; static const modalSurfaceBorder = 'modal.surfaceBorder';
// window controls
static const windowControlCloseHoverBackground = 'windowControl.closeHoverBackground';
static const windowControlCloseHoverForeground = 'windowControl.closeHoverForeground';
// shadow
static const shadowAmbient = 'shadow.ambient';
// divider // divider
static const dividerColor = 'divider.color'; static const dividerColor = 'divider.color';
@@ -325,6 +348,9 @@ abstract class TokenKeys {
modalOverlayBackground, modalOverlayBackground,
modalSurfaceBackground, modalSurfaceBackground,
modalSurfaceBorder, modalSurfaceBorder,
windowControlCloseHoverBackground,
windowControlCloseHoverForeground,
shadowAmbient,
dividerColor, dividerColor,
statusSuccess, statusSuccess,
statusWarning, statusWarning,
+2
View File
@@ -20,6 +20,7 @@ import 'package:clide/builtin/problems/problems.dart';
import 'package:clide/builtin/settings_ui/settings_ui.dart'; import 'package:clide/builtin/settings_ui/settings_ui.dart';
import 'package:clide/builtin/terminal/terminal.dart'; import 'package:clide/builtin/terminal/terminal.dart';
import 'package:clide/builtin/theme_picker/theme_picker.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/tickets/tickets.dart';
import 'package:clide/builtin/todos/todos.dart'; import 'package:clide/builtin/todos/todos.dart';
import 'package:clide/builtin/welcome/welcome.dart'; import 'package:clide/builtin/welcome/welcome.dart';
@@ -145,6 +146,7 @@ Future<void> main() async {
..register(CanvasExtension()) ..register(CanvasExtension())
..register(GraphExtension()) ..register(GraphExtension())
// UI extensions // UI extensions
..register(ViewExtension(textZoom: services.textZoom))
..register(SettingsUiExtension()) ..register(SettingsUiExtension())
..register(ExtensionsUiExtension()) ..register(ExtensionsUiExtension())
..register(KeybindingsUiExtension()) ..register(KeybindingsUiExtension())
+19
View File
@@ -13,6 +13,11 @@ import '../ipc/schema_v1.dart';
import '../panes/event_sink.dart'; import '../panes/event_sink.dart';
import 'dispatcher.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 /// Daemon-side state for the `files` subsystem. Holds one
/// [FileWatcher] rooted at the workspace and a resolved [IgnoreSet]. /// [FileWatcher] rooted at the workspace and a resolved [IgnoreSet].
class FilesService { class FilesService {
@@ -84,6 +89,20 @@ void registerFilesCommands(DaemonDispatcher d, FilesService files) {
if (!file.existsSync()) { if (!file.existsSync()) {
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'file not found: $path')); 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(); final content = file.readAsStringSync();
return IpcResponse.ok(id: req.id, data: {'path': path, 'content': content}); return IpcResponse.ok(id: req.id, data: {'path': path, 'content': content});
}); });
+37 -1
View File
@@ -12,6 +12,16 @@ import '../ipc/schema_v1.dart';
import '../panes/event_sink.dart'; import '../panes/event_sink.dart';
import 'dispatcher.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( void registerGitCommands(
DaemonDispatcher d, DaemonDispatcher d,
GitClient git, GitClient git,
@@ -30,6 +40,8 @@ void registerGitCommands(
try { try {
final staged = req.args['staged'] as bool? ?? false; final staged = req.args['staged'] as bool? ?? false;
final paths = _pathList(req.args['paths']); 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); final diffs = await git.diff(staged: staged, paths: paths);
return IpcResponse.ok(id: req.id, data: { return IpcResponse.ok(id: req.id, data: {
'staged': staged, 'staged': staged,
@@ -53,6 +65,8 @@ void registerGitCommands(
), ),
); );
} }
final tooMany = _tooManyPaths(req.id, paths);
if (tooMany != null) return tooMany;
try { try {
await git.stage(paths); await git.stage(paths);
_emitChanged(events); _emitChanged(events);
@@ -190,8 +204,18 @@ void registerGitCommands(
}); });
d.register('git.log', (req) async { 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 { try {
final count = (req.args['count'] as num?)?.toInt() ?? 20;
final entries = await git.log(count: count); final entries = await git.log(count: count);
return IpcResponse.ok(id: req.id, data: { return IpcResponse.ok(id: req.id, data: {
'entries': [for (final e in entries) e.toJson()], 'entries': [for (final e in entries) e.toJson()],
@@ -264,6 +288,18 @@ List<String> _pathList(Object? raw) {
return const []; 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) { void _emitChanged(DaemonEventSink events) {
events.emit(IpcEvent( events.emit(IpcEvent(
subsystem: 'git', subsystem: 'git',
+10 -1
View File
@@ -9,7 +9,7 @@ import 'dart:io';
import '../../kernel/src/toolchain_paths.dart'; import '../../kernel/src/toolchain_paths.dart';
import 'diff.dart' show GitDiff, parseDiffOutput; import 'diff.dart' show GitDiff, parseDiffOutput;
import 'operations.dart' show GitException, GitLogEntry; import 'operations.dart' show GitException, GitLogEntry, validateGitRef;
import 'status.dart'; import 'status.dart';
class GitClient { class GitClient {
@@ -191,8 +191,13 @@ class GitClient {
} }
Future<String> push({String? remote, String? branch, bool setUpstream = false}) async { 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']; final args = ['push'];
if (setUpstream) args.add('-u'); 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 (remote != null) args.add(remote);
if (branch != null) args.add(branch); if (branch != null) args.add(branch);
final r = await _run(args); final r = await _run(args);
@@ -201,6 +206,10 @@ class GitClient {
} }
Future<void> checkout(String branch) async { 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]); final r = await _run(['checkout', branch]);
if (r.exitCode != 0) throw GitException('git checkout failed', stderr: r.stderr as String); if (r.exitCode != 0) throw GitException('git checkout failed', stderr: r.stderr as String);
} }
+28
View File
@@ -37,6 +37,21 @@ class GitException implements Exception {
String toString() => 'GitException: $message'; 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 { class GitLogEntry {
const GitLogEntry({ const GitLogEntry({
required this.hash, required this.hash,
@@ -204,8 +219,14 @@ Future<String> gitPush(
String? branch, String? branch,
bool setUpstream = false, bool setUpstream = false,
}) async { }) async {
if (remote != null) validateGitRef(remote, kind: 'remote');
if (branch != null) validateGitRef(branch, kind: 'branch');
final args = ['push']; final args = ['push'];
if (setUpstream) args.add('-u'); 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 (remote != null) args.add(remote);
if (branch != null) args.add(branch); if (branch != null) args.add(branch);
final r = await Process.run(gitBin, args, workingDirectory: workDir.path); 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. /// 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 { Future<void> gitCheckout(Directory workDir, String branch) async {
validateGitRef(branch, kind: 'branch');
final r = await Process.run( final r = await Process.run(
gitBin, gitBin,
['checkout', branch], ['checkout', branch],
+6 -2
View File
@@ -108,7 +108,7 @@ class _WinButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hoverBg = isClose ? const Color(0xFFE81123) : tokens.listItemHoverBackground; final hoverBg = isClose ? tokens.windowControlCloseHoverBackground : tokens.listItemHoverBackground;
return ClideTappable( return ClideTappable(
onTap: onTap, onTap: onTap,
builder: (context, hovered, _) => Container( builder: (context, hovered, _) => Container(
@@ -116,7 +116,11 @@ class _WinButton extends StatelessWidget {
height: hatHeight, height: hatHeight,
color: hovered ? hoverBg : null, color: hovered ? hoverBg : null,
alignment: Alignment.center, 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,
),
), ),
); );
} }
+3 -3
View File
@@ -126,11 +126,11 @@ class _ClidePaletteState extends State<ClidePalette> {
color: tokens.dropdownBackground, color: tokens.dropdownBackground,
border: Border.all(color: tokens.dropdownBorder), border: Border.all(color: tokens.dropdownBorder),
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
boxShadow: const [ boxShadow: [
BoxShadow( BoxShadow(
color: Color(0x40000000), color: tokens.shadowAmbient,
blurRadius: 12, blurRadius: 12,
offset: Offset(0, 4), offset: const Offset(0, 4),
), ),
], ],
), ),
+1 -1
View File
@@ -13,7 +13,7 @@ description: >-
subsystem handlers (pane, files, editor, git, pql), and the subsystem handlers (pane, files, editor, git, pql), and the
extension framework. extension framework.
publish_to: none publish_to: none
version: 2.0.0 version: 2.1.0
repository: https://github.com/postmeridiem/clide repository: https://github.com/postmeridiem/clide
# Pre-push line-coverage floor. Ratchets up only — see D-66. # Pre-push line-coverage floor. Ratchets up only — see D-66.
+16
View File
@@ -110,6 +110,22 @@ void main() {
expect(r.error!.message, contains('not found')); 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 { test('files.ls with a path outside the root is rejected', () async {
final r = await call('files.ls', const {'path': '../escape'}); final r = await call('files.ls', const {'path': '../escape'});
expect(r.ok, isFalse); expect(r.ok, isFalse);
+41
View File
@@ -294,4 +294,45 @@ void main() {
final r = await call('git.stage', {'paths': 'new.txt'}); final r = await call('git.stage', {'paths': 'new.txt'});
expect(r.ok, isTrue); 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'));
});
} }
+42
View File
@@ -227,6 +227,48 @@ void main() {
} on GitException catch (_) {} } 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 { test('gitPull against no remote throws GitException', () async {
try { try {
await gitPull(sandbox); await gitPull(sandbox);
@@ -4,6 +4,7 @@ library;
import 'package:clide/extension/extension.dart'; import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -83,6 +84,114 @@ void main() {
expect(arr.sizeOf(Slots.contextPanel), 150); expect(arr.sizeOf(Slots.contextPanel), 150);
}); });
testWidgets('exposes a slider Semantics node with the current size', (tester) async {
final arr = LayoutArrangement();
arr.applyPreset(const LayoutPresetContribution(
id: 'test-preset',
displayName: 'Test',
slots: [
LayoutSlot(slot: Slots.sidebar, position: SlotPosition.left, defaultSize: 240),
],
));
final semHandle = tester.ensureSemantics();
await tester.pumpWidget(harness(
f,
Center(
child: SizedBox(
width: 40,
height: 200,
child: DragResizeHandle(
arrangement: arr,
slot: Slots.sidebar,
axis: Axis.horizontal,
),
),
),
));
await tester.pumpAndSettle();
final data = tester.getSemantics(find.byType(DragResizeHandle));
expect(data.label, 'Sidebar width');
expect(data.value, '240 pixels');
final actions = data.getSemanticsData().actions;
expect(actions & SemanticsAction.increase.index, isNot(0));
expect(actions & SemanticsAction.decrease.index, isNot(0));
semHandle.dispose();
});
testWidgets('contextPanel slider Semantics label matches the slot', (tester) async {
final arr = LayoutArrangement();
arr.applyPreset(const LayoutPresetContribution(
id: 'test-preset',
displayName: 'Test',
slots: [
LayoutSlot(slot: Slots.contextPanel, position: SlotPosition.right, defaultSize: 320),
],
));
final semHandle = tester.ensureSemantics();
await tester.pumpWidget(harness(
f,
Center(
child: SizedBox(
width: 40,
height: 200,
child: DragResizeHandle(
arrangement: arr,
slot: Slots.contextPanel,
axis: Axis.horizontal,
),
),
),
));
await tester.pumpAndSettle();
final data = tester.getSemantics(find.byType(DragResizeHandle));
expect(data.label, 'Context panel width');
expect(data.value, '320 pixels');
semHandle.dispose();
});
test('bumpedSlotSize keeps natural sign for left-anchored slots', () {
expect(bumpedSlotSize(slot: Slots.sidebar, current: 200, rawDelta: 10), 210);
expect(bumpedSlotSize(slot: Slots.sidebar, current: 200, rawDelta: -10), 190);
expect(bumpedSlotSize(slot: Slots.workspace, current: 500, rawDelta: 50), 550);
});
test('bumpedSlotSize flips sign for the right-anchored context panel', () {
expect(bumpedSlotSize(slot: Slots.contextPanel, current: 200, rawDelta: 10), 190);
expect(bumpedSlotSize(slot: Slots.contextPanel, current: 200, rawDelta: -10), 210);
});
testWidgets('vertical-axis handle uses arrow up/down shortcuts and "height" label', (tester) async {
final arr = LayoutArrangement();
arr.applyPreset(const LayoutPresetContribution(
id: 'test-preset',
displayName: 'Test',
slots: [
LayoutSlot(slot: Slots.workspace, position: SlotPosition.center, defaultSize: 300),
],
));
final semHandle = tester.ensureSemantics();
await tester.pumpWidget(harness(
f,
Center(
child: SizedBox(
width: 200,
height: 40,
child: DragResizeHandle(
arrangement: arr,
slot: Slots.workspace,
axis: Axis.vertical,
),
),
),
));
await tester.pumpAndSettle();
final data = tester.getSemantics(find.byType(DragResizeHandle));
// Custom (non-sidebar, non-contextPanel) slots fall through to
// the slot.value-based label branch.
expect(data.label, 'workspace height');
semHandle.dispose();
});
testWidgets('hovered state flips the line colour without throwing', (tester) async { testWidgets('hovered state flips the line colour without throwing', (tester) async {
final arr = LayoutArrangement(); final arr = LayoutArrangement();
arr.applyPreset(const LayoutPresetContribution( arr.applyPreset(const LayoutPresetContribution(
+62
View File
@@ -0,0 +1,62 @@
import 'package:clide/kernel/src/text_zoom.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('TextZoom', () {
test('starts at 1.0', () {
expect(TextZoom().scale, 1.0);
});
test('increase adds one step, notifies listeners', () {
final z = TextZoom();
var calls = 0;
z.addListener(() => calls++);
z.increase();
expect(z.scale, closeTo(1.0 + TextZoom.stepScale, 1e-9));
expect(calls, 1);
});
test('decrease subtracts one step', () {
final z = TextZoom();
z.decrease();
expect(z.scale, closeTo(1.0 - TextZoom.stepScale, 1e-9));
});
test('reset jumps back to 1.0', () {
final z = TextZoom()
..increase()
..increase();
expect(z.scale, isNot(1.0));
z.reset();
expect(z.scale, 1.0);
});
test('clamps at minScale', () {
final z = TextZoom();
for (var i = 0; i < 100; i++) {
z.decrease();
}
expect(z.scale, TextZoom.minScale);
});
test('clamps at maxScale', () {
final z = TextZoom();
for (var i = 0; i < 100; i++) {
z.increase();
}
expect(z.scale, TextZoom.maxScale);
});
test('no-op increment does not notify', () {
final z = TextZoom();
for (var i = 0; i < 100; i++) {
z.increase();
}
// Already at max — the next increase shouldn't fire.
var calls = 0;
z.addListener(() => calls++);
z.increase();
expect(calls, 0);
});
});
}