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