Compare commits
61
Commits
2f6c11e9a6
..
v2.0.0
@@ -0,0 +1,193 @@
|
||||
---
|
||||
name: pql
|
||||
description: >
|
||||
Query and plan against a markdown vault via the pql CLI. Two surfaces:
|
||||
(1) structural queries — frontmatter, wikilinks, tags, headings, Bases,
|
||||
DSL — use when the user asks about vault contents ("which notes…", "find
|
||||
where…", "what tags", "who links to X", "run a Base", "query the vault");
|
||||
(2) planning — decision records, tickets, project status — use when the
|
||||
user asks about decisions, tickets, work items, or project planning
|
||||
("sync decisions", "create a ticket", "what's the plan status", "show
|
||||
D-5", "board"). Requires `pql` on PATH. JSON on stdout; exit 2 = zero
|
||||
matches (not an error).
|
||||
---
|
||||
|
||||
# pql — vault queries + project planning
|
||||
|
||||
`pql` indexes a vault into SQLite and exposes structural queries plus a
|
||||
planning layer for decision records and tickets. One binary, two surfaces.
|
||||
|
||||
## Precondition
|
||||
|
||||
```bash
|
||||
command -v pql
|
||||
```
|
||||
|
||||
If absent, tell the user to install from
|
||||
https://github.com/postmeridiem/pql/releases/latest. Don't install it
|
||||
yourself. Don't fall back to grep unless the user explicitly asks.
|
||||
|
||||
## First touch: learn the vault
|
||||
|
||||
```bash
|
||||
pql schema
|
||||
```
|
||||
|
||||
Returns one row per frontmatter key with observed types and file counts.
|
||||
Run once per session before writing queries.
|
||||
|
||||
---
|
||||
|
||||
## Surface 1: Vault queries
|
||||
|
||||
### Subcommands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `pql files [glob]` | List indexed files; optional glob filter |
|
||||
| `pql tags [--sort count]` | Distinct tags with counts |
|
||||
| `pql backlinks <path>` | Files linking TO a path |
|
||||
| `pql outlinks <path>` | Links FROM a file |
|
||||
| `pql meta <path>` | Frontmatter + tags + outlinks + headings for one file |
|
||||
| `pql schema` | Typed frontmatter schema |
|
||||
| `pql base <name>` | Execute an Obsidian .base file |
|
||||
| `pql shell` | Interactive REPL (indexes once, then query per line) |
|
||||
| `pql query "<DSL>"` | SQL-derived DSL for complex queries |
|
||||
| `pql doctor` | Resolved vault/config/DB/index state |
|
||||
|
||||
### DSL examples
|
||||
|
||||
```sql
|
||||
SELECT name, fm.date WHERE fm.type = 'meeting' ORDER BY fm.date DESC LIMIT 10
|
||||
SELECT path WHERE 'project' IN tags ORDER BY path
|
||||
SELECT name, fm.prior_job WHERE fm.type = 'council-member' ORDER BY name
|
||||
```
|
||||
|
||||
Use `--file q.pql` or `--stdin` for long queries. Don't interpolate vault
|
||||
content into the command line.
|
||||
|
||||
### Query cookbook
|
||||
|
||||
- **Files in folder** → `pql files 'sessions/*'`
|
||||
- **Top tags** → `pql tags --sort count --limit 20`
|
||||
- **What links to X?** → `pql backlinks members/vaasa/persona.md`
|
||||
- **Date range** → `pql query "SELECT name, fm.date WHERE fm.date BETWEEN '2024-01-01' AND '2024-12-31'"`
|
||||
- **Run a Base** → `pql base council-sessions`
|
||||
- **Inspect one file** → `pql meta members/vaasa/persona.md --pretty`
|
||||
|
||||
---
|
||||
|
||||
## Surface 2: Planning (decisions + tickets)
|
||||
|
||||
Planning state lives in `<vault>/.pql/pql.db` (user-authored state, not a
|
||||
cache). Decision records come from `decisions/*.md`; tickets are
|
||||
SQLite-native.
|
||||
|
||||
### Decision subcommands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `pql decisions sync` | Parse decisions/*.md → upsert into pql.db |
|
||||
| `pql decisions validate` | Dry-run parse; exits non-zero on malformed records |
|
||||
| `pql decisions claim <D\|Q\|R> <domain> "title"` | Print next available ID |
|
||||
| `pql decisions list [--type X] [--domain X] [--status X]` | List decisions |
|
||||
| `pql decisions show <id> [--with-refs] [--with-tickets]` | Show with joins |
|
||||
| `pql decisions coverage` | Confirmed decisions without tickets |
|
||||
| `pql decisions refs <id>` | Cross-references involving a decision |
|
||||
|
||||
Always `pql decisions sync` before querying if decisions/*.md may have changed.
|
||||
|
||||
### Ticket subcommands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `pql ticket new <type> "title" [--decision D-NNN] [--priority P]` | Create (emits T-NNN) |
|
||||
| `pql ticket list [--status S] [--team T] [--assigned A] [--label L]` | List with filters |
|
||||
| `pql ticket show <id> [--with-context] [--with-blockers]` | Show with joins (context = ancestors + decisions + children) |
|
||||
| `pql ticket status <id[,id,...]> <new-status>` | Transition (batch via comma-separated IDs) |
|
||||
| `pql ticket assign <id> <agent>` | Set assignee |
|
||||
| `pql ticket block <id> --by <other>` | Add blocker |
|
||||
| `pql ticket unblock <id> --from <other>` | Remove blocker |
|
||||
| `pql ticket team <id> <team>` | Set team |
|
||||
| `pql ticket label <id> add\|rm <label>` | Manage labels |
|
||||
| `pql ticket board [--team T]` | Kanban board view |
|
||||
|
||||
Ticket types: initiative, epic, story, task, bug.
|
||||
Status flow: backlog → ready → in_progress → review → done (also cancelled).
|
||||
|
||||
### Plan subcommands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `pql plan status` | Dashboard: decision counts, open Qs, ticket summary, coverage gaps |
|
||||
| `pql plan export [--to FILE]` | Snapshot planning state to JSON (default: `pql-plan.json`) |
|
||||
| `pql plan import [--from FILE]` | Restore planning state from a JSON snapshot |
|
||||
|
||||
### Versioning planning state
|
||||
|
||||
Planning state lives in `pql.db` (gitignored). To version it in git,
|
||||
use `pql plan export` to write a committed JSON snapshot. pql does NOT
|
||||
do this automatically — the user decides when and how to trigger it:
|
||||
|
||||
- Pre-push hook: `.githooks/pre-push` calls `pql plan export && git add pql-plan.json`
|
||||
- Sprint close: a skill or script exports + commits on milestone
|
||||
- Manual: run `pql plan export` before committing when state changed
|
||||
|
||||
On a fresh clone, `pql plan import` restores from the snapshot.
|
||||
|
||||
### Planning cookbook
|
||||
|
||||
- **Sync and list confirmed** → `pql decisions sync && pql decisions list --type confirmed`
|
||||
- **Show with refs** → `pql decisions show D-5 --with-refs --pretty`
|
||||
- **Create ticket** → `pql ticket new task "implement X" --decision D-5`
|
||||
- **Batch close** → `pql ticket status T-1,T-2,T-3 done`
|
||||
- **Coverage gaps** → `pql decisions coverage`
|
||||
- **Dashboard** → `pql plan status --pretty`
|
||||
- **Snapshot for git** → `pql plan export`
|
||||
|
||||
---
|
||||
|
||||
## Output contract (both surfaces)
|
||||
|
||||
- **stdout:** JSON array (default); `--jsonl` for one object/line; `--pretty`; `--limit N`.
|
||||
- **stderr:** JSON diagnostics `{"level":"…","code":"pql.<phase>.<kind>","msg":"…"}`.
|
||||
- **Exit codes:**
|
||||
- `0` — success, ≥1 result
|
||||
- `2` — zero matches (not an error — say "no matches", not "failed")
|
||||
- `64` — bad flag
|
||||
- `65` — parse/compile error (pass stderr back)
|
||||
- `66` — vault/config not found
|
||||
- `69` — unavailable
|
||||
- `70` — internal error
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Don't pipe to `jq` for simple projections — use `--limit`, `--pretty`, `--jsonl`.
|
||||
- Don't chain `pql files` + `pql meta` — one `pql query` with WHERE.
|
||||
- Don't parse errors — pass stderr diagnostics back directly.
|
||||
- Don't forget `pql decisions sync` before querying decisions.
|
||||
- Don't try to install or upgrade pql — instruct the user if missing.
|
||||
|
||||
## When NOT to use
|
||||
|
||||
- **Body text search** → `grep`/`rg`.
|
||||
- **Reading file contents** → `Read` tool.
|
||||
- **Code structure** → tree-sitter / LSP.
|
||||
- **Modifying vault files** → `Write`/`Edit`. pql doesn't write to vault content.
|
||||
|
||||
## Permissions
|
||||
|
||||
The consuming project's `.claude/settings.json` should allow:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["Bash(pql)", "Bash(pql *)"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Updating the skill
|
||||
|
||||
`pql skill status` reports drift. `pql skill install` writes/updates;
|
||||
`--force` overrides hand-edits. `pql doctor` also surfaces skill state.
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: testmode
|
||||
description: >
|
||||
Run and interpret the ClideTestApp platform integration harness.
|
||||
Use after modifying toolchain resolution, IPC dispatch, extension
|
||||
lifecycle, theme loading, or native platform code. Also use when
|
||||
the user says "run testmode", "test the app", "smoke test", or
|
||||
"verify the build". Triggers on changes to: lib/kernel/src/toolchain.dart,
|
||||
lib/src/daemon/dispatcher.dart, lib/src/ipc/, lib/extension/,
|
||||
lib/kernel/src/theme/, lib/builtin/*/src/extension.dart,
|
||||
linux/CMakeLists.txt, macos/, Makefile (run targets), lib/main.dart.
|
||||
---
|
||||
|
||||
# Testmode harness
|
||||
|
||||
`ClideTestApp` is a lightweight Flutter app that boots the real platform
|
||||
layer, runs structured tests, prints results, and exits. It catches
|
||||
regressions that unit tests cannot see: missing binaries, broken
|
||||
subprocess wiring, IPC dispatch failures, extension activation order,
|
||||
and theme parse errors.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
make run-testmode # all categories, 60s timeout
|
||||
make run-testmode TESTMODE_CATEGORY=toolchain
|
||||
make run-testmode TESTMODE_CATEGORY=ipc
|
||||
make run-testmode TESTMODE_CATEGORY=extensions
|
||||
make run-testmode TESTMODE_TIMEOUT=120 # longer timeout for slow builds
|
||||
```
|
||||
|
||||
Results go to stdout **and** `/tmp/clide-testmode.log`. The last line
|
||||
of testmode output is machine-readable:
|
||||
|
||||
```
|
||||
[testmode:json] {"passed":33,"failed":0,"total":33,"failures":[]}
|
||||
```
|
||||
|
||||
Exit code is non-zero when any test fails. The Makefile verifies
|
||||
`"failed":0` in the log via `grep -q`.
|
||||
|
||||
## When to run which category
|
||||
|
||||
| Changed area | Category | Why |
|
||||
|---|---|---|
|
||||
| Toolchain, PATH, dugite, ptyc, shell | `toolchain` | Binary resolution + exec |
|
||||
| IPC envelope, dispatcher, schema | `ipc` | Round-trip + error contract |
|
||||
| Extension manifest, activate, contributions | `extensions` | Register + activate lifecycle |
|
||||
| Theme YAML, loader, palette | `extensions` | Theme parse is in this category |
|
||||
| Platform config (CMakeLists, pbxproj, Makefile) | `all` | Full rebuild validates everything |
|
||||
| Any doubt | `all` | ~30s, cheap insurance |
|
||||
|
||||
## Interpreting output
|
||||
|
||||
- `[testmode] exec | ... | OK` — subprocess ran, exit 0 or 1.
|
||||
- `[testmode] PASS | ...` — assertion passed (IPC / extension tests).
|
||||
- `[testmode] FAIL | ...` — assertion failed. The output column says why.
|
||||
- `[testmode] exec | ... | EXCEPTION` — binary not found or not executable.
|
||||
- `[testmode] exec | ... | TIMEOUT` — subprocess hung (5s limit).
|
||||
|
||||
If the app never prints `[testmode]` lines, the testmode gate in
|
||||
`main.dart` didn't fire — check that `CLIDE_TESTMODE` is set to a
|
||||
non-empty string (not a bare bool).
|
||||
|
||||
## Adding tests
|
||||
|
||||
All test logic lives in `lib/test_app.dart`. Pattern:
|
||||
|
||||
```dart
|
||||
// In the appropriate _run*Tests method:
|
||||
await _testExec('label', binary, ['args'], workDir);
|
||||
// or
|
||||
_addResult('label', boolCondition, 'detail string');
|
||||
```
|
||||
|
||||
New categories: add a `runFoo` bool from the `category` string,
|
||||
gate a new `_runFooTests` method, and add the category name to
|
||||
the Makefile help comment and this skill's table above.
|
||||
|
||||
Extension tests must include transitive dependencies — e.g.
|
||||
`GitExtension` requires `DiffExtension` to activate first.
|
||||
|
||||
## Source files
|
||||
|
||||
- `lib/test_app.dart` — all test logic and UI
|
||||
- `lib/main.dart:54` — testmode gate (`String.fromEnvironment`)
|
||||
- `Makefile:42–56` — `run-testmode` target with category variable
|
||||
@@ -2,11 +2,12 @@
|
||||
name: theme-ui
|
||||
description: >-
|
||||
Token selection guide for clide UI development. Use when building or
|
||||
modifying widgets, panels, pane chrome, status indicators, or any
|
||||
visual surface. Ensures correct background, border, text, and hover
|
||||
modifying widgets, panels, pane chrome, status indicators, icons, or
|
||||
any visual surface. Ensures correct background, border, text, and hover
|
||||
tokens are applied per surface type. Triggers on: new widget code,
|
||||
theme-related changes, "which token", "what color", color/background
|
||||
questions, visual inconsistency fixes, new panel/pane/view development.
|
||||
questions, visual inconsistency fixes, new panel/pane/view development,
|
||||
adding or looking up Phosphor icons, icon codepoints.
|
||||
---
|
||||
|
||||
# Theme-UI — token selection for clide surfaces
|
||||
@@ -167,6 +168,31 @@ types, priority levels) should NOT add tokens to `SurfaceTokens`. Instead:
|
||||
This keeps the core token surface lean and lets each extension own its
|
||||
palette. The pattern scales to any extension needing domain colors.
|
||||
|
||||
## Icons — Phosphor Icons
|
||||
|
||||
The app bundles Phosphor Icons (v2.0.8, MIT) as TTF fonts at
|
||||
`assets/fonts/phosphor/` (regular, bold, fill weights).
|
||||
|
||||
**Codepoint reference:** `assets/fonts/phosphor/codepoints.csv` —
|
||||
full mapping of all 1512 icon codepoints to kebab-case and PascalCase
|
||||
names. Read this file to look up any icon by name or codepoint.
|
||||
|
||||
**Adding an icon:** find the codepoint in `codepoints.csv`, then add
|
||||
a `static const` entry to `PhosphorIcons` in
|
||||
`lib/widgets/src/icons/phosphor.dart`:
|
||||
|
||||
```dart
|
||||
static const arrowClockwise = PhosphorIconPainter(0xe036);
|
||||
```
|
||||
|
||||
Only add icons we actually use — don't bulk-import the full set.
|
||||
|
||||
**Using an icon:** `ClideIcon(PhosphorIcons.arrowClockwise, size: 13)`
|
||||
or as a `TabContribution` icon field: `icon: PhosphorIcons.lightbulb`.
|
||||
|
||||
**Bold weight:** pass `family: 'Phosphor-Bold'` to `PhosphorIconPainter`.
|
||||
Fill weight: `family: 'Phosphor-Fill'`.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Borrowing another surface's token (`sidebarBackground` for hat bar)
|
||||
|
||||
+8
-4
@@ -1,3 +1,6 @@
|
||||
# -- macOS sandbox (machine-specific paths; template is committed) ------
|
||||
/macos/Runner/DebugProfile.entitlements
|
||||
|
||||
# -- Flutter / Dart (single package at repo root) -----------------------
|
||||
/.dart_tool/
|
||||
/.packages
|
||||
@@ -27,6 +30,9 @@ tools/ui/.serve.pid
|
||||
/ptyc/bin/
|
||||
/ptyc/*.o
|
||||
|
||||
# -- dugite-native (bundled git, downloaded at build time) ---------------
|
||||
/native/dugite/
|
||||
|
||||
# -- Test, coverage, profile output ------------------------------------
|
||||
*.test
|
||||
*.out
|
||||
@@ -41,7 +47,8 @@ coverage.*
|
||||
test/goldens/failures/
|
||||
|
||||
# -- pql per-repo state (index + planning DB, rebuilt from markdown) ----
|
||||
/.pql/
|
||||
/.pql/*
|
||||
!/.pql/pql-plan.json
|
||||
|
||||
# -- SQLite index files (defensive; should never land at repo root) ----
|
||||
*.sqlite
|
||||
@@ -88,6 +95,3 @@ legacy/**/.coverage
|
||||
legacy/**/.coverage.*
|
||||
.clide/settings.yaml
|
||||
.claude/skills/pql/
|
||||
.pql/*
|
||||
!.pql/pql-plan.json
|
||||
!.pql/hooks/
|
||||
|
||||
+3506
File diff suppressed because it is too large
Load Diff
+117
-1
@@ -16,8 +16,72 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.0.0] — 2026-05-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- PTY FFI constants now platform-dispatched: `TIOCSWINSZ` (`0x80087467`
|
||||
macOS / `0x5414` Linux), `O_NONBLOCK` (`0x0004` / `0x0800`), and
|
||||
`MsghdrDarwin` struct with correct 4-byte field widths for macOS
|
||||
`recvmsg()`.
|
||||
|
||||
- App settings directory uses `~/Library/Application Support/clide` on
|
||||
macOS instead of `~/.config/clide`.
|
||||
|
||||
- Removed hardcoded `TERMINFO=/usr/share/terminfo` from pane spawn
|
||||
environment — let the system resolve terminfo per platform.
|
||||
|
||||
- Panel `tabsFor()` now sorts by contribution priority when no user
|
||||
order is set.
|
||||
|
||||
### Changed
|
||||
|
||||
- Canonical upstream moved from Gitea to GitHub
|
||||
(`github.com/postmeridiem/clide`).
|
||||
|
||||
- README rewritten to reflect current single-process Flutter
|
||||
architecture, built-in extensions, and build commands.
|
||||
|
||||
- Renamed desktop binary from `clide_app` to `clide` (Linux + macOS).
|
||||
|
||||
- macOS app icon uses the black-circle variant matching the Linux
|
||||
desktop icon.
|
||||
|
||||
- ClideTestApp expanded to three test categories (toolchain, ipc,
|
||||
extensions) with JSON summary, non-zero exit on failure, and
|
||||
`make run-testmode TESTMODE_CATEGORY=<cat>` for selective runs.
|
||||
|
||||
### Added
|
||||
|
||||
- `make install` / `make uninstall` — builds the release bundle and
|
||||
installs it to `~/.local/` with XDG desktop entry, icon registration
|
||||
at seven sizes, and icon cache refresh. macOS installs to
|
||||
`~/Applications/clide.app`.
|
||||
|
||||
- Backend isolate — all subprocess and file I/O runs in a dedicated
|
||||
isolate, keeping the merged UI/platform thread on macOS free for
|
||||
rendering. Communicates via SendPort using the existing IPC protocol.
|
||||
Two-phase boot: resolve toolchain on spawn, initialize services on
|
||||
project open. Scheduler ticker only runs while a project is active.
|
||||
|
||||
- Toolchain — centralized binary resolution replacing five ad-hoc
|
||||
mechanisms. Resolves git, pql, tmux, ptyc, shell once at boot via
|
||||
background isolate. Status bar reads `toolchain.missing` directly.
|
||||
|
||||
- GitClient — typed Dart API wrapping all git operations. Every
|
||||
subprocess call goes through `_run()` with toolchain-resolved path
|
||||
and environment. Replaces scattered `Process.run('git', ...)` calls.
|
||||
|
||||
- Native directory picker — macOS NSOpenPanel via method channel in
|
||||
AppDelegate, GTK file chooser on Linux. Falls back to text-input
|
||||
dialog on web. Shows "No git repo found" dialog on invalid selection.
|
||||
|
||||
- macOS desktop target — OS-detecting Makefile (`make run` works on
|
||||
macOS/Linux/Windows), 1280x720 default window, squared app icons,
|
||||
sandbox entitlements with SBPL exceptions, `_DARWIN_C_SOURCE` for
|
||||
ptyc compilation, native traffic dots skipped (macOS titlebar owns
|
||||
them), expanded PATH for Homebrew and `~/.local/bin` on GUI apps.
|
||||
|
||||
- Shared ClideFilterBox widget with search icon, clear button, and
|
||||
debounced input. Applied consistently across all sidebar panes:
|
||||
Files (path filter with flat results), Git (filter staged/unstaged
|
||||
@@ -84,6 +148,46 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
`clideFontBadge` (11) for sidebar metadata and status badges.
|
||||
`clideLineHeight` (1.25) applied app-wide via `DefaultTextStyle`.
|
||||
|
||||
- `files.read` IPC command for reading file content by path.
|
||||
|
||||
- pql sidebar restructured: Search tab is the default left tab with
|
||||
ranked text search (debounced, scored results with score bar) and
|
||||
a DSL toggle for raw PQL query mode; Markdown tab (filtered to
|
||||
`.md` files) on the right. Clicking a result opens it in the
|
||||
context panel markdown viewer with bidirectional focus highlighting.
|
||||
|
||||
- Kernel scheduler service with tiered timers (1min, 10min, 15min,
|
||||
1hr, midnight) running on a background isolate. Emits `SchedulerTick`
|
||||
events on the `DaemonBus`. Extensions subscribe by tier (T-61).
|
||||
|
||||
- Auto-refresh for sidebar panels: decisions refresh on file changes
|
||||
to `decisions/*.md` and on 1-minute scheduler tick; tickets refresh
|
||||
on 1-minute tick and on status change events (T-62).
|
||||
|
||||
- Manual refresh button (arrowClockwise icon) in decisions, tickets,
|
||||
and pql markdown panels (T-63).
|
||||
|
||||
- `ClideAccordion` shared widget — extracted from tickets and
|
||||
decisions views. Supports optional `leading` widget (color dot).
|
||||
Pin/focus accordion logic: manually toggled sections are pinned;
|
||||
the focused item's section auto-opens; unpinned sections without
|
||||
focus auto-collapse.
|
||||
|
||||
- Ticket status buttons wired to `pql ticket status` — clicking a
|
||||
status in the detail view transitions the ticket, refreshes the
|
||||
sidebar list, and scrolls the ticket into its new section.
|
||||
|
||||
- `pql.tickets.status` IPC command accepting a list of IDs for
|
||||
batch status transitions.
|
||||
|
||||
- Ticket sidebar sections split into individual statuses: IN
|
||||
PROGRESS, REVIEW, READY, BACKLOG, DONE, CANCELLED (was four
|
||||
coarse groups).
|
||||
|
||||
- Phosphor Icons codepoint reference CSV at
|
||||
`assets/fonts/phosphor/codepoints.csv` — full mapping of all
|
||||
1512 icon glyphs to kebab-case and PascalCase names.
|
||||
|
||||
- Graph view in context panel — lists files with inbound/outbound
|
||||
link counts from `pql search --connections` (T-39).
|
||||
|
||||
@@ -113,7 +217,13 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
`clideFontMono` to match surrounding text weight.
|
||||
|
||||
- Default sidebar and context panel widths widened to 400px and
|
||||
420px respectively (previous maximums).
|
||||
420px respectively (previous maximums). Context panel max raised
|
||||
to 1000px.
|
||||
|
||||
- Ticket detail loading uses `pql ticket show --with-context` for
|
||||
a single-call fetch of ancestors, decisions, and children.
|
||||
Replaces N+1 parent-chain walk. `--with-decision` and
|
||||
`--with-children` flags consolidated into `--with-context`.
|
||||
|
||||
- Ticket descriptions render through ClideMarkdown instead of plain
|
||||
text, with clickable DQRT record links.
|
||||
@@ -121,6 +231,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
- Decision detail view subscribes to the message bus for navigation
|
||||
(same pattern as tickets), enabling navigation from any source.
|
||||
|
||||
- TreeSitterService is now a shared singleton — eliminates native
|
||||
double-free crashes from multiple WASM engine instances.
|
||||
|
||||
- Code block syntax highlighting fixes overlapping tree-sitter spans
|
||||
that caused duplicated text (e.g. `makemake` instead of `make`).
|
||||
|
||||
- Context panel drag resize fixed — dragging left now correctly
|
||||
grows the right panel instead of shrinking it.
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
See [`decisions/README.md`](decisions/README.md).
|
||||
@@ -5,10 +5,20 @@
|
||||
|
||||
INSTALL_DIR ?= $(HOME)/.local/bin
|
||||
|
||||
VERSION_BASE ?= $(shell awk -F': *' '/^version:/ {gsub(/[" ]/,"",$$2); print $$2; exit}' pubspec.yaml)
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
DIRTY := $(shell git diff --quiet HEAD 2>/dev/null || echo .dirty)
|
||||
VERSION ?= $(VERSION_BASE)+$(COMMIT)$(DIRTY)
|
||||
# -- OS detection ------------------------------------------------------------
|
||||
# Maps uname to Flutter device/build targets: linux, macos, windows.
|
||||
|
||||
UNAME_S := $(shell uname -s)
|
||||
ifeq ($(UNAME_S),Linux)
|
||||
FLUTTER_OS := linux
|
||||
else ifeq ($(UNAME_S),Darwin)
|
||||
FLUTTER_OS := macos
|
||||
else
|
||||
# Windows (MSYS2, Git Bash, Cygwin all report variants with "MINGW"/"MSYS"/"CYGWIN")
|
||||
FLUTTER_OS := windows
|
||||
endif
|
||||
|
||||
VERSION ?= $(shell awk -F': *' '/^version:/ {gsub(/[" ]/,"",$$2); print $$2; exit}' pubspec.yaml)
|
||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
.PHONY: help
|
||||
@@ -19,7 +29,35 @@ help: ## Show this help.
|
||||
|
||||
.PHONY: run
|
||||
run: ## Launch the Flutter desktop app.
|
||||
GDK_BACKEND=x11 LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter run -d linux
|
||||
ifeq ($(FLUTTER_OS),linux)
|
||||
GDK_BACKEND=x11 LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter run -d linux --dart-define=CLIDE_PROJECT=$(CURDIR)
|
||||
else
|
||||
flutter run -d $(FLUTTER_OS) --dart-define=CLIDE_PROJECT=$(CURDIR)
|
||||
endif
|
||||
|
||||
TESTMODE_CATEGORY ?= all
|
||||
TESTMODE_TIMEOUT ?= 60
|
||||
|
||||
.PHONY: run-testmode
|
||||
run-testmode: ## Launch ClideTestApp (TESTMODE_CATEGORY=toolchain|ipc|extensions|all).
|
||||
ifeq ($(FLUTTER_OS),linux)
|
||||
@GDK_BACKEND=x11 LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} \
|
||||
flutter run -d linux \
|
||||
--dart-define=CLIDE_PROJECT=$(CURDIR) \
|
||||
--dart-define=CLIDE_TESTMODE=$(TESTMODE_CATEGORY) 2>&1 \
|
||||
| tee /tmp/clide-testmode.log & PID=$$!; \
|
||||
(sleep $(TESTMODE_TIMEOUT) && kill $$PID 2>/dev/null) & TIMER=$$!; \
|
||||
wait $$PID 2>/dev/null; kill $$TIMER 2>/dev/null; \
|
||||
grep -q '"failed":0' /tmp/clide-testmode.log
|
||||
else
|
||||
@flutter run -d $(FLUTTER_OS) \
|
||||
--dart-define=CLIDE_PROJECT=$(CURDIR) \
|
||||
--dart-define=CLIDE_TESTMODE=$(TESTMODE_CATEGORY) 2>&1 \
|
||||
| tee /tmp/clide-testmode.log & PID=$$!; \
|
||||
(sleep $(TESTMODE_TIMEOUT) && kill $$PID 2>/dev/null) & TIMER=$$!; \
|
||||
wait $$PID 2>/dev/null; kill $$TIMER 2>/dev/null; \
|
||||
grep -q '"failed":0' /tmp/clide-testmode.log
|
||||
endif
|
||||
|
||||
.PHONY: pubget
|
||||
pubget: ## flutter pub get.
|
||||
@@ -27,7 +65,11 @@ pubget: ## flutter pub get.
|
||||
|
||||
.PHONY: build-check
|
||||
build-check: ## Verify native + Dart build compiles (no run).
|
||||
ifeq ($(FLUTTER_OS),linux)
|
||||
LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter build linux
|
||||
else
|
||||
flutter build $(FLUTTER_OS)
|
||||
endif
|
||||
|
||||
.PHONY: analyze
|
||||
analyze: ## flutter analyze.
|
||||
@@ -85,6 +127,10 @@ ui-smoke: ## Build + serve + run Playwright smoke + stop.
|
||||
tools/ui/serve.sh
|
||||
@sh -c 'trap "tools/ui/stop.sh >/dev/null 2>&1" EXIT; cd tools/ui && npx playwright test smoke.spec.ts'
|
||||
|
||||
.PHONY: build
|
||||
build: ## flutter build for the current OS.
|
||||
flutter build $(FLUTTER_OS)
|
||||
|
||||
.PHONY: build-linux
|
||||
build-linux: ## flutter build linux (desktop bundle).
|
||||
flutter build linux
|
||||
@@ -93,6 +139,100 @@ build-linux: ## flutter build linux (desktop bundle).
|
||||
build-macos: ## flutter build macos (desktop bundle).
|
||||
flutter build macos
|
||||
|
||||
# -- install / uninstall -----------------------------------------------------
|
||||
|
||||
# Install prefix. Bundle lands at $(INSTALL_PREFIX)/clide/ with a
|
||||
# symlink at $(INSTALL_DIR)/clide pointing into it.
|
||||
INSTALL_PREFIX ?= $(HOME)/.local/lib
|
||||
|
||||
ifeq ($(FLUTTER_OS),linux)
|
||||
BUNDLE_DIR := build/linux/x64/release/bundle
|
||||
else ifeq ($(FLUTTER_OS),macos)
|
||||
BUNDLE_DIR := build/macos/Build/Products/Release/clide.app
|
||||
endif
|
||||
|
||||
ICON_SIZES := 16 32 48 128 192 256 512
|
||||
|
||||
.PHONY: install
|
||||
install: build ## Build + install clide to ~/.local (INSTALL_PREFIX, INSTALL_DIR).
|
||||
ifeq ($(FLUTTER_OS),linux)
|
||||
@mkdir -p $(INSTALL_PREFIX) $(INSTALL_DIR)
|
||||
rm -rf $(INSTALL_PREFIX)/clide
|
||||
cp -a $(BUNDLE_DIR) $(INSTALL_PREFIX)/clide
|
||||
ln -sf $(INSTALL_PREFIX)/clide/clide $(INSTALL_DIR)/clide
|
||||
@for size in $(ICON_SIZES); do \
|
||||
dir=$(HOME)/.local/share/icons/hicolor/$${size}x$${size}/apps; \
|
||||
mkdir -p $$dir; \
|
||||
cp assets/logo/appicon-$${size}.png $$dir/clide.png; \
|
||||
done
|
||||
@mkdir -p $(HOME)/.local/share/applications
|
||||
@sed 's|Exec=clide|Exec=$(INSTALL_PREFIX)/clide/clide|' linux/clide.desktop \
|
||||
> $(HOME)/.local/share/applications/clide.desktop
|
||||
@gtk-update-icon-cache -f -t $(HOME)/.local/share/icons/hicolor 2>/dev/null || true
|
||||
@update-desktop-database $(HOME)/.local/share/applications 2>/dev/null || true
|
||||
@echo "installed: $(INSTALL_DIR)/clide -> $(INSTALL_PREFIX)/clide/clide"
|
||||
@echo "desktop: ~/.local/share/applications/clide.desktop"
|
||||
@echo "version: $(VERSION)"
|
||||
else ifeq ($(FLUTTER_OS),macos)
|
||||
@mkdir -p $(HOME)/Applications
|
||||
rm -rf $(HOME)/Applications/clide.app
|
||||
cp -a $(BUNDLE_DIR) $(HOME)/Applications/clide.app
|
||||
@echo "installed: ~/Applications/clide.app"
|
||||
@echo "version: $(VERSION)"
|
||||
else
|
||||
@echo "install not yet supported on $(FLUTTER_OS)"
|
||||
@exit 1
|
||||
endif
|
||||
|
||||
.PHONY: uninstall
|
||||
uninstall: ## Remove installed clide.
|
||||
ifeq ($(FLUTTER_OS),linux)
|
||||
rm -f $(INSTALL_DIR)/clide
|
||||
rm -rf $(INSTALL_PREFIX)/clide
|
||||
rm -f $(HOME)/.local/share/applications/clide.desktop
|
||||
@for size in $(ICON_SIZES); do \
|
||||
rm -f $(HOME)/.local/share/icons/hicolor/$${size}x$${size}/apps/clide.png; \
|
||||
done
|
||||
@gtk-update-icon-cache -f -t $(HOME)/.local/share/icons/hicolor 2>/dev/null || true
|
||||
@update-desktop-database $(HOME)/.local/share/applications 2>/dev/null || true
|
||||
@echo "uninstalled"
|
||||
else ifeq ($(FLUTTER_OS),macos)
|
||||
rm -rf $(HOME)/Applications/clide.app
|
||||
@echo "uninstalled"
|
||||
endif
|
||||
|
||||
# -- dugite-native (bundled git) ------------------------------------------
|
||||
|
||||
DUGITE_VERSION := v2.53.0-3
|
||||
DUGITE_COMMIT := f49d009
|
||||
|
||||
ifeq ($(FLUTTER_OS),macos)
|
||||
ifeq ($(shell uname -m),arm64)
|
||||
DUGITE_PLATFORM := macOS-arm64
|
||||
else
|
||||
DUGITE_PLATFORM := macOS-x64
|
||||
endif
|
||||
else ifeq ($(FLUTTER_OS),linux)
|
||||
DUGITE_PLATFORM := ubuntu-x64
|
||||
else
|
||||
DUGITE_PLATFORM := windows-x64
|
||||
endif
|
||||
|
||||
DUGITE_TAR := dugite-native-v2.53.0-$(DUGITE_COMMIT)-$(DUGITE_PLATFORM).tar.gz
|
||||
DUGITE_URL := https://github.com/desktop/dugite-native/releases/download/$(DUGITE_VERSION)/$(DUGITE_TAR)
|
||||
DUGITE_DIR := native/dugite
|
||||
|
||||
.PHONY: dugite-fetch
|
||||
dugite-fetch: ## Download and extract the dugite-native git distribution.
|
||||
@if [ -f $(DUGITE_DIR)/bin/git ]; then echo "dugite already present at $(DUGITE_DIR)/bin/git"; exit 0; fi
|
||||
@mkdir -p $(DUGITE_DIR)
|
||||
curl -sL "$(DUGITE_URL)" | tar xz -C $(DUGITE_DIR)
|
||||
@echo "dugite-native $(DUGITE_VERSION) ($(DUGITE_PLATFORM)) extracted to $(DUGITE_DIR)/"
|
||||
|
||||
.PHONY: dugite-clean
|
||||
dugite-clean: ## Remove the dugite-native directory.
|
||||
rm -rf $(DUGITE_DIR)
|
||||
|
||||
# -- ptyc (C supporter tool) ---------------------------------------------
|
||||
|
||||
PTYC_PRESENT := $(shell test -f ptyc/Makefile && echo yes || echo no)
|
||||
|
||||
@@ -1,24 +1,47 @@
|
||||
# clide
|
||||
|
||||
A Flutter desktop IDE for Claude Code. Markdown-first content, pql-powered queries, canvas and graph surfaces, a Go sidecar handling PTYs / subprocesses / git / pql. Claude drives the UI through a `clide` CLI.
|
||||
A Flutter desktop IDE for Claude Code. Native rendering, terminal-first interaction, pql-powered queries, canvas and graph surfaces. Linux and macOS.
|
||||
|
||||
Currently pre-v2.0, scaffolding. The north-star design lives in [`docs/initial-plan.md`](docs/initial-plan.md); architectural decisions are captured in [`docs/ADRs/`](docs/ADRs/). The Python Textual v1.2.0 implementation is archived under [`legacy/`](legacy/) for reference.
|
||||
## Architecture
|
||||
|
||||
## Three surfaces, one tool
|
||||
Single Flutter package at the repo root. The app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), and the extension framework. tmux owns Claude session persistence.
|
||||
|
||||
- **`app/`** — Flutter desktop application (Linux / macOS; Windows is a stretch).
|
||||
- **`sidecar/`** — Go sidecar/CLI, single binary with two modes: `clide <subcommand>` (one-shot for Claude) and `clide --daemon` (long-running sidecar for the app). Owns PTYs, subprocesses, file watchers, git, pql invocations.
|
||||
- **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. clide wraps it for every query surface; never re-implements it.
|
||||
- **`lib/`** — all Dart code. Kernel services (theme, i18n, settings, panels, commands, focus), UI widgets, built-in extensions, and the extension contract.
|
||||
- **`ptyc/`** — small C helper. Spawns a PTY + child and hands the master fd back over `SCM_RIGHTS`. Every pane (shell, tmux, claude, LSP, debug adapter) goes through it.
|
||||
- **[pql](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it.
|
||||
|
||||
## Why the rebuild?
|
||||
Claude drives the UI through a `clide` CLI surface (Bash, not MCP). Every CLI subcommand has a UI affordance and every UI action has a CLI equivalent.
|
||||
|
||||
The Python Textual implementation (`legacy/`) proved the pane model but capped at terminal-only rendering. A short-lived experiment (`claudian`, April 2026) explored an Obsidian-plugin approach and was abandoned because Obsidian is Electron, and the user's history with terminal-in-Electron ruled that host out.
|
||||
## Built-in extensions
|
||||
|
||||
Flutter desktop resolves both constraints: native Skia rendering avoids Electron's failure modes, `xterm.dart` gives us a solid terminal surface, and the pane model can be rebuilt natively. The Obsidian ideas worth keeping (canvas, graph) fold in; everything else (vault concept, inline query tables, plugin ecosystem inheritance) doesn't.
|
||||
canvas, claude, claude_control, decisions, diff, editor, extensions_ui, files, git, graph, grammars_core, ipc_status, keybindings_ui, markdown, pql, problems, settings_ui, terminal, theme_picker, tickets, todos, welcome.
|
||||
|
||||
## Building
|
||||
|
||||
Requires Flutter (stable channel) on the host. One-time setup:
|
||||
|
||||
```
|
||||
make hooks && flutter pub get
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```
|
||||
make run # launch the desktop app
|
||||
make test # fast suite: analyze + format + unit + widget + golden
|
||||
make test-core # core subsystem tests (IPC, PTY, git, pane registry)
|
||||
make test-integration # real app boot integration tests
|
||||
make build-linux # flutter build linux
|
||||
make build-macos # flutter build macos
|
||||
make ptyc-build # build the ptyc PTY-spawn helper
|
||||
make push-check # pre-push gate: decisions + core + fast tests
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
No build yet beyond the repo scaffold. Tier 0 acceptance: sidecar daemon runs, Flutter app connects, empty IDE shell appears with placeholders.
|
||||
Pre-v2.0 (`2.0.0-dev`). Interaction model and panel system landed. The Python Textual v1.2.0 predecessor is archived under [`legacy/`](legacy/).
|
||||
|
||||
Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Architectural decisions: [`decisions/`](decisions/).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+13
-1
@@ -37,7 +37,7 @@ schema_version: 1
|
||||
self:
|
||||
name: clide
|
||||
version: "2.0.0-dev"
|
||||
homepage: https://git.schweitz.net/jpmschweitzer/clide
|
||||
homepage: https://github.com/postmeridiem/clide
|
||||
license: MIT
|
||||
license_file: assets/LICENSE
|
||||
copyright: "© 2026 Jeroen Schweitzer"
|
||||
@@ -109,6 +109,18 @@ dependencies:
|
||||
the core PTY wrapper for libc bindings (socketpair, recvmsg with
|
||||
SCM_RIGHTS, ioctl). Dart-team maintained; zero transitive deps.
|
||||
|
||||
- name: dugite-native (Git)
|
||||
kind: native-binary
|
||||
version: "2.53.0"
|
||||
homepage: https://github.com/desktop/dugite-native
|
||||
license: GPL-2.0 (Git), MIT (dugite-native build scripts)
|
||||
purpose: >-
|
||||
Self-contained, relocatable Git distribution for macOS/Linux/Windows.
|
||||
Bundled so clide can run git inside the macOS app sandbox without
|
||||
depending on Homebrew or Xcode Command Line Tools. Same approach
|
||||
used by GitHub Desktop and Tower. Binary not committed — downloaded
|
||||
at build time via `make dugite-fetch`.
|
||||
|
||||
- name: tree-sitter
|
||||
kind: native-shared-lib
|
||||
version: "0.26.8"
|
||||
|
||||
+20
-32
@@ -13,6 +13,8 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
import 'package:clide/src/git/client.dart';
|
||||
// Daemon-only deep imports — these pull in dart:ffi (PTY) and
|
||||
// daemon-subsystem wiring that the Flutter app doesn't need and
|
||||
// can't compile for web. See lib/clide.dart for the barrel split.
|
||||
@@ -142,20 +144,22 @@ Future<void> _runDaemon(List<String> args) async {
|
||||
socketPath: socketPath,
|
||||
dispatch: dispatcher.dispatch,
|
||||
);
|
||||
final ptycPath = _resolvePtycPath();
|
||||
final toolchain = Toolchain();
|
||||
toolchain.applyResolved(Toolchain.resolvePaths(workspaceRoot: Directory.current.path));
|
||||
|
||||
final events = _ServerEventSink(server);
|
||||
final registry = PaneRegistry(events: events);
|
||||
registerPaneCommands(dispatcher, registry, defaultPtycPath: ptycPath);
|
||||
registerPaneCommands(dispatcher, registry);
|
||||
|
||||
final files = FilesService.atCwd(events: events);
|
||||
registerFilesCommands(dispatcher, files);
|
||||
|
||||
final editor = EditorRegistry(events: events, workspaceRoot: files.root);
|
||||
registerEditorCommands(dispatcher, editor);
|
||||
final gitClient = GitClient(toolchain: toolchain, workDir: files.root);
|
||||
registerGitCommands(dispatcher, gitClient, events);
|
||||
|
||||
registerGitCommands(dispatcher, files.root, events);
|
||||
|
||||
final pql = PqlClient(workDir: files.root);
|
||||
final pql = PqlClient(workDir: files.root, toolchain: toolchain);
|
||||
registerPqlCommands(dispatcher, pql);
|
||||
|
||||
final stopping = Completer<void>();
|
||||
@@ -238,10 +242,7 @@ Future<void> _runCliArgs(
|
||||
// Responses come back on the same socket. Events may be interleaved
|
||||
// (the daemon broadcasts), so we skip events until we see the
|
||||
// response whose id matches our request.
|
||||
final lines = socket
|
||||
.cast<List<int>>()
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter());
|
||||
final lines = socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter());
|
||||
|
||||
try {
|
||||
await for (final line in lines) {
|
||||
@@ -342,11 +343,14 @@ Future<void> _runGit(List<String> args) async {
|
||||
final rest = args.sublist(1);
|
||||
final setUpstream = rest.contains('-u');
|
||||
final positional = rest.where((a) => a != '-u').toList();
|
||||
await _runCliArgs('git.push', {
|
||||
if (setUpstream) 'setUpstream': true,
|
||||
if (positional.isNotEmpty) 'remote': positional[0],
|
||||
if (positional.length > 1) 'branch': positional[1],
|
||||
}, exitOnOk: true);
|
||||
await _runCliArgs(
|
||||
'git.push',
|
||||
{
|
||||
if (setUpstream) 'setUpstream': true,
|
||||
if (positional.isNotEmpty) 'remote': positional[0],
|
||||
if (positional.length > 1) 'branch': positional[1],
|
||||
},
|
||||
exitOnOk: true);
|
||||
default:
|
||||
_die('unknown git verb: ${args.first}');
|
||||
}
|
||||
@@ -386,13 +390,11 @@ Future<void> _runTail(List<String> args) async {
|
||||
void quit() {
|
||||
unawaited(socket.close());
|
||||
}
|
||||
|
||||
ProcessSignal.sigint.watch().listen((_) => quit());
|
||||
ProcessSignal.sigterm.watch().listen((_) => quit());
|
||||
|
||||
final lines = socket
|
||||
.cast<List<int>>()
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter());
|
||||
final lines = socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter());
|
||||
|
||||
try {
|
||||
await for (final line in lines) {
|
||||
@@ -424,20 +426,6 @@ void _emitError({
|
||||
stderr.writeln(jsonEncode(err.toJson()));
|
||||
}
|
||||
|
||||
String _resolvePtycPath() {
|
||||
final self = Platform.resolvedExecutable;
|
||||
final binDir = File(self).parent.path;
|
||||
final candidates = [
|
||||
'$binDir/../ptyc/bin/ptyc',
|
||||
'$binDir/ptyc',
|
||||
'ptyc',
|
||||
];
|
||||
for (final c in candidates) {
|
||||
if (File(c).existsSync()) return c;
|
||||
}
|
||||
return 'ptyc';
|
||||
}
|
||||
|
||||
Never _die(String msg) {
|
||||
_emitError(
|
||||
code: IpcExitCode.userError,
|
||||
|
||||
+2
-2
@@ -10,10 +10,10 @@ set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
BUNDLE="app/build/linux/x64/release/bundle/clide_app"
|
||||
BUNDLE="build/linux/x64/release/bundle/clide"
|
||||
|
||||
echo "==> build linux release bundle"
|
||||
(cd app && flutter build linux --release)
|
||||
flutter build linux --release
|
||||
|
||||
if [[ ! -x "$BUNDLE" ]]; then
|
||||
echo "smoke: bundle not found at $BUNDLE" >&2
|
||||
|
||||
+9
-11
@@ -1,20 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fast test layer — unit + widget + golden. Runs in <60s on a warm
|
||||
# cache. Called from `make test` and the pre-push hook.
|
||||
# Fast test layer — analyze + format + unit + widget + golden.
|
||||
# Runs in <60s on a warm cache. Called from `make test` and the
|
||||
# pre-push hook.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "==> dart analyze (root package)"
|
||||
dart analyze
|
||||
echo "==> flutter analyze"
|
||||
flutter analyze --no-fatal-infos
|
||||
|
||||
echo "==> dart format (whole tree)"
|
||||
dart format --set-exit-if-changed .
|
||||
|
||||
echo "==> dart test (root package)"
|
||||
dart test
|
||||
echo "==> dart test (forkpty — incompatible with flutter test runner)"
|
||||
dart test --tags forkpty test/pty/session_test.dart
|
||||
|
||||
echo "==> flutter analyze (app)"
|
||||
(cd app && flutter analyze)
|
||||
|
||||
echo "==> flutter test (app unit + widget + golden)"
|
||||
(cd app && flutter test)
|
||||
echo "==> flutter test (unit + widget + golden)"
|
||||
flutter test --exclude-tags forkpty
|
||||
|
||||
+1
-1
@@ -5,4 +5,4 @@ set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "==> a11y suite"
|
||||
(cd app && flutter test test/a11y/)
|
||||
flutter test test/a11y/
|
||||
|
||||
+4
-2
@@ -33,9 +33,11 @@ TIMEOUT_SECONDS=${TIMEOUT_SECONDS:-120}
|
||||
# Run dart test in its own process group so we can kill descendants on
|
||||
# timeout. `setsid` starts a new session; `timeout --kill-after` SIGKILLs
|
||||
# after SIGTERM if the test ignores it.
|
||||
echo "test-core: dart test test/ (timeout ${TIMEOUT_SECONDS}s)"
|
||||
CORE_DIRS="test/ipc test/pty test/daemon test/git test/panes test/files test/editor test/cli test/pql"
|
||||
|
||||
echo "test-core: dart test ${CORE_DIRS} (timeout ${TIMEOUT_SECONDS}s)"
|
||||
if ! timeout --kill-after=5s "${TIMEOUT_SECONDS}s" \
|
||||
setsid --wait dart test test/ ; then
|
||||
setsid --wait dart test $CORE_DIRS ; then
|
||||
rc=$?
|
||||
if [[ $rc -eq 124 ]]; then
|
||||
echo "test-core: TIMEOUT — killing descendants" >&2
|
||||
|
||||
+2
-5
@@ -6,12 +6,9 @@ set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "==> flutter test --coverage"
|
||||
(cd app && flutter test --coverage)
|
||||
|
||||
echo "==> root dart coverage (skipped until dart_test + coverage integrate)"
|
||||
# dart test doesn't emit lcov natively; wire package:coverage later.
|
||||
flutter test --coverage
|
||||
|
||||
if command -v lcov >/dev/null 2>&1; then
|
||||
echo "==> lcov summary"
|
||||
lcov --summary app/coverage/lcov.info
|
||||
lcov --summary coverage/lcov.info
|
||||
fi
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
cd app
|
||||
for f in integration_test/*_test.dart; do
|
||||
echo "==> integration_test: $f"
|
||||
flutter test "$f"
|
||||
|
||||
@@ -42,4 +42,13 @@ Toolchain, supply chain, CI, ignore strategy.
|
||||
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml).
|
||||
- **Raised by:** 2026-04-23 format engine evaluation.
|
||||
|
||||
### D-59: Bundled git via dugite-native
|
||||
- **Date:** 2026-04-25
|
||||
- **Decision:** Ship a self-contained Git binary from [dugite-native](https://github.com/desktop/dugite-native) (the same distribution GitHub Desktop bundles). Downloaded at build time via `make dugite-fetch`, stored under `native/dugite/`, gitignored. The `Toolchain` class resolves to the bundled binary first, falling back to system git on PATH.
|
||||
- **Rationale:** The macOS app sandbox blocks execution of Homebrew-installed git (symlinks resolve to Cellar paths that SBPL cannot match without freezing rendering). `/usr/bin/git` is an xcrun shim that refuses to run inside a sandbox. Bundling dugite-native makes clide self-contained — no dependency on Homebrew, Xcode CLT, or system git. The approach is proven: GitHub Desktop, Tower, and other git GUI apps all bundle their own git for the same reason.
|
||||
- **Alternatives rejected:** (R) libgit2 via FFI — missing porcelain commands (pull/push/rebase), no hooks, would require rewriting GitClient. (R) Build git from source — dugite-native already does this with better infra. (R) SBPL exceptions for Homebrew — `(subpath "/opt/homebrew")` for process-exec freezes Flutter rendering on macOS 26.
|
||||
- **Cost:** ~57 MB download (~199 MB unpacked, stripped at build time). Must track dugite-native releases for security updates. GPL-2.0 (git binary) applies to the bundled artefact, not to clide's MIT code.
|
||||
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml).
|
||||
- **Raised by:** 2026-04-25 macOS sandbox investigation.
|
||||
|
||||
---
|
||||
|
||||
@@ -11,171 +11,201 @@ import 'package:flutter/material.dart';
|
||||
|
||||
class ClideTheme {
|
||||
// ─── palette ──────────────────────────────────────────────────────────
|
||||
static const _bg = Color(0xFF20202C); // page / editor canvas
|
||||
static const _bgSunken = Color(0xFF1A1A24); // sidebar, gutters
|
||||
static const _surface = Color(0xFF242838); // cards, table header, pills
|
||||
static const _surfaceHi = Color(0xFF2C3046); // hover, selected row
|
||||
static const _border = Color(0xFF343850);
|
||||
static const _borderHi = Color(0xFF3C445C);
|
||||
static const _bg = Color(0xFF20202C); // page / editor canvas
|
||||
static const _bgSunken = Color(0xFF1A1A24); // sidebar, gutters
|
||||
static const _surface = Color(0xFF242838); // cards, table header, pills
|
||||
static const _surfaceHi = Color(0xFF2C3046); // hover, selected row
|
||||
static const _border = Color(0xFF343850);
|
||||
static const _borderHi = Color(0xFF3C445C);
|
||||
|
||||
static const _textHi = Color(0xFFE6E8F2); // primary text
|
||||
static const _text = Color(0xFFB1BBE3); // section titles, labels
|
||||
static const _textDim = Color(0xFF78809C); // secondary
|
||||
static const _textMute = Color(0xFF545C84); // small-caps labels
|
||||
static const _textHi = Color(0xFFE6E8F2); // primary text
|
||||
static const _text = Color(0xFFB1BBE3); // section titles, labels
|
||||
static const _textDim = Color(0xFF78809C); // secondary
|
||||
static const _textMute = Color(0xFF545C84); // small-caps labels
|
||||
|
||||
static const _accent = Color(0xFF78A0F8); // periwinkle primary
|
||||
static const _accent = Color(0xFF78A0F8); // periwinkle primary
|
||||
static const _accentPress = Color(0xFF6C90DC);
|
||||
static const _accentSoft = Color(0x2278A0F8); // 13% accent for fills
|
||||
static const _accentSoft = Color(0x2278A0F8); // 13% accent for fills
|
||||
|
||||
static const _ok = Color(0xFF7DD3A8);
|
||||
static const _warn = Color(0xFFE6C370);
|
||||
static const _err = Color(0xFFE87D7D);
|
||||
static const _info = _accent;
|
||||
static const _ok = Color(0xFF7DD3A8);
|
||||
static const _warn = Color(0xFFE6C370);
|
||||
static const _err = Color(0xFFE87D7D);
|
||||
static const _info = _accent;
|
||||
|
||||
// syntax (Dart-biased)
|
||||
static const _synKeyword = Color(0xFFC792EA); // class, const, final
|
||||
static const _synType = Color(0xFF78A0F8); // Widget, BuildContext
|
||||
static const _synString = Color(0xFFA8D99B);
|
||||
static const _synNumber = Color(0xFFE6C370);
|
||||
static const _synComment = Color(0xFF545C84);
|
||||
static const _synMethod = Color(0xFF82B1FF);
|
||||
static const _synPunct = Color(0xFF78809C);
|
||||
static const _synKeyword = Color(0xFFC792EA); // class, const, final
|
||||
static const _synType = Color(0xFF78A0F8); // Widget, BuildContext
|
||||
static const _synString = Color(0xFFA8D99B);
|
||||
static const _synNumber = Color(0xFFE6C370);
|
||||
static const _synComment = Color(0xFF545C84);
|
||||
static const _synMethod = Color(0xFF82B1FF);
|
||||
static const _synPunct = Color(0xFF78809C);
|
||||
|
||||
// ─── exposed tokens ───────────────────────────────────────────────────
|
||||
static const tokens = ClideTokens(
|
||||
bg: _bg, bgSunken: _bgSunken, surface: _surface, surfaceHi: _surfaceHi,
|
||||
border: _border, borderHi: _borderHi,
|
||||
textHi: _textHi, text: _text, textDim: _textDim, textMute: _textMute,
|
||||
accent: _accent, accentPress: _accentPress, accentSoft: _accentSoft,
|
||||
ok: _ok, warn: _warn, err: _err, info: _info,
|
||||
synKeyword: _synKeyword, synType: _synType, synString: _synString,
|
||||
synNumber: _synNumber, synComment: _synComment, synMethod: _synMethod,
|
||||
bg: _bg,
|
||||
bgSunken: _bgSunken,
|
||||
surface: _surface,
|
||||
surfaceHi: _surfaceHi,
|
||||
border: _border,
|
||||
borderHi: _borderHi,
|
||||
textHi: _textHi,
|
||||
text: _text,
|
||||
textDim: _textDim,
|
||||
textMute: _textMute,
|
||||
accent: _accent,
|
||||
accentPress: _accentPress,
|
||||
accentSoft: _accentSoft,
|
||||
ok: _ok,
|
||||
warn: _warn,
|
||||
err: _err,
|
||||
info: _info,
|
||||
synKeyword: _synKeyword,
|
||||
synType: _synType,
|
||||
synString: _synString,
|
||||
synNumber: _synNumber,
|
||||
synComment: _synComment,
|
||||
synMethod: _synMethod,
|
||||
synPunct: _synPunct,
|
||||
);
|
||||
|
||||
// ─── ThemeData ────────────────────────────────────────────────────────
|
||||
static ThemeData get data => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
|
||||
colorScheme: const ColorScheme.dark(
|
||||
brightness: Brightness.dark,
|
||||
primary: _accent,
|
||||
onPrimary: Color(0xFF0D1020),
|
||||
secondary: _synKeyword,
|
||||
onSecondary: Color(0xFF0D1020),
|
||||
surface: _surface,
|
||||
onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border,
|
||||
outlineVariant: _borderHi,
|
||||
error: _err,
|
||||
onError: Color(0xFF0D1020),
|
||||
),
|
||||
colorScheme: const ColorScheme.dark(
|
||||
brightness: Brightness.dark,
|
||||
primary: _accent,
|
||||
onPrimary: Color(0xFF0D1020),
|
||||
secondary: _synKeyword,
|
||||
onSecondary: Color(0xFF0D1020),
|
||||
surface: _surface,
|
||||
onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border,
|
||||
outlineVariant: _borderHi,
|
||||
error: _err,
|
||||
onError: Color(0xFF0D1020),
|
||||
),
|
||||
|
||||
textTheme: const TextTheme(
|
||||
// Josefin Sans Light for display; JetBrains Mono for code/body.
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, height: 1.1, letterSpacing: 0.2, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, height: 1.15, letterSpacing: 0.2, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, height: 1.2, letterSpacing: 0.2, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, height: 1.3, letterSpacing: 0.3, color: _text),
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, height: 1.2, letterSpacing: 1.0, color: _textMute),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, height: 1.4, color: _text),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
// Josefin Sans Light for display; JetBrains Mono for code/body.
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, height: 1.1, letterSpacing: 0.2, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, height: 1.15, letterSpacing: 0.2, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, height: 1.2, letterSpacing: 0.2, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, height: 1.3, letterSpacing: 0.3, color: _text),
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, height: 1.2, letterSpacing: 1.0, color: _textMute),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, height: 1.4, color: _text),
|
||||
),
|
||||
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
|
||||
cardTheme: CardThemeData(
|
||||
color: _surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(color: _border),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: _surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(color: _border),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: _bgSunken,
|
||||
hintStyle: const TextStyle(color: _textMute),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: _border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: _border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: _accent, width: 1.5),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: _bgSunken,
|
||||
hintStyle: const TextStyle(color: _textMute),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: _border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: _border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: _accent, width: 1.5),
|
||||
),
|
||||
),
|
||||
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _accent,
|
||||
foregroundColor: const Color(0xFF0D1020),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: _accent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _accent,
|
||||
foregroundColor: const Color(0xFF0D1020),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: _accent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12),
|
||||
),
|
||||
),
|
||||
|
||||
// Pill-style chips (matches the Projects row in the dashboard).
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: _surface,
|
||||
side: const BorderSide(color: _borderHi),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
labelStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _text),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
),
|
||||
// Pill-style chips (matches the Projects row in the dashboard).
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: _surface,
|
||||
side: const BorderSide(color: _borderHi),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
labelStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _text),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
),
|
||||
|
||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||
|
||||
tooltipTheme: TooltipThemeData(
|
||||
decoration: BoxDecoration(
|
||||
color: _surfaceHi,
|
||||
border: Border.all(color: _borderHi),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _textHi),
|
||||
),
|
||||
tooltipTheme: TooltipThemeData(
|
||||
decoration: BoxDecoration(
|
||||
color: _surfaceHi,
|
||||
border: Border.all(color: _borderHi),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _textHi),
|
||||
),
|
||||
|
||||
scrollbarTheme: ScrollbarThemeData(
|
||||
thumbColor: WidgetStatePropertyAll(_border),
|
||||
thickness: const WidgetStatePropertyAll(6),
|
||||
radius: const Radius.circular(3),
|
||||
),
|
||||
);
|
||||
scrollbarTheme: ScrollbarThemeData(
|
||||
thumbColor: WidgetStatePropertyAll(_border),
|
||||
thickness: const WidgetStatePropertyAll(6),
|
||||
radius: const Radius.circular(3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Raw color tokens — use when Material widgets can't carry the meaning.
|
||||
class ClideTokens {
|
||||
const ClideTokens({
|
||||
required this.bg, required this.bgSunken,
|
||||
required this.surface, required this.surfaceHi,
|
||||
required this.border, required this.borderHi,
|
||||
required this.textHi, required this.text,
|
||||
required this.textDim, required this.textMute,
|
||||
required this.accent, required this.accentPress, required this.accentSoft,
|
||||
required this.ok, required this.warn, required this.err, required this.info,
|
||||
required this.synKeyword, required this.synType, required this.synString,
|
||||
required this.synNumber, required this.synComment,
|
||||
required this.synMethod, required this.synPunct,
|
||||
required this.bg,
|
||||
required this.bgSunken,
|
||||
required this.surface,
|
||||
required this.surfaceHi,
|
||||
required this.border,
|
||||
required this.borderHi,
|
||||
required this.textHi,
|
||||
required this.text,
|
||||
required this.textDim,
|
||||
required this.textMute,
|
||||
required this.accent,
|
||||
required this.accentPress,
|
||||
required this.accentSoft,
|
||||
required this.ok,
|
||||
required this.warn,
|
||||
required this.err,
|
||||
required this.info,
|
||||
required this.synKeyword,
|
||||
required this.synType,
|
||||
required this.synString,
|
||||
required this.synNumber,
|
||||
required this.synComment,
|
||||
required this.synMethod,
|
||||
required this.synPunct,
|
||||
});
|
||||
final Color bg, bgSunken, surface, surfaceHi;
|
||||
final Color border, borderHi;
|
||||
|
||||
@@ -6,69 +6,90 @@ import 'package:flutter/material.dart';
|
||||
import 'clide_theme.dart' show ClideTokens;
|
||||
|
||||
class MidnightTheme {
|
||||
static const _bg = Color(0xFF1E1E1E);
|
||||
static const _bgSunken = Color(0xFF181818);
|
||||
static const _surface = Color(0xFF252526);
|
||||
static const _surfaceHi = Color(0xFF2D2D2E);
|
||||
static const _border = Color(0xFF333333);
|
||||
static const _borderHi = Color(0xFF3F3F3F);
|
||||
static const _bg = Color(0xFF1E1E1E);
|
||||
static const _bgSunken = Color(0xFF181818);
|
||||
static const _surface = Color(0xFF252526);
|
||||
static const _surfaceHi = Color(0xFF2D2D2E);
|
||||
static const _border = Color(0xFF333333);
|
||||
static const _borderHi = Color(0xFF3F3F3F);
|
||||
|
||||
static const _textHi = Color(0xFFD4D4D4);
|
||||
static const _text = Color(0xFFBBBBBB);
|
||||
static const _textDim = Color(0xFF858585);
|
||||
static const _textMute = Color(0xFF6A6A6A);
|
||||
static const _textHi = Color(0xFFD4D4D4);
|
||||
static const _text = Color(0xFFBBBBBB);
|
||||
static const _textDim = Color(0xFF858585);
|
||||
static const _textMute = Color(0xFF6A6A6A);
|
||||
|
||||
static const _accent = Color(0xFF569CD6); // muted azure
|
||||
static const _accent = Color(0xFF569CD6); // muted azure
|
||||
static const _accentPress = Color(0xFF4785BD);
|
||||
static const _accentSoft = Color(0x22569CD6);
|
||||
static const _accentSoft = Color(0x22569CD6);
|
||||
|
||||
static const _ok = Color(0xFF89D185);
|
||||
static const _warn = Color(0xFFD7BA7D);
|
||||
static const _err = Color(0xFFF48771);
|
||||
static const _info = _accent;
|
||||
static const _ok = Color(0xFF89D185);
|
||||
static const _warn = Color(0xFFD7BA7D);
|
||||
static const _err = Color(0xFFF48771);
|
||||
static const _info = _accent;
|
||||
|
||||
static const _synKeyword = Color(0xFFC586C0);
|
||||
static const _synType = Color(0xFF4EC9B0);
|
||||
static const _synString = Color(0xFFCE9178);
|
||||
static const _synNumber = Color(0xFFB5CEA8);
|
||||
static const _synComment = Color(0xFF6A9955);
|
||||
static const _synMethod = Color(0xFFDCDCAA);
|
||||
static const _synPunct = Color(0xFF858585);
|
||||
static const _synKeyword = Color(0xFFC586C0);
|
||||
static const _synType = Color(0xFF4EC9B0);
|
||||
static const _synString = Color(0xFFCE9178);
|
||||
static const _synNumber = Color(0xFFB5CEA8);
|
||||
static const _synComment = Color(0xFF6A9955);
|
||||
static const _synMethod = Color(0xFFDCDCAA);
|
||||
static const _synPunct = Color(0xFF858585);
|
||||
|
||||
static const tokens = ClideTokens(
|
||||
bg: _bg, bgSunken: _bgSunken, surface: _surface, surfaceHi: _surfaceHi,
|
||||
border: _border, borderHi: _borderHi,
|
||||
textHi: _textHi, text: _text, textDim: _textDim, textMute: _textMute,
|
||||
accent: _accent, accentPress: _accentPress, accentSoft: _accentSoft,
|
||||
ok: _ok, warn: _warn, err: _err, info: _info,
|
||||
synKeyword: _synKeyword, synType: _synType, synString: _synString,
|
||||
synNumber: _synNumber, synComment: _synComment, synMethod: _synMethod,
|
||||
bg: _bg,
|
||||
bgSunken: _bgSunken,
|
||||
surface: _surface,
|
||||
surfaceHi: _surfaceHi,
|
||||
border: _border,
|
||||
borderHi: _borderHi,
|
||||
textHi: _textHi,
|
||||
text: _text,
|
||||
textDim: _textDim,
|
||||
textMute: _textMute,
|
||||
accent: _accent,
|
||||
accentPress: _accentPress,
|
||||
accentSoft: _accentSoft,
|
||||
ok: _ok,
|
||||
warn: _warn,
|
||||
err: _err,
|
||||
info: _info,
|
||||
synKeyword: _synKeyword,
|
||||
synType: _synType,
|
||||
synString: _synString,
|
||||
synNumber: _synNumber,
|
||||
synComment: _synComment,
|
||||
synMethod: _synMethod,
|
||||
synPunct: _synPunct,
|
||||
);
|
||||
|
||||
static ThemeData get data => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: _accent, onPrimary: Color(0xFF0B1220),
|
||||
secondary: _synKeyword, onSecondary: Color(0xFF0B1220),
|
||||
surface: _surface, onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border, outlineVariant: _borderHi,
|
||||
error: _err, onError: Color(0xFF0B1220),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||
);
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: _accent,
|
||||
onPrimary: Color(0xFF0B1220),
|
||||
secondary: _synKeyword,
|
||||
onSecondary: Color(0xFF0B1220),
|
||||
surface: _surface,
|
||||
onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border,
|
||||
outlineVariant: _borderHi,
|
||||
error: _err,
|
||||
onError: Color(0xFF0B1220),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,70 +7,91 @@ import 'package:flutter/material.dart';
|
||||
import 'clide_theme.dart' show ClideTokens;
|
||||
|
||||
class PaperTheme {
|
||||
static const _bg = Color(0xFFF4F1EA); // paper
|
||||
static const _bgSunken = Color(0xFFECE7DB); // paper-2
|
||||
static const _surface = Color(0xFFFBF8F1);
|
||||
static const _surfaceHi = Color(0xFFECE7DB);
|
||||
static const _border = Color(0xFF1A1A1A);
|
||||
static const _borderHi = Color(0xFF4A4A4A);
|
||||
static const _bg = Color(0xFFF4F1EA); // paper
|
||||
static const _bgSunken = Color(0xFFECE7DB); // paper-2
|
||||
static const _surface = Color(0xFFFBF8F1);
|
||||
static const _surfaceHi = Color(0xFFECE7DB);
|
||||
static const _border = Color(0xFF1A1A1A);
|
||||
static const _borderHi = Color(0xFF4A4A4A);
|
||||
|
||||
static const _textHi = Color(0xFF1A1A1A); // ink
|
||||
static const _text = Color(0xFF4A4A4A); // ink-2
|
||||
static const _textDim = Color(0xFF8A8A82); // ink-3
|
||||
static const _textMute = Color(0xFFA8A89E);
|
||||
static const _textHi = Color(0xFF1A1A1A); // ink
|
||||
static const _text = Color(0xFF4A4A4A); // ink-2
|
||||
static const _textDim = Color(0xFF8A8A82); // ink-3
|
||||
static const _textMute = Color(0xFFA8A89E);
|
||||
|
||||
static const _accent = Color(0xFFC14B2A); // red pencil
|
||||
static const _accent = Color(0xFFC14B2A); // red pencil
|
||||
static const _accentPress = Color(0xFFA03D20);
|
||||
static const _accentSoft = Color(0x22C14B2A);
|
||||
static const _accentSoft = Color(0x22C14B2A);
|
||||
|
||||
static const _ok = Color(0xFF2D8A52);
|
||||
static const _warn = Color(0xFFB88A2A);
|
||||
static const _err = Color(0xFFB03A2A);
|
||||
static const _info = Color(0xFF2A6FC1);
|
||||
static const _ok = Color(0xFF2D8A52);
|
||||
static const _warn = Color(0xFFB88A2A);
|
||||
static const _err = Color(0xFFB03A2A);
|
||||
static const _info = Color(0xFF2A6FC1);
|
||||
|
||||
// Syntax tuned for cream paper — desaturated so code reads like print.
|
||||
static const _synKeyword = Color(0xFF7B3F8C);
|
||||
static const _synType = Color(0xFF2A6FC1);
|
||||
static const _synString = Color(0xFF2D8A52);
|
||||
static const _synNumber = Color(0xFFB88A2A);
|
||||
static const _synComment = Color(0xFF8A8A82);
|
||||
static const _synMethod = Color(0xFF1E5D9E);
|
||||
static const _synPunct = Color(0xFF4A4A4A);
|
||||
static const _synKeyword = Color(0xFF7B3F8C);
|
||||
static const _synType = Color(0xFF2A6FC1);
|
||||
static const _synString = Color(0xFF2D8A52);
|
||||
static const _synNumber = Color(0xFFB88A2A);
|
||||
static const _synComment = Color(0xFF8A8A82);
|
||||
static const _synMethod = Color(0xFF1E5D9E);
|
||||
static const _synPunct = Color(0xFF4A4A4A);
|
||||
|
||||
static const tokens = ClideTokens(
|
||||
bg: _bg, bgSunken: _bgSunken, surface: _surface, surfaceHi: _surfaceHi,
|
||||
border: _border, borderHi: _borderHi,
|
||||
textHi: _textHi, text: _text, textDim: _textDim, textMute: _textMute,
|
||||
accent: _accent, accentPress: _accentPress, accentSoft: _accentSoft,
|
||||
ok: _ok, warn: _warn, err: _err, info: _info,
|
||||
synKeyword: _synKeyword, synType: _synType, synString: _synString,
|
||||
synNumber: _synNumber, synComment: _synComment, synMethod: _synMethod,
|
||||
bg: _bg,
|
||||
bgSunken: _bgSunken,
|
||||
surface: _surface,
|
||||
surfaceHi: _surfaceHi,
|
||||
border: _border,
|
||||
borderHi: _borderHi,
|
||||
textHi: _textHi,
|
||||
text: _text,
|
||||
textDim: _textDim,
|
||||
textMute: _textMute,
|
||||
accent: _accent,
|
||||
accentPress: _accentPress,
|
||||
accentSoft: _accentSoft,
|
||||
ok: _ok,
|
||||
warn: _warn,
|
||||
err: _err,
|
||||
info: _info,
|
||||
synKeyword: _synKeyword,
|
||||
synType: _synType,
|
||||
synString: _synString,
|
||||
synNumber: _synNumber,
|
||||
synComment: _synComment,
|
||||
synMethod: _synMethod,
|
||||
synPunct: _synPunct,
|
||||
);
|
||||
|
||||
static ThemeData get data => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: _accent, onPrimary: Color(0xFFFBF8F1),
|
||||
secondary: _info, onSecondary: Color(0xFFFBF8F1),
|
||||
surface: _surface, onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border, outlineVariant: _borderHi,
|
||||
error: _err, onError: Color(0xFFFBF8F1),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textDim),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
iconTheme: const IconThemeData(color: _text, size: 14),
|
||||
);
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: _accent,
|
||||
onPrimary: Color(0xFFFBF8F1),
|
||||
secondary: _info,
|
||||
onSecondary: Color(0xFFFBF8F1),
|
||||
surface: _surface,
|
||||
onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border,
|
||||
outlineVariant: _borderHi,
|
||||
error: _err,
|
||||
onError: Color(0xFFFBF8F1),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textDim),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
iconTheme: const IconThemeData(color: _text, size: 14),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,71 +7,92 @@ import 'package:flutter/material.dart';
|
||||
import 'clide_theme.dart' show ClideTokens;
|
||||
|
||||
class TerminalTheme {
|
||||
static const _bg = Color(0xFF0A0A0A);
|
||||
static const _bgSunken = Color(0xFF000000);
|
||||
static const _surface = Color(0xFF111111);
|
||||
static const _surfaceHi = Color(0xFF181818);
|
||||
static const _border = Color(0xFF242424);
|
||||
static const _borderHi = Color(0xFF2E2E2E);
|
||||
static const _bg = Color(0xFF0A0A0A);
|
||||
static const _bgSunken = Color(0xFF000000);
|
||||
static const _surface = Color(0xFF111111);
|
||||
static const _surfaceHi = Color(0xFF181818);
|
||||
static const _border = Color(0xFF242424);
|
||||
static const _borderHi = Color(0xFF2E2E2E);
|
||||
|
||||
static const _textHi = Color(0xFFE6E6E6);
|
||||
static const _text = Color(0xFFBDBDBD);
|
||||
static const _textDim = Color(0xFF7A7A7A);
|
||||
static const _textMute = Color(0xFF4A4A4A);
|
||||
static const _textHi = Color(0xFFE6E6E6);
|
||||
static const _text = Color(0xFFBDBDBD);
|
||||
static const _textDim = Color(0xFF7A7A7A);
|
||||
static const _textMute = Color(0xFF4A4A4A);
|
||||
|
||||
static const _accent = Color(0xFFE0B050); // amber
|
||||
static const _accent = Color(0xFFE0B050); // amber
|
||||
static const _accentPress = Color(0xFFC29438);
|
||||
static const _accentSoft = Color(0x22E0B050);
|
||||
static const _accentSoft = Color(0x22E0B050);
|
||||
|
||||
static const _ok = Color(0xFF8FDC9B);
|
||||
static const _warn = Color(0xFFE0B050);
|
||||
static const _err = Color(0xFFE05050);
|
||||
static const _info = Color(0xFFA3C4FF);
|
||||
static const _ok = Color(0xFF8FDC9B);
|
||||
static const _warn = Color(0xFFE0B050);
|
||||
static const _err = Color(0xFFE05050);
|
||||
static const _info = Color(0xFFA3C4FF);
|
||||
|
||||
// Classic 16-color palette feel
|
||||
static const _synKeyword = Color(0xFFE05050);
|
||||
static const _synType = Color(0xFFE0B050);
|
||||
static const _synString = Color(0xFF8FDC9B);
|
||||
static const _synNumber = Color(0xFFC792EA);
|
||||
static const _synComment = Color(0xFF4A4A4A);
|
||||
static const _synMethod = Color(0xFFA3C4FF);
|
||||
static const _synPunct = Color(0xFF7A7A7A);
|
||||
static const _synKeyword = Color(0xFFE05050);
|
||||
static const _synType = Color(0xFFE0B050);
|
||||
static const _synString = Color(0xFF8FDC9B);
|
||||
static const _synNumber = Color(0xFFC792EA);
|
||||
static const _synComment = Color(0xFF4A4A4A);
|
||||
static const _synMethod = Color(0xFFA3C4FF);
|
||||
static const _synPunct = Color(0xFF7A7A7A);
|
||||
|
||||
static const tokens = ClideTokens(
|
||||
bg: _bg, bgSunken: _bgSunken, surface: _surface, surfaceHi: _surfaceHi,
|
||||
border: _border, borderHi: _borderHi,
|
||||
textHi: _textHi, text: _text, textDim: _textDim, textMute: _textMute,
|
||||
accent: _accent, accentPress: _accentPress, accentSoft: _accentSoft,
|
||||
ok: _ok, warn: _warn, err: _err, info: _info,
|
||||
synKeyword: _synKeyword, synType: _synType, synString: _synString,
|
||||
synNumber: _synNumber, synComment: _synComment, synMethod: _synMethod,
|
||||
bg: _bg,
|
||||
bgSunken: _bgSunken,
|
||||
surface: _surface,
|
||||
surfaceHi: _surfaceHi,
|
||||
border: _border,
|
||||
borderHi: _borderHi,
|
||||
textHi: _textHi,
|
||||
text: _text,
|
||||
textDim: _textDim,
|
||||
textMute: _textMute,
|
||||
accent: _accent,
|
||||
accentPress: _accentPress,
|
||||
accentSoft: _accentSoft,
|
||||
ok: _ok,
|
||||
warn: _warn,
|
||||
err: _err,
|
||||
info: _info,
|
||||
synKeyword: _synKeyword,
|
||||
synType: _synType,
|
||||
synString: _synString,
|
||||
synNumber: _synNumber,
|
||||
synComment: _synComment,
|
||||
synMethod: _synMethod,
|
||||
synPunct: _synPunct,
|
||||
);
|
||||
|
||||
static ThemeData get data => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: _accent, onPrimary: Color(0xFF000000),
|
||||
secondary: _info, onSecondary: Color(0xFF000000),
|
||||
surface: _surface, onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border, outlineVariant: _borderHi,
|
||||
error: _err, onError: Color(0xFF000000),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||
// Terminal mockups go full-mono even for display.
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||
);
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: _accent,
|
||||
onPrimary: Color(0xFF000000),
|
||||
secondary: _info,
|
||||
onSecondary: Color(0xFF000000),
|
||||
surface: _surface,
|
||||
onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border,
|
||||
outlineVariant: _borderHi,
|
||||
error: _err,
|
||||
onError: Color(0xFF000000),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||
// Terminal mockups go full-mono even for display.
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,33 +72,33 @@ class ClideThemes {
|
||||
dark: true,
|
||||
subtitle: 'cool near-black + periwinkle · default',
|
||||
palette: ClidePalette(
|
||||
bg: Color(0xFF20202C),
|
||||
bgSunken: Color(0xFF1A1A24),
|
||||
surface: Color(0xFF242838),
|
||||
surfaceHi: Color(0xFF2C3046),
|
||||
border: Color(0xFF343850),
|
||||
borderHi: Color(0xFF3C445C),
|
||||
textHi: Color(0xFFE6E8F2),
|
||||
text: Color(0xFFB1BBE3),
|
||||
textDim: Color(0xFF78809C),
|
||||
textMute: Color(0xFF545C84),
|
||||
accent: Color(0xFF78A0F8),
|
||||
bg: Color(0xFF20202C),
|
||||
bgSunken: Color(0xFF1A1A24),
|
||||
surface: Color(0xFF242838),
|
||||
surfaceHi: Color(0xFF2C3046),
|
||||
border: Color(0xFF343850),
|
||||
borderHi: Color(0xFF3C445C),
|
||||
textHi: Color(0xFFE6E8F2),
|
||||
text: Color(0xFFB1BBE3),
|
||||
textDim: Color(0xFF78809C),
|
||||
textMute: Color(0xFF545C84),
|
||||
accent: Color(0xFF78A0F8),
|
||||
accentPress: Color(0xFF6C90DC),
|
||||
accentSoft: Color(0x2178A0F8),
|
||||
onAccent: Color(0xFF0D1020),
|
||||
ok: Color(0xFF7DD3A8),
|
||||
warn: Color(0xFFE6C370),
|
||||
err: Color(0xFFE87D7D),
|
||||
info: Color(0xFF78A0F8),
|
||||
accentSoft: Color(0x2178A0F8),
|
||||
onAccent: Color(0xFF0D1020),
|
||||
ok: Color(0xFF7DD3A8),
|
||||
warn: Color(0xFFE6C370),
|
||||
err: Color(0xFFE87D7D),
|
||||
info: Color(0xFF78A0F8),
|
||||
),
|
||||
syntax: ClideSyntax(
|
||||
keyword: Color(0xFFC792EA),
|
||||
type: Color(0xFF78A0F8),
|
||||
string: Color(0xFFA8D99B),
|
||||
number: Color(0xFFE6C370),
|
||||
comment: Color(0xFF545C84),
|
||||
method: Color(0xFF82B1FF),
|
||||
punct: Color(0xFF78809C),
|
||||
keyword: Color(0xFFC792EA),
|
||||
type: Color(0xFF78A0F8),
|
||||
string: Color(0xFFA8D99B),
|
||||
number: Color(0xFFE6C370),
|
||||
comment: Color(0xFF545C84),
|
||||
method: Color(0xFF82B1FF),
|
||||
punct: Color(0xFF78809C),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -108,33 +108,33 @@ class ClideThemes {
|
||||
dark: true,
|
||||
subtitle: 'VS Code-adjacent muted dark',
|
||||
palette: ClidePalette(
|
||||
bg: Color(0xFF1E1E1E),
|
||||
bgSunken: Color(0xFF181818),
|
||||
surface: Color(0xFF252526),
|
||||
surfaceHi: Color(0xFF2D2D2E),
|
||||
border: Color(0xFF333333),
|
||||
borderHi: Color(0xFF3F3F3F),
|
||||
textHi: Color(0xFFD4D4D4),
|
||||
text: Color(0xFFBBBBBB),
|
||||
textDim: Color(0xFF858585),
|
||||
textMute: Color(0xFF6A6A6A),
|
||||
accent: Color(0xFF569CD6),
|
||||
bg: Color(0xFF1E1E1E),
|
||||
bgSunken: Color(0xFF181818),
|
||||
surface: Color(0xFF252526),
|
||||
surfaceHi: Color(0xFF2D2D2E),
|
||||
border: Color(0xFF333333),
|
||||
borderHi: Color(0xFF3F3F3F),
|
||||
textHi: Color(0xFFD4D4D4),
|
||||
text: Color(0xFFBBBBBB),
|
||||
textDim: Color(0xFF858585),
|
||||
textMute: Color(0xFF6A6A6A),
|
||||
accent: Color(0xFF569CD6),
|
||||
accentPress: Color(0xFF4785BD),
|
||||
accentSoft: Color(0x21569CD6),
|
||||
onAccent: Color(0xFF0B1220),
|
||||
ok: Color(0xFF89D185),
|
||||
warn: Color(0xFFD7BA7D),
|
||||
err: Color(0xFFF48771),
|
||||
info: Color(0xFF569CD6),
|
||||
accentSoft: Color(0x21569CD6),
|
||||
onAccent: Color(0xFF0B1220),
|
||||
ok: Color(0xFF89D185),
|
||||
warn: Color(0xFFD7BA7D),
|
||||
err: Color(0xFFF48771),
|
||||
info: Color(0xFF569CD6),
|
||||
),
|
||||
syntax: ClideSyntax(
|
||||
keyword: Color(0xFFC586C0),
|
||||
type: Color(0xFF4EC9B0),
|
||||
string: Color(0xFFCE9178),
|
||||
number: Color(0xFFB5CEA8),
|
||||
comment: Color(0xFF6A9955),
|
||||
method: Color(0xFFDCDCAA),
|
||||
punct: Color(0xFF858585),
|
||||
keyword: Color(0xFFC586C0),
|
||||
type: Color(0xFF4EC9B0),
|
||||
string: Color(0xFFCE9178),
|
||||
number: Color(0xFFB5CEA8),
|
||||
comment: Color(0xFF6A9955),
|
||||
method: Color(0xFFDCDCAA),
|
||||
punct: Color(0xFF858585),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -144,33 +144,33 @@ class ClideThemes {
|
||||
dark: false,
|
||||
subtitle: 'drafting sheet · red-pencil accent · light',
|
||||
palette: ClidePalette(
|
||||
bg: Color(0xFFF4F1EA),
|
||||
bgSunken: Color(0xFFECE7DB),
|
||||
surface: Color(0xFFFBF8F1),
|
||||
surfaceHi: Color(0xFFECE7DB),
|
||||
border: Color(0xFF1A1A1A),
|
||||
borderHi: Color(0xFF4A4A4A),
|
||||
textHi: Color(0xFF1A1A1A),
|
||||
text: Color(0xFF4A4A4A),
|
||||
textDim: Color(0xFF8A8A82),
|
||||
textMute: Color(0xFFA8A89E),
|
||||
accent: Color(0xFFC14B2A),
|
||||
bg: Color(0xFFF4F1EA),
|
||||
bgSunken: Color(0xFFECE7DB),
|
||||
surface: Color(0xFFFBF8F1),
|
||||
surfaceHi: Color(0xFFECE7DB),
|
||||
border: Color(0xFF1A1A1A),
|
||||
borderHi: Color(0xFF4A4A4A),
|
||||
textHi: Color(0xFF1A1A1A),
|
||||
text: Color(0xFF4A4A4A),
|
||||
textDim: Color(0xFF8A8A82),
|
||||
textMute: Color(0xFFA8A89E),
|
||||
accent: Color(0xFFC14B2A),
|
||||
accentPress: Color(0xFFA03D20),
|
||||
accentSoft: Color(0x21C14B2A),
|
||||
onAccent: Color(0xFFFBF8F1),
|
||||
ok: Color(0xFF2D8A52),
|
||||
warn: Color(0xFFB88A2A),
|
||||
err: Color(0xFFB03A2A),
|
||||
info: Color(0xFF2A6FC1),
|
||||
accentSoft: Color(0x21C14B2A),
|
||||
onAccent: Color(0xFFFBF8F1),
|
||||
ok: Color(0xFF2D8A52),
|
||||
warn: Color(0xFFB88A2A),
|
||||
err: Color(0xFFB03A2A),
|
||||
info: Color(0xFF2A6FC1),
|
||||
),
|
||||
syntax: ClideSyntax(
|
||||
keyword: Color(0xFF7B3F8C),
|
||||
type: Color(0xFF2A6FC1),
|
||||
string: Color(0xFF2D8A52),
|
||||
number: Color(0xFFB88A2A),
|
||||
comment: Color(0xFF8A8A82),
|
||||
method: Color(0xFF1E5D9E),
|
||||
punct: Color(0xFF4A4A4A),
|
||||
keyword: Color(0xFF7B3F8C),
|
||||
type: Color(0xFF2A6FC1),
|
||||
string: Color(0xFF2D8A52),
|
||||
number: Color(0xFFB88A2A),
|
||||
comment: Color(0xFF8A8A82),
|
||||
method: Color(0xFF1E5D9E),
|
||||
punct: Color(0xFF4A4A4A),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -180,33 +180,33 @@ class ClideThemes {
|
||||
dark: true,
|
||||
subtitle: 'near-black + amber · tmux feel',
|
||||
palette: ClidePalette(
|
||||
bg: Color(0xFF0A0A0A),
|
||||
bgSunken: Color(0xFF000000),
|
||||
surface: Color(0xFF111111),
|
||||
surfaceHi: Color(0xFF181818),
|
||||
border: Color(0xFF242424),
|
||||
borderHi: Color(0xFF2E2E2E),
|
||||
textHi: Color(0xFFE6E6E6),
|
||||
text: Color(0xFFBDBDBD),
|
||||
textDim: Color(0xFF7A7A7A),
|
||||
textMute: Color(0xFF4A4A4A),
|
||||
accent: Color(0xFFE0B050),
|
||||
bg: Color(0xFF0A0A0A),
|
||||
bgSunken: Color(0xFF000000),
|
||||
surface: Color(0xFF111111),
|
||||
surfaceHi: Color(0xFF181818),
|
||||
border: Color(0xFF242424),
|
||||
borderHi: Color(0xFF2E2E2E),
|
||||
textHi: Color(0xFFE6E6E6),
|
||||
text: Color(0xFFBDBDBD),
|
||||
textDim: Color(0xFF7A7A7A),
|
||||
textMute: Color(0xFF4A4A4A),
|
||||
accent: Color(0xFFE0B050),
|
||||
accentPress: Color(0xFFC29438),
|
||||
accentSoft: Color(0x21E0B050),
|
||||
onAccent: Color(0xFF000000),
|
||||
ok: Color(0xFF8FDC9B),
|
||||
warn: Color(0xFFE0B050),
|
||||
err: Color(0xFFE05050),
|
||||
info: Color(0xFFA3C4FF),
|
||||
accentSoft: Color(0x21E0B050),
|
||||
onAccent: Color(0xFF000000),
|
||||
ok: Color(0xFF8FDC9B),
|
||||
warn: Color(0xFFE0B050),
|
||||
err: Color(0xFFE05050),
|
||||
info: Color(0xFFA3C4FF),
|
||||
),
|
||||
syntax: ClideSyntax(
|
||||
keyword: Color(0xFFE05050),
|
||||
type: Color(0xFFE0B050),
|
||||
string: Color(0xFF8FDC9B),
|
||||
number: Color(0xFFC792EA),
|
||||
comment: Color(0xFF4A4A4A),
|
||||
method: Color(0xFFA3C4FF),
|
||||
punct: Color(0xFF7A7A7A),
|
||||
keyword: Color(0xFFE05050),
|
||||
type: Color(0xFFE0B050),
|
||||
string: Color(0xFF8FDC9B),
|
||||
number: Color(0xFFC792EA),
|
||||
comment: Color(0xFF4A4A4A),
|
||||
method: Color(0xFFA3C4FF),
|
||||
punct: Color(0xFF7A7A7A),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -332,8 +332,8 @@ node; Claude can add a node by `clide canvas node …`.
|
||||
|
||||
- **Flutter Desktop on Linux + macOS** is the primary target;
|
||||
Windows is a stretch goal. Mobile is not a goal.
|
||||
- **Gitea remote** (`git.schweitz.net/jpmschweitzer/clide`) remains
|
||||
the canonical host.
|
||||
- **GitHub remote** (`github.com/postmeridiem/clide`) is the
|
||||
canonical host.
|
||||
- **`pql` stays untouched** for Tiers 0–2; upstream pql work
|
||||
happens in pql's repo during Tiers 3–5.
|
||||
- **tmux** is optional plumbing the sidecar can compose for team
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# macOS PTY Problem — Diagnosis Complete
|
||||
|
||||
## Status: Root cause found
|
||||
|
||||
The PTY master fd is valid (`isatty=1`), SCM_RIGHTS transfer is correct, all struct layouts are correct. The problem is **timing**: the reader isolate starts too late and the shell has already exited by the time `read()` is called on the master fd. macOS returns EOF (n=0) immediately when the slave side is closed — unlike Linux which buffers data.
|
||||
|
||||
## The timing sequence (what happens)
|
||||
|
||||
```
|
||||
1. Dart: socketpair()
|
||||
2. Dart: Process.start(ptyc)
|
||||
3. ptyc: posix_openpt + grantpt + unlockpt + open(slave)
|
||||
4. ptyc: fork() → child starts zsh
|
||||
5. ptyc: sendmsg(SCM_RIGHTS, master_fd) ← master fd sent
|
||||
6. ptyc: printf(ok) + exit (or block if diagnostic)
|
||||
7. Dart: recvmsg() → receives master_fd ← fd is valid, isatty=1
|
||||
8. Dart: PtySession._() constructor
|
||||
9. Dart: _startReader() → Isolate.spawn() ← ASYNC, does not start immediately
|
||||
10. ... event loop yields ...
|
||||
11. Reader isolate starts, calls read(masterFd)
|
||||
12. But zsh already exited → slave closed → read() returns 0 (EOF)
|
||||
```
|
||||
|
||||
The gap between step 9 (Isolate.spawn scheduled) and step 11 (reader actually runs) is where the data is lost. On macOS, the kernel doesn't buffer PTY master data after the slave closes.
|
||||
|
||||
## Why is zsh exiting?
|
||||
|
||||
zsh (`/bin/zsh`) is an interactive shell. It should NOT exit immediately — it should show a prompt and wait for input. But in the test, it does exit. Possible reasons:
|
||||
|
||||
1. **No controlling terminal at spawn time.** ptyc's child does `setsid() + TIOCSCTTY + dup2(slave, 0/1/2)` — this should work. But if the slave fd is already closed or invalid by the time dup2 runs, zsh gets no tty and exits.
|
||||
|
||||
2. **Environment.** The `env` passed to ptyc may be empty or missing `TERM`, `HOME`, `SHELL` etc. Without `TERM`, zsh may fail to initialize and exit.
|
||||
|
||||
3. **The `PTYC_SOCK_FD` inherits open fds.** When ptyc forks, the child inherits ALL open fds (master, slave, socket, exec-failure pipe). ptyc closes master and pipe in the child, but the socket fd stays open. This shouldn't cause an exit, but it's worth checking.
|
||||
|
||||
4. **stdin is connected to the slave.** After dup2(slave, 0), zsh reads from the PTY slave. If Dart hasn't written anything to the master AND there's no PTY echo (because the master isn't being read), zsh might get SIGHUP or detect a broken pipe.
|
||||
|
||||
## Verified facts
|
||||
|
||||
| Test | Result |
|
||||
|------|--------|
|
||||
| ptyc compiles on macOS | ✓ |
|
||||
| socketpair fd inherited by ptyc | ✓ (verified with test binary) |
|
||||
| ptyc creates PTY and forks | ✓ (`{"ok":true,"pid":N}`) |
|
||||
| SCM_RIGHTS transfer | ✓ (correct cmsg layout) |
|
||||
| cmsghdr struct (Dart) | ✓ (CmsghdrDarwin, 12 bytes, correct offsets) |
|
||||
| msghdr struct (Dart) | ✓ (MsghdrDarwin, correct field sizes) |
|
||||
| SOL_SOCKET | ✓ (0xffff on macOS) |
|
||||
| TIOCSWINSZ | ✓ (0x80087467 on macOS) |
|
||||
| Received fd is a tty | ✓ (isatty=1) |
|
||||
| Reader isolate starts | ✓ (prints "started, fd=N") |
|
||||
| Reader gets data | ✗ — EOF immediately (n=0) |
|
||||
| Keeping ptyc alive helps | ✗ — still EOF |
|
||||
| close(master) in ptyc is the cause | ✗ — disproven |
|
||||
|
||||
## Next steps
|
||||
|
||||
1. **Investigate why zsh exits immediately.** Add logging to ptyc's child process to verify it reaches execvp. Check if the child gets a signal (SIGHUP, SIGTERM) right after exec.
|
||||
|
||||
2. **Check the environment passed to ptyc.** If `env` is empty, the child shell has no `TERM`, `HOME`, etc. and may exit immediately.
|
||||
|
||||
3. **Try a long-running command** instead of zsh — e.g. `sleep 10` — to rule out shell-specific init failures.
|
||||
|
||||
4. **Consider `forkpty()` on macOS** — eliminates the timing gap entirely. `forkpty()` creates the PTY, forks, and returns the master fd all in one call from the same process. The reader can start before the child is even exec'd.
|
||||
|
||||
## Environment
|
||||
|
||||
- macOS 26.3.1 (Darwin 25.3.0), Apple Silicon (arm64)
|
||||
- Flutter 3.41.7, Developer ID signed (no sandbox)
|
||||
- pql 1.4.4, dugite-native git 2.53.0
|
||||
- ptyc compiled with: `cc -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 -D_FORTIFY_SOURCE=2 -D_DARWIN_C_SOURCE`
|
||||
@@ -0,0 +1,57 @@
|
||||
# PTY on macOS: A New Diagnostic and Resolution Plan
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Previous attempts to fix the PTY functionality on macOS have failed, even after correcting a deadlock in the Dart code. The core of the problem appears to be a fundamental mismatch in how the C helper (`ptyc`) constructs a control message and how the Dart FFI layer is trying to parse it.
|
||||
|
||||
This document proposes a new, systematic plan to diagnose and resolve this issue by focusing on a key unresolved contradiction: the length of the ancillary data message (`cmsg_len`).
|
||||
|
||||
## 2. The Unresolved Contradiction
|
||||
|
||||
The primary blocker is a discrepancy in the expected length of the `SCM_RIGHTS` control message:
|
||||
|
||||
- **Observed Behavior:** The raw byte dump provided in `docs/macos-pty-problem.md` from the Dart `recvmsg` call shows the `cmsg_len` field as **16 bytes**.
|
||||
- **Code Analysis:** An analysis of the POSIX `CMSG_LEN` macro, as used in `ptyc.c`, suggests the length should be **20 bytes** on a 64-bit macOS system.
|
||||
|
||||
This contradiction means we cannot be certain about the memory layout of the data we are parsing in Dart. Without resolving this, any attempt to fix the data offset is guesswork.
|
||||
|
||||
## 3. Proposed Investigation and Resolution Plan
|
||||
|
||||
This plan will definitively resolve the `cmsg_len` discrepancy and lead to a correct implementation.
|
||||
|
||||
### Step 1: Instrument `ptyc` to Reveal Ground Truth
|
||||
|
||||
The first step is to get definitive data from the source. We will modify `ptyc.c` to log the exact values it's using.
|
||||
|
||||
- **Action:** Add `fprintf(stderr, ...)` statements in `ptyc.c` right before the `sendmsg` call.
|
||||
- **Data to Log:**
|
||||
1. The calculated value of `CMSG_LEN(sizeof(int))`.
|
||||
2. The value of `CMSG_SPACE(sizeof(int))`.
|
||||
3. The value of `sizeof(struct cmsghdr)`.
|
||||
- **Expected Outcome:** This will give us the "ground truth" of the control message structure as constructed by `ptyc` in the actual build environment, resolving the 16-vs-20-byte mystery.
|
||||
|
||||
### Step 2: Correctly Implement the Dart FFI Parser
|
||||
|
||||
With the true `cmsg_len` and memory layout confirmed, we can correctly implement the Dart-side parser.
|
||||
|
||||
- **Action:** Modify `lib/src/pty/ffi/scm_rights.dart`.
|
||||
- **Logic:**
|
||||
- If the logged `cmsg_len` from Step 1 confirms there is alignment padding (i.e., data starts at offset 16), the `dataOffset` calculation will be updated to `16`.
|
||||
- If the logged data shows no padding (i.e., data starts at offset 12), the `dataOffset` will be confirmed as `12`, and we will know the issue lies elsewhere.
|
||||
- **Expected Outcome:** A Dart parser (`recvFd`) that correctly reads the file descriptor from the ancillary data based on empirical evidence, not theoretical calculation.
|
||||
|
||||
### Step 3: Ensure Asynchronous Operation
|
||||
|
||||
The previously identified deadlock, while not the root cause of this specific failure, is still a critical bug.
|
||||
|
||||
- **Action:** Ensure the fix in `lib/src/pty/session.dart` is applied, where the blocking `scm.recvFd` call is replaced with its asynchronous counterpart, `_recvFdAsync`.
|
||||
- **Expected Outcome:** The Dart event loop is not blocked during PTY creation, preventing deadlocks.
|
||||
|
||||
### Step 4: Verification
|
||||
|
||||
With the above changes in place, we will verify the complete solution.
|
||||
|
||||
- **Action:** Run the existing test suite via `flutter test test/pty/session_test.dart`.
|
||||
- **Success Criteria:** All tests in `session_test.dart` must pass, indicating that a PTY can be spawned, its output can be read, and data can be written to it.
|
||||
|
||||
This methodical approach replaces guesswork with a data-driven diagnosis, providing a clear and direct path to resolving the long-standing macOS PTY issue.
|
||||
@@ -21,9 +21,7 @@ import '../test/helpers/fake_ipc.dart';
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets(
|
||||
'clide app boots with classic 3-column layout + welcome + statusbar',
|
||||
(tester) async {
|
||||
testWidgets('clide app boots with classic 3-column layout + welcome + statusbar', (tester) async {
|
||||
final themes = [
|
||||
await const ThemeLoader().fromAsset(
|
||||
rootBundle,
|
||||
@@ -40,8 +38,7 @@ void main() {
|
||||
'builtin.theme-picker',
|
||||
'builtin.default-layout',
|
||||
],
|
||||
daemonClientFactory: (log, events) =>
|
||||
FakeDaemonClient(log: log, events: events),
|
||||
daemonClientFactory: (log, events) => FakeDaemonClient(log: log, events: events),
|
||||
autoStartDaemonClient: false,
|
||||
);
|
||||
services.extensions
|
||||
|
||||
@@ -14,8 +14,7 @@ import '../test/helpers/fake_ipc.dart';
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('disable + re-enable an extension mounts/unmounts its UI',
|
||||
(tester) async {
|
||||
testWidgets('disable + re-enable an extension mounts/unmounts its UI', (tester) async {
|
||||
final themes = [
|
||||
await const ThemeLoader().fromAsset(
|
||||
rootBundle,
|
||||
@@ -31,8 +30,7 @@ void main() {
|
||||
'builtin.ipc-status',
|
||||
'builtin.default-layout',
|
||||
],
|
||||
daemonClientFactory: (log, events) =>
|
||||
FakeDaemonClient(log: log, events: events),
|
||||
daemonClientFactory: (log, events) => FakeDaemonClient(log: log, events: events),
|
||||
autoStartDaemonClient: false,
|
||||
);
|
||||
services.extensions
|
||||
|
||||
@@ -14,8 +14,7 @@ import '../test/helpers/fake_ipc.dart';
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('theme.pick command opens modal; selecting dismisses it',
|
||||
(tester) async {
|
||||
testWidgets('theme.pick command opens modal; selecting dismisses it', (tester) async {
|
||||
final themes = [
|
||||
await const ThemeLoader().fromAsset(
|
||||
rootBundle,
|
||||
@@ -31,8 +30,7 @@ void main() {
|
||||
'builtin.theme-picker',
|
||||
'builtin.default-layout',
|
||||
],
|
||||
daemonClientFactory: (log, events) =>
|
||||
FakeDaemonClient(log: log, events: events),
|
||||
daemonClientFactory: (log, events) => FakeDaemonClient(log: log, events: events),
|
||||
autoStartDaemonClient: false,
|
||||
);
|
||||
services.extensions
|
||||
|
||||
+93
-55
@@ -92,30 +92,30 @@ class _RootShellState extends State<_RootShell> {
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: ColoredBox(
|
||||
color: tokens.globalBackground,
|
||||
child: ClideResizeBorder(
|
||||
windowControls: widget.services.window,
|
||||
child: Column(
|
||||
children: [
|
||||
_HatBar(kernel: widget.services),
|
||||
Expanded(
|
||||
child: DialogHost(
|
||||
router: widget.services.dialog,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: RootLayout()),
|
||||
const ClidePalette(),
|
||||
const Positioned.fill(child: _WelcomeOverlay()),
|
||||
],
|
||||
color: tokens.globalBackground,
|
||||
child: ClideResizeBorder(
|
||||
windowControls: widget.services.window,
|
||||
child: Column(
|
||||
children: [
|
||||
_HatBar(kernel: widget.services),
|
||||
Expanded(
|
||||
child: DialogHost(
|
||||
router: widget.services.dialog,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: RootLayout()),
|
||||
const ClidePalette(),
|
||||
const Positioned.fill(child: _WelcomeOverlay()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -286,15 +286,7 @@ class _LeftHatContent extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (kIsWeb) return const SizedBox.shrink();
|
||||
if (!kIsWeb && Platform.isMacOS) {
|
||||
return Row(children: [
|
||||
_TrafficDot(color: const Color(0xFFFF5F57), onTap: wc.close),
|
||||
const SizedBox(width: 6),
|
||||
_TrafficDot(color: const Color(0xFFFEBC2E), onTap: wc.minimize),
|
||||
const SizedBox(width: 6),
|
||||
_TrafficDot(color: const Color(0xFF28C840), onTap: wc.toggleMaximize),
|
||||
]);
|
||||
}
|
||||
// On macOS the native titlebar draws traffic lights; skip duplicates.
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
@@ -316,23 +308,6 @@ class _RightHatContent extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _TrafficDot extends StatelessWidget {
|
||||
const _TrafficDot({required this.color, required this.onTap});
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
width: 12, height: 12,
|
||||
decoration: BoxDecoration(color: hovered ? color : color.withAlpha(0xCC), shape: BoxShape.circle),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WinBtn extends StatelessWidget {
|
||||
const _WinBtn({required this.icon, required this.onTap, required this.tokens, this.isClose = false});
|
||||
final ClideIconPainter icon;
|
||||
@@ -346,7 +321,8 @@ class _WinBtn extends StatelessWidget {
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
width: 36, height: hatHeight,
|
||||
width: 36,
|
||||
height: hatHeight,
|
||||
color: hovered ? hoverBg : null,
|
||||
alignment: Alignment.center,
|
||||
child: ClideIcon(icon, size: 14, color: hovered && isClose ? const Color(0xFFFFFFFF) : tokens.chromeForeground),
|
||||
@@ -432,8 +408,27 @@ class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> {
|
||||
widget.onDismiss();
|
||||
}
|
||||
|
||||
void _openFolder() {
|
||||
void _openFolder() async {
|
||||
widget.onDismiss();
|
||||
|
||||
try {
|
||||
final picked = await widget.kernel.window.pickDirectory();
|
||||
if (picked != null) {
|
||||
final ok = await widget.kernel.project.open(picked);
|
||||
if (ok) {
|
||||
widget.kernel.panels.activateTab(Slots.workspace, 'claude.primary');
|
||||
} else {
|
||||
widget.kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(
|
||||
path: picked,
|
||||
onDismiss: () => dismiss(),
|
||||
));
|
||||
}
|
||||
}
|
||||
return;
|
||||
} on MissingPluginException {
|
||||
// Fall through to text dialog.
|
||||
}
|
||||
|
||||
widget.kernel.dialog.show<String>((ctx, dismiss) {
|
||||
return _OpenFolderDialog(
|
||||
onOpen: (path) async {
|
||||
@@ -467,7 +462,7 @@ class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> {
|
||||
focusNode: _focus,
|
||||
onKeyEvent: _onKey,
|
||||
child: Container(
|
||||
width: 380,
|
||||
width: 480,
|
||||
constraints: const BoxConstraints(maxHeight: 420),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.dropdownBackground,
|
||||
@@ -502,10 +497,9 @@ class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> {
|
||||
decoration: BoxDecoration(border: Border(top: BorderSide(color: tokens.dividerColor))),
|
||||
child: Column(
|
||||
children: [
|
||||
_ActionRow(label: 'Open Local Project', shortcut: 'Ctrl+O', tokens: tokens, onTap: _openFolder),
|
||||
_ActionRow(label: 'New Window', shortcut: 'Ctrl+Shift+N', tokens: tokens, onTap: _newWindow),
|
||||
if (widget.kernel.project.isOpen)
|
||||
_ActionRow(label: 'Close Workspace', shortcut: '', tokens: tokens, onTap: _closeWorkspace),
|
||||
_ActionRow(label: 'Open Local Project', shortcut: Platform.isMacOS ? '⌘O' : 'Ctrl+O', tokens: tokens, onTap: _openFolder),
|
||||
_ActionRow(label: 'New Window', shortcut: Platform.isMacOS ? '⌘⇧N' : 'Ctrl+Shift+N', tokens: tokens, onTap: _newWindow),
|
||||
if (widget.kernel.project.isOpen) _ActionRow(label: 'Close Project', shortcut: '', tokens: tokens, onTap: _closeWorkspace),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -578,8 +572,7 @@ class _ActionRow extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: ClideText(label, fontSize: 14)),
|
||||
if (shortcut != null && shortcut!.isNotEmpty)
|
||||
ClideText(shortcut!, fontSize: 12, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
if (shortcut != null && shortcut!.isNotEmpty) ClideText(shortcut!, fontSize: 12, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -618,7 +611,10 @@ class _OpenFolderDialogState extends State<_OpenFolderDialog> {
|
||||
Future<void> _submit() async {
|
||||
final path = _controller.text.trim();
|
||||
if (path.isEmpty) return;
|
||||
setState(() { _loading = true; _error = null; });
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await widget.onOpen(path);
|
||||
} catch (_) {
|
||||
@@ -681,6 +677,48 @@ class _OpenFolderDialogState extends State<_OpenFolderDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
class _NotARepoDialog extends StatelessWidget {
|
||||
const _NotARepoDialog({required this.path, required this.onDismiss});
|
||||
final String path;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
width: 420,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.modalSurfaceBackground,
|
||||
border: Border.all(color: tokens.modalSurfaceBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ClideText('No git repo found', fontSize: 16, fontWeight: FontWeight.w600),
|
||||
const SizedBox(height: 8),
|
||||
ClideText(path, muted: true, fontSize: 13),
|
||||
const SizedBox(height: 8),
|
||||
const ClideText(
|
||||
'A clide project root requires a git repository.',
|
||||
muted: true,
|
||||
fontSize: 13,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ClideButton(label: 'OK', onPressed: () => onDismiss()),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SlotHost extends StatelessWidget {
|
||||
const SlotHost({super.key, required this.slot});
|
||||
final SlotId slot;
|
||||
@@ -892,7 +930,7 @@ class _ContextSlot extends StatelessWidget {
|
||||
return Container(
|
||||
color: tokens.panelBackground,
|
||||
alignment: Alignment.topLeft,
|
||||
padding: const EdgeInsets.fromLTRB(0, 2, 2, 0),
|
||||
padding: const EdgeInsets.only(right: 2),
|
||||
child: active.build(context),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,8 +29,7 @@ class ClaudePane extends StatefulWidget {
|
||||
this.isPrimary = true,
|
||||
this.secondaryIndex,
|
||||
this.showChrome = true,
|
||||
}) : assert(isPrimary || secondaryIndex != null,
|
||||
'secondary panes need an index');
|
||||
}) : assert(isPrimary || secondaryIndex != null, 'secondary panes need an index');
|
||||
|
||||
final bool isPrimary;
|
||||
final bool showChrome;
|
||||
@@ -48,20 +47,26 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
late final Terminal _terminal;
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
String? _paneId;
|
||||
String? _sessionName;
|
||||
String? _error;
|
||||
String _statusLine = 'attaching…';
|
||||
|
||||
bool _spawned = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_terminal = Terminal(maxLines: _maxLines);
|
||||
_terminal.onOutput = _onOutput;
|
||||
_terminal.onResize = _onResize;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _spawn());
|
||||
// Don't spawn here — wait for the first onResize from TerminalView
|
||||
// so the PTY gets real dimensions, not 80x24 defaults.
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_resizeTimer?.cancel();
|
||||
_flushTimer?.cancel();
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
final id = _paneId;
|
||||
@@ -78,6 +83,25 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _spawnWhenReady() async {
|
||||
if (!mounted) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
if (!kernel.project.isOpen) {
|
||||
// Wait for a project to open before spawning.
|
||||
final c = Completer<void>();
|
||||
late final StreamSubscription<ProjectOpened> sub;
|
||||
sub = kernel.events.on<ProjectOpened>().listen((_) {
|
||||
sub.cancel();
|
||||
if (!c.isCompleted) c.complete();
|
||||
});
|
||||
await c.future.timeout(const Duration(seconds: 10), onTimeout: () {
|
||||
sub.cancel();
|
||||
});
|
||||
if (!mounted) return;
|
||||
}
|
||||
return _spawn();
|
||||
}
|
||||
|
||||
Future<void> _spawn() async {
|
||||
if (!mounted) return;
|
||||
final ipc = _ipc();
|
||||
@@ -95,28 +119,32 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
repoRoot = (rootResp.data['path'] as String?) ?? repoRoot;
|
||||
}
|
||||
|
||||
final sessionName = widget.isPrimary
|
||||
? primarySessionName(repoRoot)
|
||||
: secondarySessionName(repoRoot, widget.secondaryIndex!);
|
||||
_sessionName = widget.isPrimary ? primarySessionName(repoRoot) : secondarySessionName(repoRoot, widget.secondaryIndex!);
|
||||
|
||||
// Try tmux-wrapped first (persistence). Fall back to direct claude
|
||||
// if tmux spawn errors.
|
||||
// tmux-wrapped session for persistence (D-041).
|
||||
// -x/-y set the initial window size; without them tmux defaults
|
||||
// to a huge size when running inside a PTY without a real terminal.
|
||||
final cols = _terminal.viewWidth;
|
||||
final rows = _terminal.viewHeight;
|
||||
var argv = <String>[
|
||||
'tmux',
|
||||
'new-session',
|
||||
'-A',
|
||||
'-s',
|
||||
sessionName,
|
||||
'--',
|
||||
'claude',
|
||||
_sessionName!,
|
||||
'-x',
|
||||
'$cols',
|
||||
'-y',
|
||||
'$rows',
|
||||
];
|
||||
print('[spawn] cols=${_terminal.viewWidth} rows=${_terminal.viewHeight}');
|
||||
var resp = await ipc.request('pane.spawn', args: {
|
||||
'argv': argv,
|
||||
'kind': PaneKind.claude.wire,
|
||||
'cwd': repoRoot,
|
||||
'cols': _terminal.viewWidth,
|
||||
'rows': _terminal.viewHeight,
|
||||
'title': sessionName,
|
||||
'title': _sessionName,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
@@ -129,7 +157,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
'cwd': repoRoot,
|
||||
'cols': _terminal.viewWidth,
|
||||
'rows': _terminal.viewHeight,
|
||||
'title': sessionName,
|
||||
'title': _sessionName,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setState(() {
|
||||
@@ -139,7 +167,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
}
|
||||
setState(() => _statusLine = 'no-tmux · fresh every launch');
|
||||
} else {
|
||||
setState(() => _statusLine = 'tmux · $sessionName');
|
||||
setState(() => _statusLine = 'tmux · $_sessionName');
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
@@ -149,6 +177,16 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
final _outputBuf = StringBuffer();
|
||||
Timer? _flushTimer;
|
||||
|
||||
void _flushOutput() {
|
||||
_flushTimer = null;
|
||||
if (_outputBuf.isEmpty) return;
|
||||
_terminal.write(_outputBuf.toString());
|
||||
_outputBuf.clear();
|
||||
}
|
||||
|
||||
void _subscribe() {
|
||||
final kernel = _kernel();
|
||||
if (kernel == null) return;
|
||||
@@ -158,7 +196,14 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
case 'pane.output':
|
||||
final b64 = e.data['bytes_b64'];
|
||||
if (b64 is String) {
|
||||
_terminal.write(utf8.decode(base64Decode(b64), allowMalformed: true));
|
||||
_outputBuf.write(utf8.decode(base64Decode(b64), allowMalformed: true));
|
||||
// Batch all output from the current event loop turn into one
|
||||
// terminal.write() call. scheduleMicrotask runs after all
|
||||
// pending events but before the next frame, so split escape
|
||||
// sequences within the same event batch are reunited.
|
||||
if (_flushTimer == null) {
|
||||
_flushTimer = Timer(Duration.zero, _flushOutput);
|
||||
}
|
||||
}
|
||||
case 'pane.exit':
|
||||
if (widget.isPrimary) {
|
||||
@@ -181,10 +226,29 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
_ipc()?.request('pane.write', args: {'id': id, 'text': text});
|
||||
}
|
||||
|
||||
Timer? _resizeTimer;
|
||||
|
||||
void _onResize(int cols, int rows, int _, int __) {
|
||||
final id = _paneId;
|
||||
if (id == null) return;
|
||||
_ipc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
|
||||
print('[onResize] cols=$cols rows=$rows spawned=$_spawned paneId=$_paneId');
|
||||
if (!_spawned) {
|
||||
// First resize — TerminalView has real dimensions now.
|
||||
_spawned = true;
|
||||
_spawnWhenReady();
|
||||
return;
|
||||
}
|
||||
// Debounce resize — rapid SIGWINCH during window drag corrupts
|
||||
// the terminal rendering. Wait for the resize to settle.
|
||||
_resizeTimer?.cancel();
|
||||
_resizeTimer = Timer(const Duration(milliseconds: 150), () {
|
||||
final id = _paneId;
|
||||
if (id == null) return;
|
||||
_ipc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
|
||||
// tmux sizes windows by client, not PTY winsize. Explicitly
|
||||
// resize the tmux window to match the TerminalView dimensions.
|
||||
if (_sessionName != null) {
|
||||
Process.run('tmux', ['resize-window', '-t', _sessionName!, '-x', '$cols', '-y', '$rows']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
DaemonClient? _ipc() => _kernel()?.ipc;
|
||||
@@ -199,9 +263,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = widget.isPrimary
|
||||
? 'claude — primary'
|
||||
: 'claude — secondary ${widget.secondaryIndex}';
|
||||
final title = widget.isPrimary ? 'claude — primary' : 'claude — secondary ${widget.secondaryIndex}';
|
||||
final body = _error != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
||||
@@ -109,7 +109,13 @@ class _TabRow extends StatelessWidget {
|
||||
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: tokens.dividerColor))),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < sessions.length; i++) _Tab(session: sessions[i], active: i == activeIndex, tokens: tokens, onTap: () => onSelect(i), onClose: sessions[i].isPrimary ? null : () => onClose(i)),
|
||||
for (var i = 0; i < sessions.length; i++)
|
||||
_Tab(
|
||||
session: sessions[i],
|
||||
active: i == activeIndex,
|
||||
tokens: tokens,
|
||||
onTap: () => onSelect(i),
|
||||
onClose: sessions[i].isPrimary ? null : () => onClose(i)),
|
||||
const SizedBox(width: 4),
|
||||
_AddButton(tokens: tokens, onTap: onAdd),
|
||||
const Spacer(),
|
||||
|
||||
@@ -30,6 +30,5 @@ class DecisionTypeColors {
|
||||
rejected: Color(0xFFC03030),
|
||||
);
|
||||
|
||||
static DecisionTypeColors forTheme({required bool dark}) =>
|
||||
dark ? DecisionTypeColors.dark : DecisionTypeColors.light;
|
||||
static DecisionTypeColors forTheme({required bool dark}) => dark ? DecisionTypeColors.dark : DecisionTypeColors.light;
|
||||
}
|
||||
|
||||
@@ -19,8 +19,12 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
String _filter = '';
|
||||
String? _focusedId;
|
||||
final _focusedKey = GlobalKey();
|
||||
final Set<String> _expanded = {'confirmed'};
|
||||
final Set<String> _pinned = {'confirmed'};
|
||||
StreamSubscription<Message>? _focusSub;
|
||||
StreamSubscription<DaemonEvent>? _fileSub;
|
||||
StreamSubscription<SchedulerTick>? _schedulerSub;
|
||||
bool _refreshing = false;
|
||||
bool _pendingRefresh = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
@@ -28,11 +32,34 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
if (_focusSub == null) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
_focusSub = kernel.messages.subscribe(publisher: 'builtin.decisions', channel: 'focus').listen(_onFocus);
|
||||
_fileSub = kernel.events
|
||||
.on<DaemonEvent>()
|
||||
.where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && _isDecisionPath(e.data['path'] as String? ?? ''))
|
||||
.listen((_) => _refresh());
|
||||
_schedulerSub = kernel.events.on<SchedulerTick>().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh());
|
||||
}
|
||||
if (!_loading || _decisions.isNotEmpty) return;
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
static bool _isDecisionPath(String path) => path.startsWith('decisions/') && path.endsWith('.md');
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (!mounted) return;
|
||||
if (_refreshing) {
|
||||
_pendingRefresh = true;
|
||||
return;
|
||||
}
|
||||
_refreshing = true;
|
||||
_pendingRefresh = false;
|
||||
await _load();
|
||||
_refreshing = false;
|
||||
if (_pendingRefresh && mounted) {
|
||||
_pendingRefresh = false;
|
||||
unawaited(_refresh());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
await kernel.ipc.request('pql.decisions.sync');
|
||||
@@ -59,18 +86,22 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
@override
|
||||
void dispose() {
|
||||
_focusSub?.cancel();
|
||||
_fileSub?.cancel();
|
||||
_schedulerSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _isSectionExpanded(String type) {
|
||||
if (_pinned.contains(type)) return true;
|
||||
if (_focusedId == null) return false;
|
||||
final entry = _decisions.where((d) => d.id == _focusedId).firstOrNull;
|
||||
return (entry?.type ?? 'confirmed') == type;
|
||||
}
|
||||
|
||||
void _onFocus(Message msg) {
|
||||
final id = msg.data['id'] as String?;
|
||||
if (id == null || id == _focusedId) return;
|
||||
final entry = _decisions.where((d) => d.id == id).firstOrNull;
|
||||
final section = entry?.type ?? 'confirmed';
|
||||
setState(() {
|
||||
_focusedId = id;
|
||||
_expanded.add(section);
|
||||
});
|
||||
setState(() => _focusedId = id);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final ctx = _focusedKey.currentContext;
|
||||
if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3);
|
||||
@@ -79,10 +110,10 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
|
||||
void _toggleSection(String key) {
|
||||
setState(() {
|
||||
if (_expanded.contains(key)) {
|
||||
_expanded.remove(key);
|
||||
if (_pinned.contains(key)) {
|
||||
_pinned.remove(key);
|
||||
} else {
|
||||
_expanded.add(key);
|
||||
_pinned.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -92,11 +123,20 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (_loading) return const Center(child: ClideText('Loading decisions...', muted: true));
|
||||
if (_error != null) return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
|
||||
if (_decisions.isEmpty) return const Padding(padding: EdgeInsets.all(12), child: ClideText('No decisions found.\nRun `pql decisions sync` to index.', muted: true));
|
||||
if (_decisions.isEmpty)
|
||||
return const Padding(padding: EdgeInsets.all(12), child: ClideText('No decisions found.\nRun `pql decisions sync` to index.', muted: true));
|
||||
|
||||
final lf = _filter.toLowerCase();
|
||||
final hasFilter = lf.isNotEmpty;
|
||||
final filtered = hasFilter ? _decisions.where((d) => d.id.toLowerCase().contains(lf) || d.title.toLowerCase().contains(lf) || (d.domain ?? '').toLowerCase().contains(lf) || (d.type ?? '').contains(lf)).toList() : _decisions;
|
||||
final filtered = hasFilter
|
||||
? _decisions
|
||||
.where((d) =>
|
||||
d.id.toLowerCase().contains(lf) ||
|
||||
d.title.toLowerCase().contains(lf) ||
|
||||
(d.domain ?? '').toLowerCase().contains(lf) ||
|
||||
(d.type ?? '').contains(lf))
|
||||
.toList()
|
||||
: _decisions;
|
||||
|
||||
final confirmed = filtered.where((d) => d.type == 'confirmed').toList();
|
||||
final questions = filtered.where((d) => d.type == 'question').toList();
|
||||
@@ -107,34 +147,65 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ClideFilterBox(hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v)),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: ClideFilterBox(hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ClideTappable(
|
||||
onTap: _refreshing ? null : _refresh,
|
||||
tooltip: 'Refresh decisions',
|
||||
builder: (ctx, hovered, _) =>
|
||||
ClideIcon(PhosphorIcons.arrowClockwise, size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (confirmed.isNotEmpty) _AccordionSection(
|
||||
label: 'CONFIRMED', count: confirmed.length, tokens: tokens,
|
||||
color: typeColors.confirmed,
|
||||
expanded: hasFilter || _expanded.contains('confirmed'),
|
||||
onToggle: () => _toggleSection('confirmed'),
|
||||
children: [for (final d in confirmed) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)],
|
||||
),
|
||||
if (questions.isNotEmpty) _AccordionSection(
|
||||
label: 'QUESTIONS', count: questions.length, tokens: tokens,
|
||||
color: typeColors.question,
|
||||
expanded: hasFilter || _expanded.contains('question'),
|
||||
onToggle: () => _toggleSection('question'),
|
||||
children: [for (final d in questions) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)],
|
||||
),
|
||||
if (rejected.isNotEmpty) _AccordionSection(
|
||||
label: 'REJECTED', count: rejected.length, tokens: tokens,
|
||||
color: typeColors.rejected,
|
||||
expanded: hasFilter || _expanded.contains('rejected'),
|
||||
onToggle: () => _toggleSection('rejected'),
|
||||
children: [for (final d in rejected) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)],
|
||||
),
|
||||
if (confirmed.isNotEmpty)
|
||||
ClideAccordion(
|
||||
label: 'CONFIRMED',
|
||||
count: confirmed.length,
|
||||
leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.confirmed, shape: BoxShape.circle)),
|
||||
expanded: hasFilter || _isSectionExpanded('confirmed'),
|
||||
onToggle: () => _toggleSection('confirmed'),
|
||||
children: [
|
||||
for (final d in confirmed)
|
||||
_DecisionCard(
|
||||
entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)
|
||||
],
|
||||
),
|
||||
if (questions.isNotEmpty)
|
||||
ClideAccordion(
|
||||
label: 'QUESTIONS',
|
||||
count: questions.length,
|
||||
leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.question, shape: BoxShape.circle)),
|
||||
expanded: hasFilter || _isSectionExpanded('question'),
|
||||
onToggle: () => _toggleSection('question'),
|
||||
children: [
|
||||
for (final d in questions)
|
||||
_DecisionCard(
|
||||
entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)
|
||||
],
|
||||
),
|
||||
if (rejected.isNotEmpty)
|
||||
ClideAccordion(
|
||||
label: 'REJECTED',
|
||||
count: rejected.length,
|
||||
leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.rejected, shape: BoxShape.circle)),
|
||||
expanded: hasFilter || _isSectionExpanded('rejected'),
|
||||
onToggle: () => _toggleSection('rejected'),
|
||||
children: [
|
||||
for (final d in rejected)
|
||||
_DecisionCard(
|
||||
entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -161,46 +232,6 @@ class _DecisionEntry {
|
||||
);
|
||||
}
|
||||
|
||||
class _AccordionSection extends StatelessWidget {
|
||||
const _AccordionSection({
|
||||
required this.label, required this.count, required this.tokens,
|
||||
required this.color, required this.expanded, required this.onToggle,
|
||||
required this.children,
|
||||
});
|
||||
final String label;
|
||||
final int count;
|
||||
final SurfaceTokens tokens;
|
||||
final Color color;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggle;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideTappable(
|
||||
onTap: onToggle,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.only(left: 4, top: 10, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideIcon(expanded ? PhosphorIcons.caretDown : PhosphorIcons.caretRight, size: 10, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 6),
|
||||
Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
ClideText('$label · $count', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (expanded) ...children,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DecisionCard extends StatelessWidget {
|
||||
const _DecisionCard({required this.entry, required this.tokens, required this.typeColors, this.focused = false, this.focusKey});
|
||||
final _DecisionEntry entry;
|
||||
@@ -236,8 +267,7 @@ class _DecisionCard extends StatelessWidget {
|
||||
const SizedBox(width: 6),
|
||||
ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
const Spacer(),
|
||||
if (entry.domain != null)
|
||||
ClideText(entry.domain!, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
if (entry.domain != null) ClideText(entry.domain!, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
@@ -32,6 +32,8 @@ class DecisionsExtension extends ClideExtension {
|
||||
icon: PhosphorIcons.lightbulb,
|
||||
build: (_) => DecisionDetailView(initialId: selectedId),
|
||||
));
|
||||
ctx.arrangement.setVisible(Slots.contextPanel, true);
|
||||
ctx.arrangement.setCollapsed(Slots.contextPanel, false);
|
||||
ctx.panels.activateTab(Slots.contextPanel, 'decisions.detail');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,9 +68,7 @@ class _DiffViewState extends State<DiffView> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
c.showStaged
|
||||
? 'No staged changes.'
|
||||
: 'No unstaged changes.',
|
||||
c.showStaged ? 'No staged changes.' : 'No unstaged changes.',
|
||||
muted: true,
|
||||
),
|
||||
),
|
||||
@@ -81,8 +79,7 @@ class _DiffViewState extends State<DiffView> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final diff in c.diffs)
|
||||
_FileDiff(diff: diff, controller: c),
|
||||
for (final diff in c.diffs) _FileDiff(diff: diff, controller: c),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -118,9 +115,7 @@ class _DiffToolbar extends StatelessWidget {
|
||||
child: ClideText(
|
||||
'Unstaged',
|
||||
fontSize: clideFontCaption,
|
||||
color: controller.showStaged
|
||||
? tokens.globalTextMuted
|
||||
: tokens.globalForeground,
|
||||
color: controller.showStaged ? tokens.globalTextMuted : tokens.globalForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -134,9 +129,7 @@ class _DiffToolbar extends StatelessWidget {
|
||||
child: ClideText(
|
||||
'Staged',
|
||||
fontSize: clideFontCaption,
|
||||
color: controller.showStaged
|
||||
? tokens.globalForeground
|
||||
: tokens.globalTextMuted,
|
||||
color: controller.showStaged ? tokens.globalForeground : tokens.globalTextMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -188,12 +181,8 @@ class _FileDiff extends StatelessWidget {
|
||||
color: tokens.panelHeaderForeground,
|
||||
),
|
||||
),
|
||||
if (additions > 0)
|
||||
ClideText('+$additions ', fontSize: clideFontCaption,
|
||||
color: tokens.statusSuccess),
|
||||
if (removals > 0)
|
||||
ClideText('-$removals', fontSize: clideFontCaption,
|
||||
color: tokens.statusError),
|
||||
if (additions > 0) ClideText('+$additions ', fontSize: clideFontCaption, color: tokens.statusSuccess),
|
||||
if (removals > 0) ClideText('-$removals', fontSize: clideFontCaption, color: tokens.statusError),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -17,8 +17,7 @@ import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class EditorController extends ChangeNotifier {
|
||||
EditorController({required this.ipc, required DaemonBus events})
|
||||
: _events = events {
|
||||
EditorController({required this.ipc, required DaemonBus events}) : _events = events {
|
||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||
}
|
||||
|
||||
@@ -77,9 +76,7 @@ class EditorController extends ChangeNotifier {
|
||||
_activePath = r.data['path']! as String;
|
||||
_content = (r.data['content'] as String?) ?? '';
|
||||
final sel = r.data['selection'];
|
||||
_selection = sel is Map
|
||||
? Selection.fromJson(sel.cast<String, Object?>())
|
||||
: const Selection.collapsed(0);
|
||||
_selection = sel is Map ? Selection.fromJson(sel.cast<String, Object?>()) : const Selection.collapsed(0);
|
||||
_dirty = (r.data['dirty'] as bool?) ?? false;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
@@ -27,7 +27,7 @@ class EditorView extends StatefulWidget {
|
||||
|
||||
class _EditorViewState extends State<EditorView> {
|
||||
EditorController? _controller;
|
||||
final TreeSitterService _syntax = TreeSitterService();
|
||||
final TreeSitterService _syntax = TreeSitterService.shared;
|
||||
late final SyntaxTextController _text;
|
||||
late final FocusNode _focus;
|
||||
String? _lastRemoteContent;
|
||||
@@ -45,8 +45,7 @@ class _EditorViewState extends State<EditorView> {
|
||||
super.didChangeDependencies();
|
||||
if (_controller != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_controller = EditorController(ipc: kernel.ipc, events: kernel.events)
|
||||
..addListener(_onControllerChanged);
|
||||
_controller = EditorController(ipc: kernel.ipc, events: kernel.events)..addListener(_onControllerChanged);
|
||||
unawaited(_controller!.hydrate());
|
||||
}
|
||||
|
||||
@@ -57,7 +56,6 @@ class _EditorViewState extends State<EditorView> {
|
||||
_focus.dispose();
|
||||
_controller?.removeListener(_onControllerChanged);
|
||||
_controller?.dispose();
|
||||
_syntax.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -81,29 +79,22 @@ class _EditorViewState extends State<EditorView> {
|
||||
final c = _controller;
|
||||
if (c == null || c.activeId == null) return;
|
||||
final value = _text.value;
|
||||
if (value.text == c.content &&
|
||||
value.selection.baseOffset == c.selection.start &&
|
||||
value.selection.extentOffset == c.selection.end) {
|
||||
if (value.text == c.content && value.selection.baseOffset == c.selection.start && value.selection.extentOffset == c.selection.end) {
|
||||
return;
|
||||
}
|
||||
_lastRemoteContent = value.text;
|
||||
c.pushLocalEdit(
|
||||
newContent: value.text,
|
||||
newSelection: Selection(
|
||||
start: value.selection.start < 0
|
||||
? value.text.length
|
||||
: value.selection.start,
|
||||
end: value.selection.end < 0
|
||||
? value.text.length
|
||||
: value.selection.end,
|
||||
start: value.selection.start < 0 ? value.text.length : value.selection.start,
|
||||
end: value.selection.end < 0 ? value.text.length : value.selection.end,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
final isCmd = HardwareKeyboard.instance.isMetaPressed ||
|
||||
HardwareKeyboard.instance.isControlPressed;
|
||||
final isCmd = HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed;
|
||||
if (isCmd && event.logicalKey == LogicalKeyboardKey.keyS) {
|
||||
unawaited(_controller?.save());
|
||||
return KeyEventResult.handled;
|
||||
|
||||
@@ -8,8 +8,7 @@ import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class SyntaxTextController extends TextEditingController {
|
||||
SyntaxTextController({required TreeSitterService syntax})
|
||||
: _syntax = syntax;
|
||||
SyntaxTextController({required TreeSitterService syntax}) : _syntax = syntax;
|
||||
|
||||
final TreeSitterService _syntax;
|
||||
|
||||
@@ -74,8 +73,7 @@ class SyntaxTextController extends TextEditingController {
|
||||
|
||||
// Convert byte offsets to character offsets.
|
||||
// Build a byte-to-char map only up to the max byte we need.
|
||||
final spans = _spans.where((s) => s.end <= sourceBytes.length).toList()
|
||||
..sort((a, b) => a.start != b.start ? a.start - b.start : a.end - b.end);
|
||||
final spans = _spans.where((s) => s.end <= sourceBytes.length).toList()..sort((a, b) => a.start != b.start ? a.start - b.start : a.end - b.end);
|
||||
|
||||
if (spans.isEmpty) {
|
||||
return TextSpan(text: text, style: style);
|
||||
@@ -121,9 +119,7 @@ class SyntaxTextController extends TextEditingController {
|
||||
continue;
|
||||
}
|
||||
final spanCharStart = byteToChar[span.start];
|
||||
final spanCharEnd = span.end <= maxByte
|
||||
? byteToChar[span.end]
|
||||
: source.length;
|
||||
final spanCharEnd = span.end <= maxByte ? byteToChar[span.end] : source.length;
|
||||
|
||||
if (spanCharStart < charPos) continue;
|
||||
|
||||
|
||||
@@ -136,8 +136,7 @@ class _Children extends StatelessWidget {
|
||||
controller: controller,
|
||||
depth: depth,
|
||||
),
|
||||
if (controller.isExpanded(e.path))
|
||||
_Children(path: e.path, controller: controller, depth: depth + 1),
|
||||
if (controller.isExpanded(e.path)) _Children(path: e.path, controller: controller, depth: depth + 1),
|
||||
],
|
||||
)
|
||||
else
|
||||
@@ -294,4 +293,3 @@ class _FilteredFileRow extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,94 +76,93 @@ class _GitPanelViewState extends State<GitPanelView> {
|
||||
child: Column(
|
||||
children: [
|
||||
ClideFilterBox(hint: 'Filter changes…', onChanged: (v) => setState(() => _filter = v)),
|
||||
Expanded(child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_BranchHeader(controller: c),
|
||||
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),
|
||||
),
|
||||
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 [],
|
||||
),
|
||||
if (c.staged.isNotEmpty) ...[
|
||||
_FileGroup(
|
||||
label: 'Staged',
|
||||
entries: _applyFilter(c.staged),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Unstage all',
|
||||
onTap: () => unawaited(c.unstage(const [])),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_BranchHeader(controller: c),
|
||||
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),
|
||||
),
|
||||
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 [],
|
||||
),
|
||||
if (c.staged.isNotEmpty) ...[
|
||||
_FileGroup(
|
||||
label: 'Staged',
|
||||
entries: _applyFilter(c.staged),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Unstage all',
|
||||
onTap: () => unawaited(c.unstage(const [])),
|
||||
),
|
||||
],
|
||||
onUnstage: (path) => unawaited(c.unstage([path])),
|
||||
),
|
||||
_CommitInput(
|
||||
commitMsg: _commitMsg,
|
||||
commitFocus: _commitFocus,
|
||||
controller: c,
|
||||
),
|
||||
],
|
||||
onUnstage: (path) => unawaited(c.unstage([path])),
|
||||
),
|
||||
_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()),
|
||||
if (c.unstaged.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Changes',
|
||||
entries: _applyFilter(c.unstaged),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Stage all',
|
||||
onTap: () => unawaited(c.stageAll()),
|
||||
),
|
||||
],
|
||||
onStage: (path) => unawaited(c.stage([path])),
|
||||
onDiscard: (path) => _confirmDiscard(context, c, path),
|
||||
),
|
||||
],
|
||||
onStage: (path) => unawaited(c.stage([path])),
|
||||
onDiscard: (path) => _confirmDiscard(context, c, path),
|
||||
),
|
||||
if (c.untracked.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Untracked',
|
||||
entries: _applyFilter(c.untracked),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Stage all',
|
||||
onTap: () {
|
||||
final paths = [
|
||||
for (final e in c.untracked) e['path'] as String,
|
||||
];
|
||||
unawaited(c.stage(paths));
|
||||
},
|
||||
if (c.untracked.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Untracked',
|
||||
entries: _applyFilter(c.untracked),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Stage all',
|
||||
onTap: () {
|
||||
final paths = [
|
||||
for (final e in c.untracked) e['path'] as String,
|
||||
];
|
||||
unawaited(c.stage(paths));
|
||||
},
|
||||
),
|
||||
],
|
||||
onStage: (path) => unawaited(c.stage([path])),
|
||||
),
|
||||
],
|
||||
onStage: (path) => unawaited(c.stage([path])),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -369,8 +368,7 @@ class _GitFileRow extends StatelessWidget {
|
||||
},
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.sidebarItemHover : null,
|
||||
padding: const EdgeInsets.only(
|
||||
left: 20, right: 8, top: 2, bottom: 2),
|
||||
padding: const EdgeInsets.only(left: 20, right: 8, top: 2, bottom: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(
|
||||
|
||||
@@ -140,8 +140,7 @@ class _BranchPickerState extends State<_BranchPicker> {
|
||||
_loading = false;
|
||||
if (r.ok) {
|
||||
_branches = [
|
||||
for (final b in (r.data['branches'] as List? ?? const []))
|
||||
(b as Map).cast<String, Object?>(),
|
||||
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';
|
||||
@@ -197,10 +196,8 @@ class _BranchPickerState extends State<_BranchPicker> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_loading)
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
|
||||
if (_error != null)
|
||||
Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true)),
|
||||
if (_loading) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
|
||||
if (_error != null) Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true)),
|
||||
if (!_loading && _error == null && _branches.isEmpty)
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('No branches found.', muted: true)),
|
||||
if (_branches.isNotEmpty)
|
||||
@@ -266,9 +263,7 @@ class _BranchRow extends StatelessWidget {
|
||||
name,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontSize: clideFontMono,
|
||||
color: current
|
||||
? tokens.globalForeground
|
||||
: tokens.listItemForeground,
|
||||
color: current ? tokens.globalForeground : tokens.listItemForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -10,19 +10,19 @@ class ToolStatusItem extends StatelessWidget {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.toolCheck,
|
||||
listenable: kernel.toolchain,
|
||||
builder: (ctx, _) {
|
||||
final tc = kernel.toolCheck;
|
||||
if (!tc.checked) return const SizedBox.shrink();
|
||||
final tc = kernel.toolchain;
|
||||
if (!tc.resolved) return const SizedBox.shrink();
|
||||
if (tc.allOk) {
|
||||
return _chip('ok', tokens.statusSuccess, tokens);
|
||||
return _chip('application ok', tokens.statusSuccess, tokens);
|
||||
}
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < tc.errors.length; i++) ...[
|
||||
for (var i = 0; i < tc.missing.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 10),
|
||||
_chip(tc.errors[i], tokens.statusWarning, tokens),
|
||||
_chip('${tc.missing[i]} not found', tokens.statusWarning, tokens),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'dart:async';
|
||||
import 'package:clide/builtin/markdown/src/markdown_viewer.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class MarkdownExtension extends ClideExtension {
|
||||
@override
|
||||
@@ -10,51 +12,35 @@ class MarkdownExtension extends ClideExtension {
|
||||
@override
|
||||
String get title => 'Markdown';
|
||||
@override
|
||||
String get version => '0.2.0';
|
||||
String get version => '0.3.0';
|
||||
@override
|
||||
List<String> get dependsOn => const ['builtin.editor'];
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
ClideExtensionContext? _ctx;
|
||||
StreamSubscription<DaemonEvent>? _editorSub;
|
||||
bool _viewerSpawned = false;
|
||||
StreamSubscription<Message>? _sub;
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'markdown.viewer',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Markdown',
|
||||
icon: PhosphorIcons.fileText,
|
||||
build: (_) => const MarkdownViewer(),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
_ctx = ctx;
|
||||
_editorSub = ctx.events.on<DaemonEvent>().listen((e) {
|
||||
if (e.subsystem != 'editor') return;
|
||||
if (e.kind == 'editor.active-changed' || e.kind == 'editor.opened') {
|
||||
final path = e.data['path'] as String?;
|
||||
if (path != null && path.endsWith('.md')) {
|
||||
_spawnViewer();
|
||||
}
|
||||
}
|
||||
_sub = ctx.messages.subscribe(publisher: id, channel: 'selection').listen((msg) {
|
||||
final path = msg.data['path'] as String?;
|
||||
if (path == null) return;
|
||||
ctx.panels.activateTab(Slots.contextPanel, 'markdown.viewer');
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ctx.messages.publish(id, 'load', {'path': path});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deactivate() async {
|
||||
_editorSub?.cancel();
|
||||
if (_viewerSpawned) {
|
||||
_ctx?.panels.uncontribute('markdown.viewer');
|
||||
_viewerSpawned = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _spawnViewer() {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null || _viewerSpawned) return;
|
||||
ctx.panels.contribute(TabContribution(
|
||||
id: 'markdown.viewer',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Viewer',
|
||||
priority: -100,
|
||||
build: (_) => const MarkdownViewer(),
|
||||
));
|
||||
_viewerSpawned = true;
|
||||
ctx.panels.activateTab(Slots.contextPanel, 'markdown.viewer');
|
||||
}
|
||||
Future<void> deactivate() async => _sub?.cancel();
|
||||
}
|
||||
|
||||
@@ -15,14 +15,19 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
|
||||
String? _path;
|
||||
String? _content;
|
||||
String? _error;
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
StreamSubscription<Message>? _selectionSub;
|
||||
StreamSubscription<DaemonEvent>? _editorSub;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_eventSub != null) return;
|
||||
if (_selectionSub != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_eventSub = kernel.events.on<DaemonEvent>().listen((e) {
|
||||
_selectionSub = kernel.messages.subscribe(publisher: 'builtin.markdown', channel: 'load').listen((msg) {
|
||||
final path = msg.data['path'] as String?;
|
||||
if (path != null) _loadFile(path);
|
||||
});
|
||||
_editorSub = kernel.events.on<DaemonEvent>().listen((e) {
|
||||
if (e.kind == 'editor.buffer_activated') {
|
||||
final path = e.data['path'] as String?;
|
||||
if (path != null && path.endsWith('.md')) {
|
||||
@@ -30,33 +35,21 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
|
||||
}
|
||||
}
|
||||
});
|
||||
final activeTab = kernel.panels.activeTabIn(Slots.workspace);
|
||||
if (activeTab == 'editor.active') {
|
||||
unawaited(_loadActiveBuffer());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSub?.cancel();
|
||||
_selectionSub?.cancel();
|
||||
_editorSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadActiveBuffer() async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request('editor.active');
|
||||
if (!mounted || !resp.ok) return;
|
||||
final path = resp.data['path'] as String?;
|
||||
if (path != null && path.endsWith('.md')) {
|
||||
await _loadFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadFile(String path) async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request('files.read', args: {'path': path});
|
||||
if (!mounted) return;
|
||||
if (resp.ok) {
|
||||
kernel.messages.publish('builtin.markdown', 'focus', {'path': path});
|
||||
setState(() {
|
||||
_path = path;
|
||||
_content = resp.data['content'] as String? ?? '';
|
||||
@@ -84,7 +77,7 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
|
||||
if (_content == null) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Open a .md file to preview it here.', muted: true),
|
||||
child: ClideText('Select a .md file to preview it here.', muted: true),
|
||||
);
|
||||
}
|
||||
return ClidePaneChrome(
|
||||
|
||||
@@ -61,8 +61,7 @@ class _BacklinksViewState extends State<BacklinksView> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ClideText(
|
||||
c.activePath!.split('/').last,
|
||||
color: tokens.globalForeground,
|
||||
@@ -70,8 +69,7 @@ class _BacklinksViewState extends State<BacklinksView> {
|
||||
),
|
||||
if (c.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ClideText(
|
||||
c.error!,
|
||||
color: tokens.statusError,
|
||||
@@ -120,8 +118,7 @@ class _LinkGroup extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 2),
|
||||
padding: const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 2),
|
||||
child: ClideText(
|
||||
'$label (${links.length})',
|
||||
fontSize: clideFontCaption,
|
||||
@@ -133,8 +130,7 @@ class _LinkGroup extends StatelessWidget {
|
||||
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 2),
|
||||
child: ClideText('None', fontSize: clideFontCaption, muted: true),
|
||||
),
|
||||
for (final link in links)
|
||||
_LinkRow(link: link, pathKey: pathKey),
|
||||
for (final link in links) _LinkRow(link: link, pathKey: pathKey),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -159,21 +155,17 @@ class _LinkRow extends StatelessWidget {
|
||||
onTap: () {
|
||||
if (!target.startsWith('http')) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
unawaited(
|
||||
kernel.ipc.request('editor.open', args: {'path': target}));
|
||||
unawaited(kernel.ipc.request('editor.open', args: {'path': target}));
|
||||
}
|
||||
},
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.sidebarItemHover : null,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
|
||||
child: ClideText(
|
||||
display,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: target.startsWith('http')
|
||||
? tokens.statusInfo
|
||||
: tokens.sidebarForeground,
|
||||
color: target.startsWith('http') ? tokens.statusInfo : tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// State model for the pql sidebar panel.
|
||||
///
|
||||
/// Manages schema cache, query execution, file listing, and
|
||||
/// decision/ticket views. All data comes through pql.* IPC verbs.
|
||||
/// Manages search, DSL query execution, and markdown file listing.
|
||||
/// All data comes through pql.* IPC verbs.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
@@ -9,16 +9,21 @@ import 'dart:async';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum PqlView { files, query }
|
||||
enum PqlView { query, markdown }
|
||||
|
||||
enum SearchMode { search, dsl }
|
||||
|
||||
class PqlController extends ChangeNotifier {
|
||||
PqlController({required this.ipc});
|
||||
|
||||
final DaemonClient ipc;
|
||||
|
||||
PqlView _view = PqlView.files;
|
||||
PqlView _view = PqlView.query;
|
||||
PqlView get view => _view;
|
||||
|
||||
SearchMode _searchMode = SearchMode.search;
|
||||
SearchMode get searchMode => _searchMode;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
|
||||
@@ -38,19 +43,53 @@ class PqlController extends ChangeNotifier {
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
switch (v) {
|
||||
case PqlView.files:
|
||||
unawaited(loadFiles());
|
||||
case PqlView.markdown:
|
||||
unawaited(loadMarkdownFiles());
|
||||
case PqlView.query:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadFiles({String? glob}) async {
|
||||
void toggleSearchMode() {
|
||||
_searchMode = _searchMode == SearchMode.search ? SearchMode.dsl : SearchMode.search;
|
||||
_results = const [];
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> search(String terms) async {
|
||||
if (terms.trim().isEmpty) {
|
||||
_results = const [];
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_loading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
final r = await ipc.request('pql.search', args: {
|
||||
'terms': terms,
|
||||
'limit': 50,
|
||||
});
|
||||
|
||||
_loading = false;
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message;
|
||||
_results = const [];
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_results = _castList(r.data['results']);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loadMarkdownFiles({String? glob}) async {
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
|
||||
final r = await ipc.request('pql.files', args: {
|
||||
if (glob != null) 'glob': glob,
|
||||
'glob': glob ?? '**/*.md',
|
||||
'limit': 200,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/// Sidebar panel for pql — file listing, DSL query input,
|
||||
/// decisions list, and ticket board views.
|
||||
/// Sidebar panel for pql — ranked search, DSL query, and markdown file listing.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
@@ -19,6 +18,10 @@ class PqlPanelView extends StatefulWidget {
|
||||
|
||||
class _PqlPanelViewState extends State<PqlPanelView> {
|
||||
PqlController? _controller;
|
||||
String? _focusedPath;
|
||||
final _focusedKey = GlobalKey();
|
||||
StreamSubscription<Message>? _focusSub;
|
||||
StreamSubscription<DaemonEvent>? _fileSub;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
@@ -26,11 +29,34 @@ class _PqlPanelViewState extends State<PqlPanelView> {
|
||||
if (_controller != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_controller = PqlController(ipc: kernel.ipc);
|
||||
unawaited(_controller!.loadFiles());
|
||||
_focusSub = kernel.messages.subscribe(publisher: 'builtin.markdown', channel: 'focus').listen((msg) {
|
||||
final path = msg.data['path'] as String?;
|
||||
if (path == null || path == _focusedPath) return;
|
||||
setState(() {
|
||||
_focusedPath = path;
|
||||
if (_controller!.view != PqlView.markdown) {
|
||||
_controller!.switchView(PqlView.markdown);
|
||||
}
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final ctx = _focusedKey.currentContext;
|
||||
if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3);
|
||||
});
|
||||
});
|
||||
_fileSub = kernel.events
|
||||
.on<DaemonEvent>()
|
||||
.where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && (e.data['path'] as String? ?? '').endsWith('.md'))
|
||||
.listen((_) {
|
||||
if (_controller?.view == PqlView.markdown) {
|
||||
unawaited(_controller!.loadMarkdownFiles());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusSub?.cancel();
|
||||
_fileSub?.cancel();
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -43,59 +69,49 @@ class _PqlPanelViewState extends State<PqlPanelView> {
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Semantics(
|
||||
label: 'pql panel',
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_ViewTabs(controller: c),
|
||||
if (c.view == PqlView.query)
|
||||
ClideFilterBox(
|
||||
hint: 'PQL query…',
|
||||
debounce: Duration.zero,
|
||||
onChanged: (_) {},
|
||||
onSubmitted: (v) => unawaited(c.runQuery(v)),
|
||||
),
|
||||
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.results.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true),
|
||||
),
|
||||
if (!c.loading && c.results.isEmpty && c.error == null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('No results.', muted: true),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (c.view == PqlView.files)
|
||||
for (final f in c.results) _FileRow(entry: f),
|
||||
if (c.view == PqlView.query)
|
||||
for (final r in c.results) _QueryResultRow(entry: r),
|
||||
],
|
||||
),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_ViewTabs(controller: c),
|
||||
if (c.view == PqlView.query) ...[
|
||||
_SearchInput(controller: c),
|
||||
],
|
||||
if (c.view == PqlView.markdown)
|
||||
ClideFilterBox(
|
||||
hint: 'Filter markdown…',
|
||||
onChanged: (v) => unawaited(c.loadMarkdownFiles(glob: v.isEmpty ? null : '**/*$v*.md')),
|
||||
),
|
||||
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.results.isEmpty) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
|
||||
if (!c.loading && c.results.isEmpty && c.error == null && c.view == PqlView.markdown)
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('No markdown files found.', muted: true)),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (c.view == PqlView.markdown)
|
||||
for (final f in c.results)
|
||||
_FileRow(
|
||||
entry: f,
|
||||
focused: (f['path'] as String?) == _focusedPath,
|
||||
focusKey: (f['path'] as String?) == _focusedPath ? _focusedKey : null,
|
||||
),
|
||||
if (c.view == PqlView.query && c.searchMode == SearchMode.search)
|
||||
for (final r in c.results) _SearchResultRow(entry: r),
|
||||
if (c.view == PqlView.query && c.searchMode == SearchMode.dsl)
|
||||
for (final r in c.results) _QueryResultRow(entry: r),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -119,21 +135,14 @@ class _ViewTabs extends StatelessWidget {
|
||||
for (final v in PqlView.values)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
toggled: controller.view == v,
|
||||
label: v.name,
|
||||
child: GestureDetector(
|
||||
onTap: () => controller.switchView(v),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: ClideText(
|
||||
_tabLabel(v),
|
||||
fontSize: clideFontCaption,
|
||||
color: controller.view == v
|
||||
? tokens.globalForeground
|
||||
: tokens.globalTextMuted,
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: () => controller.switchView(v),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: ClideText(
|
||||
_tabLabel(v),
|
||||
fontSize: clideFontCaption,
|
||||
color: controller.view == v ? tokens.globalForeground : tokens.globalTextMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -144,36 +153,157 @@ class _ViewTabs extends StatelessWidget {
|
||||
}
|
||||
|
||||
static String _tabLabel(PqlView v) => switch (v) {
|
||||
PqlView.files => 'Files',
|
||||
PqlView.query => 'Query',
|
||||
PqlView.query => 'Search',
|
||||
PqlView.markdown => 'Markdown',
|
||||
};
|
||||
}
|
||||
|
||||
class _FileRow extends StatelessWidget {
|
||||
const _FileRow({required this.entry});
|
||||
class _SearchInput extends StatelessWidget {
|
||||
const _SearchInput({required this.controller});
|
||||
final PqlController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final isDsl = controller.searchMode == SearchMode.dsl;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideFilterBox(
|
||||
hint: isDsl ? 'PQL query…' : 'Search vault…',
|
||||
debounce: isDsl ? Duration.zero : const Duration(milliseconds: 300),
|
||||
onChanged: isDsl ? (_) {} : (v) => unawaited(controller.search(v)),
|
||||
onSubmitted: isDsl ? (v) => unawaited(controller.runQuery(v)) : (v) => unawaited(controller.search(v)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: controller.toggleSearchMode,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: isDsl ? tokens.globalFocus.withAlpha(0x30) : null,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
border: Border.all(color: isDsl ? tokens.globalFocus : tokens.panelBorder),
|
||||
),
|
||||
child: ClideText('DSL', fontSize: clideFontBadge, color: isDsl ? tokens.globalFocus : tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(
|
||||
isDsl ? 'SQL-like query mode' : 'ranked text search',
|
||||
fontSize: clideFontBadge,
|
||||
muted: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchResultRow extends StatelessWidget {
|
||||
const _SearchResultRow({required this.entry});
|
||||
final Map<String, Object?> entry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final path = entry['path'] as String? ?? '';
|
||||
final name = entry['name'] as String? ?? path.split('/').last;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'Open $name',
|
||||
final score = (entry['score'] as num?)?.toDouble() ?? 0;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||
child: ClideTappable(
|
||||
onTap: () {
|
||||
final kernel = ClideKernel.of(context);
|
||||
unawaited(
|
||||
kernel.ipc.request('editor.open', args: {'path': path}));
|
||||
if (path.endsWith('.md')) {
|
||||
ClideKernel.of(context).messages.publish('builtin.markdown', 'selection', {'path': path});
|
||||
}
|
||||
},
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.sidebarItemHover : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.sidebarItemHover : null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(path, fontSize: clideFontCaption, color: tokens.sidebarForeground, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 3),
|
||||
_ScoreBar(score: score, tokens: tokens),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScoreBar extends StatelessWidget {
|
||||
const _ScoreBar({required this.score, required this.tokens});
|
||||
final double score;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 60,
|
||||
height: 3,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(1.5),
|
||||
child: ColoredBox(
|
||||
color: tokens.panelBorder,
|
||||
child: FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: score.clamp(0, 1),
|
||||
child: ColoredBox(color: tokens.globalFocus),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ClideText('${(score * 100).round()}%', fontSize: clideFontBadge, muted: true, fontFamily: clideMonoFamily),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FileRow extends StatelessWidget {
|
||||
const _FileRow({required this.entry, this.focused = false, this.focusKey});
|
||||
final Map<String, Object?> entry;
|
||||
final bool focused;
|
||||
final GlobalKey? focusKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final path = entry['path'] as String? ?? '';
|
||||
return Padding(
|
||||
key: focusKey,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||
child: ClideTappable(
|
||||
onTap: () => ClideKernel.of(context).messages.publish('builtin.markdown', 'selection', {'path': path}),
|
||||
builder: (context, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.sidebarItemHover : (focused ? tokens.sidebarItemSelected : null),
|
||||
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,
|
||||
),
|
||||
),
|
||||
@@ -190,10 +320,7 @@ class _QueryResultRow extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final name = entry['name'] as String? ?? entry['path'] as String? ?? '';
|
||||
final values = entry.entries
|
||||
.where((e) => e.key != 'name' && e.key != 'path')
|
||||
.map((e) => '${e.key}: ${e.value}')
|
||||
.join(' · ');
|
||||
final values = entry.entries.where((e) => e.key != 'name' && e.key != 'path').map((e) => '${e.key}: ${e.value}').join(' · ');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: Column(
|
||||
@@ -201,8 +328,7 @@ class _QueryResultRow extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(name, color: tokens.sidebarForeground),
|
||||
if (values.isNotEmpty)
|
||||
ClideText(values, fontSize: clideFontCaption, muted: true, maxLines: 2),
|
||||
if (values.isNotEmpty) ClideText(values, fontSize: clideFontCaption, muted: true, maxLines: 2),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -55,8 +55,7 @@ class ProblemsController extends ChangeNotifier {
|
||||
}
|
||||
final skill = (doctor.data['skill'] as Map?)?.cast<String, Object?>();
|
||||
if (skill != null) {
|
||||
final project =
|
||||
(skill['project'] as Map?)?.cast<String, Object?>();
|
||||
final project = (skill['project'] as Map?)?.cast<String, Object?>();
|
||||
if (project != null) {
|
||||
final state = project['state'] as String?;
|
||||
if (state == 'stale') {
|
||||
|
||||
@@ -49,7 +49,8 @@ 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: [
|
||||
@@ -64,16 +65,15 @@ class _ProblemsViewState extends State<ProblemsView> {
|
||||
label: 'refresh problems',
|
||||
child: GestureDetector(
|
||||
onTap: () => unawaited(c.refresh()),
|
||||
child: MouseRegion(cursor: SystemMouseCursors.click, child: ClideText('Refresh', fontSize: clideFontCaption, color: tokens.sidebarForeground)),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click, child: ClideText('Refresh', fontSize: clideFontCaption, color: tokens.sidebarForeground)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (c.loading && c.problems.isEmpty)
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('Scanning…', muted: true)),
|
||||
if (!c.loading && filtered.isEmpty)
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('No problems found.', muted: true)),
|
||||
if (c.loading && c.problems.isEmpty) const Padding(padding: EdgeInsets.all(12), child: ClideText('Scanning…', muted: true)),
|
||||
if (!c.loading && filtered.isEmpty) const Padding(padding: EdgeInsets.all(12), child: ClideText('No problems found.', muted: true)),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
|
||||
@@ -139,9 +139,7 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final subtitle = _error != null
|
||||
? _error!
|
||||
: (_paneId == null ? 'spawning shell…' : 'pid $_pid · ${_paneId!}');
|
||||
final subtitle = _error != null ? _error! : (_paneId == null ? 'spawning shell…' : 'pid $_pid · ${_paneId!}');
|
||||
|
||||
return ClidePaneChrome(
|
||||
title: 'terminal',
|
||||
|
||||
@@ -31,8 +31,7 @@ class _ThemePickerViewState extends State<ThemePickerView> {
|
||||
|
||||
return Semantics(
|
||||
container: true,
|
||||
label: i.string('modal.title',
|
||||
namespace: ThemePickerView.ns, placeholder: 'Select theme'),
|
||||
label: i.string('modal.title', namespace: ThemePickerView.ns, placeholder: 'Select theme'),
|
||||
explicitChildNodes: true,
|
||||
child: ClideSurface(
|
||||
width: 420,
|
||||
@@ -45,8 +44,7 @@ class _ThemePickerViewState extends State<ThemePickerView> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideText(
|
||||
i.string('modal.title',
|
||||
namespace: ThemePickerView.ns, placeholder: 'Select theme'),
|
||||
i.string('modal.title', namespace: ThemePickerView.ns, placeholder: 'Select theme'),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
@@ -65,9 +63,7 @@ class _ThemePickerViewState extends State<ThemePickerView> {
|
||||
displayName: t.displayName,
|
||||
selected: t.name == currentName,
|
||||
hovered: _hovered == t.name,
|
||||
hint: i.string('row.select.hint',
|
||||
namespace: ThemePickerView.ns,
|
||||
placeholder: 'Activate this theme'),
|
||||
hint: i.string('row.select.hint', namespace: ThemePickerView.ns, placeholder: 'Activate this theme'),
|
||||
onEnter: () => setState(() => _hovered = t.name),
|
||||
onExit: () => setState(() => _hovered = null),
|
||||
onTap: () {
|
||||
@@ -84,12 +80,9 @@ class _ThemePickerViewState extends State<ThemePickerView> {
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ClideButton(
|
||||
label: i.string('modal.cancel',
|
||||
namespace: ThemePickerView.ns, placeholder: 'Cancel'),
|
||||
semanticHint: i.string('modal.cancel.hint',
|
||||
namespace: ThemePickerView.ns,
|
||||
placeholder:
|
||||
'Close the theme picker without changing the current theme'),
|
||||
label: i.string('modal.cancel', namespace: ThemePickerView.ns, placeholder: 'Cancel'),
|
||||
semanticHint:
|
||||
i.string('modal.cancel.hint', namespace: ThemePickerView.ns, placeholder: 'Close the theme picker without changing the current theme'),
|
||||
onPressed: () => widget.onDismiss(),
|
||||
),
|
||||
],
|
||||
@@ -125,14 +118,8 @@ class _ThemeRow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final bg = selected
|
||||
? tokens.listItemSelectedBackground
|
||||
: (hovered
|
||||
? tokens.listItemHoverBackground
|
||||
: tokens.listItemBackground);
|
||||
final fg = selected
|
||||
? tokens.listItemSelectedForeground
|
||||
: tokens.listItemForeground;
|
||||
final bg = selected ? tokens.listItemSelectedBackground : (hovered ? tokens.listItemHoverBackground : tokens.listItemBackground);
|
||||
final fg = selected ? tokens.listItemSelectedForeground : tokens.listItemForeground;
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
|
||||
@@ -32,6 +32,8 @@ class TicketsExtension extends ClideExtension {
|
||||
icon: PhosphorIcons.ticket,
|
||||
build: (_) => TicketDetailView(initialId: selectedId),
|
||||
));
|
||||
ctx.arrangement.setVisible(Slots.contextPanel, true);
|
||||
ctx.arrangement.setCollapsed(Slots.contextPanel, false);
|
||||
ctx.panels.activateTab(Slots.contextPanel, 'tickets.detail');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,6 +40,5 @@ class TicketTypeColors {
|
||||
bug: Color(0xFFC03030),
|
||||
);
|
||||
|
||||
static TicketTypeColors forTheme({required bool dark}) =>
|
||||
dark ? TicketTypeColors.dark : TicketTypeColors.light;
|
||||
static TicketTypeColors forTheme({required bool dark}) => dark ? TicketTypeColors.dark : TicketTypeColors.light;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ class TicketDetailController extends ChangeNotifier {
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
|
||||
final resp = await ipc.request('pql.tickets.show', args: {'id': id});
|
||||
final resp = await ipc.request('pql.tickets.show', args: {'id': id, 'withContext': true});
|
||||
if (!resp.ok) {
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
@@ -57,33 +57,10 @@ class TicketDetailController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
final ticket = resp.data;
|
||||
final parents = <Map<String, Object?>>[];
|
||||
final decisions = <Map<String, Object?>>[];
|
||||
final ancestors = (ticket['ancestors'] as List?)?.cast<Map<String, Object?>>() ?? const [];
|
||||
final decisions = (ticket['decisions'] as List?)?.cast<Map<String, Object?>>() ?? const [];
|
||||
|
||||
// Walk parent chain
|
||||
var pid = ticket['parent_id'] as String?;
|
||||
while (pid != null) {
|
||||
final pr = await ipc.request('pql.tickets.show', args: {'id': pid});
|
||||
if (!pr.ok) break;
|
||||
parents.add(pr.data);
|
||||
pid = pr.data['parent_id'] as String?;
|
||||
}
|
||||
|
||||
// Collect decision refs from ticket and parents
|
||||
final refs = <String>{};
|
||||
final dr = ticket['decision_ref'] as String?;
|
||||
if (dr != null) refs.add(dr);
|
||||
for (final p in parents) {
|
||||
final pdr = p['decision_ref'] as String?;
|
||||
if (pdr != null) refs.add(pdr);
|
||||
}
|
||||
|
||||
for (final ref in refs) {
|
||||
final dr = await ipc.request('pql.decisions.show', args: {'id': ref});
|
||||
if (dr.ok) decisions.add(dr.data);
|
||||
}
|
||||
|
||||
_detail = TicketDetail(ticket: ticket, parents: parents, decisions: decisions);
|
||||
_detail = TicketDetail(ticket: ticket, parents: ancestors, decisions: decisions);
|
||||
_loading = false;
|
||||
messages.publish('builtin.tickets', 'focus', {'id': id});
|
||||
notifyListeners();
|
||||
|
||||
@@ -122,8 +122,7 @@ class _TicketHeader extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
ClideText(detail.id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily),
|
||||
const Spacer(),
|
||||
if (detail.priority != null)
|
||||
ClideText(detail.priority!, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
if (detail.priority != null) ClideText(detail.priority!, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -153,10 +152,18 @@ class _StatusControls extends StatelessWidget {
|
||||
for (final s in _statuses) ...[
|
||||
Expanded(
|
||||
child: ClideTappable(
|
||||
onTap: () async {
|
||||
await controller.ipc.request('pql.tickets.status', args: {'ids': detail.id, 'status': s});
|
||||
await controller.load(detail.id);
|
||||
},
|
||||
onTap: detail.status == s
|
||||
? null
|
||||
: () async {
|
||||
final resp = await controller.ipc.request('pql.tickets.status', args: {
|
||||
'ids': [detail.id],
|
||||
'status': s
|
||||
});
|
||||
if (resp.ok) {
|
||||
controller.messages.publish('builtin.tickets', 'changed', {'id': detail.id});
|
||||
await controller.load(detail.id);
|
||||
}
|
||||
},
|
||||
builder: (ctx, hovered, _) {
|
||||
final active = detail.status == s;
|
||||
final color = active ? tokens.statusInfo : (hovered ? tokens.globalForeground : tokens.globalTextMuted);
|
||||
|
||||
@@ -20,31 +20,40 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
String _filter = '';
|
||||
String? _focusedId;
|
||||
final _focusedKey = GlobalKey();
|
||||
final Set<String> _expanded = {'active', 'backlog'};
|
||||
final Set<String> _pinned = {'in_progress', 'ready', 'backlog'};
|
||||
StreamSubscription<Message>? _focusSub;
|
||||
StreamSubscription<SchedulerTick>? _schedulerSub;
|
||||
StreamSubscription<Message>? _changedSub;
|
||||
bool _refreshing = false;
|
||||
bool _pendingRefresh = false;
|
||||
|
||||
bool _isSectionExpanded(String status) {
|
||||
if (_pinned.contains(status)) return true;
|
||||
if (_focusedId == null) return false;
|
||||
final entry = _tickets.where((t) => t.id == _focusedId).firstOrNull;
|
||||
return _sectionForStatus(entry?.status) == status;
|
||||
}
|
||||
|
||||
void _toggle(String key) {
|
||||
setState(() {
|
||||
if (_expanded.contains(key)) _expanded.remove(key); else _expanded.add(key);
|
||||
if (_pinned.contains(key)) {
|
||||
_pinned.remove(key);
|
||||
} else {
|
||||
_pinned.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static String _sectionForStatus(String? status) => switch (status) {
|
||||
'in_progress' => 'active',
|
||||
'backlog' || 'ready' => 'backlog',
|
||||
'done' => 'done',
|
||||
_ => 'other',
|
||||
};
|
||||
static String _sectionForStatus(String? status) => status ?? 'backlog';
|
||||
|
||||
void _onFocus(Message msg) {
|
||||
final id = msg.data['id'] as String?;
|
||||
if (id == null || id == _focusedId) return;
|
||||
final entry = _tickets.where((t) => t.id == id).firstOrNull;
|
||||
final section = _sectionForStatus(entry?.status);
|
||||
setState(() {
|
||||
_focusedId = id;
|
||||
_expanded.add(section);
|
||||
});
|
||||
_scrollToFocused(id);
|
||||
}
|
||||
|
||||
void _scrollToFocused(String id) {
|
||||
setState(() => _focusedId = id);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final ctx = _focusedKey.currentContext;
|
||||
if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3);
|
||||
@@ -57,6 +66,13 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
if (_focusSub == null) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
_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((_) {
|
||||
if (id != null && mounted) _scrollToFocused(id);
|
||||
}));
|
||||
});
|
||||
_schedulerSub = kernel.events.on<SchedulerTick>().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh());
|
||||
}
|
||||
if (!_loading || _tickets.isNotEmpty) return;
|
||||
unawaited(_load());
|
||||
@@ -65,9 +81,27 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
@override
|
||||
void dispose() {
|
||||
_focusSub?.cancel();
|
||||
_changedSub?.cancel();
|
||||
_schedulerSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (!mounted) return;
|
||||
if (_refreshing) {
|
||||
_pendingRefresh = true;
|
||||
return;
|
||||
}
|
||||
_refreshing = true;
|
||||
_pendingRefresh = false;
|
||||
await _load();
|
||||
_refreshing = false;
|
||||
if (_pendingRefresh && mounted) {
|
||||
_pendingRefresh = false;
|
||||
unawaited(_refresh());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request('pql.tickets.list');
|
||||
@@ -99,29 +133,69 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
|
||||
final lf = _filter.toLowerCase();
|
||||
final hasFilter = lf.isNotEmpty;
|
||||
final filtered = hasFilter ? _tickets.where((t) => t.id.toLowerCase().contains(lf) || t.title.toLowerCase().contains(lf) || (t.status ?? '').contains(lf) || (t.type ?? '').contains(lf)).toList() : _tickets;
|
||||
final filtered = hasFilter
|
||||
? _tickets
|
||||
.where((t) => t.id.toLowerCase().contains(lf) || t.title.toLowerCase().contains(lf) || (t.status ?? '').contains(lf) || (t.type ?? '').contains(lf))
|
||||
.toList()
|
||||
: _tickets;
|
||||
|
||||
final active = filtered.where((t) => t.status == 'in_progress').toList();
|
||||
final backlog = filtered.where((t) => t.status == 'backlog' || t.status == 'ready').toList();
|
||||
final done = filtered.where((t) => t.status == 'done').toList();
|
||||
final other = filtered.where((t) => t.status == 'cancelled' || t.status == 'review').toList();
|
||||
const sections = [
|
||||
('in_progress', 'IN PROGRESS'),
|
||||
('review', 'REVIEW'),
|
||||
('ready', 'READY'),
|
||||
('backlog', 'BACKLOG'),
|
||||
('done', 'DONE'),
|
||||
('cancelled', 'CANCELLED'),
|
||||
];
|
||||
|
||||
final byStatus = <String, List<_TicketEntry>>{};
|
||||
for (final t in filtered) {
|
||||
(byStatus[t.status ?? 'backlog'] ??= []).add(t);
|
||||
}
|
||||
|
||||
final isDark = ClideTheme.of(context).dark;
|
||||
final typeColors = TicketTypeColors.forTheme(dark: isDark);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ClideFilterBox(hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v)),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: ClideFilterBox(hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ClideTappable(
|
||||
onTap: _refreshing ? null : _refresh,
|
||||
tooltip: 'Refresh tickets',
|
||||
builder: (ctx, hovered, _) =>
|
||||
ClideIcon(PhosphorIcons.arrowClockwise, size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (active.isNotEmpty) _AccordionSection(label: 'ACTIVE', count: active.length, tokens: tokens, expanded: hasFilter || _expanded.contains('active'), onToggle: () => _toggle('active'), children: [for (final t in active) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId, focusKey: t.id == _focusedId ? _focusedKey : null)]),
|
||||
if (backlog.isNotEmpty) _AccordionSection(label: 'BACKLOG', count: backlog.length, tokens: tokens, expanded: hasFilter || _expanded.contains('backlog'), onToggle: () => _toggle('backlog'), children: [for (final t in backlog) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId, focusKey: t.id == _focusedId ? _focusedKey : null)]),
|
||||
if (done.isNotEmpty) _AccordionSection(label: 'DONE', count: done.length, tokens: tokens, expanded: hasFilter || _expanded.contains('done'), onToggle: () => _toggle('done'), children: [for (final t in done) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId, focusKey: t.id == _focusedId ? _focusedKey : null)]),
|
||||
if (other.isNotEmpty) _AccordionSection(label: 'OTHER', count: other.length, tokens: tokens, expanded: hasFilter || _expanded.contains('other'), onToggle: () => _toggle('other'), children: [for (final t in other) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId, focusKey: t.id == _focusedId ? _focusedKey : null)]),
|
||||
for (final (status, label) in sections)
|
||||
if (byStatus[status] case final items? when items.isNotEmpty)
|
||||
ClideAccordion(
|
||||
label: label,
|
||||
count: items.length,
|
||||
expanded: hasFilter || _isSectionExpanded(status),
|
||||
onToggle: () => _toggle(status),
|
||||
children: [
|
||||
for (final t in items)
|
||||
_TicketCard(
|
||||
entry: t,
|
||||
tokens: tokens,
|
||||
typeColors: typeColors,
|
||||
focused: t.id == _focusedId,
|
||||
focusKey: t.id == _focusedId ? _focusedKey : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -150,39 +224,6 @@ class _TicketEntry {
|
||||
);
|
||||
}
|
||||
|
||||
class _AccordionSection extends StatelessWidget {
|
||||
const _AccordionSection({required this.label, required this.count, required this.tokens, required this.expanded, required this.onToggle, required this.children});
|
||||
final String label;
|
||||
final int count;
|
||||
final SurfaceTokens tokens;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggle;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideTappable(
|
||||
onTap: onToggle,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.only(left: 4, top: 10, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideIcon(expanded ? PhosphorIcons.caretDown : PhosphorIcons.caretRight, size: 10, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 6),
|
||||
ClideText('$label · $count', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (expanded) ...children,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TicketCard extends StatelessWidget {
|
||||
const _TicketCard({required this.entry, required this.tokens, required this.typeColors, this.focused = false, this.focusKey});
|
||||
final _TicketEntry entry;
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart' show MissingPluginException;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class WelcomeView extends StatelessWidget {
|
||||
@@ -105,7 +106,25 @@ class _StartColumn extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _openFolder(BuildContext context) {
|
||||
void _openFolder(BuildContext context) async {
|
||||
try {
|
||||
final picked = await kernel.window.pickDirectory();
|
||||
if (picked != null) {
|
||||
final ok = await kernel.project.open(picked);
|
||||
if (ok) {
|
||||
kernel.panels.activateTab(Slots.workspace, 'claude.primary');
|
||||
} else {
|
||||
kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(
|
||||
path: picked,
|
||||
onDismiss: () => dismiss(),
|
||||
));
|
||||
}
|
||||
}
|
||||
return;
|
||||
} on MissingPluginException {
|
||||
// Platform has no native picker — fall through to text dialog.
|
||||
}
|
||||
|
||||
kernel.dialog.show<String>((ctx, dismiss) {
|
||||
return _OpenProjectDialog(
|
||||
onOpen: (path) async {
|
||||
@@ -144,8 +163,7 @@ class _ActionRow extends StatelessWidget {
|
||||
ClideIcon(icon, size: 18, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(child: ClideText(label, fontSize: 15, color: tokens.globalForeground)),
|
||||
if (shortcut != null)
|
||||
ClideText(shortcut!, fontSize: 13, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
if (shortcut != null) ClideText(shortcut!, fontSize: 13, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -172,8 +190,7 @@ class _RecentColumn extends StatelessWidget {
|
||||
if (recents.isEmpty)
|
||||
const ClideText('No recent projects.', muted: true, fontSize: 14)
|
||||
else
|
||||
for (final r in recents)
|
||||
_RecentRow(project: r, tokens: tokens, onTap: () => _openRecent(r.path)),
|
||||
for (final r in recents) _RecentRow(project: r, tokens: tokens, onTap: () => _openRecent(r.path)),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -213,7 +230,9 @@ class _RecentRow extends StatelessWidget {
|
||||
const SizedBox(height: 3),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(child: ClideText(project.relativePath, muted: true, fontSize: 13, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
Flexible(
|
||||
child: ClideText(project.relativePath,
|
||||
muted: true, fontSize: 13, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
if (project.branch != null) ...[
|
||||
ClideText(' · ', muted: true, fontSize: 13),
|
||||
ClideIcon(PhosphorIcons.gitBranch, size: 11, color: tokens.globalTextMuted),
|
||||
@@ -242,20 +261,20 @@ class _StatusLine extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final themeName = kernel.theme.currentName;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.toolCheck,
|
||||
listenable: kernel.toolchain,
|
||||
builder: (ctx, _) {
|
||||
final tc = kernel.toolCheck;
|
||||
final tc = kernel.toolchain;
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ClideText('clide 2.0.0-dev', muted: true, fontSize: 12, fontFamily: clideMonoFamily),
|
||||
ClideText(' · ', muted: true, fontSize: 12),
|
||||
if (!tc.checked)
|
||||
if (!tc.resolved)
|
||||
ClideText('checking…', muted: true, fontSize: 12, fontFamily: clideMonoFamily)
|
||||
else if (tc.allOk)
|
||||
ClideText('application ok', fontSize: 12, fontFamily: clideMonoFamily, color: tokens.statusSuccess)
|
||||
else
|
||||
ClideText(tc.errors.join(' · '), fontSize: 12, fontFamily: clideMonoFamily, color: tokens.statusWarning),
|
||||
ClideText(tc.missing.map((t) => '$t not found').join(' · '), fontSize: 12, fontFamily: clideMonoFamily, color: tokens.statusWarning),
|
||||
ClideText(' · ', muted: true, fontSize: 12),
|
||||
_ThemeLink(tokens: tokens, kernel: kernel, themeName: themeName),
|
||||
],
|
||||
@@ -317,7 +336,10 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> {
|
||||
Future<void> _submit() async {
|
||||
final path = _controller.text.trim();
|
||||
if (path.isEmpty) return;
|
||||
setState(() { _loading = true; _error = null; });
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await widget.onOpen(path);
|
||||
} catch (_) {
|
||||
@@ -379,3 +401,45 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NotARepoDialog extends StatelessWidget {
|
||||
const _NotARepoDialog({required this.path, required this.onDismiss});
|
||||
final String path;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
width: 420,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.modalSurfaceBackground,
|
||||
border: Border.all(color: tokens.modalSurfaceBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ClideText('No git repo found', fontSize: 16, fontWeight: FontWeight.w600),
|
||||
const SizedBox(height: 8),
|
||||
ClideText(path, muted: true, fontSize: 13),
|
||||
const SizedBox(height: 8),
|
||||
const ClideText(
|
||||
'A clide project root requires a git repository.',
|
||||
muted: true,
|
||||
fontSize: 13,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ClideButton(label: 'OK', onPressed: () => onDismiss()),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -21,9 +21,9 @@ export 'src/editor/buffer.dart';
|
||||
export 'src/files/ignore.dart';
|
||||
export 'src/files/listing.dart' show FileEntry, listDir;
|
||||
export 'src/git/diff.dart' show GitDiff, GitHunk, DiffLine, DiffLineKind;
|
||||
export 'src/git/client.dart' show GitClient;
|
||||
export 'src/git/operations.dart' show GitLogEntry, GitException;
|
||||
export 'src/git/status.dart'
|
||||
show GitStatus, GitFileStatus, GitFileState, GitConflictType;
|
||||
export 'src/git/status.dart' show GitStatus, GitFileStatus, GitFileState, GitConflictType;
|
||||
export 'src/pql/client.dart' show PqlClient, PqlException;
|
||||
export 'src/ipc/envelope.dart';
|
||||
export 'src/ipc/paths.dart';
|
||||
|
||||
@@ -91,8 +91,7 @@ extension ClideExtensionContextMessages on ClideExtensionContext {
|
||||
extension ClideExtensionContextI18n on ClideExtensionContext {
|
||||
/// `ctx.t('welcome.title', placeholder: 'clide')` →
|
||||
/// `i18n.string('welcome.title', namespace: id, placeholder: 'clide')`.
|
||||
String t(String key, {String? placeholder}) =>
|
||||
i18n.string(key, namespace: id, placeholder: placeholder);
|
||||
String t(String key, {String? placeholder}) => i18n.string(key, namespace: id, placeholder: placeholder);
|
||||
|
||||
/// [t] with interpolation replacers.
|
||||
String tr(
|
||||
|
||||
@@ -55,6 +55,5 @@ class ExtensionManifest {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<ExtensionManifest> fromFile(File f) async =>
|
||||
ExtensionManifest.fromYamlString(await f.readAsString());
|
||||
static Future<ExtensionManifest> fromFile(File f) async => ExtensionManifest.fromYamlString(await f.readAsString());
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export 'src/notify.dart';
|
||||
export 'src/os.dart';
|
||||
export 'src/panels/arrangement.dart';
|
||||
export 'src/project.dart';
|
||||
export 'src/scheduler.dart';
|
||||
export 'src/secrets.dart';
|
||||
export 'src/tray.dart';
|
||||
export 'src/panels/drag_resize.dart';
|
||||
@@ -47,5 +48,5 @@ export 'src/theme/palette.dart';
|
||||
export 'src/theme/resolver.dart';
|
||||
export 'src/theme/semantic.dart';
|
||||
export 'src/theme/tokens.dart';
|
||||
export 'src/tool_check.dart';
|
||||
export 'src/toolchain.dart';
|
||||
export 'src/window_controls.dart';
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/// Manages the backend isolate lifecycle.
|
||||
///
|
||||
/// Two-phase boot:
|
||||
/// 1. [spawn] — starts the isolate, resolves toolchain (binary checks only).
|
||||
/// 2. [openWorkspace] — initializes services for a specific project root.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:clide/kernel/src/backend_entry.dart';
|
||||
import 'package:clide/kernel/src/ipc/isolate_client.dart';
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
|
||||
class Backend {
|
||||
Backend._({
|
||||
required this.client,
|
||||
required this.toolchain,
|
||||
required SendPort backendRequestPort,
|
||||
required Isolate isolate,
|
||||
required ReceivePort receivePort,
|
||||
}) : _backendRequestPort = backendRequestPort,
|
||||
_isolate = isolate,
|
||||
_receivePort = receivePort;
|
||||
|
||||
final IsolateClient client;
|
||||
final Toolchain toolchain;
|
||||
final SendPort _backendRequestPort;
|
||||
final Isolate _isolate;
|
||||
final ReceivePort _receivePort;
|
||||
|
||||
Completer<void>? _projectCompleter;
|
||||
final Map<String, Completer<String?>> _validateCompleters = {};
|
||||
int _validateId = 0;
|
||||
|
||||
/// Spawn the backend isolate. Returns when the toolchain is resolved.
|
||||
/// No services are active yet — call [openWorkspace] to activate.
|
||||
static Future<Backend> spawn({
|
||||
required IsolateClient Function(SendPort backendPort) clientFactory,
|
||||
String? hintRoot,
|
||||
}) async {
|
||||
final receivePort = ReceivePort();
|
||||
final completer = Completer<Backend>();
|
||||
|
||||
late final IsolateClient client;
|
||||
late final Isolate isolate;
|
||||
late final SendPort backendRequestPort;
|
||||
late final Backend backend;
|
||||
|
||||
receivePort.listen((message) {
|
||||
if (message is Map<String, Object?>) {
|
||||
final type = message['type'] as String?;
|
||||
if (type == 'ready') {
|
||||
backendRequestPort = message['requestPort'] as SendPort;
|
||||
client = clientFactory(backendRequestPort);
|
||||
|
||||
final tcData = message['toolchain'] as Map<String, Object?>;
|
||||
final toolchain = Toolchain();
|
||||
toolchain.applyResolved(ResolvedPaths(
|
||||
git: tcData['git'] as String?,
|
||||
pql: tcData['pql'] as String?,
|
||||
tmux: tcData['tmux'] as String?,
|
||||
ptyc: tcData['ptyc'] as String?,
|
||||
shell: tcData['shell'] as String?,
|
||||
gitEnv: (tcData['gitEnv'] as Map?)?.cast<String, String>(),
|
||||
));
|
||||
|
||||
backend = Backend._(
|
||||
client: client,
|
||||
toolchain: toolchain,
|
||||
backendRequestPort: backendRequestPort,
|
||||
isolate: isolate,
|
||||
receivePort: receivePort,
|
||||
);
|
||||
|
||||
if (!completer.isCompleted) completer.complete(backend);
|
||||
} else if (type == 'project.validated') {
|
||||
final id = message['id'] as String;
|
||||
final root = message['root'] as String?;
|
||||
final c = backend._validateCompleters.remove(id);
|
||||
if (c != null && !c.isCompleted) c.complete(root);
|
||||
} else if (type == 'project.ready') {
|
||||
// Update toolchain with project-specific paths.
|
||||
final tcData = message['toolchain'] as Map<String, Object?>;
|
||||
backend.toolchain.applyResolved(ResolvedPaths(
|
||||
git: tcData['git'] as String?,
|
||||
pql: tcData['pql'] as String?,
|
||||
tmux: tcData['tmux'] as String?,
|
||||
ptyc: tcData['ptyc'] as String?,
|
||||
shell: tcData['shell'] as String?,
|
||||
gitEnv: (tcData['gitEnv'] as Map?)?.cast<String, String>(),
|
||||
));
|
||||
backend._projectCompleter?.complete();
|
||||
backend._projectCompleter = null;
|
||||
} else {
|
||||
// Response or event — forward to the client.
|
||||
client.handleMessage(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
isolate = await Isolate.spawn(
|
||||
backendEntry,
|
||||
BackendBootMessage(frontendPort: receivePort.sendPort, hintRoot: hintRoot),
|
||||
);
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// Validate a path as a git repo. Returns the repo root or null.
|
||||
/// Runs git rev-parse in the backend isolate (no main-thread I/O).
|
||||
Future<String?> validateProject(String path) {
|
||||
final id = '${_validateId++}';
|
||||
final c = Completer<String?>();
|
||||
_validateCompleters[id] = c;
|
||||
_backendRequestPort.send({
|
||||
'type': 'project.validate',
|
||||
'path': path,
|
||||
'id': id,
|
||||
});
|
||||
return c.future;
|
||||
}
|
||||
|
||||
/// Activate a project. The backend (re)initializes all services
|
||||
/// for the given root directory. Returns when services are ready.
|
||||
Future<void> openProject(String path) {
|
||||
_projectCompleter = Completer<void>();
|
||||
_backendRequestPort.send({
|
||||
'type': 'project.open',
|
||||
'path': path,
|
||||
});
|
||||
return _projectCompleter!.future;
|
||||
}
|
||||
|
||||
/// Shut down the backend isolate.
|
||||
void dispose() {
|
||||
_isolate.kill(priority: Isolate.beforeNextEvent);
|
||||
_receivePort.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/// Backend isolate entry point.
|
||||
///
|
||||
/// Two-phase boot:
|
||||
/// 1. Resolve toolchain (find binaries) → report ready.
|
||||
/// 2. On `project.open` message → initialize services for the project.
|
||||
///
|
||||
/// The dispatcher only registers command handlers after a project is
|
||||
/// activated. IPC requests arriving before that get an error response.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
import 'package:clide/src/daemon/editor_commands.dart';
|
||||
import 'package:clide/src/daemon/files_commands.dart';
|
||||
import 'package:clide/src/daemon/git_commands.dart';
|
||||
import 'package:clide/src/daemon/pane_commands.dart';
|
||||
import 'package:clide/src/daemon/pql_commands.dart';
|
||||
import 'package:clide/src/editor/registry.dart' show EditorRegistry;
|
||||
import 'package:clide/src/git/client.dart';
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:clide/src/ipc/schema_v1.dart';
|
||||
import 'package:clide/src/panes/event_sink.dart';
|
||||
import 'package:clide/src/panes/registry.dart';
|
||||
import 'package:clide/src/pql/client.dart';
|
||||
|
||||
/// Message sent from main isolate to bootstrap the backend.
|
||||
class BackendBootMessage {
|
||||
const BackendBootMessage({required this.frontendPort, this.hintRoot});
|
||||
final SendPort frontendPort;
|
||||
|
||||
/// Optional path hint for initial toolchain resolution (e.g. CLIDE_PROJECT).
|
||||
/// Used to find project-local binaries like dugite before a project opens.
|
||||
final String? hintRoot;
|
||||
}
|
||||
|
||||
/// Top-level entry point for the backend isolate.
|
||||
void backendEntry(BackendBootMessage boot) {
|
||||
final frontendPort = boot.frontendPort;
|
||||
final requestPort = ReceivePort();
|
||||
final eventSink = _IsolateEventSink(frontendPort);
|
||||
final dispatcher = DaemonDispatcher();
|
||||
late Toolchain toolchain;
|
||||
|
||||
// Phase 1: resolve toolchain — just find binaries, don't init services.
|
||||
// We need a project root for ptyc/dugite paths. Use a sensible
|
||||
// default; the real project comes from project.open.
|
||||
final resolveRoot = boot.hintRoot ?? Platform.environment['HOME'] ?? '/tmp';
|
||||
toolchain = Toolchain();
|
||||
toolchain.applyResolved(resolveToolchainPaths(resolveRoot));
|
||||
|
||||
// Listen for messages from the frontend.
|
||||
requestPort.listen((message) async {
|
||||
if (message is! Map<String, Object?>) return;
|
||||
final type = message['type'] as String?;
|
||||
|
||||
if (type == 'project.validate') {
|
||||
// Validate a path as a git repo. Runs git rev-parse in the backend
|
||||
// isolate (safe from the merged thread). Returns the repo root or null.
|
||||
final path = message['path'] as String;
|
||||
final id = message['id'] as String;
|
||||
try {
|
||||
final r = await Process.run(toolchain.git, ['rev-parse', '--show-toplevel'], workingDirectory: path, environment: toolchain.gitEnv);
|
||||
if (r.exitCode == 0) {
|
||||
final root = (r.stdout as String).trim();
|
||||
frontendPort.send({'type': 'project.validated', 'id': id, 'root': root});
|
||||
} else {
|
||||
frontendPort.send({'type': 'project.validated', 'id': id, 'root': null});
|
||||
}
|
||||
} catch (_) {
|
||||
frontendPort.send({'type': 'project.validated', 'id': id, 'root': null});
|
||||
}
|
||||
} else if (type == 'project.open') {
|
||||
// Phase 2: (re)initialize services for the given project.
|
||||
final projectPath = message['path'] as String;
|
||||
final workDir = Directory(projectPath);
|
||||
|
||||
// Re-resolve toolchain with the actual project root (finds
|
||||
// dugite in native/dugite/, ptyc in ptyc/bin/, etc.)
|
||||
toolchain = Toolchain();
|
||||
toolchain.applyResolved(resolveToolchainPaths(projectPath));
|
||||
|
||||
// Clear existing handlers and re-register with new project.
|
||||
dispatcher.clear();
|
||||
|
||||
final filesService = FilesService(root: workDir, events: eventSink);
|
||||
registerFilesCommands(dispatcher, filesService);
|
||||
|
||||
final editorRegistry = EditorRegistry(events: eventSink, workspaceRoot: workDir);
|
||||
registerEditorCommands(dispatcher, editorRegistry);
|
||||
|
||||
final gitClient = GitClient(toolchain: toolchain, workDir: workDir);
|
||||
registerGitCommands(dispatcher, gitClient, eventSink);
|
||||
|
||||
final pql = PqlClient(workDir: workDir, toolchain: toolchain);
|
||||
registerPqlCommands(dispatcher, pql);
|
||||
|
||||
final paneRegistry = PaneRegistry(events: eventSink);
|
||||
registerPaneCommands(dispatcher, paneRegistry);
|
||||
|
||||
// Tell the frontend the project is active.
|
||||
frontendPort.send({
|
||||
'type': 'project.ready',
|
||||
'path': projectPath,
|
||||
'toolchain': _serializeToolchain(toolchain),
|
||||
});
|
||||
} else {
|
||||
// IPC request — dispatch if we have handlers.
|
||||
final req = IpcRequest.fromJson(message);
|
||||
if (dispatcher.isEmpty) {
|
||||
frontendPort.send(IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'No project active',
|
||||
hint: 'Open a project first',
|
||||
),
|
||||
).toJson());
|
||||
} else {
|
||||
final resp = await dispatcher.dispatch(req);
|
||||
frontendPort.send(resp.toJson());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Send ready with toolchain state and request port.
|
||||
frontendPort.send({
|
||||
'type': 'ready',
|
||||
'requestPort': requestPort.sendPort,
|
||||
'toolchain': _serializeToolchain(toolchain),
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, Object?> _serializeToolchain(Toolchain tc) => {
|
||||
'git': tc.git,
|
||||
'pql': tc.pql,
|
||||
'tmux': tc.tmux,
|
||||
'ptyc': tc.ptyc,
|
||||
'shell': tc.shell,
|
||||
'gitEnv': tc.gitEnv,
|
||||
'missing': tc.missing,
|
||||
};
|
||||
|
||||
/// Sends IPC events to the frontend via SendPort.
|
||||
class _IsolateEventSink implements DaemonEventSink {
|
||||
_IsolateEventSink(this._port);
|
||||
final SendPort _port;
|
||||
|
||||
@override
|
||||
void emit(IpcEvent event) {
|
||||
_port.send(event.toJson());
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,7 @@ class ClideClipboard {
|
||||
bucket.insert(0, value);
|
||||
if (bucket.length > historyLimit) bucket.removeLast();
|
||||
if (toPlain != null) {
|
||||
await flutter_services.Clipboard.setData(
|
||||
flutter_services.ClipboardData(text: toPlain(value)));
|
||||
await flutter_services.Clipboard.setData(flutter_services.ClipboardData(text: toPlain(value)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +44,7 @@ class ClideClipboard {
|
||||
}
|
||||
|
||||
Future<void> writePlain(String text) async {
|
||||
await flutter_services.Clipboard.setData(
|
||||
flutter_services.ClipboardData(text: text));
|
||||
await flutter_services.Clipboard.setData(flutter_services.ClipboardData(text: text));
|
||||
final bucket = _history.putIfAbsent(String, () => <Object>[]);
|
||||
bucket.insert(0, text);
|
||||
if (bucket.length > historyLimit) bucket.removeLast();
|
||||
|
||||
@@ -36,10 +36,7 @@ class Keybinding {
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is Keybinding &&
|
||||
other.key == key &&
|
||||
listEquals(other.modifiers, modifiers);
|
||||
bool operator ==(Object other) => other is Keybinding && other.key == key && listEquals(other.modifiers, modifiers);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(key, Object.hashAll(modifiers));
|
||||
|
||||
@@ -5,13 +5,11 @@ import 'package:clide/kernel/src/events/types.dart';
|
||||
class DaemonBus {
|
||||
DaemonBus();
|
||||
|
||||
final StreamController<ClideEventEnvelope> _controller =
|
||||
StreamController<ClideEventEnvelope>.broadcast();
|
||||
final StreamController<ClideEventEnvelope> _controller = StreamController<ClideEventEnvelope>.broadcast();
|
||||
|
||||
Stream<ClideEventEnvelope> get stream => _controller.stream;
|
||||
|
||||
Stream<T> on<T extends ClideEvent>() =>
|
||||
_controller.stream.where((e) => e.event is T).map((e) => e.event as T);
|
||||
Stream<T> on<T extends ClideEvent>() => _controller.stream.where((e) => e.event is T).map((e) => e.event as T);
|
||||
|
||||
void emit(ClideEvent event) {
|
||||
if (_controller.isClosed) return;
|
||||
|
||||
@@ -124,8 +124,7 @@ class ExtensionManager extends ChangeNotifier {
|
||||
}
|
||||
for (final dep in ext.dependsOn) {
|
||||
if (!_activated.contains(dep)) {
|
||||
log.warn(
|
||||
'extensions', 'skipping ${ext.id}: dependency not activated: $dep');
|
||||
log.warn('extensions', 'skipping ${ext.id}: dependency not activated: $dep');
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -140,8 +139,7 @@ class ExtensionManager extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
log.info('extensions', 'activated $id');
|
||||
} catch (e, st) {
|
||||
log.error('extensions', 'activate failed for $id',
|
||||
error: e, stackTrace: st);
|
||||
log.error('extensions', 'activate failed for $id', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,8 +157,7 @@ class ExtensionManager extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
log.info('extensions', 'deactivated $id');
|
||||
} catch (e, st) {
|
||||
log.error('extensions', 'deactivate failed for $id',
|
||||
error: e, stackTrace: st);
|
||||
log.error('extensions', 'deactivate failed for $id', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+31
-17
@@ -22,11 +22,12 @@ import 'package:clide/kernel/src/os.dart';
|
||||
import 'package:clide/kernel/src/panels/arrangement.dart';
|
||||
import 'package:clide/kernel/src/panels/registry.dart';
|
||||
import 'package:clide/kernel/src/project.dart';
|
||||
import 'package:clide/kernel/src/scheduler.dart';
|
||||
import 'package:clide/kernel/src/secrets.dart';
|
||||
import 'package:clide/kernel/src/settings.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/loader.dart';
|
||||
import 'package:clide/kernel/src/tool_check.dart';
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
import 'package:clide/kernel/src/tray.dart';
|
||||
import 'package:clide/kernel/src/window_controls.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -60,7 +61,8 @@ class KernelServices {
|
||||
required this.project,
|
||||
required this.extensions,
|
||||
required this.window,
|
||||
required this.toolCheck,
|
||||
required this.toolchain,
|
||||
required this.scheduler,
|
||||
});
|
||||
|
||||
final Logger log;
|
||||
@@ -87,7 +89,8 @@ class KernelServices {
|
||||
final ProjectManager project;
|
||||
final ExtensionManager extensions;
|
||||
final WindowControls window;
|
||||
final ToolCheck toolCheck;
|
||||
final Toolchain toolchain;
|
||||
final SchedulerService scheduler;
|
||||
|
||||
static Future<KernelServices> boot({
|
||||
required Directory appDir,
|
||||
@@ -99,10 +102,15 @@ class KernelServices {
|
||||
List<Locale> availableLocales = const [Locale('en', 'US')],
|
||||
String? socketPath,
|
||||
DaemonClient Function(Logger, DaemonBus)? daemonClientFactory,
|
||||
DaemonClient? isolateClient,
|
||||
bool autoStartDaemonClient = true,
|
||||
Toolchain? toolchain,
|
||||
Future<void> Function(String path)? onProjectOpen,
|
||||
Future<String?> Function(String path)? onValidateProject,
|
||||
DaemonBus? sharedBus,
|
||||
}) async {
|
||||
final log = Logger();
|
||||
final events = DaemonBus();
|
||||
final events = sharedBus ?? DaemonBus();
|
||||
final messages = MessageBus();
|
||||
|
||||
final settings = SettingsStore(appDir: appDir);
|
||||
@@ -135,19 +143,25 @@ class KernelServices {
|
||||
final net = NetworkStatus();
|
||||
final focus = FocusTracker();
|
||||
final window = WindowControls();
|
||||
final toolCheck = ToolCheck();
|
||||
final tc = toolchain ?? Toolchain();
|
||||
final scheduler = SchedulerService(events);
|
||||
scheduler.start();
|
||||
final project = ProjectManager(
|
||||
log: log,
|
||||
events: events,
|
||||
settings: settings,
|
||||
toolchain: tc,
|
||||
onProjectOpen: onProjectOpen,
|
||||
onValidateProject: onValidateProject,
|
||||
);
|
||||
final ipc = daemonClientFactory != null
|
||||
? daemonClientFactory(log, events)
|
||||
: DaemonClient(
|
||||
socketPath: socketPath ?? defaultSocketPath(),
|
||||
log: log,
|
||||
events: events,
|
||||
);
|
||||
final ipc = isolateClient ??
|
||||
(daemonClientFactory != null
|
||||
? daemonClientFactory(log, events)
|
||||
: DaemonClient(
|
||||
socketPath: socketPath ?? defaultSocketPath(),
|
||||
log: log,
|
||||
events: events,
|
||||
));
|
||||
final extensions = ExtensionManager(
|
||||
log: log,
|
||||
events: events,
|
||||
@@ -202,7 +216,8 @@ class KernelServices {
|
||||
project: project,
|
||||
extensions: extensions,
|
||||
window: window,
|
||||
toolCheck: toolCheck,
|
||||
toolchain: tc,
|
||||
scheduler: scheduler,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,6 +238,7 @@ class KernelServices {
|
||||
focus.dispose();
|
||||
project.dispose();
|
||||
extensions.dispose();
|
||||
scheduler.dispose();
|
||||
await log.dispose();
|
||||
messages.dispose();
|
||||
await events.dispose();
|
||||
@@ -241,13 +257,11 @@ class ClideKernel extends InheritedWidget {
|
||||
static KernelServices of(BuildContext context) {
|
||||
final w = context.dependOnInheritedWidgetOfExactType<ClideKernel>();
|
||||
if (w == null) {
|
||||
throw FlutterError(
|
||||
'ClideKernel.of() called with a context that is not a descendant of a ClideKernel.');
|
||||
throw FlutterError('ClideKernel.of() called with a context that is not a descendant of a ClideKernel.');
|
||||
}
|
||||
return w.services;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(ClideKernel oldWidget) =>
|
||||
services != oldWidget.services;
|
||||
bool updateShouldNotify(ClideKernel oldWidget) => services != oldWidget.services;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,5 @@ class InMemoryCatalogLoader implements CatalogLoader {
|
||||
return const {};
|
||||
}
|
||||
|
||||
static bool _eq(Locale a, Locale b) =>
|
||||
a.languageCode == b.languageCode && a.countryCode == b.countryCode;
|
||||
static bool _eq(Locale a, Locale b) => a.languageCode == b.languageCode && a.countryCode == b.countryCode;
|
||||
}
|
||||
|
||||
@@ -55,8 +55,7 @@ class I18n extends ChangeNotifier {
|
||||
Locale locale,
|
||||
Map<String, Object?> catalog,
|
||||
) {
|
||||
_cache.putIfAbsent(
|
||||
namespace, () => <Locale, Map<String, Object?>>{})[locale] = catalog;
|
||||
_cache.putIfAbsent(namespace, () => <Locale, Map<String, Object?>>{})[locale] = catalog;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -79,11 +79,7 @@ class DaemonClient extends ChangeNotifier {
|
||||
_backoff = const Duration(milliseconds: 200);
|
||||
_setConnected(true);
|
||||
_log.info('ipc', 'connected to $socketPath');
|
||||
socket
|
||||
.cast<List<int>>()
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen(
|
||||
socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).listen(
|
||||
_handleLine,
|
||||
onDone: _handleDisconnect,
|
||||
onError: (Object e) {
|
||||
@@ -93,8 +89,7 @@ class DaemonClient extends ChangeNotifier {
|
||||
cancelOnError: true,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.debug(
|
||||
'ipc', 'connect failed ($e); retry in ${_backoff.inMilliseconds}ms');
|
||||
_log.debug('ipc', 'connect failed ($e); retry in ${_backoff.inMilliseconds}ms');
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/// IPC client that sends requests to a backend isolate via SendPort.
|
||||
///
|
||||
/// Replaces [InProcessClient] for production use. The backend isolate
|
||||
/// owns the [DaemonDispatcher] and all subprocess/file-I/O services.
|
||||
/// Requests and responses travel as serialized Maps over SendPort,
|
||||
/// reusing the existing IPC protocol (IpcRequest/IpcResponse/IpcEvent).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/types.dart';
|
||||
import 'package:clide/kernel/src/ipc/client.dart';
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
|
||||
class IsolateClient extends DaemonClient {
|
||||
IsolateClient({
|
||||
required Logger log,
|
||||
required DaemonBus events,
|
||||
required SendPort backendPort,
|
||||
}) : _backendPort = backendPort,
|
||||
_events = events,
|
||||
super(socketPath: '', log: log, events: events);
|
||||
|
||||
final SendPort _backendPort;
|
||||
final DaemonBus _events;
|
||||
|
||||
/// The event bus that receives events from the backend.
|
||||
DaemonBus get events => _events;
|
||||
final Map<String, Completer<IpcResponse>> _pending = {};
|
||||
int _nextId = 0;
|
||||
|
||||
/// Called by [Backend] to feed incoming messages from the backend isolate.
|
||||
void handleMessage(Map<String, Object?> msg) {
|
||||
final type = msg['type'] as String?;
|
||||
switch (type) {
|
||||
case 'response':
|
||||
final resp = IpcResponse.fromJson(msg);
|
||||
final c = _pending.remove(resp.id);
|
||||
if (c != null && !c.isCompleted) c.complete(resp);
|
||||
case 'event':
|
||||
final evt = IpcEvent.fromJson(msg);
|
||||
_events.emit(DaemonEvent(
|
||||
subsystem: evt.subsystem,
|
||||
kind: evt.kind,
|
||||
data: evt.data,
|
||||
ts: evt.timestamp,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isConnected => true;
|
||||
|
||||
@override
|
||||
Future<void> start() async {}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
|
||||
@override
|
||||
Future<IpcResponse> request(String cmd, {Map<String, Object?> args = const {}}) {
|
||||
final id = '${_nextId++}';
|
||||
final req = IpcRequest(id: id, cmd: cmd, args: args);
|
||||
final c = Completer<IpcResponse>();
|
||||
_pending[id] = c;
|
||||
_backendPort.send(req.toJson());
|
||||
return c.future;
|
||||
}
|
||||
}
|
||||
+10
-20
@@ -23,8 +23,7 @@ class LogRecord {
|
||||
@override
|
||||
String toString() {
|
||||
final lv = level.name.toUpperCase().padRight(5);
|
||||
final buf =
|
||||
StringBuffer('${timestamp.toIso8601String()} $lv [$source] $message');
|
||||
final buf = StringBuffer('${timestamp.toIso8601String()} $lv [$source] $message');
|
||||
if (error != null) buf.write(' | error=$error');
|
||||
return buf.toString();
|
||||
}
|
||||
@@ -33,33 +32,24 @@ class LogRecord {
|
||||
typedef LogSink = void Function(LogRecord);
|
||||
|
||||
class Logger {
|
||||
Logger({this.minLevel = LogLevel.info, List<LogSink>? sinks})
|
||||
: _sinks = List<LogSink>.from(sinks ?? <LogSink>[stderrSink]);
|
||||
Logger({this.minLevel = LogLevel.info, List<LogSink>? sinks}) : _sinks = List<LogSink>.from(sinks ?? <LogSink>[stderrSink]);
|
||||
|
||||
LogLevel minLevel;
|
||||
final List<LogSink> _sinks;
|
||||
final StreamController<LogRecord> _stream =
|
||||
StreamController<LogRecord>.broadcast();
|
||||
final StreamController<LogRecord> _stream = StreamController<LogRecord>.broadcast();
|
||||
|
||||
Stream<LogRecord> get records => _stream.stream;
|
||||
|
||||
void addSink(LogSink sink) => _sinks.add(sink);
|
||||
|
||||
void trace(String source, String message) =>
|
||||
_emit(LogLevel.trace, source, message);
|
||||
void debug(String source, String message) =>
|
||||
_emit(LogLevel.debug, source, message);
|
||||
void info(String source, String message) =>
|
||||
_emit(LogLevel.info, source, message);
|
||||
void warn(String source, String message, {Object? error}) =>
|
||||
_emit(LogLevel.warn, source, message, error: error);
|
||||
void error(String source, String message,
|
||||
{Object? error, StackTrace? stackTrace}) =>
|
||||
_emit(LogLevel.error, source, message,
|
||||
error: error, stackTrace: stackTrace);
|
||||
void trace(String source, String message) => _emit(LogLevel.trace, source, message);
|
||||
void debug(String source, String message) => _emit(LogLevel.debug, source, message);
|
||||
void info(String source, String message) => _emit(LogLevel.info, source, message);
|
||||
void warn(String source, String message, {Object? error}) => _emit(LogLevel.warn, source, message, error: error);
|
||||
void error(String source, String message, {Object? error, StackTrace? stackTrace}) =>
|
||||
_emit(LogLevel.error, source, message, error: error, stackTrace: stackTrace);
|
||||
|
||||
void _emit(LogLevel level, String source, String message,
|
||||
{Object? error, StackTrace? stackTrace}) {
|
||||
void _emit(LogLevel level, String source, String message, {Object? error, StackTrace? stackTrace}) {
|
||||
if (level.index < minLevel.index) return;
|
||||
final rec = LogRecord(
|
||||
level: level,
|
||||
|
||||
@@ -29,16 +29,10 @@ class Notifications extends ChangeNotifier {
|
||||
|
||||
List<ClideNotification> get active => List.unmodifiable(_active);
|
||||
|
||||
void info(String message, {String? title, Duration? duration}) =>
|
||||
_push(NotificationLevel.info, message, title: title, duration: duration);
|
||||
void warn(String message, {String? title, Duration? duration}) =>
|
||||
_push(NotificationLevel.warning, message,
|
||||
title: title, duration: duration);
|
||||
void error(String message, {String? title, Duration? duration}) =>
|
||||
_push(NotificationLevel.error, message, title: title, duration: duration);
|
||||
void success(String message, {String? title, Duration? duration}) =>
|
||||
_push(NotificationLevel.success, message,
|
||||
title: title, duration: duration);
|
||||
void info(String message, {String? title, Duration? duration}) => _push(NotificationLevel.info, message, title: title, duration: duration);
|
||||
void warn(String message, {String? title, Duration? duration}) => _push(NotificationLevel.warning, message, title: title, duration: duration);
|
||||
void error(String message, {String? title, Duration? duration}) => _push(NotificationLevel.error, message, title: title, duration: duration);
|
||||
void success(String message, {String? title, Duration? duration}) => _push(NotificationLevel.success, message, title: title, duration: duration);
|
||||
|
||||
void dismiss(String id) {
|
||||
_timers.remove(id)?.cancel();
|
||||
|
||||
@@ -37,9 +37,7 @@ class _DragResizeHandleState extends State<DragResizeHandle> {
|
||||
final lineColor = _hovered ? tokens.panelActiveBorder : tokens.dividerColor;
|
||||
|
||||
return MouseRegion(
|
||||
cursor: widget.axis == Axis.horizontal
|
||||
? SystemMouseCursors.resizeColumn
|
||||
: SystemMouseCursors.resizeRow,
|
||||
cursor: widget.axis == Axis.horizontal ? SystemMouseCursors.resizeColumn : SystemMouseCursors.resizeRow,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Listener(
|
||||
@@ -72,9 +70,7 @@ class _DragResizeHandleState extends State<DragResizeHandle> {
|
||||
final start = _dragStartSize;
|
||||
final startPt = _dragStartPointer;
|
||||
if (start == null || startPt == null) return;
|
||||
final rawDelta = widget.axis == Axis.horizontal
|
||||
? e.position.dx - startPt.dx
|
||||
: e.position.dy - startPt.dy;
|
||||
final rawDelta = widget.axis == Axis.horizontal ? e.position.dx - startPt.dx : e.position.dy - startPt.dy;
|
||||
final delta = widget.slot == Slots.contextPanel ? -rawDelta : rawDelta;
|
||||
widget.arrangement.setSize(widget.slot, start + delta);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ LayoutPresetContribution classicPreset() => const LayoutPresetContribution(
|
||||
position: SlotPosition.right,
|
||||
defaultSize: 420,
|
||||
minSize: 220,
|
||||
maxSize: 420,
|
||||
maxSize: 1000,
|
||||
),
|
||||
LayoutSlot(
|
||||
slot: Slots.statusbar,
|
||||
|
||||
@@ -53,10 +53,7 @@ class PanelRegistry extends ChangeNotifier {
|
||||
entry.value.removeWhere((c) => c.id == contributionId);
|
||||
if (entry.value.length != before) {
|
||||
if (_activeTab[entry.key] == contributionId) {
|
||||
_activeTab[entry.key] =
|
||||
entry.value.whereType<TabContribution>().isEmpty
|
||||
? null
|
||||
: entry.value.whereType<TabContribution>().first.id;
|
||||
_activeTab[entry.key] = entry.value.whereType<TabContribution>().isEmpty ? null : entry.value.whereType<TabContribution>().first.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,19 +63,21 @@ class PanelRegistry extends ChangeNotifier {
|
||||
Iterable<SlotDefinition> get slots => _defs.values;
|
||||
SlotDefinition? definitionFor(SlotId id) => _defs[id];
|
||||
|
||||
List<ContributionPoint> contributionsFor(SlotId id) =>
|
||||
List.unmodifiable(_mounts[id] ?? const []);
|
||||
List<ContributionPoint> contributionsFor(SlotId id) => List.unmodifiable(_mounts[id] ?? const []);
|
||||
|
||||
List<TabContribution> tabsFor(SlotId id) {
|
||||
final tabs = contributionsFor(id).whereType<TabContribution>().toList();
|
||||
final order = _order[id];
|
||||
if (order == null || order.isEmpty) return tabs;
|
||||
if (order == null || order.isEmpty) {
|
||||
tabs.sort((a, b) => a.priority.compareTo(b.priority));
|
||||
return tabs;
|
||||
}
|
||||
tabs.sort((a, b) {
|
||||
final ai = order.indexOf(a.id);
|
||||
final bi = order.indexOf(b.id);
|
||||
if (ai < 0 && bi < 0) return 0;
|
||||
if (ai < 0 && bi < 0) return a.priority.compareTo(b.priority);
|
||||
if (ai < 0) return 1;
|
||||
if (bi < 0) return 1;
|
||||
if (bi < 0) return -1;
|
||||
return ai.compareTo(bi);
|
||||
});
|
||||
return tabs;
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/types.dart';
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/kernel/src/settings.dart';
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class RecentProject {
|
||||
@@ -47,13 +48,22 @@ class ProjectManager extends ChangeNotifier {
|
||||
required Logger log,
|
||||
required DaemonBus events,
|
||||
required SettingsStore settings,
|
||||
required Toolchain toolchain,
|
||||
Future<void> Function(String path)? onProjectOpen,
|
||||
Future<String?> Function(String path)? onValidateProject,
|
||||
}) : _log = log,
|
||||
_events = events,
|
||||
_settings = settings;
|
||||
_settings = settings,
|
||||
_toolchain = toolchain,
|
||||
_onProjectOpen = onProjectOpen,
|
||||
_onValidateProject = onValidateProject;
|
||||
|
||||
final Logger _log;
|
||||
final DaemonBus _events;
|
||||
final SettingsStore _settings;
|
||||
final Toolchain _toolchain;
|
||||
final Future<void> Function(String path)? _onProjectOpen;
|
||||
final Future<String?> Function(String path)? _onValidateProject;
|
||||
|
||||
Directory? _current;
|
||||
Directory? get current => _current;
|
||||
@@ -77,12 +87,18 @@ class ProjectManager extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<bool> open(String path) async {
|
||||
final root = await resolveWorkspace(path);
|
||||
final root = await resolveProject(path);
|
||||
if (root == null) {
|
||||
_log.warn('project', 'not a git repo: $path');
|
||||
return false;
|
||||
}
|
||||
_current = Directory(root);
|
||||
|
||||
// Tell the backend isolate to (re)initialize services for this workspace.
|
||||
if (_onProjectOpen != null) {
|
||||
await _onProjectOpen(root);
|
||||
}
|
||||
|
||||
await _settings.setProjectDir(_current);
|
||||
await _settings.set<String>('app.lastProject', root);
|
||||
|
||||
@@ -114,9 +130,15 @@ class ProjectManager extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<String?> resolveWorkspace(String path) async {
|
||||
Future<String?> resolveProject(String path) async {
|
||||
// Prefer backend validation (runs in the backend isolate, safe from
|
||||
// the merged UI thread). Falls back to direct Process.run for tests
|
||||
// and the CLI binary.
|
||||
if (_onValidateProject != null) {
|
||||
return _onValidateProject(path);
|
||||
}
|
||||
try {
|
||||
final r = await Process.run('git', ['rev-parse', '--show-toplevel'], workingDirectory: path, runInShell: false);
|
||||
final r = await Process.run(_toolchain.git, ['rev-parse', '--show-toplevel'], workingDirectory: path, environment: _toolchain.gitEnv);
|
||||
if (r.exitCode != 0) return null;
|
||||
final out = (r.stdout as String).trim();
|
||||
return out.isEmpty ? null : out;
|
||||
@@ -128,7 +150,7 @@ class ProjectManager extends ChangeNotifier {
|
||||
|
||||
Future<String?> _currentBranch(String root) async {
|
||||
try {
|
||||
final r = await Process.run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], workingDirectory: root, runInShell: false);
|
||||
final r = await Process.run(_toolchain.git, ['rev-parse', '--abbrev-ref', 'HEAD'], workingDirectory: root, environment: _toolchain.gitEnv);
|
||||
if (r.exitCode != 0) return null;
|
||||
final out = (r.stdout as String).trim();
|
||||
return out.isEmpty ? null : out;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/types.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum SchedulerTier {
|
||||
oneMinute(Duration(minutes: 1)),
|
||||
tenMinutes(Duration(minutes: 10)),
|
||||
fifteenMinutes(Duration(minutes: 15)),
|
||||
oneHour(Duration(hours: 1)),
|
||||
midnight(Duration(minutes: 1));
|
||||
|
||||
const SchedulerTier(this.interval);
|
||||
final Duration interval;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SchedulerTick extends ClideEvent {
|
||||
const SchedulerTick({required this.tier});
|
||||
final SchedulerTier tier;
|
||||
|
||||
@override
|
||||
String get subsystem => 'scheduler';
|
||||
@override
|
||||
String get kind => 'tick';
|
||||
@override
|
||||
Map<String, Object?> payload() => {'tier': tier.name};
|
||||
}
|
||||
|
||||
class SchedulerService {
|
||||
SchedulerService(this._events);
|
||||
final DaemonBus _events;
|
||||
|
||||
Isolate? _isolate;
|
||||
ReceivePort? _port;
|
||||
StreamSubscription<dynamic>? _sub;
|
||||
StreamSubscription<dynamic>? _projectSub;
|
||||
|
||||
/// Listen for project lifecycle events. The periodic ticker only runs
|
||||
/// while a project is open — no wasted cycles on the welcome screen.
|
||||
void start() {
|
||||
_projectSub = _events.on<ProjectOpened>().listen((_) => _startTicker());
|
||||
_events.on<ProjectClosed>().listen((_) => _stopTicker());
|
||||
}
|
||||
|
||||
/// Start the periodic ticker and fire an immediate first cycle so
|
||||
/// all panels refresh without waiting for the first interval.
|
||||
void _startTicker() {
|
||||
_stopTicker();
|
||||
|
||||
// Stagger the initial ticks to avoid a rebuild storm on project open.
|
||||
var delay = 0;
|
||||
for (final tier in SchedulerTier.values) {
|
||||
if (tier == SchedulerTier.midnight) continue;
|
||||
Timer(Duration(milliseconds: delay), () {
|
||||
_events.emit(SchedulerTick(tier: tier));
|
||||
});
|
||||
delay += 500;
|
||||
}
|
||||
|
||||
// Then start the periodic isolate.
|
||||
_port = ReceivePort();
|
||||
_sub = _port!.listen((msg) {
|
||||
if (msg is String) {
|
||||
final tier = SchedulerTier.values.firstWhere((t) => t.name == msg);
|
||||
_events.emit(SchedulerTick(tier: tier));
|
||||
}
|
||||
});
|
||||
Isolate.spawn(_isolateEntry, _port!.sendPort).then((iso) => _isolate = iso);
|
||||
}
|
||||
|
||||
void _stopTicker() {
|
||||
_sub?.cancel();
|
||||
_port?.close();
|
||||
_isolate?.kill(priority: Isolate.immediate);
|
||||
_isolate = null;
|
||||
_port = null;
|
||||
_sub = null;
|
||||
}
|
||||
|
||||
static void _isolateEntry(SendPort send) {
|
||||
int lastDay = DateTime.now().day;
|
||||
for (final tier in SchedulerTier.values) {
|
||||
if (tier == SchedulerTier.midnight) {
|
||||
Timer.periodic(const Duration(minutes: 1), (_) {
|
||||
final day = DateTime.now().day;
|
||||
if (day != lastDay) {
|
||||
lastDay = day;
|
||||
send.send(tier.name);
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
Timer.periodic(tier.interval, (_) => send.send(tier.name));
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_projectSub?.cancel();
|
||||
_stopTicker();
|
||||
}
|
||||
}
|
||||
@@ -53,9 +53,7 @@ class SettingsStore extends ChangeNotifier {
|
||||
return _projectValues[key];
|
||||
case SettingsScope.ext:
|
||||
// project overrides app for the same ext.* key
|
||||
return _projectValues.containsKey(key)
|
||||
? _projectValues[key]
|
||||
: _appValues[key];
|
||||
return _projectValues.containsKey(key) ? _projectValues[key] : _appValues[key];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +64,7 @@ class SettingsStore extends ChangeNotifier {
|
||||
await _writeFile(_appFile, _appValues);
|
||||
case SettingsScope.project:
|
||||
if (projectDir == null) {
|
||||
throw StateError(
|
||||
'Cannot set project-scoped key with no project open: $key');
|
||||
throw StateError('Cannot set project-scoped key with no project open: $key');
|
||||
}
|
||||
_projectValues[key] = value;
|
||||
await _writeFile(_projectFile, _projectValues);
|
||||
@@ -109,8 +106,7 @@ class SettingsStore extends ChangeNotifier {
|
||||
if (key.startsWith('app.')) return SettingsScope.app;
|
||||
if (key.startsWith('project.')) return SettingsScope.project;
|
||||
if (key.startsWith('ext.')) return SettingsScope.ext;
|
||||
throw ArgumentError(
|
||||
'Settings key must start with app.|project.|ext.: "$key"');
|
||||
throw ArgumentError('Settings key must start with app.|project.|ext.: "$key"');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,15 @@ import 'package:ffi/ffi.dart';
|
||||
// -- Opaque handles ----------------------------------------------------------
|
||||
|
||||
final class TSParser extends Opaque {}
|
||||
|
||||
final class TSTree extends Opaque {}
|
||||
|
||||
final class TSQuery extends Opaque {}
|
||||
|
||||
final class TSQueryCursor extends Opaque {}
|
||||
|
||||
final class TSWasmStore extends Opaque {}
|
||||
|
||||
final class TSWasmEngine extends Opaque {}
|
||||
|
||||
// -- Structs -----------------------------------------------------------------
|
||||
@@ -51,10 +56,8 @@ final class TSWasmError extends Struct {
|
||||
typedef _TsParserNew = Pointer<TSParser> Function();
|
||||
typedef _TsParserDelete = Void Function(Pointer<TSParser>);
|
||||
typedef _TsParserSetLanguage = Bool Function(Pointer<TSParser>, Pointer<Void>);
|
||||
typedef _TsParserSetWasmStore = Void Function(
|
||||
Pointer<TSParser>, Pointer<TSWasmStore>);
|
||||
typedef _TsParserParseString = Pointer<TSTree> Function(
|
||||
Pointer<TSParser>, Pointer<TSTree>, Pointer<Utf8>, Uint32);
|
||||
typedef _TsParserSetWasmStore = Void Function(Pointer<TSParser>, Pointer<TSWasmStore>);
|
||||
typedef _TsParserParseString = Pointer<TSTree> Function(Pointer<TSParser>, Pointer<TSTree>, Pointer<Utf8>, Uint32);
|
||||
|
||||
// Tree
|
||||
typedef _TsTreeDelete = Void Function(Pointer<TSTree>);
|
||||
@@ -65,28 +68,21 @@ typedef _TsNodeStartByte = Uint32 Function(TSNode);
|
||||
typedef _TsNodeEndByte = Uint32 Function(TSNode);
|
||||
|
||||
// Query
|
||||
typedef _TsQueryNew = Pointer<TSQuery> Function(
|
||||
Pointer<Void>, Pointer<Utf8>, Uint32, Pointer<Uint32>, Pointer<Int32>);
|
||||
typedef _TsQueryNew = Pointer<TSQuery> Function(Pointer<Void>, Pointer<Utf8>, Uint32, Pointer<Uint32>, Pointer<Int32>);
|
||||
typedef _TsQueryDelete = Void Function(Pointer<TSQuery>);
|
||||
typedef _TsQueryCaptureCount = Uint32 Function(Pointer<TSQuery>);
|
||||
typedef _TsQueryCaptureNameForId = Pointer<Utf8> Function(
|
||||
Pointer<TSQuery>, Uint32, Pointer<Uint32>);
|
||||
typedef _TsQueryCaptureNameForId = Pointer<Utf8> Function(Pointer<TSQuery>, Uint32, Pointer<Uint32>);
|
||||
|
||||
// Query cursor
|
||||
typedef _TsQueryCursorNew = Pointer<TSQueryCursor> Function();
|
||||
typedef _TsQueryCursorDelete = Void Function(Pointer<TSQueryCursor>);
|
||||
typedef _TsQueryCursorExec = Void Function(
|
||||
Pointer<TSQueryCursor>, Pointer<TSQuery>, TSNode);
|
||||
typedef _TsQueryCursorNextMatch = Bool Function(
|
||||
Pointer<TSQueryCursor>, Pointer<TSQueryMatch>);
|
||||
typedef _TsQueryCursorExec = Void Function(Pointer<TSQueryCursor>, Pointer<TSQuery>, TSNode);
|
||||
typedef _TsQueryCursorNextMatch = Bool Function(Pointer<TSQueryCursor>, Pointer<TSQueryMatch>);
|
||||
|
||||
// WASM store
|
||||
typedef _TsWasmStoreNew = Pointer<TSWasmStore> Function(
|
||||
Pointer<TSWasmEngine>, Pointer<TSWasmError>);
|
||||
typedef _TsWasmStoreNew = Pointer<TSWasmStore> Function(Pointer<TSWasmEngine>, Pointer<TSWasmError>);
|
||||
typedef _TsWasmStoreDelete = Void Function(Pointer<TSWasmStore>);
|
||||
typedef _TsWasmStoreLoadLanguage = Pointer<Void> Function(
|
||||
Pointer<TSWasmStore>, Pointer<Utf8>, Pointer<Uint8>, Uint32,
|
||||
Pointer<TSWasmError>);
|
||||
typedef _TsWasmStoreLoadLanguage = Pointer<Void> Function(Pointer<TSWasmStore>, Pointer<Utf8>, Pointer<Uint8>, Uint32, Pointer<TSWasmError>);
|
||||
|
||||
// WASM engine (from wasmtime C API, re-exported by tree-sitter)
|
||||
typedef _WasmEngineNew = Pointer<TSWasmEngine> Function();
|
||||
@@ -97,10 +93,8 @@ typedef _WasmEngineDelete = Void Function(Pointer<TSWasmEngine>);
|
||||
typedef DTsParserNew = Pointer<TSParser> Function();
|
||||
typedef DTsParserDelete = void Function(Pointer<TSParser>);
|
||||
typedef DTsParserSetLanguage = bool Function(Pointer<TSParser>, Pointer<Void>);
|
||||
typedef DTsParserSetWasmStore = void Function(
|
||||
Pointer<TSParser>, Pointer<TSWasmStore>);
|
||||
typedef DTsParserParseString = Pointer<TSTree> Function(
|
||||
Pointer<TSParser>, Pointer<TSTree>, Pointer<Utf8>, int);
|
||||
typedef DTsParserSetWasmStore = void Function(Pointer<TSParser>, Pointer<TSWasmStore>);
|
||||
typedef DTsParserParseString = Pointer<TSTree> Function(Pointer<TSParser>, Pointer<TSTree>, Pointer<Utf8>, int);
|
||||
|
||||
typedef DTsTreeDelete = void Function(Pointer<TSTree>);
|
||||
typedef DTsTreeRootNode = TSNode Function(Pointer<TSTree>);
|
||||
@@ -108,26 +102,19 @@ typedef DTsTreeRootNode = TSNode Function(Pointer<TSTree>);
|
||||
typedef DTsNodeStartByte = int Function(TSNode);
|
||||
typedef DTsNodeEndByte = int Function(TSNode);
|
||||
|
||||
typedef DTsQueryNew = Pointer<TSQuery> Function(
|
||||
Pointer<Void>, Pointer<Utf8>, int, Pointer<Uint32>, Pointer<Int32>);
|
||||
typedef DTsQueryNew = Pointer<TSQuery> Function(Pointer<Void>, Pointer<Utf8>, int, Pointer<Uint32>, Pointer<Int32>);
|
||||
typedef DTsQueryDelete = void Function(Pointer<TSQuery>);
|
||||
typedef DTsQueryCaptureCount = int Function(Pointer<TSQuery>);
|
||||
typedef DTsQueryCaptureNameForId = Pointer<Utf8> Function(
|
||||
Pointer<TSQuery>, int, Pointer<Uint32>);
|
||||
typedef DTsQueryCaptureNameForId = Pointer<Utf8> Function(Pointer<TSQuery>, int, Pointer<Uint32>);
|
||||
|
||||
typedef DTsQueryCursorNew = Pointer<TSQueryCursor> Function();
|
||||
typedef DTsQueryCursorDelete = void Function(Pointer<TSQueryCursor>);
|
||||
typedef DTsQueryCursorExec = void Function(
|
||||
Pointer<TSQueryCursor>, Pointer<TSQuery>, TSNode);
|
||||
typedef DTsQueryCursorNextMatch = bool Function(
|
||||
Pointer<TSQueryCursor>, Pointer<TSQueryMatch>);
|
||||
typedef DTsQueryCursorExec = void Function(Pointer<TSQueryCursor>, Pointer<TSQuery>, TSNode);
|
||||
typedef DTsQueryCursorNextMatch = bool Function(Pointer<TSQueryCursor>, Pointer<TSQueryMatch>);
|
||||
|
||||
typedef DTsWasmStoreNew = Pointer<TSWasmStore> Function(
|
||||
Pointer<TSWasmEngine>, Pointer<TSWasmError>);
|
||||
typedef DTsWasmStoreNew = Pointer<TSWasmStore> Function(Pointer<TSWasmEngine>, Pointer<TSWasmError>);
|
||||
typedef DTsWasmStoreDelete = void Function(Pointer<TSWasmStore>);
|
||||
typedef DTsWasmStoreLoadLanguage = Pointer<Void> Function(
|
||||
Pointer<TSWasmStore>, Pointer<Utf8>, Pointer<Uint8>, int,
|
||||
Pointer<TSWasmError>);
|
||||
typedef DTsWasmStoreLoadLanguage = Pointer<Void> Function(Pointer<TSWasmStore>, Pointer<Utf8>, Pointer<Uint8>, int, Pointer<TSWasmError>);
|
||||
|
||||
typedef DWasmEngineNew = Pointer<TSWasmEngine> Function();
|
||||
typedef DWasmEngineDelete = void Function(Pointer<TSWasmEngine>);
|
||||
@@ -136,60 +123,28 @@ typedef DWasmEngineDelete = void Function(Pointer<TSWasmEngine>);
|
||||
|
||||
class TreeSitterLib {
|
||||
TreeSitterLib._(DynamicLibrary lib)
|
||||
: parserNew = lib.lookupFunction<_TsParserNew, DTsParserNew>(
|
||||
'ts_parser_new'),
|
||||
parserDelete = lib.lookupFunction<_TsParserDelete, DTsParserDelete>(
|
||||
'ts_parser_delete'),
|
||||
parserSetLanguage =
|
||||
lib.lookupFunction<_TsParserSetLanguage, DTsParserSetLanguage>(
|
||||
'ts_parser_set_language'),
|
||||
parserSetWasmStore =
|
||||
lib.lookupFunction<_TsParserSetWasmStore, DTsParserSetWasmStore>(
|
||||
'ts_parser_set_wasm_store'),
|
||||
parserParseString =
|
||||
lib.lookupFunction<_TsParserParseString, DTsParserParseString>(
|
||||
'ts_parser_parse_string'),
|
||||
treeDelete = lib.lookupFunction<_TsTreeDelete, DTsTreeDelete>(
|
||||
'ts_tree_delete'),
|
||||
treeRootNode = lib.lookupFunction<_TsTreeRootNode, DTsTreeRootNode>(
|
||||
'ts_tree_root_node'),
|
||||
nodeStartByte = lib.lookupFunction<_TsNodeStartByte, DTsNodeStartByte>(
|
||||
'ts_node_start_byte'),
|
||||
nodeEndByte = lib.lookupFunction<_TsNodeEndByte, DTsNodeEndByte>(
|
||||
'ts_node_end_byte'),
|
||||
queryNew =
|
||||
lib.lookupFunction<_TsQueryNew, DTsQueryNew>('ts_query_new'),
|
||||
queryDelete = lib.lookupFunction<_TsQueryDelete, DTsQueryDelete>(
|
||||
'ts_query_delete'),
|
||||
queryCaptureCount =
|
||||
lib.lookupFunction<_TsQueryCaptureCount, DTsQueryCaptureCount>(
|
||||
'ts_query_capture_count'),
|
||||
queryCaptureNameForId = lib.lookupFunction<_TsQueryCaptureNameForId,
|
||||
DTsQueryCaptureNameForId>('ts_query_capture_name_for_id'),
|
||||
queryCursorNew =
|
||||
lib.lookupFunction<_TsQueryCursorNew, DTsQueryCursorNew>(
|
||||
'ts_query_cursor_new'),
|
||||
queryCursorDelete =
|
||||
lib.lookupFunction<_TsQueryCursorDelete, DTsQueryCursorDelete>(
|
||||
'ts_query_cursor_delete'),
|
||||
queryCursorExec =
|
||||
lib.lookupFunction<_TsQueryCursorExec, DTsQueryCursorExec>(
|
||||
'ts_query_cursor_exec'),
|
||||
queryCursorNextMatch =
|
||||
lib.lookupFunction<_TsQueryCursorNextMatch, DTsQueryCursorNextMatch>(
|
||||
'ts_query_cursor_next_match'),
|
||||
wasmStoreNew = lib.lookupFunction<_TsWasmStoreNew, DTsWasmStoreNew>(
|
||||
'ts_wasm_store_new'),
|
||||
wasmStoreDelete =
|
||||
lib.lookupFunction<_TsWasmStoreDelete, DTsWasmStoreDelete>(
|
||||
'ts_wasm_store_delete'),
|
||||
wasmStoreLoadLanguage = lib.lookupFunction<_TsWasmStoreLoadLanguage,
|
||||
DTsWasmStoreLoadLanguage>('ts_wasm_store_load_language'),
|
||||
wasmEngineNew = lib.lookupFunction<_WasmEngineNew, DWasmEngineNew>(
|
||||
'wasm_engine_new'),
|
||||
wasmEngineDelete =
|
||||
lib.lookupFunction<_WasmEngineDelete, DWasmEngineDelete>(
|
||||
'wasm_engine_delete');
|
||||
: parserNew = lib.lookupFunction<_TsParserNew, DTsParserNew>('ts_parser_new'),
|
||||
parserDelete = lib.lookupFunction<_TsParserDelete, DTsParserDelete>('ts_parser_delete'),
|
||||
parserSetLanguage = lib.lookupFunction<_TsParserSetLanguage, DTsParserSetLanguage>('ts_parser_set_language'),
|
||||
parserSetWasmStore = lib.lookupFunction<_TsParserSetWasmStore, DTsParserSetWasmStore>('ts_parser_set_wasm_store'),
|
||||
parserParseString = lib.lookupFunction<_TsParserParseString, DTsParserParseString>('ts_parser_parse_string'),
|
||||
treeDelete = lib.lookupFunction<_TsTreeDelete, DTsTreeDelete>('ts_tree_delete'),
|
||||
treeRootNode = lib.lookupFunction<_TsTreeRootNode, DTsTreeRootNode>('ts_tree_root_node'),
|
||||
nodeStartByte = lib.lookupFunction<_TsNodeStartByte, DTsNodeStartByte>('ts_node_start_byte'),
|
||||
nodeEndByte = lib.lookupFunction<_TsNodeEndByte, DTsNodeEndByte>('ts_node_end_byte'),
|
||||
queryNew = lib.lookupFunction<_TsQueryNew, DTsQueryNew>('ts_query_new'),
|
||||
queryDelete = lib.lookupFunction<_TsQueryDelete, DTsQueryDelete>('ts_query_delete'),
|
||||
queryCaptureCount = lib.lookupFunction<_TsQueryCaptureCount, DTsQueryCaptureCount>('ts_query_capture_count'),
|
||||
queryCaptureNameForId = lib.lookupFunction<_TsQueryCaptureNameForId, DTsQueryCaptureNameForId>('ts_query_capture_name_for_id'),
|
||||
queryCursorNew = lib.lookupFunction<_TsQueryCursorNew, DTsQueryCursorNew>('ts_query_cursor_new'),
|
||||
queryCursorDelete = lib.lookupFunction<_TsQueryCursorDelete, DTsQueryCursorDelete>('ts_query_cursor_delete'),
|
||||
queryCursorExec = lib.lookupFunction<_TsQueryCursorExec, DTsQueryCursorExec>('ts_query_cursor_exec'),
|
||||
queryCursorNextMatch = lib.lookupFunction<_TsQueryCursorNextMatch, DTsQueryCursorNextMatch>('ts_query_cursor_next_match'),
|
||||
wasmStoreNew = lib.lookupFunction<_TsWasmStoreNew, DTsWasmStoreNew>('ts_wasm_store_new'),
|
||||
wasmStoreDelete = lib.lookupFunction<_TsWasmStoreDelete, DTsWasmStoreDelete>('ts_wasm_store_delete'),
|
||||
wasmStoreLoadLanguage = lib.lookupFunction<_TsWasmStoreLoadLanguage, DTsWasmStoreLoadLanguage>('ts_wasm_store_load_language'),
|
||||
wasmEngineNew = lib.lookupFunction<_WasmEngineNew, DWasmEngineNew>('wasm_engine_new'),
|
||||
wasmEngineDelete = lib.lookupFunction<_WasmEngineDelete, DWasmEngineDelete>('wasm_engine_delete');
|
||||
|
||||
final DTsParserNew parserNew;
|
||||
final DTsParserDelete parserDelete;
|
||||
|
||||
@@ -42,6 +42,9 @@ class _LoadedGrammar {
|
||||
}
|
||||
|
||||
class TreeSitterService {
|
||||
static final TreeSitterService shared = TreeSitterService._();
|
||||
TreeSitterService._();
|
||||
|
||||
final Map<String, _LoadedGrammar> _grammars = {};
|
||||
final Set<String> _unavailable = {};
|
||||
|
||||
@@ -93,8 +96,7 @@ class TreeSitterService {
|
||||
|
||||
try {
|
||||
// Load grammar WASM bytes.
|
||||
final wasmData =
|
||||
await rootBundle.load('assets/grammars/$language.wasm');
|
||||
final wasmData = await rootBundle.load('assets/grammars/$language.wasm');
|
||||
final wasmBytes = wasmData.buffer.asUint8List();
|
||||
|
||||
// Load into WASM store.
|
||||
@@ -104,7 +106,11 @@ class TreeSitterService {
|
||||
final error = calloc<TSWasmError>();
|
||||
|
||||
final lang = lib.wasmStoreLoadLanguage(
|
||||
_store!, nameNative.cast(), wasmNative, wasmBytes.length, error,
|
||||
_store!,
|
||||
nameNative.cast(),
|
||||
wasmNative,
|
||||
wasmBytes.length,
|
||||
error,
|
||||
);
|
||||
|
||||
calloc.free(wasmNative);
|
||||
@@ -122,8 +128,7 @@ class TreeSitterService {
|
||||
// Load highlight query.
|
||||
String? querySource;
|
||||
try {
|
||||
querySource =
|
||||
await rootBundle.loadString('assets/queries/$language.scm');
|
||||
querySource = await rootBundle.loadString('assets/queries/$language.scm');
|
||||
} catch (_) {}
|
||||
|
||||
Pointer<TSQuery> query = nullptr;
|
||||
@@ -136,7 +141,11 @@ class TreeSitterService {
|
||||
final errorType = calloc<Int32>();
|
||||
|
||||
query = lib.queryNew(
|
||||
lang, queryNative.cast(), queryLen, errorOffset, errorType,
|
||||
lang,
|
||||
queryNative.cast(),
|
||||
queryLen,
|
||||
errorOffset,
|
||||
errorType,
|
||||
);
|
||||
|
||||
calloc.free(queryNative);
|
||||
@@ -202,7 +211,10 @@ class TreeSitterService {
|
||||
final sourceNative = source.toNativeUtf8();
|
||||
final sourceLen = utf8.encode(source).length;
|
||||
final tree = lib.parserParseString(
|
||||
parser, nullptr, sourceNative.cast(), sourceLen,
|
||||
parser,
|
||||
nullptr,
|
||||
sourceNative.cast(),
|
||||
sourceLen,
|
||||
);
|
||||
|
||||
if (tree == nullptr) {
|
||||
@@ -263,21 +275,14 @@ class TreeSitterService {
|
||||
|
||||
static Color colorForRole(String role, SurfaceTokens tokens) {
|
||||
return switch (role) {
|
||||
'keyword' || 'repeat' || 'conditional' || 'include' ||
|
||||
'exception' || 'operator' =>
|
||||
tokens.syntaxKeyword,
|
||||
'keyword' || 'repeat' || 'conditional' || 'include' || 'exception' || 'operator' => tokens.syntaxKeyword,
|
||||
'type' || 'type.builtin' || 'constructor' => tokens.syntaxType,
|
||||
'string' || 'string.special' => tokens.syntaxString,
|
||||
'number' || 'float' || 'boolean' => tokens.syntaxNumber,
|
||||
'comment' => tokens.syntaxComment,
|
||||
'function' || 'function.builtin' || 'function.method' ||
|
||||
'method' =>
|
||||
tokens.syntaxMethod,
|
||||
'punctuation.bracket' || 'punctuation.delimiter' ||
|
||||
'punctuation.special' =>
|
||||
tokens.syntaxPunct,
|
||||
'variable' || 'variable.builtin' || 'variable.parameter' =>
|
||||
tokens.globalForeground,
|
||||
'function' || 'function.builtin' || 'function.method' || 'method' => tokens.syntaxMethod,
|
||||
'punctuation.bracket' || 'punctuation.delimiter' || 'punctuation.special' => tokens.syntaxPunct,
|
||||
'variable' || 'variable.builtin' || 'variable.parameter' => tokens.globalForeground,
|
||||
'property' || 'field' => tokens.syntaxMethod,
|
||||
'constant' || 'constant.builtin' => tokens.syntaxNumber,
|
||||
'tag' || 'attribute' => tokens.syntaxKeyword,
|
||||
|
||||
@@ -145,7 +145,6 @@ Color _composite(Color src, Color dst) {
|
||||
}
|
||||
|
||||
double _relativeLuminance(Color c) {
|
||||
double chan(double v) =>
|
||||
v <= 0.03928 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4).toDouble();
|
||||
double chan(double v) => v <= 0.03928 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4).toDouble();
|
||||
return 0.2126 * chan(c.r) + 0.7152 * chan(c.g) + 0.0722 * chan(c.b);
|
||||
}
|
||||
|
||||
@@ -25,9 +25,7 @@ class ThemeController extends ChangeNotifier {
|
||||
String? initialName,
|
||||
}) : _resolver = resolver,
|
||||
_defs = Map.fromEntries(bundled.map((d) => MapEntry(d.name, d))) {
|
||||
final first = initialName != null && _defs.containsKey(initialName)
|
||||
? initialName
|
||||
: bundled.first.name;
|
||||
final first = initialName != null && _defs.containsKey(initialName) ? initialName : bundled.first.name;
|
||||
_currentName = first;
|
||||
_current = _build(first);
|
||||
}
|
||||
@@ -89,8 +87,7 @@ class ClideTheme extends InheritedNotifier<ThemeController> {
|
||||
static ClideThemeData of(BuildContext context) {
|
||||
final w = context.dependOnInheritedWidgetOfExactType<ClideTheme>();
|
||||
if (w == null) {
|
||||
throw FlutterError(
|
||||
'ClideTheme.of() called with a context that is not a descendant of a ClideTheme.');
|
||||
throw FlutterError('ClideTheme.of() called with a context that is not a descendant of a ClideTheme.');
|
||||
}
|
||||
return w.notifier!.current;
|
||||
}
|
||||
@@ -98,8 +95,7 @@ class ClideTheme extends InheritedNotifier<ThemeController> {
|
||||
static ThemeController controllerOf(BuildContext context) {
|
||||
final w = context.dependOnInheritedWidgetOfExactType<ClideTheme>();
|
||||
if (w == null) {
|
||||
throw FlutterError(
|
||||
'ClideTheme.controllerOf() called with a context that is not a descendant of a ClideTheme.');
|
||||
throw FlutterError('ClideTheme.controllerOf() called with a context that is not a descendant of a ClideTheme.');
|
||||
}
|
||||
return w.notifier!;
|
||||
}
|
||||
|
||||
@@ -80,15 +80,13 @@ class ThemeLoader {
|
||||
displayName: displayName,
|
||||
dark: dark,
|
||||
palette: palette,
|
||||
semanticOverride:
|
||||
semantic is Map ? _parseSemantic(semantic, palette) : null,
|
||||
semanticOverride: semantic is Map ? _parseSemantic(semantic, palette) : null,
|
||||
surfaceOverride: mergedSurface.isNotEmpty ? mergedSurface : null,
|
||||
extensionOverride: extension is Map ? _parseRefMap(extension) : null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ThemeDefinition> fromAsset(
|
||||
AssetBundle bundle, String assetPath) async {
|
||||
Future<ThemeDefinition> fromAsset(AssetBundle bundle, String assetPath) async {
|
||||
final txt = await bundle.loadString(assetPath);
|
||||
final fallback = assetPath.split('/').last.replaceAll('.yaml', '');
|
||||
return fromYamlString(txt, fallbackName: fallback);
|
||||
@@ -115,8 +113,7 @@ SemanticRoles _parseSemantic(Map src, Palette palette) {
|
||||
final roles = <String, Color>{};
|
||||
src.forEach((k, v) {
|
||||
if (v is! String) return;
|
||||
final resolved =
|
||||
v.startsWith('#') ? Palette.parseHex(v) : palette.lookup(v);
|
||||
final resolved = v.startsWith('#') ? Palette.parseHex(v) : palette.lookup(v);
|
||||
if (resolved != null) roles['$k'] = resolved;
|
||||
});
|
||||
return SemanticRoles(roles);
|
||||
|
||||
@@ -65,10 +65,8 @@ class ThemeResolver {
|
||||
sidebarSectionHeader: surface[TokenKeys.sidebarSectionHeader]!,
|
||||
statusBarBackground: surface[TokenKeys.statusBarBackground]!,
|
||||
statusBarForeground: surface[TokenKeys.statusBarForeground]!,
|
||||
statusBarItemActiveBackground:
|
||||
surface[TokenKeys.statusBarItemActiveBackground]!,
|
||||
statusBarItemHoverBackground:
|
||||
surface[TokenKeys.statusBarItemHoverBackground]!,
|
||||
statusBarItemActiveBackground: surface[TokenKeys.statusBarItemActiveBackground]!,
|
||||
statusBarItemHoverBackground: surface[TokenKeys.statusBarItemHoverBackground]!,
|
||||
tabBarBackground: surface[TokenKeys.tabBarBackground]!,
|
||||
tabActive: surface[TokenKeys.tabActive]!,
|
||||
tabInactive: surface[TokenKeys.tabInactive]!,
|
||||
@@ -84,10 +82,8 @@ class ThemeResolver {
|
||||
listItemBackground: surface[TokenKeys.listItemBackground]!,
|
||||
listItemForeground: surface[TokenKeys.listItemForeground]!,
|
||||
listItemHoverBackground: surface[TokenKeys.listItemHoverBackground]!,
|
||||
listItemSelectedBackground:
|
||||
surface[TokenKeys.listItemSelectedBackground]!,
|
||||
listItemSelectedForeground:
|
||||
surface[TokenKeys.listItemSelectedForeground]!,
|
||||
listItemSelectedBackground: surface[TokenKeys.listItemSelectedBackground]!,
|
||||
listItemSelectedForeground: surface[TokenKeys.listItemSelectedForeground]!,
|
||||
scrollbarSlider: surface[TokenKeys.scrollbarSlider]!,
|
||||
scrollbarSliderHover: surface[TokenKeys.scrollbarSliderHover]!,
|
||||
scrollbarTrack: surface[TokenKeys.scrollbarTrack]!,
|
||||
@@ -135,9 +131,7 @@ class ThemeResolver {
|
||||
// theme never has a null surface color. Themes that omit these
|
||||
// will land readable if uninspired.
|
||||
roles.putIfAbsent(role, () {
|
||||
return palette.lookup('foreground') ??
|
||||
palette.lookup('background') ??
|
||||
const Color(0xFFFFFFFF);
|
||||
return palette.lookup('foreground') ?? palette.lookup('background') ?? const Color(0xFFFFFFFF);
|
||||
});
|
||||
}
|
||||
return SemanticRoles(roles);
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../src/pty/env.dart';
|
||||
|
||||
class ToolCheck extends ChangeNotifier {
|
||||
bool ptycOk = false;
|
||||
bool pqlOk = false;
|
||||
@@ -18,24 +20,31 @@ class ToolCheck extends ChangeNotifier {
|
||||
if (!gitOk) 'git not found',
|
||||
];
|
||||
|
||||
/// Workspace root, set by the app at boot. Falls back to cwd.
|
||||
static String? workspaceRoot;
|
||||
|
||||
Future<void> check() async {
|
||||
final cwd = Directory.current.path;
|
||||
ptycOk = File('$cwd/native/linux-x64/ptyc').existsSync() ||
|
||||
File('$cwd/ptyc/bin/ptyc').existsSync() ||
|
||||
await _which('ptyc');
|
||||
pqlOk = await _which('pql');
|
||||
tmuxOk = await _which('tmux');
|
||||
gitOk = await _which('git');
|
||||
final root = workspaceRoot ?? Directory.current.path;
|
||||
ptycOk = File('$root/native/linux-x64/ptyc').existsSync() ||
|
||||
File('$root/native/macos-arm64/ptyc').existsSync() ||
|
||||
File('$root/native/macos-x64/ptyc').existsSync() ||
|
||||
File('$root/ptyc/bin/ptyc').existsSync() ||
|
||||
_existsOnPath('ptyc');
|
||||
pqlOk = _existsOnPath('pql');
|
||||
tmuxOk = _existsOnPath('tmux');
|
||||
gitOk = _existsOnPath('git');
|
||||
checked = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
static Future<bool> _which(String name) async {
|
||||
try {
|
||||
final r = await Process.run('which', [name]);
|
||||
return r.exitCode == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
/// Check if [name] exists as an executable in any PATH directory.
|
||||
/// Uses direct file-existence checks — works inside a macOS sandbox
|
||||
/// without needing to exec `which`.
|
||||
static bool _existsOnPath(String name) {
|
||||
for (final dir in expandedPath.split(':')) {
|
||||
if (dir.isEmpty) continue;
|
||||
if (File('$dir/$name').existsSync()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/// Centralized binary resolution for external tools.
|
||||
///
|
||||
/// Resolution runs in a background isolate via [resolvePaths] to avoid
|
||||
/// blocking the merged UI/platform thread on macOS. The result is
|
||||
/// applied on the main thread via [applyResolved].
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Serializable result of tool resolution (crosses isolate boundary).
|
||||
class ResolvedPaths {
|
||||
const ResolvedPaths({
|
||||
this.git,
|
||||
this.pql,
|
||||
this.tmux,
|
||||
this.ptyc,
|
||||
this.shell,
|
||||
this.gitEnv,
|
||||
});
|
||||
|
||||
final String? git;
|
||||
final String? pql;
|
||||
final String? tmux;
|
||||
final String? ptyc;
|
||||
final String? shell;
|
||||
final Map<String, String>? gitEnv;
|
||||
}
|
||||
|
||||
class Toolchain extends ChangeNotifier {
|
||||
String? _git;
|
||||
String? _pql;
|
||||
String? _tmux;
|
||||
String? _ptyc;
|
||||
String? _shell;
|
||||
Map<String, String>? _gitEnv;
|
||||
bool _resolved = false;
|
||||
|
||||
String get git => _git ?? 'git';
|
||||
String get pql => _pql ?? 'pql';
|
||||
String get tmux => _tmux ?? 'tmux';
|
||||
String get ptyc => _ptyc ?? 'ptyc';
|
||||
String get shell => _shell ?? '/bin/bash';
|
||||
|
||||
/// Extra environment variables for git (e.g. GIT_EXEC_PATH for dugite).
|
||||
Map<String, String>? get gitEnv => _gitEnv;
|
||||
|
||||
bool get resolved => _resolved;
|
||||
bool get allOk => _resolved && missing.isEmpty;
|
||||
|
||||
List<String> get missing => [
|
||||
if (_git == null) 'git',
|
||||
if (_pql == null) 'pql',
|
||||
if (_tmux == null) 'tmux',
|
||||
if (_ptyc == null) 'ptyc',
|
||||
];
|
||||
|
||||
/// Returns a Future that completes when resolution finishes.
|
||||
Future<void> waitForResolution() {
|
||||
if (_resolved) return Future.value();
|
||||
final c = Completer<void>();
|
||||
void listener() {
|
||||
if (_resolved) {
|
||||
removeListener(listener);
|
||||
if (!c.isCompleted) c.complete();
|
||||
}
|
||||
}
|
||||
|
||||
addListener(listener);
|
||||
return c.future;
|
||||
}
|
||||
|
||||
/// Apply paths resolved in a background isolate.
|
||||
void applyResolved(ResolvedPaths p) {
|
||||
_git = p.git;
|
||||
_pql = p.pql;
|
||||
_tmux = p.tmux;
|
||||
_ptyc = p.ptyc;
|
||||
_shell = p.shell;
|
||||
_gitEnv = p.gitEnv;
|
||||
_resolved = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Pure function — runs in a background isolate. All file I/O happens
|
||||
/// here, off the main thread.
|
||||
static ResolvedPaths resolvePaths({required String workspaceRoot}) {
|
||||
final dugite = '$workspaceRoot/native/dugite/bin';
|
||||
|
||||
String? git;
|
||||
Map<String, String>? gitEnv;
|
||||
final dugiteGit = _firstExisting(['$dugite/git']);
|
||||
if (dugiteGit != null) {
|
||||
git = dugiteGit;
|
||||
final dugiteRoot = File(dugiteGit).parent.parent.path;
|
||||
gitEnv = {
|
||||
'GIT_EXEC_PATH': '$dugiteRoot/libexec/git-core',
|
||||
'GIT_TEMPLATE_DIR': '$dugiteRoot/share/git-core/templates',
|
||||
};
|
||||
} else {
|
||||
git = _findOnPath('git');
|
||||
}
|
||||
|
||||
final pql = _findOnPath('pql');
|
||||
final tmux = _findOnPath('tmux');
|
||||
final shell = _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash');
|
||||
|
||||
final ptyc = _firstExisting([
|
||||
'$workspaceRoot/ptyc/bin/ptyc',
|
||||
'$workspaceRoot/native/linux-x64/ptyc',
|
||||
'$workspaceRoot/native/macos-arm64/ptyc',
|
||||
'$workspaceRoot/native/macos-x64/ptyc',
|
||||
if (Platform.environment['HOME'] case final home?) '$home/.local/bin/ptyc',
|
||||
]) ??
|
||||
_findOnPath('ptyc');
|
||||
|
||||
return ResolvedPaths(
|
||||
git: git,
|
||||
pql: pql,
|
||||
tmux: tmux,
|
||||
ptyc: ptyc,
|
||||
shell: shell,
|
||||
gitEnv: gitEnv,
|
||||
);
|
||||
}
|
||||
|
||||
static String? _findOnPath(String name) {
|
||||
for (final dir in _expandedPath().split(':')) {
|
||||
if (dir.isEmpty) continue;
|
||||
final f = File('$dir/$name');
|
||||
if (f.existsSync()) return f.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Build expanded PATH inline — must be self-contained for isolate use.
|
||||
static String _expandedPath() {
|
||||
final base = Platform.environment['PATH'] ?? '';
|
||||
if (!Platform.isMacOS) return base;
|
||||
final home = Platform.environment['HOME'] ?? '';
|
||||
final extras = <String>[
|
||||
if (home.isNotEmpty) '$home/.local/bin',
|
||||
'/opt/homebrew/bin',
|
||||
'/opt/homebrew/sbin',
|
||||
'/usr/local/bin',
|
||||
];
|
||||
final existing = base.split(':').toSet();
|
||||
final missing = extras.where((p) => !existing.contains(p));
|
||||
if (missing.isEmpty) return base;
|
||||
return [...missing, ...existing].join(':');
|
||||
}
|
||||
|
||||
static String? _firstExisting(List<String> candidates) {
|
||||
for (final c in candidates) {
|
||||
if (File(c).existsSync()) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level function for compute/isolate use. Takes a single String
|
||||
/// argument (the workspace root) and returns a plain-data result.
|
||||
ResolvedPaths resolveToolchainPaths(String workspaceRoot) {
|
||||
final dugite = '$workspaceRoot/native/dugite/bin';
|
||||
|
||||
String? git;
|
||||
Map<String, String>? gitEnv;
|
||||
final dugiteGit = _firstExistingStandalone(['$dugite/git']);
|
||||
if (dugiteGit != null) {
|
||||
git = dugiteGit;
|
||||
final dugiteRoot = File(dugiteGit).parent.parent.path;
|
||||
gitEnv = {
|
||||
'GIT_EXEC_PATH': '$dugiteRoot/libexec/git-core',
|
||||
'GIT_TEMPLATE_DIR': '$dugiteRoot/share/git-core/templates',
|
||||
};
|
||||
} else {
|
||||
git = _findOnPathStandalone('git');
|
||||
}
|
||||
|
||||
return ResolvedPaths(
|
||||
git: git,
|
||||
pql: _findOnPathStandalone('pql'),
|
||||
tmux: _findOnPathStandalone('tmux'),
|
||||
ptyc: _firstExistingStandalone([
|
||||
'$workspaceRoot/ptyc/bin/ptyc',
|
||||
'$workspaceRoot/native/linux-x64/ptyc',
|
||||
'$workspaceRoot/native/macos-arm64/ptyc',
|
||||
'$workspaceRoot/native/macos-x64/ptyc',
|
||||
if (Platform.environment['HOME'] case final home?) '$home/.local/bin/ptyc',
|
||||
]) ??
|
||||
_findOnPathStandalone('ptyc'),
|
||||
shell: _findOnPathStandalone(Platform.environment['SHELL']?.split('/').last ?? 'bash'),
|
||||
gitEnv: gitEnv,
|
||||
);
|
||||
}
|
||||
|
||||
String? _findOnPathStandalone(String name) {
|
||||
for (final dir in _expandedPathStandalone().split(':')) {
|
||||
if (dir.isEmpty) continue;
|
||||
final f = File('$dir/$name');
|
||||
if (f.existsSync()) return f.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _firstExistingStandalone(List<String> candidates) {
|
||||
for (final c in candidates) {
|
||||
if (File(c).existsSync()) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _expandedPathStandalone() {
|
||||
final base = Platform.environment['PATH'] ?? '';
|
||||
if (!Platform.isMacOS) return base;
|
||||
final home = Platform.environment['HOME'] ?? '';
|
||||
final extras = <String>[
|
||||
if (home.isNotEmpty) '$home/.local/bin',
|
||||
'/opt/homebrew/bin',
|
||||
'/opt/homebrew/sbin',
|
||||
'/usr/local/bin',
|
||||
];
|
||||
final existing = base.split(':').toSet();
|
||||
final missing = extras.where((p) => !existing.contains(p));
|
||||
if (missing.isEmpty) return base;
|
||||
return [...missing, ...existing].join(':');
|
||||
}
|
||||
@@ -20,8 +20,7 @@ class TrayRegistry extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Iterable<TrayItemContribution> get items {
|
||||
final sorted = _items.values.toList()
|
||||
..sort((a, b) => a.priority.compareTo(b.priority));
|
||||
final sorted = _items.values.toList()..sort((a, b) => a.priority.compareTo(b.priority));
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,4 +65,12 @@ class WindowControls extends ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the native OS directory picker.
|
||||
/// Returns the selected path, or null if the user cancelled.
|
||||
/// Throws [MissingPluginException] if the platform has no handler,
|
||||
/// so callers can fall back to a text-input dialog.
|
||||
Future<String?> pickDirectory() {
|
||||
return _channel.invokeMethod<String>('pickDirectory');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ class LuaHost {
|
||||
|
||||
/// Boot the vendored liblua. Throws until Tier 6.
|
||||
static Future<LuaHost> start() async {
|
||||
throw UnsupportedError(
|
||||
'Lua runtime lands at Tier 6 (supporter tool sibling of ptyc).');
|
||||
throw UnsupportedError('Lua runtime lands at Tier 6 (supporter tool sibling of ptyc).');
|
||||
}
|
||||
|
||||
Future<void> dispose() async {}
|
||||
|
||||
+63
-51
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/app.dart';
|
||||
import 'package:clide/test_app.dart';
|
||||
import 'package:clide/builtin/canvas/canvas.dart';
|
||||
import 'package:clide/builtin/claude/claude.dart';
|
||||
import 'package:clide/builtin/claude_control/claude_control.dart';
|
||||
@@ -24,11 +23,11 @@ import 'package:clide/builtin/theme_picker/theme_picker.dart';
|
||||
import 'package:clide/builtin/tickets/tickets.dart';
|
||||
import 'package:clide/builtin/todos/todos.dart';
|
||||
import 'package:clide/builtin/welcome/welcome.dart';
|
||||
import 'dart:io' show Directory, File, Platform;
|
||||
import 'dart:io' show Directory, Platform;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/kernel/src/ipc/in_process.dart';
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
import 'package:clide/src/daemon/editor_commands.dart';
|
||||
import 'package:clide/src/daemon/files_commands.dart';
|
||||
@@ -36,10 +35,12 @@ import 'package:clide/src/daemon/git_commands.dart';
|
||||
import 'package:clide/src/daemon/pane_commands.dart';
|
||||
import 'package:clide/src/daemon/pql_commands.dart';
|
||||
import 'package:clide/src/editor/registry.dart' show EditorRegistry;
|
||||
import 'package:clide/src/panes/registry.dart';
|
||||
import 'package:clide/src/git/client.dart';
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:clide/src/panes/event_sink.dart';
|
||||
import 'package:clide/src/panes/registry.dart';
|
||||
import 'package:clide/src/pql/client.dart';
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -47,6 +48,13 @@ import 'package:flutter/widgets.dart';
|
||||
Future<void> main() async {
|
||||
final binding = WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Test mode: skip the full app, run the test harness instead.
|
||||
const testMode = String.fromEnvironment('CLIDE_TESTMODE');
|
||||
if (testMode.isNotEmpty) {
|
||||
runApp(const ClideTestApp());
|
||||
return;
|
||||
}
|
||||
|
||||
binding.ensureSemantics();
|
||||
|
||||
TreeSitterLib.init();
|
||||
@@ -54,28 +62,40 @@ Future<void> main() async {
|
||||
final appDir = await _resolveAppDir();
|
||||
final themes = await _loadBundledThemes();
|
||||
|
||||
// Resolve toolchain + boot daemon inline — same as Linux.
|
||||
// With proper signing (Developer ID), no sandbox or isolate needed.
|
||||
final toolchain = Toolchain();
|
||||
if (!kIsWeb) {
|
||||
const workspace = String.fromEnvironment('CLIDE_PROJECT');
|
||||
final root = workspace.isNotEmpty ? workspace : Directory.current.path;
|
||||
toolchain.applyResolved(resolveToolchainPaths(root));
|
||||
}
|
||||
|
||||
final services = await KernelServices.boot(
|
||||
appDir: appDir,
|
||||
bundledThemes: themes,
|
||||
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
|
||||
preloadNamespaces: _tier0Namespaces,
|
||||
autoStartDaemonClient: false,
|
||||
daemonClientFactory: kIsWeb ? null : (log, events) {
|
||||
final dispatcher = DaemonDispatcher();
|
||||
final eventSink = _BusEventSink(events);
|
||||
final filesService = FilesService.atCwd(events: eventSink);
|
||||
final workRoot = filesService.root;
|
||||
final ptycPath = _resolvePtyc(workRoot.path);
|
||||
final paneRegistry = PaneRegistry(events: eventSink);
|
||||
registerPaneCommands(dispatcher, paneRegistry, defaultPtycPath: ptycPath);
|
||||
registerFilesCommands(dispatcher, filesService);
|
||||
final editorRegistry = EditorRegistry(events: eventSink, workspaceRoot: workRoot);
|
||||
registerEditorCommands(dispatcher, editorRegistry);
|
||||
registerGitCommands(dispatcher, workRoot, eventSink);
|
||||
final pql = PqlClient(workDir: workRoot);
|
||||
registerPqlCommands(dispatcher, pql);
|
||||
return InProcessClient(log: log, events: events, dispatcher: dispatcher);
|
||||
},
|
||||
toolchain: toolchain,
|
||||
daemonClientFactory: kIsWeb
|
||||
? null
|
||||
: (log, events) {
|
||||
final dispatcher = DaemonDispatcher();
|
||||
final eventSink = _BusEventSink(events);
|
||||
final filesService = FilesService.atCwd(events: eventSink);
|
||||
final workRoot = filesService.root;
|
||||
final paneRegistry = PaneRegistry(events: eventSink);
|
||||
registerPaneCommands(dispatcher, paneRegistry);
|
||||
registerFilesCommands(dispatcher, filesService);
|
||||
final editorRegistry = EditorRegistry(events: eventSink, workspaceRoot: workRoot);
|
||||
registerEditorCommands(dispatcher, editorRegistry);
|
||||
final gitClient = GitClient(toolchain: toolchain, workDir: workRoot);
|
||||
registerGitCommands(dispatcher, gitClient, eventSink);
|
||||
final pql = PqlClient(workDir: workRoot, toolchain: toolchain);
|
||||
registerPqlCommands(dispatcher, pql);
|
||||
return InProcessClient(log: log, events: events, dispatcher: dispatcher);
|
||||
},
|
||||
);
|
||||
|
||||
// Register every built-in. Tier 0 activates only the four that do
|
||||
@@ -115,7 +135,6 @@ Future<void> main() async {
|
||||
await services.extensions.activateAll();
|
||||
|
||||
if (!kIsWeb) {
|
||||
unawaited(services.toolCheck.check());
|
||||
await services.project.loadRecents();
|
||||
var opened = await services.project.openLast();
|
||||
if (!opened) {
|
||||
@@ -129,6 +148,21 @@ Future<void> main() async {
|
||||
runApp(ClideApp(services: services));
|
||||
}
|
||||
|
||||
class _BusEventSink implements DaemonEventSink {
|
||||
_BusEventSink(this._bus);
|
||||
final DaemonBus _bus;
|
||||
|
||||
@override
|
||||
void emit(IpcEvent event) {
|
||||
_bus.emit(DaemonEvent(
|
||||
subsystem: event.subsystem,
|
||||
kind: event.kind,
|
||||
data: event.data,
|
||||
ts: DateTime.now(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the app-settings directory.
|
||||
///
|
||||
/// On web we don't touch the filesystem — hand back a sentinel dir so
|
||||
@@ -137,8 +171,13 @@ Future<void> main() async {
|
||||
Future<Directory> _resolveAppDir() async {
|
||||
if (kIsWeb) return Directory('/clide-web-no-disk');
|
||||
final home = Platform.environment['HOME'] ?? '/tmp';
|
||||
final xdg = Platform.environment['XDG_CONFIG_HOME'] ?? '$home/.config';
|
||||
final dir = Directory('$xdg/clide');
|
||||
final String base;
|
||||
if (Platform.isMacOS) {
|
||||
base = '$home/Library/Application Support';
|
||||
} else {
|
||||
base = Platform.environment['XDG_CONFIG_HOME'] ?? '$home/.config';
|
||||
}
|
||||
final dir = Directory('$base/clide');
|
||||
await dir.create(recursive: true);
|
||||
return dir;
|
||||
}
|
||||
@@ -161,33 +200,6 @@ Future<List<ThemeDefinition>> _loadBundledThemes() async {
|
||||
/// Every Tier-0 extension that ships an i18n catalog. Extensions
|
||||
/// registered but not active (the 17 stubs) don't preload — their
|
||||
/// catalogs load lazily on activate in later tiers.
|
||||
String _resolvePtyc(String repoRoot) {
|
||||
final candidates = [
|
||||
'$repoRoot/native/linux-x64/ptyc',
|
||||
'$repoRoot/ptyc/bin/ptyc',
|
||||
'${Platform.environment['HOME']}/.local/bin/ptyc',
|
||||
'ptyc',
|
||||
];
|
||||
for (final c in candidates) {
|
||||
if (File(c).existsSync()) return c;
|
||||
}
|
||||
return 'ptyc';
|
||||
}
|
||||
|
||||
class _BusEventSink implements DaemonEventSink {
|
||||
_BusEventSink(this._bus);
|
||||
final DaemonBus _bus;
|
||||
|
||||
@override
|
||||
void emit(IpcEvent event) {
|
||||
_bus.emit(DaemonEvent(
|
||||
subsystem: event.subsystem,
|
||||
kind: event.kind,
|
||||
data: event.data,
|
||||
ts: DateTime.now(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const List<String> _tier0Namespaces = [
|
||||
'builtin.default-layout',
|
||||
|
||||
@@ -16,6 +16,13 @@ class DaemonDispatcher {
|
||||
_handlers[cmd] = handler;
|
||||
}
|
||||
|
||||
/// Remove all registered handlers except ping/version.
|
||||
void clear() {
|
||||
_handlers.removeWhere((k, _) => k != 'ping' && k != 'version');
|
||||
}
|
||||
|
||||
bool get isEmpty => _handlers.length <= 2; // only ping + version
|
||||
|
||||
Future<IpcResponse> dispatch(IpcRequest req) async {
|
||||
final h = _handlers[req.cmd];
|
||||
if (h == null) {
|
||||
|
||||
@@ -95,7 +95,9 @@ Future<IpcResponse> _activate(IpcRequest req, EditorRegistry r) async {
|
||||
Future<IpcResponse> _list(IpcRequest req, EditorRegistry r) async {
|
||||
return IpcResponse.ok(
|
||||
id: req.id,
|
||||
data: {'buffers': [for (final b in r.buffers) b.toJson()]},
|
||||
data: {
|
||||
'buffers': [for (final b in r.buffers) b.toJson()]
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,9 +141,7 @@ Future<IpcResponse> _setContent(IpcRequest req, EditorRegistry r) async {
|
||||
if (id == null) return _notFound(req.id, 'no active buffer');
|
||||
if (r.get(id) == null) return _notFound(req.id, 'no such buffer: $id');
|
||||
final content = EditorRegistry.contentFromArgs(req.args);
|
||||
final sel = req.args['selection'] == null
|
||||
? null
|
||||
: EditorRegistry.selectionFromArgs(req.args['selection']);
|
||||
final sel = req.args['selection'] == null ? null : EditorRegistry.selectionFromArgs(req.args['selection']);
|
||||
r.setContent(id, content, selection: sel);
|
||||
return IpcResponse.ok(id: req.id, data: {'id': id, 'length': content.length});
|
||||
}
|
||||
@@ -161,4 +161,3 @@ Future<IpcResponse> _close(IpcRequest req, EditorRegistry r) async {
|
||||
r.close(id);
|
||||
return IpcResponse.ok(id: req.id, data: {'id': id});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../files/ignore.dart';
|
||||
import '../files/listing.dart';
|
||||
import '../files/watcher.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
import '../panes/event_sink.dart';
|
||||
import 'dispatcher.dart';
|
||||
|
||||
@@ -55,13 +56,28 @@ class FilesService {
|
||||
}
|
||||
|
||||
void registerFilesCommands(DaemonDispatcher d, FilesService files) {
|
||||
d.register('files.root', (req) async => IpcResponse.ok(
|
||||
id: req.id,
|
||||
data: {
|
||||
'path': files.root.absolute.path,
|
||||
'ignorePatterns': files.ignore.length,
|
||||
},
|
||||
));
|
||||
d.register(
|
||||
'files.root',
|
||||
(req) async => IpcResponse.ok(
|
||||
id: req.id,
|
||||
data: {
|
||||
'path': files.root.absolute.path,
|
||||
'ignorePatterns': files.ignore.length,
|
||||
},
|
||||
));
|
||||
|
||||
d.register('files.read', (req) async {
|
||||
final path = req.args['path'] as String?;
|
||||
if (path == null || path.isEmpty) {
|
||||
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'files.read requires a path'));
|
||||
}
|
||||
final file = File('${files.root.absolute.path}/$path');
|
||||
if (!file.existsSync()) {
|
||||
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'file not found: $path'));
|
||||
}
|
||||
final content = file.readAsStringSync();
|
||||
return IpcResponse.ok(id: req.id, data: {'path': path, 'content': content});
|
||||
});
|
||||
|
||||
d.register('files.ls', (req) async {
|
||||
final dir = (req.args['path'] as String?) ?? '';
|
||||
|
||||
@@ -5,11 +5,8 @@
|
||||
/// can refresh.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import '../git/diff.dart';
|
||||
import '../git/operations.dart';
|
||||
import '../git/status.dart';
|
||||
import '../git/client.dart';
|
||||
import '../git/operations.dart' show GitException;
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
import '../panes/event_sink.dart';
|
||||
@@ -17,22 +14,30 @@ import 'dispatcher.dart';
|
||||
|
||||
void registerGitCommands(
|
||||
DaemonDispatcher d,
|
||||
Directory workDir,
|
||||
GitClient git,
|
||||
DaemonEventSink events,
|
||||
) {
|
||||
d.register('git.status', (req) async {
|
||||
final status = await gitStatus(workDir);
|
||||
return IpcResponse.ok(id: req.id, data: status.toJson());
|
||||
try {
|
||||
final status = await git.status();
|
||||
return IpcResponse.ok(id: req.id, data: status.toJson());
|
||||
} on GitException catch (e) {
|
||||
return _gitError(req.id, e);
|
||||
}
|
||||
});
|
||||
|
||||
d.register('git.diff', (req) async {
|
||||
final staged = req.args['staged'] as bool? ?? false;
|
||||
final paths = _pathList(req.args['paths']);
|
||||
final diffs = await gitDiff(workDir, staged: staged, paths: paths);
|
||||
return IpcResponse.ok(id: req.id, data: {
|
||||
'staged': staged,
|
||||
'diffs': [for (final d in diffs) d.toJson()],
|
||||
});
|
||||
try {
|
||||
final staged = req.args['staged'] as bool? ?? false;
|
||||
final paths = _pathList(req.args['paths']);
|
||||
final diffs = await git.diff(staged: staged, paths: paths);
|
||||
return IpcResponse.ok(id: req.id, data: {
|
||||
'staged': staged,
|
||||
'diffs': [for (final d in diffs) d.toJson()],
|
||||
});
|
||||
} on GitException catch (e) {
|
||||
return _gitError(req.id, e);
|
||||
}
|
||||
});
|
||||
|
||||
d.register('git.stage', (req) async {
|
||||
@@ -49,7 +54,7 @@ void registerGitCommands(
|
||||
);
|
||||
}
|
||||
try {
|
||||
await gitStage(workDir, paths);
|
||||
await git.stage(paths);
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: {'staged': paths});
|
||||
} on GitException catch (e) {
|
||||
@@ -59,7 +64,7 @@ void registerGitCommands(
|
||||
|
||||
d.register('git.stage-all', (req) async {
|
||||
try {
|
||||
await gitStage(workDir, const []);
|
||||
await git.stage(const []);
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: const {'staged': 'all'});
|
||||
} on GitException catch (e) {
|
||||
@@ -70,7 +75,7 @@ void registerGitCommands(
|
||||
d.register('git.unstage', (req) async {
|
||||
final paths = _pathList(req.args['paths']);
|
||||
try {
|
||||
await gitUnstage(workDir, paths);
|
||||
await git.unstage(paths);
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: {'unstaged': paths});
|
||||
} on GitException catch (e) {
|
||||
@@ -91,7 +96,7 @@ void registerGitCommands(
|
||||
);
|
||||
}
|
||||
try {
|
||||
await gitStageHunk(workDir, patch);
|
||||
await git.stageHunk(patch);
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: const {'applied': true});
|
||||
} on GitException catch (e) {
|
||||
@@ -112,7 +117,7 @@ void registerGitCommands(
|
||||
);
|
||||
}
|
||||
try {
|
||||
await gitUnstageHunk(workDir, patch);
|
||||
await git.unstageHunk(patch);
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: const {'applied': true});
|
||||
} on GitException catch (e) {
|
||||
@@ -133,7 +138,7 @@ void registerGitCommands(
|
||||
);
|
||||
}
|
||||
try {
|
||||
await gitDiscard(workDir, paths);
|
||||
await git.discard(paths);
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: {'discarded': paths});
|
||||
} on GitException catch (e) {
|
||||
@@ -154,7 +159,7 @@ void registerGitCommands(
|
||||
);
|
||||
}
|
||||
try {
|
||||
final hash = await gitCommit(workDir, message);
|
||||
final hash = await git.commit(message);
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: {'hash': hash});
|
||||
} on GitException catch (e) {
|
||||
@@ -166,7 +171,7 @@ void registerGitCommands(
|
||||
final message = req.args['message'] as String?;
|
||||
final includeUntracked = req.args['includeUntracked'] as bool? ?? false;
|
||||
try {
|
||||
await gitStash(workDir, message: message, includeUntracked: includeUntracked);
|
||||
await git.stash(message: message, includeUntracked: includeUntracked);
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: const {'stashed': true});
|
||||
} on GitException catch (e) {
|
||||
@@ -176,7 +181,7 @@ void registerGitCommands(
|
||||
|
||||
d.register('git.stash-pop', (req) async {
|
||||
try {
|
||||
await gitStashPop(workDir);
|
||||
await git.stashPop();
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: const {'popped': true});
|
||||
} on GitException catch (e) {
|
||||
@@ -185,16 +190,20 @@ void registerGitCommands(
|
||||
});
|
||||
|
||||
d.register('git.log', (req) async {
|
||||
final count = (req.args['count'] as num?)?.toInt() ?? 20;
|
||||
final entries = await gitLog(workDir, count: count);
|
||||
return IpcResponse.ok(id: req.id, data: {
|
||||
'entries': [for (final e in entries) e.toJson()],
|
||||
});
|
||||
try {
|
||||
final count = (req.args['count'] as num?)?.toInt() ?? 20;
|
||||
final entries = await git.log(count: count);
|
||||
return IpcResponse.ok(id: req.id, data: {
|
||||
'entries': [for (final e in entries) e.toJson()],
|
||||
});
|
||||
} on GitException catch (e) {
|
||||
return _gitError(req.id, e);
|
||||
}
|
||||
});
|
||||
|
||||
d.register('git.pull', (req) async {
|
||||
try {
|
||||
final output = await gitPull(workDir);
|
||||
final output = await git.pull();
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: {'output': output});
|
||||
} on GitException catch (e) {
|
||||
@@ -207,12 +216,7 @@ void registerGitCommands(
|
||||
final branch = req.args['branch'] as String?;
|
||||
final setUpstream = req.args['setUpstream'] as bool? ?? false;
|
||||
try {
|
||||
final output = await gitPush(
|
||||
workDir,
|
||||
remote: remote,
|
||||
branch: branch,
|
||||
setUpstream: setUpstream,
|
||||
);
|
||||
final output = await git.push(remote: remote, branch: branch, setUpstream: setUpstream);
|
||||
return IpcResponse.ok(id: req.id, data: {'output': output});
|
||||
} on GitException catch (e) {
|
||||
return _gitError(req.id, e);
|
||||
@@ -220,13 +224,16 @@ void registerGitCommands(
|
||||
});
|
||||
|
||||
d.register('git.branches', (req) async {
|
||||
final branches = await gitBranches(workDir);
|
||||
return IpcResponse.ok(id: req.id, data: {
|
||||
'branches': [
|
||||
for (final b in branches)
|
||||
{'name': b.name, 'current': b.current},
|
||||
],
|
||||
});
|
||||
try {
|
||||
final b = await git.branches();
|
||||
return IpcResponse.ok(id: req.id, data: {
|
||||
'branches': [
|
||||
for (final e in b) {'name': e.name, 'current': e.current}
|
||||
],
|
||||
});
|
||||
} on GitException catch (e) {
|
||||
return _gitError(req.id, e);
|
||||
}
|
||||
});
|
||||
|
||||
d.register('git.checkout', (req) async {
|
||||
@@ -242,7 +249,7 @@ void registerGitCommands(
|
||||
);
|
||||
}
|
||||
try {
|
||||
await gitCheckout(workDir, branch);
|
||||
await git.checkout(branch);
|
||||
_emitChanged(events);
|
||||
return IpcResponse.ok(id: req.id, data: {'branch': branch});
|
||||
} on GitException catch (e) {
|
||||
|
||||
@@ -17,8 +17,8 @@ import '../panes/pane.dart';
|
||||
import '../panes/registry.dart';
|
||||
import 'dispatcher.dart';
|
||||
|
||||
void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry, {String defaultPtycPath = 'ptyc'}) {
|
||||
d.register('pane.spawn', (req) => _spawn(req, registry, defaultPtycPath));
|
||||
void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry) {
|
||||
d.register('pane.spawn', (req) => _spawn(req, registry));
|
||||
d.register('pane.list', (req) => _list(req, registry));
|
||||
d.register('pane.close', (req) => _close(req, registry));
|
||||
d.register('pane.write', (req) => _write(req, registry));
|
||||
@@ -27,8 +27,7 @@ void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry, {String def
|
||||
d.register('pane.tail', (req) => _tail(req, registry));
|
||||
}
|
||||
|
||||
IpcResponse _userErr(String id, String message, {String? hint}) =>
|
||||
IpcResponse.err(
|
||||
IpcResponse _userErr(String id, String message, {String? hint}) => IpcResponse.err(
|
||||
id: id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.userError,
|
||||
@@ -47,7 +46,7 @@ IpcResponse _notFound(String id, String message) => IpcResponse.err(
|
||||
),
|
||||
);
|
||||
|
||||
Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry, String defaultPtycPath) async {
|
||||
Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry) async {
|
||||
final args = req.args;
|
||||
final rawArgv = args['argv'];
|
||||
if (rawArgv is! List || rawArgv.isEmpty) {
|
||||
@@ -70,8 +69,7 @@ Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry, String default
|
||||
Map<String, String>? env;
|
||||
if (envArg is Map) {
|
||||
env = {
|
||||
for (final e in envArg.entries)
|
||||
'${e.key}': '${e.value}',
|
||||
for (final e in envArg.entries) '${e.key}': '${e.value}',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,7 +82,6 @@ Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry, String default
|
||||
cols: (args['cols'] as num?)?.toInt() ?? 80,
|
||||
rows: (args['rows'] as num?)?.toInt() ?? 24,
|
||||
title: args['title'] as String?,
|
||||
ptycPath: (args['ptyc_path'] as String?) ?? defaultPtycPath,
|
||||
);
|
||||
return IpcResponse.ok(id: req.id, data: pane.toJson());
|
||||
} catch (e) {
|
||||
@@ -102,7 +99,9 @@ Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry, String default
|
||||
Future<IpcResponse> _list(IpcRequest req, PaneRegistry registry) async {
|
||||
return IpcResponse.ok(
|
||||
id: req.id,
|
||||
data: {'panes': [for (final p in registry.panes) p.toJson()]},
|
||||
data: {
|
||||
'panes': [for (final p in registry.panes) p.toJson()]
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,20 @@ void registerPqlCommands(DaemonDispatcher d, PqlClient pql) {
|
||||
}
|
||||
});
|
||||
|
||||
d.register('pql.search', (req) async {
|
||||
final terms = req.args['terms'] as String?;
|
||||
if (terms == null || terms.isEmpty) {
|
||||
return _userError(req.id, 'pql.search requires a terms string');
|
||||
}
|
||||
try {
|
||||
final limit = (req.args['limit'] as num?)?.toInt();
|
||||
final results = await pql.search(terms, limit: limit);
|
||||
return IpcResponse.ok(id: req.id, data: {'results': results});
|
||||
} on PqlException catch (e) {
|
||||
return _pqlError(req.id, e);
|
||||
}
|
||||
});
|
||||
|
||||
d.register('pql.doctor', (req) async {
|
||||
try {
|
||||
final report = await pql.doctor();
|
||||
@@ -186,7 +200,7 @@ void registerPqlCommands(DaemonDispatcher d, PqlClient pql) {
|
||||
try {
|
||||
final result = await pql.ticketShow(
|
||||
id,
|
||||
withDecision: req.args['withDecision'] as bool? ?? false,
|
||||
withContext: req.args['withContext'] as bool? ?? false,
|
||||
withBlockers: req.args['withBlockers'] as bool? ?? false,
|
||||
);
|
||||
return IpcResponse.ok(id: req.id, data: result);
|
||||
@@ -195,6 +209,25 @@ void registerPqlCommands(DaemonDispatcher d, PqlClient pql) {
|
||||
}
|
||||
});
|
||||
|
||||
d.register('pql.tickets.status', (req) async {
|
||||
final rawIds = req.args['ids'];
|
||||
final ids = rawIds is List
|
||||
? rawIds.cast<String>()
|
||||
: rawIds is String
|
||||
? [rawIds]
|
||||
: <String>[];
|
||||
final status = req.args['status'] as String?;
|
||||
if (ids.isEmpty || status == null || status.isEmpty) {
|
||||
return _userError(req.id, 'pql.tickets.status requires ids and status');
|
||||
}
|
||||
try {
|
||||
final result = await pql.ticketSetStatus(ids, status);
|
||||
return IpcResponse.ok(id: req.id, data: {'tickets': result});
|
||||
} on PqlException catch (e) {
|
||||
return _pqlError(req.id, e);
|
||||
}
|
||||
});
|
||||
|
||||
d.register('pql.tickets.board', (req) async {
|
||||
try {
|
||||
final board = await pql.ticketBoard(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user