61 Commits
Author SHA1 Message Date
jpmschweitzerandClaude 5301c4753c release v2.0.0
test / unit + widget + golden + a11y (push) Failing after 31s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-03 22:06:31 +02:00
jpmschweitzerandClaude 38b017e92b dart format session.dart
test / unit + widget + golden + a11y (push) Failing after 34s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-03 21:52:53 +02:00
jpmschweitzerandClaude b45699ccd1 fix test suite — green on make test
tabsFor() sorts by contribution priority when no user order is
set. Test expectations updated for sidebar defaultSize 400 and
decision ID D-1 (no zero-padding). PTY tests tagged forkpty and
run via dart test (forkpty output unreliable inside flutter test
runner). CI script adds --no-fatal-infos and --exclude-tags.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-03 21:52:39 +02:00
jpmschweitzerandClaude 864a1062a6 remove dead code and fix analyzer warnings
Unused _resolvePtycPath (bin/clide.dart), _TrafficDot (app.dart
and clide_column_hat.dart), stale @override on _spawned field
(claude_pane.dart), duplicate import and unused local in
test_app.dart, unnecessary non-null assertions in project.dart.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-03 21:52:26 +02:00
jpmschweitzerandClaude 943ab966d2 harden PTY FFI layer for macOS
Platform-dispatched constants where Linux and macOS diverge:
TIOCSWINSZ, O_NONBLOCK, MsghdrDarwin struct (4-byte msg_iovlen
and msg_controllen vs Linux's 8-byte size_t fields), and split
recvmsg into Linux/Darwin typed variants so scm_rights uses the
correct struct per platform.

Also: app settings dir uses ~/Library/Application Support on
macOS, removed hardcoded TERMINFO from pane spawn env, removed
debug print from native_pty resize.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-03 21:52:13 +02:00
jpmschweitzerandClaude 9c7ec008dc dart format whole tree
Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-03 21:51:59 +02:00
Jeroen Schweitzer c436e72c5c Merge remote-tracking branch 'gitea/main'
test / unit + widget + golden + a11y (push) Failing after 30s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
# Conflicts:
#	.pql/pql-plan.json
2026-05-03 21:24:52 +02:00
Jeroen SchweitzerandClaude Opus 4.6 82ea4bd908 add macOS PTY investigation docs
test / unit + widget + golden + a11y (push) Failing after 40s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
macos-pty-problem.md: diagnosis of the SCM_RIGHTS fd issue (resolved
by switching to forkpty via NativePty).

pty-proposition.md: Gemini's diagnostic plan (cmsg_len analysis,
ptyc instrumentation, async recvFd).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-03 21:11:04 +02:00
Jeroen SchweitzerandClaude Opus 4.6 56d13c71ef use tmux resize-window for explicit window size control
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
tmux determines window size from its client, not the PTY winsize.
TIOCSWINSZ + SIGWINCH on the master fd has no effect on tmux's
internal dimensions. Use tmux resize-window -t <session> -x -y
to explicitly set the window size on resize.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 13:17:56 +02:00
Jeroen SchweitzerandClaude Opus 4.6 956279eca4 tell tmux to refresh-client on resize
test / unit + widget + golden + a11y (push) Failing after 27s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
tmux doesn't re-read the PTY winsize on SIGWINCH. After the ioctl
resize, call tmux refresh-client -C cols,rows to update tmux's
internal window dimensions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 13:11:26 +02:00
Jeroen SchweitzerandClaude Opus 4.6 a028eb7ad3 pass -x/-y to tmux new-session for correct initial size
test / unit + widget + golden + a11y (push) Failing after 27s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
tmux running inside a PTY without a traditional terminal client
defaults to a huge window size (2000+ cols, 10000 rows). Pass
explicit -x and -y flags matching the TerminalView dimensions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 13:03:51 +02:00
Jeroen SchweitzerandClaude Opus 4.6 0b3c6bc1f8 restore tmux session in Claude pane
test / unit + widget + golden + a11y (push) Failing after 34s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Reverts the bare-shell diagnostic. Spawns tmux new-session -A -s
<sessionName> for persistence. Claude command will be added back
after tmux + resize are stable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 12:20:03 +02:00
Jeroen SchweitzerandClaude Opus 4.6 29fe6795b3 rename CLIDE_WORKSPACE to CLIDE_PROJECT
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Missed in the workspace → project rename. Consistent with the
project.open / project.validate / ProjectOpened naming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 12:11:15 +02:00
Jeroen SchweitzerandClaude Opus 4.6 2921adc9cb add TERMINFO env for top/ncurses, tighten output flush
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
top fails with "Error opening terminal: xterm-256color" because the
PTY child can't find terminfo. Set TERMINFO=/usr/share/terminfo
explicitly in the pane environment.

