Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1b78d2845 | ||
|
|
fc4021e98b | ||
|
|
46f84728d2 | ||
|
|
6d0ebab721 | ||
|
|
bcea5f15b7 | ||
|
|
34ce01cbf1 | ||
|
|
1238802f54 | ||
|
|
4fa4ce1bac | ||
|
|
7d951a247f |
@@ -3514,3 +3514,14 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'status', 'backlog', 'review', NULL, '2026-06-10 18:24:41', '2026-06-10 18:24:41', '2026-06-10 18:24:41', NULL, 'c5a88f9594c44896a6a3d1a4b2418ed2', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'status', 'review', 'done', NULL, '2026-06-10 18:27:58', '2026-06-10 18:27:58', '2026-06-10 18:27:58', NULL, 'a9c72ab53b69f5ca6bf1fa4dd0ddfa05', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5M14B76B31654D959XM5AC', 'status', 'backlog', 'done', NULL, '2026-06-10 18:38:53', '2026-06-10 18:38:53', '2026-06-10 18:38:53', NULL, 'b94cfe8ba315b3be6775474c681b4e80', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'description', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
|
||||
|
||||
FOLLOW-UP SCOPE (folded in 2026-06-11):
|
||||
|
||||
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
|
||||
|
||||
2. TODO — tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 — honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
|
||||
|
||||
3. TODO — exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', NULL, '2026-06-11 07:05:54', '2026-06-11 07:05:54', '2026-06-11 07:05:54', NULL, 'f6d9c657c6987f7927bc3ba0bc02b3a4', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'status', 'backlog', 'ready', NULL, '2026-06-11 07:05:58', '2026-06-11 07:05:58', '2026-06-11 07:05:58', NULL, '1553f134361839180feffa625a88c06d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'status', 'ready', 'done', NULL, '2026-06-11 10:12:25', '2026-06-11 10:12:25', '2026-06-11 10:12:25', NULL, '53cfd5cf779b9a8474afe7e74efd02a3', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -177,3 +177,4 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'T-350', '2026-06-10 18:02:50', '2026-06-10 18:02:50', NULL, 'a5c0c22d84621b14a5208317414d6026', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5HMYDXP62RKH3HP55T6AYG', 'T-351', '2026-06-10 18:23:41', '2026-06-10 18:23:41', NULL, '9d2da44c16c5aa38c0a36e4b00ef5f15', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5M14B76B31654D959XM5AC', 'T-352', '2026-06-10 18:34:05', '2026-06-10 18:34:05', NULL, '3fe3e1d5fb7c0fbd084b45116575ad98', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'T-353', '2026-06-11 06:50:21', '2026-06-11 06:50:21', NULL, '53374633101d04f94981baaf4f2e0315', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
|
||||
@@ -3240,3 +3240,31 @@ This is a wrong-workDir timing issue, not db-busy (so the T-350 retry doesn''t c
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5M14B76B31654D959XM5AC', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Ticket sidebar errors on first load: pql runs before the workspace workDir is set', 'The tickets sidebar (and other pql panes) fail on first load when clide is desktop-launched: the daemon''s PqlClient is constructed with workDir = the boot CWD (the launch dir, e.g. ~), not the repo. The pane''s first pql.tickets.list fires before swapIpcServer reconfigures the dispatcher with the project''s workRoot, so pql runs in the wrong dir — against a stale/global pql.db — and errors (observed: ''ticket_deps.blocker_record_id missing — pql.db is from an earlier schema''). A manual refresh works because by then the workspace is open and the workDir is correct.
|
||||
|
||||
This is a wrong-workDir timing issue, not db-busy (so the T-350 retry doesn''t catch it). Fix: the pql-backed panes refetch on ProjectOpened (which fires after the IPC server swaps to the project workRoot). Implemented for the tickets pane; the decisions/pql/search panes share the latent bug and should get the same refetch.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 18:34:05', '2026-06-10 18:38:53', NULL, '3891f832c1d930d8fefca2fae7ffea86', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 06:50:21', NULL, 'd5de0d7f9b1c365f7a35c6d0aefa178e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
|
||||
|
||||
FOLLOW-UP SCOPE (folded in 2026-06-11):
|
||||
|
||||
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
|
||||
|
||||
2. TODO — tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 — honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
|
||||
|
||||
3. TODO — exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 07:05:54', NULL, 'd81c595ab22359cc6c75d490d17d8c5d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
|
||||
|
||||
FOLLOW-UP SCOPE (folded in 2026-06-11):
|
||||
|
||||
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
|
||||
|
||||
2. TODO — tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 — honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
|
||||
|
||||
3. TODO — exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 07:05:58', NULL, '352f3ceb8714f14bd12515a9b4542794', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'task', NULL, 'Evaluate pinned dependency versions + full CVE/advisory check', 'flutter pub outdated reports 16 packages behind latest (3 direct: ffi 2.1.3→2.2.0, jovial_svg 1.1.26→1.1.30, markdown 7.2.2→7.3.1; dev: alchemist 0.12.1→0.14.0, mocktail 1.0.4→1.0.5, test 1.31.0→1.31.1; plus transitive incl. xml 6.6.1→7.0.1 major). Per the prefer-zero-deps + exact-pin + advisory-review guardrail (D-42, CLAUDE.md supply chain), evaluate each pinned/direct dep: review CVEs/advisories (OSV.dev + pub.dev) for the current pin AND the candidate version, then bump the safe ones (artefact + assets/licenses.yaml in the same commit) and document any deliberately-held pins. Transitive deps move with the resolver/Flutter SDK; note but don''t force. Triggered by repeated ''N packages have newer versions'' noise on every build.
|
||||
|
||||
FOLLOW-UP SCOPE (folded in 2026-06-11):
|
||||
|
||||
1. DONE: osv-scanner supply-chain gate added to `make push-check` (ci/osv_scan.sh; fail-closed on any pubspec.lock advisory). Requires osv-scanner on PATH (brew install osv-scanner).
|
||||
|
||||
2. TODO — tighten env floors to reality. pubspec.yaml `environment` currently declares Dart >=3.5.0 / Flutter >=3.19.0, but we actually require more (alchemist 0.12 needs Flutter 3.32; the held markdown 7.3.1 needs Dart 3.9). Raise floors to ~Dart >=3.9.0 / Flutter >=3.32.0 — honest minimums. Raising Dart to 3.9 also unblocks the held markdown 7.2.2 -> 7.3.1 bump.
|
||||
|
||||
3. TODO — exact toolchain pin for reproducible builds. No FVM .fvmrc / .tool-versions / .flutter-version exists; a fresh clone builds with whatever Flutter the dev has (>= floor). Add an exact pin (FVM .fvmrc or asdf/mise .tool-versions) targeting the current toolchain (Dart 3.12.1 / Flutter 3.44.1).', 'done', 'medium', NULL, NULL, NULL, '2026-06-11 06:50:21', '2026-06-11 10:12:25', NULL, '4738f0609647b88e169e6e5788ded217', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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);
|
||||
|
||||
@@ -16,6 +16,59 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.3.3] — 2026-06-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ticket/decision sidebars load on first open after a desktop launch.** A
|
||||
desktop launch starts in HOME (not a git repo), and the daemon booted its
|
||||
pql/git/files workspace there — so pql ran in HOME, hit a stale
|
||||
`~/.pql/pql.db`, and the sidebars showed "pql … failed" until the project was
|
||||
reopened (a manual refresh worked once the workspace had swapped to the repo).
|
||||
The daemon now boots at the last opened project when the launch directory
|
||||
isn't itself a repo, so pql targets the real workspace from the first request.
|
||||
(T-352)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Minimum toolchain raised to honest values.** `pubspec.yaml` now declares
|
||||
Flutter `>=3.35.0` / Dart `>=3.9.0` (was `3.19.0` / `3.5.0`) — the real
|
||||
minimums our deps already required (`alchemist` needs Flutter 3.32; Dart 3.9
|
||||
ships in Flutter 3.35). The exact build toolchain is pinned in `.fvmrc`
|
||||
(Flutter 3.44.1). Moving to the Dart 3.9 language level reformatted the tree
|
||||
to the new "tall" style and adopted two new lints (`unnecessary_underscores`,
|
||||
`use_null_aware_elements`). (T-353)
|
||||
|
||||
### Security
|
||||
|
||||
- **Dependency audit + refresh.** Reviewed every pinned and transitive
|
||||
dependency against the GitHub Advisory Database / OSV (Pub ecosystem) — no
|
||||
advisory affects any current or candidate version. Refreshed the safe pins:
|
||||
`ffi` 2.1.3→2.2.0, `jovial_svg` 1.1.26→1.1.30 (pulls `jovial_misc` 0.10.0 +
|
||||
`xml` 7.0.1), `mocktail` 1.0.4→1.0.5, and `markdown` 7.2.2→7.3.1 (unblocked by
|
||||
the Dart 3.9 floor below). Deliberately held with reasons in `pubspec.yaml`:
|
||||
`alchemist` (0.13 golden churn), `test` (Flutter-SDK locked). (T-353)
|
||||
- **Supply-chain gate (`make security`).** A new `security` target runs
|
||||
`osv-scanner` over `pubspec.lock` and fails if any resolved dependency has a
|
||||
known advisory — a hard gate on top of `dart pub`'s passive, non-failing
|
||||
advisory print. Intended for the CI PR-merge pipeline (kept out of
|
||||
`push-check` so dev machines don't need the scanner installed); run locally
|
||||
any time with `make security`. (T-353)
|
||||
|
||||
## [2.3.2] — 2026-06-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ticket and decision sidebars reliably load on first open (real fix).** The
|
||||
2.3.1 re-fetch-on-open helped only when a project is picked *after* the window
|
||||
is up; with sticky-startup the project opens during boot, before the panes
|
||||
mount, so they never saw the event. The underlying cause was a race: the boot
|
||||
IPC-server swap (to the launch CWD) and the project-open swap (to the repo)
|
||||
ran concurrently, and the late-finishing boot swap could clobber the repo
|
||||
bind — leaving the daemon's pql/git/files pointed at the launch directory
|
||||
(HOME) and the sidebars erroring on a stale/global pql.db. Swaps are now
|
||||
serialized so the repo bind always wins. (T-352)
|
||||
|
||||
## [2.3.1] — 2026-06-10
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -309,8 +309,8 @@ clide-cli-clean: ## Remove the compiled C `clide` client.
|
||||
# -- security -------------------------------------------------------------
|
||||
|
||||
.PHONY: security
|
||||
security: ## Dart advisory review.
|
||||
@echo "security: Dart advisories reviewed manually before pubspec.yaml bumps."
|
||||
security: ## Supply-chain gate — osv-scanner over pubspec.lock (CI PR-merge pipeline; run locally on demand). Fails on a known advisory.
|
||||
ci/osv_scan.sh
|
||||
|
||||
# -- pre-push gate --------------------------------------------------------
|
||||
|
||||
@@ -319,6 +319,10 @@ decisions-validate: ## Parser dry-run over governance/{decisions,questions,rejec
|
||||
pql decisions validate
|
||||
|
||||
.PHONY: push-check
|
||||
# NOTE: the `security` (osv-scanner) gate is deliberately NOT in push-check —
|
||||
# it runs in the CI PR-merge pipeline (where the scanner is provisioned) so we
|
||||
# don't force every dev machine to install osv-scanner. Run it locally any time
|
||||
# with `make security`.
|
||||
push-check: decisions-validate changelog-gate test-coverage coverage-gate test-core ## Pre-push gate (fast — <2 min target). Order is fail-fast: instant gates (decisions, changelog) first, then the coverage suite + gate (the expensive, most-likely-to-fail stage) BEFORE test-core — a coverage miss aborts here instead of after running everything, so a fix doesn't force a full re-run of the rest. test-coverage already runs the a11y suite (test/a11y), so no separate test-a11y pass.
|
||||
|
||||
.PHONY: push-check-full
|
||||
|
||||
@@ -39,7 +39,7 @@ self:
|
||||
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
|
||||
# (runs implicitly on every build/run/test). Don't hand-edit; bump
|
||||
# pubspec instead.
|
||||
version: "2.3.1"
|
||||
version: "2.3.3"
|
||||
homepage: https://github.com/postmeridiem/clide
|
||||
license: MIT
|
||||
license_file: assets/LICENSE
|
||||
@@ -116,7 +116,7 @@ dependencies:
|
||||
|
||||
- name: ffi
|
||||
kind: dart-package
|
||||
version: "2.1.3"
|
||||
version: "2.2.0"
|
||||
homepage: https://pub.dev/packages/ffi
|
||||
license: BSD-3-Clause
|
||||
purpose: >-
|
||||
@@ -171,7 +171,7 @@ dependencies:
|
||||
|
||||
- name: jovial_svg
|
||||
kind: dart-package
|
||||
version: "1.1.26"
|
||||
version: "1.1.30"
|
||||
homepage: https://pub.dev/packages/jovial_svg
|
||||
license: BSD-3-Clause
|
||||
purpose: >-
|
||||
@@ -182,7 +182,7 @@ dependencies:
|
||||
|
||||
- name: markdown
|
||||
kind: dart-package
|
||||
version: "7.2.2"
|
||||
version: "7.3.1"
|
||||
homepage: https://pub.dev/packages/markdown
|
||||
license: BSD-3-Clause
|
||||
purpose: >-
|
||||
@@ -207,7 +207,7 @@ dependencies:
|
||||
dev_dependencies:
|
||||
- name: mocktail
|
||||
kind: dart-package
|
||||
version: "1.0.4"
|
||||
version: "1.0.5"
|
||||
homepage: https://pub.dev/packages/mocktail
|
||||
license: MIT
|
||||
purpose: >-
|
||||
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Supply-chain gate — fails the push if any resolved dependency in
|
||||
# pubspec.lock has a known advisory (OSV / GitHub Advisory Database).
|
||||
#
|
||||
# Complements `dart pub get`'s passive advisory print (informational,
|
||||
# non-failing) with a hard, fail-closed gate. Native deps (dugite,
|
||||
# tree-sitter, wasmtime) are vendored by SHA and not in a lockfile OSV
|
||||
# reads — they're reviewed separately on bump (D-42, CLAUDE.md supply chain).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Resolve osv-scanner: PATH first, then a brew prefix (the git pre-push hook
|
||||
# may run with a leaner PATH than the dev's interactive shell).
|
||||
OSV="$(command -v osv-scanner || true)"
|
||||
if [[ -z "$OSV" ]] && command -v brew >/dev/null 2>&1; then
|
||||
cand="$(brew --prefix 2>/dev/null)/bin/osv-scanner"
|
||||
[[ -x "$cand" ]] && OSV="$cand"
|
||||
fi
|
||||
if [[ -z "$OSV" ]]; then
|
||||
echo "==> osv gate: osv-scanner not found on PATH." >&2
|
||||
echo " Install it: brew install osv-scanner" >&2
|
||||
echo " (or: go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "==> osv gate: scanning pubspec.lock for known advisories"
|
||||
if "$OSV" scan source --lockfile=pubspec.lock; then
|
||||
echo "==> osv gate OK: no known advisories"
|
||||
else
|
||||
echo "==> osv gate FAIL: a dependency has a known advisory (see above)." >&2
|
||||
echo " Bump the affected package (+ its assets/licenses.yaml entry), or" >&2
|
||||
echo " document an explicit, justified exception before pushing." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -10,13 +10,7 @@
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
class ClideTheme {
|
||||
const ClideTheme({
|
||||
required this.name,
|
||||
required this.dark,
|
||||
required this.subtitle,
|
||||
required this.palette,
|
||||
required this.syntax,
|
||||
});
|
||||
const ClideTheme({required this.name, required this.dark, required this.subtitle, required this.palette, required this.syntax});
|
||||
final String name;
|
||||
final bool dark;
|
||||
final String subtitle;
|
||||
|
||||
@@ -33,23 +33,13 @@ void main() {
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
final themes = [
|
||||
await const ThemeLoader().fromAsset(
|
||||
rootBundle,
|
||||
'lib/kernel/src/theme/themes/summer-night.yaml',
|
||||
),
|
||||
];
|
||||
final themes = [await const ThemeLoader().fromAsset(rootBundle, 'lib/kernel/src/theme/themes/summer-night.yaml')];
|
||||
final services = await KernelServices.boot(
|
||||
appDir: await Directory.systemTemp.createTemp('clide_intg_'),
|
||||
bundledThemes: themes,
|
||||
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
|
||||
preloadNamespaces: const [
|
||||
'builtin.welcome',
|
||||
'builtin.ipc-status',
|
||||
'builtin.theme-picker',
|
||||
'builtin.default-layout',
|
||||
],
|
||||
daemonClientFactory: (log, events, _, __) => FakeDaemonClient(log: log, events: events),
|
||||
preloadNamespaces: const ['builtin.welcome', 'builtin.ipc-status', 'builtin.theme-picker', 'builtin.default-layout'],
|
||||
daemonClientFactory: (log, events, _, _) => FakeDaemonClient(log: log, events: events),
|
||||
autoStartDaemonClient: false,
|
||||
);
|
||||
services.extensions
|
||||
|
||||
@@ -25,22 +25,13 @@ void main() {
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
final themes = [
|
||||
await const ThemeLoader().fromAsset(
|
||||
rootBundle,
|
||||
'lib/kernel/src/theme/themes/summer-night.yaml',
|
||||
),
|
||||
];
|
||||
final themes = [await const ThemeLoader().fromAsset(rootBundle, 'lib/kernel/src/theme/themes/summer-night.yaml')];
|
||||
final services = await KernelServices.boot(
|
||||
appDir: await Directory.systemTemp.createTemp('clide_lc_'),
|
||||
bundledThemes: themes,
|
||||
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
|
||||
preloadNamespaces: const [
|
||||
'builtin.welcome',
|
||||
'builtin.ipc-status',
|
||||
'builtin.default-layout',
|
||||
],
|
||||
daemonClientFactory: (log, events, _, __) => FakeDaemonClient(log: log, events: events),
|
||||
preloadNamespaces: const ['builtin.welcome', 'builtin.ipc-status', 'builtin.default-layout'],
|
||||
daemonClientFactory: (log, events, _, _) => FakeDaemonClient(log: log, events: events),
|
||||
autoStartDaemonClient: false,
|
||||
);
|
||||
services.extensions
|
||||
|
||||
@@ -16,22 +16,13 @@ void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('theme.pick command opens modal; selecting dismisses it', (tester) async {
|
||||
final themes = [
|
||||
await const ThemeLoader().fromAsset(
|
||||
rootBundle,
|
||||
'lib/kernel/src/theme/themes/summer-night.yaml',
|
||||
),
|
||||
];
|
||||
final themes = [await const ThemeLoader().fromAsset(rootBundle, 'lib/kernel/src/theme/themes/summer-night.yaml')];
|
||||
final services = await KernelServices.boot(
|
||||
appDir: await Directory.systemTemp.createTemp('clide_theme_intg_'),
|
||||
bundledThemes: themes,
|
||||
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
|
||||
preloadNamespaces: const [
|
||||
'builtin.welcome',
|
||||
'builtin.theme-picker',
|
||||
'builtin.default-layout',
|
||||
],
|
||||
daemonClientFactory: (log, events, _, __) => FakeDaemonClient(log: log, events: events),
|
||||
preloadNamespaces: const ['builtin.welcome', 'builtin.theme-picker', 'builtin.default-layout'],
|
||||
daemonClientFactory: (log, events, _, _) => FakeDaemonClient(log: log, events: events),
|
||||
autoStartDaemonClient: false,
|
||||
);
|
||||
services.extensions
|
||||
|
||||
+56
-110
@@ -38,10 +38,7 @@ class _AppRoot extends StatelessWidget {
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: clideName,
|
||||
color: const Color(0xFF000000),
|
||||
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) => PageRouteBuilder<T>(
|
||||
settings: settings,
|
||||
pageBuilder: (ctx, _, __) => builder(ctx),
|
||||
),
|
||||
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) => PageRouteBuilder<T>(settings: settings, pageBuilder: (ctx, _, _) => builder(ctx)),
|
||||
home: _RootShell(services: services),
|
||||
);
|
||||
}
|
||||
@@ -240,35 +237,19 @@ class RootLayout extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
if (sidebarVisible && sidebarCollapsed)
|
||||
ClideSpine(
|
||||
label: _sidebarSpineLabel(kernel),
|
||||
side: SpineSide.left,
|
||||
onExpand: () => a.setCollapsed(Slots.sidebar, false),
|
||||
)
|
||||
ClideSpine(label: _sidebarSpineLabel(kernel), side: SpineSide.left, onExpand: () => a.setCollapsed(Slots.sidebar, false))
|
||||
else if (sidebarVisible) ...[
|
||||
SizedBox(
|
||||
width: sidebarSize,
|
||||
child: SlotHost(slot: Slots.sidebar),
|
||||
),
|
||||
DragResizeHandle(
|
||||
arrangement: a,
|
||||
slot: Slots.sidebar,
|
||||
axis: Axis.horizontal,
|
||||
),
|
||||
DragResizeHandle(arrangement: a, slot: Slots.sidebar, axis: Axis.horizontal),
|
||||
],
|
||||
const Expanded(child: SlotHost(slot: Slots.workspace)),
|
||||
if (contextVisible && contextCollapsed)
|
||||
ClideSpine(
|
||||
label: 'context',
|
||||
side: SpineSide.right,
|
||||
onExpand: () => a.setCollapsed(Slots.contextPanel, false),
|
||||
)
|
||||
ClideSpine(label: 'context', side: SpineSide.right, onExpand: () => a.setCollapsed(Slots.contextPanel, false))
|
||||
else if (contextVisible) ...[
|
||||
DragResizeHandle(
|
||||
arrangement: a,
|
||||
slot: Slots.contextPanel,
|
||||
axis: Axis.horizontal,
|
||||
),
|
||||
DragResizeHandle(arrangement: a, slot: Slots.contextPanel, axis: Axis.horizontal),
|
||||
SizedBox(
|
||||
width: contextSize,
|
||||
child: SlotHost(slot: Slots.contextPanel),
|
||||
@@ -281,14 +262,18 @@ class RootLayout extends StatelessWidget {
|
||||
SizedBox(
|
||||
height: dockHeight,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder))),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder)),
|
||||
),
|
||||
child: SlotHost(slot: Slots.dock),
|
||||
),
|
||||
),
|
||||
if (statusVisible)
|
||||
Container(
|
||||
height: statusHeight,
|
||||
decoration: BoxDecoration(border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder))),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -296,12 +281,18 @@ class RootLayout extends StatelessWidget {
|
||||
// children) so they never shift when a pane collapses (T-294).
|
||||
StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: sidebarCollapsed, visible: sidebarVisible),
|
||||
if (sidebarVisible && !sidebarCollapsed)
|
||||
SizedBox(width: sidebarSize, child: _BottomRail(slot: Slots.sidebar))
|
||||
SizedBox(
|
||||
width: sidebarSize,
|
||||
child: _BottomRail(slot: Slots.sidebar),
|
||||
)
|
||||
else if (sidebarVisible && sidebarCollapsed)
|
||||
const SizedBox(width: ClideSpine.width),
|
||||
const Expanded(child: StatusbarHost()),
|
||||
if (contextVisible && !contextCollapsed)
|
||||
SizedBox(width: contextSize, child: _BottomRail(slot: Slots.contextPanel))
|
||||
SizedBox(
|
||||
width: contextSize,
|
||||
child: _BottomRail(slot: Slots.contextPanel),
|
||||
)
|
||||
else if (contextVisible && contextCollapsed)
|
||||
const SizedBox(width: ClideSpine.width),
|
||||
StatusbarCollapseToggle(slot: Slots.contextPanel, collapsed: contextCollapsed, visible: contextVisible),
|
||||
@@ -392,11 +383,13 @@ class _RightHatContent extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
if (kIsWeb) return const SizedBox.shrink();
|
||||
if (!kIsWeb && Platform.isMacOS) return const SizedBox.shrink();
|
||||
return Row(children: [
|
||||
return Row(
|
||||
children: [
|
||||
_WinBtn(icon: const PhosphorIconPainter(0xe32a), onTap: wc.minimize, tokens: tokens),
|
||||
_WinBtn(icon: const PhosphorIconPainter(0xe45e), onTap: wc.toggleMaximize, tokens: tokens),
|
||||
_WinBtn(icon: PhosphorIcons.byName('x'), onTap: wc.close, tokens: tokens, isClose: true),
|
||||
]);
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,11 +410,7 @@ class _WinBtn extends StatelessWidget {
|
||||
height: hatHeight,
|
||||
color: hovered ? hoverBg : null,
|
||||
alignment: Alignment.center,
|
||||
child: ClideIcon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: hovered && isClose ? tokens.windowControlCloseHoverForeground : tokens.chromeForeground,
|
||||
),
|
||||
child: ClideIcon(icon, size: 14, color: hovered && isClose ? tokens.windowControlCloseHoverForeground : tokens.chromeForeground),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -544,26 +533,29 @@ class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> {
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (ctx, i) => _RecentProjectRow(
|
||||
project: filtered[i],
|
||||
tokens: tokens,
|
||||
onTap: () => _openProject(filtered[i].path),
|
||||
),
|
||||
itemBuilder: (ctx, i) => _RecentProjectRow(project: filtered[i], tokens: tokens, onTap: () => _openProject(filtered[i].path)),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('No recent projects.', muted: true)),
|
||||
Container(
|
||||
decoration: BoxDecoration(border: Border(top: BorderSide(color: tokens.dividerColor))),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: tokens.dividerColor)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_ActionRow(
|
||||
label: 'Open Local Project',
|
||||
shortcut: Platform.isMacOS ? '⌘O' : 'Ctrl+O',
|
||||
tokens: tokens,
|
||||
onTap: () => _runFileCommand('file.openFolder')),
|
||||
onTap: () => _runFileCommand('file.openFolder'),
|
||||
),
|
||||
_ActionRow(
|
||||
label: 'New Window', shortcut: Platform.isMacOS ? '⌘⇧N' : 'Ctrl+Shift+N', tokens: tokens, onTap: () => _runFileCommand('file.newWindow')),
|
||||
label: 'New Window',
|
||||
shortcut: Platform.isMacOS ? '⌘⇧N' : 'Ctrl+Shift+N',
|
||||
tokens: tokens,
|
||||
onTap: () => _runFileCommand('file.newWindow'),
|
||||
),
|
||||
if (widget.kernel.project.isOpen)
|
||||
_ActionRow(label: 'Close Project', shortcut: '', tokens: tokens, onTap: () => _runFileCommand('file.closeWorkspace')),
|
||||
],
|
||||
@@ -604,8 +596,14 @@ class _RecentProjectRow extends StatelessWidget {
|
||||
// Elide a long path instead of overflowing the row
|
||||
// (matches the welcome recents row; T-160 discipline).
|
||||
Flexible(
|
||||
child: ClideText(project.relativePath,
|
||||
muted: true, fontSize: 12, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
child: ClideText(
|
||||
project.relativePath,
|
||||
muted: true,
|
||||
fontSize: 12,
|
||||
fontFamily: clideMonoFamily,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
ClideText(' · ', muted: true, fontSize: 12),
|
||||
ClideIcon(PhosphorIcons.byName('git-branch'), size: 10, color: tokens.globalTextMuted),
|
||||
@@ -706,10 +704,7 @@ class _SlotHostState extends State<SlotHost> {
|
||||
return Container(color: tokens.panelBackground);
|
||||
}
|
||||
final activeId = kernel.panels.activeTabIn(widget.slot) ?? tabs.first.id;
|
||||
final active = tabs.firstWhere(
|
||||
(t) => t.id == activeId,
|
||||
orElse: () => tabs.first,
|
||||
);
|
||||
final active = tabs.firstWhere((t) => t.id == activeId, orElse: () => tabs.first);
|
||||
return _SlotBody(slot: widget.slot, tabs: tabs, active: active, activeId: activeId);
|
||||
},
|
||||
),
|
||||
@@ -731,21 +726,11 @@ class _SlotBody extends StatelessWidget {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
|
||||
if (slot == Slots.sidebar) {
|
||||
return _SidebarSlot(
|
||||
tabs: tabs,
|
||||
active: active,
|
||||
activeId: activeId,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
);
|
||||
return _SidebarSlot(tabs: tabs, active: active, activeId: activeId, onSelect: (id) => kernel.panels.activateTab(slot, id));
|
||||
}
|
||||
|
||||
if (slot == Slots.contextPanel) {
|
||||
return _ContextSlot(
|
||||
tabs: tabs,
|
||||
active: active,
|
||||
activeId: activeId,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
);
|
||||
return _ContextSlot(tabs: tabs, active: active, activeId: activeId, onSelect: (id) => kernel.panels.activateTab(slot, id));
|
||||
}
|
||||
|
||||
if (slot == Slots.workspace) {
|
||||
@@ -757,9 +742,7 @@ class _SlotBody extends StatelessWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
ClideTabBar(
|
||||
items: [
|
||||
for (final t in tabs) ClideTabItem(id: t.id, title: _resolveTitle(context, t)),
|
||||
],
|
||||
items: [for (final t in tabs) ClideTabItem(id: t.id, title: _resolveTitle(context, t))],
|
||||
activeId: active.id,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
),
|
||||
@@ -774,21 +757,12 @@ class _SlotBody extends StatelessWidget {
|
||||
final key = t.titleKey;
|
||||
final ns = t.i18nNamespace;
|
||||
if (key == null || ns == null) return t.title;
|
||||
return ClideKernel.of(context).i18n.string(
|
||||
key,
|
||||
namespace: ns,
|
||||
placeholder: t.title,
|
||||
);
|
||||
return ClideKernel.of(context).i18n.string(key, namespace: ns, placeholder: t.title);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarSlot extends StatelessWidget {
|
||||
const _SidebarSlot({
|
||||
required this.tabs,
|
||||
required this.active,
|
||||
required this.activeId,
|
||||
required this.onSelect,
|
||||
});
|
||||
const _SidebarSlot({required this.tabs, required this.active, required this.activeId, required this.onSelect});
|
||||
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
@@ -863,10 +837,7 @@ class _WorkspaceSlot extends StatelessWidget {
|
||||
SizedBox(
|
||||
height: topHeight,
|
||||
child: reveal != null
|
||||
? _RevealedTab(
|
||||
tab: reveal,
|
||||
onClose: () => kernel.panels.activateTab(Slots.workspace, _claudeTabId),
|
||||
)
|
||||
? _RevealedTab(tab: reveal, onClose: () => kernel.panels.activateTab(Slots.workspace, _claudeTabId))
|
||||
: topTab.build(ctx),
|
||||
),
|
||||
_EditorDragHandle(arrangement: kernel.arrangement, totalHeight: totalHeight),
|
||||
@@ -903,12 +874,7 @@ class _RevealedTab extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
_SlotBody._resolveTitle(context, tab),
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.panelHeaderForeground,
|
||||
maxLines: 1,
|
||||
),
|
||||
child: ClideText(_SlotBody._resolveTitle(context, tab), fontSize: clideFontCaption, color: tokens.panelHeaderForeground, maxLines: 1),
|
||||
),
|
||||
Semantics(
|
||||
button: true,
|
||||
@@ -918,7 +884,7 @@ class _RevealedTab extends StatelessWidget {
|
||||
child: ClideTappable(
|
||||
onTap: onClose,
|
||||
tooltip: 'Close',
|
||||
builder: (_, hovered, __) => Padding(
|
||||
builder: (_, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: ClideIcon(PhosphorIcons.byName('x'), size: 12, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
@@ -1027,12 +993,7 @@ class _EditorBumpIntent extends Intent {
|
||||
}
|
||||
|
||||
class _ContextSlot extends StatelessWidget {
|
||||
const _ContextSlot({
|
||||
required this.tabs,
|
||||
required this.active,
|
||||
required this.activeId,
|
||||
required this.onSelect,
|
||||
});
|
||||
const _ContextSlot({required this.tabs, required this.active, required this.activeId, required this.onSelect});
|
||||
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
@@ -1042,12 +1003,7 @@ class _ContextSlot extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
color: tokens.panelBackground,
|
||||
alignment: Alignment.topLeft,
|
||||
padding: const EdgeInsets.only(right: 2),
|
||||
child: active.build(context),
|
||||
);
|
||||
return Container(color: tokens.panelBackground, alignment: Alignment.topLeft, padding: const EdgeInsets.only(right: 2), child: active.build(context));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1068,14 +1024,7 @@ class _BottomRail extends StatelessWidget {
|
||||
return Container(
|
||||
color: tokens.chromeBackground,
|
||||
child: ClideIconRail(
|
||||
items: [
|
||||
for (final t in tabs)
|
||||
ClideIconRailItem(
|
||||
id: t.id,
|
||||
icon: _iconFor(slot, t),
|
||||
tooltip: _SlotBody._resolveTitle(ctx, t),
|
||||
),
|
||||
],
|
||||
items: [for (final t in tabs) ClideIconRailItem(id: t.id, icon: _iconFor(slot, t), tooltip: _SlotBody._resolveTitle(ctx, t))],
|
||||
activeId: activeId,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
),
|
||||
@@ -1209,10 +1158,7 @@ class _WelcomeOverlay extends StatelessWidget {
|
||||
builder: (ctx, _) {
|
||||
if (kernel.project.isOpen) return const SizedBox.shrink();
|
||||
final tokens = ClideTheme.of(ctx).surface;
|
||||
return ColoredBox(
|
||||
color: tokens.globalBackground,
|
||||
child: const WelcomeView(),
|
||||
);
|
||||
return ColoredBox(color: tokens.globalBackground, child: const WelcomeView());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ const List<String> clideAllowedToolsArgs = ['--allowedTools', clideBashAllowRule
|
||||
/// the orient-snapshot (`clide status`) and live pane/editor reflection
|
||||
/// arrive with Epic C (T-218..T-221) and are deliberately left out so the
|
||||
/// note never points the agent at a command that returns nothing yet.
|
||||
String clideContextNote(String workspaceRoot) => 'You are running inside clide, an IDE that is hosting this session. clide exposes its IDE '
|
||||
String clideContextNote(String workspaceRoot) =>
|
||||
'You are running inside clide, an IDE that is hosting this session. clide exposes its IDE '
|
||||
'surface as a `clide` command on your PATH; drive it with `clide <subsystem> <verb>`. '
|
||||
'Subsystems that respond today: `files` (workspace tree — `clide files root`, `files list`), '
|
||||
'`editor` (`clide editor open <path>`, `editor active`), `git` (`clide git status`), '
|
||||
@@ -61,16 +62,8 @@ String clideContextNote(String workspaceRoot) => 'You are running inside clide,
|
||||
/// * `CLIDE_WORKSPACE` — the workspace root.
|
||||
/// * `PATH` — prepended with [clideCliDir] when it is non-null (i.e. `clide`
|
||||
/// is not already resolvable), otherwise left untouched.
|
||||
Map<String, String> agentEnvDelta({
|
||||
required String workspaceRoot,
|
||||
required String socketPath,
|
||||
required String? currentPath,
|
||||
required String? clideCliDir,
|
||||
}) {
|
||||
final delta = <String, String>{
|
||||
'CLIDE_SOCK': socketPath,
|
||||
'CLIDE_WORKSPACE': workspaceRoot,
|
||||
};
|
||||
Map<String, String> agentEnvDelta({required String workspaceRoot, required String socketPath, required String? currentPath, required String? clideCliDir}) {
|
||||
final delta = <String, String>{'CLIDE_SOCK': socketPath, 'CLIDE_WORKSPACE': workspaceRoot};
|
||||
if (clideCliDir != null && clideCliDir.isNotEmpty) {
|
||||
delta['PATH'] = (currentPath == null || currentPath.isEmpty) ? clideCliDir : '$clideCliDir:$currentPath';
|
||||
}
|
||||
@@ -85,11 +78,7 @@ Map<String, String> agentEnvDelta({
|
||||
///
|
||||
/// [candidateDirs] is an ordered fallback list; [isExecutableFile] probes
|
||||
/// `<dir>/clide`. Both are injected so the resolver is pure and testable.
|
||||
String? resolveClideCliDir({
|
||||
required String? currentPath,
|
||||
required List<String> candidateDirs,
|
||||
required bool Function(String path) isExecutableFile,
|
||||
}) {
|
||||
String? resolveClideCliDir({required String? currentPath, required List<String> candidateDirs, required bool Function(String path) isExecutableFile}) {
|
||||
if (currentPath != null) {
|
||||
for (final dir in currentPath.split(':')) {
|
||||
if (dir.isNotEmpty && isExecutableFile('$dir/clide')) return null;
|
||||
@@ -142,21 +131,9 @@ AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base})
|
||||
'$workspaceRoot/native/${nativeClideDirName()}',
|
||||
File(Platform.resolvedExecutable).parent.path,
|
||||
];
|
||||
final cliDir = resolveClideCliDir(
|
||||
currentPath: currentPath,
|
||||
candidateDirs: candidates,
|
||||
isExecutableFile: _isExecutableFile,
|
||||
);
|
||||
final delta = agentEnvDelta(
|
||||
workspaceRoot: workspaceRoot,
|
||||
socketPath: workspaceSocketPath(workspaceRoot),
|
||||
currentPath: currentPath,
|
||||
clideCliDir: cliDir,
|
||||
);
|
||||
return AgentBootstrap(
|
||||
envDelta: {...?base, ...delta},
|
||||
extraArgs: ['--allowedTools', clideBashAllowRule],
|
||||
);
|
||||
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
|
||||
final delta = agentEnvDelta(workspaceRoot: workspaceRoot, socketPath: workspaceSocketPath(workspaceRoot), currentPath: currentPath, clideCliDir: cliDir);
|
||||
return AgentBootstrap(envDelta: {...?base, ...delta}, extraArgs: ['--allowedTools', clideBashAllowRule]);
|
||||
}
|
||||
|
||||
bool _isExecutableFile(String path) {
|
||||
|
||||
@@ -35,20 +35,12 @@ class ClaudeBanner extends StatelessWidget {
|
||||
children: [
|
||||
const ClideSvgView.asset('assets/logo/logo.svg', width: 60, height: 60),
|
||||
const SizedBox(height: 18),
|
||||
ClideText(
|
||||
'Claude',
|
||||
fontSize: clideFontDialogTitle,
|
||||
color: claudeAccent,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
ClideText('Claude', fontSize: clideFontDialogTitle, color: claudeAccent, fontWeight: FontWeight.w500),
|
||||
const SizedBox(height: 2),
|
||||
ClideText(role, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily),
|
||||
const SizedBox(height: 16),
|
||||
if (ws != null) ClideText(ws, fontSize: clideFontCaption, muted: true),
|
||||
if (statusLine != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
ClideText(statusLine!, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily),
|
||||
],
|
||||
if (statusLine != null) ...[const SizedBox(height: 2), ClideText(statusLine!, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily)],
|
||||
const SizedBox(height: 16),
|
||||
const ClideText('Warming up — your conversation will appear here.', fontSize: clideFontSmall, muted: true),
|
||||
],
|
||||
|
||||
@@ -352,7 +352,12 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
}
|
||||
}
|
||||
|
||||
void _applyHistory(String text) => _applyValue(TextEditingValue(text: text, selection: TextSelection.collapsed(offset: text.length)));
|
||||
void _applyHistory(String text) => _applyValue(
|
||||
TextEditingValue(
|
||||
text: text,
|
||||
selection: TextSelection.collapsed(offset: text.length),
|
||||
),
|
||||
);
|
||||
|
||||
/// Set the controller without it being treated as a user edit (so the
|
||||
/// preview doesn't overwrite the persisted draft or exit navigation).
|
||||
@@ -368,10 +373,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
final tokens = _attachments.map((a) => a.pathToken);
|
||||
if (text.trim().isEmpty && _attachments.isEmpty) return;
|
||||
// Typed text first, then the attachment @path references.
|
||||
final message = [
|
||||
if (text.trim().isNotEmpty) text,
|
||||
...tokens,
|
||||
].join(' ');
|
||||
final message = [if (text.trim().isNotEmpty) text, ...tokens].join(' ');
|
||||
widget.onSubmit(message);
|
||||
_controller.clear();
|
||||
setState(() => _attachments.clear());
|
||||
@@ -454,11 +456,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
if (_attachments.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [for (final a in _attachments) _chip(theme, a)],
|
||||
),
|
||||
child: Wrap(spacing: 6, runSpacing: 6, children: [for (final a in _attachments) _chip(theme, a)]),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
@@ -489,13 +487,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
if (!hasText)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: ClideText(widget.hint, muted: true, fontSize: clideFontBody),
|
||||
),
|
||||
if (!hasText) Positioned(left: 0, top: 0, right: 0, child: ClideText(widget.hint, muted: true, fontSize: clideFontBody)),
|
||||
EditableText(
|
||||
controller: _controller,
|
||||
focusNode: _focus,
|
||||
@@ -516,10 +508,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
const SizedBox(width: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: PermissionModeControl(
|
||||
mode: widget.permissionMode!,
|
||||
onSelect: widget.onSetPermissionMode!,
|
||||
),
|
||||
child: PermissionModeControl(mode: widget.permissionMode!, onSelect: widget.onSetPermissionMode!),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -548,12 +537,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
_chipLeading(theme, a),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: ClideText(
|
||||
a.fileName,
|
||||
fontSize: clideFontSmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
child: ClideText(a.fileName, fontSize: clideFontSmall, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Semantics(
|
||||
|
||||
@@ -94,13 +94,7 @@ class ClaudePermissions {
|
||||
/// + plugin + MCP), the skill names, the default model and permission mode.
|
||||
@immutable
|
||||
class ClaudeProbe {
|
||||
const ClaudeProbe({
|
||||
required this.version,
|
||||
required this.slashCommands,
|
||||
required this.skills,
|
||||
this.model,
|
||||
this.permissionMode,
|
||||
});
|
||||
const ClaudeProbe({required this.version, required this.slashCommands, required this.skills, this.model, this.permissionMode});
|
||||
|
||||
final String version;
|
||||
final List<String> slashCommands;
|
||||
@@ -400,10 +394,7 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
/// Global first so that local entries, added later, win on collisions.
|
||||
List<(ConfigScope, Directory)> _scopeDirs() {
|
||||
final pd = _projectDir;
|
||||
return [
|
||||
(ConfigScope.global, _globalDir),
|
||||
if (pd != null) (ConfigScope.local, Directory('${pd.path}/.claude')),
|
||||
];
|
||||
return [(ConfigScope.global, _globalDir), if (pd != null) (ConfigScope.local, Directory('${pd.path}/.claude'))];
|
||||
}
|
||||
|
||||
Future<List<ClaudeSkill>> _loadSkills(Directory scopeDir, ConfigScope scope) async {
|
||||
@@ -415,12 +406,7 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
final manifest = File('${entry.path}/SKILL.md');
|
||||
if (!await manifest.exists()) continue;
|
||||
final fm = _parseFrontmatter(await manifest.readAsString());
|
||||
out.add(ClaudeSkill(
|
||||
name: fm.name ?? _basename(entry.path),
|
||||
description: fm.description,
|
||||
scope: scope,
|
||||
path: manifest.path,
|
||||
));
|
||||
out.add(ClaudeSkill(name: fm.name ?? _basename(entry.path), description: fm.description, scope: scope, path: manifest.path));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -432,11 +418,7 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
await for (final entry in dir.list()) {
|
||||
if (entry is! File || !entry.path.endsWith('.md')) continue;
|
||||
final base = _basename(entry.path);
|
||||
out.add(ClaudeCommand(
|
||||
name: base.substring(0, base.length - 3),
|
||||
scope: scope,
|
||||
path: entry.path,
|
||||
));
|
||||
out.add(ClaudeCommand(name: base.substring(0, base.length - 3), scope: scope, path: entry.path));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -449,11 +431,7 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
await for (final entry in dir.list()) {
|
||||
if (entry is! File || !entry.path.endsWith('.md')) continue;
|
||||
final base = _basename(entry.path);
|
||||
out.add(ClaudeAgent(
|
||||
name: base.substring(0, base.length - 3),
|
||||
scope: scope,
|
||||
path: entry.path,
|
||||
));
|
||||
out.add(ClaudeAgent(name: base.substring(0, base.length - 3), scope: scope, path: entry.path));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -472,11 +450,7 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
ClaudePermissions _permissionsOf(Map<String, Object?> settings) {
|
||||
final p = settings['permissions'];
|
||||
if (p is! Map) return const ClaudePermissions();
|
||||
return ClaudePermissions(
|
||||
allow: _stringList(p['allow']),
|
||||
deny: _stringList(p['deny']),
|
||||
ask: _stringList(p['ask']),
|
||||
);
|
||||
return ClaudePermissions(allow: _stringList(p['allow']), deny: _stringList(p['deny']), ask: _stringList(p['ask']));
|
||||
}
|
||||
|
||||
// ---- Watching -----------------------------------------------------------
|
||||
@@ -573,9 +547,7 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
/// Claude's `mcpServers` is a `Map<String, {...config}>` keyed on server name.
|
||||
static List<ClaudeMcpServer> _parseMcpServers(Object? raw) {
|
||||
if (raw is! Map) return const [];
|
||||
return [
|
||||
for (final key in raw.keys) ClaudeMcpServer(name: '$key'),
|
||||
];
|
||||
return [for (final key in raw.keys) ClaudeMcpServer(name: '$key')];
|
||||
}
|
||||
|
||||
static List<T> _dedupeByName<T>(List<T> all, String Function(T) nameOf) {
|
||||
@@ -608,14 +580,7 @@ Future<String?> _defaultVersionRunner() async {
|
||||
|
||||
Future<String?> _defaultInitProbe() async {
|
||||
try {
|
||||
final r = await Process.run('claude', [
|
||||
'-p',
|
||||
'.',
|
||||
'--no-session-persistence',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
]);
|
||||
final r = await Process.run('claude', ['-p', '.', '--no-session-persistence', '--output-format', 'stream-json', '--verbose']);
|
||||
return r.stdout as String?;
|
||||
} catch (_) {
|
||||
return null;
|
||||
|
||||
@@ -218,14 +218,18 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
final managed = orch.byMemberName(memberName);
|
||||
if (managed == null) return;
|
||||
final forkId = 'fork:$memberName-${DateTime.now().millisecondsSinceEpoch}';
|
||||
unawaited(orch.spawn(SpawnSpec(
|
||||
unawaited(
|
||||
orch.spawn(
|
||||
SpawnSpec(
|
||||
id: forkId,
|
||||
role: 'fork of $memberName',
|
||||
// sessionId is a placeholder; real claude session id arrives via init.
|
||||
sessionId: forkId,
|
||||
cwd: managed.cwd,
|
||||
forkSourceSessionId: managed.sessionId,
|
||||
)));
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refreshStats() async {
|
||||
@@ -276,11 +280,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
_MetaRow('sessions', '${latest.sessionCount}'),
|
||||
_MetaRow('tool calls', '${latest.toolCallCount}'),
|
||||
]),
|
||||
if (latest != null)
|
||||
_MetaSection('LIFETIME', [
|
||||
_MetaRow('messages', '${_stats.lifetimeMessages}'),
|
||||
_MetaRow('sessions', '${_stats.lifetimeSessions}'),
|
||||
]),
|
||||
if (latest != null) _MetaSection('LIFETIME', [_MetaRow('messages', '${_stats.lifetimeMessages}'), _MetaRow('sessions', '${_stats.lifetimeSessions}')]),
|
||||
..._runtimeSection(tokens),
|
||||
];
|
||||
if (sections.isEmpty) {
|
||||
@@ -357,17 +357,10 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
final broker = _orchestrator?.broker;
|
||||
if (chatModel != null && broker != null) {
|
||||
children.add(const SizedBox(height: 12));
|
||||
children.add(TeamChatSidebar(
|
||||
model: chatModel,
|
||||
broker: broker,
|
||||
onPopOut: _openChatPane,
|
||||
));
|
||||
children.add(TeamChatSidebar(model: chatModel, broker: broker, onPopOut: _openChatPane));
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: children,
|
||||
);
|
||||
return ListView(padding: const EdgeInsets.all(12), children: children);
|
||||
}
|
||||
|
||||
Widget _taskSection(SurfaceTokens tokens) {
|
||||
@@ -420,18 +413,11 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
// Footer hint.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: ClideText(
|
||||
'expand a list to see all · click a skill/agent/command → opens its .md',
|
||||
muted: true,
|
||||
fontSize: clideFontSmall,
|
||||
),
|
||||
child: ClideText('expand a list to see all · click a skill/agent/command → opens its .md', muted: true, fontSize: clideFontSmall),
|
||||
),
|
||||
];
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: children,
|
||||
);
|
||||
return ListView(padding: const EdgeInsets.all(12), children: children);
|
||||
}
|
||||
|
||||
/// One key→value row in the pinned SETTINGS table.
|
||||
@@ -492,17 +478,11 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
List<Widget> _configSectionChildren(SurfaceTokens tokens, ClaudeConfig config, _ConfigSection section) {
|
||||
switch (section) {
|
||||
case _ConfigSection.skills:
|
||||
return [
|
||||
for (final skill in config.skills) _configFileRow(tokens, skill.name, skill.path),
|
||||
];
|
||||
return [for (final skill in config.skills) _configFileRow(tokens, skill.name, skill.path)];
|
||||
case _ConfigSection.agents:
|
||||
return [
|
||||
for (final agent in config.agents) _configFileRow(tokens, agent.name, agent.path),
|
||||
];
|
||||
return [for (final agent in config.agents) _configFileRow(tokens, agent.name, agent.path)];
|
||||
case _ConfigSection.commands:
|
||||
return [
|
||||
for (final cmd in config.commands) _configFileRow(tokens, cmd.name, cmd.path),
|
||||
];
|
||||
return [for (final cmd in config.commands) _configFileRow(tokens, cmd.name, cmd.path)];
|
||||
case _ConfigSection.hooks:
|
||||
return [
|
||||
for (final hook in config.hooks)
|
||||
@@ -540,11 +520,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
Widget _configFileRow(SurfaceTokens tokens, String name, String? path) {
|
||||
final row = Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||
child: ClideText(
|
||||
name,
|
||||
fontSize: clideFontSmall,
|
||||
color: path != null ? tokens.globalFocus : tokens.globalForeground,
|
||||
),
|
||||
child: ClideText(name, fontSize: clideFontSmall, color: path != null ? tokens.globalFocus : tokens.globalForeground),
|
||||
);
|
||||
if (path == null) return row;
|
||||
void openMarkdown() => ClideKernel.of(context).messages.publish('builtin.markdown', 'selection', {'path': path});
|
||||
@@ -558,11 +534,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
onTap: openMarkdown,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||
child: ClideText(
|
||||
name,
|
||||
fontSize: clideFontSmall,
|
||||
color: hovered ? tokens.globalForeground : tokens.globalFocus,
|
||||
),
|
||||
child: ClideText(name, fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalFocus),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -587,11 +559,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
_ConfigPermKind.deny => kindDeny,
|
||||
};
|
||||
|
||||
final groups = [
|
||||
(_ConfigPermKind.allow, perms.allow),
|
||||
(_ConfigPermKind.ask, perms.ask),
|
||||
(_ConfigPermKind.deny, perms.deny),
|
||||
];
|
||||
final groups = [(_ConfigPermKind.allow, perms.allow), (_ConfigPermKind.ask, perms.ask), (_ConfigPermKind.deny, perms.deny)];
|
||||
|
||||
final rows = <Widget>[];
|
||||
for (final (kind, rules) in groups) {
|
||||
@@ -639,12 +607,15 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
final children = <Widget>[];
|
||||
for (var i = 0; i < sections.length; i++) {
|
||||
final s = sections[i];
|
||||
children.add(Padding(
|
||||
children.add(
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: i == 0 ? 0 : 16, bottom: 6),
|
||||
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
));
|
||||
),
|
||||
);
|
||||
for (final r in s.rows) {
|
||||
children.add(Padding(
|
||||
children.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: _rowPitch),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -654,22 +625,16 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
child: ClideText(r.label, muted: true, fontSize: clideFontSmall),
|
||||
),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
r.value,
|
||||
fontSize: clideFontSmall,
|
||||
color: r.valueColor ?? tokens.globalForeground,
|
||||
),
|
||||
child: ClideText(r.value, fontSize: clideFontSmall, color: r.valueColor ?? tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return ListView(padding: const EdgeInsets.all(12), children: children);
|
||||
}
|
||||
}
|
||||
|
||||
// T-183: accordion sections for the Config tab.
|
||||
@@ -788,7 +753,11 @@ class _AgentRosterRowState extends State<_AgentRosterRow> {
|
||||
// Color dot
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 3),
|
||||
child: Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Name + status
|
||||
@@ -840,11 +809,7 @@ class _AgentRosterRowState extends State<_AgentRosterRow> {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
'Enable bypassPermissions? All tool calls will be auto-allowed.',
|
||||
fontSize: clideFontSmall,
|
||||
color: tokens.globalTextMuted,
|
||||
),
|
||||
child: ClideText('Enable bypassPermissions? All tool calls will be auto-allowed.', fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Confirm
|
||||
@@ -889,14 +854,7 @@ class _AgentRosterRowState extends State<_AgentRosterRow> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControls(
|
||||
BuildContext context,
|
||||
SurfaceTokens tokens,
|
||||
ManagedSession managed,
|
||||
bool isVisible,
|
||||
bool isMuted,
|
||||
bool isInjecting,
|
||||
) {
|
||||
Widget _buildControls(BuildContext context, SurfaceTokens tokens, ManagedSession managed, bool isVisible, bool isMuted, bool isInjecting) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -979,7 +937,7 @@ String _permissionModeBadge(String mode) => switch (mode) {
|
||||
'plan' => 'P',
|
||||
'bypassPermissions' => 'B',
|
||||
_ => 'D', // default
|
||||
};
|
||||
};
|
||||
|
||||
/// Clickable permission-mode badge shown in each roster row (T-181).
|
||||
///
|
||||
@@ -990,12 +948,7 @@ String _permissionModeBadge(String mode) => switch (mode) {
|
||||
/// It is a custom painted label (no Material), consistent with the rendering
|
||||
/// stack rules (D-7, CLAUDE.md guardrails).
|
||||
class _PermissionModeBadge extends StatelessWidget {
|
||||
const _PermissionModeBadge({
|
||||
required this.mode,
|
||||
required this.tokens,
|
||||
required this.onCycle,
|
||||
required this.onBypass,
|
||||
});
|
||||
const _PermissionModeBadge({required this.mode, required this.tokens, required this.onCycle, required this.onBypass});
|
||||
|
||||
final String mode;
|
||||
final SurfaceTokens tokens;
|
||||
@@ -1012,7 +965,8 @@ class _PermissionModeBadge extends StatelessWidget {
|
||||
final isBypass = mode == 'bypassPermissions';
|
||||
final badgeColor = isBypass ? const Color(0xFFF06C6F) : tokens.globalFocus;
|
||||
|
||||
final tooltip = 'Permission mode: ${permissionModeLabel(mode)}. '
|
||||
final tooltip =
|
||||
'Permission mode: ${permissionModeLabel(mode)}. '
|
||||
'Click to cycle default/acceptEdits/plan; Shift-click for bypassPermissions.';
|
||||
|
||||
return Padding(
|
||||
@@ -1046,11 +1000,7 @@ class _PermissionModeBadge extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
border: Border.all(color: badgeColor.withAlpha(hovered ? 180 : 100), width: 1),
|
||||
),
|
||||
child: ClideText(
|
||||
label,
|
||||
fontSize: 9,
|
||||
color: badgeColor,
|
||||
),
|
||||
child: ClideText(label, fontSize: 9, color: badgeColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1060,12 +1010,7 @@ class _PermissionModeBadge extends StatelessWidget {
|
||||
|
||||
/// A single icon-button used in the roster row controls.
|
||||
class _IconButton extends StatelessWidget {
|
||||
const _IconButton({
|
||||
required this.painter,
|
||||
required this.tooltip,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
const _IconButton({required this.painter, required this.tooltip, required this.color, required this.onTap});
|
||||
|
||||
final ClideIconPainter painter;
|
||||
final String tooltip;
|
||||
@@ -1096,11 +1041,7 @@ class _IconButton extends StatelessWidget {
|
||||
/// Inline text input for injecting a message into a session (T-171).
|
||||
/// Submits on Enter; Cancel is handled by the parent via [_IconButton].
|
||||
class _InjectTextField extends StatelessWidget {
|
||||
const _InjectTextField({
|
||||
required this.controller,
|
||||
required this.tokens,
|
||||
required this.onSubmit,
|
||||
});
|
||||
const _InjectTextField({required this.controller, required this.tokens, required this.onSubmit});
|
||||
|
||||
final TextEditingController controller;
|
||||
final SurfaceTokens tokens;
|
||||
@@ -1119,12 +1060,7 @@ class _InjectTextField extends StatelessWidget {
|
||||
child: EditableText(
|
||||
controller: controller,
|
||||
focusNode: FocusNode(debugLabel: 'inject-${controller.hashCode}')..requestFocus(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: clideFontSmall,
|
||||
color: tokens.globalForeground,
|
||||
height: 1.4,
|
||||
),
|
||||
style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: clideFontSmall, color: tokens.globalForeground, height: 1.4),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
onSubmitted: onSubmit,
|
||||
@@ -1139,11 +1075,7 @@ class _InjectTextField extends StatelessWidget {
|
||||
|
||||
/// One row in the TASKS section: status marker + title + owner + reassign.
|
||||
class _TaskRow extends StatelessWidget {
|
||||
const _TaskRow({
|
||||
required this.task,
|
||||
required this.members,
|
||||
required this.broker,
|
||||
});
|
||||
const _TaskRow({required this.task, required this.members, required this.broker});
|
||||
|
||||
final TeamTask task;
|
||||
final List<TeamMemberJoined> members;
|
||||
@@ -1240,18 +1172,9 @@ class _TabStrip extends StatelessWidget {
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.only(bottom: 3),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: t == current ? tokens.globalFocus : const Color(0x00000000),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: ClideText(
|
||||
_label(t),
|
||||
fontSize: clideFontSmall,
|
||||
color: t == current || hovered ? tokens.globalForeground : tokens.globalTextMuted,
|
||||
border: Border(bottom: BorderSide(color: t == current ? tokens.globalFocus : const Color(0x00000000), width: 2)),
|
||||
),
|
||||
child: ClideText(_label(t), fontSize: clideFontSmall, color: t == current || hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -197,9 +197,12 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
sub.cancel();
|
||||
if (!c.isCompleted) c.complete();
|
||||
});
|
||||
await c.future.timeout(const Duration(seconds: 10), onTimeout: () {
|
||||
await c.future.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
sub.cancel();
|
||||
});
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
}
|
||||
return _spawn();
|
||||
@@ -267,13 +270,9 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
// assigned by `--fork-session` and arrives in the init event (T-172).
|
||||
_sessionId ??= freshSessionId();
|
||||
try {
|
||||
managed = await orch.spawn(SpawnSpec(
|
||||
id: _orchId,
|
||||
role: 'fork ${widget.secondaryIndex}',
|
||||
sessionId: _sessionId!,
|
||||
cwd: repoRoot,
|
||||
forkSourceSessionId: forkSource,
|
||||
));
|
||||
managed = await orch.spawn(
|
||||
SpawnSpec(id: _orchId, role: 'fork ${widget.secondaryIndex}', sessionId: _sessionId!, cwd: repoRoot, forkSourceSessionId: forkSource),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'Could not start fork: $e');
|
||||
return;
|
||||
@@ -291,14 +290,16 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
final resume = await File(transcriptFile).exists();
|
||||
|
||||
try {
|
||||
managed = await orch.spawn(SpawnSpec(
|
||||
managed = await orch.spawn(
|
||||
SpawnSpec(
|
||||
id: _orchId,
|
||||
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
|
||||
sessionId: _sessionId!,
|
||||
cwd: repoRoot,
|
||||
resume: resume,
|
||||
transcriptPath: resume ? transcriptFile : null,
|
||||
));
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'Could not start claude: $e');
|
||||
return;
|
||||
@@ -426,13 +427,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
final dir = Directory(claudeProjectDir(root));
|
||||
final sessions = await listSessions(dir);
|
||||
if (!mounted) return;
|
||||
final picked = await dialog.show<String>(
|
||||
(ctx, dismiss) => SessionPickerDialog(
|
||||
sessions: sessions,
|
||||
onPick: (id) => dismiss(id),
|
||||
onCancel: dismiss,
|
||||
),
|
||||
);
|
||||
final picked = await dialog.show<String>((ctx, dismiss) => SessionPickerDialog(sessions: sessions, onPick: (id) => dismiss(id), onCancel: dismiss));
|
||||
if (picked == null || !mounted) return;
|
||||
setState(() => _statusLine = 'resuming…');
|
||||
await _respawnWithSession(picked);
|
||||
@@ -479,10 +474,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
|
||||
final Widget body;
|
||||
if (_error != null) {
|
||||
body = Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ClideText(_error!, muted: true),
|
||||
);
|
||||
body = Padding(padding: const EdgeInsets.all(16), child: ClideText(_error!, muted: true));
|
||||
} else if (_conversation != null) {
|
||||
// Rebuild conversation + composer zone together on each prompt change so
|
||||
// the view hides a prompted tool-use card the moment its prompt appears
|
||||
@@ -520,7 +512,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
// with the conversation; renders nothing when there are no tasks.
|
||||
ListenableBuilder(
|
||||
listenable: _conversation!,
|
||||
builder: (_, __) => ClaudeTaskDock(tasks: taskListFrom(_conversation!.items)),
|
||||
builder: (_, _) => ClaudeTaskDock(tasks: taskListFrom(_conversation!.items)),
|
||||
),
|
||||
// An open prompt takes the composer's space and hides the text
|
||||
// input until it's answered, so interaction stays out of the
|
||||
@@ -557,22 +549,11 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
body = const Center(child: ClideText('starting…', muted: true));
|
||||
}
|
||||
|
||||
final content = widget.showChrome
|
||||
? ClidePaneChrome(
|
||||
title: title,
|
||||
subtitle: _error ?? _statusLine,
|
||||
child: body,
|
||||
)
|
||||
: body;
|
||||
final content = widget.showChrome ? ClidePaneChrome(title: title, subtitle: _error ?? _statusLine, child: body) : body;
|
||||
|
||||
// Surface this pane's status to the bottom status-bar slot while it's
|
||||
// the focused pane (T-150).
|
||||
return ClidePane(
|
||||
contributionId: widget.contributionId,
|
||||
active: widget.active,
|
||||
statusWidget: _statusWidget(tokens),
|
||||
child: content,
|
||||
);
|
||||
return ClidePane(contributionId: widget.contributionId, active: widget.active, statusWidget: _statusWidget(tokens), child: content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,13 +571,7 @@ class _ModeBadge extends StatelessWidget {
|
||||
return Semantics(
|
||||
label: 'permission mode: ${permissionModeLabel(mode)}',
|
||||
excludeSemantics: true,
|
||||
child: ClideText(
|
||||
permissionModeLabel(mode),
|
||||
fontSize: clideFontSmall,
|
||||
fontFamily: clideMonoFamily,
|
||||
color: permissionModeColor(mode, tokens),
|
||||
maxLines: 1,
|
||||
),
|
||||
child: ClideText(permissionModeLabel(mode), fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: permissionModeColor(mode, tokens), maxLines: 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,11 +85,13 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
|
||||
/// Public entry point used by the `claude.new-secondary` command.
|
||||
void addSecondary() {
|
||||
final index = _nextSecondary++;
|
||||
_controller.add(MultitabEntry<_Session>(
|
||||
_controller.add(
|
||||
MultitabEntry<_Session>(
|
||||
id: 'secondary-$index',
|
||||
title: 'session $index',
|
||||
payload: _Session(isPrimary: false, secondaryIndex: index),
|
||||
));
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Open a new pane as a fork of [sourceClaudeSessionId] (T-172).
|
||||
@@ -99,11 +101,13 @@ class ClaudeSessionHostState extends State<ClaudeSessionHost> {
|
||||
/// without touching the original.
|
||||
void addFork(String sourceClaudeSessionId) {
|
||||
final index = _nextSecondary++;
|
||||
_controller.add(MultitabEntry<_Session>(
|
||||
_controller.add(
|
||||
MultitabEntry<_Session>(
|
||||
id: 'secondary-$index',
|
||||
title: 'fork $index',
|
||||
payload: _Session(isPrimary: false, secondaryIndex: index, forkSourceId: sourceClaudeSessionId),
|
||||
));
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -8,12 +8,7 @@ library;
|
||||
import 'dart:convert';
|
||||
|
||||
class DailyActivity {
|
||||
const DailyActivity({
|
||||
required this.date,
|
||||
required this.messageCount,
|
||||
required this.sessionCount,
|
||||
required this.toolCallCount,
|
||||
});
|
||||
const DailyActivity({required this.date, required this.messageCount, required this.sessionCount, required this.toolCallCount});
|
||||
|
||||
final String date; // "YYYY-MM-DD" (sorts chronologically as a string)
|
||||
final int messageCount;
|
||||
@@ -54,12 +49,14 @@ ClaudeStats parseClaudeStats(String jsonStr) {
|
||||
if (da is List) {
|
||||
for (final e in da) {
|
||||
if (e is! Map) continue;
|
||||
daily.add(DailyActivity(
|
||||
daily.add(
|
||||
DailyActivity(
|
||||
date: '${e['date']}',
|
||||
messageCount: _int(e['messageCount']),
|
||||
sessionCount: _int(e['sessionCount']),
|
||||
toolCallCount: _int(e['toolCallCount']),
|
||||
));
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return ClaudeStats(lastComputed: j['lastComputedDate'] as String?, daily: daily);
|
||||
|
||||
@@ -62,10 +62,7 @@ String nextSafePermissionMode(String current) {
|
||||
if (s.cost != null) '\$${s.cost!.toStringAsFixed(2)}',
|
||||
if (s.rateLimitInfo != null) s.rateLimitInfo!,
|
||||
].join(' · ');
|
||||
return (
|
||||
leading: s.model != null ? shortModelLabel(s.model!) : null,
|
||||
trailing: trailing.isEmpty ? null : trailing,
|
||||
);
|
||||
return (leading: s.model != null ? shortModelLabel(s.model!) : null, trailing: trailing.isEmpty ? null : trailing);
|
||||
}
|
||||
|
||||
/// Friendly label for Claude's permission modes.
|
||||
|
||||
@@ -62,10 +62,7 @@ class _ClaudeTaskDockState extends State<ClaudeTaskDock> {
|
||||
ClideDivider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 4, 10, 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [for (final t in tasks) _taskRow(tokens, t)],
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [for (final t in tasks) _taskRow(tokens, t)]),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -84,7 +81,9 @@ class _ClaudeTaskDockState extends State<ClaudeTaskDock> {
|
||||
ClideText(summary, fontSize: clideFontCaption, color: tokens.globalTextMuted),
|
||||
if (!_expanded && current != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: ClideText(current, fontSize: clideFontCaption, color: tokens.globalTextMuted, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
Expanded(
|
||||
child: ClideText(current, fontSize: clideFontCaption, color: tokens.globalTextMuted, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
] else
|
||||
const Spacer(),
|
||||
],
|
||||
@@ -106,14 +105,13 @@ class _ClaudeTaskDockState extends State<ClaudeTaskDock> {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(padding: const EdgeInsets.only(top: 1), child: ClideIcon(PhosphorIcons.byName(glyph), size: 13, color: color)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 1),
|
||||
child: ClideIcon(PhosphorIcons.byName(glyph), size: 13, color: color),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
t.text,
|
||||
fontSize: clideFontCaption,
|
||||
color: t.status == TaskStatus.completed ? tokens.globalTextMuted : tokens.globalForeground,
|
||||
),
|
||||
child: ClideText(t.text, fontSize: clideFontCaption, color: t.status == TaskStatus.completed ? tokens.globalTextMuted : tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -99,16 +99,10 @@ String pasteCacheDir() {
|
||||
/// written to [tempDir] (default [pasteCacheDir]) and attached by its
|
||||
/// path. Returns an empty list when the clipboard holds neither, so the
|
||||
/// composer pastes text instead.
|
||||
Future<List<ComposerAttachment>> resolveClipboardAttachment(
|
||||
ClipboardSource source, {
|
||||
Directory? tempDir,
|
||||
DateTime Function() now = DateTime.now,
|
||||
}) async {
|
||||
Future<List<ComposerAttachment>> resolveClipboardAttachment(ClipboardSource source, {Directory? tempDir, DateTime Function() now = DateTime.now}) async {
|
||||
final files = await source.readFiles();
|
||||
if (files.isNotEmpty) {
|
||||
return [
|
||||
for (final p in files) ComposerAttachment(path: p, isImage: _looksLikeImage(p)),
|
||||
];
|
||||
return [for (final p in files) ComposerAttachment(path: p, isImage: _looksLikeImage(p))];
|
||||
}
|
||||
|
||||
final image = await source.readImage();
|
||||
|
||||
@@ -183,20 +183,13 @@ class _ConversationCardState extends State<ConversationCard> {
|
||||
if (!_collapsed) ...[
|
||||
const SizedBox(height: 4),
|
||||
widget.body,
|
||||
for (final seg in widget.extraSegments) ...[
|
||||
_segmentLabel(tokens, seg.label),
|
||||
seg.child,
|
||||
],
|
||||
for (final seg in widget.extraSegments) ...[_segmentLabel(tokens, seg.label), seg.child],
|
||||
],
|
||||
],
|
||||
);
|
||||
return Padding(
|
||||
padding: widget.margin,
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: _frame(tokens, content),
|
||||
),
|
||||
child: MouseRegion(onEnter: (_) => setState(() => _hover = true), onExit: (_) => setState(() => _hover = false), child: _frame(tokens, content)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -289,11 +282,7 @@ class _ConversationCardState extends State<ConversationCard> {
|
||||
onTap: () => setState(() => _collapsed = !_collapsed),
|
||||
builder: (_, hovered, pressed) => Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: ClideIcon(
|
||||
_collapsed ? PhosphorIcons.byName('caret-right') : PhosphorIcons.byName('caret-down'),
|
||||
size: 12,
|
||||
color: tokens.globalTextMuted,
|
||||
),
|
||||
child: ClideIcon(_collapsed ? PhosphorIcons.byName('caret-right') : PhosphorIcons.byName('caret-down'), size: 12, color: tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -360,12 +349,7 @@ class _ConversationCardState extends State<ConversationCard> {
|
||||
onTap: items[i].onTap,
|
||||
builder: (_, hovered, pressed) => Padding(
|
||||
padding: const EdgeInsets.only(left: 10),
|
||||
child: ClideText(
|
||||
items[i].label,
|
||||
fontSize: clideFontMeta,
|
||||
color: tokens.globalTextMuted,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
child: ClideText(items[i].label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -21,11 +21,8 @@ class ConversationController extends ChangeNotifier {
|
||||
/// over stream-json so the pane would otherwise start empty. [onDispose]
|
||||
/// is invoked from [dispose] — wire it to the reader's `dispose` so
|
||||
/// cancelling the view tears down the underlying tail.
|
||||
ConversationController({
|
||||
required Stream<ConversationItem> stream,
|
||||
Iterable<ConversationItem>? seed,
|
||||
Future<void> Function()? onDispose,
|
||||
}) : _onDispose = onDispose {
|
||||
ConversationController({required Stream<ConversationItem> stream, Iterable<ConversationItem>? seed, Future<void> Function()? onDispose})
|
||||
: _onDispose = onDispose {
|
||||
if (seed != null) _items.addAll(seed);
|
||||
_sub = stream.listen(_onItem);
|
||||
}
|
||||
@@ -34,13 +31,10 @@ class ConversationController extends ChangeNotifier {
|
||||
/// the [ConversationItem]s a [TranscriptPublisher] writes onto
|
||||
/// [publisher]/[channel]. Decouples the view from the reader so several
|
||||
/// panels can render the same conversation (team work, T-139/T-140).
|
||||
factory ConversationController.fromBus({
|
||||
required MessageBus messages,
|
||||
String channel = ClaudeConversation.leadChannel,
|
||||
Future<void> Function()? onDispose,
|
||||
}) {
|
||||
final stream =
|
||||
messages.subscribe(publisher: ClaudeConversation.publisher, channel: channel).map((m) => m.data[ClaudeConversation.itemKey] as ConversationItem);
|
||||
factory ConversationController.fromBus({required MessageBus messages, String channel = ClaudeConversation.leadChannel, Future<void> Function()? onDispose}) {
|
||||
final stream = messages
|
||||
.subscribe(publisher: ClaudeConversation.publisher, channel: channel)
|
||||
.map((m) => m.data[ClaudeConversation.itemKey] as ConversationItem);
|
||||
return ConversationController(stream: stream, onDispose: onDispose);
|
||||
}
|
||||
|
||||
|
||||
@@ -181,11 +181,9 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
/// - [runByToolUseId]: the rest of the run — prose / thinking / tool cards —
|
||||
/// nested in a holder UNDER the Agent card (T-264), with a successful
|
||||
/// sidechain tool result left out (it folds into its own tool card).
|
||||
({
|
||||
Set<String> ownedSidechainUuids,
|
||||
Map<String, List<UserMessage>> promptsByToolUseId,
|
||||
Map<String, List<ConversationItem>> runByToolUseId,
|
||||
}) _sidechainFold(List<ConversationItem> items) {
|
||||
({Set<String> ownedSidechainUuids, Map<String, List<UserMessage>> promptsByToolUseId, Map<String, List<ConversationItem>> runByToolUseId}) _sidechainFold(
|
||||
List<ConversationItem> items,
|
||||
) {
|
||||
final agentByMsgUuid = <String, AssistantToolUse>{
|
||||
for (final it in items)
|
||||
if (it is AssistantToolUse && _isAgentTool(it.name)) it.uuid: it,
|
||||
@@ -413,7 +411,7 @@ String? resolveWorkspaceFilePath(String? root, String raw) {
|
||||
/// Open a clicked workspace file reference in the editor, jumping to [line]
|
||||
/// when present — the Dart-side twin of `clide editor open <path>` (T-300, D-6).
|
||||
void _openFile(BuildContext context, String path, int? line) {
|
||||
unawaited(ClideKernel.of(context).ipc.request('editor.open', args: {'path': path, if (line != null) 'line': line}));
|
||||
unawaited(ClideKernel.of(context).ipc.request('editor.open', args: {'path': path, 'line': ?line}));
|
||||
}
|
||||
|
||||
/// One conversation item, rendered by kind.
|
||||
@@ -566,16 +564,13 @@ class _ConversationTurn extends StatelessWidget {
|
||||
image: ClideFileImage(m.path),
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.centerLeft,
|
||||
errorBuilder: (_, __, ___) => _imagePlaceholder(m.path),
|
||||
errorBuilder: (_, _, _) => _imagePlaceholder(m.path),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (caption != null && caption.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
ClideText(caption, fontSize: clideFontMeta, color: tokens.globalTextMuted),
|
||||
],
|
||||
if (caption != null && caption.isNotEmpty) ...[const SizedBox(height: 4), ClideText(caption, fontSize: clideFontMeta, color: tokens.globalTextMuted)],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -585,11 +580,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
ClideKernel.of(context).dialog.show<Object>(
|
||||
(ctx, dismiss) => ClideLightbox(
|
||||
onDismiss: dismiss,
|
||||
child: Image(
|
||||
image: ClideFileImage(path),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => _imagePlaceholder(path),
|
||||
),
|
||||
child: Image(image: ClideFileImage(path), fit: BoxFit.contain, errorBuilder: (_, _, _) => _imagePlaceholder(path)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -685,8 +676,15 @@ class _ConversationTurn extends StatelessWidget {
|
||||
// (note E): call input (body) → prompt → returned result.
|
||||
final segments = <CardSegment>[
|
||||
for (final p in promptsByToolUseId[t.toolUseId] ?? const <UserMessage>[])
|
||||
CardSegment(label: 'prompt', child: ClideText(p.text, muted: true, fontSize: clideFontMeta)),
|
||||
if (succeeded && !(isAgent && hasRun)) CardSegment(label: 'result', child: ClideCodeBlock(source: result.content, language: _resultLanguage(t))),
|
||||
CardSegment(
|
||||
label: 'prompt',
|
||||
child: ClideText(p.text, muted: true, fontSize: clideFontMeta),
|
||||
),
|
||||
if (succeeded && !(isAgent && hasRun))
|
||||
CardSegment(
|
||||
label: 'result',
|
||||
child: ClideCodeBlock(source: result.content, language: _resultLanguage(t)),
|
||||
),
|
||||
];
|
||||
|
||||
// A resolved permission-prompted call is tinted green if approved / red if
|
||||
@@ -757,12 +755,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
collapsible: quiet || multiline,
|
||||
collapsedByDefault: quiet, // genuine errors stay expanded; a denial folds
|
||||
collapsedSummary: (quiet || multiline) ? _firstLine(t.content) : null,
|
||||
body: ClideText(
|
||||
t.content,
|
||||
fontSize: clideFontMeta,
|
||||
fontFamily: clideMonoFamily,
|
||||
color: quiet ? tokens.globalTextMuted : tokens.statusError,
|
||||
),
|
||||
body: ClideText(t.content, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: quiet ? tokens.globalTextMuted : tokens.statusError),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -783,12 +776,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
collapsedSummary: multiline ? _firstLine(t.content) : null,
|
||||
body: isOutputTool
|
||||
? ClideCodeBlock(source: t.content, language: 'text')
|
||||
: ClideText(
|
||||
t.content,
|
||||
fontSize: clideFontMeta,
|
||||
fontFamily: clideMonoFamily,
|
||||
color: tokens.globalForeground,
|
||||
),
|
||||
: ClideText(t.content, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
}
|
||||
}
|
||||
_orchestrator?.chatModel.postAsUser(body, toName: recipient);
|
||||
return IpcResponse.ok(id: '', data: {'status': 'posted', if (recipient != null) 'to': recipient});
|
||||
return IpcResponse.ok(id: '', data: {'status': 'posted', 'to': ?recipient});
|
||||
},
|
||||
),
|
||||
// claude.agent.fork: branch a managed session into a new fork session
|
||||
@@ -281,13 +281,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
}
|
||||
final cwd = args.length >= 2 ? args[1] : source.cwd;
|
||||
final forkId = 'fork:$sourceId-${DateTime.now().millisecondsSinceEpoch}';
|
||||
await orch.spawn(SpawnSpec(
|
||||
id: forkId,
|
||||
role: 'fork of $sourceId',
|
||||
sessionId: forkId,
|
||||
cwd: cwd,
|
||||
forkSourceSessionId: source.sessionId,
|
||||
));
|
||||
await orch.spawn(SpawnSpec(id: forkId, role: 'fork of $sourceId', sessionId: forkId, cwd: cwd, forkSourceSessionId: source.sessionId));
|
||||
return IpcResponse.ok(id: '', data: {'forkId': forkId, 'sourceId': sourceId, 'status': 'spawned'});
|
||||
},
|
||||
),
|
||||
@@ -305,12 +299,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
// its model · permission-mode · context line here.
|
||||
// flex: 1 → StatusbarHost wraps this in Flexible(loose) so the slot
|
||||
// yields width under pressure and ClideMarquee scrolls (T-160).
|
||||
StatusItemContribution(
|
||||
id: 'claude.status-context',
|
||||
priority: 50,
|
||||
flex: 1,
|
||||
build: (_) => const PaneContextStatusItem(),
|
||||
),
|
||||
StatusItemContribution(id: 'claude.status-context', priority: 50, flex: 1, build: (_) => const PaneContextStatusItem()),
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -399,13 +388,15 @@ class ClaudeExtension extends ClideExtension {
|
||||
}
|
||||
final target = _orchestrator?.byId('primary') ?? _orchestrator?.visibleSessions.firstOrNull;
|
||||
if (target == null) return;
|
||||
target.conversation.inject(ImageMessage(
|
||||
target.conversation.inject(
|
||||
ImageMessage(
|
||||
uuid: 'image-${DateTime.now().microsecondsSinceEpoch}',
|
||||
timestamp: DateTime.now(),
|
||||
isSidechain: false,
|
||||
path: path,
|
||||
caption: m.data['caption'] as String?,
|
||||
));
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -466,9 +457,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
if (root == null || home == null) return IpcResponse.ok(id: '', data: const {});
|
||||
final dir = Directory('$home/.claude/projects/${root.replaceAll('/', '-')}');
|
||||
final sessions = await listSessions(dir);
|
||||
await ctx.dialog.show<Object>(
|
||||
(c, dismiss) => SessionStorageDialog(dir: dir, sessions: sessions, onClose: dismiss),
|
||||
);
|
||||
await ctx.dialog.show<Object>((c, dismiss) => SessionStorageDialog(dir: dir, sessions: sessions, onClose: dismiss));
|
||||
return IpcResponse.ok(id: '', data: const {'status': 'shown'});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,7 @@ void openImageLightbox(BuildContext context, String path) {
|
||||
ClideKernel.of(context).dialog.show<Object>(
|
||||
(ctx, dismiss) => ClideLightbox(
|
||||
onDismiss: dismiss,
|
||||
child: Image(
|
||||
image: ClideFileImage(path),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (ctx, _, __) => _placeholder(ctx, 48),
|
||||
),
|
||||
child: Image(image: ClideFileImage(path), fit: BoxFit.contain, errorBuilder: (ctx, _, _) => _placeholder(ctx, 48)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -64,13 +60,7 @@ class ImageThumbnail extends StatelessWidget {
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: Image(
|
||||
image: ClideFileImage(path),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (ctx, _, __) => _placeholder(ctx, size),
|
||||
),
|
||||
child: Image(image: ClideFileImage(path), width: size, height: size, fit: BoxFit.cover, errorBuilder: (ctx, _, _) => _placeholder(ctx, size)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -94,11 +94,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
|
||||
side: ClideAnchorSide.above,
|
||||
align: ClideAnchorAlign.end,
|
||||
offset: const Offset(0, -6),
|
||||
overlayBuilder: (ctx, ctrl) => ClideMenu(
|
||||
onClose: ctrl.close,
|
||||
minWidth: 180,
|
||||
entries: _entries(ClideTheme.of(ctx).surface),
|
||||
),
|
||||
overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ClideTheme.of(ctx).surface)),
|
||||
anchor: ListenableBuilder(
|
||||
listenable: _overlay,
|
||||
builder: (ctx, _) {
|
||||
@@ -116,9 +112,7 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
border: Border.all(
|
||||
color: open ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.globalBorder),
|
||||
),
|
||||
border: Border.all(color: open ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.globalBorder)),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: ClideIcon(permissionModeIcon(widget.mode), size: 16, color: permissionModeColor(widget.mode, tokens)),
|
||||
|
||||
@@ -27,7 +27,8 @@ const _kOther = '\u0000other';
|
||||
/// Preformatted note for the "Deny & simplify" permission option (T-311): deny
|
||||
/// THIS action and ask Claude to reformulate it more simply, explicitly without
|
||||
/// touching the permission surface (memories / settings).
|
||||
const _kDenySimplifyNote = 'Denied — this action is too complex for the permission system to approve cleanly. '
|
||||
const _kDenySimplifyNote =
|
||||
'Denied — this action is too complex for the permission system to approve cleanly. '
|
||||
'Please retry with a simpler, more granular approach (break it into smaller steps or use a plainer command) '
|
||||
'to avoid this permission prompt. This is a one-off for THIS action only: do not add a memory and do not '
|
||||
'change permission settings — just reformulate and try again. Do not narrate or explain the change; '
|
||||
@@ -295,11 +296,13 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
[
|
||||
if (_q.isNotEmpty) _questionBody(tokens, 0),
|
||||
const SizedBox(height: 6),
|
||||
Row(children: [
|
||||
Row(
|
||||
children: [
|
||||
ClideButton(label: 'Submit', variant: ClideButtonVariant.primary, onPressed: (_q.isNotEmpty && _answer(0).isNotEmpty) ? _submit : null),
|
||||
const Spacer(),
|
||||
_chatInstead(tokens),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -312,11 +315,13 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
const SizedBox(height: 8),
|
||||
for (var i = 0; i < _q.length; i++) _reviewRow(tokens, i),
|
||||
const SizedBox(height: 6),
|
||||
Row(children: [
|
||||
Row(
|
||||
children: [
|
||||
ClideButton(label: '‹ Back', onPressed: () => setState(() => _step = _q.length - 1)),
|
||||
const SizedBox(width: 8),
|
||||
ClideButton(label: 'Submit answers', variant: ClideButtonVariant.primary, onPressed: _allAnswered ? _submit : null),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -330,19 +335,14 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
const SizedBox(height: 10),
|
||||
_questionBody(tokens, _step),
|
||||
const SizedBox(height: 6),
|
||||
Row(children: [
|
||||
if (_step > 0) ...[
|
||||
ClideButton(label: '‹ Back', onPressed: () => setState(() => _step--)),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
ClideButton(
|
||||
label: last ? 'Review ›' : 'Next ›',
|
||||
variant: ClideButtonVariant.primary,
|
||||
onPressed: answered ? () => setState(() => _step++) : null,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
if (_step > 0) ...[ClideButton(label: '‹ Back', onPressed: () => setState(() => _step--)), const SizedBox(width: 8)],
|
||||
ClideButton(label: last ? 'Review ›' : 'Next ›', variant: ClideButtonVariant.primary, onPressed: answered ? () => setState(() => _step++) : null),
|
||||
const Spacer(),
|
||||
_chatInstead(tokens),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -354,12 +354,17 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
final done = _answer(i).isNotEmpty;
|
||||
final text = '${i + 1} · $head${done ? ' ✓' : ''}';
|
||||
if (i == _step) {
|
||||
chips.add(Container(
|
||||
chips.add(
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.globalFocus.withValues(alpha: 0.18), border: Border.all(color: tokens.statusInfo), borderRadius: BorderRadius.circular(4)),
|
||||
color: tokens.globalFocus.withValues(alpha: 0.18),
|
||||
border: Border.all(color: tokens.statusInfo),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
|
||||
));
|
||||
),
|
||||
);
|
||||
} else {
|
||||
chips.add(ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: done ? tokens.statusSuccess : tokens.globalTextMuted));
|
||||
}
|
||||
@@ -376,8 +381,13 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 110, child: ClideText(head, fontSize: clideFontMeta, color: tokens.globalTextMuted)),
|
||||
Expanded(child: ClideText('→ ${_answer(qi)}', fontSize: clideFontSmall, color: tokens.globalForeground)),
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: ClideText(head, fontSize: clideFontMeta, color: tokens.globalTextMuted),
|
||||
),
|
||||
Expanded(
|
||||
child: ClideText('→ ${_answer(qi)}', fontSize: clideFontSmall, color: tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -403,10 +413,7 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
_optButton(qi, _kOther, 'Other…', q.multiSelect, '', q.options.length + 1),
|
||||
],
|
||||
),
|
||||
if (hasOther) ...[
|
||||
const SizedBox(height: 8),
|
||||
_NoteField(controller: _other[qi], placeholder: 'type your answer…'),
|
||||
],
|
||||
if (hasOther) ...[const SizedBox(height: 8), _NoteField(controller: _other[qi], placeholder: 'type your answer…')],
|
||||
const SizedBox(height: 8),
|
||||
_NoteField(controller: _qnote[qi], placeholder: '+ note (optional)'),
|
||||
],
|
||||
@@ -499,10 +506,7 @@ Widget toolInputBody(SurfaceTokens tokens, String toolName, Map<String, dynamic>
|
||||
/// / timeout annotations.
|
||||
Widget toolBashBody(SurfaceTokens tokens, Map<String, dynamic> input) {
|
||||
final cmd = (input['command'] as String? ?? '').trimRight();
|
||||
final notes = <String>[
|
||||
if (input['run_in_background'] == true) 'background',
|
||||
if (input['timeout'] is num) 'timeout ${input['timeout']}ms',
|
||||
];
|
||||
final notes = <String>[if (input['run_in_background'] == true) 'background', if (input['timeout'] is num) 'timeout ${input['timeout']}ms'];
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -564,19 +568,14 @@ Widget toolReadLikeBody(SurfaceTokens tokens, String toolName, Map<String, dynam
|
||||
if (pat != null && pat.isNotEmpty) extra.add('"$pat"');
|
||||
}
|
||||
final label = [path.toString(), ...extra].where((s) => s.isNotEmpty).join(' ');
|
||||
return ClideText(
|
||||
label.isNotEmpty ? label : toolName,
|
||||
fontSize: clideFontMeta,
|
||||
fontFamily: clideMonoFamily,
|
||||
color: tokens.globalForeground,
|
||||
);
|
||||
return ClideText(label.isNotEmpty ? label : toolName, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground);
|
||||
}
|
||||
|
||||
/// A muted file path line, shared across tool bodies.
|
||||
Widget toolPathLine(SurfaceTokens tokens, String path) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: ClideText(path, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
|
||||
);
|
||||
);
|
||||
|
||||
// -- shared note / free-text field -------------------------------------------
|
||||
|
||||
@@ -613,7 +612,7 @@ class _NoteFieldState extends State<_NoteField> {
|
||||
children: [
|
||||
ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: widget.controller,
|
||||
builder: (_, v, __) => v.text.isEmpty ? ClideText(widget.placeholder, muted: true, fontSize: clideFontSmall) : const SizedBox.shrink(),
|
||||
builder: (_, v, _) => v.text.isEmpty ? ClideText(widget.placeholder, muted: true, fontSize: clideFontSmall) : const SizedBox.shrink(),
|
||||
),
|
||||
EditableText(
|
||||
controller: widget.controller,
|
||||
@@ -652,14 +651,9 @@ List<_Question> _parseQuestions(Map<String, dynamic> input) {
|
||||
return [
|
||||
for (final q in raw)
|
||||
if (q is Map)
|
||||
_Question(
|
||||
q['question'] as String? ?? '',
|
||||
q['header'] as String? ?? '',
|
||||
q['multiSelect'] as bool? ?? false,
|
||||
[
|
||||
_Question(q['question'] as String? ?? '', q['header'] as String? ?? '', q['multiSelect'] as bool? ?? false, [
|
||||
for (final o in (q['options'] as List? ?? const []))
|
||||
if (o is Map) _Option(o['label'] as String? ?? '', o['description'] as String? ?? ''),
|
||||
],
|
||||
),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -13,13 +13,7 @@ import 'dart:io';
|
||||
|
||||
/// One session in the workspace, summarised for the picker.
|
||||
class SessionSummary {
|
||||
const SessionSummary({
|
||||
required this.id,
|
||||
required this.modified,
|
||||
this.firstUser,
|
||||
this.lastUser,
|
||||
this.sizeBytes = 0,
|
||||
});
|
||||
const SessionSummary({required this.id, required this.modified, this.firstUser, this.lastUser, this.sizeBytes = 0});
|
||||
|
||||
/// The session id (the `<uuid>` of `<uuid>.jsonl`).
|
||||
final String id;
|
||||
@@ -94,11 +88,7 @@ String? _userTextOf(String line) {
|
||||
/// Sessions in [dir] (the munged project dir), most-recently-modified first,
|
||||
/// capped at [max]. Each is summarised by bookend user prompts read from a
|
||||
/// bounded [window] at each end of its transcript.
|
||||
Future<List<SessionSummary>> listSessions(
|
||||
Directory dir, {
|
||||
int max = 20,
|
||||
int window = 128 * 1024,
|
||||
}) async {
|
||||
Future<List<SessionSummary>> listSessions(Directory dir, {int max = 20, int window = 128 * 1024}) async {
|
||||
if (!await dir.exists()) return const [];
|
||||
final files = <File>[];
|
||||
await for (final e in dir.list(followLinks: false)) {
|
||||
@@ -109,13 +99,15 @@ Future<List<SessionSummary>> listSessions(
|
||||
final stat = await f.stat();
|
||||
final bookends = await _bookends(f, window);
|
||||
final id = _sessionId(f.path);
|
||||
summaries.add(SessionSummary(
|
||||
summaries.add(
|
||||
SessionSummary(
|
||||
id: id,
|
||||
modified: stat.modified,
|
||||
firstUser: bookends.first,
|
||||
lastUser: bookends.last,
|
||||
sizeBytes: stat.size + await _dirSize(Directory('${dir.path}/$id')),
|
||||
));
|
||||
),
|
||||
);
|
||||
}
|
||||
summaries.sort((a, b) => b.modified.compareTo(a.modified));
|
||||
return summaries.length > max ? summaries.sublist(0, max) : summaries;
|
||||
|
||||
@@ -32,11 +32,7 @@ const _resumeTailBytes = 256 * 1024;
|
||||
|
||||
/// Creates the subprocess for a session — production uses
|
||||
/// [ClaudeStreamJsonProcess.start]; tests inject a fake.
|
||||
typedef ProcessFactory = Future<StreamJsonProcess> Function({
|
||||
required List<String> sessionArgs,
|
||||
required String cwd,
|
||||
Map<String, String>? env,
|
||||
});
|
||||
typedef ProcessFactory = Future<StreamJsonProcess> Function({required List<String> sessionArgs, required String cwd, Map<String, String>? env});
|
||||
|
||||
/// What to spawn. [id] is the orchestrator's stable key (e.g. `primary`,
|
||||
/// `teammate:tyre`); [sessionId] is claude's `--session-id`.
|
||||
@@ -150,10 +146,7 @@ ClaudeSessionOrchestrator? activeSessionOrchestrator;
|
||||
|
||||
class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
ClaudeSessionOrchestrator({ProcessFactory? processFactory}) : _factory = processFactory ?? _spawnClaude {
|
||||
_chatModel = TeamChatModel(
|
||||
broker: broker,
|
||||
sessionResolver: (name) => byMemberName(name)?.session,
|
||||
);
|
||||
_chatModel = TeamChatModel(broker: broker, sessionResolver: (name) => byMemberName(name)?.session);
|
||||
}
|
||||
|
||||
final ProcessFactory _factory;
|
||||
@@ -224,18 +217,9 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
preambles.add(_teamSystemPrompt(name, spec.role));
|
||||
}
|
||||
final bootstrap = agentBootstrap(spec.cwd, base: spec.env);
|
||||
sessionArgs = [
|
||||
'--append-system-prompt',
|
||||
preambles.join('\n\n'),
|
||||
...bootstrap.extraArgs,
|
||||
...sessionArgs,
|
||||
];
|
||||
sessionArgs = ['--append-system-prompt', preambles.join('\n\n'), ...bootstrap.extraArgs, ...sessionArgs];
|
||||
|
||||
final proc = await _factory(
|
||||
sessionArgs: sessionArgs,
|
||||
cwd: spec.cwd,
|
||||
env: bootstrap.envDelta,
|
||||
);
|
||||
final proc = await _factory(sessionArgs: sessionArgs, cwd: spec.cwd, env: bootstrap.envDelta);
|
||||
final session = StreamJsonSession(proc, mcpServers: mcpServers)..start();
|
||||
final seed = spec.resume && spec.transcriptPath != null ? await _readTranscriptTail(spec.transcriptPath!) : null;
|
||||
final conversation = ConversationController(stream: session.items, seed: seed, onDispose: session.dispose);
|
||||
@@ -362,7 +346,8 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// The team-awareness preamble injected via `--append-system-prompt` (T-170).
|
||||
static String _teamSystemPrompt(String name, String role) => 'You are part of a clide-managed agent team. Your name is "$name" and your role is "$role". '
|
||||
static String _teamSystemPrompt(String name, String role) =>
|
||||
'You are part of a clide-managed agent team. Your name is "$name" and your role is "$role". '
|
||||
'Coordinate with teammates using the clide-team MCP tools: '
|
||||
'send_message(to, text) to message one teammate by name, broadcast(text) to message all, '
|
||||
'list_teammates() to see the roster, inbox() to read messages sent to you, and '
|
||||
|
||||
@@ -12,12 +12,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class SessionPickerDialog extends StatefulWidget {
|
||||
const SessionPickerDialog({
|
||||
super.key,
|
||||
required this.sessions,
|
||||
required this.onPick,
|
||||
required this.onCancel,
|
||||
});
|
||||
const SessionPickerDialog({super.key, required this.sessions, required this.onPick, required this.onCancel});
|
||||
|
||||
final List<SessionSummary> sessions;
|
||||
final void Function(String id) onPick;
|
||||
@@ -92,11 +87,7 @@ class _SessionPickerDialogState extends State<SessionPickerDialog> {
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: widget.sessions.length,
|
||||
itemBuilder: (ctx, i) => _row(theme, i),
|
||||
),
|
||||
child: ListView.builder(shrinkWrap: true, itemCount: widget.sessions.length, itemBuilder: (ctx, i) => _row(theme, i)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -117,13 +108,7 @@ class _SessionPickerDialogState extends State<SessionPickerDialog> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(
|
||||
s.label,
|
||||
fontSize: clideFontSmall,
|
||||
color: theme.globalForeground,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
ClideText(s.label, fontSize: clideFontSmall, color: theme.globalForeground, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 2),
|
||||
ClideText(relativeTime(s.modified), muted: true, fontSize: clideFontSmall),
|
||||
],
|
||||
|
||||
@@ -17,13 +17,7 @@ import 'package:flutter/widgets.dart';
|
||||
typedef SessionDeleter = Future<void> Function(Directory dir, String id);
|
||||
|
||||
class SessionStorageDialog extends StatefulWidget {
|
||||
const SessionStorageDialog({
|
||||
super.key,
|
||||
required this.dir,
|
||||
required this.sessions,
|
||||
required this.onClose,
|
||||
this.deleter = deleteSession,
|
||||
});
|
||||
const SessionStorageDialog({super.key, required this.dir, required this.sessions, required this.onClose, this.deleter = deleteSession});
|
||||
|
||||
final Directory dir;
|
||||
final List<SessionSummary> sessions;
|
||||
@@ -79,19 +73,11 @@ class _SessionStorageDialogState extends State<SessionStorageDialog> {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 4),
|
||||
child: ClideText(
|
||||
'Session storage · ${formatBytes(_total)} total',
|
||||
fontSize: clideFontBody,
|
||||
color: theme.globalForeground,
|
||||
),
|
||||
child: ClideText('Session storage · ${formatBytes(_total)} total', fontSize: clideFontBody, color: theme.globalForeground),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
|
||||
child: ClideText(
|
||||
'Deleting a session you are currently using will break that pane.',
|
||||
muted: true,
|
||||
fontSize: clideFontSmall,
|
||||
),
|
||||
child: ClideText('Deleting a session you are currently using will break that pane.', muted: true, fontSize: clideFontSmall),
|
||||
),
|
||||
if (_sessions.isEmpty)
|
||||
Padding(
|
||||
@@ -100,11 +86,7 @@ class _SessionStorageDialogState extends State<SessionStorageDialog> {
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _sessions.length,
|
||||
itemBuilder: (ctx, i) => _row(theme, _sessions[i]),
|
||||
),
|
||||
child: ListView.builder(shrinkWrap: true, itemCount: _sessions.length, itemBuilder: (ctx, i) => _row(theme, _sessions[i])),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -40,11 +40,7 @@ class ClaudeStreamJsonProcess implements StreamJsonProcess {
|
||||
|
||||
/// Spawn `claude` in stream-json mode. [sessionArgs] is `['--session-id', id]`
|
||||
/// for a new session or `['--resume', id]` to resume an existing one (T-161).
|
||||
static Future<ClaudeStreamJsonProcess> start({
|
||||
required List<String> sessionArgs,
|
||||
required String cwd,
|
||||
Map<String, String>? env,
|
||||
}) async {
|
||||
static Future<ClaudeStreamJsonProcess> start({required List<String> sessionArgs, required String cwd, Map<String, String>? env}) async {
|
||||
final proc = await Process.start(
|
||||
'claude',
|
||||
[
|
||||
@@ -310,7 +306,8 @@ class StreamJsonSession {
|
||||
// makes claude drive their JSON-RPC over `mcp_message` (T-170). Only sent
|
||||
// when we actually host a server, so a plain session is unchanged.
|
||||
if (_mcpServers.isNotEmpty) {
|
||||
_proc.writeLine(jsonEncode({
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': 'init-${_localSeq++}',
|
||||
'request': {
|
||||
@@ -318,7 +315,8 @@ class StreamJsonSession {
|
||||
'hooks': <String, dynamic>{},
|
||||
'sdkMcpServers': [for (final s in _mcpServers) s.name],
|
||||
},
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,7 +446,8 @@ class StreamJsonSession {
|
||||
final input = (request['input'] as Map?)?.cast<String, dynamic>() ?? <String, dynamic>{};
|
||||
final tuid = request['tool_use_id'] as String? ?? '';
|
||||
if (tuid.isNotEmpty) _promptedToolUses.add(tuid);
|
||||
_queue.add(ToolPrompt(
|
||||
_queue.add(
|
||||
ToolPrompt(
|
||||
promptId: rid,
|
||||
toolName: toolName,
|
||||
displayName: request['display_name'] as String? ?? toolName,
|
||||
@@ -456,7 +455,8 @@ class StreamJsonSession {
|
||||
toolUseId: request['tool_use_id'] as String? ?? '',
|
||||
input: input,
|
||||
permissionSuggestions: (request['permission_suggestions'] as List?) ?? const [],
|
||||
));
|
||||
),
|
||||
);
|
||||
_pendingCtl.add(pendingPrompt);
|
||||
return; // awaits resolvePrompt
|
||||
}
|
||||
@@ -465,10 +465,12 @@ class StreamJsonSession {
|
||||
unawaited(_handleMcpMessage(rid, request.cast<String, dynamic>()));
|
||||
return;
|
||||
}
|
||||
_proc.writeLine(jsonEncode({
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_response',
|
||||
'response': {'subtype': 'error', 'request_id': rid, 'error': 'Unsupported control request subtype: ${request['subtype']}'},
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Answer an `mcp_message` control_request: dispatch its JSON-RPC to the named
|
||||
@@ -489,14 +491,16 @@ class StreamJsonSession {
|
||||
} else {
|
||||
mcpResponse = await _dispatchMcp(server, message);
|
||||
}
|
||||
_proc.writeLine(jsonEncode({
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_response',
|
||||
'response': {
|
||||
'subtype': 'success',
|
||||
'request_id': rid,
|
||||
'response': {'mcp_response': mcpResponse}
|
||||
'response': {'mcp_response': mcpResponse},
|
||||
},
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
McpServer? _mcpServerNamed(String? name) {
|
||||
@@ -559,10 +563,12 @@ class StreamJsonSession {
|
||||
_toolUseOutcome[prompt.toolUseId] = decision is AllowTool;
|
||||
if (decision is DenyTool && decision.quiet) _quietErrorToolUses.add(prompt.toolUseId);
|
||||
}
|
||||
_proc.writeLine(jsonEncode({
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_response',
|
||||
'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()},
|
||||
}));
|
||||
}),
|
||||
);
|
||||
if (decision is AllowTool) {
|
||||
// The prompt card is ephemeral (it vanishes once resolved), so leave a
|
||||
// compact record of an answered question in the conversation log (D-78).
|
||||
@@ -652,16 +658,13 @@ class StreamJsonSession {
|
||||
/// so it renders immediately (stream-json doesn't replay stdin without
|
||||
/// `--replay-user-messages`).
|
||||
void send(String text) {
|
||||
_proc.writeLine(jsonEncode({
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'user',
|
||||
'message': {'role': 'user', 'content': text},
|
||||
}));
|
||||
_items.add(UserMessage(
|
||||
uuid: 'local-${_localSeq++}',
|
||||
timestamp: DateTime.now(),
|
||||
isSidechain: false,
|
||||
text: text,
|
||||
));
|
||||
}),
|
||||
);
|
||||
_items.add(UserMessage(uuid: 'local-${_localSeq++}', timestamp: DateTime.now(), isSidechain: false, text: text));
|
||||
_setBusy(true);
|
||||
}
|
||||
|
||||
@@ -669,11 +672,13 @@ class StreamJsonSession {
|
||||
/// the `interrupt` control_request; claude cancels the current turn and ends
|
||||
/// it with a `result`, which clears [busy]. Safe to call when idle.
|
||||
void interrupt() {
|
||||
_proc.writeLine(jsonEncode({
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': 'interrupt-${_localSeq++}',
|
||||
'request': {'subtype': 'interrupt'},
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Set the session's permission mode (T-181, D-77). Sends a
|
||||
@@ -685,11 +690,13 @@ class StreamJsonSession {
|
||||
/// cockpit badge's plain click; bypassPermissions is reachable only via a
|
||||
/// confirmed shift-click (T-181).
|
||||
void setPermissionMode(String mode) {
|
||||
_proc.writeLine(jsonEncode({
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': 'set-perm-${_localSeq++}',
|
||||
'request': {'subtype': 'set_permission_mode', 'mode': mode},
|
||||
}));
|
||||
}),
|
||||
);
|
||||
// Optimistically reflect the change so the badge / status line update
|
||||
// immediately (T-250) — the control_request emits no status event, and a
|
||||
// fresh system/init only arrives later. The next init reconciles if the
|
||||
|
||||
@@ -50,4 +50,4 @@ TaskStatus _statusFrom(Object? raw) => switch (raw) {
|
||||
'in_progress' => TaskStatus.inProgress,
|
||||
'completed' => TaskStatus.completed,
|
||||
_ => TaskStatus.pending,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -35,13 +35,7 @@ class TeamMemberRef {
|
||||
|
||||
/// A message left for a member, in arrival order.
|
||||
class TeamMessage {
|
||||
const TeamMessage({
|
||||
required this.from,
|
||||
required this.text,
|
||||
required this.at,
|
||||
this.to,
|
||||
this.broadcast = false,
|
||||
});
|
||||
const TeamMessage({required this.from, required this.text, required this.at, this.to, this.broadcast = false});
|
||||
final String from;
|
||||
|
||||
/// Recipient name: a single member's display name (direct message), `null`
|
||||
@@ -52,13 +46,7 @@ class TeamMessage {
|
||||
final DateTime at;
|
||||
final bool broadcast;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'from': from,
|
||||
if (to != null) 'to': to,
|
||||
'text': text,
|
||||
'at': at.toIso8601String(),
|
||||
if (broadcast) 'broadcast': true,
|
||||
};
|
||||
Map<String, dynamic> toJson() => {'from': from, if (to != null) 'to': to, 'text': text, 'at': at.toIso8601String(), if (broadcast) 'broadcast': true};
|
||||
}
|
||||
|
||||
/// A shared task. Status is one of `open` / `claimed` / `done`.
|
||||
@@ -69,12 +57,7 @@ class TeamTask {
|
||||
String status;
|
||||
String? owner;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'status': status,
|
||||
if (owner != null) 'owner': owner,
|
||||
};
|
||||
Map<String, dynamic> toJson() => {'id': id, 'title': title, 'status': status, if (owner != null) 'owner': owner};
|
||||
}
|
||||
|
||||
/// Pushes [text] into the member identified by [toMemberId] as a user message
|
||||
@@ -304,7 +287,7 @@ class TeamBroker {
|
||||
return {'ok': true, 'task': t.toJson()};
|
||||
}
|
||||
return {
|
||||
'tasks': [for (final t in _tasks.values) t.toJson()]
|
||||
'tasks': [for (final t in _tasks.values) t.toJson()],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -362,7 +345,8 @@ class TeamMcpServer implements McpServer {
|
||||
return _result(broker.claimTask(memberId, id: arguments['id'] as String?, title: arguments['title'] as String?));
|
||||
case 'task_status':
|
||||
return _result(
|
||||
broker.taskStatus(memberId, id: arguments['id'] as String?, status: arguments['status'] as String?, title: arguments['title'] as String?));
|
||||
broker.taskStatus(memberId, id: arguments['id'] as String?, status: arguments['status'] as String?, title: arguments['title'] as String?),
|
||||
);
|
||||
default:
|
||||
return _error('Unknown team tool: $name');
|
||||
}
|
||||
|
||||
@@ -29,11 +29,7 @@ typedef SessionResolver = StreamJsonSession? Function(String memberName);
|
||||
/// Lifetime matches the orchestrator: created once, subscribed to the broker,
|
||||
/// disposed when the orchestrator is torn down.
|
||||
class TeamChatModel {
|
||||
TeamChatModel({
|
||||
required TeamBroker broker,
|
||||
SessionResolver? sessionResolver,
|
||||
}) : _broker = broker,
|
||||
_sessionResolver = sessionResolver {
|
||||
TeamChatModel({required TeamBroker broker, SessionResolver? sessionResolver}) : _broker = broker, _sessionResolver = sessionResolver {
|
||||
_sub = broker.messages.listen(_onMessage);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,12 +26,7 @@ import 'package:flutter/widgets.dart';
|
||||
/// [onPopOut] is called when the user taps the pop-out icon to open the full
|
||||
/// pane — the extension wires this to `panels.activateTab`.
|
||||
class TeamChatSidebar extends StatefulWidget {
|
||||
const TeamChatSidebar({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.broker,
|
||||
required this.onPopOut,
|
||||
});
|
||||
const TeamChatSidebar({super.key, required this.model, required this.broker, required this.onPopOut});
|
||||
|
||||
final TeamChatModel model;
|
||||
final TeamBroker broker;
|
||||
@@ -149,11 +144,7 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
|
||||
onTap: widget.onPopOut,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1),
|
||||
child: ClideIcon(
|
||||
PhosphorIcons.byName('arrows-out-simple'),
|
||||
size: 10,
|
||||
color: hovered ? tokens.globalForeground : tokens.globalTextMuted,
|
||||
),
|
||||
child: ClideIcon(PhosphorIcons.byName('arrows-out-simple'), size: 10, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -177,13 +168,7 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
|
||||
formatLabel: (n) => '@$n',
|
||||
child: Focus(
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: _ChatInputField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
tokens: tokens,
|
||||
onSubmit: _submit,
|
||||
placeholder: '@name or @team …',
|
||||
),
|
||||
child: _ChatInputField(controller: _controller, focusNode: _focusNode, tokens: tokens, onSubmit: _submit, placeholder: '@name or @team …'),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -200,11 +185,7 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
|
||||
/// Reads from the same [TeamChatModel] as [TeamChatSidebar]. Supports the
|
||||
/// interrupt tickbox and full @-completion.
|
||||
class TeamChatPane extends StatefulWidget {
|
||||
const TeamChatPane({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.broker,
|
||||
});
|
||||
const TeamChatPane({super.key, required this.model, required this.broker});
|
||||
|
||||
final TeamChatModel model;
|
||||
final TeamBroker broker;
|
||||
@@ -231,11 +212,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
|
||||
// Scroll to bottom on new message.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 120),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
_scrollController.animateTo(_scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 120), curve: Curves.easeOut);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -297,11 +274,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
|
||||
final text = raw.trim();
|
||||
if (text.isEmpty) return;
|
||||
final parsed = parseAtTag(text);
|
||||
widget.model.postAsUser(
|
||||
parsed.body.isEmpty ? text : parsed.body,
|
||||
toName: parsed.recipient,
|
||||
interrupt: _interrupt,
|
||||
);
|
||||
widget.model.postAsUser(parsed.body.isEmpty ? text : parsed.body, toName: parsed.recipient, interrupt: _interrupt);
|
||||
_controller.clear();
|
||||
}
|
||||
|
||||
@@ -340,11 +313,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (_, i) => _ChatRow(
|
||||
key: ValueKey(messages[i].at.microsecondsSinceEpoch),
|
||||
message: messages[i],
|
||||
tokens: tokens,
|
||||
),
|
||||
itemBuilder: (_, i) => _ChatRow(key: ValueKey(messages[i].at.microsecondsSinceEpoch), message: messages[i], tokens: tokens),
|
||||
),
|
||||
),
|
||||
// Composer + interrupt tickbox.
|
||||
@@ -375,24 +344,13 @@ class _TeamChatPaneState extends State<TeamChatPane> {
|
||||
margin: const EdgeInsets.only(right: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: _interrupt ? tokens.globalFocus.withAlpha(40) : const Color(0x00000000),
|
||||
border: Border.all(
|
||||
color: _interrupt ? tokens.globalFocus : tokens.globalTextMuted,
|
||||
width: 1,
|
||||
),
|
||||
border: Border.all(color: _interrupt ? tokens.globalFocus : tokens.globalTextMuted, width: 1),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: _interrupt
|
||||
? Center(
|
||||
child: ClideIcon(PhosphorIcons.byName('check'), size: 9, color: tokens.globalFocus),
|
||||
)
|
||||
: null,
|
||||
child: _interrupt ? Center(child: ClideIcon(PhosphorIcons.byName('check'), size: 9, color: tokens.globalFocus)) : null,
|
||||
),
|
||||
),
|
||||
ClideText(
|
||||
'Interrupt',
|
||||
fontSize: clideFontSmall,
|
||||
color: _interrupt ? tokens.globalForeground : tokens.globalTextMuted,
|
||||
),
|
||||
ClideText('Interrupt', fontSize: clideFontSmall, color: _interrupt ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -405,13 +363,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
|
||||
formatLabel: (n) => '@$n',
|
||||
child: Focus(
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: _ChatInputField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
tokens: tokens,
|
||||
onSubmit: _submit,
|
||||
placeholder: '@name or @team …',
|
||||
),
|
||||
child: _ChatInputField(controller: _controller, focusNode: _focusNode, tokens: tokens, onSubmit: _submit, placeholder: '@name or @team …'),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -451,10 +403,7 @@ class _ChatRow extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
margin: const EdgeInsets.only(right: 5, top: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: senderColor.withAlpha(30),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
decoration: BoxDecoration(color: senderColor.withAlpha(30), borderRadius: BorderRadius.circular(2)),
|
||||
child: ClideText(message.from, fontSize: clideFontSmall, color: senderColor),
|
||||
),
|
||||
if (toLabel != null)
|
||||
@@ -480,13 +429,7 @@ class _ChatRow extends StatelessWidget {
|
||||
|
||||
/// Inline text input for the chat composer.
|
||||
class _ChatInputField extends StatelessWidget {
|
||||
const _ChatInputField({
|
||||
required this.controller,
|
||||
required this.focusNode,
|
||||
required this.tokens,
|
||||
required this.onSubmit,
|
||||
required this.placeholder,
|
||||
});
|
||||
const _ChatInputField({required this.controller, required this.focusNode, required this.tokens, required this.onSubmit, required this.placeholder});
|
||||
|
||||
final TextEditingController controller;
|
||||
final FocusNode focusNode;
|
||||
@@ -507,12 +450,7 @@ class _ChatInputField extends StatelessWidget {
|
||||
child: EditableText(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: clideFontSmall,
|
||||
color: tokens.globalForeground,
|
||||
height: 1.4,
|
||||
),
|
||||
style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: clideFontSmall, color: tokens.globalForeground, height: 1.4),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
onSubmitted: onSubmit,
|
||||
|
||||
@@ -51,10 +51,7 @@ class _TeamPanelHostState extends State<TeamPanelHost> {
|
||||
void _onJoined(TeamMemberJoined m) {
|
||||
if (_controllers.containsKey(m.agentId)) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_controllers[m.agentId] = ConversationController.fromBus(
|
||||
messages: kernel.messages,
|
||||
channel: ClaudeConversation.teammateChannel(m.agentId),
|
||||
);
|
||||
_controllers[m.agentId] = ConversationController.fromBus(messages: kernel.messages, channel: ClaudeConversation.teammateChannel(m.agentId));
|
||||
setState(() => _members.add(m));
|
||||
}
|
||||
|
||||
@@ -92,11 +89,7 @@ class _TeamPanelHostState extends State<TeamPanelHost> {
|
||||
}),
|
||||
),
|
||||
Expanded(
|
||||
child: _TeammateGrid(
|
||||
members: _members,
|
||||
controllers: _controllers,
|
||||
tokens: tokens,
|
||||
),
|
||||
child: _TeammateGrid(members: _members, controllers: _controllers, tokens: tokens),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -145,11 +138,7 @@ class _TeammateGrid extends StatelessWidget {
|
||||
children: [
|
||||
for (var r = 0; r < rows; r++)
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
for (var c = 0; c < cols; c++) Expanded(child: _cell(r * cols + c)),
|
||||
],
|
||||
),
|
||||
child: Row(children: [for (var c = 0; c < cols; c++) Expanded(child: _cell(r * cols + c))]),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -36,10 +36,13 @@ Future<bool> applyTicketPickUp(
|
||||
final id = data['id'] as String?;
|
||||
final status = data['status'] as String?;
|
||||
if (id != null && id.isNotEmpty && kPickUpStartableStatuses.contains(status)) {
|
||||
final resp = await ipc.request('pql.tickets.status', args: {
|
||||
final resp = await ipc.request(
|
||||
'pql.tickets.status',
|
||||
args: {
|
||||
'ids': [id],
|
||||
'status': 'in_progress',
|
||||
});
|
||||
},
|
||||
);
|
||||
if (resp.ok) messages.publish('builtin.tickets', 'changed', {'id': id});
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -50,16 +50,11 @@ class TranscriptPublisher {
|
||||
/// [ClaudeConversation.publisher] / [channel]. The subscription is
|
||||
/// attached synchronously, so a controller that subscribes before the
|
||||
/// reader's first poll never misses the initial tail.
|
||||
TranscriptPublisher({
|
||||
required MessageBus messages,
|
||||
required TranscriptReader reader,
|
||||
this.channel = ClaudeConversation.leadChannel,
|
||||
}) : _messages = messages,
|
||||
TranscriptPublisher({required MessageBus messages, required TranscriptReader reader, this.channel = ClaudeConversation.leadChannel})
|
||||
: _messages = messages,
|
||||
_reader = reader {
|
||||
_sub = _reader.stream.listen((item) {
|
||||
_messages.publish(ClaudeConversation.publisher, channel, {
|
||||
ClaudeConversation.itemKey: item,
|
||||
});
|
||||
_messages.publish(ClaudeConversation.publisher, channel, {ClaudeConversation.itemKey: item});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -171,13 +171,7 @@ final class AssistantToolUse extends ConversationItem {
|
||||
/// driver has already resolved (workspace-relative paths are resolved before
|
||||
/// injection); [caption] is an optional one-line label.
|
||||
final class ImageMessage extends ConversationItem {
|
||||
const ImageMessage({
|
||||
required super.uuid,
|
||||
required super.timestamp,
|
||||
required super.isSidechain,
|
||||
required this.path,
|
||||
this.caption,
|
||||
});
|
||||
const ImageMessage({required super.uuid, required super.timestamp, required super.isSidechain, required this.path, this.caption});
|
||||
|
||||
/// Absolute path to the image file on disk.
|
||||
final String path;
|
||||
@@ -215,14 +209,7 @@ const _isolateParseThreshold = 64 * 1024;
|
||||
const _knownMajorVersions = {1, 2};
|
||||
|
||||
/// Record types to skip (do not emit as conversation items).
|
||||
const _skipTypes = {
|
||||
'attachment',
|
||||
'system',
|
||||
'last-prompt',
|
||||
'permission-mode',
|
||||
'file-history-snapshot',
|
||||
'queue-operation',
|
||||
};
|
||||
const _skipTypes = {'attachment', 'system', 'last-prompt', 'permission-mode', 'file-history-snapshot', 'queue-operation'};
|
||||
|
||||
/// Tails Claude Code's transcript JSONL and emits [ConversationItem]s.
|
||||
///
|
||||
@@ -439,14 +426,7 @@ class TranscriptReader {
|
||||
/// (T-145, T-168). All fields nullable — a chunk only carries what it saw,
|
||||
/// and the reader [merge]s deltas into a running status.
|
||||
class SessionStatus {
|
||||
const SessionStatus({
|
||||
this.model,
|
||||
this.permissionMode,
|
||||
this.contextTokens,
|
||||
this.cost,
|
||||
this.contextWindow,
|
||||
this.rateLimitInfo,
|
||||
});
|
||||
const SessionStatus({this.model, this.permissionMode, this.contextTokens, this.cost, this.contextWindow, this.rateLimitInfo});
|
||||
|
||||
/// Assistant `message.model`, e.g. `claude-opus-4-7`.
|
||||
final String? model;
|
||||
@@ -550,8 +530,10 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
|
||||
final majorStr = dotIdx > 0 ? rawVersion.substring(0, dotIdx) : rawVersion;
|
||||
final major = int.tryParse(majorStr);
|
||||
if (major != null && !_knownMajorVersions.contains(major)) {
|
||||
warnings.add('unfamiliar transcript version "$rawVersion" (major=$major); '
|
||||
'parsing will degrade gracefully');
|
||||
warnings.add(
|
||||
'unfamiliar transcript version "$rawVersion" (major=$major); '
|
||||
'parsing will degrade gracefully',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,14 +610,17 @@ void _parseUserInto(
|
||||
|
||||
if (content is String) {
|
||||
if (content.isNotEmpty) {
|
||||
out.add(UserMessage(
|
||||
out.add(
|
||||
UserMessage(
|
||||
uuid: uuid,
|
||||
timestamp: timestamp,
|
||||
isSidechain: isSidechain,
|
||||
parentUuid: parentUuid,
|
||||
parentToolUseId: parentToolUseId,
|
||||
text: content,
|
||||
injected: injected));
|
||||
injected: injected,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -650,7 +635,8 @@ void _parseUserInto(
|
||||
if (text.isNotEmpty) textParts.add(text);
|
||||
case 'tool_result':
|
||||
final rawContent = item['content'];
|
||||
out.add(ToolResultMessage(
|
||||
out.add(
|
||||
ToolResultMessage(
|
||||
uuid: uuid,
|
||||
timestamp: timestamp,
|
||||
isSidechain: isSidechain,
|
||||
@@ -659,7 +645,8 @@ void _parseUserInto(
|
||||
toolUseId: item['tool_use_id'] as String? ?? '',
|
||||
content: rawContent is String ? rawContent : jsonEncode(rawContent),
|
||||
isError: item['is_error'] as bool? ?? false,
|
||||
));
|
||||
),
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -689,18 +676,35 @@ void _parseAssistantInto(
|
||||
case 'text':
|
||||
final text = item['text'] as String? ?? '';
|
||||
if (text.isNotEmpty) {
|
||||
out.add(AssistantTextMessage(
|
||||
uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, parentToolUseId: parentToolUseId, text: text));
|
||||
out.add(
|
||||
AssistantTextMessage(
|
||||
uuid: uuid,
|
||||
timestamp: timestamp,
|
||||
isSidechain: isSidechain,
|
||||
parentUuid: parentUuid,
|
||||
parentToolUseId: parentToolUseId,
|
||||
text: text,
|
||||
),
|
||||
);
|
||||
}
|
||||
case 'thinking':
|
||||
final thinking = item['thinking'] as String? ?? '';
|
||||
if (thinking.isNotEmpty) {
|
||||
out.add(AssistantThinkingMessage(
|
||||
uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, parentToolUseId: parentToolUseId, thinking: thinking));
|
||||
out.add(
|
||||
AssistantThinkingMessage(
|
||||
uuid: uuid,
|
||||
timestamp: timestamp,
|
||||
isSidechain: isSidechain,
|
||||
parentUuid: parentUuid,
|
||||
parentToolUseId: parentToolUseId,
|
||||
thinking: thinking,
|
||||
),
|
||||
);
|
||||
}
|
||||
case 'tool_use':
|
||||
final rawInput = item['input'];
|
||||
out.add(AssistantToolUse(
|
||||
out.add(
|
||||
AssistantToolUse(
|
||||
uuid: uuid,
|
||||
timestamp: timestamp,
|
||||
isSidechain: isSidechain,
|
||||
@@ -709,7 +713,8 @@ void _parseAssistantInto(
|
||||
toolUseId: item['id'] as String? ?? '',
|
||||
name: item['name'] as String? ?? '',
|
||||
input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{},
|
||||
));
|
||||
),
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -70,19 +70,12 @@ class CliInstallExtension extends ClideExtension {
|
||||
final ctx = _ctx;
|
||||
if (r.ok) {
|
||||
ctx?.notify.success(r.message, title: 'clide CLI installed');
|
||||
return IpcResponse.ok(id: '', data: {
|
||||
'installed': r.installedPath,
|
||||
'onPath': r.onPath,
|
||||
});
|
||||
return IpcResponse.ok(id: '', data: {'installed': r.installedPath, 'onPath': r.onPath});
|
||||
}
|
||||
ctx?.notify.error(r.message, title: 'clide CLI install failed');
|
||||
return IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: r.message,
|
||||
),
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: r.message),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
class DecisionTypeColors {
|
||||
const DecisionTypeColors({
|
||||
required this.confirmed,
|
||||
required this.question,
|
||||
required this.rejected,
|
||||
});
|
||||
const DecisionTypeColors({required this.confirmed, required this.question, required this.rejected});
|
||||
|
||||
final Color confirmed;
|
||||
final Color question;
|
||||
@@ -18,17 +14,9 @@ class DecisionTypeColors {
|
||||
_ => confirmed,
|
||||
};
|
||||
|
||||
static const dark = DecisionTypeColors(
|
||||
confirmed: Color(0xFF7DD3A8),
|
||||
question: Color(0xFFE6C370),
|
||||
rejected: Color(0xFFE87D7D),
|
||||
);
|
||||
static const dark = DecisionTypeColors(confirmed: Color(0xFF7DD3A8), question: Color(0xFFE6C370), rejected: Color(0xFFE87D7D));
|
||||
|
||||
static const light = DecisionTypeColors(
|
||||
confirmed: Color(0xFF1D7A4E),
|
||||
question: Color(0xFFB08A20),
|
||||
rejected: Color(0xFFC03030),
|
||||
);
|
||||
static const light = DecisionTypeColors(confirmed: Color(0xFF1D7A4E), question: Color(0xFFB08A20), rejected: Color(0xFFC03030));
|
||||
|
||||
static DecisionTypeColors forTheme({required bool dark}) => dark ? DecisionTypeColors.dark : DecisionTypeColors.light;
|
||||
}
|
||||
|
||||
@@ -110,10 +110,7 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
return ClidePaneChrome(
|
||||
title: id,
|
||||
subtitle: title,
|
||||
leading: ReaderPinButton(
|
||||
pinned: _nav?.hasPinned ?? false,
|
||||
onTap: _decision != null ? _onPin : null,
|
||||
),
|
||||
leading: ReaderPinButton(pinned: _nav?.hasPinned ?? false, onTap: _decision != null ? _onPin : null),
|
||||
trailing: [
|
||||
ReaderActionBar(
|
||||
canGoBack: _nav?.canGoBack ?? false,
|
||||
@@ -144,7 +141,11 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
children: [
|
||||
ClideTooltip(
|
||||
message: type ?? 'confirmed',
|
||||
child: Container(width: 10, height: 10, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ClideText(id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily),
|
||||
@@ -159,21 +160,12 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClideText(title, fontSize: 15, fontWeight: FontWeight.w500),
|
||||
if (date != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
ClideText(date, muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily),
|
||||
],
|
||||
if (status != null && status != 'active') ...[
|
||||
const SizedBox(height: 8),
|
||||
_StatusBadge(status: status, tokens: tokens),
|
||||
],
|
||||
if (date != null) ...[const SizedBox(height: 6), ClideText(date, muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily)],
|
||||
if (status != null && status != 'active') ...[const SizedBox(height: 8), _StatusBadge(status: status, tokens: tokens)],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (body != null && body.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id)),
|
||||
],
|
||||
if (body != null && body.isNotEmpty) ...[const SizedBox(height: 12), ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id))],
|
||||
if (refs.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
ClideText('CROSS-REFERENCES', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
|
||||
@@ -139,11 +139,13 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
final hasFilter = lf.isNotEmpty;
|
||||
final filtered = hasFilter
|
||||
? _decisions
|
||||
.where((d) =>
|
||||
.where(
|
||||
(d) =>
|
||||
d.id.toLowerCase().contains(lf) ||
|
||||
d.title.toLowerCase().contains(lf) ||
|
||||
(d.domain ?? '').toLowerCase().contains(lf) ||
|
||||
(d.type ?? '').contains(lf))
|
||||
(d.type ?? '').contains(lf),
|
||||
)
|
||||
.toList()
|
||||
: _decisions;
|
||||
|
||||
@@ -158,7 +160,9 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: ClideFilterBox(address: 'decisions.panel', hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v))),
|
||||
Expanded(
|
||||
child: ClideFilterBox(address: 'decisions.panel', hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ClideTappable(
|
||||
@@ -180,39 +184,66 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
ClideAccordion(
|
||||
label: 'CONFIRMED',
|
||||
count: confirmed.length,
|
||||
leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.confirmed, shape: BoxShape.circle)),
|
||||
leading: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: typeColors.confirmed, shape: BoxShape.circle),
|
||||
),
|
||||
expanded: hasFilter || _isSectionExpanded('confirmed'),
|
||||
onToggle: () => _toggleSection('confirmed'),
|
||||
children: [
|
||||
for (final d in confirmed)
|
||||
_DecisionCard(
|
||||
entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)
|
||||
entry: d,
|
||||
tokens: tokens,
|
||||
typeColors: typeColors,
|
||||
focused: d.id == _focusedId,
|
||||
focusKey: d.id == _focusedId ? _focusedKey : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (questions.isNotEmpty)
|
||||
ClideAccordion(
|
||||
label: 'QUESTIONS',
|
||||
count: questions.length,
|
||||
leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.question, shape: BoxShape.circle)),
|
||||
leading: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: typeColors.question, shape: BoxShape.circle),
|
||||
),
|
||||
expanded: hasFilter || _isSectionExpanded('question'),
|
||||
onToggle: () => _toggleSection('question'),
|
||||
children: [
|
||||
for (final d in questions)
|
||||
_DecisionCard(
|
||||
entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)
|
||||
entry: d,
|
||||
tokens: tokens,
|
||||
typeColors: typeColors,
|
||||
focused: d.id == _focusedId,
|
||||
focusKey: d.id == _focusedId ? _focusedKey : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (rejected.isNotEmpty)
|
||||
ClideAccordion(
|
||||
label: 'REJECTED',
|
||||
count: rejected.length,
|
||||
leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.rejected, shape: BoxShape.circle)),
|
||||
leading: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: typeColors.rejected, shape: BoxShape.circle),
|
||||
),
|
||||
expanded: hasFilter || _isSectionExpanded('rejected'),
|
||||
onToggle: () => _toggleSection('rejected'),
|
||||
children: [
|
||||
for (final d in rejected)
|
||||
_DecisionCard(
|
||||
entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)
|
||||
entry: d,
|
||||
tokens: tokens,
|
||||
typeColors: typeColors,
|
||||
focused: d.id == _focusedId,
|
||||
focusKey: d.id == _focusedId ? _focusedKey : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -271,7 +302,11 @@ class _DecisionCard extends StatelessWidget {
|
||||
children: [
|
||||
ClideTooltip(
|
||||
message: entry.type ?? 'confirmed',
|
||||
child: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
|
||||
@@ -25,12 +25,7 @@ class DeepLinkExtension extends ClideExtension {
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
CommandContribution(
|
||||
id: 'deeplink.invoke',
|
||||
command: 'deeplink.invoke',
|
||||
title: 'Open a clide:// deep link',
|
||||
run: _invoke,
|
||||
),
|
||||
CommandContribution(id: 'deeplink.invoke', command: 'deeplink.invoke', title: 'Open a clide:// deep link', run: _invoke),
|
||||
];
|
||||
|
||||
@override
|
||||
|
||||
@@ -16,19 +16,8 @@ class DefaultLayoutExtension extends ClideExtension {
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
_preset ?? classicPreset(),
|
||||
CommandContribution(
|
||||
id: 'layout.reset',
|
||||
command: 'layout.reset',
|
||||
title: 'Layout: Reset to Classic',
|
||||
run: _reset,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'palette.toggle',
|
||||
command: 'palette.toggle',
|
||||
title: 'Command Palette',
|
||||
defaultBinding: 'ctrl+shift+p',
|
||||
run: _togglePalette,
|
||||
),
|
||||
CommandContribution(id: 'layout.reset', command: 'layout.reset', title: 'Layout: Reset to Classic', run: _reset),
|
||||
CommandContribution(id: 'palette.toggle', command: 'palette.toggle', title: 'Command Palette', defaultBinding: 'ctrl+shift+p', run: _togglePalette),
|
||||
// Collapse toggles (D-051, D-054)
|
||||
CommandContribution(
|
||||
id: 'sidebar.collapse',
|
||||
@@ -45,35 +34,11 @@ class DefaultLayoutExtension extends ClideExtension {
|
||||
run: _collapseContext,
|
||||
),
|
||||
// Panel focus (D-054)
|
||||
CommandContribution(
|
||||
id: 'panel.focus.left',
|
||||
command: 'panel.focus.left',
|
||||
title: 'Focus Left Panel',
|
||||
defaultBinding: 'ctrl+1',
|
||||
run: _focusLeft,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'panel.focus.middle',
|
||||
command: 'panel.focus.middle',
|
||||
title: 'Focus Middle Panel',
|
||||
defaultBinding: 'ctrl+2',
|
||||
run: _focusMiddle,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'panel.focus.right',
|
||||
command: 'panel.focus.right',
|
||||
title: 'Focus Right Panel',
|
||||
defaultBinding: 'ctrl+3',
|
||||
run: _focusRight,
|
||||
),
|
||||
CommandContribution(id: 'panel.focus.left', command: 'panel.focus.left', title: 'Focus Left Panel', defaultBinding: 'ctrl+1', run: _focusLeft),
|
||||
CommandContribution(id: 'panel.focus.middle', command: 'panel.focus.middle', title: 'Focus Middle Panel', defaultBinding: 'ctrl+2', run: _focusMiddle),
|
||||
CommandContribution(id: 'panel.focus.right', command: 'panel.focus.right', title: 'Focus Right Panel', defaultBinding: 'ctrl+3', run: _focusRight),
|
||||
// Focus mode (D-052, D-054)
|
||||
CommandContribution(
|
||||
id: 'panel.focusMode',
|
||||
command: 'panel.focusMode',
|
||||
title: 'Toggle Focus Mode',
|
||||
defaultBinding: 'ctrl+.',
|
||||
run: _toggleFocusMode,
|
||||
),
|
||||
CommandContribution(id: 'panel.focusMode', command: 'panel.focusMode', title: 'Toggle Focus Mode', defaultBinding: 'ctrl+.', run: _toggleFocusMode),
|
||||
CommandContribution(
|
||||
id: 'panel.focusMode.exit',
|
||||
command: 'panel.focusMode.exit',
|
||||
@@ -86,20 +51,8 @@ class DefaultLayoutExtension extends ClideExtension {
|
||||
run: _exitFocusMode,
|
||||
),
|
||||
// Editor split (D-049, D-054)
|
||||
CommandContribution(
|
||||
id: 'editor.open',
|
||||
command: 'editor.open',
|
||||
title: 'Open Editor',
|
||||
defaultBinding: 'ctrl+e',
|
||||
run: _openEditor,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'editor.close',
|
||||
command: 'editor.close',
|
||||
title: 'Close Editor',
|
||||
defaultBinding: 'ctrl+w',
|
||||
run: _closeEditor,
|
||||
),
|
||||
CommandContribution(id: 'editor.open', command: 'editor.open', title: 'Open Editor', defaultBinding: 'ctrl+e', run: _openEditor),
|
||||
CommandContribution(id: 'editor.close', command: 'editor.close', title: 'Close Editor', defaultBinding: 'ctrl+w', run: _closeEditor),
|
||||
// Sidebar section switching (D-054): alt+1 through alt+5
|
||||
for (var i = 0; i < 5; i++)
|
||||
CommandContribution(
|
||||
@@ -314,10 +267,6 @@ class DefaultLayoutExtension extends ClideExtension {
|
||||
|
||||
static IpcResponse _notActivated() => IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'not activated',
|
||||
),
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'not activated'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,18 +50,12 @@ class DiffController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Load diffs. Optionally filter to [paths] and toggle [staged].
|
||||
Future<void> load({
|
||||
bool staged = false,
|
||||
List<String> paths = const [],
|
||||
}) async {
|
||||
Future<void> load({bool staged = false, List<String> paths = const []}) async {
|
||||
_staged = staged;
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
|
||||
final r = await ipc.request('git.diff', args: {
|
||||
'staged': staged,
|
||||
if (paths.isNotEmpty) 'paths': paths,
|
||||
});
|
||||
final r = await ipc.request('git.diff', args: {'staged': staged, if (paths.isNotEmpty) 'paths': paths});
|
||||
|
||||
_loading = false;
|
||||
if (!r.ok) {
|
||||
|
||||
@@ -98,24 +98,11 @@ class _DiffViewState extends State<DiffView> {
|
||||
if (c.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
c.error!,
|
||||
color: tokens.statusError,
|
||||
),
|
||||
),
|
||||
if (c.loading && c.diffs.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true),
|
||||
child: ClideText(c.error!, color: tokens.statusError),
|
||||
),
|
||||
if (c.loading && c.diffs.isEmpty) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
|
||||
if (!c.loading && c.diffs.isEmpty && c.error == null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
c.showStaged ? 'No staged changes.' : 'No unstaged changes.',
|
||||
muted: true,
|
||||
),
|
||||
),
|
||||
Padding(padding: const EdgeInsets.all(12), child: ClideText(c.showStaged ? 'No staged changes.' : 'No unstaged changes.', muted: true)),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: _scroll,
|
||||
@@ -163,11 +150,7 @@ class _DiffToolbar extends StatelessWidget {
|
||||
label: 'show unstaged changes',
|
||||
child: GestureDetector(
|
||||
onTap: controller.showStaged ? controller.toggleStaged : null,
|
||||
child: ClideText(
|
||||
'Unstaged',
|
||||
fontSize: clideFontCaption,
|
||||
color: controller.showStaged ? tokens.globalTextMuted : tokens.globalForeground,
|
||||
),
|
||||
child: ClideText('Unstaged', fontSize: clideFontCaption, color: controller.showStaged ? tokens.globalTextMuted : tokens.globalForeground),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -177,11 +160,7 @@ class _DiffToolbar extends StatelessWidget {
|
||||
label: 'show staged changes',
|
||||
child: GestureDetector(
|
||||
onTap: controller.showStaged ? null : controller.toggleStaged,
|
||||
child: ClideText(
|
||||
'Staged',
|
||||
fontSize: clideFontCaption,
|
||||
color: controller.showStaged ? tokens.globalForeground : tokens.globalTextMuted,
|
||||
),
|
||||
child: ClideText('Staged', fontSize: clideFontCaption, color: controller.showStaged ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -233,11 +212,7 @@ class _FileDiff extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
path,
|
||||
fontSize: clideFontCaption,
|
||||
color: focused ? tokens.globalFocus : tokens.panelHeaderForeground,
|
||||
),
|
||||
child: ClideText(path, fontSize: clideFontCaption, color: focused ? tokens.globalFocus : tokens.panelHeaderForeground),
|
||||
),
|
||||
if (additions > 0) ClideText('+$additions ', fontSize: clideFontCaption, color: tokens.statusSuccess),
|
||||
if (removals > 0) ClideText('-$removals', fontSize: clideFontCaption, color: tokens.statusError),
|
||||
@@ -250,12 +225,7 @@ class _FileDiff extends StatelessWidget {
|
||||
child: ClideText(meta.join(' · '), fontSize: clideFontCaption, muted: true),
|
||||
),
|
||||
if (!isBinary)
|
||||
for (final hunk in hunks)
|
||||
_HunkView(
|
||||
hunk: (hunk as Map).cast<String, Object?>(),
|
||||
filePath: path,
|
||||
controller: controller,
|
||||
),
|
||||
for (final hunk in hunks) _HunkView(hunk: (hunk as Map).cast<String, Object?>(), filePath: path, controller: controller),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
@@ -263,11 +233,7 @@ class _FileDiff extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _HunkView extends StatelessWidget {
|
||||
const _HunkView({
|
||||
required this.hunk,
|
||||
required this.filePath,
|
||||
required this.controller,
|
||||
});
|
||||
const _HunkView({required this.hunk, required this.filePath, required this.controller});
|
||||
|
||||
final Map<String, Object?> hunk;
|
||||
final String filePath;
|
||||
@@ -284,17 +250,9 @@ class _HunkView extends StatelessWidget {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: ClideText(
|
||||
header,
|
||||
fontSize: clideFontMono,
|
||||
muted: true,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
),
|
||||
for (final lineObj in lines)
|
||||
_DiffLineRow(
|
||||
line: (lineObj as Map).cast<String, Object?>(),
|
||||
child: ClideText(header, fontSize: clideFontMono, muted: true, fontFamily: clideMonoFamily),
|
||||
),
|
||||
for (final lineObj in lines) _DiffLineRow(line: (lineObj as Map).cast<String, Object?>()),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -313,18 +271,9 @@ class _DiffLineRow extends StatelessWidget {
|
||||
final newLineNo = line['newLineNo'] as num?;
|
||||
|
||||
final (Color bg, Color fg) = switch (kind) {
|
||||
'addition' => (
|
||||
tokens.statusSuccess.withValues(alpha: 0.15),
|
||||
tokens.statusSuccess,
|
||||
),
|
||||
'removal' => (
|
||||
tokens.statusError.withValues(alpha: 0.15),
|
||||
tokens.statusError,
|
||||
),
|
||||
_ => (
|
||||
const Color(0x00000000),
|
||||
tokens.globalForeground,
|
||||
),
|
||||
'addition' => (tokens.statusSuccess.withValues(alpha: 0.15), tokens.statusSuccess),
|
||||
'removal' => (tokens.statusError.withValues(alpha: 0.15), tokens.statusError),
|
||||
_ => (const Color(0x00000000), tokens.globalForeground),
|
||||
};
|
||||
|
||||
final prefix = switch (kind) {
|
||||
@@ -361,22 +310,10 @@ class _DiffLineRow extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ClideText(
|
||||
prefix,
|
||||
fontSize: clideFontMono,
|
||||
color: fg,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
ClideText(prefix, fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily),
|
||||
const SizedBox(width: 2),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
text,
|
||||
fontSize: clideFontMono,
|
||||
color: fg,
|
||||
fontFamily: clideMonoFamily,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
child: ClideText(text, fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.clip),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -99,12 +99,7 @@ class EditorController extends ChangeNotifier {
|
||||
if (raw is! List) return;
|
||||
_buffers = [
|
||||
for (final b in raw)
|
||||
if (b is Map)
|
||||
(
|
||||
id: b['id']! as String,
|
||||
path: b['path']! as String,
|
||||
dirty: (b['dirty'] as bool?) ?? false,
|
||||
),
|
||||
if (b is Map) (id: b['id']! as String, path: b['path']! as String, dirty: (b['dirty'] as bool?) ?? false),
|
||||
];
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -143,10 +138,7 @@ class EditorController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Called by the widget on every local text edit.
|
||||
void pushLocalEdit({
|
||||
required String newContent,
|
||||
required Selection newSelection,
|
||||
}) {
|
||||
void pushLocalEdit({required String newContent, required Selection newSelection}) {
|
||||
final id = _activeId;
|
||||
if (id == null) return;
|
||||
|
||||
@@ -162,11 +154,7 @@ class EditorController extends ChangeNotifier {
|
||||
// large buffers, so event broadcasts stay small.
|
||||
_pendingLocalEdits++;
|
||||
_suppressNextRemoteEdit = true;
|
||||
ipc.request('editor.set-content', args: {
|
||||
'id': id,
|
||||
'text': newContent,
|
||||
'selection': newSelection.toJson(),
|
||||
}).whenComplete(() => _pendingLocalEdits--);
|
||||
ipc.request('editor.set-content', args: {'id': id, 'text': newContent, 'selection': newSelection.toJson()}).whenComplete(() => _pendingLocalEdits--);
|
||||
}
|
||||
|
||||
Future<void> save() async {
|
||||
|
||||
@@ -65,10 +65,7 @@ class _EditorViewState extends State<EditorView> {
|
||||
final kernel = ClideKernel.of(context);
|
||||
_controller = EditorController(ipc: kernel.ipc, events: kernel.events)..addListener(_onControllerChanged);
|
||||
_keymap = kernel.keymap;
|
||||
_matcher = SequenceMatcher(
|
||||
keymap: () => kernel.keymap.keymap ?? Keymap(const []),
|
||||
context: () => kernel.keymap.scope,
|
||||
);
|
||||
_matcher = SequenceMatcher(keymap: () => kernel.keymap.keymap ?? Keymap(const []), context: () => kernel.keymap.scope);
|
||||
// Rebuild when the Vim mode flips so the editor toggles read-only.
|
||||
kernel.keymap.addListener(_onModeChanged);
|
||||
unawaited(_controller!.hydrate());
|
||||
@@ -105,10 +102,7 @@ class _EditorViewState extends State<EditorView> {
|
||||
_text.updatePath(c.activePath);
|
||||
if (c.content != _lastRemoteContent) {
|
||||
_lastRemoteContent = c.content;
|
||||
final sel = TextSelection(
|
||||
baseOffset: c.selection.start.clamp(0, c.content.length),
|
||||
extentOffset: c.selection.end.clamp(0, c.content.length),
|
||||
);
|
||||
final sel = TextSelection(baseOffset: c.selection.start.clamp(0, c.content.length), extentOffset: c.selection.end.clamp(0, c.content.length));
|
||||
_text.removeListener(_onTextChanged);
|
||||
_text.value = TextEditingValue(text: c.content, selection: sel);
|
||||
_text.addListener(_onTextChanged);
|
||||
@@ -306,9 +300,7 @@ class _EditorViewState extends State<EditorView> {
|
||||
return ClidePaneChrome(
|
||||
title: 'editor',
|
||||
subtitle: 'no buffer · use `clide open <path>` or pick a file in the tree',
|
||||
child: const Center(
|
||||
child: ClideText('Open a file to begin editing.', muted: true),
|
||||
),
|
||||
child: const Center(child: ClideText('Open a file to begin editing.', muted: true)),
|
||||
);
|
||||
}
|
||||
return MultitabPane<String>(
|
||||
@@ -357,12 +349,7 @@ class _TextBody extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = TextStyle(
|
||||
color: foreground,
|
||||
fontSize: clideFontMono,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamilyFallback: clideMonoFamilyFallback,
|
||||
);
|
||||
final style = TextStyle(color: foreground, fontSize: clideFontMono, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback);
|
||||
final editable = EditableText(
|
||||
controller: controller,
|
||||
focusNode: focus,
|
||||
@@ -398,7 +385,10 @@ class _TextBody extends StatelessWidget {
|
||||
|
||||
/// Advance width of one monospace glyph in [style].
|
||||
static double _charWidth(TextStyle style) {
|
||||
final tp = TextPainter(text: TextSpan(text: '0', style: style), textDirection: TextDirection.ltr)..layout();
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(text: '0', style: style),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
return tp.width;
|
||||
}
|
||||
}
|
||||
@@ -417,7 +407,8 @@ class _RulerPainter extends CustomPainter {
|
||||
Offset(x, size.height),
|
||||
Paint()
|
||||
..color = color
|
||||
..strokeWidth = 1);
|
||||
..strokeWidth = 1,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -36,7 +36,10 @@ class SyntaxTextController extends TextEditingController {
|
||||
if (source == _highlightedText) return;
|
||||
|
||||
_highlighting = true;
|
||||
_syntax.highlight(path, source).then((result) {
|
||||
_syntax
|
||||
.highlight(path, source)
|
||||
.then(
|
||||
(result) {
|
||||
_highlighting = false;
|
||||
if (text != source) {
|
||||
_requestHighlight();
|
||||
@@ -45,9 +48,11 @@ class SyntaxTextController extends TextEditingController {
|
||||
_highlightedText = source;
|
||||
_spans = result.spans;
|
||||
notifyListeners();
|
||||
}, onError: (_) {
|
||||
},
|
||||
onError: (_) {
|
||||
_highlighting = false;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -57,11 +62,7 @@ class SyntaxTextController extends TextEditingController {
|
||||
}
|
||||
|
||||
@override
|
||||
TextSpan buildTextSpan({
|
||||
required BuildContext context,
|
||||
TextStyle? style,
|
||||
required bool withComposing,
|
||||
}) {
|
||||
TextSpan buildTextSpan({required BuildContext context, TextStyle? style, required bool withComposing}) {
|
||||
final tokens = _tokens;
|
||||
if (_spans.isEmpty || tokens == null || text.isEmpty) {
|
||||
return TextSpan(text: text, style: style);
|
||||
@@ -125,20 +126,17 @@ class SyntaxTextController extends TextEditingController {
|
||||
|
||||
// Gap before this span — plain text.
|
||||
if (spanCharStart > charPos) {
|
||||
children.add(TextSpan(
|
||||
text: source.substring(charPos, spanCharStart),
|
||||
style: style,
|
||||
));
|
||||
children.add(TextSpan(text: source.substring(charPos, spanCharStart), style: style));
|
||||
}
|
||||
|
||||
// The highlighted span.
|
||||
if (spanCharEnd > spanCharStart) {
|
||||
children.add(TextSpan(
|
||||
children.add(
|
||||
TextSpan(
|
||||
text: source.substring(spanCharStart, spanCharEnd),
|
||||
style: style?.copyWith(
|
||||
color: TreeSitterService.colorForRole(span.role, tokens),
|
||||
style: style?.copyWith(color: TreeSitterService.colorForRole(span.role, tokens)),
|
||||
),
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
charPos = spanCharEnd;
|
||||
@@ -146,10 +144,7 @@ class SyntaxTextController extends TextEditingController {
|
||||
|
||||
// Trailing plain text.
|
||||
if (charPos < source.length) {
|
||||
children.add(TextSpan(
|
||||
text: source.substring(charPos),
|
||||
style: style,
|
||||
));
|
||||
children.add(TextSpan(text: source.substring(charPos), style: style));
|
||||
}
|
||||
|
||||
return TextSpan(style: style, children: children);
|
||||
|
||||
@@ -79,13 +79,7 @@ class VimResult {
|
||||
/// Apply [action] to [v]. [count] repeats motions/line-edits; [visual]
|
||||
/// selects between collapse-to-caret (normal) and extend-from-anchor
|
||||
/// (visual) for motions, and enables the `visual*` range ops.
|
||||
VimResult applyVim(
|
||||
String action,
|
||||
TextEditingValue v, {
|
||||
VimRegister register = VimRegister.empty,
|
||||
bool visual = false,
|
||||
int count = 1,
|
||||
}) {
|
||||
VimResult applyVim(String action, TextEditingValue v, {VimRegister register = VimRegister.empty, bool visual = false, int count = 1}) {
|
||||
final t = v.text;
|
||||
final caret = v.selection.extentOffset.clamp(0, t.length);
|
||||
final anchor = v.selection.baseOffset.clamp(0, t.length);
|
||||
@@ -226,7 +220,8 @@ int _up(String t, int off) {
|
||||
int _cls(String ch) {
|
||||
if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r') return 0;
|
||||
final c = ch.codeUnitAt(0);
|
||||
final isWord = (c >= 0x30 && c <= 0x39) || // 0-9
|
||||
final isWord =
|
||||
(c >= 0x30 && c <= 0x39) || // 0-9
|
||||
(c >= 0x41 && c <= 0x5A) || // A-Z
|
||||
(c >= 0x61 && c <= 0x7A) || // a-z
|
||||
c == 0x5F; // _
|
||||
@@ -279,13 +274,19 @@ int _wordEnd(String t, int off) {
|
||||
// -- Edit helpers -----------------------------------------------------------
|
||||
|
||||
VimResult _collapsed(String text, int caret) => VimResult(
|
||||
TextEditingValue(text: text, selection: TextSelection.collapsed(offset: _clamp(caret, 0, text.length))),
|
||||
);
|
||||
TextEditingValue(
|
||||
text: text,
|
||||
selection: TextSelection.collapsed(offset: _clamp(caret, 0, text.length)),
|
||||
),
|
||||
);
|
||||
|
||||
VimResult _insertAt(String t, int caret) => VimResult(
|
||||
TextEditingValue(text: t, selection: TextSelection.collapsed(offset: _clamp(caret, 0, t.length))),
|
||||
TextEditingValue(
|
||||
text: t,
|
||||
selection: TextSelection.collapsed(offset: _clamp(caret, 0, t.length)),
|
||||
),
|
||||
enterInsert: true,
|
||||
);
|
||||
);
|
||||
|
||||
VimResult _deleteChar(String t, int caret, int count) {
|
||||
final le = _lineEnd(t, caret);
|
||||
@@ -298,7 +299,10 @@ VimResult _deleteChar(String t, int caret, int count) {
|
||||
final nle = _lineEnd(nt, caret);
|
||||
final ncaret = _clamp(caret, nls, nle > nls ? nle - 1 : nls);
|
||||
return VimResult(
|
||||
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: ncaret)),
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: ncaret),
|
||||
),
|
||||
register: VimRegister(removed),
|
||||
);
|
||||
}
|
||||
@@ -324,7 +328,10 @@ VimResult _deleteLines(String t, int caret, int count) {
|
||||
caretLineStart = ls;
|
||||
}
|
||||
return VimResult(
|
||||
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: _firstNonBlank(nt, caretLineStart))),
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: _firstNonBlank(nt, caretLineStart)),
|
||||
),
|
||||
register: reg,
|
||||
);
|
||||
}
|
||||
@@ -337,7 +344,10 @@ VimResult _deleteToEnd(String t, int caret) {
|
||||
final ls = _lineStart(nt, caret);
|
||||
final nle = _lineEnd(nt, caret);
|
||||
return VimResult(
|
||||
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: _clamp(caret, ls, nle > ls ? nle - 1 : ls))),
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: _clamp(caret, ls, nle > ls ? nle - 1 : ls)),
|
||||
),
|
||||
register: VimRegister(removed),
|
||||
);
|
||||
}
|
||||
@@ -349,7 +359,10 @@ VimResult _deleteToOffset(String t, int caret, int target, {bool insert = false}
|
||||
final removed = t.substring(lo, hi);
|
||||
final nt = t.replaceRange(lo, hi, '');
|
||||
return VimResult(
|
||||
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: lo)),
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: lo),
|
||||
),
|
||||
register: VimRegister(removed),
|
||||
enterInsert: insert,
|
||||
);
|
||||
@@ -366,7 +379,10 @@ VimResult _changeLine(String t, int caret, int count) {
|
||||
final removed = t.substring(ls, end);
|
||||
final nt = t.replaceRange(ls, end, '');
|
||||
return VimResult(
|
||||
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: ls)),
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: ls),
|
||||
),
|
||||
register: VimRegister(removed.endsWith('\n') ? removed : '$removed\n', linewise: true),
|
||||
enterInsert: true,
|
||||
);
|
||||
@@ -382,7 +398,10 @@ VimResult _yankLines(String t, int caret, int count) {
|
||||
}
|
||||
final yanked = t.substring(ls, end);
|
||||
return VimResult(
|
||||
TextEditingValue(text: t, selection: TextSelection.collapsed(offset: caret)),
|
||||
TextEditingValue(
|
||||
text: t,
|
||||
selection: TextSelection.collapsed(offset: caret),
|
||||
),
|
||||
register: VimRegister(yanked.endsWith('\n') ? yanked : '$yanked\n', linewise: true),
|
||||
);
|
||||
}
|
||||
@@ -394,7 +413,12 @@ VimResult _paste(String t, int caret, VimRegister reg, {required bool before}) {
|
||||
if (before) {
|
||||
final ls = _lineStart(t, caret);
|
||||
final nt = t.replaceRange(ls, ls, body);
|
||||
return VimResult(TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: _firstNonBlank(nt, ls))));
|
||||
return VimResult(
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: _firstNonBlank(nt, ls)),
|
||||
),
|
||||
);
|
||||
}
|
||||
final le = _lineEnd(t, caret);
|
||||
final insertAt = le < t.length ? le + 1 : t.length;
|
||||
@@ -402,12 +426,22 @@ VimResult _paste(String t, int caret, VimRegister reg, {required bool before}) {
|
||||
final chunk = le < t.length ? body : '\n${body.substring(0, body.length - 1)}';
|
||||
final nt = t.replaceRange(insertAt, insertAt, chunk);
|
||||
final caretLine = le < t.length ? insertAt : insertAt + 1;
|
||||
return VimResult(TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: _firstNonBlank(nt, caretLine))));
|
||||
return VimResult(
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: _firstNonBlank(nt, caretLine)),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Charwise: p pastes after the caret, P at the caret.
|
||||
final at = before ? caret : _clamp(caret + 1, 0, t.length);
|
||||
final nt = t.replaceRange(at, at, reg.text);
|
||||
return VimResult(TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: at + reg.text.length - 1)));
|
||||
return VimResult(
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: at + reg.text.length - 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
VimResult _openLine(String t, int caret, {required bool below}) {
|
||||
@@ -415,14 +449,20 @@ VimResult _openLine(String t, int caret, {required bool below}) {
|
||||
final le = _lineEnd(t, caret);
|
||||
final nt = t.replaceRange(le, le, '\n');
|
||||
return VimResult(
|
||||
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: le + 1)),
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: le + 1),
|
||||
),
|
||||
enterInsert: true,
|
||||
);
|
||||
}
|
||||
final ls = _lineStart(t, caret);
|
||||
final nt = t.replaceRange(ls, ls, '\n');
|
||||
return VimResult(
|
||||
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: ls)),
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: ls),
|
||||
),
|
||||
enterInsert: true,
|
||||
);
|
||||
}
|
||||
@@ -435,7 +475,10 @@ VimResult _deleteRange(String t, int anchor, int caret, {required bool insert})
|
||||
final removed = t.substring(lo, hi);
|
||||
final nt = t.replaceRange(lo, hi, '');
|
||||
return VimResult(
|
||||
TextEditingValue(text: nt, selection: TextSelection.collapsed(offset: lo)),
|
||||
TextEditingValue(
|
||||
text: nt,
|
||||
selection: TextSelection.collapsed(offset: lo),
|
||||
),
|
||||
register: VimRegister(removed),
|
||||
enterInsert: insert,
|
||||
);
|
||||
@@ -445,7 +488,10 @@ VimResult _yankRange(String t, int anchor, int caret) {
|
||||
final lo = anchor < caret ? anchor : caret;
|
||||
final hi = _clamp((anchor < caret ? caret : anchor) + 1, 0, t.length);
|
||||
return VimResult(
|
||||
TextEditingValue(text: t, selection: TextSelection.collapsed(offset: lo)),
|
||||
TextEditingValue(
|
||||
text: t,
|
||||
selection: TextSelection.collapsed(offset: lo),
|
||||
),
|
||||
register: VimRegister(t.substring(lo, hi)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,17 +50,11 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
if (c.error != null && c.rootPath == null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(c.error!, muted: true),
|
||||
);
|
||||
return Padding(padding: const EdgeInsets.all(12), child: ClideText(c.error!, muted: true));
|
||||
}
|
||||
final root = c.rootPath;
|
||||
if (root == null) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true),
|
||||
);
|
||||
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true));
|
||||
}
|
||||
final rootName = root.split(Platform.pathSeparator).last;
|
||||
return Column(
|
||||
@@ -98,18 +92,12 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
final matches = c.allLoadedEntries().where((e) {
|
||||
return e.path.toLowerCase().contains(lowerFilter) || e.name.toLowerCase().contains(lowerFilter);
|
||||
}).toList();
|
||||
return [
|
||||
for (final e in matches) _FilteredFileRow(entry: e),
|
||||
];
|
||||
return [for (final e in matches) _FilteredFileRow(entry: e)];
|
||||
}
|
||||
}
|
||||
|
||||
class _Children extends StatelessWidget {
|
||||
const _Children({
|
||||
required this.path,
|
||||
required this.controller,
|
||||
required this.depth,
|
||||
});
|
||||
const _Children({required this.path, required this.controller, required this.depth});
|
||||
|
||||
final String path;
|
||||
final FileTreeController controller;
|
||||
@@ -129,33 +117,19 @@ class _Children extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_DirRow(
|
||||
name: e.name,
|
||||
path: e.path,
|
||||
controller: controller,
|
||||
depth: depth,
|
||||
),
|
||||
_DirRow(name: e.name, path: e.path, controller: controller, depth: depth),
|
||||
if (controller.isExpanded(e.path)) _Children(path: e.path, controller: controller, depth: depth + 1),
|
||||
],
|
||||
)
|
||||
else
|
||||
_FileRow(
|
||||
name: e.name,
|
||||
path: e.path,
|
||||
depth: depth,
|
||||
),
|
||||
_FileRow(name: e.name, path: e.path, depth: depth),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DirRow extends StatelessWidget {
|
||||
const _DirRow({
|
||||
required this.name,
|
||||
required this.path,
|
||||
required this.controller,
|
||||
required this.depth,
|
||||
});
|
||||
const _DirRow({required this.name, required this.path, required this.controller, required this.depth});
|
||||
|
||||
final String name;
|
||||
final String path;
|
||||
@@ -173,11 +147,7 @@ class _DirRow extends StatelessWidget {
|
||||
child: _Row(
|
||||
depth: depth,
|
||||
onTap: () => controller.toggle(path),
|
||||
leading: ClideIcon(
|
||||
const ChevronRightIcon(),
|
||||
size: 10,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
leading: ClideIcon(const ChevronRightIcon(), size: 10, color: tokens.sidebarForeground),
|
||||
label: name,
|
||||
rotateLeading: expanded,
|
||||
),
|
||||
@@ -186,11 +156,7 @@ class _DirRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _FileRow extends StatelessWidget {
|
||||
const _FileRow({
|
||||
required this.name,
|
||||
required this.path,
|
||||
required this.depth,
|
||||
});
|
||||
const _FileRow({required this.name, required this.path, required this.depth});
|
||||
|
||||
final String name;
|
||||
final String path;
|
||||
@@ -202,11 +168,7 @@ class _FileRow extends StatelessWidget {
|
||||
button: true,
|
||||
label: 'Open $name',
|
||||
onTap: () => _openFile(context, path),
|
||||
child: _Row(
|
||||
depth: depth,
|
||||
onTap: () => _openFile(context, path),
|
||||
label: name,
|
||||
),
|
||||
child: _Row(depth: depth, onTap: () => _openFile(context, path), label: name),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,13 +180,7 @@ class _FileRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _Row extends StatelessWidget {
|
||||
const _Row({
|
||||
required this.depth,
|
||||
required this.onTap,
|
||||
required this.label,
|
||||
this.leading,
|
||||
this.rotateLeading = false,
|
||||
});
|
||||
const _Row({required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false});
|
||||
|
||||
final int depth;
|
||||
final VoidCallback onTap;
|
||||
@@ -252,12 +208,7 @@ class _Row extends StatelessWidget {
|
||||
] else
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
child: ClideText(label, maxLines: 1, overflow: TextOverflow.ellipsis, color: tokens.sidebarForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -26,10 +26,6 @@ class GitExtension extends ClideExtension {
|
||||
priority: -80,
|
||||
build: (_) => const GitPanelView(),
|
||||
),
|
||||
StatusItemContribution(
|
||||
id: 'git.branch',
|
||||
priority: 10,
|
||||
build: (_) => const GitStatusItem(),
|
||||
),
|
||||
StatusItemContribution(id: 'git.branch', priority: 10, build: (_) => const GitStatusItem()),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -114,9 +114,7 @@ class GitController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<bool> stash({String? message}) async {
|
||||
final r = await ipc.request('git.stash', args: {
|
||||
if (message != null) 'message': message,
|
||||
});
|
||||
final r = await ipc.request('git.stash', args: {'message': ?message});
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,57 +87,26 @@ class _GitPanelViewState extends State<GitPanelView> {
|
||||
if (c.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ClideText(
|
||||
c.error!,
|
||||
color: tokens.statusError,
|
||||
fontSize: clideFontCaption,
|
||||
maxLines: 3,
|
||||
),
|
||||
),
|
||||
if (c.loading && c.isClean)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true),
|
||||
child: ClideText(c.error!, color: tokens.statusError, fontSize: clideFontCaption, maxLines: 3),
|
||||
),
|
||||
if (c.loading && c.isClean) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
|
||||
if (!c.loading && c.isClean && c.error == null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Nothing to commit, working tree clean.', muted: true),
|
||||
),
|
||||
if (c.conflicted.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Merge conflicts',
|
||||
entries: _applyFilter(c.conflicted),
|
||||
actions: const [],
|
||||
),
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('Nothing to commit, working tree clean.', muted: true)),
|
||||
if (c.conflicted.isNotEmpty) _FileGroup(label: 'Merge conflicts', entries: _applyFilter(c.conflicted), actions: const []),
|
||||
if (c.staged.isNotEmpty) ...[
|
||||
_FileGroup(
|
||||
label: 'Staged',
|
||||
entries: _applyFilter(c.staged),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Unstage all',
|
||||
onTap: () => unawaited(c.unstage(const [])),
|
||||
),
|
||||
],
|
||||
actions: [_GroupAction(label: 'Unstage all', onTap: () => unawaited(c.unstage(const [])))],
|
||||
onUnstage: (path) => unawaited(c.unstage([path])),
|
||||
),
|
||||
_CommitInput(
|
||||
commitMsg: _commitMsg,
|
||||
commitFocus: _commitFocus,
|
||||
controller: c,
|
||||
),
|
||||
_CommitInput(commitMsg: _commitMsg, commitFocus: _commitFocus, controller: c),
|
||||
],
|
||||
if (c.unstaged.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Changes',
|
||||
entries: _applyFilter(c.unstaged),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Stage all',
|
||||
onTap: () => unawaited(c.stageAll()),
|
||||
),
|
||||
],
|
||||
actions: [_GroupAction(label: 'Stage all', onTap: () => unawaited(c.stageAll()))],
|
||||
onStage: (path) => unawaited(c.stage([path])),
|
||||
onDiscard: (path) => _confirmDiscard(context, c, path),
|
||||
),
|
||||
@@ -149,9 +118,7 @@ class _GitPanelViewState extends State<GitPanelView> {
|
||||
_GroupAction(
|
||||
label: 'Stage all',
|
||||
onTap: () {
|
||||
final paths = [
|
||||
for (final e in c.untracked) e['path'] as String,
|
||||
];
|
||||
final paths = [for (final e in c.untracked) e['path'] as String];
|
||||
unawaited(c.stage(paths));
|
||||
},
|
||||
),
|
||||
@@ -160,7 +127,8 @@ class _GitPanelViewState extends State<GitPanelView> {
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -185,23 +153,11 @@ class _BranchHeader extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
parts.join(' '),
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
_SmallAction(
|
||||
label: 'Pull',
|
||||
semanticsLabel: 'git pull',
|
||||
onTap: () => unawaited(controller.pull()),
|
||||
child: ClideText(parts.join(' '), fontSize: clideFontCaption, color: tokens.sidebarForeground),
|
||||
),
|
||||
_SmallAction(label: 'Pull', semanticsLabel: 'git pull', onTap: () => unawaited(controller.pull())),
|
||||
const SizedBox(width: 4),
|
||||
_SmallAction(
|
||||
label: 'Push',
|
||||
semanticsLabel: 'git push',
|
||||
onTap: () => unawaited(controller.push()),
|
||||
),
|
||||
_SmallAction(label: 'Push', semanticsLabel: 'git push', onTap: () => unawaited(controller.push())),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -209,11 +165,7 @@ class _BranchHeader extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _CommitInput extends StatelessWidget {
|
||||
const _CommitInput({
|
||||
required this.commitMsg,
|
||||
required this.commitFocus,
|
||||
required this.controller,
|
||||
});
|
||||
const _CommitInput({required this.commitMsg, required this.commitFocus, required this.controller});
|
||||
|
||||
final TextEditingController commitMsg;
|
||||
final FocusNode commitFocus;
|
||||
@@ -232,19 +184,12 @@ class _CommitInput extends StatelessWidget {
|
||||
label: 'commit message',
|
||||
textField: true,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: tokens.globalBorder),
|
||||
),
|
||||
decoration: BoxDecoration(border: Border.all(color: tokens.globalBorder)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: EditableText(
|
||||
controller: commitMsg,
|
||||
focusNode: commitFocus,
|
||||
style: TextStyle(
|
||||
fontFamily: clideUiFamily,
|
||||
fontWeight: clideUiDefaultWeight,
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.globalForeground,
|
||||
),
|
||||
style: TextStyle(fontFamily: clideUiFamily, fontWeight: clideUiDefaultWeight, fontSize: clideFontCaption, color: tokens.globalForeground),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalFocus,
|
||||
maxLines: 3,
|
||||
@@ -268,9 +213,11 @@ class _CommitInput extends StatelessWidget {
|
||||
void _doCommit() {
|
||||
final msg = commitMsg.text.trim();
|
||||
if (msg.isEmpty) return;
|
||||
unawaited(controller.commit(msg).then((hash) {
|
||||
unawaited(
|
||||
controller.commit(msg).then((hash) {
|
||||
if (hash != null) commitMsg.clear();
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,14 +228,7 @@ class _GroupAction {
|
||||
}
|
||||
|
||||
class _FileGroup extends StatelessWidget {
|
||||
const _FileGroup({
|
||||
required this.label,
|
||||
required this.entries,
|
||||
this.actions = const [],
|
||||
this.onStage,
|
||||
this.onUnstage,
|
||||
this.onDiscard,
|
||||
});
|
||||
const _FileGroup({required this.label, required this.entries, this.actions = const [], this.onStage, this.onUnstage, this.onDiscard});
|
||||
|
||||
final String label;
|
||||
final List<Map<String, Object?>> entries;
|
||||
@@ -309,39 +249,20 @@ class _FileGroup extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
'$label (${entries.length})',
|
||||
fontSize: clideFontCaption,
|
||||
muted: true,
|
||||
color: tokens.sidebarForeground,
|
||||
child: ClideText('$label (${entries.length})', fontSize: clideFontCaption, muted: true, color: tokens.sidebarForeground),
|
||||
),
|
||||
),
|
||||
for (final a in actions) ...[
|
||||
_SmallAction(label: a.label, onTap: a.onTap),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
for (final a in actions) ...[_SmallAction(label: a.label, onTap: a.onTap), const SizedBox(width: 4)],
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final entry in entries)
|
||||
_GitFileRow(
|
||||
entry: entry,
|
||||
onStage: onStage,
|
||||
onUnstage: onUnstage,
|
||||
onDiscard: onDiscard,
|
||||
),
|
||||
for (final entry in entries) _GitFileRow(entry: entry, onStage: onStage, onUnstage: onUnstage, onDiscard: onDiscard),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GitFileRow extends StatelessWidget {
|
||||
const _GitFileRow({
|
||||
required this.entry,
|
||||
this.onStage,
|
||||
this.onUnstage,
|
||||
this.onDiscard,
|
||||
});
|
||||
const _GitFileRow({required this.entry, this.onStage, this.onUnstage, this.onDiscard});
|
||||
|
||||
final Map<String, Object?> entry;
|
||||
final void Function(String path)? onStage;
|
||||
@@ -371,39 +292,15 @@ class _GitFileRow extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(left: 20, right: 8, top: 2, bottom: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(
|
||||
_stateIndicator(state),
|
||||
fontSize: clideFontCaption,
|
||||
color: _stateColor(state, tokens),
|
||||
),
|
||||
ClideText(_stateIndicator(state), fontSize: clideFontCaption, color: _stateColor(state, tokens)),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
child: ClideText(name, maxLines: 1, overflow: TextOverflow.ellipsis, color: tokens.sidebarForeground),
|
||||
),
|
||||
if (hovered) ...[
|
||||
if (onStage != null)
|
||||
_SmallAction(
|
||||
label: '+',
|
||||
semanticsLabel: 'stage $name',
|
||||
onTap: () => onStage!(path),
|
||||
),
|
||||
if (onUnstage != null)
|
||||
_SmallAction(
|
||||
label: '-',
|
||||
semanticsLabel: 'unstage $name',
|
||||
onTap: () => onUnstage!(path),
|
||||
),
|
||||
if (onDiscard != null)
|
||||
_SmallAction(
|
||||
label: 'x',
|
||||
semanticsLabel: 'discard changes to $name',
|
||||
onTap: () => onDiscard!(path),
|
||||
),
|
||||
if (onStage != null) _SmallAction(label: '+', semanticsLabel: 'stage $name', onTap: () => onStage!(path)),
|
||||
if (onUnstage != null) _SmallAction(label: '-', semanticsLabel: 'unstage $name', onTap: () => onUnstage!(path)),
|
||||
if (onDiscard != null) _SmallAction(label: 'x', semanticsLabel: 'discard changes to $name', onTap: () => onDiscard!(path)),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -447,11 +344,7 @@ class _GitFileRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _SmallAction extends StatelessWidget {
|
||||
const _SmallAction({
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.semanticsLabel,
|
||||
});
|
||||
const _SmallAction({required this.label, required this.onTap, this.semanticsLabel});
|
||||
|
||||
final String label;
|
||||
final String? semanticsLabel;
|
||||
@@ -467,11 +360,7 @@ class _SmallAction extends StatelessWidget {
|
||||
onTap: onTap,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: ClideText(
|
||||
label,
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
child: ClideText(label, fontSize: clideFontCaption, color: tokens.sidebarForeground),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -479,11 +368,7 @@ class _SmallAction extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _DiscardConfirmDialog extends StatelessWidget {
|
||||
const _DiscardConfirmDialog({
|
||||
required this.path,
|
||||
required this.onConfirm,
|
||||
required this.onCancel,
|
||||
});
|
||||
const _DiscardConfirmDialog({required this.path, required this.onConfirm, required this.onCancel});
|
||||
|
||||
final String path;
|
||||
final VoidCallback onConfirm;
|
||||
@@ -505,30 +390,16 @@ class _DiscardConfirmDialog extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(
|
||||
'Discard changes?',
|
||||
color: tokens.globalForeground,
|
||||
),
|
||||
ClideText('Discard changes?', color: tokens.globalForeground),
|
||||
const SizedBox(height: 8),
|
||||
ClideText(
|
||||
'Unstaged changes to $name will be permanently lost.',
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.statusError,
|
||||
),
|
||||
ClideText('Unstaged changes to $name will be permanently lost.', fontSize: clideFontCaption, color: tokens.statusError),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ClideButton(
|
||||
label: 'Cancel',
|
||||
variant: ClideButtonVariant.subtle,
|
||||
onPressed: onCancel,
|
||||
),
|
||||
ClideButton(label: 'Cancel', variant: ClideButtonVariant.subtle, onPressed: onCancel),
|
||||
const SizedBox(width: 8),
|
||||
ClideButton(
|
||||
label: 'Discard',
|
||||
onPressed: onConfirm,
|
||||
),
|
||||
ClideButton(label: 'Discard', onPressed: onConfirm),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -51,13 +51,7 @@ class _GitStatusItemState extends State<GitStatusItem> {
|
||||
|
||||
void _openBranchPicker() {
|
||||
final kernel = ClideKernel.of(context);
|
||||
kernel.dialog.show<String>(
|
||||
(ctx, dismiss) => _BranchPicker(
|
||||
ipc: kernel.ipc,
|
||||
currentBranch: _branch,
|
||||
onDismiss: dismiss,
|
||||
),
|
||||
);
|
||||
kernel.dialog.show<String>((ctx, dismiss) => _BranchPicker(ipc: kernel.ipc, currentBranch: _branch, onDismiss: dismiss));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -79,17 +73,9 @@ class _GitStatusItemState extends State<GitStatusItem> {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideIcon(
|
||||
const GitBranchIcon(),
|
||||
size: 12,
|
||||
color: tokens.statusBarForeground,
|
||||
),
|
||||
ClideIcon(const GitBranchIcon(), size: 12, color: tokens.statusBarForeground),
|
||||
const SizedBox(width: 4),
|
||||
ClideText(
|
||||
parts.join(' '),
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.statusBarForeground,
|
||||
),
|
||||
ClideText(parts.join(' '), fontSize: clideFontCaption, color: tokens.statusBarForeground),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -100,11 +86,7 @@ class _GitStatusItemState extends State<GitStatusItem> {
|
||||
}
|
||||
|
||||
class _BranchPicker extends StatefulWidget {
|
||||
const _BranchPicker({
|
||||
required this.ipc,
|
||||
required this.currentBranch,
|
||||
required this.onDismiss,
|
||||
});
|
||||
const _BranchPicker({required this.ipc, required this.currentBranch, required this.onDismiss});
|
||||
|
||||
final DaemonClient ipc;
|
||||
final String? currentBranch;
|
||||
@@ -139,9 +121,7 @@ class _BranchPickerState extends State<_BranchPicker> {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
if (r.ok) {
|
||||
_branches = [
|
||||
for (final b in (r.data['branches'] as List? ?? const [])) (b as Map).cast<String, Object?>(),
|
||||
];
|
||||
_branches = [for (final b in (r.data['branches'] as List? ?? const [])) (b as Map).cast<String, Object?>()];
|
||||
} else {
|
||||
_error = r.error?.message ?? 'failed to load branches';
|
||||
}
|
||||
@@ -210,11 +190,7 @@ class _BranchPickerState extends State<_BranchPicker> {
|
||||
final b = _branches[i];
|
||||
final name = b['name'] as String? ?? '';
|
||||
final current = b['current'] as bool? ?? false;
|
||||
return _BranchRow(
|
||||
name: name,
|
||||
current: current,
|
||||
onTap: current ? null : () => unawaited(_checkout(name)),
|
||||
);
|
||||
return _BranchRow(name: name, current: current, onTap: current ? null : () => unawaited(_checkout(name)));
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -226,11 +202,7 @@ class _BranchPickerState extends State<_BranchPicker> {
|
||||
}
|
||||
|
||||
class _BranchRow extends StatelessWidget {
|
||||
const _BranchRow({
|
||||
required this.name,
|
||||
required this.current,
|
||||
this.onTap,
|
||||
});
|
||||
const _BranchRow({required this.name, required this.current, this.onTap});
|
||||
|
||||
final String name;
|
||||
final bool current;
|
||||
@@ -250,11 +222,7 @@ class _BranchRow extends StatelessWidget {
|
||||
if (current)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ClideIcon(
|
||||
const CheckIcon(),
|
||||
size: 12,
|
||||
color: tokens.statusSuccess,
|
||||
),
|
||||
child: ClideIcon(const CheckIcon(), size: 12, color: tokens.statusSuccess),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 20),
|
||||
|
||||
@@ -26,9 +26,12 @@ class _GraphViewState extends State<GraphView> {
|
||||
|
||||
Future<void> _load() async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request('pql.exec', args: {
|
||||
final resp = await kernel.ipc.request(
|
||||
'pql.exec',
|
||||
args: {
|
||||
'argv': ['search', '--connections', '--limit', '50'],
|
||||
});
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!resp.ok) {
|
||||
setState(() {
|
||||
@@ -62,10 +65,7 @@ class _GraphViewState extends State<GraphView> {
|
||||
return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
|
||||
}
|
||||
if (_nodes.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('No linked files found.\nAdd wikilinks to your markdown files.', muted: true),
|
||||
);
|
||||
return const Padding(padding: EdgeInsets.all(12), child: ClideText('No linked files found.\nAdd wikilinks to your markdown files.', muted: true));
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: _nodes.length,
|
||||
|
||||
@@ -10,11 +10,5 @@ class IpcStatusExtension extends ClideExtension {
|
||||
String get version => '0.2.0';
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
StatusItemContribution(
|
||||
id: 'ipc-status.indicator',
|
||||
priority: 100,
|
||||
build: (_) => const ToolStatusItem(),
|
||||
),
|
||||
];
|
||||
List<ContributionPoint> get contributions => [StatusItemContribution(id: 'ipc-status.indicator', priority: 100, build: (_) => const ToolStatusItem())];
|
||||
}
|
||||
|
||||
@@ -36,7 +36,11 @@ class ToolStatusItem extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(width: 7, height: 7, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(label, fontSize: clideFontCaption, color: color),
|
||||
],
|
||||
|
||||
@@ -28,12 +28,7 @@ class KeybindingsUiExtension extends ClideExtension {
|
||||
|
||||
/// Presets that ship today, each exposed as a `keymap.preset.<name>`
|
||||
/// command that activates it.
|
||||
static const _presets = <String, String>{
|
||||
'default': 'Keymap: Default',
|
||||
'vim': 'Keymap: Vim',
|
||||
'vscode': 'Keymap: VS Code',
|
||||
'jetbrains': 'Keymap: JetBrains',
|
||||
};
|
||||
static const _presets = <String, String>{'default': 'Keymap: Default', 'vim': 'Keymap: Vim', 'vscode': 'Keymap: VS Code', 'jetbrains': 'Keymap: JetBrains'};
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
|
||||
@@ -95,18 +95,12 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
|
||||
return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
|
||||
}
|
||||
if (_content == null) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Select a .md file to preview it here.', muted: true),
|
||||
);
|
||||
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Select a .md file to preview it here.', muted: true));
|
||||
}
|
||||
return ClidePaneChrome(
|
||||
title: _path ?? 'viewer',
|
||||
subtitle: '${_content!.split('\n').length} lines',
|
||||
leading: ReaderPinButton(
|
||||
pinned: _nav?.hasPinned ?? false,
|
||||
onTap: _path != null ? _onPin : null,
|
||||
),
|
||||
leading: ReaderPinButton(pinned: _nav?.hasPinned ?? false, onTap: _path != null ? _onPin : null),
|
||||
trailing: [
|
||||
ReaderActionBar(
|
||||
canGoBack: _nav?.canGoBack ?? false,
|
||||
|
||||
@@ -66,7 +66,9 @@ class _Kv extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 90, child: ClideText(label, fontSize: 13, color: tokens.globalTextMuted)),
|
||||
Expanded(child: ClideText(value, fontSize: 13, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
Expanded(
|
||||
child: ClideText(value, fontSize: 13, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -89,7 +91,10 @@ class _Licenses extends StatelessWidget {
|
||||
final deps = snap.data!.dependencies;
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 260),
|
||||
decoration: BoxDecoration(border: Border.all(color: tokens.globalBorder), borderRadius: BorderRadius.circular(4)),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: tokens.globalBorder),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
|
||||
@@ -68,13 +68,20 @@ class MenuBarExtension extends ClideExtension {
|
||||
/// The curated File / View / Help tree (T-48). View ends with a `view.*`
|
||||
/// auto-fill so newly-registered view commands surface without edits here.
|
||||
List<TopMenu> buildClideMenuTree() => [
|
||||
TopMenu(title: 'File', mnemonic: 0, nodes: [
|
||||
TopMenu(
|
||||
title: 'File',
|
||||
mnemonic: 0,
|
||||
nodes: [
|
||||
const MenuCommandItem('file.openFolder', fallbackTitle: 'Open Folder…'),
|
||||
const MenuCommandItem('file.newWindow', fallbackTitle: 'New Window'),
|
||||
const MenuSeparator(),
|
||||
MenuCommandItem('file.closeWorkspace', fallbackTitle: 'Close Project', enabledWhen: (s) => s.project.isOpen),
|
||||
]),
|
||||
TopMenu(title: 'View', mnemonic: 0, nodes: const [
|
||||
],
|
||||
),
|
||||
TopMenu(
|
||||
title: 'View',
|
||||
mnemonic: 0,
|
||||
nodes: const [
|
||||
MenuCommandItem('view.zoomIn'),
|
||||
MenuCommandItem('view.zoomOut'),
|
||||
MenuCommandItem('view.zoomReset'),
|
||||
@@ -85,8 +92,11 @@ List<TopMenu> buildClideMenuTree() => [
|
||||
MenuCommandItem('panel.focusMode'),
|
||||
MenuSeparator(),
|
||||
MenuAutoFill('view.'),
|
||||
]),
|
||||
TopMenu(title: 'Help', mnemonic: 0, nodes: const [
|
||||
MenuCommandItem('help.about', fallbackTitle: 'About clide'),
|
||||
]),
|
||||
];
|
||||
],
|
||||
),
|
||||
TopMenu(
|
||||
title: 'Help',
|
||||
mnemonic: 0,
|
||||
nodes: const [MenuCommandItem('help.about', fallbackTitle: 'About clide')],
|
||||
),
|
||||
];
|
||||
|
||||
@@ -143,10 +143,7 @@ class _OpenFolderDialogState extends State<OpenFolderDialog> {
|
||||
onSubmitted: (_) => unawaited(_submit()),
|
||||
),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
ClideText(_error!, color: tokens.statusError, fontSize: 12),
|
||||
],
|
||||
if (_error != null) ...[const SizedBox(height: 8), ClideText(_error!, color: tokens.statusError, fontSize: 12)],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
@@ -188,17 +185,11 @@ class NotARepoDialog extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
ClideText(path, muted: true, fontSize: 13),
|
||||
const SizedBox(height: 8),
|
||||
const ClideText(
|
||||
'A clide project root requires a git repository.',
|
||||
muted: true,
|
||||
fontSize: 13,
|
||||
),
|
||||
const ClideText('A clide project root requires a git repository.', muted: true, fontSize: 13),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ClideButton(label: 'OK', onPressed: () => onDismiss()),
|
||||
],
|
||||
children: [ClideButton(label: 'OK', onPressed: () => onDismiss())],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -72,18 +72,11 @@ class MenuBar extends StatelessWidget {
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([controller, kernel.commands, kernel.project]),
|
||||
builder: (ctx, _) {
|
||||
final menus = resolveMenus(
|
||||
buildClideMenuTree(),
|
||||
kernel.commands,
|
||||
kernel,
|
||||
bindingLabel: (id) => keymapBindingLabel(kernel.keymap, id),
|
||||
);
|
||||
final menus = resolveMenus(buildClideMenuTree(), kernel.commands, kernel, bindingLabel: (id) => keymapBindingLabel(kernel.keymap, id));
|
||||
controller.setMnemonics([for (final m in menus) m.title[m.mnemonic].toLowerCase()]);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < menus.length; i++) _TopMenuButton(index: i, menu: menus[i], controller: controller, kernel: kernel),
|
||||
],
|
||||
children: [for (var i = 0; i < menus.length; i++) _TopMenuButton(index: i, menu: menus[i], controller: controller, kernel: kernel)],
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -189,11 +182,7 @@ class _TopMenuButtonState extends State<_TopMenuButton> {
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: clideInsetStandard),
|
||||
color: open || hovered ? tokens.listItemHoverBackground : null,
|
||||
child: ClideText(
|
||||
widget.menu.title,
|
||||
fontSize: 12,
|
||||
color: open || hovered ? tokens.globalForeground : tokens.chromeForeground,
|
||||
),
|
||||
child: ClideText(widget.menu.title, fontSize: 12, color: open || hovered ? tokens.globalForeground : tokens.chromeForeground),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -108,12 +108,7 @@ String? keymapBindingLabel(KeymapService keymap, String commandId) {
|
||||
/// menus. [bindingLabel] supplies the keybinding string for a command id
|
||||
/// (typically [keymapBindingLabel] bound to the keymap); when it returns null
|
||||
/// the command's own `defaultBinding` is used as a fallback.
|
||||
List<ResolvedMenu> resolveMenus(
|
||||
List<TopMenu> tree,
|
||||
CommandRegistry registry,
|
||||
KernelServices services, {
|
||||
String? Function(String commandId)? bindingLabel,
|
||||
}) {
|
||||
List<ResolvedMenu> resolveMenus(List<TopMenu> tree, CommandRegistry registry, KernelServices services, {String? Function(String commandId)? bindingLabel}) {
|
||||
final placed = <String>{
|
||||
for (final m in tree)
|
||||
for (final n in m.nodes)
|
||||
@@ -142,9 +137,7 @@ List<ResolvedMenu> resolveMenus(
|
||||
List<ResolvedNode> expand(MenuNode n) => switch (n) {
|
||||
MenuCommandItem() => [resolveItem(n)],
|
||||
MenuSeparator() => const [ResolvedSeparator()],
|
||||
MenuAutoFill(:final prefix) => [
|
||||
for (final c in _autoFill(registry, prefix, placed)) resolveItem(MenuCommandItem(c.command)),
|
||||
],
|
||||
MenuAutoFill(:final prefix) => [for (final c in _autoFill(registry, prefix, placed)) resolveItem(MenuCommandItem(c.command))],
|
||||
};
|
||||
|
||||
return [
|
||||
|
||||
@@ -84,10 +84,7 @@ class _OutputViewState extends State<OutputView> {
|
||||
child: rows.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
widget.ring.isEmpty ? 'No output yet.' : 'No output matches the filter.',
|
||||
muted: true,
|
||||
),
|
||||
child: ClideText(widget.ring.isEmpty ? 'No output yet.' : 'No output matches the filter.', muted: true),
|
||||
)
|
||||
: Stack(
|
||||
children: [
|
||||
@@ -100,12 +97,7 @@ class _OutputViewState extends State<OutputView> {
|
||||
children: [for (final r in rows) _LogRow(record: r)],
|
||||
),
|
||||
),
|
||||
if (!_following)
|
||||
Positioned(
|
||||
right: 12,
|
||||
bottom: 8,
|
||||
child: _JumpPill(onTap: _jumpToLatest),
|
||||
),
|
||||
if (!_following) Positioned(right: 12, bottom: 8, child: _JumpPill(onTap: _jumpToLatest)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -128,15 +120,9 @@ class _OutputViewState extends State<OutputView> {
|
||||
child: ClideFilterBox(address: 'output.panel', hint: 'Filter…', onChanged: _c.setText),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_Chip(
|
||||
label: 'Level: ${_c.minLevel.name}',
|
||||
onTap: () => _c.setMinLevel(LogLevel.values[(_c.minLevel.index + 1) % LogLevel.values.length]),
|
||||
),
|
||||
_Chip(label: 'Level: ${_c.minLevel.name}', onTap: () => _c.setMinLevel(LogLevel.values[(_c.minLevel.index + 1) % LogLevel.values.length])),
|
||||
const SizedBox(width: 6),
|
||||
_Chip(
|
||||
label: 'Source: ${_c.source ?? 'all'}',
|
||||
onTap: _cycleSource,
|
||||
),
|
||||
_Chip(label: 'Source: ${_c.source ?? 'all'}', onTap: _cycleSource),
|
||||
const SizedBox(width: 6),
|
||||
_Chip(label: 'Clear', onTap: _c.clear),
|
||||
],
|
||||
@@ -235,8 +221,14 @@ class _LogRow extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 92,
|
||||
child: ClideText(record.source,
|
||||
fontSize: clideFontMono, color: tokens.globalTextMuted, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.clip),
|
||||
child: ClideText(
|
||||
record.source,
|
||||
fontSize: clideFontMono,
|
||||
color: tokens.globalTextMuted,
|
||||
fontFamily: clideMonoFamily,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
|
||||
@@ -42,13 +42,7 @@ class _BacklinksViewState extends State<BacklinksView> {
|
||||
builder: (context, _) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (c.activePath == null) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
'Open a file to see its links.',
|
||||
muted: true,
|
||||
),
|
||||
);
|
||||
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Open a file to see its links.', muted: true));
|
||||
}
|
||||
return Semantics(
|
||||
label: 'backlinks for ${c.activePath}',
|
||||
@@ -62,35 +56,16 @@ class _BacklinksViewState extends State<BacklinksView> {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ClideText(
|
||||
c.activePath!.split('/').last,
|
||||
color: tokens.globalForeground,
|
||||
),
|
||||
child: ClideText(c.activePath!.split('/').last, color: tokens.globalForeground),
|
||||
),
|
||||
if (c.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ClideText(
|
||||
c.error!,
|
||||
color: tokens.statusError,
|
||||
fontSize: clideFontCaption,
|
||||
),
|
||||
),
|
||||
if (c.loading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true),
|
||||
),
|
||||
_LinkGroup(
|
||||
label: 'Backlinks',
|
||||
links: c.backlinks,
|
||||
pathKey: 'source',
|
||||
),
|
||||
_LinkGroup(
|
||||
label: 'Outlinks',
|
||||
links: c.outlinks,
|
||||
pathKey: 'target',
|
||||
child: ClideText(c.error!, color: tokens.statusError, fontSize: clideFontCaption),
|
||||
),
|
||||
if (c.loading) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
|
||||
_LinkGroup(label: 'Backlinks', links: c.backlinks, pathKey: 'source'),
|
||||
_LinkGroup(label: 'Outlinks', links: c.outlinks, pathKey: 'target'),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -101,11 +76,7 @@ class _BacklinksViewState extends State<BacklinksView> {
|
||||
}
|
||||
|
||||
class _LinkGroup extends StatelessWidget {
|
||||
const _LinkGroup({
|
||||
required this.label,
|
||||
required this.links,
|
||||
required this.pathKey,
|
||||
});
|
||||
const _LinkGroup({required this.label, required this.links, required this.pathKey});
|
||||
|
||||
final String label;
|
||||
final List<Map<String, Object?>> links;
|
||||
@@ -119,11 +90,7 @@ class _LinkGroup extends StatelessWidget {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 2),
|
||||
child: ClideText(
|
||||
'$label (${links.length})',
|
||||
fontSize: clideFontCaption,
|
||||
muted: true,
|
||||
),
|
||||
child: ClideText('$label (${links.length})', fontSize: clideFontCaption, muted: true),
|
||||
),
|
||||
if (links.isEmpty)
|
||||
const Padding(
|
||||
|
||||
@@ -71,10 +71,7 @@ class PqlController extends ChangeNotifier {
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
final r = await ipc.request('pql.search', args: {
|
||||
'terms': terms,
|
||||
'limit': 50,
|
||||
});
|
||||
final r = await ipc.request('pql.search', args: {'terms': terms, 'limit': 50});
|
||||
|
||||
_loading = false;
|
||||
if (!r.ok) {
|
||||
@@ -91,10 +88,7 @@ class PqlController extends ChangeNotifier {
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
|
||||
final r = await ipc.request('pql.files', args: {
|
||||
'glob': glob ?? '**/*.md',
|
||||
'limit': 200,
|
||||
});
|
||||
final r = await ipc.request('pql.files', args: {'glob': glob ?? '**/*.md', 'limit': 200});
|
||||
|
||||
_loading = false;
|
||||
if (!r.ok) {
|
||||
@@ -113,10 +107,7 @@ class PqlController extends ChangeNotifier {
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
final r = await ipc.request('pql.query', args: {
|
||||
'query': dsl,
|
||||
'limit': 200,
|
||||
});
|
||||
final r = await ipc.request('pql.query', args: {'query': dsl, 'limit': 200});
|
||||
|
||||
_loading = false;
|
||||
if (!r.ok) {
|
||||
|
||||
@@ -187,10 +187,7 @@ class _SearchResultRow extends StatelessWidget {
|
||||
},
|
||||
builder: (context, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.sidebarItemHover : null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
decoration: BoxDecoration(color: hovered ? tokens.sidebarItemHover : null, borderRadius: BorderRadius.circular(4)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -259,13 +256,7 @@ class _FileRow extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: focused ? Border.all(color: tokens.globalFocus, width: 1) : null,
|
||||
),
|
||||
child: ClideText(
|
||||
path,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
child: ClideText(path, maxLines: 1, overflow: TextOverflow.ellipsis, fontSize: clideFontCaption, color: tokens.sidebarForeground),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -16,11 +16,7 @@ class Problem {
|
||||
final String message;
|
||||
final String? hint;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'source': source,
|
||||
'message': message,
|
||||
if (hint != null) 'hint': hint,
|
||||
};
|
||||
Map<String, Object?> toJson() => {'source': source, 'message': message, if (hint != null) 'hint': hint};
|
||||
}
|
||||
|
||||
class ProblemsController extends ChangeNotifier {
|
||||
@@ -47,11 +43,7 @@ class ProblemsController extends ChangeNotifier {
|
||||
if (doctor.ok) {
|
||||
final db = (doctor.data['db'] as Map?)?.cast<String, Object?>();
|
||||
if (db != null && db['exists'] == false) {
|
||||
found.add(const Problem(
|
||||
source: 'pql',
|
||||
message: 'pql index database not found',
|
||||
hint: 'Run pql to build the index.',
|
||||
));
|
||||
found.add(const Problem(source: 'pql', message: 'pql index database not found', hint: 'Run pql to build the index.'));
|
||||
}
|
||||
final skill = (doctor.data['skill'] as Map?)?.cast<String, Object?>();
|
||||
if (skill != null) {
|
||||
@@ -59,37 +51,21 @@ class ProblemsController extends ChangeNotifier {
|
||||
if (project != null) {
|
||||
final state = project['state'] as String?;
|
||||
if (state == 'stale') {
|
||||
found.add(const Problem(
|
||||
source: 'pql',
|
||||
message: 'pql skill is stale — newer version available',
|
||||
hint: 'Run: pql skill install',
|
||||
));
|
||||
found.add(const Problem(source: 'pql', message: 'pql skill is stale — newer version available', hint: 'Run: pql skill install'));
|
||||
} else if (state == 'missing') {
|
||||
found.add(const Problem(
|
||||
source: 'pql',
|
||||
message: 'pql skill not installed',
|
||||
hint: 'Run: pql init --with-skill=yes',
|
||||
));
|
||||
found.add(const Problem(source: 'pql', message: 'pql skill not installed', hint: 'Run: pql init --with-skill=yes'));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
found.add(Problem(
|
||||
source: 'pql',
|
||||
message: 'pql doctor failed',
|
||||
hint: doctor.error?.message,
|
||||
));
|
||||
found.add(Problem(source: 'pql', message: 'pql doctor failed', hint: doctor.error?.message));
|
||||
}
|
||||
|
||||
final sync = await ipc.request('pql.decisions.sync');
|
||||
if (sync.ok) {
|
||||
final broken = (sync.data['broken'] as num?)?.toInt() ?? 0;
|
||||
if (broken > 0) {
|
||||
found.add(Problem(
|
||||
source: 'decisions',
|
||||
message: '$broken broken cross-reference(s) in governance/',
|
||||
hint: 'Run: pql decisions validate',
|
||||
));
|
||||
found.add(Problem(source: 'decisions', message: '$broken broken cross-reference(s) in governance/', hint: 'Run: pql decisions validate'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,8 +49,9 @@ class _ProblemsViewState extends State<ProblemsView> {
|
||||
explicitChildNodes: true,
|
||||
child: () {
|
||||
final lf = _filter.toLowerCase();
|
||||
final filtered =
|
||||
lf.isEmpty ? c.problems : c.problems.where((p) => p.message.toLowerCase().contains(lf) || p.source.toLowerCase().contains(lf)).toList();
|
||||
final filtered = lf.isEmpty
|
||||
? c.problems
|
||||
: c.problems.where((p) => p.message.toLowerCase().contains(lf) || p.source.toLowerCase().contains(lf)).toList();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -59,14 +60,18 @@ class _ProblemsViewState extends State<ProblemsView> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: ClideText('Problems (${filtered.length})', fontSize: clideFontCaption, color: tokens.sidebarForeground)),
|
||||
Expanded(
|
||||
child: ClideText('Problems (${filtered.length})', fontSize: clideFontCaption, color: tokens.sidebarForeground),
|
||||
),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'refresh problems',
|
||||
child: GestureDetector(
|
||||
onTap: () => unawaited(c.refresh()),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click, child: ClideText('Refresh', fontSize: clideFontCaption, color: tokens.sidebarForeground)),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: ClideText('Refresh', fontSize: clideFontCaption, color: tokens.sidebarForeground),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -108,31 +113,15 @@ class _ProblemRow extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClideText(
|
||||
problem.source,
|
||||
fontSize: clideFontMono,
|
||||
color: tokens.statusWarning,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
ClideText(problem.source, fontSize: clideFontMono, color: tokens.statusWarning, fontFamily: clideMonoFamily),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
problem.message,
|
||||
color: tokens.sidebarForeground,
|
||||
maxLines: 2,
|
||||
),
|
||||
),
|
||||
Expanded(child: ClideText(problem.message, color: tokens.sidebarForeground, maxLines: 2)),
|
||||
],
|
||||
),
|
||||
if (problem.hint != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 44, top: 2),
|
||||
child: ClideText(
|
||||
problem.hint!,
|
||||
fontSize: clideFontMono,
|
||||
muted: true,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
child: ClideText(problem.hint!, fontSize: clideFontMono, muted: true, fontFamily: clideMonoFamily),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -86,7 +86,9 @@ class FindInFilesController extends ChangeNotifier {
|
||||
/// results. Returns the number of files changed and matches replaced.
|
||||
/// Callers must gate on [isWorkingTreeClean] + user confirmation first.
|
||||
Future<({int files, int count})> applyReplace() async {
|
||||
final r = await ipc.request('search.replace', args: {
|
||||
final r = await ipc.request(
|
||||
'search.replace',
|
||||
args: {
|
||||
'pattern': pattern,
|
||||
'regex': regex,
|
||||
'ignoreCase': ignoreCase,
|
||||
@@ -94,7 +96,8 @@ class FindInFilesController extends ChangeNotifier {
|
||||
'exclude': _split(excludeGlobs),
|
||||
'replacement': replacement,
|
||||
'apply': true,
|
||||
});
|
||||
},
|
||||
);
|
||||
final files = (r.data['filesChanged'] as num?)?.toInt() ?? 0;
|
||||
final count = (r.data['totalCount'] as num?)?.toInt() ?? 0;
|
||||
await run(pattern); // refresh the match list against the new content
|
||||
@@ -123,13 +126,10 @@ class FindInFilesController extends ChangeNotifier {
|
||||
_running = true;
|
||||
notifyListeners();
|
||||
|
||||
final resp = await ipc.request('search.grep', args: {
|
||||
'pattern': pattern,
|
||||
'regex': regex,
|
||||
'ignoreCase': ignoreCase,
|
||||
'include': _split(includeGlobs),
|
||||
'exclude': _split(excludeGlobs),
|
||||
});
|
||||
final resp = await ipc.request(
|
||||
'search.grep',
|
||||
args: {'pattern': pattern, 'regex': regex, 'ignoreCase': ignoreCase, 'include': _split(includeGlobs), 'exclude': _split(excludeGlobs)},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
_error = resp.error?.message ?? 'search failed';
|
||||
_running = false;
|
||||
|
||||
@@ -50,17 +50,16 @@ class _SearchPanelViewState extends State<SearchPanelView> {
|
||||
if (c.replacement.isEmpty || c.matchCount == 0) return;
|
||||
final dialog = ClideKernel.of(context).dialog;
|
||||
if (!await c.isWorkingTreeClean()) {
|
||||
await dialog.show<Object>((ctx, dismiss) => _MessageDialog(
|
||||
title: 'Working tree not clean',
|
||||
body: 'Commit or stash your changes before replacing — git is the only undo.',
|
||||
dismiss: dismiss,
|
||||
));
|
||||
await dialog.show<Object>(
|
||||
(ctx, dismiss) =>
|
||||
_MessageDialog(title: 'Working tree not clean', body: 'Commit or stash your changes before replacing — git is the only undo.', dismiss: dismiss),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final confirmed = await dialog.show<bool>((ctx, dismiss) => _ConfirmDialog(
|
||||
body: 'Replace ${c.matchCount} match(es) across ${c.fileCount} file(s)? This cannot be undone in clide.',
|
||||
dismiss: dismiss,
|
||||
));
|
||||
final confirmed = await dialog.show<bool>(
|
||||
(ctx, dismiss) =>
|
||||
_ConfirmDialog(body: 'Replace ${c.matchCount} match(es) across ${c.fileCount} file(s)? This cannot be undone in clide.', dismiss: dismiss),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
await c.applyReplace();
|
||||
}
|
||||
@@ -139,13 +138,15 @@ class _SearchPanelViewState extends State<SearchPanelView> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideFilterBox(
|
||||
address: 'search.findInFiles.replace', hint: 'Replace', icon: null, debounce: Duration.zero, onChanged: c.setReplacement)),
|
||||
const SizedBox(width: 6),
|
||||
_ReplaceAllButton(
|
||||
enabled: c.replacement.isNotEmpty && c.matchCount > 0,
|
||||
tokens: tokens,
|
||||
onTap: _replaceAll,
|
||||
address: 'search.findInFiles.replace',
|
||||
hint: 'Replace',
|
||||
icon: null,
|
||||
debounce: Duration.zero,
|
||||
onChanged: c.setReplacement,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_ReplaceAllButton(enabled: c.replacement.isNotEmpty && c.matchCount > 0, tokens: tokens, onTap: _replaceAll),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
@@ -154,10 +155,16 @@ class _SearchPanelViewState extends State<SearchPanelView> {
|
||||
hint: 'files to include (e.g. *.dart)',
|
||||
icon: null,
|
||||
debounce: Duration.zero,
|
||||
onChanged: (v) => c.include = v),
|
||||
onChanged: (v) => c.include = v,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClideFilterBox(
|
||||
address: 'search.findInFiles.exclude', hint: 'files to exclude', icon: null, debounce: Duration.zero, onChanged: (v) => c.exclude = v),
|
||||
address: 'search.findInFiles.exclude',
|
||||
hint: 'files to exclude',
|
||||
icon: null,
|
||||
debounce: Duration.zero,
|
||||
onChanged: (v) => c.exclude = v,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -171,14 +178,7 @@ class _SearchPanelViewState extends State<SearchPanelView> {
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
for (final entry in groups.entries)
|
||||
_FileGroup(
|
||||
path: entry.key,
|
||||
matches: entry.value,
|
||||
tokens: tokens,
|
||||
onTap: c.openMatch,
|
||||
query: c.query,
|
||||
replacement: c.replacement,
|
||||
),
|
||||
_FileGroup(path: entry.key, matches: entry.value, tokens: tokens, onTap: c.openMatch, query: c.query, replacement: c.replacement),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -196,18 +196,15 @@ class _ModeSwitcher extends StatelessWidget {
|
||||
final SurfaceTokens tokens;
|
||||
final ValueChanged<SearchTabMode> onSelect;
|
||||
|
||||
static const _labels = {
|
||||
SearchTabMode.find: 'Find',
|
||||
SearchTabMode.vault: 'Vault',
|
||||
SearchTabMode.query: 'Query',
|
||||
SearchTabMode.markdown: 'Markdown',
|
||||
};
|
||||
static const _labels = {SearchTabMode.find: 'Find', SearchTabMode.vault: 'Vault', SearchTabMode.query: 'Query', SearchTabMode.markdown: 'Markdown'};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: tokens.panelBorder))),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: tokens.panelBorder)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final m in SearchTabMode.values) ...[
|
||||
@@ -233,13 +230,7 @@ class _ModeSwitcher extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _Toggle extends StatelessWidget {
|
||||
const _Toggle({
|
||||
required this.label,
|
||||
required this.tooltip,
|
||||
required this.active,
|
||||
required this.tokens,
|
||||
required this.onTap,
|
||||
});
|
||||
const _Toggle({required this.label, required this.tooltip, required this.active, required this.tokens, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final String tooltip;
|
||||
@@ -296,14 +287,7 @@ class _StatusText extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _FileGroup extends StatelessWidget {
|
||||
const _FileGroup({
|
||||
required this.path,
|
||||
required this.matches,
|
||||
required this.tokens,
|
||||
required this.onTap,
|
||||
required this.query,
|
||||
required this.replacement,
|
||||
});
|
||||
const _FileGroup({required this.path, required this.matches, required this.tokens, required this.onTap, required this.query, required this.replacement});
|
||||
|
||||
final String path;
|
||||
final List<SearchMatch> matches;
|
||||
@@ -336,13 +320,7 @@ class _FileGroup extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _MatchRow extends StatelessWidget {
|
||||
const _MatchRow({
|
||||
required this.match,
|
||||
required this.tokens,
|
||||
required this.onTap,
|
||||
required this.query,
|
||||
required this.replacement,
|
||||
});
|
||||
const _MatchRow({required this.match, required this.tokens, required this.onTap, required this.query, required this.replacement});
|
||||
|
||||
final SearchMatch match;
|
||||
final SurfaceTokens tokens;
|
||||
@@ -385,11 +363,17 @@ class _MatchRow extends StatelessWidget {
|
||||
return RichText(
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: TextSpan(style: _base, children: [
|
||||
text: TextSpan(
|
||||
style: _base,
|
||||
children: [
|
||||
TextSpan(text: line.substring(0, start)),
|
||||
TextSpan(text: line.substring(start, end), style: _base.copyWith(color: tokens.globalFocus, fontWeight: FontWeight.bold)),
|
||||
TextSpan(
|
||||
text: line.substring(start, end),
|
||||
style: _base.copyWith(color: tokens.globalFocus, fontWeight: FontWeight.bold),
|
||||
),
|
||||
TextSpan(text: line.substring(end)),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -403,12 +387,18 @@ class _MatchRow extends StatelessWidget {
|
||||
RichText(
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: TextSpan(text: match.preview, style: _base.copyWith(decoration: TextDecoration.lineThrough, color: tokens.globalTextMuted)),
|
||||
text: TextSpan(
|
||||
text: match.preview,
|
||||
style: _base.copyWith(decoration: TextDecoration.lineThrough, color: tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
RichText(
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: TextSpan(text: after, style: _base.copyWith(color: tokens.globalFocus)),
|
||||
text: TextSpan(
|
||||
text: after,
|
||||
style: _base.copyWith(color: tokens.globalFocus),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -437,11 +427,7 @@ class _ReplaceAllButton extends StatelessWidget {
|
||||
border: Border.all(color: tokens.buttonBorder),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: ClideText(
|
||||
'Replace all',
|
||||
fontSize: clideFontCaption,
|
||||
color: enabled ? tokens.sidebarForeground : tokens.globalTextMuted,
|
||||
),
|
||||
child: ClideText('Replace all', fontSize: clideFontCaption, color: enabled ? tokens.sidebarForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -50,13 +50,7 @@ class ReaderActionBar extends StatelessWidget {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ActionButton(
|
||||
painter: PhosphorIcons.byName('caret-left'),
|
||||
tooltip: 'Back',
|
||||
enabled: canGoBack,
|
||||
onTap: canGoBack ? onBack : null,
|
||||
tokens: tokens,
|
||||
),
|
||||
_ActionButton(painter: PhosphorIcons.byName('caret-left'), tooltip: 'Back', enabled: canGoBack, onTap: canGoBack ? onBack : null, tokens: tokens),
|
||||
const SizedBox(width: 2),
|
||||
_ActionButton(
|
||||
painter: PhosphorIcons.byName('caret-right'),
|
||||
@@ -67,23 +61,11 @@ class ReaderActionBar extends StatelessWidget {
|
||||
),
|
||||
if (hasPinned) ...[
|
||||
const SizedBox(width: 2),
|
||||
_ActionButton(
|
||||
painter: PhosphorIcons.byName('arrow-u-up-left'),
|
||||
tooltip: 'Jump to pin',
|
||||
enabled: true,
|
||||
onTap: onJumpToPin,
|
||||
tokens: tokens,
|
||||
),
|
||||
_ActionButton(painter: PhosphorIcons.byName('arrow-u-up-left'), tooltip: 'Jump to pin', enabled: true, onTap: onJumpToPin, tokens: tokens),
|
||||
],
|
||||
if (onEdit != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
_ActionButton(
|
||||
painter: PhosphorIcons.byName('pencil-simple'),
|
||||
tooltip: 'Edit in editor',
|
||||
enabled: true,
|
||||
onTap: onEdit,
|
||||
tokens: tokens,
|
||||
),
|
||||
_ActionButton(painter: PhosphorIcons.byName('pencil-simple'), tooltip: 'Edit in editor', enabled: true, onTap: onEdit, tokens: tokens),
|
||||
],
|
||||
],
|
||||
);
|
||||
@@ -118,14 +100,7 @@ class ReaderPinButton extends StatelessWidget {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _ActionButton extends StatelessWidget {
|
||||
const _ActionButton({
|
||||
required this.painter,
|
||||
required this.tooltip,
|
||||
required this.enabled,
|
||||
required this.onTap,
|
||||
required this.tokens,
|
||||
this.active = false,
|
||||
});
|
||||
const _ActionButton({required this.painter, required this.tooltip, required this.enabled, required this.onTap, required this.tokens, this.active = false});
|
||||
|
||||
final ClideIconPainter painter;
|
||||
final String tooltip;
|
||||
@@ -161,10 +136,7 @@ class _ActionButton extends StatelessWidget {
|
||||
width: 20,
|
||||
height: 20,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: hovered && enabled ? tokens.sidebarItemHover : null,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
decoration: BoxDecoration(color: hovered && enabled ? tokens.sidebarItemHover : null, borderRadius: BorderRadius.circular(3)),
|
||||
child: ClideIcon(painter, size: 11, color: color),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -69,13 +69,16 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
final shell = Platform.environment['SHELL'] ?? '/bin/bash';
|
||||
final cwd = Directory.current.path;
|
||||
|
||||
final response = await ipc.request('pane.spawn', args: {
|
||||
final response = await ipc.request(
|
||||
'pane.spawn',
|
||||
args: {
|
||||
'argv': [shell, '-l'],
|
||||
'kind': PaneKind.terminal.wire,
|
||||
'cwd': cwd,
|
||||
'cols': _terminal.viewWidth,
|
||||
'rows': _terminal.viewHeight,
|
||||
});
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!response.ok) {
|
||||
setState(() => _error = response.error?.message ?? 'spawn failed');
|
||||
@@ -120,11 +123,7 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
void _onTerminalResize(int cols, int rows, int pixelWidth, int pixelHeight) {
|
||||
final id = _paneId;
|
||||
if (id == null) return;
|
||||
_kernelIpc()?.request('pane.resize', args: {
|
||||
'id': id,
|
||||
'cols': cols,
|
||||
'rows': rows,
|
||||
});
|
||||
_kernelIpc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
|
||||
}
|
||||
|
||||
DaemonClient? _kernelIpc() => _kernel()?.ipc;
|
||||
@@ -144,12 +143,7 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
return ClidePaneChrome(
|
||||
title: 'terminal',
|
||||
subtitle: subtitle,
|
||||
child: _error != null
|
||||
? _ErrorBody(message: _error!)
|
||||
: ClidePtyView(
|
||||
terminal: _terminal,
|
||||
label: 'terminal — $subtitle',
|
||||
),
|
||||
child: _error != null ? _ErrorBody(message: _error!) : ClidePtyView(terminal: _terminal, label: 'terminal — $subtitle'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -165,11 +159,7 @@ class _ErrorBody extends StatelessWidget {
|
||||
alignment: Alignment.topLeft,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ClideText('Terminal unavailable'),
|
||||
const SizedBox(height: 4),
|
||||
ClideText(message, muted: true),
|
||||
],
|
||||
children: [const ClideText('Terminal unavailable'), const SizedBox(height: 4), ClideText(message, muted: true)],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -23,21 +23,11 @@ class ThemePickerExtension extends ClideExtension {
|
||||
// Opens the settings modal (T-238). Command id kept as `theme.pick`
|
||||
// (the welcome theme-link and other callers reference it); ⌘K opens
|
||||
// Settings, whose only section today is the theme picker.
|
||||
CommandContribution(
|
||||
id: 'theme.pick',
|
||||
command: 'theme.pick',
|
||||
title: 'Settings…',
|
||||
defaultBinding: 'ctrl+k',
|
||||
run: _pick,
|
||||
),
|
||||
CommandContribution(id: 'theme.pick', command: 'theme.pick', title: 'Settings…', defaultBinding: 'ctrl+k', run: _pick),
|
||||
// Always-visible switcher in the far-right status bar (T-234).
|
||||
// priority >= 100 places it in the right group; registered after
|
||||
// ipc-status so it sits to its right.
|
||||
StatusItemContribution(
|
||||
id: 'theme-picker.switcher',
|
||||
priority: 110,
|
||||
build: (_) => const ThemeSwitcherStatusItem(),
|
||||
),
|
||||
StatusItemContribution(id: 'theme-picker.switcher', priority: 110, build: (_) => const ThemeSwitcherStatusItem()),
|
||||
];
|
||||
|
||||
Future<IpcResponse> _pick(List<String> args) async {
|
||||
@@ -45,21 +35,10 @@ class ThemePickerExtension extends ClideExtension {
|
||||
if (ctx == null) {
|
||||
return IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'theme-picker not activated',
|
||||
),
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'theme-picker not activated'),
|
||||
);
|
||||
}
|
||||
final selected = await ctx.dialog.show<String>(
|
||||
(context, dismiss) => SettingsView(
|
||||
controller: ctx.theme,
|
||||
onDismiss: dismiss,
|
||||
),
|
||||
);
|
||||
return IpcResponse.ok(id: '', data: {
|
||||
'selected': selected ?? ctx.theme.currentName,
|
||||
});
|
||||
final selected = await ctx.dialog.show<String>((context, dismiss) => SettingsView(controller: ctx.theme, onDismiss: dismiss));
|
||||
return IpcResponse.ok(id: '', data: {'selected': selected ?? ctx.theme.currentName});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,7 @@ import 'package:flutter/widgets.dart';
|
||||
/// (D-69); the shared `theme_families` helpers keep both surfaces in sync.
|
||||
/// Selecting a theme applies it live and dismisses; Cancel just closes.
|
||||
class SettingsView extends StatefulWidget {
|
||||
const SettingsView({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onDismiss,
|
||||
});
|
||||
const SettingsView({super.key, required this.controller, required this.onDismiss});
|
||||
|
||||
final ThemeController controller;
|
||||
final void Function([String? selected]) onDismiss;
|
||||
@@ -233,9 +229,7 @@ class _ThemeRow extends StatelessWidget {
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: ClideText(displayName, color: fg),
|
||||
),
|
||||
Expanded(child: ClideText(displayName, color: fg)),
|
||||
ClideText(name, color: tokens.globalTextMuted, fontSize: clideFontCaption),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -129,19 +129,9 @@ class _ThemePopoverState extends State<_ThemePopover> {
|
||||
maxWidth: 280,
|
||||
maxHeight: 360,
|
||||
entries: [
|
||||
ClideMenuItem(
|
||||
label: 'High contrast',
|
||||
active: _hc,
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: _toggleHc,
|
||||
),
|
||||
ClideMenuItem(label: 'High contrast', active: _hc, keepOpenOnSelect: true, onSelect: _toggleHc),
|
||||
const ClideMenuSeparator(),
|
||||
for (final t in _themes)
|
||||
ClideMenuItem(
|
||||
label: t.displayName,
|
||||
active: t.name == currentBase,
|
||||
onSelect: () => _pick(t),
|
||||
),
|
||||
for (final t in _themes) ClideMenuItem(label: t.displayName, active: t.name == currentBase, onSelect: () => _pick(t)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
class TicketTypeColors {
|
||||
const TicketTypeColors({
|
||||
required this.initiative,
|
||||
required this.epic,
|
||||
required this.story,
|
||||
required this.task,
|
||||
required this.bug,
|
||||
});
|
||||
const TicketTypeColors({required this.initiative, required this.epic, required this.story, required this.task, required this.bug});
|
||||
|
||||
final Color initiative;
|
||||
final Color epic;
|
||||
|
||||
@@ -73,10 +73,7 @@ class _TicketDetailViewState extends State<TicketDetailView> {
|
||||
return ClidePaneChrome(
|
||||
title: d.id,
|
||||
subtitle: d.title,
|
||||
leading: ReaderPinButton(
|
||||
pinned: _nav?.hasPinned ?? false,
|
||||
onTap: _onPin,
|
||||
),
|
||||
leading: ReaderPinButton(pinned: _nav?.hasPinned ?? false, onTap: _onPin),
|
||||
trailing: [
|
||||
ReaderActionBar(
|
||||
canGoBack: _nav?.canGoBack ?? false,
|
||||
@@ -104,13 +101,7 @@ class _TicketDetailViewState extends State<TicketDetailView> {
|
||||
const SizedBox(height: 16),
|
||||
_SectionLabel(label: 'PARENT TREE', tokens: tokens),
|
||||
const SizedBox(height: 6),
|
||||
for (var i = 0; i < d.parents.length; i++)
|
||||
_CompactCard(
|
||||
data: d.parents[i],
|
||||
tokens: tokens,
|
||||
typeColors: typeColors,
|
||||
indent: i,
|
||||
),
|
||||
for (var i = 0; i < d.parents.length; i++) _CompactCard(data: d.parents[i], tokens: tokens, typeColors: typeColors, indent: i),
|
||||
],
|
||||
if (d.decisions.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
@@ -150,7 +141,11 @@ class _TicketHeader extends StatelessWidget {
|
||||
children: [
|
||||
ClideTooltip(
|
||||
message: detail.type ?? 'task',
|
||||
child: Container(width: 10, height: 10, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ClideText(detail.id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily),
|
||||
@@ -188,10 +183,13 @@ class _StatusControls extends StatelessWidget {
|
||||
onTap: detail.status == s
|
||||
? null
|
||||
: () async {
|
||||
final resp = await controller.ipc.request('pql.tickets.status', args: {
|
||||
final resp = await controller.ipc.request(
|
||||
'pql.tickets.status',
|
||||
args: {
|
||||
'ids': [detail.id],
|
||||
'status': s
|
||||
});
|
||||
'status': s,
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
controller.messages.publish('builtin.tickets', 'changed', {'id': detail.id});
|
||||
await controller.load(detail.id);
|
||||
@@ -266,7 +264,11 @@ class _CompactCard extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(width: 6, height: 6, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
const SizedBox(width: 8),
|
||||
@@ -312,7 +314,11 @@ class _DecisionRefCard extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(id, fontSize: clideFontSmall, color: color, fontFamily: clideMonoFamily),
|
||||
const Spacer(),
|
||||
|
||||
@@ -26,13 +26,7 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
/// ticket type; all on by default. An empty set never persists — toggling off
|
||||
/// the last one snaps all back on, so the list is never mysteriously blank.
|
||||
static const _allTypes = {'initiative', 'epic', 'story', 'task', 'bug'};
|
||||
static const _typeOrder = [
|
||||
('initiative', 'Initiative'),
|
||||
('epic', 'Epic'),
|
||||
('story', 'Story'),
|
||||
('task', 'Task'),
|
||||
('bug', 'Bug'),
|
||||
];
|
||||
static const _typeOrder = [('initiative', 'Initiative'), ('epic', 'Epic'), ('story', 'Story'), ('task', 'Task'), ('bug', 'Bug')];
|
||||
final Set<String> _enabledTypes = {..._allTypes};
|
||||
StreamSubscription<Message>? _focusSub;
|
||||
StreamSubscription<SchedulerTick>? _schedulerSub;
|
||||
@@ -107,9 +101,11 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
_focusSub = kernel.messages.subscribe(publisher: 'builtin.tickets', channel: 'focus').listen(_onFocus);
|
||||
_changedSub = kernel.messages.subscribe(publisher: 'builtin.tickets', channel: 'changed').listen((msg) {
|
||||
final id = msg.data['id'] as String?;
|
||||
unawaited(_refresh().then((_) {
|
||||
unawaited(
|
||||
_refresh().then((_) {
|
||||
if (id != null && mounted) _scrollToFocused(id);
|
||||
}));
|
||||
}),
|
||||
);
|
||||
});
|
||||
_schedulerSub = kernel.events.on<SchedulerTick>().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh());
|
||||
// The first load can fire before the project's workspace is wired into
|
||||
@@ -208,7 +204,9 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: ClideFilterBox(address: 'tickets.panel', hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v))),
|
||||
Expanded(
|
||||
child: ClideFilterBox(address: 'tickets.panel', hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ClideTappable(
|
||||
@@ -277,14 +275,7 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
/// type; double-click isolates it (chart-legend solo). One [GestureDetector]
|
||||
/// owns both so Flutter disambiguates single vs double.
|
||||
class _TypeChip extends StatelessWidget {
|
||||
const _TypeChip({
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.active,
|
||||
required this.onToggle,
|
||||
required this.onSolo,
|
||||
required this.tokens,
|
||||
});
|
||||
const _TypeChip({required this.label, required this.color, required this.active, required this.onToggle, required this.onSolo, required this.tokens});
|
||||
|
||||
final String label;
|
||||
final Color color;
|
||||
@@ -317,7 +308,11 @@ class _TypeChip extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(width: 8, height: 8, decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle)),
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(label, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: active ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
],
|
||||
@@ -412,15 +407,17 @@ class _TicketCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClideText(entry.title, fontSize: clideFontCaption),
|
||||
if (statusLabel != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
_StatusBadge(label: statusLabel, tokens: tokens, status: entry.status),
|
||||
],
|
||||
if (statusLabel != null) ...[const SizedBox(height: 6), _StatusBadge(label: statusLabel, tokens: tokens, status: entry.status)],
|
||||
],
|
||||
),
|
||||
// Hover affordance (T-327): hand the full ticket to the focused
|
||||
// Claude pane via the message bus.
|
||||
if (hovered) Positioned(top: 0, right: 0, child: _PickUpAction(id: entry.id, tokens: tokens)),
|
||||
if (hovered)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: _PickUpAction(id: entry.id, tokens: tokens),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -482,10 +479,7 @@ class _StatusBadge extends StatelessWidget {
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withAlpha(0x30),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
decoration: BoxDecoration(color: color.withAlpha(0x30), borderRadius: BorderRadius.circular(3)),
|
||||
child: ClideText(label, fontSize: clideFontBadge, color: color, fontFamily: clideMonoFamily),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,12 +20,7 @@ class VimModeIndicator extends StatelessWidget {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: ClideText(
|
||||
'-- ${service.mode.label} --',
|
||||
fontFamily: clideMonoFamily,
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.statusBarForeground,
|
||||
),
|
||||
child: ClideText('-- ${service.mode.label} --', fontFamily: clideMonoFamily, fontSize: clideFontCaption, color: tokens.statusBarForeground),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -26,10 +26,7 @@ class WelcomeExtension extends ClideExtension {
|
||||
id: 'workspace.open-project',
|
||||
command: 'workspace.open-project',
|
||||
title: 'Workspace: Open project…',
|
||||
run: (_) async => IpcResponse.ok(
|
||||
id: '',
|
||||
data: const {'note': 'project picker lands in a later tier'},
|
||||
),
|
||||
run: (_) async => IpcResponse.ok(id: '', data: const {'note': 'project picker lands in a later tier'}),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -40,15 +40,16 @@ class WelcomeView extends StatelessWidget {
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _StartColumn(tokens: tokens, kernel: kernel)),
|
||||
Expanded(
|
||||
child: _StartColumn(tokens: tokens, kernel: kernel),
|
||||
),
|
||||
const SizedBox(width: 56),
|
||||
Expanded(child: _RecentColumn(tokens: tokens, kernel: kernel)),
|
||||
Expanded(
|
||||
child: _RecentColumn(tokens: tokens, kernel: kernel),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showTips) ...[
|
||||
const SizedBox(height: 48),
|
||||
_TipsCard(tokens: tokens),
|
||||
],
|
||||
if (showTips) ...[const SizedBox(height: 48), _TipsCard(tokens: tokens)],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -119,7 +120,9 @@ class _TipsCard extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: ClideText(tips[i].$1, fontSize: clideFontMeta, color: tokens.globalTextMuted)),
|
||||
Expanded(
|
||||
child: ClideText(tips[i].$1, fontSize: clideFontMeta, color: tokens.globalTextMuted),
|
||||
),
|
||||
ClideText(tips[i].$2, fontSize: clideFontSmall, color: tokens.globalForeground, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
@@ -166,27 +169,9 @@ class _StartColumn extends StatelessWidget {
|
||||
children: [
|
||||
ClideText('START', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
const SizedBox(height: 20),
|
||||
_ActionRow(
|
||||
icon: PhosphorIcons.byName('folder'),
|
||||
label: 'Open folder…',
|
||||
shortcut: '⌘O',
|
||||
tokens: tokens,
|
||||
onTap: () => _openFolder(context),
|
||||
),
|
||||
_ActionRow(
|
||||
icon: PhosphorIcons.byName('git-branch'),
|
||||
label: 'Clone from git…',
|
||||
shortcut: '⌘G',
|
||||
tokens: tokens,
|
||||
onTap: () {},
|
||||
),
|
||||
_ActionRow(
|
||||
icon: PhosphorIcons.byName('chat-circle'),
|
||||
label: 'Start a Claude session',
|
||||
shortcut: '⌘C',
|
||||
tokens: tokens,
|
||||
onTap: () {},
|
||||
),
|
||||
_ActionRow(icon: PhosphorIcons.byName('folder'), label: 'Open folder…', shortcut: '⌘O', tokens: tokens, onTap: () => _openFolder(context)),
|
||||
_ActionRow(icon: PhosphorIcons.byName('git-branch'), label: 'Clone from git…', shortcut: '⌘G', tokens: tokens, onTap: () {}),
|
||||
_ActionRow(icon: PhosphorIcons.byName('chat-circle'), label: 'Start a Claude session', shortcut: '⌘C', tokens: tokens, onTap: () {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -199,10 +184,7 @@ class _StartColumn extends StatelessWidget {
|
||||
if (ok) {
|
||||
kernel.panels.activateTab(Slots.workspace, 'claude.primary');
|
||||
} else {
|
||||
kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(
|
||||
path: picked,
|
||||
onDismiss: () => dismiss(),
|
||||
));
|
||||
kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(path: picked, onDismiss: () => dismiss()));
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -239,15 +221,14 @@ class _ActionRow extends StatelessWidget {
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideIcon(icon, size: 18, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(child: ClideText(label, fontSize: clideFontBody, color: tokens.globalForeground)),
|
||||
Expanded(
|
||||
child: ClideText(label, fontSize: clideFontBody, color: tokens.globalForeground),
|
||||
),
|
||||
if (shortcut != null) ClideText(shortcut!, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
@@ -296,12 +277,7 @@ class _RecentColumn extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _RecentRow extends StatelessWidget {
|
||||
const _RecentRow({
|
||||
required this.project,
|
||||
required this.tokens,
|
||||
required this.onTap,
|
||||
required this.onToggleSticky,
|
||||
});
|
||||
const _RecentRow({required this.project, required this.tokens, required this.onTap, required this.onToggleSticky});
|
||||
final RecentProject project;
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
@@ -313,10 +289,7 @@ class _RecentRow extends StatelessWidget {
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -328,27 +301,36 @@ class _RecentRow extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: ClideText(project.relativePath,
|
||||
muted: true, fontSize: clideFontMeta, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
child: ClideText(
|
||||
project.relativePath,
|
||||
muted: true,
|
||||
fontSize: clideFontMeta,
|
||||
fontFamily: clideMonoFamily,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (project.branch != null) ...[
|
||||
ClideText(' · ', muted: true, fontSize: clideFontMeta),
|
||||
ClideIcon(PhosphorIcons.byName('git-branch'), size: 11, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 3),
|
||||
Flexible(
|
||||
child: ClideText(project.branch!,
|
||||
muted: true, fontSize: clideFontMeta, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
child: ClideText(
|
||||
project.branch!,
|
||||
muted: true,
|
||||
fontSize: clideFontMeta,
|
||||
fontFamily: clideMonoFamily,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_StickyToggle(
|
||||
key: ValueKey('welcome.sticky.${project.path}'),
|
||||
sticky: project.startupSticky,
|
||||
tokens: tokens,
|
||||
onTap: onToggleSticky,
|
||||
),
|
||||
_StickyToggle(key: ValueKey('welcome.sticky.${project.path}'), sticky: project.startupSticky, tokens: tokens, onTap: onToggleSticky),
|
||||
const SizedBox(width: 12),
|
||||
ClideText(project.timeAgo, muted: true, fontSize: clideFontMeta),
|
||||
],
|
||||
@@ -420,8 +402,12 @@ class _StatusLine extends StatelessWidget {
|
||||
else if (tc.allOk)
|
||||
ClideText('application ok', fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusSuccess)
|
||||
else
|
||||
ClideText(tc.missing.map((t) => '$t not found').join(' · '),
|
||||
fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusWarning),
|
||||
ClideText(
|
||||
tc.missing.map((t) => '$t not found').join(' · '),
|
||||
fontSize: clideFontSmall,
|
||||
fontFamily: clideMonoFamily,
|
||||
color: tokens.statusWarning,
|
||||
),
|
||||
ClideText(' · ', muted: true, fontSize: clideFontSmall),
|
||||
_ThemeLink(tokens: tokens, kernel: kernel, themeName: themeName),
|
||||
],
|
||||
@@ -526,16 +512,17 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> {
|
||||
controller: _controller,
|
||||
focusNode: _focus,
|
||||
style: TextStyle(
|
||||
color: tokens.globalForeground, fontSize: clideFontCaption, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback),
|
||||
color: tokens.globalForeground,
|
||||
fontSize: clideFontCaption,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamilyFallback: clideMonoFamilyFallback,
|
||||
),
|
||||
cursorColor: tokens.globalForeground,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
onSubmitted: (_) => unawaited(_submit()),
|
||||
),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
ClideText(_error!, color: tokens.statusError, fontSize: clideFontSmall),
|
||||
],
|
||||
if (_error != null) ...[const SizedBox(height: 8), ClideText(_error!, color: tokens.statusError, fontSize: clideFontSmall)],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
@@ -575,17 +562,11 @@ class _NotARepoDialog extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
ClideText(path, muted: true, fontSize: clideFontMeta),
|
||||
const SizedBox(height: 8),
|
||||
const ClideText(
|
||||
'A clide project root requires a git repository.',
|
||||
muted: true,
|
||||
fontSize: clideFontMeta,
|
||||
),
|
||||
const ClideText('A clide project root requires a git repository.', muted: true, fontSize: clideFontMeta),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ClideButton(label: 'OK', onPressed: () => onDismiss()),
|
||||
],
|
||||
children: [ClideButton(label: 'OK', onPressed: () => onDismiss())],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -56,13 +56,7 @@ class TabContribution extends ContributionPoint {
|
||||
/// A status-bar item. Order is determined by [priority] within each
|
||||
/// alignment group; negative priorities float left, positive right.
|
||||
class StatusItemContribution extends ContributionPoint {
|
||||
const StatusItemContribution({
|
||||
required super.id,
|
||||
required this.build,
|
||||
this.priority = 0,
|
||||
this.listenable,
|
||||
this.flex = 0,
|
||||
});
|
||||
const StatusItemContribution({required super.id, required this.build, this.priority = 0, this.listenable, this.flex = 0});
|
||||
|
||||
@override
|
||||
SlotId get slot => Slots.statusbar;
|
||||
@@ -80,14 +74,7 @@ class StatusItemContribution extends ContributionPoint {
|
||||
|
||||
/// A button in the main toolbar.
|
||||
class ToolbarButtonContribution extends ContributionPoint {
|
||||
const ToolbarButtonContribution({
|
||||
required super.id,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.icon,
|
||||
this.tooltip,
|
||||
this.priority = 0,
|
||||
});
|
||||
const ToolbarButtonContribution({required super.id, required this.label, required this.onPressed, this.icon, this.tooltip, this.priority = 0});
|
||||
|
||||
@override
|
||||
SlotId get slot => Slots.toolbar;
|
||||
@@ -101,14 +88,7 @@ class ToolbarButtonContribution extends ContributionPoint {
|
||||
/// A command extensions register with [CommandRegistry]. Surfaced by the
|
||||
/// command palette, the keybinding resolver, and `clide` CLI subcommands.
|
||||
class CommandContribution extends ContributionPoint {
|
||||
const CommandContribution({
|
||||
required super.id,
|
||||
required this.command,
|
||||
required this.run,
|
||||
this.title,
|
||||
this.defaultBinding,
|
||||
this.bindingWhen,
|
||||
});
|
||||
const CommandContribution({required super.id, required this.command, required this.run, this.title, this.defaultBinding, this.bindingWhen});
|
||||
|
||||
final String command; // e.g. "git.commit"
|
||||
final String? title; // "Git: Commit staged"
|
||||
@@ -124,12 +104,7 @@ class CommandContribution extends ContributionPoint {
|
||||
|
||||
/// Registers an item in the OS tray / menu-bar.
|
||||
class TrayItemContribution extends ContributionPoint {
|
||||
const TrayItemContribution({
|
||||
required super.id,
|
||||
required this.label,
|
||||
required this.onSelected,
|
||||
this.priority = 0,
|
||||
});
|
||||
const TrayItemContribution({required super.id, required this.label, required this.onSelected, this.priority = 0});
|
||||
|
||||
@override
|
||||
SlotId get slot => Slots.tray;
|
||||
@@ -141,11 +116,7 @@ class TrayItemContribution extends ContributionPoint {
|
||||
/// A named layout arrangement. One "classic" preset ships with
|
||||
/// `builtin.default-layout`; other presets can be contributed.
|
||||
class LayoutPresetContribution extends ContributionPoint {
|
||||
const LayoutPresetContribution({
|
||||
required super.id,
|
||||
required this.displayName,
|
||||
required this.slots,
|
||||
});
|
||||
const LayoutPresetContribution({required super.id, required this.displayName, required this.slots});
|
||||
|
||||
final String displayName;
|
||||
final List<LayoutSlot> slots;
|
||||
@@ -154,14 +125,7 @@ class LayoutPresetContribution extends ContributionPoint {
|
||||
/// One slot in a [LayoutPresetContribution]. Describes where the slot
|
||||
/// appears and its initial size/visibility.
|
||||
class LayoutSlot {
|
||||
const LayoutSlot({
|
||||
required this.slot,
|
||||
required this.position,
|
||||
this.defaultSize,
|
||||
this.minSize,
|
||||
this.maxSize,
|
||||
this.visible = true,
|
||||
});
|
||||
const LayoutSlot({required this.slot, required this.position, this.defaultSize, this.minSize, this.maxSize, this.visible = true});
|
||||
|
||||
final SlotId slot;
|
||||
final SlotPosition position;
|
||||
|
||||
@@ -98,15 +98,6 @@ extension ClideExtensionContextI18n on ClideExtensionContext {
|
||||
String t(String key, {String? placeholder}) => i18n.string(key, namespace: id, placeholder: placeholder);
|
||||
|
||||
/// [t] with interpolation replacers.
|
||||
String tr(
|
||||
String key, {
|
||||
String? placeholder,
|
||||
List<I18nReplacer> replacers = const [],
|
||||
}) =>
|
||||
i18n.interpolated(
|
||||
key,
|
||||
namespace: id,
|
||||
placeholder: placeholder,
|
||||
replacers: replacers,
|
||||
);
|
||||
String tr(String key, {String? placeholder, List<I18nReplacer> replacers = const []}) =>
|
||||
i18n.interpolated(key, namespace: id, placeholder: placeholder, replacers: replacers);
|
||||
}
|
||||
|
||||
@@ -45,14 +45,7 @@ class ExtensionManifest {
|
||||
if (d is String) deps.add(d);
|
||||
}
|
||||
}
|
||||
return ExtensionManifest(
|
||||
id: id,
|
||||
title: title,
|
||||
version: version,
|
||||
dependsOn: deps,
|
||||
entry: entry,
|
||||
schemaVersion: schemaVersion,
|
||||
);
|
||||
return ExtensionManifest(id: id, title: title, version: version, dependsOn: deps, entry: entry, schemaVersion: schemaVersion);
|
||||
}
|
||||
|
||||
static Future<ExtensionManifest> fromFile(File f) async => ExtensionManifest.fromYamlString(await f.readAsString());
|
||||
|
||||
@@ -54,13 +54,7 @@ class CliInstallStatus {
|
||||
|
||||
/// Result of [CliInstaller.install].
|
||||
class CliInstallResult {
|
||||
const CliInstallResult({
|
||||
required this.ok,
|
||||
required this.message,
|
||||
this.installedPath,
|
||||
this.onPath = true,
|
||||
this.fromDevTree = false,
|
||||
});
|
||||
const CliInstallResult({required this.ok, required this.message, this.installedPath, this.onPath = true, this.fromDevTree = false});
|
||||
|
||||
final bool ok;
|
||||
final String message;
|
||||
@@ -80,12 +74,8 @@ class CliInstallResult {
|
||||
/// environment, candidate client locations, the target dir) is injectable so
|
||||
/// the logic is unit-testable without a real install.
|
||||
class CliInstaller {
|
||||
CliInstaller({
|
||||
required this.resolvedExecutable,
|
||||
Map<String, String>? env,
|
||||
List<String>? bundledClientCandidates,
|
||||
String? installDir,
|
||||
}) : env = env ?? Platform.environment,
|
||||
CliInstaller({required this.resolvedExecutable, Map<String, String>? env, List<String>? bundledClientCandidates, String? installDir})
|
||||
: env = env ?? Platform.environment,
|
||||
bundledClientCandidates = bundledClientCandidates ?? _defaultBundledCandidates(resolvedExecutable, env ?? Platform.environment),
|
||||
installDir = installDir ?? _defaultInstallDir(env ?? Platform.environment);
|
||||
|
||||
@@ -132,7 +122,8 @@ class CliInstaller {
|
||||
if (src == null) {
|
||||
return const CliInstallResult(
|
||||
ok: false,
|
||||
message: 'No bundled clide client found to install. Build with '
|
||||
message:
|
||||
'No bundled clide client found to install. Build with '
|
||||
'`make build` so the C client ships inside the app bundle.',
|
||||
);
|
||||
}
|
||||
@@ -203,11 +194,7 @@ class CliInstaller {
|
||||
return null;
|
||||
}
|
||||
|
||||
String _expandedPath() => expandedPath(
|
||||
env['PATH'] ?? '',
|
||||
macOS: Platform.isMacOS,
|
||||
home: env['HOME'] ?? '',
|
||||
);
|
||||
String _expandedPath() => expandedPath(env['PATH'] ?? '', macOS: Platform.isMacOS, home: env['HOME'] ?? '');
|
||||
|
||||
static String _defaultInstallDir(Map<String, String> env) => '${env['HOME'] ?? ''}/.local/bin';
|
||||
|
||||
@@ -217,10 +204,7 @@ class CliInstaller {
|
||||
/// `Contents/MacOS/` on macOS).
|
||||
static List<String> _defaultBundledCandidates(String resolvedExecutable, Map<String, String> env) {
|
||||
final exeDir = File(resolvedExecutable).parent.path;
|
||||
return [
|
||||
if ((env['CLIDE_CLI_BIN'] ?? '').isNotEmpty) env['CLIDE_CLI_BIN']!,
|
||||
'$exeDir/clide-cli',
|
||||
];
|
||||
return [if ((env['CLIDE_CLI_BIN'] ?? '').isNotEmpty) env['CLIDE_CLI_BIN']!, '$exeDir/clide-cli'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,12 +223,7 @@ bool isDevTreeClient(String path) => _devTreeClient.hasMatch(path);
|
||||
/// platform-parameterized function so both branches are testable off-platform.
|
||||
String expandedPath(String base, {required bool macOS, String home = ''}) {
|
||||
if (!macOS) return base;
|
||||
final extras = <String>[
|
||||
if (home.isNotEmpty) '$home/.local/bin',
|
||||
'/opt/homebrew/bin',
|
||||
'/opt/homebrew/sbin',
|
||||
'/usr/local/bin',
|
||||
];
|
||||
final extras = <String>[if (home.isNotEmpty) '$home/.local/bin', '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin'];
|
||||
final existing = base.split(':').toSet();
|
||||
final missing = extras.where((p) => !existing.contains(p));
|
||||
if (missing.isEmpty) return base;
|
||||
|
||||
@@ -14,10 +14,7 @@ class ClideClipboard {
|
||||
final int historyLimit;
|
||||
final Map<Type, List<Object>> _history = {};
|
||||
|
||||
Future<void> write<T extends Object>(
|
||||
T value, {
|
||||
String Function(T)? toPlain,
|
||||
}) async {
|
||||
Future<void> write<T extends Object>(T value, {String Function(T)? toPlain}) async {
|
||||
final bucket = _history.putIfAbsent(T, () => <Object>[]);
|
||||
bucket.insert(0, value);
|
||||
if (bucket.length > historyLimit) bucket.removeLast();
|
||||
|
||||
@@ -5,9 +5,7 @@ import 'package:flutter/services.dart';
|
||||
/// (modifiers sorted, lowercased) so equality works for lookup keys.
|
||||
@immutable
|
||||
class Keybinding {
|
||||
Keybinding({required Set<String> modifiers, required String key})
|
||||
: modifiers = _canonModifiers(modifiers),
|
||||
key = key.toLowerCase();
|
||||
Keybinding({required Set<String> modifiers, required String key}) : modifiers = _canonModifiers(modifiers), key = key.toLowerCase();
|
||||
|
||||
final List<String> modifiers;
|
||||
final String key;
|
||||
|
||||
@@ -17,19 +17,12 @@ class CommandRegistry extends ChangeNotifier {
|
||||
Iterable<CommandContribution> get all => _byCommand.values;
|
||||
CommandContribution? get(String command) => _byCommand[command];
|
||||
|
||||
Future<IpcResponse> execute(
|
||||
String command, {
|
||||
List<String> args = const [],
|
||||
}) async {
|
||||
Future<IpcResponse> execute(String command, {List<String> args = const []}) async {
|
||||
final c = _byCommand[command];
|
||||
if (c == null) {
|
||||
return IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.notFound,
|
||||
kind: IpcErrorKind.notFound,
|
||||
message: 'no such command: $command',
|
||||
),
|
||||
error: IpcError(code: IpcExitCode.notFound, kind: IpcErrorKind.notFound, message: 'no such command: $command'),
|
||||
);
|
||||
}
|
||||
return c.run(args);
|
||||
|
||||
@@ -2,10 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
typedef DialogBuilder<T> = Widget Function(
|
||||
BuildContext context,
|
||||
void Function([T? result]) dismiss,
|
||||
);
|
||||
typedef DialogBuilder<T> = Widget Function(BuildContext context, void Function([T? result]) dismiss);
|
||||
|
||||
/// Single-at-a-time modal router.
|
||||
///
|
||||
@@ -70,12 +67,7 @@ class _Queued {
|
||||
/// Hosts the current dialog from [DialogRouter]. Place high in the tree
|
||||
/// (inside the WidgetsApp) so dialogs overlay every other surface.
|
||||
class DialogHost extends StatelessWidget {
|
||||
const DialogHost({
|
||||
super.key,
|
||||
required this.router,
|
||||
required this.child,
|
||||
this.backdropColor = const Color(0xC0000000),
|
||||
});
|
||||
const DialogHost({super.key, required this.router, required this.child, this.backdropColor = const Color(0xC0000000)});
|
||||
|
||||
final DialogRouter router;
|
||||
final Widget child;
|
||||
@@ -98,10 +90,7 @@ class DialogHost extends StatelessWidget {
|
||||
child: ColoredBox(
|
||||
color: backdropColor,
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
onTap: () {},
|
||||
child: b(ctx, router.dismiss),
|
||||
),
|
||||
child: GestureDetector(onTap: () {}, child: b(ctx, router.dismiss)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
class Message {
|
||||
Message({
|
||||
required this.publisher,
|
||||
required this.channel,
|
||||
required this.data,
|
||||
}) : timestamp = DateTime.now();
|
||||
Message({required this.publisher, required this.channel, required this.data}) : timestamp = DateTime.now();
|
||||
|
||||
final String publisher;
|
||||
final String channel;
|
||||
|
||||
@@ -13,13 +13,7 @@ class ClideEventEnvelope {
|
||||
final ClideEvent event;
|
||||
final DateTime timestamp;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'v': 1,
|
||||
'subsystem': event.subsystem,
|
||||
'kind': event.kind,
|
||||
'ts': timestamp.toIso8601String(),
|
||||
'data': event.payload(),
|
||||
};
|
||||
Map<String, Object?> toJson() => {'v': 1, 'subsystem': event.subsystem, 'kind': event.kind, 'ts': timestamp.toIso8601String(), 'data': event.payload()};
|
||||
}
|
||||
|
||||
class DaemonConnectionChanged extends ClideEvent {
|
||||
@@ -89,12 +83,7 @@ class ExtensionDeactivated extends ClideEvent {
|
||||
/// narrow by subsystem+kind, or register a converter that emits a typed
|
||||
/// `ClideEvent` subclass into the bus.
|
||||
class DaemonEvent extends ClideEvent {
|
||||
const DaemonEvent({
|
||||
required this.subsystem,
|
||||
required this.kind,
|
||||
required this.data,
|
||||
required this.ts,
|
||||
});
|
||||
const DaemonEvent({required this.subsystem, required this.kind, required this.data, required this.ts});
|
||||
|
||||
@override
|
||||
final String subsystem;
|
||||
@@ -160,11 +149,7 @@ class TeamMemberJoined extends ClideEvent {
|
||||
/// A Claude Code tmux teammate's pane went away (it exited or the team
|
||||
/// dissolved) — T-139.
|
||||
class TeamMemberLeft extends ClideEvent {
|
||||
const TeamMemberLeft({
|
||||
required this.team,
|
||||
required this.agentId,
|
||||
required this.paneId,
|
||||
});
|
||||
const TeamMemberLeft({required this.team, required this.agentId, required this.paneId});
|
||||
|
||||
final String team;
|
||||
final String agentId;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user