Compare commits
26
Commits
baf90270d1
..
v2.0.0
+167
-167
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,32 @@ 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
|
||||
@@ -29,6 +53,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
See [`decisions/README.md`](decisions/README.md).
|
||||
@@ -18,10 +18,7 @@ else
|
||||
FLUTTER_OS := windows
|
||||
endif
|
||||
|
||||
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)
|
||||
VERSION ?= $(shell awk -F': *' '/^version:/ {gsub(/[" ]/,"",$$2); print $$2; exit}' pubspec.yaml)
|
||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
.PHONY: help
|
||||
@@ -33,9 +30,9 @@ help: ## Show this help.
|
||||
.PHONY: run
|
||||
run: ## Launch the Flutter desktop app.
|
||||
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_WORKSPACE=$(CURDIR)
|
||||
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_WORKSPACE=$(CURDIR)
|
||||
flutter run -d $(FLUTTER_OS) --dart-define=CLIDE_PROJECT=$(CURDIR)
|
||||
endif
|
||||
|
||||
TESTMODE_CATEGORY ?= all
|
||||
@@ -46,7 +43,7 @@ run-testmode: ## Launch ClideTestApp (TESTMODE_CATEGORY=toolchain|ipc|extensions
|
||||
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_WORKSPACE=$(CURDIR) \
|
||||
--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=$$!; \
|
||||
@@ -54,7 +51,7 @@ ifeq ($(FLUTTER_OS),linux)
|
||||
grep -q '"failed":0' /tmp/clide-testmode.log
|
||||
else
|
||||
@flutter run -d $(FLUTTER_OS) \
|
||||
--dart-define=CLIDE_WORKSPACE=$(CURDIR) \
|
||||
--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=$$!; \
|
||||
@@ -142,6 +139,68 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
+12
-28
@@ -149,7 +149,7 @@ Future<void> _runDaemon(List<String> args) async {
|
||||
|
||||
final events = _ServerEventSink(server);
|
||||
final registry = PaneRegistry(events: events);
|
||||
registerPaneCommands(dispatcher, registry, toolchain: toolchain);
|
||||
registerPaneCommands(dispatcher, registry);
|
||||
|
||||
final files = FilesService.atCwd(events: events);
|
||||
registerFilesCommands(dispatcher, files);
|
||||
@@ -242,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) {
|
||||
@@ -346,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}');
|
||||
}
|
||||
@@ -390,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) {
|
||||
@@ -428,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,
|
||||
|
||||
+5
-2
@@ -6,10 +6,13 @@ set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "==> flutter analyze"
|
||||
flutter analyze
|
||||
flutter analyze --no-fatal-infos
|
||||
|
||||
echo "==> dart format (whole tree)"
|
||||
dart format --set-exit-if-changed .
|
||||
|
||||
echo "==> dart test (forkpty — incompatible with flutter test runner)"
|
||||
dart test --tags forkpty test/pty/session_test.dart
|
||||
|
||||
echo "==> flutter test (unit + widget + golden)"
|
||||
flutter test
|
||||
flutter test --exclude-tags forkpty
|
||||
|
||||
@@ -11,171 +11,201 @@ import 'package:flutter/material.dart';
|
||||
|
||||
class ClideTheme {
|
||||
// ─── palette ──────────────────────────────────────────────────────────
|
||||
static const _bg = Color(0xFF20202C); // page / editor canvas
|
||||
static const _bgSunken = Color(0xFF1A1A24); // sidebar, gutters
|
||||
static const _surface = Color(0xFF242838); // cards, table header, pills
|
||||
static const _surfaceHi = Color(0xFF2C3046); // hover, selected row
|
||||
static const _border = Color(0xFF343850);
|
||||
static const _borderHi = Color(0xFF3C445C);
|
||||
static const _bg = Color(0xFF20202C); // page / editor canvas
|
||||
static const _bgSunken = Color(0xFF1A1A24); // sidebar, gutters
|
||||
static const _surface = Color(0xFF242838); // cards, table header, pills
|
||||
static const _surfaceHi = Color(0xFF2C3046); // hover, selected row
|
||||
static const _border = Color(0xFF343850);
|
||||
static const _borderHi = Color(0xFF3C445C);
|
||||
|
||||
static const _textHi = Color(0xFFE6E8F2); // primary text
|
||||
static const _text = Color(0xFFB1BBE3); // section titles, labels
|
||||
static const _textDim = Color(0xFF78809C); // secondary
|
||||
static const _textMute = Color(0xFF545C84); // small-caps labels
|
||||
static const _textHi = Color(0xFFE6E8F2); // primary text
|
||||
static const _text = Color(0xFFB1BBE3); // section titles, labels
|
||||
static const _textDim = Color(0xFF78809C); // secondary
|
||||
static const _textMute = Color(0xFF545C84); // small-caps labels
|
||||
|
||||
static const _accent = Color(0xFF78A0F8); // periwinkle primary
|
||||
static const _accent = Color(0xFF78A0F8); // periwinkle primary
|
||||
static const _accentPress = Color(0xFF6C90DC);
|
||||
static const _accentSoft = Color(0x2278A0F8); // 13% accent for fills
|
||||
static const _accentSoft = Color(0x2278A0F8); // 13% accent for fills
|
||||
|
||||
static const _ok = Color(0xFF7DD3A8);
|
||||
static const _warn = Color(0xFFE6C370);
|
||||
static const _err = Color(0xFFE87D7D);
|
||||
static const _info = _accent;
|
||||
static const _ok = Color(0xFF7DD3A8);
|
||||
static const _warn = Color(0xFFE6C370);
|
||||
static const _err = Color(0xFFE87D7D);
|
||||
static const _info = _accent;
|
||||
|
||||
// syntax (Dart-biased)
|
||||
static const _synKeyword = Color(0xFFC792EA); // class, const, final
|
||||
static const _synType = Color(0xFF78A0F8); // Widget, BuildContext
|
||||
static const _synString = Color(0xFFA8D99B);
|
||||
static const _synNumber = Color(0xFFE6C370);
|
||||
static const _synComment = Color(0xFF545C84);
|
||||
static const _synMethod = Color(0xFF82B1FF);
|
||||
static const _synPunct = Color(0xFF78809C);
|
||||
static const _synKeyword = Color(0xFFC792EA); // class, const, final
|
||||
static const _synType = Color(0xFF78A0F8); // Widget, BuildContext
|
||||
static const _synString = Color(0xFFA8D99B);
|
||||
static const _synNumber = Color(0xFFE6C370);
|
||||
static const _synComment = Color(0xFF545C84);
|
||||
static const _synMethod = Color(0xFF82B1FF);
|
||||
static const _synPunct = Color(0xFF78809C);
|
||||
|
||||
// ─── exposed tokens ───────────────────────────────────────────────────
|
||||
static const tokens = ClideTokens(
|
||||
bg: _bg, bgSunken: _bgSunken, surface: _surface, surfaceHi: _surfaceHi,
|
||||
border: _border, borderHi: _borderHi,
|
||||
textHi: _textHi, text: _text, textDim: _textDim, textMute: _textMute,
|
||||
accent: _accent, accentPress: _accentPress, accentSoft: _accentSoft,
|
||||
ok: _ok, warn: _warn, err: _err, info: _info,
|
||||
synKeyword: _synKeyword, synType: _synType, synString: _synString,
|
||||
synNumber: _synNumber, synComment: _synComment, synMethod: _synMethod,
|
||||
bg: _bg,
|
||||
bgSunken: _bgSunken,
|
||||
surface: _surface,
|
||||
surfaceHi: _surfaceHi,
|
||||
border: _border,
|
||||
borderHi: _borderHi,
|
||||
textHi: _textHi,
|
||||
text: _text,
|
||||
textDim: _textDim,
|
||||
textMute: _textMute,
|
||||
accent: _accent,
|
||||
accentPress: _accentPress,
|
||||
accentSoft: _accentSoft,
|
||||
ok: _ok,
|
||||
warn: _warn,
|
||||
err: _err,
|
||||
info: _info,
|
||||
synKeyword: _synKeyword,
|
||||
synType: _synType,
|
||||
synString: _synString,
|
||||
synNumber: _synNumber,
|
||||
synComment: _synComment,
|
||||
synMethod: _synMethod,
|
||||
synPunct: _synPunct,
|
||||
);
|
||||
|
||||
// ─── ThemeData ────────────────────────────────────────────────────────
|
||||
static ThemeData get data => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
|
||||
colorScheme: const ColorScheme.dark(
|
||||
brightness: Brightness.dark,
|
||||
primary: _accent,
|
||||
onPrimary: Color(0xFF0D1020),
|
||||
secondary: _synKeyword,
|
||||
onSecondary: Color(0xFF0D1020),
|
||||
surface: _surface,
|
||||
onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border,
|
||||
outlineVariant: _borderHi,
|
||||
error: _err,
|
||||
onError: Color(0xFF0D1020),
|
||||
),
|
||||
colorScheme: const ColorScheme.dark(
|
||||
brightness: Brightness.dark,
|
||||
primary: _accent,
|
||||
onPrimary: Color(0xFF0D1020),
|
||||
secondary: _synKeyword,
|
||||
onSecondary: Color(0xFF0D1020),
|
||||
surface: _surface,
|
||||
onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border,
|
||||
outlineVariant: _borderHi,
|
||||
error: _err,
|
||||
onError: Color(0xFF0D1020),
|
||||
),
|
||||
|
||||
textTheme: const TextTheme(
|
||||
// Josefin Sans Light for display; JetBrains Mono for code/body.
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, height: 1.1, letterSpacing: 0.2, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, height: 1.15, letterSpacing: 0.2, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, height: 1.2, letterSpacing: 0.2, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, height: 1.3, letterSpacing: 0.3, color: _text),
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, height: 1.2, letterSpacing: 1.0, color: _textMute),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, height: 1.4, color: _text),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
// Josefin Sans Light for display; JetBrains Mono for code/body.
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, height: 1.1, letterSpacing: 0.2, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, height: 1.15, letterSpacing: 0.2, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, height: 1.2, letterSpacing: 0.2, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, height: 1.3, letterSpacing: 0.3, color: _text),
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, height: 1.2, letterSpacing: 1.0, color: _textMute),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, height: 1.4, color: _text),
|
||||
),
|
||||
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
|
||||
cardTheme: CardThemeData(
|
||||
color: _surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(color: _border),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: _surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(color: _border),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: _bgSunken,
|
||||
hintStyle: const TextStyle(color: _textMute),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: _border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: _border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: _accent, width: 1.5),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: _bgSunken,
|
||||
hintStyle: const TextStyle(color: _textMute),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: _border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: _border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: _accent, width: 1.5),
|
||||
),
|
||||
),
|
||||
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _accent,
|
||||
foregroundColor: const Color(0xFF0D1020),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: _accent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _accent,
|
||||
foregroundColor: const Color(0xFF0D1020),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: _accent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12),
|
||||
),
|
||||
),
|
||||
|
||||
// Pill-style chips (matches the Projects row in the dashboard).
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: _surface,
|
||||
side: const BorderSide(color: _borderHi),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
labelStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _text),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
),
|
||||
// Pill-style chips (matches the Projects row in the dashboard).
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: _surface,
|
||||
side: const BorderSide(color: _borderHi),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
labelStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _text),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
),
|
||||
|
||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||
|
||||
tooltipTheme: TooltipThemeData(
|
||||
decoration: BoxDecoration(
|
||||
color: _surfaceHi,
|
||||
border: Border.all(color: _borderHi),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _textHi),
|
||||
),
|
||||
tooltipTheme: TooltipThemeData(
|
||||
decoration: BoxDecoration(
|
||||
color: _surfaceHi,
|
||||
border: Border.all(color: _borderHi),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
textStyle: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: _textHi),
|
||||
),
|
||||
|
||||
scrollbarTheme: ScrollbarThemeData(
|
||||
thumbColor: WidgetStatePropertyAll(_border),
|
||||
thickness: const WidgetStatePropertyAll(6),
|
||||
radius: const Radius.circular(3),
|
||||
),
|
||||
);
|
||||
scrollbarTheme: ScrollbarThemeData(
|
||||
thumbColor: WidgetStatePropertyAll(_border),
|
||||
thickness: const WidgetStatePropertyAll(6),
|
||||
radius: const Radius.circular(3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Raw color tokens — use when Material widgets can't carry the meaning.
|
||||
class ClideTokens {
|
||||
const ClideTokens({
|
||||
required this.bg, required this.bgSunken,
|
||||
required this.surface, required this.surfaceHi,
|
||||
required this.border, required this.borderHi,
|
||||
required this.textHi, required this.text,
|
||||
required this.textDim, required this.textMute,
|
||||
required this.accent, required this.accentPress, required this.accentSoft,
|
||||
required this.ok, required this.warn, required this.err, required this.info,
|
||||
required this.synKeyword, required this.synType, required this.synString,
|
||||
required this.synNumber, required this.synComment,
|
||||
required this.synMethod, required this.synPunct,
|
||||
required this.bg,
|
||||
required this.bgSunken,
|
||||
required this.surface,
|
||||
required this.surfaceHi,
|
||||
required this.border,
|
||||
required this.borderHi,
|
||||
required this.textHi,
|
||||
required this.text,
|
||||
required this.textDim,
|
||||
required this.textMute,
|
||||
required this.accent,
|
||||
required this.accentPress,
|
||||
required this.accentSoft,
|
||||
required this.ok,
|
||||
required this.warn,
|
||||
required this.err,
|
||||
required this.info,
|
||||
required this.synKeyword,
|
||||
required this.synType,
|
||||
required this.synString,
|
||||
required this.synNumber,
|
||||
required this.synComment,
|
||||
required this.synMethod,
|
||||
required this.synPunct,
|
||||
});
|
||||
final Color bg, bgSunken, surface, surfaceHi;
|
||||
final Color border, borderHi;
|
||||
|
||||
@@ -6,69 +6,90 @@ import 'package:flutter/material.dart';
|
||||
import 'clide_theme.dart' show ClideTokens;
|
||||
|
||||
class MidnightTheme {
|
||||
static const _bg = Color(0xFF1E1E1E);
|
||||
static const _bgSunken = Color(0xFF181818);
|
||||
static const _surface = Color(0xFF252526);
|
||||
static const _surfaceHi = Color(0xFF2D2D2E);
|
||||
static const _border = Color(0xFF333333);
|
||||
static const _borderHi = Color(0xFF3F3F3F);
|
||||
static const _bg = Color(0xFF1E1E1E);
|
||||
static const _bgSunken = Color(0xFF181818);
|
||||
static const _surface = Color(0xFF252526);
|
||||
static const _surfaceHi = Color(0xFF2D2D2E);
|
||||
static const _border = Color(0xFF333333);
|
||||
static const _borderHi = Color(0xFF3F3F3F);
|
||||
|
||||
static const _textHi = Color(0xFFD4D4D4);
|
||||
static const _text = Color(0xFFBBBBBB);
|
||||
static const _textDim = Color(0xFF858585);
|
||||
static const _textMute = Color(0xFF6A6A6A);
|
||||
static const _textHi = Color(0xFFD4D4D4);
|
||||
static const _text = Color(0xFFBBBBBB);
|
||||
static const _textDim = Color(0xFF858585);
|
||||
static const _textMute = Color(0xFF6A6A6A);
|
||||
|
||||
static const _accent = Color(0xFF569CD6); // muted azure
|
||||
static const _accent = Color(0xFF569CD6); // muted azure
|
||||
static const _accentPress = Color(0xFF4785BD);
|
||||
static const _accentSoft = Color(0x22569CD6);
|
||||
static const _accentSoft = Color(0x22569CD6);
|
||||
|
||||
static const _ok = Color(0xFF89D185);
|
||||
static const _warn = Color(0xFFD7BA7D);
|
||||
static const _err = Color(0xFFF48771);
|
||||
static const _info = _accent;
|
||||
static const _ok = Color(0xFF89D185);
|
||||
static const _warn = Color(0xFFD7BA7D);
|
||||
static const _err = Color(0xFFF48771);
|
||||
static const _info = _accent;
|
||||
|
||||
static const _synKeyword = Color(0xFFC586C0);
|
||||
static const _synType = Color(0xFF4EC9B0);
|
||||
static const _synString = Color(0xFFCE9178);
|
||||
static const _synNumber = Color(0xFFB5CEA8);
|
||||
static const _synComment = Color(0xFF6A9955);
|
||||
static const _synMethod = Color(0xFFDCDCAA);
|
||||
static const _synPunct = Color(0xFF858585);
|
||||
static const _synKeyword = Color(0xFFC586C0);
|
||||
static const _synType = Color(0xFF4EC9B0);
|
||||
static const _synString = Color(0xFFCE9178);
|
||||
static const _synNumber = Color(0xFFB5CEA8);
|
||||
static const _synComment = Color(0xFF6A9955);
|
||||
static const _synMethod = Color(0xFFDCDCAA);
|
||||
static const _synPunct = Color(0xFF858585);
|
||||
|
||||
static const tokens = ClideTokens(
|
||||
bg: _bg, bgSunken: _bgSunken, surface: _surface, surfaceHi: _surfaceHi,
|
||||
border: _border, borderHi: _borderHi,
|
||||
textHi: _textHi, text: _text, textDim: _textDim, textMute: _textMute,
|
||||
accent: _accent, accentPress: _accentPress, accentSoft: _accentSoft,
|
||||
ok: _ok, warn: _warn, err: _err, info: _info,
|
||||
synKeyword: _synKeyword, synType: _synType, synString: _synString,
|
||||
synNumber: _synNumber, synComment: _synComment, synMethod: _synMethod,
|
||||
bg: _bg,
|
||||
bgSunken: _bgSunken,
|
||||
surface: _surface,
|
||||
surfaceHi: _surfaceHi,
|
||||
border: _border,
|
||||
borderHi: _borderHi,
|
||||
textHi: _textHi,
|
||||
text: _text,
|
||||
textDim: _textDim,
|
||||
textMute: _textMute,
|
||||
accent: _accent,
|
||||
accentPress: _accentPress,
|
||||
accentSoft: _accentSoft,
|
||||
ok: _ok,
|
||||
warn: _warn,
|
||||
err: _err,
|
||||
info: _info,
|
||||
synKeyword: _synKeyword,
|
||||
synType: _synType,
|
||||
synString: _synString,
|
||||
synNumber: _synNumber,
|
||||
synComment: _synComment,
|
||||
synMethod: _synMethod,
|
||||
synPunct: _synPunct,
|
||||
);
|
||||
|
||||
static ThemeData get data => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: _accent, onPrimary: Color(0xFF0B1220),
|
||||
secondary: _synKeyword, onSecondary: Color(0xFF0B1220),
|
||||
surface: _surface, onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border, outlineVariant: _borderHi,
|
||||
error: _err, onError: Color(0xFF0B1220),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||
);
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: _accent,
|
||||
onPrimary: Color(0xFF0B1220),
|
||||
secondary: _synKeyword,
|
||||
onSecondary: Color(0xFF0B1220),
|
||||
surface: _surface,
|
||||
onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border,
|
||||
outlineVariant: _borderHi,
|
||||
error: _err,
|
||||
onError: Color(0xFF0B1220),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,70 +7,91 @@ import 'package:flutter/material.dart';
|
||||
import 'clide_theme.dart' show ClideTokens;
|
||||
|
||||
class PaperTheme {
|
||||
static const _bg = Color(0xFFF4F1EA); // paper
|
||||
static const _bgSunken = Color(0xFFECE7DB); // paper-2
|
||||
static const _surface = Color(0xFFFBF8F1);
|
||||
static const _surfaceHi = Color(0xFFECE7DB);
|
||||
static const _border = Color(0xFF1A1A1A);
|
||||
static const _borderHi = Color(0xFF4A4A4A);
|
||||
static const _bg = Color(0xFFF4F1EA); // paper
|
||||
static const _bgSunken = Color(0xFFECE7DB); // paper-2
|
||||
static const _surface = Color(0xFFFBF8F1);
|
||||
static const _surfaceHi = Color(0xFFECE7DB);
|
||||
static const _border = Color(0xFF1A1A1A);
|
||||
static const _borderHi = Color(0xFF4A4A4A);
|
||||
|
||||
static const _textHi = Color(0xFF1A1A1A); // ink
|
||||
static const _text = Color(0xFF4A4A4A); // ink-2
|
||||
static const _textDim = Color(0xFF8A8A82); // ink-3
|
||||
static const _textMute = Color(0xFFA8A89E);
|
||||
static const _textHi = Color(0xFF1A1A1A); // ink
|
||||
static const _text = Color(0xFF4A4A4A); // ink-2
|
||||
static const _textDim = Color(0xFF8A8A82); // ink-3
|
||||
static const _textMute = Color(0xFFA8A89E);
|
||||
|
||||
static const _accent = Color(0xFFC14B2A); // red pencil
|
||||
static const _accent = Color(0xFFC14B2A); // red pencil
|
||||
static const _accentPress = Color(0xFFA03D20);
|
||||
static const _accentSoft = Color(0x22C14B2A);
|
||||
static const _accentSoft = Color(0x22C14B2A);
|
||||
|
||||
static const _ok = Color(0xFF2D8A52);
|
||||
static const _warn = Color(0xFFB88A2A);
|
||||
static const _err = Color(0xFFB03A2A);
|
||||
static const _info = Color(0xFF2A6FC1);
|
||||
static const _ok = Color(0xFF2D8A52);
|
||||
static const _warn = Color(0xFFB88A2A);
|
||||
static const _err = Color(0xFFB03A2A);
|
||||
static const _info = Color(0xFF2A6FC1);
|
||||
|
||||
// Syntax tuned for cream paper — desaturated so code reads like print.
|
||||
static const _synKeyword = Color(0xFF7B3F8C);
|
||||
static const _synType = Color(0xFF2A6FC1);
|
||||
static const _synString = Color(0xFF2D8A52);
|
||||
static const _synNumber = Color(0xFFB88A2A);
|
||||
static const _synComment = Color(0xFF8A8A82);
|
||||
static const _synMethod = Color(0xFF1E5D9E);
|
||||
static const _synPunct = Color(0xFF4A4A4A);
|
||||
static const _synKeyword = Color(0xFF7B3F8C);
|
||||
static const _synType = Color(0xFF2A6FC1);
|
||||
static const _synString = Color(0xFF2D8A52);
|
||||
static const _synNumber = Color(0xFFB88A2A);
|
||||
static const _synComment = Color(0xFF8A8A82);
|
||||
static const _synMethod = Color(0xFF1E5D9E);
|
||||
static const _synPunct = Color(0xFF4A4A4A);
|
||||
|
||||
static const tokens = ClideTokens(
|
||||
bg: _bg, bgSunken: _bgSunken, surface: _surface, surfaceHi: _surfaceHi,
|
||||
border: _border, borderHi: _borderHi,
|
||||
textHi: _textHi, text: _text, textDim: _textDim, textMute: _textMute,
|
||||
accent: _accent, accentPress: _accentPress, accentSoft: _accentSoft,
|
||||
ok: _ok, warn: _warn, err: _err, info: _info,
|
||||
synKeyword: _synKeyword, synType: _synType, synString: _synString,
|
||||
synNumber: _synNumber, synComment: _synComment, synMethod: _synMethod,
|
||||
bg: _bg,
|
||||
bgSunken: _bgSunken,
|
||||
surface: _surface,
|
||||
surfaceHi: _surfaceHi,
|
||||
border: _border,
|
||||
borderHi: _borderHi,
|
||||
textHi: _textHi,
|
||||
text: _text,
|
||||
textDim: _textDim,
|
||||
textMute: _textMute,
|
||||
accent: _accent,
|
||||
accentPress: _accentPress,
|
||||
accentSoft: _accentSoft,
|
||||
ok: _ok,
|
||||
warn: _warn,
|
||||
err: _err,
|
||||
info: _info,
|
||||
synKeyword: _synKeyword,
|
||||
synType: _synType,
|
||||
synString: _synString,
|
||||
synNumber: _synNumber,
|
||||
synComment: _synComment,
|
||||
synMethod: _synMethod,
|
||||
synPunct: _synPunct,
|
||||
);
|
||||
|
||||
static ThemeData get data => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: _accent, onPrimary: Color(0xFFFBF8F1),
|
||||
secondary: _info, onSecondary: Color(0xFFFBF8F1),
|
||||
surface: _surface, onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border, outlineVariant: _borderHi,
|
||||
error: _err, onError: Color(0xFFFBF8F1),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textDim),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
iconTheme: const IconThemeData(color: _text, size: 14),
|
||||
);
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: _accent,
|
||||
onPrimary: Color(0xFFFBF8F1),
|
||||
secondary: _info,
|
||||
onSecondary: Color(0xFFFBF8F1),
|
||||
surface: _surface,
|
||||
onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border,
|
||||
outlineVariant: _borderHi,
|
||||
error: _err,
|
||||
onError: Color(0xFFFBF8F1),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textDim),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
iconTheme: const IconThemeData(color: _text, size: 14),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,71 +7,92 @@ import 'package:flutter/material.dart';
|
||||
import 'clide_theme.dart' show ClideTokens;
|
||||
|
||||
class TerminalTheme {
|
||||
static const _bg = Color(0xFF0A0A0A);
|
||||
static const _bgSunken = Color(0xFF000000);
|
||||
static const _surface = Color(0xFF111111);
|
||||
static const _surfaceHi = Color(0xFF181818);
|
||||
static const _border = Color(0xFF242424);
|
||||
static const _borderHi = Color(0xFF2E2E2E);
|
||||
static const _bg = Color(0xFF0A0A0A);
|
||||
static const _bgSunken = Color(0xFF000000);
|
||||
static const _surface = Color(0xFF111111);
|
||||
static const _surfaceHi = Color(0xFF181818);
|
||||
static const _border = Color(0xFF242424);
|
||||
static const _borderHi = Color(0xFF2E2E2E);
|
||||
|
||||
static const _textHi = Color(0xFFE6E6E6);
|
||||
static const _text = Color(0xFFBDBDBD);
|
||||
static const _textDim = Color(0xFF7A7A7A);
|
||||
static const _textMute = Color(0xFF4A4A4A);
|
||||
static const _textHi = Color(0xFFE6E6E6);
|
||||
static const _text = Color(0xFFBDBDBD);
|
||||
static const _textDim = Color(0xFF7A7A7A);
|
||||
static const _textMute = Color(0xFF4A4A4A);
|
||||
|
||||
static const _accent = Color(0xFFE0B050); // amber
|
||||
static const _accent = Color(0xFFE0B050); // amber
|
||||
static const _accentPress = Color(0xFFC29438);
|
||||
static const _accentSoft = Color(0x22E0B050);
|
||||
static const _accentSoft = Color(0x22E0B050);
|
||||
|
||||
static const _ok = Color(0xFF8FDC9B);
|
||||
static const _warn = Color(0xFFE0B050);
|
||||
static const _err = Color(0xFFE05050);
|
||||
static const _info = Color(0xFFA3C4FF);
|
||||
static const _ok = Color(0xFF8FDC9B);
|
||||
static const _warn = Color(0xFFE0B050);
|
||||
static const _err = Color(0xFFE05050);
|
||||
static const _info = Color(0xFFA3C4FF);
|
||||
|
||||
// Classic 16-color palette feel
|
||||
static const _synKeyword = Color(0xFFE05050);
|
||||
static const _synType = Color(0xFFE0B050);
|
||||
static const _synString = Color(0xFF8FDC9B);
|
||||
static const _synNumber = Color(0xFFC792EA);
|
||||
static const _synComment = Color(0xFF4A4A4A);
|
||||
static const _synMethod = Color(0xFFA3C4FF);
|
||||
static const _synPunct = Color(0xFF7A7A7A);
|
||||
static const _synKeyword = Color(0xFFE05050);
|
||||
static const _synType = Color(0xFFE0B050);
|
||||
static const _synString = Color(0xFF8FDC9B);
|
||||
static const _synNumber = Color(0xFFC792EA);
|
||||
static const _synComment = Color(0xFF4A4A4A);
|
||||
static const _synMethod = Color(0xFFA3C4FF);
|
||||
static const _synPunct = Color(0xFF7A7A7A);
|
||||
|
||||
static const tokens = ClideTokens(
|
||||
bg: _bg, bgSunken: _bgSunken, surface: _surface, surfaceHi: _surfaceHi,
|
||||
border: _border, borderHi: _borderHi,
|
||||
textHi: _textHi, text: _text, textDim: _textDim, textMute: _textMute,
|
||||
accent: _accent, accentPress: _accentPress, accentSoft: _accentSoft,
|
||||
ok: _ok, warn: _warn, err: _err, info: _info,
|
||||
synKeyword: _synKeyword, synType: _synType, synString: _synString,
|
||||
synNumber: _synNumber, synComment: _synComment, synMethod: _synMethod,
|
||||
bg: _bg,
|
||||
bgSunken: _bgSunken,
|
||||
surface: _surface,
|
||||
surfaceHi: _surfaceHi,
|
||||
border: _border,
|
||||
borderHi: _borderHi,
|
||||
textHi: _textHi,
|
||||
text: _text,
|
||||
textDim: _textDim,
|
||||
textMute: _textMute,
|
||||
accent: _accent,
|
||||
accentPress: _accentPress,
|
||||
accentSoft: _accentSoft,
|
||||
ok: _ok,
|
||||
warn: _warn,
|
||||
err: _err,
|
||||
info: _info,
|
||||
synKeyword: _synKeyword,
|
||||
synType: _synType,
|
||||
synString: _synString,
|
||||
synNumber: _synNumber,
|
||||
synComment: _synComment,
|
||||
synMethod: _synMethod,
|
||||
synPunct: _synPunct,
|
||||
);
|
||||
|
||||
static ThemeData get data => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: _accent, onPrimary: Color(0xFF000000),
|
||||
secondary: _info, onSecondary: Color(0xFF000000),
|
||||
surface: _surface, onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border, outlineVariant: _borderHi,
|
||||
error: _err, onError: Color(0xFF000000),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||
// Terminal mockups go full-mono even for display.
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||
);
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: _bg,
|
||||
canvasColor: _bg,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: _accent,
|
||||
onPrimary: Color(0xFF000000),
|
||||
secondary: _info,
|
||||
onSecondary: Color(0xFF000000),
|
||||
surface: _surface,
|
||||
onSurface: _textHi,
|
||||
surfaceContainerHighest: _surfaceHi,
|
||||
outline: _border,
|
||||
outlineVariant: _borderHi,
|
||||
error: _err,
|
||||
onError: Color(0xFF000000),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 48, color: _textHi),
|
||||
displayMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w300, fontSize: 34, color: _textHi),
|
||||
headlineSmall: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 20, color: _textHi),
|
||||
titleMedium: TextStyle(fontFamily: 'Josefin Sans', fontWeight: FontWeight.w400, fontSize: 14, color: _text),
|
||||
// Terminal mockups go full-mono even for display.
|
||||
labelSmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w500, fontSize: 10, letterSpacing: 1.0, color: _textMute),
|
||||
bodyMedium: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 12, height: 1.45, color: _textHi),
|
||||
bodySmall: TextStyle(fontFamily: 'JetBrains Mono', fontWeight: FontWeight.w400, fontSize: 11, color: _text),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: _border, thickness: 1, space: 1),
|
||||
iconTheme: const IconThemeData(color: _textDim, size: 14),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,33 +72,33 @@ class ClideThemes {
|
||||
dark: true,
|
||||
subtitle: 'cool near-black + periwinkle · default',
|
||||
palette: ClidePalette(
|
||||
bg: Color(0xFF20202C),
|
||||
bgSunken: Color(0xFF1A1A24),
|
||||
surface: Color(0xFF242838),
|
||||
surfaceHi: Color(0xFF2C3046),
|
||||
border: Color(0xFF343850),
|
||||
borderHi: Color(0xFF3C445C),
|
||||
textHi: Color(0xFFE6E8F2),
|
||||
text: Color(0xFFB1BBE3),
|
||||
textDim: Color(0xFF78809C),
|
||||
textMute: Color(0xFF545C84),
|
||||
accent: Color(0xFF78A0F8),
|
||||
bg: Color(0xFF20202C),
|
||||
bgSunken: Color(0xFF1A1A24),
|
||||
surface: Color(0xFF242838),
|
||||
surfaceHi: Color(0xFF2C3046),
|
||||
border: Color(0xFF343850),
|
||||
borderHi: Color(0xFF3C445C),
|
||||
textHi: Color(0xFFE6E8F2),
|
||||
text: Color(0xFFB1BBE3),
|
||||
textDim: Color(0xFF78809C),
|
||||
textMute: Color(0xFF545C84),
|
||||
accent: Color(0xFF78A0F8),
|
||||
accentPress: Color(0xFF6C90DC),
|
||||
accentSoft: Color(0x2178A0F8),
|
||||
onAccent: Color(0xFF0D1020),
|
||||
ok: Color(0xFF7DD3A8),
|
||||
warn: Color(0xFFE6C370),
|
||||
err: Color(0xFFE87D7D),
|
||||
info: Color(0xFF78A0F8),
|
||||
accentSoft: Color(0x2178A0F8),
|
||||
onAccent: Color(0xFF0D1020),
|
||||
ok: Color(0xFF7DD3A8),
|
||||
warn: Color(0xFFE6C370),
|
||||
err: Color(0xFFE87D7D),
|
||||
info: Color(0xFF78A0F8),
|
||||
),
|
||||
syntax: ClideSyntax(
|
||||
keyword: Color(0xFFC792EA),
|
||||
type: Color(0xFF78A0F8),
|
||||
string: Color(0xFFA8D99B),
|
||||
number: Color(0xFFE6C370),
|
||||
comment: Color(0xFF545C84),
|
||||
method: Color(0xFF82B1FF),
|
||||
punct: Color(0xFF78809C),
|
||||
keyword: Color(0xFFC792EA),
|
||||
type: Color(0xFF78A0F8),
|
||||
string: Color(0xFFA8D99B),
|
||||
number: Color(0xFFE6C370),
|
||||
comment: Color(0xFF545C84),
|
||||
method: Color(0xFF82B1FF),
|
||||
punct: Color(0xFF78809C),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -108,33 +108,33 @@ class ClideThemes {
|
||||
dark: true,
|
||||
subtitle: 'VS Code-adjacent muted dark',
|
||||
palette: ClidePalette(
|
||||
bg: Color(0xFF1E1E1E),
|
||||
bgSunken: Color(0xFF181818),
|
||||
surface: Color(0xFF252526),
|
||||
surfaceHi: Color(0xFF2D2D2E),
|
||||
border: Color(0xFF333333),
|
||||
borderHi: Color(0xFF3F3F3F),
|
||||
textHi: Color(0xFFD4D4D4),
|
||||
text: Color(0xFFBBBBBB),
|
||||
textDim: Color(0xFF858585),
|
||||
textMute: Color(0xFF6A6A6A),
|
||||
accent: Color(0xFF569CD6),
|
||||
bg: Color(0xFF1E1E1E),
|
||||
bgSunken: Color(0xFF181818),
|
||||
surface: Color(0xFF252526),
|
||||
surfaceHi: Color(0xFF2D2D2E),
|
||||
border: Color(0xFF333333),
|
||||
borderHi: Color(0xFF3F3F3F),
|
||||
textHi: Color(0xFFD4D4D4),
|
||||
text: Color(0xFFBBBBBB),
|
||||
textDim: Color(0xFF858585),
|
||||
textMute: Color(0xFF6A6A6A),
|
||||
accent: Color(0xFF569CD6),
|
||||
accentPress: Color(0xFF4785BD),
|
||||
accentSoft: Color(0x21569CD6),
|
||||
onAccent: Color(0xFF0B1220),
|
||||
ok: Color(0xFF89D185),
|
||||
warn: Color(0xFFD7BA7D),
|
||||
err: Color(0xFFF48771),
|
||||
info: Color(0xFF569CD6),
|
||||
accentSoft: Color(0x21569CD6),
|
||||
onAccent: Color(0xFF0B1220),
|
||||
ok: Color(0xFF89D185),
|
||||
warn: Color(0xFFD7BA7D),
|
||||
err: Color(0xFFF48771),
|
||||
info: Color(0xFF569CD6),
|
||||
),
|
||||
syntax: ClideSyntax(
|
||||
keyword: Color(0xFFC586C0),
|
||||
type: Color(0xFF4EC9B0),
|
||||
string: Color(0xFFCE9178),
|
||||
number: Color(0xFFB5CEA8),
|
||||
comment: Color(0xFF6A9955),
|
||||
method: Color(0xFFDCDCAA),
|
||||
punct: Color(0xFF858585),
|
||||
keyword: Color(0xFFC586C0),
|
||||
type: Color(0xFF4EC9B0),
|
||||
string: Color(0xFFCE9178),
|
||||
number: Color(0xFFB5CEA8),
|
||||
comment: Color(0xFF6A9955),
|
||||
method: Color(0xFFDCDCAA),
|
||||
punct: Color(0xFF858585),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -144,33 +144,33 @@ class ClideThemes {
|
||||
dark: false,
|
||||
subtitle: 'drafting sheet · red-pencil accent · light',
|
||||
palette: ClidePalette(
|
||||
bg: Color(0xFFF4F1EA),
|
||||
bgSunken: Color(0xFFECE7DB),
|
||||
surface: Color(0xFFFBF8F1),
|
||||
surfaceHi: Color(0xFFECE7DB),
|
||||
border: Color(0xFF1A1A1A),
|
||||
borderHi: Color(0xFF4A4A4A),
|
||||
textHi: Color(0xFF1A1A1A),
|
||||
text: Color(0xFF4A4A4A),
|
||||
textDim: Color(0xFF8A8A82),
|
||||
textMute: Color(0xFFA8A89E),
|
||||
accent: Color(0xFFC14B2A),
|
||||
bg: Color(0xFFF4F1EA),
|
||||
bgSunken: Color(0xFFECE7DB),
|
||||
surface: Color(0xFFFBF8F1),
|
||||
surfaceHi: Color(0xFFECE7DB),
|
||||
border: Color(0xFF1A1A1A),
|
||||
borderHi: Color(0xFF4A4A4A),
|
||||
textHi: Color(0xFF1A1A1A),
|
||||
text: Color(0xFF4A4A4A),
|
||||
textDim: Color(0xFF8A8A82),
|
||||
textMute: Color(0xFFA8A89E),
|
||||
accent: Color(0xFFC14B2A),
|
||||
accentPress: Color(0xFFA03D20),
|
||||
accentSoft: Color(0x21C14B2A),
|
||||
onAccent: Color(0xFFFBF8F1),
|
||||
ok: Color(0xFF2D8A52),
|
||||
warn: Color(0xFFB88A2A),
|
||||
err: Color(0xFFB03A2A),
|
||||
info: Color(0xFF2A6FC1),
|
||||
accentSoft: Color(0x21C14B2A),
|
||||
onAccent: Color(0xFFFBF8F1),
|
||||
ok: Color(0xFF2D8A52),
|
||||
warn: Color(0xFFB88A2A),
|
||||
err: Color(0xFFB03A2A),
|
||||
info: Color(0xFF2A6FC1),
|
||||
),
|
||||
syntax: ClideSyntax(
|
||||
keyword: Color(0xFF7B3F8C),
|
||||
type: Color(0xFF2A6FC1),
|
||||
string: Color(0xFF2D8A52),
|
||||
number: Color(0xFFB88A2A),
|
||||
comment: Color(0xFF8A8A82),
|
||||
method: Color(0xFF1E5D9E),
|
||||
punct: Color(0xFF4A4A4A),
|
||||
keyword: Color(0xFF7B3F8C),
|
||||
type: Color(0xFF2A6FC1),
|
||||
string: Color(0xFF2D8A52),
|
||||
number: Color(0xFFB88A2A),
|
||||
comment: Color(0xFF8A8A82),
|
||||
method: Color(0xFF1E5D9E),
|
||||
punct: Color(0xFF4A4A4A),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -180,33 +180,33 @@ class ClideThemes {
|
||||
dark: true,
|
||||
subtitle: 'near-black + amber · tmux feel',
|
||||
palette: ClidePalette(
|
||||
bg: Color(0xFF0A0A0A),
|
||||
bgSunken: Color(0xFF000000),
|
||||
surface: Color(0xFF111111),
|
||||
surfaceHi: Color(0xFF181818),
|
||||
border: Color(0xFF242424),
|
||||
borderHi: Color(0xFF2E2E2E),
|
||||
textHi: Color(0xFFE6E6E6),
|
||||
text: Color(0xFFBDBDBD),
|
||||
textDim: Color(0xFF7A7A7A),
|
||||
textMute: Color(0xFF4A4A4A),
|
||||
accent: Color(0xFFE0B050),
|
||||
bg: Color(0xFF0A0A0A),
|
||||
bgSunken: Color(0xFF000000),
|
||||
surface: Color(0xFF111111),
|
||||
surfaceHi: Color(0xFF181818),
|
||||
border: Color(0xFF242424),
|
||||
borderHi: Color(0xFF2E2E2E),
|
||||
textHi: Color(0xFFE6E6E6),
|
||||
text: Color(0xFFBDBDBD),
|
||||
textDim: Color(0xFF7A7A7A),
|
||||
textMute: Color(0xFF4A4A4A),
|
||||
accent: Color(0xFFE0B050),
|
||||
accentPress: Color(0xFFC29438),
|
||||
accentSoft: Color(0x21E0B050),
|
||||
onAccent: Color(0xFF000000),
|
||||
ok: Color(0xFF8FDC9B),
|
||||
warn: Color(0xFFE0B050),
|
||||
err: Color(0xFFE05050),
|
||||
info: Color(0xFFA3C4FF),
|
||||
accentSoft: Color(0x21E0B050),
|
||||
onAccent: Color(0xFF000000),
|
||||
ok: Color(0xFF8FDC9B),
|
||||
warn: Color(0xFFE0B050),
|
||||
err: Color(0xFFE05050),
|
||||
info: Color(0xFFA3C4FF),
|
||||
),
|
||||
syntax: ClideSyntax(
|
||||
keyword: Color(0xFFE05050),
|
||||
type: Color(0xFFE0B050),
|
||||
string: Color(0xFF8FDC9B),
|
||||
number: Color(0xFFC792EA),
|
||||
comment: Color(0xFF4A4A4A),
|
||||
method: Color(0xFFA3C4FF),
|
||||
punct: Color(0xFF7A7A7A),
|
||||
keyword: Color(0xFFE05050),
|
||||
type: Color(0xFFE0B050),
|
||||
string: Color(0xFF8FDC9B),
|
||||
number: Color(0xFFC792EA),
|
||||
comment: Color(0xFF4A4A4A),
|
||||
method: Color(0xFFA3C4FF),
|
||||
punct: Color(0xFF7A7A7A),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -332,8 +332,8 @@ node; Claude can add a node by `clide canvas node …`.
|
||||
|
||||
- **Flutter Desktop on Linux + macOS** is the primary target;
|
||||
Windows is a stretch goal. Mobile is not a goal.
|
||||
- **Gitea remote** (`git.schweitz.net/jpmschweitzer/clide`) remains
|
||||
the canonical host.
|
||||
- **GitHub remote** (`github.com/postmeridiem/clide`) is the
|
||||
canonical host.
|
||||
- **`pql` stays untouched** for Tiers 0–2; upstream pql work
|
||||
happens in pql's repo during Tiers 3–5.
|
||||
- **tmux** is optional plumbing the sidecar can compose for team
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# macOS PTY Problem — Diagnosis Complete
|
||||
|
||||
## Status: Root cause found
|
||||
|
||||
The PTY master fd is valid (`isatty=1`), SCM_RIGHTS transfer is correct, all struct layouts are correct. The problem is **timing**: the reader isolate starts too late and the shell has already exited by the time `read()` is called on the master fd. macOS returns EOF (n=0) immediately when the slave side is closed — unlike Linux which buffers data.
|
||||
|
||||
## The timing sequence (what happens)
|
||||
|
||||
```
|
||||
1. Dart: socketpair()
|
||||
2. Dart: Process.start(ptyc)
|
||||
3. ptyc: posix_openpt + grantpt + unlockpt + open(slave)
|
||||
4. ptyc: fork() → child starts zsh
|
||||
5. ptyc: sendmsg(SCM_RIGHTS, master_fd) ← master fd sent
|
||||
6. ptyc: printf(ok) + exit (or block if diagnostic)
|
||||
7. Dart: recvmsg() → receives master_fd ← fd is valid, isatty=1
|
||||
8. Dart: PtySession._() constructor
|
||||
9. Dart: _startReader() → Isolate.spawn() ← ASYNC, does not start immediately
|
||||
10. ... event loop yields ...
|
||||
11. Reader isolate starts, calls read(masterFd)
|
||||
12. But zsh already exited → slave closed → read() returns 0 (EOF)
|
||||
```
|
||||
|
||||
The gap between step 9 (Isolate.spawn scheduled) and step 11 (reader actually runs) is where the data is lost. On macOS, the kernel doesn't buffer PTY master data after the slave closes.
|
||||
|
||||
## Why is zsh exiting?
|
||||
|
||||
zsh (`/bin/zsh`) is an interactive shell. It should NOT exit immediately — it should show a prompt and wait for input. But in the test, it does exit. Possible reasons:
|
||||
|
||||
1. **No controlling terminal at spawn time.** ptyc's child does `setsid() + TIOCSCTTY + dup2(slave, 0/1/2)` — this should work. But if the slave fd is already closed or invalid by the time dup2 runs, zsh gets no tty and exits.
|
||||
|
||||
2. **Environment.** The `env` passed to ptyc may be empty or missing `TERM`, `HOME`, `SHELL` etc. Without `TERM`, zsh may fail to initialize and exit.
|
||||
|
||||
3. **The `PTYC_SOCK_FD` inherits open fds.** When ptyc forks, the child inherits ALL open fds (master, slave, socket, exec-failure pipe). ptyc closes master and pipe in the child, but the socket fd stays open. This shouldn't cause an exit, but it's worth checking.
|
||||
|
||||
4. **stdin is connected to the slave.** After dup2(slave, 0), zsh reads from the PTY slave. If Dart hasn't written anything to the master AND there's no PTY echo (because the master isn't being read), zsh might get SIGHUP or detect a broken pipe.
|
||||
|
||||
## Verified facts
|
||||
|
||||
| Test | Result |
|
||||
|------|--------|
|
||||
| ptyc compiles on macOS | ✓ |
|
||||
| socketpair fd inherited by ptyc | ✓ (verified with test binary) |
|
||||
| ptyc creates PTY and forks | ✓ (`{"ok":true,"pid":N}`) |
|
||||
| SCM_RIGHTS transfer | ✓ (correct cmsg layout) |
|
||||
| cmsghdr struct (Dart) | ✓ (CmsghdrDarwin, 12 bytes, correct offsets) |
|
||||
| msghdr struct (Dart) | ✓ (MsghdrDarwin, correct field sizes) |
|
||||
| SOL_SOCKET | ✓ (0xffff on macOS) |
|
||||
| TIOCSWINSZ | ✓ (0x80087467 on macOS) |
|
||||
| Received fd is a tty | ✓ (isatty=1) |
|
||||
| Reader isolate starts | ✓ (prints "started, fd=N") |
|
||||
| Reader gets data | ✗ — EOF immediately (n=0) |
|
||||
| Keeping ptyc alive helps | ✗ — still EOF |
|
||||
| close(master) in ptyc is the cause | ✗ — disproven |
|
||||
|
||||
## Next steps
|
||||
|
||||
1. **Investigate why zsh exits immediately.** Add logging to ptyc's child process to verify it reaches execvp. Check if the child gets a signal (SIGHUP, SIGTERM) right after exec.
|
||||
|
||||
2. **Check the environment passed to ptyc.** If `env` is empty, the child shell has no `TERM`, `HOME`, etc. and may exit immediately.
|
||||
|
||||
3. **Try a long-running command** instead of zsh — e.g. `sleep 10` — to rule out shell-specific init failures.
|
||||
|
||||
4. **Consider `forkpty()` on macOS** — eliminates the timing gap entirely. `forkpty()` creates the PTY, forks, and returns the master fd all in one call from the same process. The reader can start before the child is even exec'd.
|
||||
|
||||
## Environment
|
||||
|
||||
- macOS 26.3.1 (Darwin 25.3.0), Apple Silicon (arm64)
|
||||
- Flutter 3.41.7, Developer ID signed (no sandbox)
|
||||
- pql 1.4.4, dugite-native git 2.53.0
|
||||
- ptyc compiled with: `cc -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 -D_FORTIFY_SOURCE=2 -D_DARWIN_C_SOURCE`
|
||||
@@ -0,0 +1,57 @@
|
||||
# PTY on macOS: A New Diagnostic and Resolution Plan
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Previous attempts to fix the PTY functionality on macOS have failed, even after correcting a deadlock in the Dart code. The core of the problem appears to be a fundamental mismatch in how the C helper (`ptyc`) constructs a control message and how the Dart FFI layer is trying to parse it.
|
||||
|
||||
This document proposes a new, systematic plan to diagnose and resolve this issue by focusing on a key unresolved contradiction: the length of the ancillary data message (`cmsg_len`).
|
||||
|
||||
## 2. The Unresolved Contradiction
|
||||
|
||||
The primary blocker is a discrepancy in the expected length of the `SCM_RIGHTS` control message:
|
||||
|
||||
- **Observed Behavior:** The raw byte dump provided in `docs/macos-pty-problem.md` from the Dart `recvmsg` call shows the `cmsg_len` field as **16 bytes**.
|
||||
- **Code Analysis:** An analysis of the POSIX `CMSG_LEN` macro, as used in `ptyc.c`, suggests the length should be **20 bytes** on a 64-bit macOS system.
|
||||
|
||||
This contradiction means we cannot be certain about the memory layout of the data we are parsing in Dart. Without resolving this, any attempt to fix the data offset is guesswork.
|
||||
|
||||
## 3. Proposed Investigation and Resolution Plan
|
||||
|
||||
This plan will definitively resolve the `cmsg_len` discrepancy and lead to a correct implementation.
|
||||
|
||||
### Step 1: Instrument `ptyc` to Reveal Ground Truth
|
||||
|
||||
The first step is to get definitive data from the source. We will modify `ptyc.c` to log the exact values it's using.
|
||||
|
||||
- **Action:** Add `fprintf(stderr, ...)` statements in `ptyc.c` right before the `sendmsg` call.
|
||||
- **Data to Log:**
|
||||
1. The calculated value of `CMSG_LEN(sizeof(int))`.
|
||||
2. The value of `CMSG_SPACE(sizeof(int))`.
|
||||
3. The value of `sizeof(struct cmsghdr)`.
|
||||
- **Expected Outcome:** This will give us the "ground truth" of the control message structure as constructed by `ptyc` in the actual build environment, resolving the 16-vs-20-byte mystery.
|
||||
|
||||
### Step 2: Correctly Implement the Dart FFI Parser
|
||||
|
||||
With the true `cmsg_len` and memory layout confirmed, we can correctly implement the Dart-side parser.
|
||||
|
||||
- **Action:** Modify `lib/src/pty/ffi/scm_rights.dart`.
|
||||
- **Logic:**
|
||||
- If the logged `cmsg_len` from Step 1 confirms there is alignment padding (i.e., data starts at offset 16), the `dataOffset` calculation will be updated to `16`.
|
||||
- If the logged data shows no padding (i.e., data starts at offset 12), the `dataOffset` will be confirmed as `12`, and we will know the issue lies elsewhere.
|
||||
- **Expected Outcome:** A Dart parser (`recvFd`) that correctly reads the file descriptor from the ancillary data based on empirical evidence, not theoretical calculation.
|
||||
|
||||
### Step 3: Ensure Asynchronous Operation
|
||||
|
||||
The previously identified deadlock, while not the root cause of this specific failure, is still a critical bug.
|
||||
|
||||
- **Action:** Ensure the fix in `lib/src/pty/session.dart` is applied, where the blocking `scm.recvFd` call is replaced with its asynchronous counterpart, `_recvFdAsync`.
|
||||
- **Expected Outcome:** The Dart event loop is not blocked during PTY creation, preventing deadlocks.
|
||||
|
||||
### Step 4: Verification
|
||||
|
||||
With the above changes in place, we will verify the complete solution.
|
||||
|
||||
- **Action:** Run the existing test suite via `flutter test test/pty/session_test.dart`.
|
||||
- **Success Criteria:** All tests in `session_test.dart` must pass, indicating that a PTY can be spawned, its output can be read, and data can be written to it.
|
||||
|
||||
This methodical approach replaces guesswork with a data-driven diagnosis, providing a clear and direct path to resolving the long-standing macOS PTY issue.
|
||||
@@ -21,9 +21,7 @@ import '../test/helpers/fake_ipc.dart';
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets(
|
||||
'clide app boots with classic 3-column layout + welcome + statusbar',
|
||||
(tester) async {
|
||||
testWidgets('clide app boots with classic 3-column layout + welcome + statusbar', (tester) async {
|
||||
final themes = [
|
||||
await const ThemeLoader().fromAsset(
|
||||
rootBundle,
|
||||
@@ -40,8 +38,7 @@ void main() {
|
||||
'builtin.theme-picker',
|
||||
'builtin.default-layout',
|
||||
],
|
||||
daemonClientFactory: (log, events) =>
|
||||
FakeDaemonClient(log: log, events: events),
|
||||
daemonClientFactory: (log, events) => FakeDaemonClient(log: log, events: events),
|
||||
autoStartDaemonClient: false,
|
||||
);
|
||||
services.extensions
|
||||
|
||||
@@ -14,8 +14,7 @@ import '../test/helpers/fake_ipc.dart';
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('disable + re-enable an extension mounts/unmounts its UI',
|
||||
(tester) async {
|
||||
testWidgets('disable + re-enable an extension mounts/unmounts its UI', (tester) async {
|
||||
final themes = [
|
||||
await const ThemeLoader().fromAsset(
|
||||
rootBundle,
|
||||
@@ -31,8 +30,7 @@ void main() {
|
||||
'builtin.ipc-status',
|
||||
'builtin.default-layout',
|
||||
],
|
||||
daemonClientFactory: (log, events) =>
|
||||
FakeDaemonClient(log: log, events: events),
|
||||
daemonClientFactory: (log, events) => FakeDaemonClient(log: log, events: events),
|
||||
autoStartDaemonClient: false,
|
||||
);
|
||||
services.extensions
|
||||
|
||||
@@ -14,8 +14,7 @@ import '../test/helpers/fake_ipc.dart';
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('theme.pick command opens modal; selecting dismisses it',
|
||||
(tester) async {
|
||||
testWidgets('theme.pick command opens modal; selecting dismisses it', (tester) async {
|
||||
final themes = [
|
||||
await const ThemeLoader().fromAsset(
|
||||
rootBundle,
|
||||
@@ -31,8 +30,7 @@ void main() {
|
||||
'builtin.theme-picker',
|
||||
'builtin.default-layout',
|
||||
],
|
||||
daemonClientFactory: (log, events) =>
|
||||
FakeDaemonClient(log: log, events: events),
|
||||
daemonClientFactory: (log, events) => FakeDaemonClient(log: log, events: events),
|
||||
autoStartDaemonClient: false,
|
||||
);
|
||||
services.extensions
|
||||
|
||||
+29
-44
@@ -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()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -308,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;
|
||||
@@ -338,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),
|
||||
@@ -435,9 +419,9 @@ class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> {
|
||||
widget.kernel.panels.activateTab(Slots.workspace, 'claude.primary');
|
||||
} else {
|
||||
widget.kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(
|
||||
path: picked,
|
||||
onDismiss: () => dismiss(),
|
||||
));
|
||||
path: picked,
|
||||
onDismiss: () => dismiss(),
|
||||
));
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -515,8 +499,7 @@ class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> {
|
||||
children: [
|
||||
_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),
|
||||
if (widget.kernel.project.isOpen) _ActionRow(label: 'Close Project', shortcut: '', tokens: tokens, onTap: _closeWorkspace),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -589,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),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -629,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 (_) {
|
||||
|
||||
@@ -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((_) => _spawnWhenReady());
|
||||
// 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;
|
||||
@@ -114,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) {
|
||||
@@ -148,7 +157,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
'cwd': repoRoot,
|
||||
'cols': _terminal.viewWidth,
|
||||
'rows': _terminal.viewHeight,
|
||||
'title': sessionName,
|
||||
'title': _sessionName,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setState(() {
|
||||
@@ -158,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;
|
||||
@@ -168,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;
|
||||
@@ -177,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) {
|
||||
@@ -200,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;
|
||||
@@ -218,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;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,10 @@ 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());
|
||||
_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;
|
||||
@@ -43,7 +46,10 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (!mounted) return;
|
||||
if (_refreshing) { _pendingRefresh = true; return; }
|
||||
if (_refreshing) {
|
||||
_pendingRefresh = true;
|
||||
return;
|
||||
}
|
||||
_refreshing = true;
|
||||
_pendingRefresh = false;
|
||||
await _load();
|
||||
@@ -117,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();
|
||||
@@ -140,7 +155,8 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
child: ClideTappable(
|
||||
onTap: _refreshing ? null : _refresh,
|
||||
tooltip: 'Refresh decisions',
|
||||
builder: (ctx, hovered, _) => ClideIcon(PhosphorIcons.arrowClockwise, size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
builder: (ctx, hovered, _) =>
|
||||
ClideIcon(PhosphorIcons.arrowClockwise, size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -151,27 +167,45 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
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)],
|
||||
),
|
||||
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)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -233,8 +267,7 @@ class _DecisionCard extends StatelessWidget {
|
||||
const SizedBox(width: 6),
|
||||
ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
const Spacer(),
|
||||
if (entry.domain != null)
|
||||
ClideText(entry.domain!, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
if (entry.domain != null) ClideText(entry.domain!, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
@@ -32,6 +32,8 @@ class DecisionsExtension extends ClideExtension {
|
||||
icon: PhosphorIcons.lightbulb,
|
||||
build: (_) => DecisionDetailView(initialId: selectedId),
|
||||
));
|
||||
ctx.arrangement.setVisible(Slots.contextPanel, true);
|
||||
ctx.arrangement.setCollapsed(Slots.contextPanel, false);
|
||||
ctx.panels.activateTab(Slots.contextPanel, 'decisions.detail');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,9 +68,7 @@ class _DiffViewState extends State<DiffView> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
c.showStaged
|
||||
? 'No staged changes.'
|
||||
: 'No unstaged changes.',
|
||||
c.showStaged ? 'No staged changes.' : 'No unstaged changes.',
|
||||
muted: true,
|
||||
),
|
||||
),
|
||||
@@ -81,8 +79,7 @@ class _DiffViewState extends State<DiffView> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final diff in c.diffs)
|
||||
_FileDiff(diff: diff, controller: c),
|
||||
for (final diff in c.diffs) _FileDiff(diff: diff, controller: c),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -118,9 +115,7 @@ class _DiffToolbar extends StatelessWidget {
|
||||
child: ClideText(
|
||||
'Unstaged',
|
||||
fontSize: clideFontCaption,
|
||||
color: controller.showStaged
|
||||
? tokens.globalTextMuted
|
||||
: tokens.globalForeground,
|
||||
color: controller.showStaged ? tokens.globalTextMuted : tokens.globalForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -134,9 +129,7 @@ class _DiffToolbar extends StatelessWidget {
|
||||
child: ClideText(
|
||||
'Staged',
|
||||
fontSize: clideFontCaption,
|
||||
color: controller.showStaged
|
||||
? tokens.globalForeground
|
||||
: tokens.globalTextMuted,
|
||||
color: controller.showStaged ? tokens.globalForeground : tokens.globalTextMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -188,12 +181,8 @@ class _FileDiff extends StatelessWidget {
|
||||
color: tokens.panelHeaderForeground,
|
||||
),
|
||||
),
|
||||
if (additions > 0)
|
||||
ClideText('+$additions ', fontSize: clideFontCaption,
|
||||
color: tokens.statusSuccess),
|
||||
if (removals > 0)
|
||||
ClideText('-$removals', fontSize: clideFontCaption,
|
||||
color: tokens.statusError),
|
||||
if (additions > 0) ClideText('+$additions ', fontSize: clideFontCaption, color: tokens.statusSuccess),
|
||||
if (removals > 0) ClideText('-$removals', fontSize: clideFontCaption, color: tokens.statusError),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -17,8 +17,7 @@ import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class EditorController extends ChangeNotifier {
|
||||
EditorController({required this.ipc, required DaemonBus events})
|
||||
: _events = events {
|
||||
EditorController({required this.ipc, required DaemonBus events}) : _events = events {
|
||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||
}
|
||||
|
||||
@@ -77,9 +76,7 @@ class EditorController extends ChangeNotifier {
|
||||
_activePath = r.data['path']! as String;
|
||||
_content = (r.data['content'] as String?) ?? '';
|
||||
final sel = r.data['selection'];
|
||||
_selection = sel is Map
|
||||
? Selection.fromJson(sel.cast<String, Object?>())
|
||||
: const Selection.collapsed(0);
|
||||
_selection = sel is Map ? Selection.fromJson(sel.cast<String, Object?>()) : const Selection.collapsed(0);
|
||||
_dirty = (r.data['dirty'] as bool?) ?? false;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -80,29 +79,22 @@ class _EditorViewState extends State<EditorView> {
|
||||
final c = _controller;
|
||||
if (c == null || c.activeId == null) return;
|
||||
final value = _text.value;
|
||||
if (value.text == c.content &&
|
||||
value.selection.baseOffset == c.selection.start &&
|
||||
value.selection.extentOffset == c.selection.end) {
|
||||
if (value.text == c.content && value.selection.baseOffset == c.selection.start && value.selection.extentOffset == c.selection.end) {
|
||||
return;
|
||||
}
|
||||
_lastRemoteContent = value.text;
|
||||
c.pushLocalEdit(
|
||||
newContent: value.text,
|
||||
newSelection: Selection(
|
||||
start: value.selection.start < 0
|
||||
? value.text.length
|
||||
: value.selection.start,
|
||||
end: value.selection.end < 0
|
||||
? value.text.length
|
||||
: value.selection.end,
|
||||
start: value.selection.start < 0 ? value.text.length : value.selection.start,
|
||||
end: value.selection.end < 0 ? value.text.length : value.selection.end,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
final isCmd = HardwareKeyboard.instance.isMetaPressed ||
|
||||
HardwareKeyboard.instance.isControlPressed;
|
||||
final isCmd = HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed;
|
||||
if (isCmd && event.logicalKey == LogicalKeyboardKey.keyS) {
|
||||
unawaited(_controller?.save());
|
||||
return KeyEventResult.handled;
|
||||
|
||||
@@ -8,8 +8,7 @@ import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class SyntaxTextController extends TextEditingController {
|
||||
SyntaxTextController({required TreeSitterService syntax})
|
||||
: _syntax = syntax;
|
||||
SyntaxTextController({required TreeSitterService syntax}) : _syntax = syntax;
|
||||
|
||||
final TreeSitterService _syntax;
|
||||
|
||||
@@ -74,8 +73,7 @@ class SyntaxTextController extends TextEditingController {
|
||||
|
||||
// Convert byte offsets to character offsets.
|
||||
// Build a byte-to-char map only up to the max byte we need.
|
||||
final spans = _spans.where((s) => s.end <= sourceBytes.length).toList()
|
||||
..sort((a, b) => a.start != b.start ? a.start - b.start : a.end - b.end);
|
||||
final spans = _spans.where((s) => s.end <= sourceBytes.length).toList()..sort((a, b) => a.start != b.start ? a.start - b.start : a.end - b.end);
|
||||
|
||||
if (spans.isEmpty) {
|
||||
return TextSpan(text: text, style: style);
|
||||
@@ -121,9 +119,7 @@ class SyntaxTextController extends TextEditingController {
|
||||
continue;
|
||||
}
|
||||
final spanCharStart = byteToChar[span.start];
|
||||
final spanCharEnd = span.end <= maxByte
|
||||
? byteToChar[span.end]
|
||||
: source.length;
|
||||
final spanCharEnd = span.end <= maxByte ? byteToChar[span.end] : source.length;
|
||||
|
||||
if (spanCharStart < charPos) continue;
|
||||
|
||||
|
||||
@@ -136,8 +136,7 @@ class _Children extends StatelessWidget {
|
||||
controller: controller,
|
||||
depth: depth,
|
||||
),
|
||||
if (controller.isExpanded(e.path))
|
||||
_Children(path: e.path, controller: controller, depth: depth + 1),
|
||||
if (controller.isExpanded(e.path)) _Children(path: e.path, controller: controller, depth: depth + 1),
|
||||
],
|
||||
)
|
||||
else
|
||||
@@ -294,4 +293,3 @@ class _FilteredFileRow extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,94 +76,93 @@ class _GitPanelViewState extends State<GitPanelView> {
|
||||
child: Column(
|
||||
children: [
|
||||
ClideFilterBox(hint: 'Filter changes…', onChanged: (v) => setState(() => _filter = v)),
|
||||
Expanded(child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_BranchHeader(controller: c),
|
||||
if (c.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 4),
|
||||
child: ClideText(
|
||||
c.error!,
|
||||
color: tokens.statusError,
|
||||
fontSize: clideFontCaption,
|
||||
maxLines: 3,
|
||||
),
|
||||
),
|
||||
if (c.loading && c.isClean)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true),
|
||||
),
|
||||
if (!c.loading && c.isClean && c.error == null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Nothing to commit, working tree clean.',
|
||||
muted: true),
|
||||
),
|
||||
if (c.conflicted.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Merge conflicts',
|
||||
entries: _applyFilter(c.conflicted),
|
||||
actions: const [],
|
||||
),
|
||||
if (c.staged.isNotEmpty) ...[
|
||||
_FileGroup(
|
||||
label: 'Staged',
|
||||
entries: _applyFilter(c.staged),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Unstage all',
|
||||
onTap: () => unawaited(c.unstage(const [])),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_BranchHeader(controller: c),
|
||||
if (c.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ClideText(
|
||||
c.error!,
|
||||
color: tokens.statusError,
|
||||
fontSize: clideFontCaption,
|
||||
maxLines: 3,
|
||||
),
|
||||
),
|
||||
if (c.loading && c.isClean)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true),
|
||||
),
|
||||
if (!c.loading && c.isClean && c.error == null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Nothing to commit, working tree clean.', muted: true),
|
||||
),
|
||||
if (c.conflicted.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Merge conflicts',
|
||||
entries: _applyFilter(c.conflicted),
|
||||
actions: const [],
|
||||
),
|
||||
if (c.staged.isNotEmpty) ...[
|
||||
_FileGroup(
|
||||
label: 'Staged',
|
||||
entries: _applyFilter(c.staged),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Unstage all',
|
||||
onTap: () => unawaited(c.unstage(const [])),
|
||||
),
|
||||
],
|
||||
onUnstage: (path) => unawaited(c.unstage([path])),
|
||||
),
|
||||
_CommitInput(
|
||||
commitMsg: _commitMsg,
|
||||
commitFocus: _commitFocus,
|
||||
controller: c,
|
||||
),
|
||||
],
|
||||
onUnstage: (path) => unawaited(c.unstage([path])),
|
||||
),
|
||||
_CommitInput(
|
||||
commitMsg: _commitMsg,
|
||||
commitFocus: _commitFocus,
|
||||
controller: c,
|
||||
),
|
||||
],
|
||||
if (c.unstaged.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Changes',
|
||||
entries: _applyFilter(c.unstaged),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Stage all',
|
||||
onTap: () => unawaited(c.stageAll()),
|
||||
if (c.unstaged.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Changes',
|
||||
entries: _applyFilter(c.unstaged),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Stage all',
|
||||
onTap: () => unawaited(c.stageAll()),
|
||||
),
|
||||
],
|
||||
onStage: (path) => unawaited(c.stage([path])),
|
||||
onDiscard: (path) => _confirmDiscard(context, c, path),
|
||||
),
|
||||
],
|
||||
onStage: (path) => unawaited(c.stage([path])),
|
||||
onDiscard: (path) => _confirmDiscard(context, c, path),
|
||||
),
|
||||
if (c.untracked.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Untracked',
|
||||
entries: _applyFilter(c.untracked),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Stage all',
|
||||
onTap: () {
|
||||
final paths = [
|
||||
for (final e in c.untracked) e['path'] as String,
|
||||
];
|
||||
unawaited(c.stage(paths));
|
||||
},
|
||||
if (c.untracked.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Untracked',
|
||||
entries: _applyFilter(c.untracked),
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Stage all',
|
||||
onTap: () {
|
||||
final paths = [
|
||||
for (final e in c.untracked) e['path'] as String,
|
||||
];
|
||||
unawaited(c.stage(paths));
|
||||
},
|
||||
),
|
||||
],
|
||||
onStage: (path) => unawaited(c.stage([path])),
|
||||
),
|
||||
],
|
||||
onStage: (path) => unawaited(c.stage([path])),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -369,8 +368,7 @@ class _GitFileRow extends StatelessWidget {
|
||||
},
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.sidebarItemHover : null,
|
||||
padding: const EdgeInsets.only(
|
||||
left: 20, right: 8, top: 2, bottom: 2),
|
||||
padding: const EdgeInsets.only(left: 20, right: 8, top: 2, bottom: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(
|
||||
|
||||
@@ -140,8 +140,7 @@ class _BranchPickerState extends State<_BranchPicker> {
|
||||
_loading = false;
|
||||
if (r.ok) {
|
||||
_branches = [
|
||||
for (final b in (r.data['branches'] as List? ?? const []))
|
||||
(b as Map).cast<String, Object?>(),
|
||||
for (final b in (r.data['branches'] as List? ?? const [])) (b as Map).cast<String, Object?>(),
|
||||
];
|
||||
} else {
|
||||
_error = r.error?.message ?? 'failed to load branches';
|
||||
@@ -197,10 +196,8 @@ class _BranchPickerState extends State<_BranchPicker> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_loading)
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
|
||||
if (_error != null)
|
||||
Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true)),
|
||||
if (_loading) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
|
||||
if (_error != null) Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true)),
|
||||
if (!_loading && _error == null && _branches.isEmpty)
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('No branches found.', muted: true)),
|
||||
if (_branches.isNotEmpty)
|
||||
@@ -266,9 +263,7 @@ class _BranchRow extends StatelessWidget {
|
||||
name,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontSize: clideFontMono,
|
||||
color: current
|
||||
? tokens.globalForeground
|
||||
: tokens.listItemForeground,
|
||||
color: current ? tokens.globalForeground : tokens.listItemForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -43,7 +43,10 @@ class _PqlPanelViewState extends State<PqlPanelView> {
|
||||
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((_) {
|
||||
_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());
|
||||
}
|
||||
@@ -83,8 +86,7 @@ class _PqlPanelViewState extends State<PqlPanelView> {
|
||||
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) 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(
|
||||
@@ -318,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(
|
||||
|
||||
@@ -55,8 +55,7 @@ class ProblemsController extends ChangeNotifier {
|
||||
}
|
||||
final skill = (doctor.data['skill'] as Map?)?.cast<String, Object?>();
|
||||
if (skill != null) {
|
||||
final project =
|
||||
(skill['project'] as Map?)?.cast<String, Object?>();
|
||||
final project = (skill['project'] as Map?)?.cast<String, Object?>();
|
||||
if (project != null) {
|
||||
final state = project['state'] as String?;
|
||||
if (state == 'stale') {
|
||||
|
||||
@@ -49,7 +49,8 @@ class _ProblemsViewState extends State<ProblemsView> {
|
||||
explicitChildNodes: true,
|
||||
child: () {
|
||||
final lf = _filter.toLowerCase();
|
||||
final filtered = lf.isEmpty ? c.problems : c.problems.where((p) => p.message.toLowerCase().contains(lf) || p.source.toLowerCase().contains(lf)).toList();
|
||||
final filtered =
|
||||
lf.isEmpty ? c.problems : c.problems.where((p) => p.message.toLowerCase().contains(lf) || p.source.toLowerCase().contains(lf)).toList();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -64,16 +65,15 @@ class _ProblemsViewState extends State<ProblemsView> {
|
||||
label: 'refresh problems',
|
||||
child: GestureDetector(
|
||||
onTap: () => unawaited(c.refresh()),
|
||||
child: MouseRegion(cursor: SystemMouseCursors.click, child: ClideText('Refresh', fontSize: clideFontCaption, color: tokens.sidebarForeground)),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click, child: ClideText('Refresh', fontSize: clideFontCaption, color: tokens.sidebarForeground)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (c.loading && c.problems.isEmpty)
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('Scanning…', muted: true)),
|
||||
if (!c.loading && filtered.isEmpty)
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('No problems found.', muted: true)),
|
||||
if (c.loading && c.problems.isEmpty) const Padding(padding: EdgeInsets.all(12), child: ClideText('Scanning…', muted: true)),
|
||||
if (!c.loading && filtered.isEmpty) const Padding(padding: EdgeInsets.all(12), child: ClideText('No problems found.', muted: true)),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
|
||||
@@ -139,9 +139,7 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final subtitle = _error != null
|
||||
? _error!
|
||||
: (_paneId == null ? 'spawning shell…' : 'pid $_pid · ${_paneId!}');
|
||||
final subtitle = _error != null ? _error! : (_paneId == null ? 'spawning shell…' : 'pid $_pid · ${_paneId!}');
|
||||
|
||||
return ClidePaneChrome(
|
||||
title: 'terminal',
|
||||
|
||||
@@ -31,8 +31,7 @@ class _ThemePickerViewState extends State<ThemePickerView> {
|
||||
|
||||
return Semantics(
|
||||
container: true,
|
||||
label: i.string('modal.title',
|
||||
namespace: ThemePickerView.ns, placeholder: 'Select theme'),
|
||||
label: i.string('modal.title', namespace: ThemePickerView.ns, placeholder: 'Select theme'),
|
||||
explicitChildNodes: true,
|
||||
child: ClideSurface(
|
||||
width: 420,
|
||||
@@ -45,8 +44,7 @@ class _ThemePickerViewState extends State<ThemePickerView> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideText(
|
||||
i.string('modal.title',
|
||||
namespace: ThemePickerView.ns, placeholder: 'Select theme'),
|
||||
i.string('modal.title', namespace: ThemePickerView.ns, placeholder: 'Select theme'),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
@@ -65,9 +63,7 @@ class _ThemePickerViewState extends State<ThemePickerView> {
|
||||
displayName: t.displayName,
|
||||
selected: t.name == currentName,
|
||||
hovered: _hovered == t.name,
|
||||
hint: i.string('row.select.hint',
|
||||
namespace: ThemePickerView.ns,
|
||||
placeholder: 'Activate this theme'),
|
||||
hint: i.string('row.select.hint', namespace: ThemePickerView.ns, placeholder: 'Activate this theme'),
|
||||
onEnter: () => setState(() => _hovered = t.name),
|
||||
onExit: () => setState(() => _hovered = null),
|
||||
onTap: () {
|
||||
@@ -84,12 +80,9 @@ class _ThemePickerViewState extends State<ThemePickerView> {
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ClideButton(
|
||||
label: i.string('modal.cancel',
|
||||
namespace: ThemePickerView.ns, placeholder: 'Cancel'),
|
||||
semanticHint: i.string('modal.cancel.hint',
|
||||
namespace: ThemePickerView.ns,
|
||||
placeholder:
|
||||
'Close the theme picker without changing the current theme'),
|
||||
label: i.string('modal.cancel', namespace: ThemePickerView.ns, placeholder: 'Cancel'),
|
||||
semanticHint:
|
||||
i.string('modal.cancel.hint', namespace: ThemePickerView.ns, placeholder: 'Close the theme picker without changing the current theme'),
|
||||
onPressed: () => widget.onDismiss(),
|
||||
),
|
||||
],
|
||||
@@ -125,14 +118,8 @@ class _ThemeRow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final bg = selected
|
||||
? tokens.listItemSelectedBackground
|
||||
: (hovered
|
||||
? tokens.listItemHoverBackground
|
||||
: tokens.listItemBackground);
|
||||
final fg = selected
|
||||
? tokens.listItemSelectedForeground
|
||||
: tokens.listItemForeground;
|
||||
final bg = selected ? tokens.listItemSelectedBackground : (hovered ? tokens.listItemHoverBackground : tokens.listItemBackground);
|
||||
final fg = selected ? tokens.listItemSelectedForeground : tokens.listItemForeground;
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
|
||||
@@ -32,6 +32,8 @@ class TicketsExtension extends ClideExtension {
|
||||
icon: PhosphorIcons.ticket,
|
||||
build: (_) => TicketDetailView(initialId: selectedId),
|
||||
));
|
||||
ctx.arrangement.setVisible(Slots.contextPanel, true);
|
||||
ctx.arrangement.setCollapsed(Slots.contextPanel, false);
|
||||
ctx.panels.activateTab(Slots.contextPanel, 'tickets.detail');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,6 +40,5 @@ class TicketTypeColors {
|
||||
bug: Color(0xFFC03030),
|
||||
);
|
||||
|
||||
static TicketTypeColors forTheme({required bool dark}) =>
|
||||
dark ? TicketTypeColors.dark : TicketTypeColors.light;
|
||||
static TicketTypeColors forTheme({required bool dark}) => dark ? TicketTypeColors.dark : TicketTypeColors.light;
|
||||
}
|
||||
|
||||
@@ -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,13 +152,18 @@ class _StatusControls extends StatelessWidget {
|
||||
for (final s in _statuses) ...[
|
||||
Expanded(
|
||||
child: ClideTappable(
|
||||
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);
|
||||
}
|
||||
},
|
||||
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);
|
||||
|
||||
@@ -88,7 +88,10 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (!mounted) return;
|
||||
if (_refreshing) { _pendingRefresh = true; return; }
|
||||
if (_refreshing) {
|
||||
_pendingRefresh = true;
|
||||
return;
|
||||
}
|
||||
_refreshing = true;
|
||||
_pendingRefresh = false;
|
||||
await _load();
|
||||
@@ -130,7 +133,11 @@ 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;
|
||||
|
||||
const sections = [
|
||||
('in_progress', 'IN PROGRESS'),
|
||||
@@ -159,7 +166,8 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
child: ClideTappable(
|
||||
onTap: _refreshing ? null : _refresh,
|
||||
tooltip: 'Refresh tickets',
|
||||
builder: (ctx, hovered, _) => ClideIcon(PhosphorIcons.arrowClockwise, size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
builder: (ctx, hovered, _) =>
|
||||
ClideIcon(PhosphorIcons.arrowClockwise, size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -115,9 +115,9 @@ class _StartColumn extends StatelessWidget {
|
||||
kernel.panels.activateTab(Slots.workspace, 'claude.primary');
|
||||
} else {
|
||||
kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(
|
||||
path: picked,
|
||||
onDismiss: () => dismiss(),
|
||||
));
|
||||
path: picked,
|
||||
onDismiss: () => dismiss(),
|
||||
));
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -163,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),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -191,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)),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -232,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),
|
||||
@@ -336,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 (_) {
|
||||
|
||||
+1
-2
@@ -23,8 +23,7 @@ export 'src/files/listing.dart' show FileEntry, listDir;
|
||||
export 'src/git/diff.dart' show GitDiff, GitHunk, DiffLine, DiffLineKind;
|
||||
export 'src/git/client.dart' show GitClient;
|
||||
export 'src/git/operations.dart' show GitLogEntry, GitException;
|
||||
export 'src/git/status.dart'
|
||||
show GitStatus, GitFileStatus, GitFileState, GitConflictType;
|
||||
export 'src/git/status.dart' show GitStatus, GitFileStatus, GitFileState, GitConflictType;
|
||||
export 'src/pql/client.dart' show PqlClient, PqlException;
|
||||
export 'src/ipc/envelope.dart';
|
||||
export 'src/ipc/paths.dart';
|
||||
|
||||
@@ -91,8 +91,7 @@ extension ClideExtensionContextMessages on ClideExtensionContext {
|
||||
extension ClideExtensionContextI18n on ClideExtensionContext {
|
||||
/// `ctx.t('welcome.title', placeholder: 'clide')` →
|
||||
/// `i18n.string('welcome.title', namespace: id, placeholder: 'clide')`.
|
||||
String t(String key, {String? placeholder}) =>
|
||||
i18n.string(key, namespace: id, placeholder: placeholder);
|
||||
String t(String key, {String? placeholder}) => i18n.string(key, namespace: id, placeholder: placeholder);
|
||||
|
||||
/// [t] with interpolation replacers.
|
||||
String tr(
|
||||
|
||||
@@ -55,6 +55,5 @@ class ExtensionManifest {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<ExtensionManifest> fromFile(File f) async =>
|
||||
ExtensionManifest.fromYamlString(await f.readAsString());
|
||||
static Future<ExtensionManifest> fromFile(File f) async => ExtensionManifest.fromYamlString(await f.readAsString());
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ import 'package:clide/src/pql/client.dart';
|
||||
class BackendBootMessage {
|
||||
const BackendBootMessage({required this.frontendPort, this.hintRoot});
|
||||
final SendPort frontendPort;
|
||||
/// Optional path hint for initial toolchain resolution (e.g. CLIDE_WORKSPACE).
|
||||
|
||||
/// 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;
|
||||
}
|
||||
@@ -61,8 +62,7 @@ void backendEntry(BackendBootMessage boot) {
|
||||
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);
|
||||
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});
|
||||
@@ -98,7 +98,7 @@ void backendEntry(BackendBootMessage boot) {
|
||||
registerPqlCommands(dispatcher, pql);
|
||||
|
||||
final paneRegistry = PaneRegistry(events: eventSink);
|
||||
registerPaneCommands(dispatcher, paneRegistry, toolchain: toolchain);
|
||||
registerPaneCommands(dispatcher, paneRegistry);
|
||||
|
||||
// Tell the frontend the project is active.
|
||||
frontendPort.send({
|
||||
|
||||
@@ -22,8 +22,7 @@ class ClideClipboard {
|
||||
bucket.insert(0, value);
|
||||
if (bucket.length > historyLimit) bucket.removeLast();
|
||||
if (toPlain != null) {
|
||||
await flutter_services.Clipboard.setData(
|
||||
flutter_services.ClipboardData(text: toPlain(value)));
|
||||
await flutter_services.Clipboard.setData(flutter_services.ClipboardData(text: toPlain(value)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +44,7 @@ class ClideClipboard {
|
||||
}
|
||||
|
||||
Future<void> writePlain(String text) async {
|
||||
await flutter_services.Clipboard.setData(
|
||||
flutter_services.ClipboardData(text: text));
|
||||
await flutter_services.Clipboard.setData(flutter_services.ClipboardData(text: text));
|
||||
final bucket = _history.putIfAbsent(String, () => <Object>[]);
|
||||
bucket.insert(0, text);
|
||||
if (bucket.length > historyLimit) bucket.removeLast();
|
||||
|
||||
@@ -36,10 +36,7 @@ class Keybinding {
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is Keybinding &&
|
||||
other.key == key &&
|
||||
listEquals(other.modifiers, modifiers);
|
||||
bool operator ==(Object other) => other is Keybinding && other.key == key && listEquals(other.modifiers, modifiers);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(key, Object.hashAll(modifiers));
|
||||
|
||||
@@ -5,13 +5,11 @@ import 'package:clide/kernel/src/events/types.dart';
|
||||
class DaemonBus {
|
||||
DaemonBus();
|
||||
|
||||
final StreamController<ClideEventEnvelope> _controller =
|
||||
StreamController<ClideEventEnvelope>.broadcast();
|
||||
final StreamController<ClideEventEnvelope> _controller = StreamController<ClideEventEnvelope>.broadcast();
|
||||
|
||||
Stream<ClideEventEnvelope> get stream => _controller.stream;
|
||||
|
||||
Stream<T> on<T extends ClideEvent>() =>
|
||||
_controller.stream.where((e) => e.event is T).map((e) => e.event as T);
|
||||
Stream<T> on<T extends ClideEvent>() => _controller.stream.where((e) => e.event is T).map((e) => e.event as T);
|
||||
|
||||
void emit(ClideEvent event) {
|
||||
if (_controller.isClosed) return;
|
||||
|
||||
@@ -124,8 +124,7 @@ class ExtensionManager extends ChangeNotifier {
|
||||
}
|
||||
for (final dep in ext.dependsOn) {
|
||||
if (!_activated.contains(dep)) {
|
||||
log.warn(
|
||||
'extensions', 'skipping ${ext.id}: dependency not activated: $dep');
|
||||
log.warn('extensions', 'skipping ${ext.id}: dependency not activated: $dep');
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -140,8 +139,7 @@ class ExtensionManager extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
log.info('extensions', 'activated $id');
|
||||
} catch (e, st) {
|
||||
log.error('extensions', 'activate failed for $id',
|
||||
error: e, stackTrace: st);
|
||||
log.error('extensions', 'activate failed for $id', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,8 +157,7 @@ class ExtensionManager extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
log.info('extensions', 'deactivated $id');
|
||||
} catch (e, st) {
|
||||
log.error('extensions', 'deactivate failed for $id',
|
||||
error: e, stackTrace: st);
|
||||
log.error('extensions', 'deactivate failed for $id', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,8 +154,8 @@ class KernelServices {
|
||||
onProjectOpen: onProjectOpen,
|
||||
onValidateProject: onValidateProject,
|
||||
);
|
||||
final ipc = isolateClient
|
||||
?? (daemonClientFactory != null
|
||||
final ipc = isolateClient ??
|
||||
(daemonClientFactory != null
|
||||
? daemonClientFactory(log, events)
|
||||
: DaemonClient(
|
||||
socketPath: socketPath ?? defaultSocketPath(),
|
||||
@@ -257,13 +257,11 @@ class ClideKernel extends InheritedWidget {
|
||||
static KernelServices of(BuildContext context) {
|
||||
final w = context.dependOnInheritedWidgetOfExactType<ClideKernel>();
|
||||
if (w == null) {
|
||||
throw FlutterError(
|
||||
'ClideKernel.of() called with a context that is not a descendant of a ClideKernel.');
|
||||
throw FlutterError('ClideKernel.of() called with a context that is not a descendant of a ClideKernel.');
|
||||
}
|
||||
return w.services;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(ClideKernel oldWidget) =>
|
||||
services != oldWidget.services;
|
||||
bool updateShouldNotify(ClideKernel oldWidget) => services != oldWidget.services;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,5 @@ class InMemoryCatalogLoader implements CatalogLoader {
|
||||
return const {};
|
||||
}
|
||||
|
||||
static bool _eq(Locale a, Locale b) =>
|
||||
a.languageCode == b.languageCode && a.countryCode == b.countryCode;
|
||||
static bool _eq(Locale a, Locale b) => a.languageCode == b.languageCode && a.countryCode == b.countryCode;
|
||||
}
|
||||
|
||||
@@ -55,8 +55,7 @@ class I18n extends ChangeNotifier {
|
||||
Locale locale,
|
||||
Map<String, Object?> catalog,
|
||||
) {
|
||||
_cache.putIfAbsent(
|
||||
namespace, () => <Locale, Map<String, Object?>>{})[locale] = catalog;
|
||||
_cache.putIfAbsent(namespace, () => <Locale, Map<String, Object?>>{})[locale] = catalog;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -79,11 +79,7 @@ class DaemonClient extends ChangeNotifier {
|
||||
_backoff = const Duration(milliseconds: 200);
|
||||
_setConnected(true);
|
||||
_log.info('ipc', 'connected to $socketPath');
|
||||
socket
|
||||
.cast<List<int>>()
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen(
|
||||
socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).listen(
|
||||
_handleLine,
|
||||
onDone: _handleDisconnect,
|
||||
onError: (Object e) {
|
||||
@@ -93,8 +89,7 @@ class DaemonClient extends ChangeNotifier {
|
||||
cancelOnError: true,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.debug(
|
||||
'ipc', 'connect failed ($e); retry in ${_backoff.inMilliseconds}ms');
|
||||
_log.debug('ipc', 'connect failed ($e); retry in ${_backoff.inMilliseconds}ms');
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
+10
-20
@@ -23,8 +23,7 @@ class LogRecord {
|
||||
@override
|
||||
String toString() {
|
||||
final lv = level.name.toUpperCase().padRight(5);
|
||||
final buf =
|
||||
StringBuffer('${timestamp.toIso8601String()} $lv [$source] $message');
|
||||
final buf = StringBuffer('${timestamp.toIso8601String()} $lv [$source] $message');
|
||||
if (error != null) buf.write(' | error=$error');
|
||||
return buf.toString();
|
||||
}
|
||||
@@ -33,33 +32,24 @@ class LogRecord {
|
||||
typedef LogSink = void Function(LogRecord);
|
||||
|
||||
class Logger {
|
||||
Logger({this.minLevel = LogLevel.info, List<LogSink>? sinks})
|
||||
: _sinks = List<LogSink>.from(sinks ?? <LogSink>[stderrSink]);
|
||||
Logger({this.minLevel = LogLevel.info, List<LogSink>? sinks}) : _sinks = List<LogSink>.from(sinks ?? <LogSink>[stderrSink]);
|
||||
|
||||
LogLevel minLevel;
|
||||
final List<LogSink> _sinks;
|
||||
final StreamController<LogRecord> _stream =
|
||||
StreamController<LogRecord>.broadcast();
|
||||
final StreamController<LogRecord> _stream = StreamController<LogRecord>.broadcast();
|
||||
|
||||
Stream<LogRecord> get records => _stream.stream;
|
||||
|
||||
void addSink(LogSink sink) => _sinks.add(sink);
|
||||
|
||||
void trace(String source, String message) =>
|
||||
_emit(LogLevel.trace, source, message);
|
||||
void debug(String source, String message) =>
|
||||
_emit(LogLevel.debug, source, message);
|
||||
void info(String source, String message) =>
|
||||
_emit(LogLevel.info, source, message);
|
||||
void warn(String source, String message, {Object? error}) =>
|
||||
_emit(LogLevel.warn, source, message, error: error);
|
||||
void error(String source, String message,
|
||||
{Object? error, StackTrace? stackTrace}) =>
|
||||
_emit(LogLevel.error, source, message,
|
||||
error: error, stackTrace: stackTrace);
|
||||
void trace(String source, String message) => _emit(LogLevel.trace, source, message);
|
||||
void debug(String source, String message) => _emit(LogLevel.debug, source, message);
|
||||
void info(String source, String message) => _emit(LogLevel.info, source, message);
|
||||
void warn(String source, String message, {Object? error}) => _emit(LogLevel.warn, source, message, error: error);
|
||||
void error(String source, String message, {Object? error, StackTrace? stackTrace}) =>
|
||||
_emit(LogLevel.error, source, message, error: error, stackTrace: stackTrace);
|
||||
|
||||
void _emit(LogLevel level, String source, String message,
|
||||
{Object? error, StackTrace? stackTrace}) {
|
||||
void _emit(LogLevel level, String source, String message, {Object? error, StackTrace? stackTrace}) {
|
||||
if (level.index < minLevel.index) return;
|
||||
final rec = LogRecord(
|
||||
level: level,
|
||||
|
||||
@@ -29,16 +29,10 @@ class Notifications extends ChangeNotifier {
|
||||
|
||||
List<ClideNotification> get active => List.unmodifiable(_active);
|
||||
|
||||
void info(String message, {String? title, Duration? duration}) =>
|
||||
_push(NotificationLevel.info, message, title: title, duration: duration);
|
||||
void warn(String message, {String? title, Duration? duration}) =>
|
||||
_push(NotificationLevel.warning, message,
|
||||
title: title, duration: duration);
|
||||
void error(String message, {String? title, Duration? duration}) =>
|
||||
_push(NotificationLevel.error, message, title: title, duration: duration);
|
||||
void success(String message, {String? title, Duration? duration}) =>
|
||||
_push(NotificationLevel.success, message,
|
||||
title: title, duration: duration);
|
||||
void info(String message, {String? title, Duration? duration}) => _push(NotificationLevel.info, message, title: title, duration: duration);
|
||||
void warn(String message, {String? title, Duration? duration}) => _push(NotificationLevel.warning, message, title: title, duration: duration);
|
||||
void error(String message, {String? title, Duration? duration}) => _push(NotificationLevel.error, message, title: title, duration: duration);
|
||||
void success(String message, {String? title, Duration? duration}) => _push(NotificationLevel.success, message, title: title, duration: duration);
|
||||
|
||||
void dismiss(String id) {
|
||||
_timers.remove(id)?.cancel();
|
||||
|
||||
@@ -37,9 +37,7 @@ class _DragResizeHandleState extends State<DragResizeHandle> {
|
||||
final lineColor = _hovered ? tokens.panelActiveBorder : tokens.dividerColor;
|
||||
|
||||
return MouseRegion(
|
||||
cursor: widget.axis == Axis.horizontal
|
||||
? SystemMouseCursors.resizeColumn
|
||||
: SystemMouseCursors.resizeRow,
|
||||
cursor: widget.axis == Axis.horizontal ? SystemMouseCursors.resizeColumn : SystemMouseCursors.resizeRow,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Listener(
|
||||
@@ -72,9 +70,7 @@ class _DragResizeHandleState extends State<DragResizeHandle> {
|
||||
final start = _dragStartSize;
|
||||
final startPt = _dragStartPointer;
|
||||
if (start == null || startPt == null) return;
|
||||
final rawDelta = widget.axis == Axis.horizontal
|
||||
? e.position.dx - startPt.dx
|
||||
: e.position.dy - startPt.dy;
|
||||
final rawDelta = widget.axis == Axis.horizontal ? e.position.dx - startPt.dx : e.position.dy - startPt.dy;
|
||||
final delta = widget.slot == Slots.contextPanel ? -rawDelta : rawDelta;
|
||||
widget.arrangement.setSize(widget.slot, start + delta);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -96,7 +96,7 @@ class ProjectManager extends ChangeNotifier {
|
||||
|
||||
// Tell the backend isolate to (re)initialize services for this workspace.
|
||||
if (_onProjectOpen != null) {
|
||||
await _onProjectOpen!(root);
|
||||
await _onProjectOpen(root);
|
||||
}
|
||||
|
||||
await _settings.setProjectDir(_current);
|
||||
@@ -135,7 +135,7 @@ class ProjectManager extends ChangeNotifier {
|
||||
// the merged UI thread). Falls back to direct Process.run for tests
|
||||
// and the CLI binary.
|
||||
if (_onValidateProject != null) {
|
||||
return _onValidateProject!(path);
|
||||
return _onValidateProject(path);
|
||||
}
|
||||
try {
|
||||
final r = await Process.run(_toolchain.git, ['rev-parse', '--show-toplevel'], workingDirectory: path, environment: _toolchain.gitEnv);
|
||||
|
||||
@@ -50,10 +50,14 @@ class SchedulerService {
|
||||
void _startTicker() {
|
||||
_stopTicker();
|
||||
|
||||
// Immediate first tick for all tiers.
|
||||
// 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;
|
||||
_events.emit(SchedulerTick(tier: tier));
|
||||
Timer(Duration(milliseconds: delay), () {
|
||||
_events.emit(SchedulerTick(tier: tier));
|
||||
});
|
||||
delay += 500;
|
||||
}
|
||||
|
||||
// Then start the periodic isolate.
|
||||
|
||||
@@ -53,9 +53,7 @@ class SettingsStore extends ChangeNotifier {
|
||||
return _projectValues[key];
|
||||
case SettingsScope.ext:
|
||||
// project overrides app for the same ext.* key
|
||||
return _projectValues.containsKey(key)
|
||||
? _projectValues[key]
|
||||
: _appValues[key];
|
||||
return _projectValues.containsKey(key) ? _projectValues[key] : _appValues[key];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +64,7 @@ class SettingsStore extends ChangeNotifier {
|
||||
await _writeFile(_appFile, _appValues);
|
||||
case SettingsScope.project:
|
||||
if (projectDir == null) {
|
||||
throw StateError(
|
||||
'Cannot set project-scoped key with no project open: $key');
|
||||
throw StateError('Cannot set project-scoped key with no project open: $key');
|
||||
}
|
||||
_projectValues[key] = value;
|
||||
await _writeFile(_projectFile, _projectValues);
|
||||
@@ -109,8 +106,7 @@ class SettingsStore extends ChangeNotifier {
|
||||
if (key.startsWith('app.')) return SettingsScope.app;
|
||||
if (key.startsWith('project.')) return SettingsScope.project;
|
||||
if (key.startsWith('ext.')) return SettingsScope.ext;
|
||||
throw ArgumentError(
|
||||
'Settings key must start with app.|project.|ext.: "$key"');
|
||||
throw ArgumentError('Settings key must start with app.|project.|ext.: "$key"');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,15 @@ import 'package:ffi/ffi.dart';
|
||||
// -- Opaque handles ----------------------------------------------------------
|
||||
|
||||
final class TSParser extends Opaque {}
|
||||
|
||||
final class TSTree extends Opaque {}
|
||||
|
||||
final class TSQuery extends Opaque {}
|
||||
|
||||
final class TSQueryCursor extends Opaque {}
|
||||
|
||||
final class TSWasmStore extends Opaque {}
|
||||
|
||||
final class TSWasmEngine extends Opaque {}
|
||||
|
||||
// -- Structs -----------------------------------------------------------------
|
||||
@@ -51,10 +56,8 @@ final class TSWasmError extends Struct {
|
||||
typedef _TsParserNew = Pointer<TSParser> Function();
|
||||
typedef _TsParserDelete = Void Function(Pointer<TSParser>);
|
||||
typedef _TsParserSetLanguage = Bool Function(Pointer<TSParser>, Pointer<Void>);
|
||||
typedef _TsParserSetWasmStore = Void Function(
|
||||
Pointer<TSParser>, Pointer<TSWasmStore>);
|
||||
typedef _TsParserParseString = Pointer<TSTree> Function(
|
||||
Pointer<TSParser>, Pointer<TSTree>, Pointer<Utf8>, Uint32);
|
||||
typedef _TsParserSetWasmStore = Void Function(Pointer<TSParser>, Pointer<TSWasmStore>);
|
||||
typedef _TsParserParseString = Pointer<TSTree> Function(Pointer<TSParser>, Pointer<TSTree>, Pointer<Utf8>, Uint32);
|
||||
|
||||
// Tree
|
||||
typedef _TsTreeDelete = Void Function(Pointer<TSTree>);
|
||||
@@ -65,28 +68,21 @@ typedef _TsNodeStartByte = Uint32 Function(TSNode);
|
||||
typedef _TsNodeEndByte = Uint32 Function(TSNode);
|
||||
|
||||
// Query
|
||||
typedef _TsQueryNew = Pointer<TSQuery> Function(
|
||||
Pointer<Void>, Pointer<Utf8>, Uint32, Pointer<Uint32>, Pointer<Int32>);
|
||||
typedef _TsQueryNew = Pointer<TSQuery> Function(Pointer<Void>, Pointer<Utf8>, Uint32, Pointer<Uint32>, Pointer<Int32>);
|
||||
typedef _TsQueryDelete = Void Function(Pointer<TSQuery>);
|
||||
typedef _TsQueryCaptureCount = Uint32 Function(Pointer<TSQuery>);
|
||||
typedef _TsQueryCaptureNameForId = Pointer<Utf8> Function(
|
||||
Pointer<TSQuery>, Uint32, Pointer<Uint32>);
|
||||
typedef _TsQueryCaptureNameForId = Pointer<Utf8> Function(Pointer<TSQuery>, Uint32, Pointer<Uint32>);
|
||||
|
||||
// Query cursor
|
||||
typedef _TsQueryCursorNew = Pointer<TSQueryCursor> Function();
|
||||
typedef _TsQueryCursorDelete = Void Function(Pointer<TSQueryCursor>);
|
||||
typedef _TsQueryCursorExec = Void Function(
|
||||
Pointer<TSQueryCursor>, Pointer<TSQuery>, TSNode);
|
||||
typedef _TsQueryCursorNextMatch = Bool Function(
|
||||
Pointer<TSQueryCursor>, Pointer<TSQueryMatch>);
|
||||
typedef _TsQueryCursorExec = Void Function(Pointer<TSQueryCursor>, Pointer<TSQuery>, TSNode);
|
||||
typedef _TsQueryCursorNextMatch = Bool Function(Pointer<TSQueryCursor>, Pointer<TSQueryMatch>);
|
||||
|
||||
// WASM store
|
||||
typedef _TsWasmStoreNew = Pointer<TSWasmStore> Function(
|
||||
Pointer<TSWasmEngine>, Pointer<TSWasmError>);
|
||||
typedef _TsWasmStoreNew = Pointer<TSWasmStore> Function(Pointer<TSWasmEngine>, Pointer<TSWasmError>);
|
||||
typedef _TsWasmStoreDelete = Void Function(Pointer<TSWasmStore>);
|
||||
typedef _TsWasmStoreLoadLanguage = Pointer<Void> Function(
|
||||
Pointer<TSWasmStore>, Pointer<Utf8>, Pointer<Uint8>, Uint32,
|
||||
Pointer<TSWasmError>);
|
||||
typedef _TsWasmStoreLoadLanguage = Pointer<Void> Function(Pointer<TSWasmStore>, Pointer<Utf8>, Pointer<Uint8>, Uint32, Pointer<TSWasmError>);
|
||||
|
||||
// WASM engine (from wasmtime C API, re-exported by tree-sitter)
|
||||
typedef _WasmEngineNew = Pointer<TSWasmEngine> Function();
|
||||
@@ -97,10 +93,8 @@ typedef _WasmEngineDelete = Void Function(Pointer<TSWasmEngine>);
|
||||
typedef DTsParserNew = Pointer<TSParser> Function();
|
||||
typedef DTsParserDelete = void Function(Pointer<TSParser>);
|
||||
typedef DTsParserSetLanguage = bool Function(Pointer<TSParser>, Pointer<Void>);
|
||||
typedef DTsParserSetWasmStore = void Function(
|
||||
Pointer<TSParser>, Pointer<TSWasmStore>);
|
||||
typedef DTsParserParseString = Pointer<TSTree> Function(
|
||||
Pointer<TSParser>, Pointer<TSTree>, Pointer<Utf8>, int);
|
||||
typedef DTsParserSetWasmStore = void Function(Pointer<TSParser>, Pointer<TSWasmStore>);
|
||||
typedef DTsParserParseString = Pointer<TSTree> Function(Pointer<TSParser>, Pointer<TSTree>, Pointer<Utf8>, int);
|
||||
|
||||
typedef DTsTreeDelete = void Function(Pointer<TSTree>);
|
||||
typedef DTsTreeRootNode = TSNode Function(Pointer<TSTree>);
|
||||
@@ -108,26 +102,19 @@ typedef DTsTreeRootNode = TSNode Function(Pointer<TSTree>);
|
||||
typedef DTsNodeStartByte = int Function(TSNode);
|
||||
typedef DTsNodeEndByte = int Function(TSNode);
|
||||
|
||||
typedef DTsQueryNew = Pointer<TSQuery> Function(
|
||||
Pointer<Void>, Pointer<Utf8>, int, Pointer<Uint32>, Pointer<Int32>);
|
||||
typedef DTsQueryNew = Pointer<TSQuery> Function(Pointer<Void>, Pointer<Utf8>, int, Pointer<Uint32>, Pointer<Int32>);
|
||||
typedef DTsQueryDelete = void Function(Pointer<TSQuery>);
|
||||
typedef DTsQueryCaptureCount = int Function(Pointer<TSQuery>);
|
||||
typedef DTsQueryCaptureNameForId = Pointer<Utf8> Function(
|
||||
Pointer<TSQuery>, int, Pointer<Uint32>);
|
||||
typedef DTsQueryCaptureNameForId = Pointer<Utf8> Function(Pointer<TSQuery>, int, Pointer<Uint32>);
|
||||
|
||||
typedef DTsQueryCursorNew = Pointer<TSQueryCursor> Function();
|
||||
typedef DTsQueryCursorDelete = void Function(Pointer<TSQueryCursor>);
|
||||
typedef DTsQueryCursorExec = void Function(
|
||||
Pointer<TSQueryCursor>, Pointer<TSQuery>, TSNode);
|
||||
typedef DTsQueryCursorNextMatch = bool Function(
|
||||
Pointer<TSQueryCursor>, Pointer<TSQueryMatch>);
|
||||
typedef DTsQueryCursorExec = void Function(Pointer<TSQueryCursor>, Pointer<TSQuery>, TSNode);
|
||||
typedef DTsQueryCursorNextMatch = bool Function(Pointer<TSQueryCursor>, Pointer<TSQueryMatch>);
|
||||
|
||||
typedef DTsWasmStoreNew = Pointer<TSWasmStore> Function(
|
||||
Pointer<TSWasmEngine>, Pointer<TSWasmError>);
|
||||
typedef DTsWasmStoreNew = Pointer<TSWasmStore> Function(Pointer<TSWasmEngine>, Pointer<TSWasmError>);
|
||||
typedef DTsWasmStoreDelete = void Function(Pointer<TSWasmStore>);
|
||||
typedef DTsWasmStoreLoadLanguage = Pointer<Void> Function(
|
||||
Pointer<TSWasmStore>, Pointer<Utf8>, Pointer<Uint8>, int,
|
||||
Pointer<TSWasmError>);
|
||||
typedef DTsWasmStoreLoadLanguage = Pointer<Void> Function(Pointer<TSWasmStore>, Pointer<Utf8>, Pointer<Uint8>, int, Pointer<TSWasmError>);
|
||||
|
||||
typedef DWasmEngineNew = Pointer<TSWasmEngine> Function();
|
||||
typedef DWasmEngineDelete = void Function(Pointer<TSWasmEngine>);
|
||||
@@ -136,60 +123,28 @@ typedef DWasmEngineDelete = void Function(Pointer<TSWasmEngine>);
|
||||
|
||||
class TreeSitterLib {
|
||||
TreeSitterLib._(DynamicLibrary lib)
|
||||
: parserNew = lib.lookupFunction<_TsParserNew, DTsParserNew>(
|
||||
'ts_parser_new'),
|
||||
parserDelete = lib.lookupFunction<_TsParserDelete, DTsParserDelete>(
|
||||
'ts_parser_delete'),
|
||||
parserSetLanguage =
|
||||
lib.lookupFunction<_TsParserSetLanguage, DTsParserSetLanguage>(
|
||||
'ts_parser_set_language'),
|
||||
parserSetWasmStore =
|
||||
lib.lookupFunction<_TsParserSetWasmStore, DTsParserSetWasmStore>(
|
||||
'ts_parser_set_wasm_store'),
|
||||
parserParseString =
|
||||
lib.lookupFunction<_TsParserParseString, DTsParserParseString>(
|
||||
'ts_parser_parse_string'),
|
||||
treeDelete = lib.lookupFunction<_TsTreeDelete, DTsTreeDelete>(
|
||||
'ts_tree_delete'),
|
||||
treeRootNode = lib.lookupFunction<_TsTreeRootNode, DTsTreeRootNode>(
|
||||
'ts_tree_root_node'),
|
||||
nodeStartByte = lib.lookupFunction<_TsNodeStartByte, DTsNodeStartByte>(
|
||||
'ts_node_start_byte'),
|
||||
nodeEndByte = lib.lookupFunction<_TsNodeEndByte, DTsNodeEndByte>(
|
||||
'ts_node_end_byte'),
|
||||
queryNew =
|
||||
lib.lookupFunction<_TsQueryNew, DTsQueryNew>('ts_query_new'),
|
||||
queryDelete = lib.lookupFunction<_TsQueryDelete, DTsQueryDelete>(
|
||||
'ts_query_delete'),
|
||||
queryCaptureCount =
|
||||
lib.lookupFunction<_TsQueryCaptureCount, DTsQueryCaptureCount>(
|
||||
'ts_query_capture_count'),
|
||||
queryCaptureNameForId = lib.lookupFunction<_TsQueryCaptureNameForId,
|
||||
DTsQueryCaptureNameForId>('ts_query_capture_name_for_id'),
|
||||
queryCursorNew =
|
||||
lib.lookupFunction<_TsQueryCursorNew, DTsQueryCursorNew>(
|
||||
'ts_query_cursor_new'),
|
||||
queryCursorDelete =
|
||||
lib.lookupFunction<_TsQueryCursorDelete, DTsQueryCursorDelete>(
|
||||
'ts_query_cursor_delete'),
|
||||
queryCursorExec =
|
||||
lib.lookupFunction<_TsQueryCursorExec, DTsQueryCursorExec>(
|
||||
'ts_query_cursor_exec'),
|
||||
queryCursorNextMatch =
|
||||
lib.lookupFunction<_TsQueryCursorNextMatch, DTsQueryCursorNextMatch>(
|
||||
'ts_query_cursor_next_match'),
|
||||
wasmStoreNew = lib.lookupFunction<_TsWasmStoreNew, DTsWasmStoreNew>(
|
||||
'ts_wasm_store_new'),
|
||||
wasmStoreDelete =
|
||||
lib.lookupFunction<_TsWasmStoreDelete, DTsWasmStoreDelete>(
|
||||
'ts_wasm_store_delete'),
|
||||
wasmStoreLoadLanguage = lib.lookupFunction<_TsWasmStoreLoadLanguage,
|
||||
DTsWasmStoreLoadLanguage>('ts_wasm_store_load_language'),
|
||||
wasmEngineNew = lib.lookupFunction<_WasmEngineNew, DWasmEngineNew>(
|
||||
'wasm_engine_new'),
|
||||
wasmEngineDelete =
|
||||
lib.lookupFunction<_WasmEngineDelete, DWasmEngineDelete>(
|
||||
'wasm_engine_delete');
|
||||
: parserNew = lib.lookupFunction<_TsParserNew, DTsParserNew>('ts_parser_new'),
|
||||
parserDelete = lib.lookupFunction<_TsParserDelete, DTsParserDelete>('ts_parser_delete'),
|
||||
parserSetLanguage = lib.lookupFunction<_TsParserSetLanguage, DTsParserSetLanguage>('ts_parser_set_language'),
|
||||
parserSetWasmStore = lib.lookupFunction<_TsParserSetWasmStore, DTsParserSetWasmStore>('ts_parser_set_wasm_store'),
|
||||
parserParseString = lib.lookupFunction<_TsParserParseString, DTsParserParseString>('ts_parser_parse_string'),
|
||||
treeDelete = lib.lookupFunction<_TsTreeDelete, DTsTreeDelete>('ts_tree_delete'),
|
||||
treeRootNode = lib.lookupFunction<_TsTreeRootNode, DTsTreeRootNode>('ts_tree_root_node'),
|
||||
nodeStartByte = lib.lookupFunction<_TsNodeStartByte, DTsNodeStartByte>('ts_node_start_byte'),
|
||||
nodeEndByte = lib.lookupFunction<_TsNodeEndByte, DTsNodeEndByte>('ts_node_end_byte'),
|
||||
queryNew = lib.lookupFunction<_TsQueryNew, DTsQueryNew>('ts_query_new'),
|
||||
queryDelete = lib.lookupFunction<_TsQueryDelete, DTsQueryDelete>('ts_query_delete'),
|
||||
queryCaptureCount = lib.lookupFunction<_TsQueryCaptureCount, DTsQueryCaptureCount>('ts_query_capture_count'),
|
||||
queryCaptureNameForId = lib.lookupFunction<_TsQueryCaptureNameForId, DTsQueryCaptureNameForId>('ts_query_capture_name_for_id'),
|
||||
queryCursorNew = lib.lookupFunction<_TsQueryCursorNew, DTsQueryCursorNew>('ts_query_cursor_new'),
|
||||
queryCursorDelete = lib.lookupFunction<_TsQueryCursorDelete, DTsQueryCursorDelete>('ts_query_cursor_delete'),
|
||||
queryCursorExec = lib.lookupFunction<_TsQueryCursorExec, DTsQueryCursorExec>('ts_query_cursor_exec'),
|
||||
queryCursorNextMatch = lib.lookupFunction<_TsQueryCursorNextMatch, DTsQueryCursorNextMatch>('ts_query_cursor_next_match'),
|
||||
wasmStoreNew = lib.lookupFunction<_TsWasmStoreNew, DTsWasmStoreNew>('ts_wasm_store_new'),
|
||||
wasmStoreDelete = lib.lookupFunction<_TsWasmStoreDelete, DTsWasmStoreDelete>('ts_wasm_store_delete'),
|
||||
wasmStoreLoadLanguage = lib.lookupFunction<_TsWasmStoreLoadLanguage, DTsWasmStoreLoadLanguage>('ts_wasm_store_load_language'),
|
||||
wasmEngineNew = lib.lookupFunction<_WasmEngineNew, DWasmEngineNew>('wasm_engine_new'),
|
||||
wasmEngineDelete = lib.lookupFunction<_WasmEngineDelete, DWasmEngineDelete>('wasm_engine_delete');
|
||||
|
||||
final DTsParserNew parserNew;
|
||||
final DTsParserDelete parserDelete;
|
||||
|
||||
@@ -96,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.
|
||||
@@ -107,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);
|
||||
@@ -125,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;
|
||||
@@ -139,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);
|
||||
@@ -205,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) {
|
||||
@@ -266,21 +275,14 @@ class TreeSitterService {
|
||||
|
||||
static Color colorForRole(String role, SurfaceTokens tokens) {
|
||||
return switch (role) {
|
||||
'keyword' || 'repeat' || 'conditional' || 'include' ||
|
||||
'exception' || 'operator' =>
|
||||
tokens.syntaxKeyword,
|
||||
'keyword' || 'repeat' || 'conditional' || 'include' || 'exception' || 'operator' => tokens.syntaxKeyword,
|
||||
'type' || 'type.builtin' || 'constructor' => tokens.syntaxType,
|
||||
'string' || 'string.special' => tokens.syntaxString,
|
||||
'number' || 'float' || 'boolean' => tokens.syntaxNumber,
|
||||
'comment' => tokens.syntaxComment,
|
||||
'function' || 'function.builtin' || 'function.method' ||
|
||||
'method' =>
|
||||
tokens.syntaxMethod,
|
||||
'punctuation.bracket' || 'punctuation.delimiter' ||
|
||||
'punctuation.special' =>
|
||||
tokens.syntaxPunct,
|
||||
'variable' || 'variable.builtin' || 'variable.parameter' =>
|
||||
tokens.globalForeground,
|
||||
'function' || 'function.builtin' || 'function.method' || 'method' => tokens.syntaxMethod,
|
||||
'punctuation.bracket' || 'punctuation.delimiter' || 'punctuation.special' => tokens.syntaxPunct,
|
||||
'variable' || 'variable.builtin' || 'variable.parameter' => tokens.globalForeground,
|
||||
'property' || 'field' => tokens.syntaxMethod,
|
||||
'constant' || 'constant.builtin' => tokens.syntaxNumber,
|
||||
'tag' || 'attribute' => tokens.syntaxKeyword,
|
||||
|
||||
@@ -145,7 +145,6 @@ Color _composite(Color src, Color dst) {
|
||||
}
|
||||
|
||||
double _relativeLuminance(Color c) {
|
||||
double chan(double v) =>
|
||||
v <= 0.03928 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4).toDouble();
|
||||
double chan(double v) => v <= 0.03928 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4).toDouble();
|
||||
return 0.2126 * chan(c.r) + 0.7152 * chan(c.g) + 0.0722 * chan(c.b);
|
||||
}
|
||||
|
||||
@@ -25,9 +25,7 @@ class ThemeController extends ChangeNotifier {
|
||||
String? initialName,
|
||||
}) : _resolver = resolver,
|
||||
_defs = Map.fromEntries(bundled.map((d) => MapEntry(d.name, d))) {
|
||||
final first = initialName != null && _defs.containsKey(initialName)
|
||||
? initialName
|
||||
: bundled.first.name;
|
||||
final first = initialName != null && _defs.containsKey(initialName) ? initialName : bundled.first.name;
|
||||
_currentName = first;
|
||||
_current = _build(first);
|
||||
}
|
||||
@@ -89,8 +87,7 @@ class ClideTheme extends InheritedNotifier<ThemeController> {
|
||||
static ClideThemeData of(BuildContext context) {
|
||||
final w = context.dependOnInheritedWidgetOfExactType<ClideTheme>();
|
||||
if (w == null) {
|
||||
throw FlutterError(
|
||||
'ClideTheme.of() called with a context that is not a descendant of a ClideTheme.');
|
||||
throw FlutterError('ClideTheme.of() called with a context that is not a descendant of a ClideTheme.');
|
||||
}
|
||||
return w.notifier!.current;
|
||||
}
|
||||
@@ -98,8 +95,7 @@ class ClideTheme extends InheritedNotifier<ThemeController> {
|
||||
static ThemeController controllerOf(BuildContext context) {
|
||||
final w = context.dependOnInheritedWidgetOfExactType<ClideTheme>();
|
||||
if (w == null) {
|
||||
throw FlutterError(
|
||||
'ClideTheme.controllerOf() called with a context that is not a descendant of a ClideTheme.');
|
||||
throw FlutterError('ClideTheme.controllerOf() called with a context that is not a descendant of a ClideTheme.');
|
||||
}
|
||||
return w.notifier!;
|
||||
}
|
||||
|
||||
@@ -80,15 +80,13 @@ class ThemeLoader {
|
||||
displayName: displayName,
|
||||
dark: dark,
|
||||
palette: palette,
|
||||
semanticOverride:
|
||||
semantic is Map ? _parseSemantic(semantic, palette) : null,
|
||||
semanticOverride: semantic is Map ? _parseSemantic(semantic, palette) : null,
|
||||
surfaceOverride: mergedSurface.isNotEmpty ? mergedSurface : null,
|
||||
extensionOverride: extension is Map ? _parseRefMap(extension) : null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ThemeDefinition> fromAsset(
|
||||
AssetBundle bundle, String assetPath) async {
|
||||
Future<ThemeDefinition> fromAsset(AssetBundle bundle, String assetPath) async {
|
||||
final txt = await bundle.loadString(assetPath);
|
||||
final fallback = assetPath.split('/').last.replaceAll('.yaml', '');
|
||||
return fromYamlString(txt, fallbackName: fallback);
|
||||
@@ -115,8 +113,7 @@ SemanticRoles _parseSemantic(Map src, Palette palette) {
|
||||
final roles = <String, Color>{};
|
||||
src.forEach((k, v) {
|
||||
if (v is! String) return;
|
||||
final resolved =
|
||||
v.startsWith('#') ? Palette.parseHex(v) : palette.lookup(v);
|
||||
final resolved = v.startsWith('#') ? Palette.parseHex(v) : palette.lookup(v);
|
||||
if (resolved != null) roles['$k'] = resolved;
|
||||
});
|
||||
return SemanticRoles(roles);
|
||||
|
||||
@@ -65,10 +65,8 @@ class ThemeResolver {
|
||||
sidebarSectionHeader: surface[TokenKeys.sidebarSectionHeader]!,
|
||||
statusBarBackground: surface[TokenKeys.statusBarBackground]!,
|
||||
statusBarForeground: surface[TokenKeys.statusBarForeground]!,
|
||||
statusBarItemActiveBackground:
|
||||
surface[TokenKeys.statusBarItemActiveBackground]!,
|
||||
statusBarItemHoverBackground:
|
||||
surface[TokenKeys.statusBarItemHoverBackground]!,
|
||||
statusBarItemActiveBackground: surface[TokenKeys.statusBarItemActiveBackground]!,
|
||||
statusBarItemHoverBackground: surface[TokenKeys.statusBarItemHoverBackground]!,
|
||||
tabBarBackground: surface[TokenKeys.tabBarBackground]!,
|
||||
tabActive: surface[TokenKeys.tabActive]!,
|
||||
tabInactive: surface[TokenKeys.tabInactive]!,
|
||||
@@ -84,10 +82,8 @@ class ThemeResolver {
|
||||
listItemBackground: surface[TokenKeys.listItemBackground]!,
|
||||
listItemForeground: surface[TokenKeys.listItemForeground]!,
|
||||
listItemHoverBackground: surface[TokenKeys.listItemHoverBackground]!,
|
||||
listItemSelectedBackground:
|
||||
surface[TokenKeys.listItemSelectedBackground]!,
|
||||
listItemSelectedForeground:
|
||||
surface[TokenKeys.listItemSelectedForeground]!,
|
||||
listItemSelectedBackground: surface[TokenKeys.listItemSelectedBackground]!,
|
||||
listItemSelectedForeground: surface[TokenKeys.listItemSelectedForeground]!,
|
||||
scrollbarSlider: surface[TokenKeys.scrollbarSlider]!,
|
||||
scrollbarSliderHover: surface[TokenKeys.scrollbarSliderHover]!,
|
||||
scrollbarTrack: surface[TokenKeys.scrollbarTrack]!,
|
||||
@@ -135,9 +131,7 @@ class ThemeResolver {
|
||||
// theme never has a null surface color. Themes that omit these
|
||||
// will land readable if uninspired.
|
||||
roles.putIfAbsent(role, () {
|
||||
return palette.lookup('foreground') ??
|
||||
palette.lookup('background') ??
|
||||
const Color(0xFFFFFFFF);
|
||||
return palette.lookup('foreground') ?? palette.lookup('background') ?? const Color(0xFFFFFFFF);
|
||||
});
|
||||
}
|
||||
return SemanticRoles(roles);
|
||||
|
||||
@@ -67,6 +67,7 @@ class Toolchain extends ChangeNotifier {
|
||||
if (!c.isCompleted) c.complete();
|
||||
}
|
||||
}
|
||||
|
||||
addListener(listener);
|
||||
return c.future;
|
||||
}
|
||||
@@ -104,17 +105,16 @@ class Toolchain extends ChangeNotifier {
|
||||
|
||||
final pql = _findOnPath('pql');
|
||||
final tmux = _findOnPath('tmux');
|
||||
final shell = _findOnPath(
|
||||
Platform.environment['SHELL']?.split('/').last ?? 'bash');
|
||||
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');
|
||||
'$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,
|
||||
@@ -184,15 +184,14 @@ ResolvedPaths resolveToolchainPaths(String workspaceRoot) {
|
||||
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'),
|
||||
'$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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
+68
-31
@@ -26,11 +26,20 @@ import 'package:clide/builtin/welcome/welcome.dart';
|
||||
import 'dart:io' show Directory, Platform;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/kernel/src/backend.dart';
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/ipc/isolate_client.dart';
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/kernel/src/ipc/in_process.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';
|
||||
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/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;
|
||||
@@ -53,21 +62,14 @@ Future<void> main() async {
|
||||
final appDir = await _resolveAppDir();
|
||||
final themes = await _loadBundledThemes();
|
||||
|
||||
// Spawn the backend isolate — all subprocess and file I/O runs there.
|
||||
// Phase 1: resolve toolchain (binary availability only, no workspace).
|
||||
// Phase 2: openProject() initializes services when a project opens.
|
||||
const workspace = String.fromEnvironment('CLIDE_WORKSPACE');
|
||||
final sharedBus = DaemonBus();
|
||||
final backend = kIsWeb ? null : await Backend.spawn(
|
||||
hintRoot: workspace.isNotEmpty ? workspace : null,
|
||||
clientFactory: (backendPort) => IsolateClient(
|
||||
log: Logger(),
|
||||
events: sharedBus,
|
||||
backendPort: backendPort,
|
||||
),
|
||||
);
|
||||
|
||||
final toolchain = backend?.toolchain ?? Toolchain();
|
||||
// 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,
|
||||
@@ -76,14 +78,24 @@ Future<void> main() async {
|
||||
preloadNamespaces: _tier0Namespaces,
|
||||
autoStartDaemonClient: false,
|
||||
toolchain: toolchain,
|
||||
isolateClient: backend?.client,
|
||||
onProjectOpen: backend != null
|
||||
? (path) => backend.openProject(path)
|
||||
: null,
|
||||
onValidateProject: backend != null
|
||||
? (path) => backend.validateProject(path)
|
||||
: null,
|
||||
sharedBus: backend != null ? sharedBus : null,
|
||||
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
|
||||
@@ -122,15 +134,35 @@ Future<void> main() async {
|
||||
|
||||
await services.extensions.activateAll();
|
||||
|
||||
// Load recents before runApp so the welcome screen shows them.
|
||||
// project.open triggers backend.openProject which initializes services.
|
||||
if (!kIsWeb) {
|
||||
await services.project.loadRecents();
|
||||
var opened = await services.project.openLast();
|
||||
if (!opened) {
|
||||
opened = await services.project.open(Directory.current.path);
|
||||
}
|
||||
if (opened) {
|
||||
services.panels.activateTab(Slots.workspace, 'claude.primary');
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -139,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;
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
|
||||
@@ -56,13 +56,15 @@ 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?;
|
||||
|
||||
@@ -227,7 +227,9 @@ void registerGitCommands(
|
||||
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}],
|
||||
'branches': [
|
||||
for (final e in b) {'name': e.name, 'current': e.current}
|
||||
],
|
||||
});
|
||||
} on GitException catch (e) {
|
||||
return _gitError(req.id, e);
|
||||
|
||||
@@ -15,11 +15,10 @@ import '../ipc/envelope.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
import '../panes/pane.dart';
|
||||
import '../panes/registry.dart';
|
||||
import '../../kernel/src/toolchain.dart';
|
||||
import 'dispatcher.dart';
|
||||
|
||||
void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry, {required Toolchain toolchain}) {
|
||||
d.register('pane.spawn', (req) => _spawn(req, registry, toolchain));
|
||||
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));
|
||||
@@ -28,8 +27,7 @@ void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry, {required T
|
||||
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,
|
||||
@@ -48,14 +46,7 @@ IpcResponse _notFound(String id, String message) => IpcResponse.err(
|
||||
),
|
||||
);
|
||||
|
||||
Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry, Toolchain toolchain) async {
|
||||
// Wait for toolchain resolution if it hasn't completed yet.
|
||||
if (!toolchain.resolved) {
|
||||
await Future.any([
|
||||
toolchain.waitForResolution(),
|
||||
Future.delayed(const Duration(seconds: 5)),
|
||||
]);
|
||||
}
|
||||
Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry) async {
|
||||
final args = req.args;
|
||||
final rawArgv = args['argv'];
|
||||
if (rawArgv is! List || rawArgv.isEmpty) {
|
||||
@@ -78,8 +69,7 @@ Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry, Toolchain tool
|
||||
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}',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,7 +82,6 @@ Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry, Toolchain tool
|
||||
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?) ?? toolchain.ptyc,
|
||||
);
|
||||
return IpcResponse.ok(id: req.id, data: pane.toJson());
|
||||
} catch (e) {
|
||||
@@ -110,7 +99,9 @@ Future<IpcResponse> _spawn(IpcRequest req, PaneRegistry registry, Toolchain tool
|
||||
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()]
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -211,7 +211,11 @@ 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 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');
|
||||
|
||||
@@ -27,8 +27,7 @@ class Selection {
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is Selection && other.start == start && other.end == end;
|
||||
bool operator ==(Object other) => other is Selection && other.start == start && other.end == end;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(start, end);
|
||||
|
||||
@@ -31,8 +31,7 @@ class EditorRegistry {
|
||||
|
||||
Iterable<EditorBuffer> get buffers => _buffers.values;
|
||||
EditorBuffer? get(String id) => _buffers[id];
|
||||
EditorBuffer? get active =>
|
||||
_activeId == null ? null : _buffers[_activeId!];
|
||||
EditorBuffer? get active => _activeId == null ? null : _buffers[_activeId!];
|
||||
|
||||
/// Open a file. If [path] is already open, returns the existing
|
||||
/// buffer (no re-read from disk — the in-memory content is the
|
||||
|
||||
@@ -47,16 +47,12 @@ Future<List<FileEntry>> listDir({
|
||||
required String dir,
|
||||
required IgnoreSet ignore,
|
||||
}) async {
|
||||
final resolved = dir.isEmpty
|
||||
? root
|
||||
: Directory('${root.absolute.path}${Platform.pathSeparator}${dir.replaceAll('/', Platform.pathSeparator)}');
|
||||
final resolved = dir.isEmpty ? root : Directory('${root.absolute.path}${Platform.pathSeparator}${dir.replaceAll('/', Platform.pathSeparator)}');
|
||||
if (!await resolved.exists()) return const [];
|
||||
|
||||
final entries = <FileEntry>[];
|
||||
await for (final e in resolved.list(followLinks: false)) {
|
||||
final name = e.uri.pathSegments.isNotEmpty
|
||||
? e.uri.pathSegments.where((s) => s.isNotEmpty).last
|
||||
: '';
|
||||
final name = e.uri.pathSegments.isNotEmpty ? e.uri.pathSegments.where((s) => s.isNotEmpty).last : '';
|
||||
final rel = dir.isEmpty ? name : '$dir/$name';
|
||||
final stat = await e.stat();
|
||||
final isDir = stat.type == FileSystemEntityType.directory;
|
||||
|
||||
@@ -71,9 +71,9 @@ class FileWatcher {
|
||||
Future<void> start() async {
|
||||
if (_sub != null) return;
|
||||
_sub = root.watch(recursive: true).listen(
|
||||
_onEvent,
|
||||
onError: (Object e, StackTrace _) => _controller.addError(e),
|
||||
);
|
||||
_onEvent,
|
||||
onError: (Object e, StackTrace _) => _controller.addError(e),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
|
||||
@@ -209,9 +209,7 @@ class GitClient {
|
||||
|
||||
Future<ProcessResult> _run(List<String> args) async {
|
||||
try {
|
||||
return await Process.run(toolchain.git, args,
|
||||
workingDirectory: workDir.path,
|
||||
environment: toolchain.gitEnv);
|
||||
return await Process.run(toolchain.git, args, workingDirectory: workDir.path, environment: toolchain.gitEnv);
|
||||
} on ProcessException catch (e) {
|
||||
throw GitException('git ${args.first}: ${e.message}', stderr: e.toString());
|
||||
}
|
||||
@@ -223,9 +221,7 @@ class GitClient {
|
||||
if (reverse) args.add('--reverse');
|
||||
args.addAll(['--unidiff-zero', '-']);
|
||||
|
||||
final proc = await Process.start(toolchain.git, args,
|
||||
workingDirectory: workDir.path,
|
||||
environment: toolchain.gitEnv);
|
||||
final proc = await Process.start(toolchain.git, args, workingDirectory: workDir.path, environment: toolchain.gitEnv);
|
||||
proc.stdin.write(patch);
|
||||
await proc.stdin.close();
|
||||
final exitCode = await proc.exitCode;
|
||||
|
||||
@@ -16,6 +16,7 @@ String get gitBin {
|
||||
_gitBin ??= _resolveGit();
|
||||
return _gitBin!;
|
||||
}
|
||||
|
||||
String? _gitBin;
|
||||
|
||||
String _resolveGit() {
|
||||
@@ -215,8 +216,7 @@ Future<String> gitPush(
|
||||
}
|
||||
|
||||
/// List local branches. Returns (name, isCurrent) pairs.
|
||||
Future<List<({String name, bool current})>> gitBranches(
|
||||
Directory workDir) async {
|
||||
Future<List<({String name, bool current})>> gitBranches(Directory workDir) async {
|
||||
final r = await Process.run(
|
||||
gitBin,
|
||||
['branch', '--format=%(refname:short)|%(HEAD)'],
|
||||
@@ -302,9 +302,7 @@ Future<void> _applyPatch(
|
||||
await proc.stdin.close();
|
||||
final exitCode = await proc.exitCode;
|
||||
if (exitCode != 0) {
|
||||
final stderr = await proc.stderr
|
||||
.transform(const SystemEncoding().decoder)
|
||||
.join();
|
||||
final stderr = await proc.stderr.transform(const SystemEncoding().decoder).join();
|
||||
throw GitException(
|
||||
'git apply failed',
|
||||
stderr: stderr,
|
||||
|
||||
+6
-17
@@ -44,15 +44,8 @@ class GitFileStatus {
|
||||
final String? origPath;
|
||||
final GitConflictType? conflictType;
|
||||
|
||||
bool get isStaged =>
|
||||
indexState != null &&
|
||||
indexState != GitFileState.untracked &&
|
||||
indexState != GitFileState.ignored &&
|
||||
!isConflicted;
|
||||
bool get isUnstaged =>
|
||||
workTreeState != null &&
|
||||
workTreeState != GitFileState.untracked &&
|
||||
!isConflicted;
|
||||
bool get isStaged => indexState != null && indexState != GitFileState.untracked && indexState != GitFileState.ignored && !isConflicted;
|
||||
bool get isUnstaged => workTreeState != null && workTreeState != GitFileState.untracked && !isConflicted;
|
||||
bool get isUntracked => workTreeState == GitFileState.untracked;
|
||||
bool get isConflicted => conflictType != null;
|
||||
|
||||
@@ -84,14 +77,10 @@ class GitStatus {
|
||||
final int behind;
|
||||
final List<GitFileStatus> entries;
|
||||
|
||||
List<GitFileStatus> get staged =>
|
||||
entries.where((e) => e.isStaged).toList();
|
||||
List<GitFileStatus> get unstaged =>
|
||||
entries.where((e) => e.isUnstaged).toList();
|
||||
List<GitFileStatus> get untracked =>
|
||||
entries.where((e) => e.isUntracked).toList();
|
||||
List<GitFileStatus> get conflicted =>
|
||||
entries.where((e) => e.isConflicted).toList();
|
||||
List<GitFileStatus> get staged => entries.where((e) => e.isStaged).toList();
|
||||
List<GitFileStatus> get unstaged => entries.where((e) => e.isUnstaged).toList();
|
||||
List<GitFileStatus> get untracked => entries.where((e) => e.isUntracked).toList();
|
||||
List<GitFileStatus> get conflicted => entries.where((e) => e.isConflicted).toList();
|
||||
|
||||
bool get isClean => entries.isEmpty;
|
||||
bool get hasConflicts => entries.any((e) => e.isConflicted);
|
||||
|
||||
@@ -90,9 +90,7 @@ class IpcResponse extends IpcMessage {
|
||||
id: j['id']! as String,
|
||||
ok: ok,
|
||||
data: (j['data'] as Map?)?.cast<String, Object?>() ?? const {},
|
||||
error: ok
|
||||
? null
|
||||
: IpcError.fromJson((j['error'] as Map).cast<String, Object?>()),
|
||||
error: ok ? null : IpcError.fromJson((j['error'] as Map).cast<String, Object?>()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,11 +69,7 @@ class DaemonServer {
|
||||
|
||||
void _handleClient(Socket client) {
|
||||
_clients.add(client);
|
||||
client
|
||||
.cast<List<int>>()
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen(
|
||||
client.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).listen(
|
||||
(line) => _handleLine(client, line),
|
||||
onDone: () => _clients.remove(client),
|
||||
onError: (Object e) {
|
||||
|
||||
@@ -22,10 +22,8 @@ class RecordingEventSink implements DaemonEventSink {
|
||||
void emit(IpcEvent event) => events.add(event);
|
||||
|
||||
/// Convenience: filter to a single subsystem (`pane`, `git`, …).
|
||||
Iterable<IpcEvent> ofSubsystem(String subsystem) =>
|
||||
events.where((e) => e.subsystem == subsystem);
|
||||
Iterable<IpcEvent> ofSubsystem(String subsystem) => events.where((e) => e.subsystem == subsystem);
|
||||
|
||||
/// Convenience: filter to a specific `type` (`pane.spawned`, …).
|
||||
Iterable<IpcEvent> ofKind(String kind) =>
|
||||
events.where((e) => e.kind == kind);
|
||||
Iterable<IpcEvent> ofKind(String kind) => events.where((e) => e.kind == kind);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,11 @@ library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../ipc/envelope.dart';
|
||||
import '../pty/session.dart';
|
||||
import '../pty/native_pty.dart';
|
||||
import 'event_sink.dart';
|
||||
import 'pane.dart';
|
||||
|
||||
@@ -20,7 +21,7 @@ class PaneRegistry {
|
||||
|
||||
final DaemonEventSink events;
|
||||
final Map<String, Pane> _panes = {};
|
||||
final Map<String, PtySession> _sessions = {};
|
||||
final Map<String, NativePty> _sessions = {};
|
||||
final Map<String, StreamSubscription<Uint8List>> _subs = {};
|
||||
int _nextId = 1;
|
||||
|
||||
@@ -42,16 +43,29 @@ class PaneRegistry {
|
||||
int cols = 80,
|
||||
int rows = 24,
|
||||
String? title,
|
||||
String ptycPath = 'ptyc',
|
||||
}) async {
|
||||
final id = 'p_${_nextId++}';
|
||||
final session = await PtySession.spawn(
|
||||
argv: argv,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
cols: cols,
|
||||
final executable = argv.first;
|
||||
final arguments = argv.length > 1 ? argv.sublist(1) : const <String>[];
|
||||
|
||||
// Merge the caller's env on top of the process environment +
|
||||
// terminal defaults, matching the old ptyc contract.
|
||||
final fullEnv = <String, String>{
|
||||
...Platform.environment,
|
||||
'TERM': 'xterm-256color',
|
||||
'COLORTERM': 'truecolor',
|
||||
'LANG': 'en_US.UTF-8',
|
||||
'LC_ALL': 'en_US.UTF-8',
|
||||
if (env != null) ...env,
|
||||
};
|
||||
|
||||
final session = NativePty.start(
|
||||
executable: executable,
|
||||
arguments: arguments,
|
||||
columns: cols,
|
||||
rows: rows,
|
||||
ptycPath: ptycPath,
|
||||
workingDirectory: cwd,
|
||||
environment: fullEnv,
|
||||
);
|
||||
final pane = Pane(
|
||||
id: id,
|
||||
|
||||
+40
-15
@@ -16,7 +16,7 @@ import 'dart:io' show Platform;
|
||||
import 'package:ffi/ffi.dart' as pkg_ffi;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants (POSIX / Linux)
|
||||
// Constants (POSIX — platform-dispatched where Linux/macOS diverge)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const int afUnix = 1;
|
||||
@@ -25,11 +25,11 @@ const int sockStream = 1;
|
||||
final int solSocket = Platform.isMacOS ? 0xffff : 1;
|
||||
final int scmRights = Platform.isMacOS ? 0x01 : 1;
|
||||
|
||||
const int fIoNonblock = 0x800; // O_NONBLOCK — 04000 octal
|
||||
final int oNonblock = Platform.isMacOS ? 0x0004 : 0x0800;
|
||||
const int fGetFl = 3;
|
||||
const int fSetFl = 4;
|
||||
|
||||
const int tiocswinsz = 0x5414; // Linux x86_64; macOS differs
|
||||
final int tiocswinsz = Platform.isMacOS ? 0x80087467 : 0x5414;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typedefs
|
||||
@@ -59,6 +59,17 @@ typedef _RecvmsgD = int Function(
|
||||
int flags,
|
||||
);
|
||||
|
||||
typedef _RecvmsgDarwinC = ffi.IntPtr Function(
|
||||
ffi.Int32 sockfd,
|
||||
ffi.Pointer<MsghdrDarwin> msg,
|
||||
ffi.Int32 flags,
|
||||
);
|
||||
typedef _RecvmsgDarwinD = int Function(
|
||||
int sockfd,
|
||||
ffi.Pointer<MsghdrDarwin> msg,
|
||||
int flags,
|
||||
);
|
||||
|
||||
typedef _ReadC = ffi.IntPtr Function(
|
||||
ffi.Int32 fd,
|
||||
ffi.Pointer<ffi.Uint8> buf,
|
||||
@@ -116,8 +127,8 @@ final class Iovec extends ffi.Struct {
|
||||
external int iov_len;
|
||||
}
|
||||
|
||||
/// POSIX `struct msghdr`. Field layout matches Linux/glibc; macOS is
|
||||
/// byte-compatible here.
|
||||
/// Linux `struct msghdr`. msg_iovlen/msg_controllen are size_t (8 bytes
|
||||
/// on 64-bit). macOS uses int/socklen_t (4 bytes) — see MsghdrDarwin.
|
||||
final class Msghdr extends ffi.Struct {
|
||||
external ffi.Pointer<ffi.Void> msg_name;
|
||||
@ffi.Uint32()
|
||||
@@ -132,6 +143,22 @@ final class Msghdr extends ffi.Struct {
|
||||
external int msg_flags;
|
||||
}
|
||||
|
||||
/// macOS `struct msghdr`. msg_iovlen is int (4 bytes), msg_controllen
|
||||
/// is socklen_t (4 bytes) — smaller than Linux's size_t fields.
|
||||
final class MsghdrDarwin extends ffi.Struct {
|
||||
external ffi.Pointer<ffi.Void> msg_name;
|
||||
@ffi.Uint32()
|
||||
external int msg_namelen;
|
||||
external ffi.Pointer<Iovec> msg_iov;
|
||||
@ffi.Int32()
|
||||
external int msg_iovlen;
|
||||
external ffi.Pointer<ffi.Void> msg_control;
|
||||
@ffi.Uint32()
|
||||
external int msg_controllen;
|
||||
@ffi.Int32()
|
||||
external int msg_flags;
|
||||
}
|
||||
|
||||
/// POSIX `struct cmsghdr` prefix. We treat the rest of the control
|
||||
/// buffer as a raw byte region and compute offsets by hand.
|
||||
// On Linux, cmsg_len is size_t (8 bytes on 64-bit).
|
||||
@@ -183,11 +210,11 @@ ffi.DynamicLibrary _openLibc() {
|
||||
return ffi.DynamicLibrary.process();
|
||||
}
|
||||
|
||||
final _SocketpairD socketpair =
|
||||
_libc.lookupFunction<_SocketpairC, _SocketpairD>('socketpair');
|
||||
final _SocketpairD socketpair = _libc.lookupFunction<_SocketpairC, _SocketpairD>('socketpair');
|
||||
|
||||
final _RecvmsgD recvmsg =
|
||||
_libc.lookupFunction<_RecvmsgC, _RecvmsgD>('recvmsg');
|
||||
final _RecvmsgD recvmsgLinux = _libc.lookupFunction<_RecvmsgC, _RecvmsgD>('recvmsg');
|
||||
|
||||
final _RecvmsgDarwinD recvmsgDarwin = _libc.lookupFunction<_RecvmsgDarwinC, _RecvmsgDarwinD>('recvmsg');
|
||||
|
||||
final _ReadD read = _libc.lookupFunction<_ReadC, _ReadD>('read');
|
||||
|
||||
@@ -195,11 +222,9 @@ final _WriteD write = _libc.lookupFunction<_WriteC, _WriteD>('write');
|
||||
|
||||
final _CloseD close = _libc.lookupFunction<_CloseC, _CloseD>('close');
|
||||
|
||||
final _IoctlPtrD ioctlWinsize =
|
||||
_libc.lookupFunction<_IoctlPtrC, _IoctlPtrD>('ioctl');
|
||||
final _IoctlPtrD ioctlWinsize = _libc.lookupFunction<_IoctlPtrC, _IoctlPtrD>('ioctl');
|
||||
|
||||
final _FcntlIntD fcntlInt =
|
||||
_libc.lookupFunction<_FcntlIntC, _FcntlIntD>('fcntl');
|
||||
final _FcntlIntD fcntlInt = _libc.lookupFunction<_FcntlIntC, _FcntlIntD>('fcntl');
|
||||
|
||||
/// Resolve `errno` through the platform-appropriate thread-local
|
||||
/// accessor. glibc exposes `__errno_location`, musl the same, macOS
|
||||
@@ -238,8 +263,8 @@ T withBuffer<T>(int bytes, T Function(ffi.Pointer<ffi.Uint8>) action) {
|
||||
bool setNonBlocking(int fd) {
|
||||
final flags = fcntlInt(fd, fGetFl, 0);
|
||||
if (flags < 0) return false;
|
||||
if ((flags & fIoNonblock) != 0) return false;
|
||||
fcntlInt(fd, fSetFl, flags | fIoNonblock);
|
||||
if ((flags & oNonblock) != 0) return false;
|
||||
fcntlInt(fd, fSetFl, flags | oNonblock);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,30 +29,61 @@ int recvFd(int socketFd) {
|
||||
final payload = pkg_ffi.calloc<ffi.Uint8>(payloadLen);
|
||||
final control = pkg_ffi.calloc<ffi.Uint8>(controlLen);
|
||||
final iov = pkg_ffi.calloc<libc.Iovec>();
|
||||
final msg = pkg_ffi.calloc<libc.Msghdr>();
|
||||
|
||||
try {
|
||||
iov.ref.iov_base = payload;
|
||||
iov.ref.iov_len = payloadLen;
|
||||
|
||||
msg.ref.msg_name = ffi.nullptr;
|
||||
msg.ref.msg_namelen = 0;
|
||||
msg.ref.msg_iov = iov;
|
||||
msg.ref.msg_iovlen = 1;
|
||||
msg.ref.msg_control = control.cast();
|
||||
msg.ref.msg_controllen = controlLen;
|
||||
msg.ref.msg_flags = 0;
|
||||
|
||||
int received;
|
||||
while (true) {
|
||||
received = libc.recvmsg(socketFd, msg, 0);
|
||||
if (received >= 0) break;
|
||||
final err = libc.errno;
|
||||
if (err == 4 /* EINTR */) continue;
|
||||
throw PtyException('recvmsg', 'recvmsg failed', errno: err);
|
||||
int msgControllen;
|
||||
|
||||
if (Platform.isMacOS) {
|
||||
final msg = pkg_ffi.calloc<libc.MsghdrDarwin>();
|
||||
try {
|
||||
msg.ref.msg_name = ffi.nullptr;
|
||||
msg.ref.msg_namelen = 0;
|
||||
msg.ref.msg_iov = iov;
|
||||
msg.ref.msg_iovlen = 1;
|
||||
msg.ref.msg_control = control.cast();
|
||||
msg.ref.msg_controllen = controlLen;
|
||||
msg.ref.msg_flags = 0;
|
||||
|
||||
while (true) {
|
||||
received = libc.recvmsgDarwin(socketFd, msg, 0);
|
||||
if (received >= 0) break;
|
||||
final err = libc.errno;
|
||||
if (err == 4 /* EINTR */) continue;
|
||||
throw PtyException('recvmsg', 'recvmsg failed', errno: err);
|
||||
}
|
||||
msgControllen = msg.ref.msg_controllen;
|
||||
} finally {
|
||||
pkg_ffi.calloc.free(msg);
|
||||
}
|
||||
} else {
|
||||
final msg = pkg_ffi.calloc<libc.Msghdr>();
|
||||
try {
|
||||
msg.ref.msg_name = ffi.nullptr;
|
||||
msg.ref.msg_namelen = 0;
|
||||
msg.ref.msg_iov = iov;
|
||||
msg.ref.msg_iovlen = 1;
|
||||
msg.ref.msg_control = control.cast();
|
||||
msg.ref.msg_controllen = controlLen;
|
||||
msg.ref.msg_flags = 0;
|
||||
|
||||
while (true) {
|
||||
received = libc.recvmsgLinux(socketFd, msg, 0);
|
||||
if (received >= 0) break;
|
||||
final err = libc.errno;
|
||||
if (err == 4 /* EINTR */) continue;
|
||||
throw PtyException('recvmsg', 'recvmsg failed', errno: err);
|
||||
}
|
||||
msgControllen = msg.ref.msg_controllen;
|
||||
} finally {
|
||||
pkg_ffi.calloc.free(msg);
|
||||
}
|
||||
}
|
||||
|
||||
if (received == 0 || msg.ref.msg_controllen < 16) {
|
||||
if (received == 0 || msgControllen < 16) {
|
||||
throw const PtyException(
|
||||
'recvmsg',
|
||||
'peer closed without sending ancillary data',
|
||||
@@ -84,7 +115,6 @@ int recvFd(int socketFd) {
|
||||
final fdPtr = (control + dataOffset).cast<ffi.Int32>();
|
||||
return fdPtr.value;
|
||||
} finally {
|
||||
pkg_ffi.calloc.free(msg);
|
||||
pkg_ffi.calloc.free(iov);
|
||||
pkg_ffi.calloc.free(control);
|
||||
pkg_ffi.calloc.free(payload);
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/// Native PTY via forkpty() — replaces the ptyc helper binary.
|
||||
///
|
||||
/// Uses Dart FFI to call forkpty() directly. The master fd stays
|
||||
/// in-process (no socketpair, no SCM_RIGHTS). The reader isolate
|
||||
/// uses poll() for clean shutdown.
|
||||
///
|
||||
/// Based on the pty-spike proof-of-concept. Platform-aware:
|
||||
/// macOS: forkpty in libSystem (DynamicLibrary.process)
|
||||
/// Linux: forkpty in libutil.so.1
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:ffi' as ffi;
|
||||
import 'dart:io' show File, Platform;
|
||||
import 'dart:isolate';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
||||
// -- structs ----------------------------------------------------------------
|
||||
|
||||
final class _Winsize extends ffi.Struct {
|
||||
@ffi.Uint16()
|
||||
external int wsRow;
|
||||
@ffi.Uint16()
|
||||
external int wsCol;
|
||||
@ffi.Uint16()
|
||||
external int wsXpixel;
|
||||
@ffi.Uint16()
|
||||
external int wsYpixel;
|
||||
}
|
||||
|
||||
final class _Pollfd extends ffi.Struct {
|
||||
@ffi.Int32()
|
||||
external int fd;
|
||||
@ffi.Int16()
|
||||
external int events;
|
||||
@ffi.Int16()
|
||||
external int revents;
|
||||
}
|
||||
|
||||
// -- FFI bindings -----------------------------------------------------------
|
||||
|
||||
final ffi.DynamicLibrary _dl = _openLib();
|
||||
|
||||
ffi.DynamicLibrary _openLib() {
|
||||
if (Platform.isMacOS) return ffi.DynamicLibrary.process();
|
||||
// Linux: forkpty lives in libutil
|
||||
return ffi.DynamicLibrary.open('libutil.so.1');
|
||||
}
|
||||
|
||||
final _forkpty = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Int32>, ffi.Pointer<ffi.Char>, ffi.Pointer<ffi.Void>, ffi.Pointer<_Winsize>),
|
||||
int Function(ffi.Pointer<ffi.Int32>, ffi.Pointer<ffi.Char>, ffi.Pointer<ffi.Void>, ffi.Pointer<_Winsize>)>('forkpty');
|
||||
|
||||
final _execve = _dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Char>, ffi.Pointer<ffi.Pointer<ffi.Char>>, ffi.Pointer<ffi.Pointer<ffi.Char>>),
|
||||
int Function(ffi.Pointer<ffi.Char>, ffi.Pointer<ffi.Pointer<ffi.Char>>, ffi.Pointer<ffi.Pointer<ffi.Char>>)>('execve');
|
||||
|
||||
final _nativeWrite = ffi.DynamicLibrary.process()
|
||||
.lookupFunction<ffi.IntPtr Function(ffi.Int32, ffi.Pointer<ffi.Void>, ffi.IntPtr), int Function(int, ffi.Pointer<ffi.Void>, int)>('write');
|
||||
|
||||
final _nativeClose = ffi.DynamicLibrary.process().lookupFunction<ffi.Int32 Function(ffi.Int32), int Function(int)>('close');
|
||||
|
||||
final _ioctl = ffi.DynamicLibrary.process()
|
||||
.lookupFunction<ffi.Int32 Function(ffi.Int32, ffi.UnsignedLong, ffi.Pointer<_Winsize>), int Function(int, int, ffi.Pointer<_Winsize>)>('ioctl');
|
||||
|
||||
final _nativeKill = ffi.DynamicLibrary.process().lookupFunction<ffi.Int32 Function(ffi.Int32, ffi.Int32), int Function(int, int)>('kill');
|
||||
|
||||
final _waitpid = ffi.DynamicLibrary.process()
|
||||
.lookupFunction<ffi.Int32 Function(ffi.Int32, ffi.Pointer<ffi.Int32>, ffi.Int32), int Function(int, ffi.Pointer<ffi.Int32>, int)>('waitpid');
|
||||
|
||||
final _chdir = ffi.DynamicLibrary.process().lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Char>), int Function(ffi.Pointer<ffi.Char>)>('chdir');
|
||||
|
||||
final _exit_ = ffi.DynamicLibrary.process().lookupFunction<ffi.Void Function(ffi.Int32), void Function(int)>('_exit');
|
||||
|
||||
final int _kTiocsWinsz = Platform.isMacOS ? 0x80087467 : 0x5414;
|
||||
const _kSighup = 1;
|
||||
const _kWnohang = 1;
|
||||
|
||||
// -- NativePty --------------------------------------------------------------
|
||||
|
||||
/// A pseudo-terminal backed by forkpty() via Dart FFI.
|
||||
///
|
||||
/// Drop-in replacement for the old ptyc-based PtySession.
|
||||
class NativePty {
|
||||
final int _fd;
|
||||
final int pid;
|
||||
final _out = StreamController<Uint8List>.broadcast();
|
||||
bool _dead = false;
|
||||
|
||||
NativePty._(this._fd, this.pid);
|
||||
|
||||
/// Byte stream of data produced by the child.
|
||||
Stream<Uint8List> get output => _out.stream;
|
||||
|
||||
bool get isClosed => _dead;
|
||||
|
||||
/// Spawn a new PTY running [executable] with [arguments].
|
||||
///
|
||||
/// [environment] must be the complete environment — it goes straight
|
||||
/// to execve's envp. Merge Platform.environment before calling.
|
||||
static NativePty start({
|
||||
required String executable,
|
||||
List<String> arguments = const ['-l'],
|
||||
required int columns,
|
||||
required int rows,
|
||||
String? workingDirectory,
|
||||
Map<String, String> environment = const {},
|
||||
}) {
|
||||
// Resolve bare command names via PATH (execve doesn't search PATH).
|
||||
if (!executable.contains('/')) {
|
||||
final path = environment['PATH'] ?? Platform.environment['PATH'] ?? '';
|
||||
for (final dir in path.split(':')) {
|
||||
if (dir.isEmpty) continue;
|
||||
final candidate = '$dir/$executable';
|
||||
if (File(candidate).existsSync()) {
|
||||
executable = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force-resolve FFI functions that run in the child process.
|
||||
// Top-level finals are lazy; touching them here ensures the FFI
|
||||
// trampolines are compiled before fork() clones the process.
|
||||
final execve = _execve;
|
||||
final chdir = _chdir;
|
||||
final exit = _exit_;
|
||||
|
||||
// Allocate ALL native memory before fork.
|
||||
final shellN = executable.toNativeUtf8(allocator: malloc).cast<ffi.Char>();
|
||||
|
||||
final allArgs = [executable, ...arguments];
|
||||
final argvN = malloc<ffi.Pointer<ffi.Char>>(allArgs.length + 1);
|
||||
for (var i = 0; i < allArgs.length; i++) {
|
||||
argvN[i] = allArgs[i].toNativeUtf8(allocator: malloc).cast();
|
||||
}
|
||||
argvN[allArgs.length] = ffi.nullptr;
|
||||
|
||||
final envList = environment.entries.toList();
|
||||
final envpN = malloc<ffi.Pointer<ffi.Char>>(envList.length + 1);
|
||||
for (var i = 0; i < envList.length; i++) {
|
||||
envpN[i] = '${envList[i].key}=${envList[i].value}'.toNativeUtf8(allocator: malloc).cast();
|
||||
}
|
||||
envpN[envList.length] = ffi.nullptr;
|
||||
|
||||
final wdN = (workingDirectory ?? '/').toNativeUtf8(allocator: malloc).cast<ffi.Char>();
|
||||
final fdOut = calloc<ffi.Int32>();
|
||||
final ws = calloc<_Winsize>()
|
||||
..ref.wsRow = rows
|
||||
..ref.wsCol = columns;
|
||||
|
||||
// Fork.
|
||||
final pid = _forkpty(fdOut, ffi.nullptr, ffi.nullptr, ws);
|
||||
|
||||
if (pid == -1) {
|
||||
_freeAll(shellN, argvN, allArgs.length, envpN, envList.length, wdN, fdOut, ws);
|
||||
throw StateError('forkpty() failed');
|
||||
}
|
||||
|
||||
if (pid == 0) {
|
||||
// CHILD — only pre-resolved FFI calls, no Dart heap.
|
||||
chdir(wdN);
|
||||
execve(shellN, argvN, envpN);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// PARENT
|
||||
final fd = fdOut.value;
|
||||
_freeAll(shellN, argvN, allArgs.length, envpN, envList.length, wdN, fdOut, ws);
|
||||
|
||||
final pty = NativePty._(fd, pid);
|
||||
pty._spawnReader();
|
||||
return pty;
|
||||
}
|
||||
|
||||
static void _freeAll(
|
||||
ffi.Pointer shell,
|
||||
ffi.Pointer<ffi.Pointer<ffi.Char>> argv,
|
||||
int argc,
|
||||
ffi.Pointer<ffi.Pointer<ffi.Char>> envp,
|
||||
int envc,
|
||||
ffi.Pointer wd,
|
||||
ffi.Pointer fdOut,
|
||||
ffi.Pointer ws,
|
||||
) {
|
||||
malloc.free(shell);
|
||||
for (var i = 0; i < argc; i++) malloc.free(argv[i]);
|
||||
malloc.free(argv);
|
||||
for (var i = 0; i < envc; i++) malloc.free(envp[i]);
|
||||
malloc.free(envp);
|
||||
malloc.free(wd);
|
||||
calloc.free(fdOut);
|
||||
calloc.free(ws);
|
||||
}
|
||||
|
||||
// -- I/O ------------------------------------------------------------------
|
||||
|
||||
void _spawnReader() async {
|
||||
final rp = ReceivePort();
|
||||
await Isolate.spawn(_readLoop, (rp.sendPort, _fd));
|
||||
rp.listen((msg) {
|
||||
if (msg == null) {
|
||||
if (!_out.isClosed) _out.close();
|
||||
rp.close();
|
||||
_reap();
|
||||
} else {
|
||||
if (!_out.isClosed) _out.add(msg as Uint8List);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Isolate entry — polls then reads until EOF/error/fd-closed.
|
||||
static void _readLoop((SendPort, int) msg) {
|
||||
final (port, fd) = msg;
|
||||
final dl = ffi.DynamicLibrary.process();
|
||||
final rd = dl.lookupFunction<ffi.IntPtr Function(ffi.Int32, ffi.Pointer<ffi.Void>, ffi.IntPtr), int Function(int, ffi.Pointer<ffi.Void>, int)>('read');
|
||||
final poll = dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<_Pollfd>, ffi.Uint32, ffi.Int32), int Function(ffi.Pointer<_Pollfd>, int, int)>('poll');
|
||||
|
||||
final buf = malloc<ffi.Uint8>(65536);
|
||||
final pfd = calloc<_Pollfd>();
|
||||
pfd.ref.fd = fd;
|
||||
pfd.ref.events = 0x0001; // POLLIN
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
final ready = poll(pfd, 1, 100);
|
||||
if (ready < 0) break;
|
||||
if (ready == 0) continue;
|
||||
if (pfd.ref.revents & 0x0038 != 0 && pfd.ref.revents & 0x0001 == 0) {
|
||||
break;
|
||||
}
|
||||
final n = rd(fd, buf.cast(), 65536);
|
||||
if (n <= 0) break;
|
||||
port.send(Uint8List.fromList(buf.asTypedList(n)));
|
||||
}
|
||||
} finally {
|
||||
calloc.free(pfd);
|
||||
malloc.free(buf);
|
||||
}
|
||||
port.send(null);
|
||||
}
|
||||
|
||||
/// Write bytes to the child's stdin.
|
||||
int write(List<int> bytes) {
|
||||
if (_dead || bytes.isEmpty) return 0;
|
||||
final buf = malloc<ffi.Uint8>(bytes.length);
|
||||
for (var i = 0; i < bytes.length; i++) buf[i] = bytes[i];
|
||||
final n = _nativeWrite(_fd, buf.cast(), bytes.length);
|
||||
malloc.free(buf);
|
||||
return n;
|
||||
}
|
||||
|
||||
/// Resize the terminal.
|
||||
void resize({required int cols, required int rows}) {
|
||||
if (_dead) return;
|
||||
final ws = calloc<_Winsize>()
|
||||
..ref.wsRow = rows
|
||||
..ref.wsCol = cols;
|
||||
_ioctl(_fd, _kTiocsWinsz, ws);
|
||||
calloc.free(ws);
|
||||
// Explicitly signal the child to re-query its terminal size.
|
||||
_nativeKill(pid, 28); // SIGWINCH = 28 on macOS/Linux
|
||||
}
|
||||
|
||||
/// Send a signal to the child.
|
||||
bool kill([int signal = _kSighup]) {
|
||||
if (_dead) return false;
|
||||
return _nativeKill(pid, signal) == 0;
|
||||
}
|
||||
|
||||
void _reap() {
|
||||
if (_dead) return;
|
||||
_dead = true;
|
||||
final s = calloc<ffi.Int32>();
|
||||
_waitpid(pid, s, _kWnohang);
|
||||
calloc.free(s);
|
||||
}
|
||||
|
||||
/// Kill the child and release resources.
|
||||
Future<void> close() async {
|
||||
if (_dead) return;
|
||||
_dead = true;
|
||||
_nativeClose(_fd);
|
||||
_nativeKill(pid, _kSighup);
|
||||
_nativeKill(pid, 9);
|
||||
final s = calloc<ffi.Int32>();
|
||||
_waitpid(pid, s, 0);
|
||||
calloc.free(s);
|
||||
if (!_out.isClosed) await _out.close();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/// PTY subsystem — spawn child processes under a PTY via `ptyc`,
|
||||
/// PTY subsystem — spawn child processes under a PTY via forkpty(),
|
||||
/// expose their master fd as a byte stream. Desktop IDE's pane model
|
||||
/// (terminal / Claude / future tmux wrappers) rides on this.
|
||||
library;
|
||||
|
||||
export 'env.dart' show clidePtyEnvDefaults, mergePtyEnv;
|
||||
export 'session.dart' show PtySession;
|
||||
export 'native_pty.dart' show NativePty;
|
||||
|
||||
+22
-13
@@ -142,11 +142,7 @@ class PtySession {
|
||||
// Drain ptyc's stdout to parse the success envelope. We don't
|
||||
// strictly need it — the fd arriving is proof-of-life — but
|
||||
// draining avoids a PIPE accumulating.
|
||||
final stdoutLine = await proc.stdout
|
||||
.transform(const Utf8Decoder())
|
||||
.transform(const LineSplitter())
|
||||
.first
|
||||
.timeout(const Duration(seconds: 5));
|
||||
final stdoutLine = await proc.stdout.transform(const Utf8Decoder()).transform(const LineSplitter()).first.timeout(const Duration(seconds: 5));
|
||||
final pid = _extractPid(stdoutLine);
|
||||
|
||||
final code = await proc.exitCode;
|
||||
@@ -317,13 +313,27 @@ class PtySession {
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
final c = s.codeUnitAt(i);
|
||||
switch (c) {
|
||||
case 0x22: b.write(r'\"'); break;
|
||||
case 0x5c: b.write(r'\\'); break;
|
||||
case 0x08: b.write(r'\b'); break;
|
||||
case 0x09: b.write(r'\t'); break;
|
||||
case 0x0a: b.write(r'\n'); break;
|
||||
case 0x0c: b.write(r'\f'); break;
|
||||
case 0x0d: b.write(r'\r'); break;
|
||||
case 0x22:
|
||||
b.write(r'\"');
|
||||
break;
|
||||
case 0x5c:
|
||||
b.write(r'\\');
|
||||
break;
|
||||
case 0x08:
|
||||
b.write(r'\b');
|
||||
break;
|
||||
case 0x09:
|
||||
b.write(r'\t');
|
||||
break;
|
||||
case 0x0a:
|
||||
b.write(r'\n');
|
||||
break;
|
||||
case 0x0c:
|
||||
b.write(r'\f');
|
||||
break;
|
||||
case 0x0d:
|
||||
b.write(r'\r');
|
||||
break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
b.write('\\u${c.toRadixString(16).padLeft(4, '0')}');
|
||||
@@ -388,4 +398,3 @@ void _readerEntrypoint(_ReaderArgs args) {
|
||||
pkg_ffi.calloc.free(buf);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+168
-100
@@ -25,16 +25,21 @@ import 'builtin/files/files.dart';
|
||||
import 'builtin/git/git.dart';
|
||||
import 'builtin/terminal/terminal.dart';
|
||||
import 'extension/extension.dart' show ClideExtension;
|
||||
import 'dart:ffi' as ffi;
|
||||
import 'package:ffi/ffi.dart' as pkg_ffi;
|
||||
import 'kernel/kernel.dart';
|
||||
import 'kernel/src/backend.dart';
|
||||
import 'src/pty/ffi/libc.dart' as libc;
|
||||
import 'kernel/src/events/bus.dart';
|
||||
import 'kernel/src/events/types.dart';
|
||||
import 'kernel/src/ipc/isolate_client.dart';
|
||||
import 'kernel/src/ipc/in_process.dart';
|
||||
import 'kernel/src/log.dart';
|
||||
import 'src/daemon/pane_commands.dart';
|
||||
import 'src/ipc/envelope.dart';
|
||||
import 'src/panes/event_sink.dart';
|
||||
import 'src/panes/registry.dart';
|
||||
import 'src/pty/session.dart';
|
||||
import 'kernel/src/toolchain.dart';
|
||||
import 'src/daemon/dispatcher.dart';
|
||||
import 'src/ipc/envelope.dart';
|
||||
import 'src/pty/env.dart' show expandedPath;
|
||||
|
||||
const _timeout = Duration(seconds: 30);
|
||||
@@ -61,7 +66,7 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
}
|
||||
|
||||
Future<void> _runTests() async {
|
||||
const workspace = String.fromEnvironment('CLIDE_WORKSPACE');
|
||||
const workspace = String.fromEnvironment('CLIDE_PROJECT');
|
||||
const category = String.fromEnvironment('CLIDE_TESTMODE');
|
||||
final workDir = workspace.isNotEmpty ? workspace : Directory.current.path;
|
||||
|
||||
@@ -92,11 +97,11 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
|
||||
print('[testmode] === done ($passed passed, $failed failed, ${_results.length} total) ===');
|
||||
print('[testmode:json] ${jsonEncode({
|
||||
'passed': passed,
|
||||
'failed': failed,
|
||||
'total': _results.length,
|
||||
'failures': failedNames,
|
||||
})}');
|
||||
'passed': passed,
|
||||
'failed': failed,
|
||||
'total': _results.length,
|
||||
'failures': failedNames,
|
||||
})}');
|
||||
|
||||
setState(() => _done = true);
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
@@ -159,16 +164,13 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
});
|
||||
|
||||
await _testAsync('git rev-parse (project.open sim)', () async {
|
||||
final r = await Process.run(tc.git, ['rev-parse', '--show-toplevel'],
|
||||
workingDirectory: workDir, environment: tc.gitEnv);
|
||||
final r = await Process.run(tc.git, ['rev-parse', '--show-toplevel'], workingDirectory: workDir, environment: tc.gitEnv);
|
||||
return 'exit=${r.exitCode} ${(r.stdout as String).trim()}';
|
||||
});
|
||||
|
||||
await _testAsync('sequential git calls', () async {
|
||||
final r1 = await Process.run(tc.git, ['rev-parse', '--show-toplevel'],
|
||||
workingDirectory: workDir, environment: tc.gitEnv);
|
||||
final r2 = await Process.run(tc.git, ['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
workingDirectory: workDir, environment: tc.gitEnv);
|
||||
final r1 = await Process.run(tc.git, ['rev-parse', '--show-toplevel'], workingDirectory: workDir, environment: tc.gitEnv);
|
||||
final r2 = await Process.run(tc.git, ['rev-parse', '--abbrev-ref', 'HEAD'], workingDirectory: workDir, environment: tc.gitEnv);
|
||||
return 'root=${(r1.stdout as String).trim()} branch=${(r2.stdout as String).trim()}';
|
||||
});
|
||||
|
||||
@@ -176,8 +178,7 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
final paths = await compute(resolveToolchainPaths, workDir);
|
||||
final tc2 = Toolchain();
|
||||
tc2.applyResolved(paths);
|
||||
final r = await Process.run(tc2.git, ['rev-parse', '--show-toplevel'],
|
||||
workingDirectory: workDir, environment: tc2.gitEnv);
|
||||
final r = await Process.run(tc2.git, ['rev-parse', '--show-toplevel'], workingDirectory: workDir, environment: tc2.gitEnv);
|
||||
return 'exit=${r.exitCode} ${(r.stdout as String).trim()}';
|
||||
});
|
||||
|
||||
@@ -311,7 +312,9 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
try { await appDir.delete(recursive: true); } catch (_) {}
|
||||
try {
|
||||
await appDir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
_addResult('ext:boot', false, '$e');
|
||||
}
|
||||
@@ -324,107 +327,150 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
Future<void> _runTerminalTests(Toolchain tc, String workDir) async {
|
||||
print('[testmode] --- terminal ---');
|
||||
|
||||
// On macOS, PtySession FFI blocks the merged thread. Test via
|
||||
// backend isolate IPC instead (same path the real app uses).
|
||||
await _testAsync('pane.spawn via backend', () async {
|
||||
print('[testmode] spawning backend...');
|
||||
final backend = await Backend.spawn(
|
||||
hintRoot: workDir,
|
||||
clientFactory: (port) => IsolateClient(
|
||||
log: Logger(),
|
||||
events: DaemonBus(),
|
||||
backendPort: port,
|
||||
),
|
||||
);
|
||||
print('[testmode] backend ready, opening project...');
|
||||
await backend.openProject(workDir);
|
||||
print('[testmode] project open, spawning pane...');
|
||||
// Test PTY via InProcessClient — same path as the real app.
|
||||
await _testAsync('pane.spawn via IPC', () async {
|
||||
final dispatcher = DaemonDispatcher();
|
||||
final bus = DaemonBus();
|
||||
final eventSink = _TestEventSink(bus);
|
||||
final paneRegistry = PaneRegistry(events: eventSink);
|
||||
registerPaneCommands(dispatcher, paneRegistry);
|
||||
final ipc = InProcessClient(log: Logger(), events: bus, dispatcher: dispatcher);
|
||||
|
||||
// Spawn a pane running /bin/echo.
|
||||
// Use the shell (allowed by SBPL), not /bin/echo (not allowed).
|
||||
final spawnResp = await backend.client.request('pane.spawn', args: {
|
||||
'argv': [tc.shell, '-c', 'echo CLIDE_BACKEND_PTY_OK'],
|
||||
// Use interactive shell — fast-exiting commands lose output on macOS
|
||||
// because the slave closes before we can read the master.
|
||||
final spawnResp = await ipc.request('pane.spawn', args: {
|
||||
'argv': [tc.shell],
|
||||
'kind': 'terminal',
|
||||
});
|
||||
print('[testmode] spawn response: ok=${spawnResp.ok} ${spawnResp.ok ? spawnResp.data : spawnResp.error?.message}');
|
||||
print('[testmode] spawn: ok=${spawnResp.ok} ${spawnResp.ok ? spawnResp.data : spawnResp.error?.message}');
|
||||
if (!spawnResp.ok) {
|
||||
backend.dispose();
|
||||
return 'spawn failed: ${spawnResp.error?.message}';
|
||||
}
|
||||
final paneId = spawnResp.data['id'] as String;
|
||||
|
||||
// Collect output events for up to 3 seconds.
|
||||
// Collect pane.output events.
|
||||
final outputParts = <String>[];
|
||||
final sub = backend.client.events.on<DaemonEvent>().listen((e) {
|
||||
int eventCount = 0;
|
||||
final sub = bus.on<DaemonEvent>().listen((e) {
|
||||
eventCount++;
|
||||
if (e.subsystem == 'pane' && e.kind == 'pane.output' && e.data['id'] == paneId) {
|
||||
final b64 = e.data['bytes_b64'] as String?;
|
||||
if (b64 != null) outputParts.add(utf8.decode(base64Decode(b64), allowMalformed: true));
|
||||
}
|
||||
});
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
print('[testmode] events=$eventCount output_parts=${outputParts.length} bytes=${outputParts.join().length}');
|
||||
if (outputParts.isNotEmpty) {
|
||||
print('[testmode] first output: ${outputParts.first.substring(0, outputParts.first.length.clamp(0, 80))}');
|
||||
}
|
||||
await sub.cancel();
|
||||
backend.dispose();
|
||||
paneRegistry.shutdown();
|
||||
|
||||
final output = outputParts.join();
|
||||
final ok = output.contains('CLIDE_BACKEND_PTY_OK');
|
||||
return ok ? 'output contains marker' : 'marker not found in ${output.length} chars: ${output.substring(0, output.length.clamp(0, 100))}';
|
||||
return output.isNotEmpty ? 'got ${output.length} chars' : 'no output (0 chars)';
|
||||
});
|
||||
|
||||
// Test: does Dart's Process.start inherit socket fds on macOS?
|
||||
await _testAsync('fd inheritance check', () async {
|
||||
final sv = pkg_ffi.calloc<ffi.Int32>(2);
|
||||
libc.socketpair(1, 1, 0, sv); // AF_UNIX, SOCK_STREAM
|
||||
final parent = sv[0];
|
||||
final child = sv[1];
|
||||
pkg_ffi.calloc.free(sv);
|
||||
final proc = await Process.start('/tmp/checkfd', [], environment: {...Platform.environment, 'PTYC_SOCK_FD': '$child'});
|
||||
final stderr = await proc.stderr.transform(utf8.decoder).join();
|
||||
final exit = await proc.exitCode;
|
||||
libc.close(parent);
|
||||
libc.close(child);
|
||||
return 'exit=$exit stderr=${stderr.trim()}';
|
||||
});
|
||||
|
||||
// Direct PtySession test — bypasses IPC, tests fd transfer + reader.
|
||||
await _testAsync('PtySession.spawn direct', () async {
|
||||
final session = await PtySession.spawn(
|
||||
argv: [tc.shell, '-c', 'echo DIRECT_PTY_TEST'],
|
||||
cwd: workDir,
|
||||
ptycPath: tc.ptyc,
|
||||
);
|
||||
print('[testmode] session pid=${session.pid} masterFd exists');
|
||||
final bytes = <int>[];
|
||||
final done = Completer<void>();
|
||||
session.output.listen(
|
||||
(chunk) {
|
||||
bytes.addAll(chunk);
|
||||
print('[testmode] got ${chunk.length} bytes');
|
||||
},
|
||||
onDone: () {
|
||||
print('[testmode] stream done');
|
||||
if (!done.isCompleted) done.complete();
|
||||
},
|
||||
onError: (e) => print('[testmode] stream error: $e'),
|
||||
);
|
||||
await done.future.timeout(const Duration(seconds: 5), onTimeout: () {
|
||||
print('[testmode] timeout waiting for output, got ${bytes.length} bytes so far');
|
||||
});
|
||||
await session.close();
|
||||
final output = utf8.decode(bytes, allowMalformed: true);
|
||||
final ok = output.contains('DIRECT_PTY_TEST');
|
||||
return ok ? 'output=$output' : 'no marker in ${bytes.length} bytes: ${output.substring(0, output.length.clamp(0, 100))}';
|
||||
});
|
||||
|
||||
if (!Platform.isMacOS) {
|
||||
// Direct PtySession tests (only on Linux where threads are separate).
|
||||
// Additional direct PtySession tests (Linux only — no merged thread).
|
||||
|
||||
// Test 1: spawn /bin/echo via PtySession, read output
|
||||
await _testAsync('pty spawn echo', () async {
|
||||
final session = await PtySession.spawn(
|
||||
argv: ['/bin/echo', 'CLIDE_PTY_TEST_OK'],
|
||||
cwd: workDir,
|
||||
ptycPath: tc.ptyc,
|
||||
);
|
||||
final bytes = <int>[];
|
||||
final done = Completer<void>();
|
||||
session.output.listen(bytes.addAll, onDone: () => done.complete());
|
||||
await done.future.timeout(const Duration(seconds: 5));
|
||||
await session.close();
|
||||
final output = utf8.decode(bytes, allowMalformed: true);
|
||||
final ok = output.contains('CLIDE_PTY_TEST_OK');
|
||||
return ok ? 'output contains marker' : 'marker not found in ${output.length} bytes';
|
||||
});
|
||||
// Test 1: spawn /bin/echo via PtySession, read output
|
||||
await _testAsync('pty spawn echo', () async {
|
||||
final session = await PtySession.spawn(
|
||||
argv: ['/bin/echo', 'CLIDE_PTY_TEST_OK'],
|
||||
cwd: workDir,
|
||||
ptycPath: tc.ptyc,
|
||||
);
|
||||
final bytes = <int>[];
|
||||
final done = Completer<void>();
|
||||
session.output.listen(bytes.addAll, onDone: () => done.complete());
|
||||
await done.future.timeout(const Duration(seconds: 5));
|
||||
await session.close();
|
||||
final output = utf8.decode(bytes, allowMalformed: true);
|
||||
final ok = output.contains('CLIDE_PTY_TEST_OK');
|
||||
return ok ? 'output contains marker' : 'marker not found in ${output.length} bytes';
|
||||
});
|
||||
|
||||
// Test 2: spawn shell, write a command, verify output
|
||||
await _testAsync('pty spawn shell', () async {
|
||||
final session = await PtySession.spawn(
|
||||
argv: [tc.shell, '-c', 'echo CLIDE_SHELL_TEST'],
|
||||
cwd: workDir,
|
||||
ptycPath: tc.ptyc,
|
||||
);
|
||||
final bytes = <int>[];
|
||||
final done = Completer<void>();
|
||||
session.output.listen(bytes.addAll, onDone: () => done.complete());
|
||||
await done.future.timeout(const Duration(seconds: 5));
|
||||
await session.close();
|
||||
final output = utf8.decode(bytes, allowMalformed: true);
|
||||
final ok = output.contains('CLIDE_SHELL_TEST');
|
||||
return ok ? 'shell output contains marker' : 'marker not found in ${output.length} bytes';
|
||||
});
|
||||
// Test 2: spawn shell, write a command, verify output
|
||||
await _testAsync('pty spawn shell', () async {
|
||||
final session = await PtySession.spawn(
|
||||
argv: [tc.shell, '-c', 'echo CLIDE_SHELL_TEST'],
|
||||
cwd: workDir,
|
||||
ptycPath: tc.ptyc,
|
||||
);
|
||||
final bytes = <int>[];
|
||||
final done = Completer<void>();
|
||||
session.output.listen(bytes.addAll, onDone: () => done.complete());
|
||||
await done.future.timeout(const Duration(seconds: 5));
|
||||
await session.close();
|
||||
final output = utf8.decode(bytes, allowMalformed: true);
|
||||
final ok = output.contains('CLIDE_SHELL_TEST');
|
||||
return ok ? 'shell output contains marker' : 'marker not found in ${output.length} bytes';
|
||||
});
|
||||
|
||||
// Test 3: spawn interactive shell, write to stdin, verify file creation
|
||||
await _testAsync('pty write to child', () async {
|
||||
final marker = '/tmp/clide-pty-test-${DateTime.now().millisecondsSinceEpoch}';
|
||||
final session = await PtySession.spawn(
|
||||
argv: [tc.shell],
|
||||
cwd: workDir,
|
||||
ptycPath: tc.ptyc,
|
||||
);
|
||||
session.write(utf8.encode('touch $marker && exit\n'));
|
||||
final bytes = <int>[];
|
||||
final done = Completer<void>();
|
||||
session.output.listen(bytes.addAll, onDone: () => done.complete());
|
||||
await done.future.timeout(const Duration(seconds: 5));
|
||||
await session.close();
|
||||
final fileCreated = File(marker).existsSync();
|
||||
if (fileCreated) File(marker).deleteSync();
|
||||
return fileCreated ? 'file created + cleaned up' : 'file not created';
|
||||
});
|
||||
// Test 3: spawn interactive shell, write to stdin, verify file creation
|
||||
await _testAsync('pty write to child', () async {
|
||||
final marker = '/tmp/clide-pty-test-${DateTime.now().millisecondsSinceEpoch}';
|
||||
final session = await PtySession.spawn(
|
||||
argv: [tc.shell],
|
||||
cwd: workDir,
|
||||
ptycPath: tc.ptyc,
|
||||
);
|
||||
session.write(utf8.encode('touch $marker && exit\n'));
|
||||
final bytes = <int>[];
|
||||
final done = Completer<void>();
|
||||
session.output.listen(bytes.addAll, onDone: () => done.complete());
|
||||
await done.future.timeout(const Duration(seconds: 5));
|
||||
await session.close();
|
||||
final fileCreated = File(marker).existsSync();
|
||||
if (fileCreated) File(marker).deleteSync();
|
||||
return fileCreated ? 'file created + cleaned up' : 'file not created';
|
||||
});
|
||||
} // end !Platform.isMacOS
|
||||
|
||||
print('[testmode]');
|
||||
@@ -462,8 +508,7 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
|
||||
Future<void> _testExec(String label, String bin, List<String> args, String workDir, {Map<String, String>? env}) async {
|
||||
try {
|
||||
final r = await Process.run(bin, args, workingDirectory: workDir, environment: env)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
final r = await Process.run(bin, args, workingDirectory: workDir, environment: env).timeout(const Duration(seconds: 5));
|
||||
final stdout = (r.stdout as String).trim();
|
||||
final stderr = (r.stderr as String).trim();
|
||||
final firstLine = stdout.isNotEmpty ? stdout.split('\n').first : (stderr.isNotEmpty ? stderr.split('\n').first : '(empty)');
|
||||
@@ -494,7 +539,8 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
children: [
|
||||
const Text('ClideTestApp', style: TextStyle(color: Color(0xFFCDD6F4), fontSize: 20, fontWeight: FontWeight.bold, decoration: TextDecoration.none)),
|
||||
const SizedBox(height: 4),
|
||||
Text(_done ? 'Done — exiting' : 'Running tests...', style: const TextStyle(color: Color(0xFF6C7086), fontSize: 13, decoration: TextDecoration.none)),
|
||||
Text(_done ? 'Done — exiting' : 'Running tests...',
|
||||
style: const TextStyle(color: Color(0xFF6C7086), fontSize: 13, decoration: TextDecoration.none)),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
@@ -505,10 +551,17 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(r.ok ? '●' : '●', style: TextStyle(color: r.ok ? const Color(0xFFA6E3A1) : const Color(0xFFF38BA8), fontSize: 12, decoration: TextDecoration.none)),
|
||||
Text(r.ok ? '●' : '●',
|
||||
style: TextStyle(color: r.ok ? const Color(0xFFA6E3A1) : const Color(0xFFF38BA8), fontSize: 12, decoration: TextDecoration.none)),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 220, child: Text(r.name, style: const TextStyle(color: Color(0xFFCDD6F4), fontSize: 12, fontFamily: 'monospace', decoration: TextDecoration.none))),
|
||||
Expanded(child: Text(r.output, style: const TextStyle(color: Color(0xFF9399B2), fontSize: 12, fontFamily: 'monospace', decoration: TextDecoration.none), overflow: TextOverflow.ellipsis)),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: Text(r.name,
|
||||
style: const TextStyle(color: Color(0xFFCDD6F4), fontSize: 12, fontFamily: 'monospace', decoration: TextDecoration.none))),
|
||||
Expanded(
|
||||
child: Text(r.output,
|
||||
style: const TextStyle(color: Color(0xFF9399B2), fontSize: 12, fontFamily: 'monospace', decoration: TextDecoration.none),
|
||||
overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -522,6 +575,21 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
||||
}
|
||||
}
|
||||
|
||||
class _TestEventSink implements DaemonEventSink {
|
||||
_TestEventSink(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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class _TestResult {
|
||||
const _TestResult({required this.name, required this.detail, required this.ok, required this.output});
|
||||
final String name;
|
||||
|
||||
@@ -39,7 +39,8 @@ class ClideAccordion extends StatelessWidget {
|
||||
ClideIcon(expanded ? PhosphorIcons.caretDown : PhosphorIcons.caretRight, size: 10, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 6),
|
||||
if (leading != null) ...[leading!, const SizedBox(width: 6)],
|
||||
ClideText('$label · $count', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
ClideText('$label · $count',
|
||||
fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -21,14 +21,12 @@ class ColumnHat extends StatelessWidget {
|
||||
final String? projectLabel;
|
||||
final String? branchLabel;
|
||||
|
||||
factory ColumnHat.left({required WindowControls windowControls}) =>
|
||||
ColumnHat._(position: HatPosition.left, windowControls: windowControls);
|
||||
factory ColumnHat.left({required WindowControls windowControls}) => ColumnHat._(position: HatPosition.left, windowControls: windowControls);
|
||||
|
||||
factory ColumnHat.center({required WindowControls windowControls, String? project, String? branch}) =>
|
||||
ColumnHat._(position: HatPosition.center, windowControls: windowControls, projectLabel: project, branchLabel: branch);
|
||||
|
||||
factory ColumnHat.right({required WindowControls windowControls}) =>
|
||||
ColumnHat._(position: HatPosition.right, windowControls: windowControls);
|
||||
factory ColumnHat.right({required WindowControls windowControls}) => ColumnHat._(position: HatPosition.right, windowControls: windowControls);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -101,27 +99,6 @@ class _RightContent 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 _WinButton extends StatelessWidget {
|
||||
const _WinButton({required this.icon, required this.onTap, required this.tokens, this.isClose = false});
|
||||
final ClideIconPainter icon;
|
||||
|
||||
@@ -52,6 +52,5 @@ class _IconPainterAdapter extends CustomPainter {
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _IconPainterAdapter old) =>
|
||||
old.painter != painter || old.color != color;
|
||||
bool shouldRepaint(covariant _IconPainterAdapter old) => old.painter != painter || old.color != color;
|
||||
}
|
||||
|
||||
@@ -130,7 +130,10 @@ class ClideMarkdown extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [for (final c in el.children ?? const []) if (c is md.Element) _buildListItem(c, tokens, onRecordTap, ordered: false)],
|
||||
children: [
|
||||
for (final c in el.children ?? const [])
|
||||
if (c is md.Element) _buildListItem(c, tokens, onRecordTap, ordered: false)
|
||||
],
|
||||
),
|
||||
);
|
||||
case 'ol':
|
||||
@@ -266,12 +269,18 @@ class ClideMarkdown extends StatelessWidget {
|
||||
case 'strong':
|
||||
return TextSpan(
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
children: [for (final c in el.children ?? const []) if (c is md.Text) TextSpan(text: _unescapeHtml(c.text)) else if (c is md.Element) _inlineElementSpan(c, tokens, onRecordTap)],
|
||||
children: [
|
||||
for (final c in el.children ?? const [])
|
||||
if (c is md.Text) TextSpan(text: _unescapeHtml(c.text)) else if (c is md.Element) _inlineElementSpan(c, tokens, onRecordTap)
|
||||
],
|
||||
);
|
||||
case 'em':
|
||||
return TextSpan(
|
||||
style: const TextStyle(fontStyle: FontStyle.italic),
|
||||
children: [for (final c in el.children ?? const []) if (c is md.Text) TextSpan(text: _unescapeHtml(c.text)) else if (c is md.Element) _inlineElementSpan(c, tokens, onRecordTap)],
|
||||
children: [
|
||||
for (final c in el.children ?? const [])
|
||||
if (c is md.Text) TextSpan(text: _unescapeHtml(c.text)) else if (c is md.Element) _inlineElementSpan(c, tokens, onRecordTap)
|
||||
],
|
||||
);
|
||||
case 'code':
|
||||
return TextSpan(
|
||||
|
||||
@@ -151,12 +151,13 @@ class _Header extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...trailing!.map(
|
||||
(w) => Padding(
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
child: w,
|
||||
if (trailing != null)
|
||||
...trailing!.map(
|
||||
(w) => Padding(
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
child: w,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onClose != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
|
||||
@@ -54,7 +54,7 @@ class ClidePtyView extends StatelessWidget {
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamilyFallback: clideMonoFamilyFallback,
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
padding: EdgeInsets.zero,
|
||||
backgroundOpacity: 1,
|
||||
cursorType: TerminalCursorType.block,
|
||||
),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user