Output flush changed from 16ms Timer to Duration.zero (next event
loop turn) for tighter batching of escape sequences.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 12:08:01 +02:00
Jeroen SchweitzerandClaude Opus 4.6 3e6c0279ef batch terminal output at 60fps, increase read buffer to 64KB
test / unit + widget + golden + a11y (push) Failing after 29s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Split escape sequences caused rendering artifacts (visible % prompt
mark, garbled lines between commands). Two fixes from the legacy
Python implementation:

1. Read buffer increased from 4096 to 65536 bytes. Larger reads mean
   fewer chunk boundaries that can split multi-byte escape sequences.

2. Output writes throttled to ~60fps via a 16ms Timer. Multiple PTY
   reads within one frame window are batched into a single
   terminal.write() call, so the xterm parser sees complete sequences
   instead of fragments that render as garbage between frames.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 12:01:22 +02:00
Jeroen SchweitzerandClaude Opus 4.6 0485a36091 stagger initial scheduler ticks to avoid rebuild storm
test / unit + widget + golden + a11y (push) Failing after 33s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Firing all tier ticks simultaneously on project open causes multiple
IPC calls (pql, git, files) to run in parallel, their responses
triggering rapid setState rebuilds that beachball the merged thread.
Stagger 500ms apart so each panel refreshes sequentially.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 11:41:42 +02:00
Jeroen SchweitzerandClaude Opus 4.6 2f7435f271 debounce terminal resize, add SIGWINCH, remove padding
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Rapid resize during window drag sends many SIGWINCH signals causing
the shell to redraw repeatedly, corrupting xterm's render. Debounce
to 150ms so only the final size is sent.

NativePty.resize now explicitly sends SIGWINCH (signal 28) after
TIOCSWINSZ, matching the legacy Python implementation.

Removed 8px padding from ClidePtyView to eliminate potential
dimension mismatch between widget size and PTY cols/rows.

Default window size increased to 1600x900.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 11:40:12 +02:00
Jeroen SchweitzerandClaude Opus 4.6 6f12c884cd defer PTY spawn until first TerminalView resize
test / unit + widget + golden + a11y (push) Failing after 1m57s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
The PTY was spawning at 80x24 defaults before TerminalView had laid
out, then the real resize caused visual artifacts (ghost lines, broken
reflow). Now spawn triggers on the first onResize callback when real
dimensions are known — matching the pty-spike's proven pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 09:24:10 +02:00
Jeroen SchweitzerandClaude Opus 4.6 6d180a876a resolve bare command names via PATH before execve
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
execve doesn't search PATH — bare names like 'tmux' or 'claude' fail
silently. Resolve via the environment's PATH in Dart before passing
to the child. This matches ptyc's old execvp behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 22:42:24 +02:00
Jeroen SchweitzerandClaude Opus 4.6 5aa0eb46e4 replace ptyc with forkpty() via Dart FFI
test / unit + widget + golden + a11y (push) Failing after 3m13s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
NativePty calls forkpty() directly — no helper binary, no socketpair,
no SCM_RIGHTS. The master fd stays in-process. Reader isolate uses
poll() for clean shutdown.

Based on the pty-spike proof-of-concept. Platform-aware: macOS uses
libSystem (DynamicLibrary.process), Linux needs libutil.so.1.
TIOCSWINSZ platform-detected.

PaneRegistry updated to use NativePty. registerPaneCommands no longer
needs a Toolchain parameter. All ptyc references removed from the
daemon layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 20:30:15 +02:00
Jeroen SchweitzerandClaude Opus 4.6 cf2ae48201 use pql decisions read for full body text
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
pql 1.4.4+ restores the 'decisions read' subcommand which returns
metadata + body + refs in one call. Reverts the temporary 'show
--with-refs' workaround.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:54:40 +02:00
Jeroen SchweitzerandClaude Opus 4.6 1f028b336d clean up decision debug prints, add --with-refs to show
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Remove debug print statements. Pass --with-refs to pql decisions show
so cross-references render in the detail view.

The decision body text is not available from pql — it indexes metadata
only. The body section of the D-record lives in the markdown file.
Rendering it requires either a pql --with-context flag or direct file
read + section extraction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:55:30 +02:00
Jeroen SchweitzerandClaude Opus 4.6 63e15d99cf fix decision detail: pql decisions show, not read
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
The pql CLI command is 'decisions show', not 'decisions read'.
The client was sending the wrong subcommand.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 08:59:33 +02:00
Jeroen SchweitzerandClaude Opus 4.6 acc7407fa5 show context panel on decision/ticket selection
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
The context panel was hidden or collapsed when a decision or ticket
was selected. activateTab alone doesn't make the panel visible.
Now both extensions explicitly setVisible + setCollapsed before
activating the detail tab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 08:23:54 +02:00
jpmschweitzerandClaude 7911075107 move canonical upstream to GitHub
Gitea remote kept as secondary (`gitea`). Updated repository URLs
in pubspec.yaml, licenses.yaml, and initial-plan.md. README rewritten
to reflect the current single-process Flutter architecture.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-26 20:07:54 +02:00
Jeroen SchweitzerandClaude Opus 4.6 32e533a12f remove redundant DECISIONS.md pointer
One-line file pointing to decisions/README.md which already exists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 18:34:55 +02:00
Jeroen SchweitzerandClaude Opus 4.6 baf90270d1 share DaemonBus between backend and kernel, fix terminal spawn timing
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
The IsolateClient and KernelServices now share the same DaemonBus.
Previously, backend events (pane.output, git.changed) went to a
separate bus that widgets couldn't see.

ClaudePane._spawnWhenReady waits for ProjectOpened before sending
pane.spawn, preventing "No project active" errors.

Recents loaded before runApp so the welcome screen shows them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 17:17:17 +02:00
Jeroen SchweitzerandClaude Opus 4.6 243a92798c fix macOS PTY: cmsghdr layout, signing, sandbox removal
macOS cmsghdr uses socklen_t (4 bytes) for cmsg_len, not size_t
(8 bytes like Linux). The Dart FFI struct was reading at wrong offsets
causing SCM_RIGHTS fd transfer to fail. Added CmsghdrDarwin with
Uint32 cmsg_len alongside the existing Linux layout.

SOL_SOCKET is 0xffff on macOS (was hardcoded to 1 for Linux).
Now platform-detected.

Switched from ad-hoc to Apple Development signing with team ID.
Removed app sandbox from entitlements — proper signing makes it
unnecessary. All SBPL hacks eliminated.

PtySession.spawn() runs recvFd in a child isolate to avoid blocking
the backend isolate's event loop.

Terminal testmode test now passes: pane.spawn via backend isolate
successfully forks and execs the shell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 17:04:22 +02:00
Jeroen SchweitzerandClaude Opus 4.6 45e8132a41 async recvFd + backend terminal test (sandbox blocks ptyc exec)
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
PtySession.spawn() now runs recvFd in a child isolate via
Isolate.spawn so the blocking FFI call doesn't stall the backend
isolate's event loop.

Terminal testmode test spawns a real backend isolate, opens a project,
and sends pane.spawn via IPC — the exact same path the full app uses.
Currently fails: ptyc successfully starts but its fork+execvp is
blocked by the macOS sandbox ("Operation not permitted"). The SBPL
allows /bin/zsh exec from the app process, but ptyc's child process
may not inherit the exec permission, or the PTYC_SOCK_FD is not
inherited by the ptyc child (Dart Process.start fd inheritance on
macOS).

IsolateClient.events getter exposed for test access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 15:55:29 +02:00
Jeroen SchweitzerandClaude Opus 4.6 8f6ab1ac95 add terminal test category, skip on macOS merged thread
test / unit + widget + golden + a11y (push) Failing after 31s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
PtySession.spawn() uses synchronous FFI calls (socketpair, recvFd via
SCM_RIGHTS) that block the merged UI/platform thread on macOS. The
test correctly identifies this — PTY must move to the backend isolate.

Three tests added (echo, shell, write-to-child) that will run on
Linux where threads are separate. macOS skips with a diagnostic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 15:26:38 +02:00
Jeroen SchweitzerandClaude Opus 4.6 e9e346cc2d add ptyc spawn test to toolchain category
Sends a valid JSON request to ptyc via stdin, verifies it parses the
request (exit 2 = syscall failed, not exit 1 = bad request). Full PTY
setup needs socketpair/SCM_RIGHTS which only PtySession provides.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 15:07:25 +02:00
Jeroen SchweitzerandClaude Opus 4.6 f8e8ee3992 widen project switcher dropdown, fix labels and shortcuts
test / unit + widget + golden + a11y (push) Failing after 30s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Dropdown width 380 → 480 to prevent RenderFlex overflow on recent
project rows. "Close Workspace" → "Close Project". Keyboard shortcuts
show ⌘ on macOS instead of Ctrl.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 14:16:59 +02:00
Jeroen SchweitzerandClaude Opus 4.6 5515d88407 lazy backend boot and rename workspace to project
Backend isolate now boots in two phases: resolve toolchain on spawn
(binary availability only), initialize services on project.open. The
dispatcher stays empty until a project activates — IPC requests before
that return "No project active".

Scheduler ticker only runs while a project is open. Fires an immediate
first cycle on ProjectOpened so sidebar panels refresh without waiting
for the next interval. Stops on ProjectClosed.

Renamed workspace → project throughout backend messages
(project.validate, project.open, project.ready), callbacks
(onProjectOpen, onValidateProject), and methods
(openProject, validateProject, resolveProject).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 14:16:46 +02:00
Jeroen SchweitzerandClaude Opus 4.6 a89910dc36 move daemon services to backend isolate
The merged UI/platform thread on macOS (Flutter 3.41) freezes on any
synchronous work — file I/O, Process.run, even isolate spawning during
early frames. No timing workaround (Timer, addPostFrameCallback,
Future.delayed) was reliable.

Fix: spawn a backend isolate that owns the DaemonDispatcher, GitClient,
PqlClient, FilesService, EditorRegistry, and Toolchain resolution. The
main isolate stays free for rendering. Communication uses SendPort with
the existing IPC message protocol (IpcRequest/IpcResponse/IpcEvent) —
zero new serialization.

New files:
  backend.dart       — spawns isolate, manages SendPort/ReceivePort
  backend_entry.dart — isolate entry point, boots all services
  isolate_client.dart — replaces InProcessClient for production

Toolchain now exposes resolveToolchainPaths() as a top-level function
with self-contained PATH expansion (no module-level state that would
prevent isolate message passing).

ClideTestApp gains boot-sequence tests: compute(), Isolate.run(),
sequential Process.run, and the full resolve+exec chain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 13:53:33 +02:00
jpmschweitzerandClaude 9fff157c9e add TESTMODE_TIMEOUT variable to run-testmode target
test / unit + widget + golden + a11y (push) Failing after 37s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-26 13:49:52 +02:00
jpmschweitzerandClaude af912cfa5f add testmode skill for ClideTestApp harness
Teaches Claude when and how to use make run-testmode, which
category to run for a given change, how to interpret output,
and the pattern for adding new test cases.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-26 13:47:30 +02:00
jpmschweitzerandClaude 9e340197d7 expand ClideTestApp with IPC, extension, and theme tests
Six improvements to the testmode harness:

1. Shell passthrough tests use the resolved shell instead of
   hardcoded /bin/zsh.
2. Exit code reflects test results (non-zero on any failure).
3. JSON summary line for machine-readable parsing.
4. IPC round-trip smoke (ping, version, unknown-cmd, encode/decode).
5. Extension lifecycle smoke (register + activate files, diff,
   git, terminal; theme loading for all four bundled themes).
6. Test categories via CLIDE_TESTMODE dart-define (toolchain, ipc,
   extensions, all). Makefile exposes TESTMODE_CATEGORY variable.

Also switches main.dart from bool.fromEnvironment to
String.fromEnvironment so category values other than "true" work.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-26 13:45:43 +02:00
jpmschweitzerandClaude 7da96b7ef3 use black-circle appicon for macOS
Replaces the transparent-background icons in the macOS asset
catalog with the black-circle variants from assets/logo/. The
64px size is downscaled from the 128px source since the master
set doesn't include that size.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-26 13:34:37 +02:00
jpmschweitzerandClaude d90b57351a rename binary from clide_app to clide
The package name is clide — the binary, bundle identifier, and
GTK application ID should match. Also aligns the macOS test host
paths and bundle identifiers.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-26 13:34:32 +02:00
jpmschweitzerandClaude 45f4328736 fix CI scripts referencing nonexistent app/ subdirectory
All six test/build scripts assumed a nested app/ package that
doesn't exist — the Flutter package lives at the repo root.
Removed every `cd app` and phantom path, consolidated the
redundant dart/flutter analyze passes in test.sh, and scoped
test_core.sh to core subsystem dirs so it no longer overlaps
with the full flutter test run.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-26 13:27:24 +02:00
jpmschweitzerandClaude 6373e399b4 remove duplicate pql gitignore block and hooks negation
The hooks under .pql/hooks/ are installed locally by pql init —
they're runtime scaffolding, not source. The original block at
lines 49-51 already covers .pql/* with the pql-plan.json
negation; the second block was redundant.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-26 13:09:45 +02:00
Jeroen SchweitzerandClaude Opus 4.6 d3fc9bc48a add native picker and error dialog to project switcher
test / unit + widget + golden + a11y (push) Failing after 29s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
The project switcher dropdown's "Open Local Project" action now uses
the native directory picker (same as the welcome screen). Shows "No
git repo found" dialog when the selected directory is not a git
repository.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 13:18:39 +02:00
Jeroen SchweitzerandClaude Opus 4.6 ea31e65db4 gitignore macOS entitlements and dugite binary, add Linux picker handler
DebugProfile.entitlements gitignored (machine-specific sandbox paths).
native/dugite/ gitignored (downloaded at build time).

Linux runner: add pickDirectory handler using GtkFileChooserDialog
in the existing clide/window method channel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 13:18:39 +02:00
Jeroen SchweitzerandClaude Opus 4.6 b0d9fab0c6 bundle git via dugite-native (D-59)
macOS sandbox blocks Homebrew git execution (symlinks resolve to Cellar
paths that SBPL cannot match). Bundled dugite-native — the same
relocatable Git distribution GitHub Desktop ships.

make dugite-fetch downloads the platform tarball to native/dugite/.
Toolchain checks the bundled binary first, sets GIT_EXEC_PATH and
GIT_TEMPLATE_DIR automatically. native/dugite/ is gitignored (57 MB
compressed).

Decision D-59 in tooling.md, dependency entry in licenses.yaml
(GPL-2.0 for the git binary, MIT for dugite-native build scripts).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 13:18:39 +02:00
Jeroen SchweitzerandClaude Opus 4.6 770048fbd2 add ClideTestApp for platform integration testing
Standalone test harness activated via CLIDE_TESTMODE=true dart-define.
Runs a table of existence checks and exec tests against all external
binaries (git, pql, tmux, ptyc, zsh), including zsh-passthrough and
env variants. Results printed to stdout and shown in a minimal UI.
Auto-exits after 15 seconds.

make run-testmode launches it with a 60-second kill timer and pipes
output to /tmp/clide-testmode.log.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 13:18:39 +02:00
Jeroen SchweitzerandClaude Opus 4.6 73e80a55a6 add Toolchain, GitClient, native directory picker
Toolchain centralizes binary resolution — replaces five ad-hoc
mechanisms (expandedPath, _resolveGit, _resolve, _resolvePtyc,
_existsOnPath). Resolves via Future.delayed after runApp to avoid
blocking the merged UI/platform thread on macOS.

GitClient wraps all git operations with a typed API. Every subprocess
call goes through _run() using toolchain.git + toolchain.gitEnv.
Replaces free functions in operations.dart.

Native directory picker: NSOpenPanel on macOS (method channel in
AppDelegate), GtkFileChooserDialog on Linux. Falls back to text-input
dialog on web or MissingPluginException. Shows "No git repo found"
dialog when the selected directory is not a git repository.

PqlClient and pane commands updated to use Toolchain. ToolCheck
replaced by Toolchain.missing/allOk. All IPC handlers now catch
GitException to prevent unhandled exceptions on the merged thread.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 13:18:39 +02:00
Jeroen SchweitzerandClaude Opus 4.6 8edcc78bfe add macOS desktop target with sandbox entitlements
OS-detecting Makefile (make run works on macOS/Linux/Windows), 1280x720
default window in MainMenu.xib, squared app icons, sandbox with scoped
SBPL exceptions for subprocess execution, _DARWIN_C_SOURCE for ptyc
compilation, expanded PATH merging Homebrew and ~/.local/bin for GUI
apps, native traffic dots skipped on macOS (titlebar owns them).

DebugProfile.entitlements is gitignored (machine-specific SBPL paths);
a template with __HOMEDIR__/__PROJECTS__/__SHELL__ placeholders is
committed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 13:18:39 +02:00
jpmschweitzerandClaude 3242c8ec13 track pql-plan.json in git
test / unit + widget + golden + a11y (push) Failing after 38s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-25 13:12:25 +02:00
jpmschweitzerandClaude 41a94b043d update docs for --with-context consolidation
test / unit + widget + golden + a11y (push) Failing after 26s
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / integration_test (xvfb) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Pql skill: ticket show flags updated to reflect --with-context
replacing --with-decision and --with-children.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-24 11:29:24 +02:00
jpmschweitzerandClaude 054808c634 drop withChildren flag — folded into --with-context by pql
test / unit + widget + golden + a11y (push) Failing after 36s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-24 10:59:34 +02:00
jpmschweitzerandClaude b8c8826b9e use pql --with-context for ticket detail loading
test / unit + widget + golden + a11y (push) Failing after 26s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Replaces the N+1 parent-chain walk (one IPC call per ancestor, one
per decision ref) with a single pql ticket show --with-context call.
pql now returns ancestors and decisions arrays inline.

Updates PqlClient.ticketShow to accept withContext/withChildren flags,
removes the obsolete withDecision flag.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-24 10:54:17 +02:00
jpmschweitzerandClaude 77c2f25eaf update skills and changelog for scheduler, accordion, and refresh
test / unit + widget + golden + a11y (push) Failing after 35s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Theme-ui skill: document Phosphor Icons section with codepoint CSV
reference, icon lookup workflow, and bold/fill weight usage.

Pql skill: update ticket status command to reflect batch ID support
and removed state machine enforcement.

Changelog: scheduler service, auto-refresh, ClideAccordion, status
buttons, per-status ticket sections, codepoint CSV.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-24 10:50:53 +02:00
jpmschweitzerandClaude ad1261fdc2 auto-refresh sidebar panels, pin/focus accordion logic
Decisions: file-change subscription for decisions/*.md plus 1-minute
scheduler tick. Tickets: 1-minute scheduler tick plus changed-event
subscription from status button clicks. PQL markdown tab: file-change
subscription for *.md files. All panels get a manual refresh button.

Accordion sections use pin/focus logic: manually toggled sections are
pinned and stay open; the focused item's section auto-opens; unpinned
sections without focus auto-collapse. Both decisions and tickets views
use ClideAccordion with this pattern.

Ticket sidebar split into six status sections (IN PROGRESS, REVIEW,
READY, BACKLOG, DONE, CANCELLED) matching pql's actual statuses.
Status changes scroll the ticket into its new section.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-24 10:50:25 +02:00
jpmschweitzerandClaude 8a0ca92a1f wire ticket status buttons to pql ticket status
Status buttons in the detail view now call pql.tickets.status IPC,
publish a changed event to refresh the sidebar list, and reload the
detail. Disabled for the current status. IPC handler accepts ids as
a list for batch transitions. PqlClient.ticketSetStatus takes
List<String> and joins with comma per pql CLI contract.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-24 10:50:05 +02:00
jpmschweitzerandClaude cf9a510e6c extract ClideAccordion widget, add arrowClockwise icon and codepoint CSV
ClideAccordion — shared accordion section with optional leading widget,
replaces private _AccordionSection in tickets and decisions views.

Phosphor arrowClockwise icon (0xe036) for refresh buttons. Full
codepoint reference CSV (1512 glyphs) at assets/fonts/phosphor/ for
icon lookup without external tools.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-24 10:49:52 +02:00
jpmschweitzerandClaude 2d08c421ae add kernel scheduler service on background isolate
Tiered Timer.periodic instances (1min, 10min, 15min, 1hr, midnight)
running on a dedicated Dart isolate. Tick messages sent back to the
main isolate via SendPort and emitted as SchedulerTick events on the
DaemonBus. Extensions subscribe with events.on<SchedulerTick>() and
filter by tier.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-24 10:49:31 +02:00
jpmschweitzerandClaude 226520cfce add ranked search to pql sidebar with DSL toggle
test / unit + widget + golden + a11y (push) Failing after 33s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Search tab defaults to ranked text search via pql search — debounced
300ms, scored results with visual score bar.  DSL toggle switches to
raw PQL query mode for SQL-like queries.  Clicking a search result
opens it in the markdown context viewer.

Adds pql.search IPC command and PqlClient.search method.  Controller
gains SearchMode enum and search() method alongside existing runQuery.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-23 22:22:16 +02:00
jpmschweitzerandClaude 2f7f797288 update changelog for pql restructure, tree-sitter fix, files.read
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-23 21:26:03 +02:00
jpmschweitzerandClaude bfd304a3f9 restructure pql sidebar: Search default, Markdown tab with viewer
Search (PQL DSL query) is now the default left tab; Markdown (filtered
to .md files) on the right.  Clicking a markdown file publishes on
the message bus; the markdown extension activates the context panel
viewer and re-publishes on a load channel after the frame so the
viewer is guaranteed to be mounted.  Viewer subscribes to load channel
and editor buffer events, publishes focus for sidebar highlighting.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-23 21:25:52 +02:00
jpmschweitzerandClaude 47c2def656 add files.read IPC, fix pane chrome layout, raise context panel max
Register files.read command for reading file content by relative path.
Remove top padding on context panel container and mainAxisSize.min
from ClidePaneChrome so the header sits flush and the pane fills
available height.  Context panel max raised from 420px to 1000px.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-23 21:25:36 +02:00
jpmschweitzerandClaude b236ec439f make TreeSitterService a shared singleton
Each ClideCodeBlock and EditorView created its own TreeSitterService,
each allocating a WASM engine, store, parser, and cursor via FFI.
When widgets were disposed, multiple instances freeing the same
underlying native resources caused double-free corruption on startup.

Single shared instance for the app lifetime.  Also fixes overlapping
tree-sitter spans that caused duplicated text in code blocks — spans
are now clipped to avoid re-emitting already-rendered characters.

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

Some files were not shown because too many files have changed in this diff Show More