Compare commits
24
Commits
30b8cf7db9
...
e2b3f7cff1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2b3f7cff1 | ||
|
|
4dd9a74be7 | ||
|
|
a96a54ec7f | ||
|
|
cede971646 | ||
|
|
4d515ce51e | ||
|
|
77e395b3f2 | ||
|
|
7507e6c7fc | ||
|
|
02cabcad35 | ||
|
|
c8d019e630 | ||
|
|
370e800864 | ||
|
|
96b26e29f0 | ||
|
|
b13483e2d5 | ||
|
|
235cbcc046 | ||
|
|
bded1a2b65 | ||
|
|
329923538e | ||
|
|
9522625304 | ||
|
|
0de5f06f43 | ||
|
|
c39c2df6f5 | ||
|
|
4ae53ba16a | ||
|
|
16e43d3096 | ||
|
|
a48ccedfd4 | ||
|
|
005d19ae93 | ||
|
|
a782511470 | ||
|
|
a355751437 |
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(dart *)",
|
||||
"Bash(flutter *)",
|
||||
"Bash(make *)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(rm -rf /*)",
|
||||
"Bash(rm -rf ~*)",
|
||||
"Bash(rm -rf $HOME*)",
|
||||
"Bash(sudo rm *)",
|
||||
"Bash(sudo chmod *)",
|
||||
"Bash(sudo chown *)",
|
||||
"Bash(mkfs*)",
|
||||
"Bash(mkfs.*)",
|
||||
"Bash(dd if=*)",
|
||||
"Bash(git push --force*)",
|
||||
"Bash(git push -f*)",
|
||||
"Bash(git push --force-with-lease*)",
|
||||
"Bash(git reset --hard*)",
|
||||
"Bash(git clean -fd*)",
|
||||
"Bash(git clean -fdx*)",
|
||||
"Bash(git clean -ffd*)",
|
||||
"Bash(git branch -D *)",
|
||||
"Bash(git checkout -- *)",
|
||||
"Bash(git restore .*)",
|
||||
"Bash(chmod -R 777 *)",
|
||||
"Bash(chmod 777 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
name: git-commit
|
||||
description: >
|
||||
Git commit conventions for this repo — message style, changelog and
|
||||
version-bump rules, attribution trailer, and the safety rules that
|
||||
always apply. Use whenever the user says "commit", "commit this",
|
||||
"create a commit", "make a commit", "amend", or asks Claude to check
|
||||
work into git in any form.
|
||||
---
|
||||
|
||||
# Git commit rule — this repo
|
||||
|
||||
Follow these conventions whenever you create a commit in this repository. These are in addition to the standard Claude Code git-safety protocol (no `--no-verify`, no force-push to main, prefer new commits over `--amend`, etc.).
|
||||
|
||||
## Message style
|
||||
|
||||
- **First line:** imperative mood, ≤ 70 characters. Examples: `add sidecar PTY scaffold`, `fix IPC reconnect after app reload`, `update CLI exit-code contract`.
|
||||
- **Body (optional):** wrap at ~72 chars. Explain the *why* — the reason this change exists. The diff already shows the *what*; don't restate it in prose.
|
||||
- **No emojis.** Anywhere.
|
||||
- **Don't prefix with types** like `feat:` or `fix:` — this repo isn't Conventional Commits. (The Python-era clide under `legacy/` used Conventional Commits; the Flutter rebuild at the repo root does not.)
|
||||
- **Don't reference the current task or flow** (`for the v2.0 milestone`, `used by the canvas panel`) — that context belongs in the PR description and rots as the repo evolves.
|
||||
- **Naming:** the project is `clide`. The Flutter desktop app lives at the repo root; the Go sidecar/CLI binary is `clide`. The supporter project is `pql` (referenced, not part of this repo). The archived Python implementation lives under `legacy/`.
|
||||
|
||||
## Logically-separated commits
|
||||
|
||||
Default to **one logical change per commit**, even when a lot of work lands in the tree at once. When there's a pile of uncommitted or untracked files:
|
||||
|
||||
1. **Read `git status` + `git diff` first** — never stage the whole tree blind.
|
||||
2. **Group by concern**, not by file location. Typical concerns to separate:
|
||||
- **Bookkeeping** — `.gitignore`, editor/IDE config, lockfiles, `go.sum` / `pubspec.lock` updates.
|
||||
- **Documentation** — `README.md`, `CLAUDE.md`, ADRs under `docs/ADRs/`, design notes under `docs/`.
|
||||
- **General-purpose tooling / skills** — things that aren't project-specific (reusable skills, shared scripts).
|
||||
- **Project-specific conventions** — this repo's own rules.
|
||||
- **Feature or subsystem** — one cohesive change per commit; a sidecar change and an app change for the same feature can land together, but two unrelated features should split.
|
||||
- **Layer changes** — Flutter app, sidecar CLI, sidecar daemon, IPC server, pql wrapper, canvas driver, git panel — separate concerns; prefer separate commits when the changes are independent.
|
||||
3. **Sequence the commits** so each one is cleanly scoped, but don't obsess about whether each intermediate commit "works" — for scaffolding PRs it's fine if the full picture only snaps together at the end.
|
||||
4. **Prefer many small focused commits over one large mixed one** — a reviewer can read, revert, or cherry-pick a focused commit; they can't do any of those to a blob.
|
||||
5. **Use `git add <specific paths>`** — never `git add -A` or `git add .` when splitting, or you'll sweep in the next commit's work by accident.
|
||||
6. **Verify between commits** with `git status` and `git log -1` to confirm the split landed as intended.
|
||||
|
||||
Corollary: if a commit's subject line needs the word "and" to be accurate, it probably should have been two commits.
|
||||
|
||||
## Changelog discipline
|
||||
|
||||
This repo follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/). **Every user-visible commit must touch `CHANGELOG.md`** under the `## [Unreleased]` section, in the appropriate subsection:
|
||||
|
||||
- **Added** — new features or capabilities.
|
||||
- **Changed** — changes to existing functionality.
|
||||
- **Deprecated** — soon-to-be-removed features.
|
||||
- **Removed** — now-removed features.
|
||||
- **Fixed** — bug fixes.
|
||||
- **Security** — vulnerability fixes.
|
||||
|
||||
Entries should be short imperative phrases that describe user-facing impact — not implementation detail. "Added sidecar PTY support for terminal pane" beats "Added `internal/pty/session.go`."
|
||||
|
||||
**What skips the changelog:** pure bookkeeping commits that have no user-visible effect (typo fix in internal comment, `.gitignore` tweak, lint config change, reformatting). When in doubt, add an entry — the harm of an extra line is zero.
|
||||
|
||||
When a commit spans multiple entries (e.g. a feature that adds one thing and fixes another), add a line under each applicable subsection rather than cramming both into one.
|
||||
|
||||
## Cutting a release
|
||||
|
||||
Cutting a release is its own commit. In a single commit:
|
||||
|
||||
1. Move all entries from `## [Unreleased]` under a new heading `## [X.Y.Z] — YYYY-MM-DD`.
|
||||
2. Leave an empty `## [Unreleased]` section at the top with its subsection skeleton ready.
|
||||
3. Bump `project.yaml` `version:` to `X.Y.Z` (drop the `-dev` suffix for the tag; re-add it on the next development commit if desired).
|
||||
4. Commit subject: `release vX.Y.Z`.
|
||||
|
||||
`project.yaml` is the single source of truth for the version — the Makefile reads it for ldflag stamping of the sidecar binary, and the Flutter app reads it for build info. Bumping `project.yaml` and the changelog out of sync is the mistake this rule prevents.
|
||||
|
||||
## Attribution trailer
|
||||
|
||||
Every commit ends with the Claude co-author line:
|
||||
|
||||
```
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
The model-identifier variant produced by the Claude Code harness (e.g. `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`) is also accepted — don't rewrite it if the harness emits that form.
|
||||
|
||||
## HEREDOC discipline
|
||||
|
||||
Pass commit messages via HEREDOC so multi-line formatting survives:
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
short imperative summary
|
||||
|
||||
Optional longer body explaining why this change was needed,
|
||||
wrapped at about 72 characters.
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Never pass multi-line messages via `-m "line1\nline2"` or multiple `-m` flags — Git's behavior differs between shells and quoting regimes and the trailer can end up in the wrong place.
|
||||
|
||||
## What not to commit
|
||||
|
||||
- `.env` and any `*.env.local` — see `.gitignore`.
|
||||
- `.claude/settings.local.json` — user-specific Claude Code settings, ignored.
|
||||
- Build artefacts: `sidecar/bin/`, `sidecar/dist/`, `build/` (Flutter output), `app/.dart_tool/` — gitignored.
|
||||
- SQLite index files (`*.sqlite`, `*.sqlite-wal`, `*.sqlite-shm`, `*.db`) — caches generated against local repos; must never land here. Gitignored defensively.
|
||||
- Coverage / test output (`*.out`, `coverage.*`, `*.test`) — gitignored.
|
||||
|
||||
## Safety reminders (reinforced from the global Claude Code protocol)
|
||||
|
||||
- **Never** `--no-verify`. If a pre-commit hook fails, fix the underlying issue and create a new commit.
|
||||
- **Never** `--amend` a commit unless the user explicitly asks. A failed-hook commit didn't land, so amending would overwrite the *previous* commit and lose work.
|
||||
- **Never** force-push to `main` or `master`. Warn the user if they ask.
|
||||
- When staging, prefer naming specific files over `git add -A` / `git add .` — those can sweep in secrets or unintended files.
|
||||
- `git status` before staging, `git diff --staged` before committing, `git log -1` after committing to confirm.
|
||||
@@ -0,0 +1,203 @@
|
||||
---
|
||||
name: skill-create
|
||||
description: >
|
||||
Guidance for creating effective Claude Code skills (.skill packages).
|
||||
Use when the user wants to create, build, design, or iterate on a skill —
|
||||
including writing SKILL.md files, bundling scripts/references/assets,
|
||||
initializing new skills, packaging skills, or improving existing ones.
|
||||
Triggers on requests like "create a skill", "make a new skill",
|
||||
"build a skill for X", "package this skill", or "improve my skill".
|
||||
---
|
||||
|
||||
|
||||
# Skill Creator
|
||||
|
||||
## About Skills
|
||||
|
||||
Skills are modular, self-contained packages that extend Claude's capabilities
|
||||
by providing specialized knowledge, workflows, and tools. They transform Claude
|
||||
from a general-purpose agent into a specialized agent equipped with procedural
|
||||
knowledge that no model can fully possess.
|
||||
|
||||
### What Skills Provide
|
||||
|
||||
- **Specialized workflows** — Multi-step procedures for specific domains
|
||||
- **Tool integrations** — Instructions for working with specific file formats or APIs
|
||||
- **Domain expertise** — Company-specific knowledge, schemas, business logic
|
||||
- **Bundled resources** — Scripts, references, and assets for complex and repetitive tasks
|
||||
|
||||
## Core Principles
|
||||
|
||||
### Concise is Key
|
||||
|
||||
The context window is a public good. Skills share it with everything else Claude
|
||||
needs: system prompt, conversation history, other skills' metadata, and the
|
||||
actual user request.
|
||||
|
||||
Default assumption: Claude is already very smart. Only add context Claude doesn't
|
||||
already have. Challenge each piece of information: "Does Claude really need this
|
||||
explanation?" and "Does this paragraph justify its token cost?"
|
||||
|
||||
Prefer concise examples over verbose explanations.
|
||||
|
||||
### Set Appropriate Degrees of Freedom
|
||||
|
||||
Match specificity to the task's fragility and variability:
|
||||
|
||||
- **High freedom** (text-based instructions): Multiple approaches valid, decisions
|
||||
depend on context, heuristics guide the approach.
|
||||
- **Medium freedom** (pseudocode or scripts with parameters): Preferred pattern
|
||||
exists, some variation acceptable, configuration affects behavior.
|
||||
- **Low freedom** (specific scripts, few parameters): Operations are fragile and
|
||||
error-prone, consistency is critical, specific sequence must be followed.
|
||||
|
||||
Think of Claude as exploring a path: a narrow bridge with cliffs needs specific
|
||||
guardrails (low freedom), while an open field allows many routes (high freedom).
|
||||
|
||||
## Anatomy of a Skill
|
||||
|
||||
```
|
||||
skill-name/
|
||||
├── SKILL.md (required)
|
||||
│ ├── YAML frontmatter metadata (required)
|
||||
│ │ ├── name: (required)
|
||||
│ │ ├── description: (required)
|
||||
│ │ └── compatibility: (optional, rarely needed)
|
||||
│ └── Markdown instructions (required)
|
||||
└── Bundled Resources (optional)
|
||||
├── scripts/ - Executable code (Python/Bash/etc.)
|
||||
├── references/ - Documentation loaded into context as needed
|
||||
└── assets/ - Files used in output (templates, icons, fonts, etc.)
|
||||
```
|
||||
|
||||
### SKILL.md (required)
|
||||
|
||||
- **Frontmatter (YAML)**: `name` and `description` fields (required). Only these
|
||||
are read by Claude to determine when the skill triggers — be clear and
|
||||
comprehensive. The `compatibility` field is for environment requirements but
|
||||
most skills don't need it.
|
||||
- **Body (Markdown)**: Instructions and guidance. Only loaded AFTER the skill
|
||||
triggers.
|
||||
|
||||
### Bundled Resources (optional)
|
||||
|
||||
**Scripts (`scripts/`)** — Executable code for tasks requiring deterministic
|
||||
reliability or that are repeatedly rewritten.
|
||||
|
||||
**References (`references/`)** — Documentation loaded as needed into context.
|
||||
Keep SKILL.md lean; move detailed reference material, schemas, and examples here.
|
||||
If files are large (>10k words), include grep search patterns in SKILL.md.
|
||||
|
||||
**Assets (`assets/`)** — Files used in output, not loaded into context (templates,
|
||||
images, icons, boilerplate code, fonts).
|
||||
|
||||
### What to NOT Include
|
||||
|
||||
Do NOT create extraneous files like README.md, INSTALLATION_GUIDE.md,
|
||||
QUICK_REFERENCE.md, CHANGELOG.md, etc. The skill should only contain information
|
||||
needed for an AI agent to do the job.
|
||||
|
||||
## Progressive Disclosure
|
||||
|
||||
Skills use a three-level loading system:
|
||||
|
||||
1. **Metadata** (name + description) — Always in context (~100 words)
|
||||
2. **SKILL.md body** — When skill triggers (<5k words)
|
||||
3. **Bundled resources** — As needed (unlimited; scripts can run without reading)
|
||||
|
||||
Keep SKILL.md body under 500 lines. Split content into separate files when
|
||||
approaching this limit. Reference split files from SKILL.md with clear
|
||||
descriptions of when to read them.
|
||||
|
||||
### Disclosure Patterns
|
||||
|
||||
**Pattern 1: High-level guide with references** — Keep overview in SKILL.md,
|
||||
link to detail files loaded only when needed.
|
||||
|
||||
**Pattern 2: Domain-specific organization** — Organize content by domain
|
||||
(e.g., `references/finance.md`, `references/sales.md`) so only relevant content
|
||||
is loaded.
|
||||
|
||||
**Pattern 3: Conditional details** — Show basic content, link to advanced
|
||||
content loaded only when the user needs those features.
|
||||
|
||||
Guidelines:
|
||||
- Avoid deeply nested references — keep one level deep from SKILL.md
|
||||
- Structure longer reference files with a table of contents at the top
|
||||
|
||||
## Skill Creation Process
|
||||
|
||||
Follow these steps in order, skipping only with clear reason:
|
||||
|
||||
### Step 1: Understand the Skill with Concrete Examples
|
||||
|
||||
Skip only when usage patterns are already clearly understood.
|
||||
|
||||
Ask the user for concrete examples of how the skill will be used:
|
||||
- "What functionality should the skill support?"
|
||||
- "Can you give some examples of how this skill would be used?"
|
||||
- "What would a user say that should trigger this skill?"
|
||||
|
||||
Avoid overwhelming users — start with the most important questions.
|
||||
|
||||
### Step 2: Plan the Reusable Skill Contents
|
||||
|
||||
Analyze each example by considering how to execute from scratch and identifying
|
||||
what scripts, references, and assets would help with repeated execution.
|
||||
|
||||
Establish a list of reusable resources: scripts, references, and assets.
|
||||
|
||||
### Step 3: Initialize the Skill
|
||||
|
||||
Create the skill directory manually:
|
||||
|
||||
```
|
||||
mkdir -p <output-directory>/<skill-name>
|
||||
```
|
||||
|
||||
Then create `SKILL.md` with frontmatter and body. Add `scripts/`, `references/`,
|
||||
and `assets/` subdirectories only as needed.
|
||||
|
||||
Skip if iterating on an existing skill.
|
||||
|
||||
### Step 4: Edit the Skill
|
||||
|
||||
Remember the skill is for another Claude instance to use. Include beneficial,
|
||||
non-obvious information.
|
||||
|
||||
For design patterns, consult:
|
||||
- `references/workflows.md` — Sequential workflows and conditional logic
|
||||
- `references/output-patterns.md` — Template and example patterns
|
||||
|
||||
**Implementation order:**
|
||||
1. Start with reusable resources (`scripts/`, `references/`, `assets/`)
|
||||
2. Test added scripts by running them
|
||||
3. Delete unused example files from initialization
|
||||
4. Update SKILL.md
|
||||
|
||||
**Writing guidelines:** Always use imperative/infinitive form.
|
||||
|
||||
**Frontmatter:**
|
||||
- `name`: The skill name — use **domain-action** naming: `{domain}-{action}`.
|
||||
The domain is the system/area the skill operates on, the action is what it does.
|
||||
Examples: `pr-review`, `sprint-plan`, `docs-search`, `git-commit`, `debt-scan`.
|
||||
Multi-action wrappers (like `ticket`) can use the domain name alone.
|
||||
The directory name must match the `name` field.
|
||||
- `description`: Primary triggering mechanism. Include what the skill does AND
|
||||
specific triggers/contexts. All "when to use" info goes here (not in body).
|
||||
|
||||
**Body:** Instructions for using the skill and its bundled resources.
|
||||
|
||||
### Step 5: Validate the Skill
|
||||
|
||||
Check the skill manually:
|
||||
- Frontmatter has `name` and `description`
|
||||
- SKILL.md body is under 500 lines
|
||||
- No extraneous files (README.md, CHANGELOG.md, etc.)
|
||||
- Scripts are executable and tested
|
||||
- References are referenced from SKILL.md
|
||||
|
||||
### Step 6: Iterate
|
||||
|
||||
After real usage, notice struggles or inefficiencies, identify needed updates,
|
||||
implement changes, and test again.
|
||||
@@ -0,0 +1,22 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
|
||||
[*.dart]
|
||||
indent_size = 2
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,71 @@
|
||||
# Gitea Actions workflow for clide.
|
||||
#
|
||||
# NOT YET ACTIVATED. Gitea Actions must be enabled in the instance
|
||||
# settings before this runs; until then the file is just a ready-made
|
||||
# pipeline Claude + the user can review.
|
||||
#
|
||||
# When the repo eventually lands on GitHub, copy this file verbatim to
|
||||
# `.github/workflows/test.yml` — Gitea Actions consumes GitHub-Actions
|
||||
# syntax, so no rewrite is needed.
|
||||
|
||||
name: test
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
name: unit + widget + golden + a11y
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable, cache: true }
|
||||
- run: dart pub get
|
||||
- run: (cd app && flutter pub get)
|
||||
- run: ci/test.sh
|
||||
- run: ci/test_a11y.sh
|
||||
- run: ci/test_coverage.sh
|
||||
|
||||
integration:
|
||||
name: integration_test (xvfb)
|
||||
runs-on: ubuntu-latest
|
||||
needs: unit
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable, cache: true }
|
||||
- run: sudo apt-get update && sudo apt-get install -y xvfb ninja-build libgtk-3-dev
|
||||
- run: dart pub get
|
||||
- run: (cd app && flutter pub get)
|
||||
- uses: coactions/setup-xvfb@v1
|
||||
with: { run: ci/test_integration.sh }
|
||||
|
||||
startup-bundle:
|
||||
name: bundle smoke (xvfb 5s)
|
||||
runs-on: ubuntu-latest
|
||||
needs: unit
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable, cache: true }
|
||||
- run: sudo apt-get update && sudo apt-get install -y xvfb ninja-build libgtk-3-dev
|
||||
- run: dart pub get
|
||||
- run: (cd app && flutter pub get)
|
||||
- run: ci/smoke_bundle.sh
|
||||
|
||||
e2e:
|
||||
name: daemon subprocess + web WASM smoke
|
||||
runs-on: ubuntu-latest
|
||||
needs: unit
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable, cache: true }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20 }
|
||||
- run: dart pub get
|
||||
- run: (cd app && flutter pub get)
|
||||
- run: (cd tools/ui && npm install && npx playwright install --with-deps chromium)
|
||||
- run: ci/test_e2e.sh
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pre-push gate. Blocks the push if any quality check fails.
|
||||
#
|
||||
# Install: `make hooks` (points git core.hooksPath at .githooks/).
|
||||
# Bypass: never. If this runs slowly, fix the slow test; don't reach
|
||||
# for --no-verify (git-commit skill forbids it).
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
echo "==> pre-push: make push-check"
|
||||
make push-check
|
||||
+88
-175
@@ -1,188 +1,101 @@
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
# -- Flutter / Dart (app + core at repo root) ---------------------------
|
||||
/.dart_tool/
|
||||
/.packages
|
||||
/.flutter-plugins
|
||||
/.flutter-plugins-dependencies
|
||||
/build/
|
||||
/.idea/
|
||||
/.vscode/
|
||||
/*.iml
|
||||
/ios/Pods/
|
||||
/macos/Pods/
|
||||
/windows/flutter/ephemeral/
|
||||
/linux/flutter/ephemeral/
|
||||
/macos/Flutter/ephemeral/
|
||||
/bin/clide
|
||||
/bin/clide.exe
|
||||
# pubspec.lock is committed (supply-chain policy).
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
# Flutter app sub-package under app/. Same patterns, different prefix.
|
||||
app/.dart_tool/
|
||||
app/.flutter-plugins
|
||||
app/.flutter-plugins-dependencies
|
||||
app/build/
|
||||
app/*.iml
|
||||
app/ios/Pods/
|
||||
app/macos/Pods/
|
||||
app/windows/flutter/ephemeral/
|
||||
app/linux/flutter/ephemeral/
|
||||
app/macos/Flutter/ephemeral/
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
# UI harness (Playwright) — npm + output artefacts.
|
||||
tools/ui/node_modules/
|
||||
tools/ui/out/
|
||||
tools/ui/test-results/
|
||||
tools/ui/playwright-report/
|
||||
tools/ui/.serve.pid
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
!clide.spec
|
||||
# -- ptyc (C supporter tool) --------------------------------------------
|
||||
/ptyc/bin/
|
||||
/ptyc/*.o
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
# -- Test, coverage, profile output ------------------------------------
|
||||
*.test
|
||||
*.out
|
||||
*.prof
|
||||
*.pprof
|
||||
coverage.*
|
||||
/tmp/
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
# -- pql per-repo state (clide dogfoods against itself; pql's index lands
|
||||
# here when running queries locally). `pql.db` is also where the
|
||||
# `tools/scripts/plan` stopgap writes decisions + tickets (D-040).
|
||||
/.pql/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
# -- Python stopgap tooling under tools/scripts/plan ------------------
|
||||
tools/scripts/__pycache__/
|
||||
tools/scripts/planning/__pycache__/
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
# -- SQLite index files (defensive; should never land at repo root) ----
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
*.sqlite-wal
|
||||
*.sqlite-shm
|
||||
*.db
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
# -- Editor / IDE ------------------------------------------------------
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
# -- OS cruft ----------------------------------------------------------
|
||||
.DS_Store
|
||||
._*
|
||||
Thumbs.db
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
# -- Secrets -----------------------------------------------------------
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
|
||||
# Clide specific
|
||||
.config/
|
||||
snapshot_report.html
|
||||
logs/
|
||||
|
||||
# Credentials
|
||||
.env.local
|
||||
.env.*.local
|
||||
*-credentials.md
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
# -- Claude Code — user-specific settings only; project-level settings
|
||||
# and skills are committed
|
||||
.claude/settings.local.json
|
||||
|
||||
# -- Legacy Python clide (under legacy/, pre-dating the Flutter rebuild).
|
||||
# Narrowly scoped so repo-root ignores stay focused on the current
|
||||
# toolchain.
|
||||
legacy/.venv/
|
||||
legacy/.ruff_cache/
|
||||
legacy/**/__pycache__/
|
||||
legacy/**/*.py[cod]
|
||||
legacy/**/*$py.class
|
||||
legacy/**/.pytest_cache/
|
||||
legacy/**/.mypy_cache/
|
||||
legacy/**/.ruff_cache/
|
||||
legacy/**/*.egg-info/
|
||||
legacy/**/htmlcov/
|
||||
legacy/**/.coverage
|
||||
legacy/**/.coverage.*
|
||||
|
||||
+120
-42
@@ -1,60 +1,138 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
All notable changes to clide are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
The format follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.2.0] - 2026-02-24
|
||||
This changelog tracks the Flutter rebuild at the repo root. The Python
|
||||
Textual implementation's changelog is preserved under
|
||||
[`legacy/CHANGELOG.md`](legacy/CHANGELOG.md).
|
||||
|
||||
Versions are tracked in [`project.yaml`](project.yaml) under `version:`,
|
||||
which is the single source of truth. Cutting a release means (a) moving
|
||||
the entries below from `## [Unreleased]` under a new dated version
|
||||
heading, and (b) bumping `project.yaml` `version:` in the same commit.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **clide-web**: Python web server replacing ttyd + zellij for browser access
|
||||
- FastAPI + uvicorn with WebSocket ↔ PTY bridge via tmux
|
||||
- Project switching via `/projects/<name>` URL routing
|
||||
- Vendored xterm.js (no CDN, works on disconnected LAN)
|
||||
- Auto-respawn on Clide exit (tmux `pane-died` hook)
|
||||
- Setup wizard for first-run configuration (`clide-web-setup`)
|
||||
- Service management make targets (start-server, stop-server, etc.)
|
||||
- **SQLite database layer**: SQLModel tables (Project, Session, UserPreference, ConnectionLog) shared between clide and clide-web
|
||||
- **Dynamic tabbed workspace**: Multi-file editor and multi-terminal support with closable tabs
|
||||
|
||||
- Migrated the `docs/ADRs/` content into `decisions/` as D/R records:
|
||||
ADR 0001 → `D-001`, ADR 0002 → `R-002` (superseded by `D-005`), ADR
|
||||
0003 → `D-003`, ADR 0004 → `D-004`, ADR 0005 → `D-005`, ADR 0006 →
|
||||
`D-006`. Titles preserved; ADR 0006's trailing open questions moved
|
||||
to `questions-architecture.md` as `Q-001` / `Q-002` / `Q-003`. The
|
||||
originals are preserved in git history.
|
||||
|
||||
- `decisions/` at the repo root — Q&D record system ported from
|
||||
settled-reach and adapted for clide's domains. Confirmed decisions
|
||||
(`D-NNN`) live under domain files (`architecture.md`, `extensions.md`,
|
||||
`accessibility.md`, `testing.md`, `tooling.md`, `process.md`); open
|
||||
questions (`Q-NNN`) live under parallel `questions-<domain>.md`;
|
||||
rejected alternatives (`R-NNN`) live in `rejected.md`. Record shape,
|
||||
claiming rules, and the eventual pql-side tooling plan are documented
|
||||
in `decisions/README.md`. Migration of the existing `docs/ADRs/` into
|
||||
these files lands in a follow-up commit.
|
||||
- `DECISIONS.md` one-line pointer at the repo root (matches
|
||||
settled-reach's convention).
|
||||
|
||||
- `tools/scripts/plan` — Python stopgap entrypoint for `decisions`
|
||||
and `ticket` subcommands, writing to `.pql/pql.db` (gitignored).
|
||||
Supports `decisions sync | validate | claim | list | show | coverage`
|
||||
and `ticket new | list | show | status | assign | team | block |
|
||||
unblock | label | search | board`, plus `sqlite-query`. Verb shape
|
||||
and output format mirror the eventual `pql` subcommands so migration
|
||||
when pql ships feature parity is a call-site find-replace
|
||||
(`tools/scripts/plan ` → `pql `). Ported from settled-reach with
|
||||
the Scrum layer stripped; ticket IDs are `T-NNN` (TEXT PKs) and
|
||||
there's no `sprints` table. Time-limited per
|
||||
[`D-040`](decisions/process.md#d-040-python-stopgap-under-toolsscriptsplan)
|
||||
/ [`R-011`](decisions/rejected.md#r-011-permanent-stopgap).
|
||||
|
||||
- `make decisions-validate` — cheap parser dry-run wired into
|
||||
`push-check`. Catches malformed records before push.
|
||||
|
||||
- Reserved extension slots — `builtin.decisions`, `builtin.tickets`,
|
||||
`builtin.claude-control`. Id-reserving stubs under
|
||||
`app/lib/builtin/` with no contributions yet. Implementations land
|
||||
once [`Q-021`](decisions/questions-architecture.md#q-021-pql-absorbs-planning-vs-keeps-separate)
|
||||
resolves (decisions + tickets) or when the claude-control tier
|
||||
arrives (`.claude/` first-class surface — distinct from the
|
||||
existing `builtin.claude` PTY-pane stub).
|
||||
|
||||
- `CLAUDE.md` — new "Decision discipline" guardrail pointing at
|
||||
`decisions/`.
|
||||
|
||||
### Changed
|
||||
- Context panel (Jira, TODOs, Problems) backgrounds now match sidebar (`$surface`)
|
||||
- Makefile uses `printf` instead of `echo` for consistent ANSI color rendering
|
||||
- Full PTY terminal emulator with pyte replaces simple terminal widget
|
||||
- Nerd Font support and clipboard paste in terminal
|
||||
|
||||
- `CLAUDE.md` — inline ADR links rewritten to point at the migrated
|
||||
`decisions/` records; bottom "Open questions" section collapsed to
|
||||
a pointer at `decisions/questions-*.md`; parent-project note
|
||||
updated to reference `decisions/architecture.md` instead of the
|
||||
deleted `docs/ADRs/`.
|
||||
|
||||
### Removed
|
||||
- **ttyd** (C binary fork with Nerd Font) — replaced by clide-web
|
||||
- **zellij** (Rust binary for session persistence) — replaced by tmux
|
||||
- `clide-launcher` shell script — logic absorbed into clide-web
|
||||
- `update-ttyd.sh` build script
|
||||
|
||||
## [1.0.0] - 2026-02-01
|
||||
- `docs/ADRs/` directory — content lifted into `decisions/` as D/R
|
||||
records (see Added above). Originals preserved in git history.
|
||||
|
||||
### Added
|
||||
- **Skill Installer Service**: Install bundled Claude Code skills (commit, branch, push, pull, stash) to user or project scope
|
||||
- **TileListView Component**: Reusable card-style list widget with consistent styling
|
||||
- **TODO.md Integration**: Parse and display project TODO.md files in the TODOs panel
|
||||
- **File Watcher**: Real-time file system monitoring with reactive updates
|
||||
- **Git Graph View**: Visual commit graph in sidebar
|
||||
- **Branch Status Widget**: Enhanced branch display with remote tracking info
|
||||
- **Theme System**: 22 built-in themes including Summer Night (default)
|
||||
- **Alt-key Shortcuts**: VSCode-familiar keybindings that don't interfere with input fields
|
||||
- Go sidecar skeleton under `sidecar/` — `cmd/clide/main.go`, `go.mod`, and the `internal/*` packages (`cli`, `daemon`, `diag`, `git`, `ipc`, `pql`, `proc`, `pty`, `version`). Deleted wholesale per [ADR 0005](docs/ADRs/0005-dart-core-ptyc-peer.md): the "sidecar language: Go" premise no longer holds once the core is Dart. All functionality listed for those packages will be reimplemented under `lib/` as part of Tier 0.
|
||||
- Go-specific Makefile targets (`lint`, `vuln`, `test-race`, `fmt`, `tidy`, `snapshot`, `tools`, `install` via Go), the `govulncheck`/`goimports`/`golangci-lint` version pins, and the pre-push hook's `GOBIN` PATH injection. Replaced with Dart/Flutter equivalents (`analyze`, `format`, `test`, `test-integration`, `build` via `dart compile exe`).
|
||||
- `module:` and `go_version:` from `project.yaml` — single-language core means no Go module path to track.
|
||||
|
||||
### Changed
|
||||
- Restructured project with clear separation of controllers, services, and widgets
|
||||
- Improved panel architecture with state preservation on hide/show
|
||||
- Enhanced git integration with better status display
|
||||
|
||||
### Fixed
|
||||
- Test fixtures updated for new widget architecture
|
||||
|
||||
## [0.1.0] - 2026-01-15
|
||||
- [ADR 0002](docs/ADRs/0002-sidecar-language-go.md) marked **superseded** by [ADR 0005](docs/ADRs/0005-dart-core-ptyc-peer.md). The "sidecar language: Go" guardrail is retired. CLAUDE.md's guardrails, dependency notes, and command reference are updated to reflect the Dart-core direction.
|
||||
- `.gitignore` retargeted: Flutter/Dart output at the repo root (`.dart_tool/`, `build/`, platform ephemeral dirs, `bin/clide`), plus a `ptyc/` section for the C helper's build artefacts. Go-specific rules removed.
|
||||
- `ci/lint.sh`, `ci/test.sh`, `ci/security.sh`, and `.githooks/pre-push` rewritten for the Dart toolchain — no Go shell-outs, no `GOBIN` PATH dance.
|
||||
|
||||
### Added
|
||||
- Initial project structure and TUI framework
|
||||
- Basic panel layout (sidebar, workspace, context)
|
||||
- Claude Code integration as primary workspace
|
||||
- Pydantic models for data validation
|
||||
- pytest test infrastructure
|
||||
|
||||
- Testing docs under `docs/testing/`: `README.md` (what each layer covers, how to run, local vs CI flow), `a11y-manual.md` (15-minute Orca + VoiceOver checklist run at every tier cut), `claude-ui-workflow.md` (how Claude Code drives the app through the Playwright harness, including the `flt-semantics-placeholder` quirk).
|
||||
- Makefile targets for every test layer and the UI harness: `test`, `test-a11y`, `test-integration`, `test-e2e`, `test-all`, `coverage`, `smoke-bundle`, `ui-dev`, `ui-stop`, `ui-smoke`. `push-check` now runs `test + test-a11y` (fast pre-push gate, <90s).
|
||||
- Per-layer CI shell scripts under `ci/`: `test.sh` (analyze + format + unit + widget + golden, ~5s), `test_a11y.sh` (a11y contract), `test_integration.sh` (integration_test one file at a time — desktop can't batch them reliably), `test_e2e.sh` (daemon subprocess + browser WASM Playwright smoke), `smoke_bundle.sh` (xvfb-run the Linux release bundle for 5s; catches dynamic-linker / asset-bundle / plugin-init regressions that widget tests can't see), `coverage.sh` (flutter test --coverage + lcov summary).
|
||||
- `.gitea/workflows/test.yml` — four-job pipeline (`unit`, `integration`, `startup-bundle`, `e2e`) that shells out to the `ci/*.sh` scripts. **Not activated yet** — Gitea Actions has to be enabled in the instance settings first. GitHub-Actions-syntax-compatible, so copying to `.github/workflows/` is a one-file move when the repo migrates.
|
||||
- Web WASM harness under `tools/ui/` — Playwright driver so Claude Code (and humans) can drive the Flutter build in a real browser via the Semantics tree. `build.sh` / `serve.sh` / `stop.sh` manage a local `http.server` on `:4280` with port-based reclaim and kill (so orphaned listeners from earlier runs get swept). `driver.ts` exposes `ClideDriver` with `byLabel` / `click` / `type` / `readText` / `screenshot` / `dumpSemanticsTree` / `waitUntilReady` (auto-clicks the `flt-semantics-placeholder` to enable the semantics tree). First Playwright test `smoke.spec.ts` asserts welcome + disconnected labels render in the browser.
|
||||
- Integration tests under `app/integration_test/`, run with the `integration_test` package against the real built app (not an in-memory widget pump). The load-bearing startup gate lives here: `app_starts_test.dart` boots `ClideApp`, waits for the root shell to settle, and asserts the three-column layout + welcome tab + statusbar connection indicator all render. Also covers theme-picker modal open/select/dismiss (`theme_picker_test.dart`) and extension enable/disable lifecycle with contributions mounting/unmounting (`extension_lifecycle_test.dart`).
|
||||
- App-level test suite under `app/test/` — 168 tests across four layers:
|
||||
- **Unit** (`kernel/`, `extension/`) — events bus, settings (scope + YAML round-trip), log, i18n fallback chain matrix, theme resolver + loader + controller, panel registry + arrangement, command registry + keybinding parser + palette filter, extension-manager dep-order / cycle detection / enable-disable, manifest loader, extension scanner.
|
||||
- **Widget** (`widgets/`, `builtin/`) — every primitive's Semantics presence + token consumption + hover/press states; each Tier 0 built-in's contributions, view, and locale-switch re-render.
|
||||
- **Golden** (`goldens/`) — widget primitives only, Alchemist + Ahem font; PNG fixtures checked in under `_files/ci/` and `_files/linux/`.
|
||||
- **A11y** (`a11y/`) — `semantic_coverage_test.dart` (contract-level check that every built-in carries title + version + label-ready contributions), `contrast_test.dart` (WCAG-AA ratio gate on every bundled theme's canonical token pairs), `i18n_coverage_test.dart` (asserts every Tier-0-referenced key is present in its `en_US` catalog), `keyboard_traversal_test.dart` (focusability smoke).
|
||||
- Test helpers under `app/test/helpers/` — `KernelFixture` (boots a KernelServices with in-memory defaults + a fake daemon for widget-level tests), `FakeDaemonClient` (subclasses the real client, no socket, drivable connected-state), `golden_harness` (Alchemist config with Ahem font for cross-platform pixel stability), `widget_harness` (wraps a widget in Directionality + ClideKernel + ClideTheme + MediaQuery).
|
||||
- Flutter desktop app scaffold under `app/` with a bare `WidgetsApp` root (no Material, no Cupertino) and the Tier 0 three-column layout.
|
||||
- **Kernel** (`app/lib/kernel/`) — 18 services consumed by every extension: `settings` (scope-resolved get/set across `app.*`/`project.*`/`ext.*`), `project`, `extensions`, `theme`, `panels` (slot registry + arrangement), `events`, `ipc`, `commands` (+ palette + keybinding resolver), `clipboard`, `files`, `notify`, `dialog` (single-at-a-time modal router), `tray`, `secrets`, `os`, `net`, `focus`, and `log`. Unified in a `ClideKernel` `InheritedWidget`.
|
||||
- **i18n** (`app/lib/kernel/src/i18n/`) — text-driven lookup ported from [fframe](https://github.com/postmeridiem/fframe)'s `L10n`: namespaced JSON catalogs, `string()` / `interpolated()` calls with caller-supplied placeholders, and a proper locale fallback chain (exact → language → default-country → default-language → placeholder). Improves on fframe's design by adding the chain, which fframe lacks.
|
||||
- **A11y from Tier 0** — `Semantics(label:, hint:, button:)` on every interactive primitive; `SemanticsBinding.ensureSemantics()` at boot; a `theme/contrast.dart` helper exposes token pairs that the a11y suite walks for WCAG-AA compliance.
|
||||
- **Extension contract** (`app/lib/extension/`) — abstract `ClideExtension`, sealed `ContributionPoint` hierarchy (`TabContribution`, `StatusItemContribution`, `ToolbarButtonContribution`, `CommandContribution`, `TrayItemContribution`, `LayoutPresetContribution`). Each extension ships one manifest contributing N atoms into kernel slots. Priority-based ordering within a slot, dependency-aware activation, YAML manifest loader + scanner for `~/.clide/extensions/`.
|
||||
- **Three-tier theme pipeline** — palette (named colors) → semantic roles → ~60 VS-Code-style surface tokens, each layer with defaults so palette-only themes ship. Ported `summer-night` as the first bundled theme; muted value calibrated for WCAG-AA contrast.
|
||||
- **Widget primitives** (`app/lib/widgets/`) — `ClideSurface`, `ClideText`, `ClideButton`, `ClideTabBar`, `ClideDivider`, `ClideScrollbar`, `ClideTooltip`, `ClideIcon` + eight `CustomPainter`-rendered icons (folder, gear, x, chevron-left/right, dot, check, plug). All token-consuming, all Semantics-wrapped.
|
||||
- **Tier 0 built-in extensions** — `builtin.default-layout` (classic three-column preset + reset command), `builtin.welcome` (workspace placeholder), `builtin.ipc-status` (live-region statusbar indicator), `builtin.theme-picker` (command + modal, bound to `ctrl+k`). Plus 17 id-reserving stubs (`builtin.claude`, `builtin.terminal`, `builtin.files`, `builtin.editor`, `builtin.git`, ...) so later tiers can fill in without rename churn.
|
||||
- **Lua runtime boundary** — `app/lib/lua/` ships as typed stubs (`host`, `adapter`, `capability_api`, `render_intent`) so third-party Lua extensions can plug in at Tier 6 without retrofitting.
|
||||
- `.gitignore` extended to cover `app/` sub-package artefacts (`app/.dart_tool`, `app/build`, per-platform ephemeral dirs, `app/*.iml`) and the Playwright harness under `tools/ui/` (`node_modules`, `out`, test-results).
|
||||
- Dart core package at the repo root: `bin/clide.dart` (one binary, `--daemon` and one-shot subcommand modes), `lib/clide.dart` barrel exporting the shared IPC types, `lib/src/ipc/` (`envelope.dart`, `server.dart`, `paths.dart`, `schema_v1.dart`), and `lib/src/daemon/dispatcher.dart`. `clide --daemon` listens on a unix socket; `clide ping` / `clide version` round-trip through it with the ADR 0006 exit-code contract (`0/1/2/3/4`). Includes `test/ipc/` and `test/daemon/` suites covering envelope parsing, the in-process server, and a subprocess smoke that verifies signal-driven shutdown + socket unlink.
|
||||
- `scripts/bazzite-flutter-setup.sh` — one-shot installer for the Flutter SDK + desktop build deps on Bazzite / Fedora Silverblue. Drops the SDK under `~/opt/flutter`, wires PATH in the user's shell rc files, and layers the Linux desktop build deps via `rpm-ostree install`.
|
||||
- [ADR 0005](docs/ADRs/0005-dart-core-ptyc-peer.md) — Dart core; sidecar directory dissolved; `ptyc` as pql-peer. Establishes one Dart AOT binary for both CLI and daemon, `lib/` as the shared core, and promotes the C PTY helper to a standalone supporter tool on the same footing as pql.
|
||||
- [ADR 0006](docs/ADRs/0006-cli-and-event-surface.md) — CLI and event surface contract. Defines the subsystem list (`pane`, `tab`, `editor`, `panel`, `tree`, `git`, `pql`, `canvas`, `graph`, `theme`, `settings`, `project`), the command shape, the versioned JSON event schema, the pql-style exit-code contract, and the command↔event duality rule that operationalises user/Claude parity.
|
||||
|
||||
- Architectural decision records carried forward from the short-lived
|
||||
`claudian` plugin project (discarded in favour of this Flutter
|
||||
rebuild):
|
||||
[ADR 0001](docs/ADRs/0001-cli-first-not-mcp.md) — CLI-first, not MCP.
|
||||
[ADR 0002](docs/ADRs/0002-sidecar-language-go.md) — Sidecar language: Go.
|
||||
[ADR 0003](docs/ADRs/0003-pql-as-supporter-tool.md) — pql as supporter tool; wrap, don't duplicate; pql is a Clide subsystem when present.
|
||||
[ADR 0004](docs/ADRs/0004-ignore-file-strategy.md) — Ignore file strategy (`ignore_files:` in `.pql/config.yaml`, layered).
|
||||
- Pre-push quality gate: `.githooks/pre-push` runs `make push-check` (lint + test + test-race + test-integration + vuln + app-analyze + app-test) so bad pushes are caught locally before they hit Gitea. The app-side targets gracefully noop until Flutter is scaffolded. Install with `make hooks` (sets `git config core.hooksPath .githooks`); the hook prepends `$GOBIN`/`$HOME/go/bin` to PATH so govulncheck resolves without the user touching their shell profile.
|
||||
- Go sidecar/CLI skeleton under `sidecar/` (module `git.schweitz.net/jpmschweitzer/clide/sidecar`): `cmd/clide/main.go`, `internal/cli` with a stdlib-flag dispatch, `internal/diag` mirroring pql's exit-code + stderr-JSON contract, `internal/version` with ldflag-stamped build info, and placeholder packages for `daemon`, `pty`, `proc`, `git`, `ipc`, `pql` awaiting their tier. `clide --version` emits JSON build-info today.
|
||||
- Root `Makefile` drives both the Go sidecar and the Flutter app under one toolchain. Version is read from `project.yaml` via awk and stamped into the sidecar via `-ldflags -X`. Flutter targets gracefully noop before the app is scaffolded so the Makefile is usable from day one. Pinned Go tooling (govulncheck, goimports, golangci-lint) installs via `make tools`.
|
||||
- `ci/` entry scripts: `test.sh`, `lint.sh` (includes the supply-chain gate — no green lint without a green CVE scan), `security.sh`, `release.sh` (stub).
|
||||
- Project identity files for the Flutter rebuild at the repo root: `project.yaml` (single source of truth for version + module path, version 2.0.0-dev), a fresh `README.md`, MIT `LICENSE`, and `.editorconfig`. The Python clide's manifest and README are preserved under `legacy/`.
|
||||
- [`docs/initial-plan.md`](docs/initial-plan.md) — the north-star design document for the Flutter rebuild. Captures what we kept from Python Clide (pane model, git skills, Claude-always-visible), what we took from Obsidian (canvas and graph — no vault, no bases, no plugin inheritance), what Claudian's short experiment contributed (Go sidecar, CLI-first, pql-as-subsystem, ignore-file strategy), and the tier roadmap (Tier 0 app+sidecar handshake → Tier 5 canvas+graph).
|
||||
- [`CLAUDE.md`](CLAUDE.md) orientation doc for future Claude Code instances: project identity, guardrails as one-liners, tier ordering, parent-project pointers, commands, dependencies & supply chain, open questions. Points at the design doc and ADRs rather than restating their content.
|
||||
- Claude Code configuration under `.claude/`: project-level
|
||||
allow/deny permissions and two skills — `skill-create` (generic
|
||||
skill authoring guidance) and `git-commit` (this repo's commit
|
||||
conventions: no Conventional Commits, Keep a Changelog discipline,
|
||||
`project.yaml`-and-changelog-bumped-together rule, attribution
|
||||
trailer, safety reminders).
|
||||
|
||||
@@ -2,344 +2,84 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
## What clide is
|
||||
|
||||
Clide is a TUI IDE wrapper for Claude Code CLI, designed to be Claude-centric with VSCode-familiar keybindings. See `docs/tui-ide-spec.md` for full specification.
|
||||
A Flutter desktop IDE for Claude Code. One language (Dart) across the stack, plus small native supporter tools where Dart can't reach.
|
||||
|
||||
### Design Principles
|
||||
- **Claude-centric**: Claude Code is the primary workspace, always visible
|
||||
- **Contextual panels**: Editor/Diff/Terminal appear only when needed
|
||||
- **Alt-key shortcuts**: Alt-based keybindings don't interfere with input fields
|
||||
- **Responsive**: Works on 13" laptop to widescreen monitors
|
||||
- **State preservation**: Hiding panels preserves all state (never destroy widgets)
|
||||
- **`app/`** — Flutter desktop application (Linux / macOS primary; Windows stretch).
|
||||
- **`lib/` + `bin/clide.dart`** — Dart core, shared by the app and by one AOT-compiled binary that has two modes: `clide <subcommand>` (one-shot for Claude) and `clide --daemon` (long-running for the app). Owns IPC, PTYs (via `ptyc`), subprocesses, file watchers, git shell-outs, pql invocations.
|
||||
- **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it.
|
||||
- **`ptyc`** — small C supporter tool, peer of pql. Spawns a PTY + child and hands the master fd back over `SCM_RIGHTS`. Clide shells out to it for every pane (shell, tmux, claude, LSP, debug adapter).
|
||||
|
||||
## Tech Stack
|
||||
App ↔ daemon ↔ CLI speak a single JSON-lines unix socket protocol. The daemon outlives app restarts so Claude sessions survive reopens. Native rendering — markdown, canvas, graph — is Dart/Flutter (`CustomPaint` + widgets), not third-party packages.
|
||||
|
||||
| Component | Library | Version |
|
||||
|-----------|---------|---------|
|
||||
| Runtime | Python | 3.12+ |
|
||||
| TUI Framework | Textual | latest |
|
||||
| CLI | Typer | latest |
|
||||
| Data Validation | Pydantic | v2 (strict mode) |
|
||||
| Settings | pydantic-settings | latest |
|
||||
| Database | SQLModel + SQLite | latest |
|
||||
| Web Server | FastAPI + uvicorn | latest |
|
||||
| Testing | pytest + pytest-asyncio + pytest-textual-snapshot | latest |
|
||||
| Extensions | pluggy | latest |
|
||||
Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Decisions: [`decisions/`](decisions/) (`D-NNN` confirmed, `Q-NNN` open, `R-NNN` rejected — see [`decisions/README.md`](decisions/README.md)). Python Textual predecessor under [`legacy/`](legacy/).
|
||||
|
||||
## Development Commands
|
||||
## Guardrails
|
||||
|
||||
### Clide (TUI)
|
||||
These are load-bearing. Violating any means the design is wrong, not the rule.
|
||||
|
||||
```bash
|
||||
make setup # Create venv, install deps
|
||||
make run # Run application
|
||||
make test # Run all tests
|
||||
make test-single # Run single test (TEST=path::test_name)
|
||||
make typecheck # Run mypy
|
||||
make lint # Run ruff check
|
||||
make format # Run ruff format
|
||||
make build # Build for current platform
|
||||
```
|
||||
- **Flutter desktop is the host. No Electron, ever.** Web target may work as a happy accident — don't compromise desktop fidelity for it. If we ship a web build at all, prefer Flutter's **WebAssembly (CanvasKit/Skwasm) compile** over the JS/HTML renderer: it matches the desktop rendering pipeline, keeps our custom `CustomPaint` components pixel-identical, and avoids the DOM-renderer quirks around input handling and terminal-style content. `xterm.dart` is the terminal renderer (Tier 1); markdown, canvas, graph are custom `CustomPaint`/widget components (Tiers 2+, 5).
|
||||
- **No heavy lifting in the UI layer.** The app renders and handles input; process/PTY/IO lifecycles live in the daemon. The daemon is Dart too — the split is *process boundary*, not language boundary.
|
||||
- **CLI-first, not MCP.** Claude talks via Bash (`clide ...`), matching pql's contract. See [`D-001`](decisions/architecture.md#d-001-cli-first-not-mcp).
|
||||
- **Dart is the core; native supporter tools fill specific gaps.** One Dart AOT binary for CLI + daemon. `ptyc` (C) for PTY spawning. `pql` (Go) for queries. No second "core language." See [`D-005`](decisions/architecture.md#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (supersedes [`R-002`](decisions/rejected.md#r-002-go-sidecar)).
|
||||
- **Own the rendering stack.** PTY (via `ptyc`), markdown renderer, graph, canvas — all clide-owned, not pulled from opinionated packages. Third-party rendering is where we'd hit ceilings first; we'd rather pay the cost up front.
|
||||
- **User/Claude parity.** Every CLI subcommand has a UI affordance in the app, and every UI action has a CLI. Events are symmetric: every UI state change is a subscribable event. See [`D-006`](decisions/architecture.md#d-006-cli-and-event-surface-contract).
|
||||
- **pql: wrap, don't duplicate, and treat it as a clide subsystem when present.** Pql logic only lives in `lib/src/pql/` (pure shell-outs). Clide owns pql's `ignore_files:` config key; it never touches pql's `.pql/` index/cache data. See [`D-003`](decisions/architecture.md#d-003-pql-as-supporter-tool-clide-wraps-never-duplicates).
|
||||
- **Repo-is-the-workspace.** The git repo root is the workspace — no parallel "vault" concept. Clide dogfoods against its own repo.
|
||||
- **Ignore discipline.** Single knob: `ignore_files:` in `.pql/config.yaml`, ordered layering. Default `[.gitignore]`; clide writes `[.gitignore, .clideignore]` when `.clideignore` exists. See [`D-004`](decisions/architecture.md#d-004-ignore-file-strategy).
|
||||
- **Decision discipline.** All architectural choices live in `decisions/<domain>.md` as `D-NNN` records. Open questions live in `decisions/questions-<domain>.md` as `Q-NNN`. Rejected alternatives live in `decisions/rejected.md` as `R-NNN`. Before an architectural change, read the relevant domain file; before disagreeing with a guardrail, propose an amendment to the underlying `D-NNN` rather than a one-off. Claim new IDs via `tools/scripts/plan decisions claim D <domain> "title"` (until pql ships planning subcommands — see [`Q-021`](decisions/questions-architecture.md#q-021-pql-absorbs-planning-vs-keeps-separate)). See [`decisions/README.md`](decisions/README.md).
|
||||
|
||||
### clide-web (Web Server)
|
||||
## Tier ordering (don't skip ahead)
|
||||
|
||||
```bash
|
||||
cd clide-web/
|
||||
make setup # Create venv, install deps, run setup wizard
|
||||
make run # Run the web server (foreground)
|
||||
make dev # Run with auto-reload
|
||||
make start-server # Start systemd service
|
||||
make stop-server # Stop systemd service
|
||||
make restart-server # Restart systemd service
|
||||
make status-server # Show service status
|
||||
make logs-server # Tail service logs
|
||||
```
|
||||
1. **Tier 0** — Flutter app + Dart daemon handshake, empty IDE shell.
|
||||
2. **Tier 1** — Claude in an `xterm.dart` pane backed by `ptyc`-spawned PTYs; session persists across app restarts.
|
||||
3. **Tier 2** — Pane model + active-file awareness + `clide open/active/insert/replace-selection/tail`.
|
||||
4. **Tier 3** — Git panel (staged/unstaged, hunk stage, conflict UI) + diff tab + `clide git …`.
|
||||
5. **Tier 4** — pql integration: Query panel, file tree, backlinks, problems — all drawing from pql.
|
||||
6. **Tier 5** — Canvas (`CustomPaint` + `InteractiveViewer`) and graph view.
|
||||
7. **Tier 6** — Dart extension API, settings, theming, distributable builds.
|
||||
|
||||
## Panel Architecture
|
||||
See `docs/initial-plan.md` for the full tier definitions and acceptance criteria.
|
||||
|
||||
## Parent projects
|
||||
|
||||
- **`legacy/`** — Python Textual clide v1.2.0. Feature-frozen. Reference for the pane model, panel set, git skills (`/commit`, `/stash`, `/pull`, `/push` — rewire to `clide git …`), TODO.md parsing format.
|
||||
- **`projects/claudian`** (April 2026, discarded) — 2-day experiment with an Obsidian-plugin approach. Its architectural patterns (CLI-first, pql-as-subsystem, ignore-file strategy, supply-chain gate, changelog discipline, commit conventions) are the records you see under `decisions/` and the skills under `.claude/skills/`. It first proposed a Go sidecar; [`D-005`](decisions/architecture.md#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer) reversed that in favour of a Dart core.
|
||||
- **[`projects/pql`](https://github.com/postmeridiem/pql)** — active supporter tool. Clide depends on it; never duplicates it.
|
||||
|
||||
## Dependencies & supply chain
|
||||
|
||||
- **Dart (core + app):** prefer-zero-deps. Flutter-SDK widgets first; third-party packages need justification. What stays is exact-pinned in `pubspec.yaml` (no caret ranges). Advisories reviewed before every bump; `pubspec.lock` committed.
|
||||
- **`ptyc` and any future native supporter tool:** no dep graph by design (libc-only for `ptyc`). "Audit" is reading the source before each bump.
|
||||
- `pubspec.lock` is always committed.
|
||||
- `make security` runs the Dart advisory review; `ci/security.sh` is the CI entry.
|
||||
|
||||
## Commands
|
||||
|
||||
(Placeholder until Tier 0 commits land these targets in real form.)
|
||||
|
||||
```
|
||||
┌─────────────────┬─────────────────────────┬──────────────────┐
|
||||
│ panel-sidebar │ panel-workspace (60%) │ panel-context │
|
||||
│ │ [Editor][Diff][Terminal]│ │
|
||||
│ [Files][Git] │ (hidden when inactive) │ [Problems][TODOs]│
|
||||
│ [Tree] ├─────────────────────────┤ [Jira] │
|
||||
│ │ │ │
|
||||
│ (content area) │ panel-claude │ (content area) │
|
||||
│ │ (40% when workspace │ │
|
||||
│ │ visible, else 100%) │ │
|
||||
├─────────────────┤ ├──────────────────┤
|
||||
│ ⎇ main ▾ │ │ [⚠ 3][✓12][Jira]│
|
||||
└─────────────────┴─────────────────────────┴──────────────────┘
|
||||
make build # dart compile exe bin/clide.dart -o bin/clide
|
||||
make test # flutter test
|
||||
make test-integration# daemon + CLI + fixture-repo suite
|
||||
make analyze # flutter analyze
|
||||
make format # dart format --set-exit-if-changed
|
||||
make build-linux # flutter build linux
|
||||
make build-macos # flutter build macos
|
||||
make ptyc-build # build the ptyc PTY-spawn helper
|
||||
make security # Dart advisory review + ptyc source review
|
||||
make push-check # pre-push gate: analyze + format + test
|
||||
make hooks # install the repo's git hooks (one-time setup)
|
||||
make clean # remove build artefacts
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
One-time setup on a fresh clone: `make hooks && flutter pub get` once Flutter is installed.
|
||||
|
||||
```
|
||||
clide/
|
||||
├── clide/ # Package source
|
||||
│ ├── __init__.py
|
||||
│ ├── __main__.py
|
||||
│ ├── app.py # Main App, layout, keybindings
|
||||
│ ├── cli.py # Typer entry point
|
||||
│ ├── controllers/ # Domain logic (no UI)
|
||||
│ ├── widgets/ # UI components
|
||||
│ │ ├── panels/ # Main layout containers
|
||||
│ │ └── components/ # Reusable UI pieces
|
||||
│ ├── models/ # Pydantic data models
|
||||
│ ├── services/ # Background task logic
|
||||
│ ├── themes/ # Theme system
|
||||
│ ├── extensions/ # Plugin system
|
||||
│ └── helpers/ # Utility functions
|
||||
│
|
||||
├── tests/
|
||||
│ ├── conftest.py
|
||||
│ ├── harnesses/
|
||||
│ │ ├── app_harness.py
|
||||
│ │ └── controller_harness.py
|
||||
│ ├── unit/
|
||||
│ ├── integration/
|
||||
│ └── snapshots/
|
||||
│
|
||||
├── .config/ # User config (gitignored)
|
||||
│ ├── settings.toml # User settings
|
||||
│ └── themes/ # Custom user themes
|
||||
│ └── my-theme.toml
|
||||
├── docs/
|
||||
│ ├── tui-ide-spec.md # Full UI/UX specification
|
||||
│ ├── web-deployment.md # Web deployment architecture
|
||||
│ └── ARCHITECTURE.md # Framework best practices
|
||||
├── pyproject.toml
|
||||
└── Makefile
|
||||
│
|
||||
clide-web/ # Web server package (wraps clide)
|
||||
├── clide_web/
|
||||
│ ├── server.py # FastAPI app, routes, WebSocket handler
|
||||
│ ├── sessions.py # tmux session manager
|
||||
│ ├── pty_bridge.py # PTY ↔ WebSocket bridge
|
||||
│ ├── config.py # Pydantic settings with DB overlay
|
||||
│ ├── setup_wizard.py # Interactive first-run config
|
||||
│ └── static/
|
||||
│ ├── index.html # HTML page (toolbar + xterm.js)
|
||||
│ └── vendor/ # Vendored xterm.js (offline)
|
||||
├── pyproject.toml
|
||||
└── Makefile
|
||||
```
|
||||
## Changelog discipline
|
||||
|
||||
## Key Patterns
|
||||
[Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/). Every user-visible commit adds an entry under `## [Unreleased]` in [`CHANGELOG.md`](CHANGELOG.md). Cutting a release means moving Unreleased entries under a new dated version heading **and** bumping `project.yaml` `version:` in the same commit — see [`.claude/skills/git-commit/SKILL.md`](.claude/skills/git-commit/SKILL.md) for the full rule.
|
||||
|
||||
### Panel Visibility (Hide, Don't Destroy)
|
||||
## Open questions
|
||||
|
||||
```python
|
||||
def toggle_workspace(self, visible: bool) -> None:
|
||||
workspace = self.query_one("#panel-workspace")
|
||||
workspace.display = visible # Preserves all child state
|
||||
|
||||
claude = self.query_one("#panel-claude")
|
||||
claude.styles.height = "40%" if visible else "100%"
|
||||
```
|
||||
|
||||
### Background Tasks
|
||||
|
||||
Use `@work` decorator for non-blocking operations:
|
||||
|
||||
```python
|
||||
@work(thread=True)
|
||||
def refresh_git_status(self) -> None:
|
||||
result = subprocess.run(["git", "status", "--porcelain"], ...)
|
||||
self.call_from_thread(self.update_git_view, result.stdout)
|
||||
```
|
||||
|
||||
### Reactive State
|
||||
|
||||
```python
|
||||
class ClideApp(App):
|
||||
current_file: reactive[str | None] = reactive(None)
|
||||
workspace_visible: reactive[bool] = reactive(False)
|
||||
problem_count: reactive[int] = reactive(0)
|
||||
todo_count: reactive[int] = reactive(0)
|
||||
compact_mode: reactive[bool] = reactive(False)
|
||||
```
|
||||
|
||||
### Pydantic Models (Strict + Frozen)
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
class GitChange(BaseModel):
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
path: str
|
||||
status: Literal["added", "modified", "deleted", "untracked", "renamed"]
|
||||
staged: bool
|
||||
```
|
||||
|
||||
### Controller → Widget Communication
|
||||
|
||||
Controllers emit Textual messages; widgets subscribe:
|
||||
|
||||
```python
|
||||
# In controller
|
||||
class GitStatusUpdated(Message):
|
||||
def __init__(self, status: GitStatus) -> None:
|
||||
self.status = status
|
||||
super().__init__()
|
||||
|
||||
self.post_message(GitStatusUpdated(status))
|
||||
|
||||
# In widget
|
||||
def on_git_status_updated(self, event: GitStatusUpdated) -> None:
|
||||
self.refresh_view(event.status)
|
||||
```
|
||||
|
||||
## Theme System
|
||||
|
||||
### Default Theme: Summer Night
|
||||
|
||||
Based on [jackw01/summer-night-vscode-theme](https://github.com/jackw01/summer-night-vscode-theme):
|
||||
|
||||
```python
|
||||
SUMMER_NIGHT = ThemeColors(
|
||||
primary="#00a3d2", # cyan
|
||||
secondary="#00a9b9", # teal
|
||||
accent="#fa5f8b", # pink
|
||||
background="#21262f", # mono_8
|
||||
surface="#393e48", # mono_7
|
||||
panel="#292e38",
|
||||
foreground="#e2e8f5", # mono_1
|
||||
success="#00ab9a", # green
|
||||
warning="#d08447", # orange
|
||||
error="#f06c6f", # red
|
||||
)
|
||||
```
|
||||
|
||||
### Built-in Themes (22 total)
|
||||
|
||||
| Category | Themes |
|
||||
|----------|--------|
|
||||
| Core | summer-night (default), summer-day |
|
||||
| Popular | one-dark, one-dark-pro, one-light, dracula, nord, gruvbox-dark, gruvbox-light |
|
||||
| GitKraken | one-dark-teal, gamma |
|
||||
| Seasonal - Winter | winter-is-coming, monokai-winter |
|
||||
| Seasonal - Fall | fall, dark-autumn |
|
||||
| Seasonal - Halloween | all-hallows-eve, halloween |
|
||||
| Seasonal - Christmas | christmas, santa-baby |
|
||||
| Hacker | pro-hacker, hacker-style |
|
||||
| Bonus | houston |
|
||||
|
||||
### Theme Definition
|
||||
|
||||
```python
|
||||
class ThemeColors(BaseModel):
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
primary: str # Main accent
|
||||
secondary: str # Secondary accent
|
||||
accent: str # Highlight accent
|
||||
background: str # Main background
|
||||
surface: str # Elevated surfaces
|
||||
panel: str # Panel backgrounds
|
||||
foreground: str # Primary text
|
||||
success: str # Success/green
|
||||
warning: str # Warning/yellow
|
||||
error: str # Error/red
|
||||
|
||||
class ThemeDefinition(BaseModel):
|
||||
name: str # Identifier (e.g., "summer-night")
|
||||
display_name: str # Human-readable name
|
||||
dark: bool # Dark or light theme
|
||||
colors: ThemeColors
|
||||
```
|
||||
|
||||
### Custom Themes
|
||||
|
||||
Users can add themes in `.config/themes/`:
|
||||
|
||||
```toml
|
||||
# .config/themes/my-theme.toml
|
||||
name = "my-theme"
|
||||
display_name = "My Custom Theme"
|
||||
dark = true
|
||||
|
||||
[colors]
|
||||
primary = "#007acc"
|
||||
secondary = "#3c3c3c"
|
||||
accent = "#0e639c"
|
||||
background = "#1e1e1e"
|
||||
surface = "#252526"
|
||||
panel = "#2d2d30"
|
||||
foreground = "#d4d4d4"
|
||||
success = "#4ec9b0"
|
||||
warning = "#dcdcaa"
|
||||
error = "#f44747"
|
||||
```
|
||||
|
||||
### Theme Switching
|
||||
|
||||
- Keybinding: `Ctrl+K Ctrl+T`
|
||||
- Settings: `theme = "summer-night"` in ClideSettings
|
||||
- Runtime: `app.theme = "dracula"`
|
||||
|
||||
## Keybindings (Alt-based)
|
||||
|
||||
| Action | Binding |
|
||||
|--------|---------|
|
||||
| Quit | `Alt+Q` |
|
||||
| Command palette | `Alt+P` |
|
||||
| Quick open | `Alt+O` |
|
||||
| Toggle left sidebar | `Alt+B` |
|
||||
| Toggle right sidebar | `Alt+Shift+B` |
|
||||
| Toggle terminal | `` Alt+` `` |
|
||||
| Focus Claude | `Alt+1` |
|
||||
| Focus Editor | `Alt+2` |
|
||||
| Focus Terminal | `Alt+3` |
|
||||
| Toggle compact mode | `Alt+C` |
|
||||
| Git panel | `Alt+G` |
|
||||
| Problems panel | `Alt+M` |
|
||||
| Select theme | `Alt+T` |
|
||||
| Save file | `Alt+S` |
|
||||
|
||||
## Configuration
|
||||
|
||||
Settings are loaded from multiple sources (in priority order):
|
||||
1. Environment variables (`CLIDE_*`)
|
||||
2. `.config/settings.toml`
|
||||
3. Defaults in ClideSettings
|
||||
|
||||
```python
|
||||
class ClideSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="CLIDE_",
|
||||
env_file=".env",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
theme: str = "summer-night"
|
||||
jira_enabled: bool = False
|
||||
jira_cli_path: str = "jira"
|
||||
|
||||
panels: PanelConfig = PanelConfig()
|
||||
keybindings: KeybindingsConfig = KeybindingsConfig()
|
||||
```
|
||||
|
||||
## Documentation Links
|
||||
|
||||
### Core Stack
|
||||
- [Textual Documentation](https://textual.textualize.io/)
|
||||
- [Textual Testing Guide](https://textual.textualize.io/guide/testing/)
|
||||
- [Textual Themes Guide](https://textual.textualize.io/guide/design/)
|
||||
- [Typer Documentation](https://typer.tiangolo.com/)
|
||||
- [Pydantic v2 Documentation](https://docs.pydantic.dev/latest/)
|
||||
- [Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)
|
||||
|
||||
### Testing
|
||||
- [pytest-asyncio](https://pytest-asyncio.readthedocs.io/en/latest/)
|
||||
- [pytest-textual-snapshot](https://github.com/Textualize/pytest-textual-snapshot)
|
||||
|
||||
### Extensions
|
||||
- [Pluggy Documentation](https://pluggy.readthedocs.io/)
|
||||
|
||||
### Build
|
||||
- [PyInstaller Documentation](https://pyinstaller.org/)
|
||||
- [Gitea Actions](https://docs.gitea.com/usage/actions/overview)
|
||||
|
||||
### Theme References
|
||||
- [Summer Night VSCode Theme](https://github.com/jackw01/summer-night-vscode-theme)
|
||||
Open questions live under [`decisions/questions-*.md`](decisions/questions.md).
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
See [`decisions/README.md`](decisions/README.md).
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Jeroen Schweitzer
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,121 +1,182 @@
|
||||
.PHONY: setup run test test-single typecheck lint format build build-macos build-linux build-windows build-all clean help
|
||||
# clide — local dev targets. CI scripts in ci/ shell out to these.
|
||||
#
|
||||
# One toolchain: Flutter + Dart. Native supporter tools (`ptyc`, future
|
||||
# peers) live in their own directories with their own build targets and
|
||||
# compose in under `make build`.
|
||||
|
||||
PYTHON := python3.12
|
||||
VENV := .venv
|
||||
BIN := $(VENV)/bin
|
||||
TEST ?= tests/
|
||||
VERSION ?= 1.0.0
|
||||
INSTALL_DIR ?= $(HOME)/.local/bin
|
||||
|
||||
# Colors for output
|
||||
BLUE := \033[0;34m
|
||||
GREEN := \033[0;32m
|
||||
RESET := \033[0m
|
||||
# Version stamping. Source of truth: project.yaml `version:` field. Local
|
||||
# builds augment with git short SHA + dirty marker (semver build metadata).
|
||||
VERSION_BASE ?= $(shell awk -F': *' '/^version:/ {gsub(/[" ]/,"",$$2); print $$2; exit}' project.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)
|
||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
help:
|
||||
@printf "$(BLUE)Clide Development Commands$(RESET)\n"
|
||||
@printf "\n"
|
||||
@printf "$(GREEN)setup$(RESET) Create venv and install dependencies\n"
|
||||
@printf "$(GREEN)run$(RESET) Run the application\n"
|
||||
@printf "$(GREEN)test$(RESET) Run all tests\n"
|
||||
@printf "$(GREEN)test-single$(RESET) Run single test (TEST=path::test_name)\n"
|
||||
@printf "$(GREEN)typecheck$(RESET) Run mypy type checking\n"
|
||||
@printf "$(GREEN)lint$(RESET) Run ruff linter\n"
|
||||
@printf "$(GREEN)format$(RESET) Run ruff formatter\n"
|
||||
@printf "$(GREEN)build$(RESET) Build executable for current platform\n"
|
||||
@printf "$(GREEN)clean$(RESET) Remove build artifacts and caches\n"
|
||||
@printf "\n"
|
||||
@printf "$(BLUE)Distribution Builds$(RESET)\n"
|
||||
@printf "\n"
|
||||
@printf "$(GREEN)build-macos$(RESET) Build macOS DMG (VERSION=x.x.x)\n"
|
||||
@printf "$(GREEN)build-linux$(RESET) Build Linux AppImage (VERSION=x.x.x)\n"
|
||||
@printf "$(GREEN)build-windows$(RESET) Build Windows installer (VERSION=x.x.x)\n"
|
||||
.PHONY: help
|
||||
help: ## Show this help.
|
||||
@awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_-]+:.*##/ {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
setup:
|
||||
@echo "Creating virtual environment..."
|
||||
$(PYTHON) -m venv $(VENV)
|
||||
@echo "Installing dependencies..."
|
||||
$(BIN)/pip install --upgrade pip
|
||||
$(BIN)/pip install -e ".[dev,build]"
|
||||
@echo "Installing pre-commit hooks..."
|
||||
$(BIN)/pre-commit install || true
|
||||
@printf "$(GREEN)Setup complete! Activate with: source $(VENV)/bin/activate$(RESET)\n"
|
||||
# -- app + core (Flutter / Dart) ----------------------------------------
|
||||
|
||||
run:
|
||||
$(BIN)/python -m clide
|
||||
# App and core targets gracefully noop if the Dart package isn't
|
||||
# scaffolded yet (pre-Tier-0 state) or if flutter isn't installed
|
||||
# locally.
|
||||
APP_PRESENT := $(shell test -f pubspec.yaml && echo yes || echo no)
|
||||
HAS_FLUTTER := $(shell command -v flutter >/dev/null && echo yes || echo no)
|
||||
|
||||
test:
|
||||
$(BIN)/pytest $(TEST)
|
||||
.PHONY: build
|
||||
build: ## Build the Dart AOT `clide` binary (CLI + --daemon modes in one binary).
|
||||
ifeq ($(APP_PRESENT),yes)
|
||||
dart compile exe bin/clide.dart -o bin/clide \
|
||||
--define=clideVersion=$(VERSION) \
|
||||
--define=clideCommit=$(COMMIT) \
|
||||
--define=clideDate=$(DATE)
|
||||
else
|
||||
@echo "(pubspec.yaml not scaffolded yet; skipping)"
|
||||
endif
|
||||
|
||||
test-single:
|
||||
$(BIN)/pytest $(TEST) -v
|
||||
.PHONY: install
|
||||
install: build ## Install bin/clide into $(INSTALL_DIR).
|
||||
ifeq ($(APP_PRESENT),yes)
|
||||
install -m 0755 bin/clide $(INSTALL_DIR)/clide
|
||||
else
|
||||
@echo "(pubspec.yaml not scaffolded yet; skipping)"
|
||||
endif
|
||||
|
||||
test-cov:
|
||||
$(BIN)/pytest --cov=clide/clide --cov-report=html --cov-report=term
|
||||
.PHONY: pubget
|
||||
pubget: ## flutter pub get (hydrate the pub cache).
|
||||
ifeq ($(APP_PRESENT),yes)
|
||||
flutter pub get
|
||||
else
|
||||
@echo "(pubspec.yaml not scaffolded yet; skipping)"
|
||||
endif
|
||||
|
||||
test-snapshots:
|
||||
$(BIN)/pytest tests/snapshots/
|
||||
.PHONY: analyze
|
||||
analyze: ## dart analyze / flutter analyze.
|
||||
ifeq ($(APP_PRESENT),yes)
|
||||
flutter analyze
|
||||
else
|
||||
@echo "(pubspec.yaml not scaffolded yet; skipping)"
|
||||
endif
|
||||
|
||||
test-snapshots-update:
|
||||
$(BIN)/pytest tests/snapshots/ --snapshot-update
|
||||
.PHONY: format
|
||||
format: ## dart format --set-exit-if-changed.
|
||||
ifeq ($(APP_PRESENT),yes)
|
||||
dart format --set-exit-if-changed .
|
||||
else
|
||||
@echo "(pubspec.yaml not scaffolded yet; skipping)"
|
||||
endif
|
||||
|
||||
typecheck:
|
||||
$(BIN)/mypy clide/
|
||||
.PHONY: test
|
||||
test: ## Fast: analyze + format + unit + widget + golden (<60s).
|
||||
ci/test.sh
|
||||
|
||||
lint:
|
||||
$(BIN)/ruff check clide/ tests/
|
||||
.PHONY: test-a11y
|
||||
test-a11y: ## A11y contract (semantic coverage + keyboard + contrast + i18n).
|
||||
ci/test_a11y.sh
|
||||
|
||||
format:
|
||||
$(BIN)/ruff format clide/ tests/
|
||||
$(BIN)/ruff check --fix clide/ tests/
|
||||
.PHONY: test-integration
|
||||
test-integration: ## Integration tests (real app boot; xvfb on headless Linux).
|
||||
ci/test_integration.sh
|
||||
|
||||
build:
|
||||
$(BIN)/pyinstaller clide.spec --clean
|
||||
.PHONY: test-e2e
|
||||
test-e2e: build ## Daemon subprocess + web WASM Playwright smoke.
|
||||
ci/test_e2e.sh
|
||||
|
||||
build-onefile:
|
||||
$(BIN)/pyinstaller \
|
||||
--name clide \
|
||||
--onefile \
|
||||
--clean \
|
||||
--noconfirm \
|
||||
clide/clide/__main__.py
|
||||
.PHONY: test-all
|
||||
test-all: test test-a11y test-integration test-e2e ## Everything, sequentially.
|
||||
|
||||
clean:
|
||||
rm -rf $(VENV)
|
||||
rm -rf dist/ build/
|
||||
rm -rf .pytest_cache/ .mypy_cache/ .ruff_cache/
|
||||
rm -rf htmlcov/ .coverage
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
.PHONY: coverage
|
||||
coverage: ## flutter test --coverage + lcov summary.
|
||||
ci/test_coverage.sh
|
||||
|
||||
# CI targets (for GitHub Actions)
|
||||
ci-lint:
|
||||
pip install ruff mypy
|
||||
ruff check clide/ tests/
|
||||
mypy clide/
|
||||
.PHONY: smoke-bundle
|
||||
smoke-bundle: ## Build Linux release bundle and run it under xvfb for 5s.
|
||||
ci/smoke_bundle.sh
|
||||
|
||||
ci-test:
|
||||
pip install -e ".[dev]"
|
||||
pytest --cov=clide/clide --cov-report=xml
|
||||
# -- web UI harness (for Claude Code to drive the app) -----------------
|
||||
|
||||
ci-build:
|
||||
pip install -e ".[build]"
|
||||
pyinstaller clide.spec --clean
|
||||
.PHONY: ui-dev
|
||||
ui-dev: ## Build web WASM + start localhost:4280 in the background.
|
||||
tools/ui/build.sh
|
||||
tools/ui/serve.sh
|
||||
|
||||
# Distribution builds
|
||||
build-macos:
|
||||
@printf "$(BLUE)Building macOS distribution...$(RESET)\n"
|
||||
./scripts/build-macos.sh $(VERSION)
|
||||
.PHONY: ui-stop
|
||||
ui-stop: ## Stop the background web server.
|
||||
tools/ui/stop.sh
|
||||
|
||||
build-linux:
|
||||
@printf "$(BLUE)Building Linux AppImage...$(RESET)\n"
|
||||
./scripts/build-linux.sh $(VERSION)
|
||||
.PHONY: ui-smoke
|
||||
ui-smoke: ## Build + serve + run Playwright smoke + stop.
|
||||
tools/ui/build.sh
|
||||
tools/ui/serve.sh
|
||||
@sh -c 'trap "tools/ui/stop.sh >/dev/null 2>&1" EXIT; cd tools/ui && npx playwright test smoke.spec.ts'
|
||||
|
||||
build-windows:
|
||||
@echo "$(BLUE)Building Windows installer...$(RESET)"
|
||||
powershell -ExecutionPolicy Bypass -File scripts/build-windows.ps1 -Version $(VERSION)
|
||||
.PHONY: build-linux
|
||||
build-linux: ## flutter build linux (desktop bundle).
|
||||
ifeq ($(APP_PRESENT),yes)
|
||||
flutter build linux
|
||||
else
|
||||
@echo "(pubspec.yaml not scaffolded yet; skipping)"
|
||||
endif
|
||||
|
||||
build-all: build-linux
|
||||
@printf "\n"
|
||||
@printf "$(GREEN)Linux build complete.$(RESET)\n"
|
||||
@printf "$(BLUE)Note:$(RESET) macOS build requires: make build-macos VERSION=$(VERSION)\n"
|
||||
@printf "$(BLUE)Note:$(RESET) Windows build requires Windows: make build-windows VERSION=$(VERSION)\n"
|
||||
.PHONY: build-macos
|
||||
build-macos: ## flutter build macos (desktop bundle).
|
||||
ifeq ($(APP_PRESENT),yes)
|
||||
flutter build macos
|
||||
else
|
||||
@echo "(pubspec.yaml not scaffolded yet; skipping)"
|
||||
endif
|
||||
|
||||
# -- ptyc (C supporter tool) --------------------------------------------
|
||||
|
||||
# Compiled alongside the main binary. Tiny, no deps beyond libc.
|
||||
PTYX_PRESENT := $(shell test -f ptyc/Makefile && echo yes || echo no)
|
||||
|
||||
.PHONY: ptyc-build
|
||||
ptyc-build: ## Build the ptyc PTY-spawn helper.
|
||||
ifeq ($(PTYX_PRESENT),yes)
|
||||
$(MAKE) -C ptyc
|
||||
else
|
||||
@echo "(ptyc/ not scaffolded yet; skipping)"
|
||||
endif
|
||||
|
||||
.PHONY: ptyc-clean
|
||||
ptyc-clean: ## Clean ptyc build artefacts.
|
||||
ifeq ($(PTYX_PRESENT),yes)
|
||||
$(MAKE) -C ptyc clean
|
||||
else
|
||||
@echo "(ptyc/ not scaffolded yet; skipping)"
|
||||
endif
|
||||
|
||||
# -- security ------------------------------------------------------------
|
||||
|
||||
.PHONY: security
|
||||
security: ## Dart advisory review + ptyc source review (manual — no floating deps).
|
||||
@echo "security: Dart advisories reviewed manually before pubspec.yaml bumps;"
|
||||
@echo " ptyc is reviewed by reading it (tiny libc-only C)."
|
||||
@echo " Automated Dart CVE tooling lands here when a reliable option exists."
|
||||
|
||||
# -- pre-push gate -------------------------------------------------------
|
||||
|
||||
.PHONY: decisions-validate
|
||||
decisions-validate: ## Parser dry-run over decisions/*.md (cheap pre-push gate).
|
||||
tools/scripts/plan decisions validate
|
||||
|
||||
.PHONY: push-check
|
||||
push-check: decisions-validate test test-a11y ## Pre-push gate: decisions + fast unit + widget + golden + a11y (<90s).
|
||||
|
||||
.PHONY: hooks
|
||||
hooks: ## Install the repo's git hooks (points core.hooksPath at .githooks/).
|
||||
git config core.hooksPath .githooks
|
||||
@echo "hooks installed (core.hooksPath = .githooks)"
|
||||
|
||||
# -- housekeeping --------------------------------------------------------
|
||||
|
||||
.PHONY: clean
|
||||
clean: ## Remove build artefacts.
|
||||
rm -rf bin build .dart_tool
|
||||
$(MAKE) ptyc-clean
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
@@ -1,168 +1,25 @@
|
||||
# Clide
|
||||
# clide
|
||||
|
||||
A terminal-based IDE that wraps Claude Code CLI, putting AI-assisted development at the center of your workflow.
|
||||
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.
|
||||
|
||||
## Why Clide?
|
||||
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.
|
||||
|
||||
Claude Code is powerful, but switching between terminal, editor, and project tools breaks your flow. Clide brings everything into one interface:
|
||||
## Three surfaces, one tool
|
||||
|
||||
- **Claude stays visible** — Claude Code runs in the center panel, always accessible
|
||||
- **Context at a glance** — File tree, git status, problems, and TODOs in dedicated panels
|
||||
- **Panels appear when needed** — Editor, diff viewer, and terminal stay hidden until you need them
|
||||
- **Git integration** — Commit, stash, pull, and push via Claude with built-in skills
|
||||
- **22 themes** — From summer-night to dracula, with custom theme support
|
||||
- **`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.
|
||||
|
||||
## Screenshot
|
||||
## Why the rebuild?
|
||||
|
||||
```
|
||||
┌─────────────────┬─────────────────────────┬──────────────────┐
|
||||
│ Sidebar │ Workspace │ Context │
|
||||
│ │ [Editor][Diff][Terminal]│ │
|
||||
│ [Files][Git] │ (appears when needed) │ [Jira][TODOs] │
|
||||
│ [Tree] ├─────────────────────────┤ [Problems] │
|
||||
│ │ │ │
|
||||
│ │ Claude │ │
|
||||
│ │ (always visible) │ │
|
||||
│ │ │ │
|
||||
├─────────────────┤ ├──────────────────┤
|
||||
│ ⎇ main ▾ │ │ │
|
||||
│ staged: 2 │ │ │
|
||||
└─────────────────┴─────────────────────────┴──────────────────┘
|
||||
```
|
||||
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.
|
||||
|
||||
## Features
|
||||
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.
|
||||
|
||||
### Left Sidebar
|
||||
- **Files** — Project file tree with syntax-aware icons
|
||||
- **Git** — Staged/unstaged changes with action buttons
|
||||
- **Tree** — Visual branch graph
|
||||
- **Branch status** — Current branch with quick switcher
|
||||
## Status
|
||||
|
||||
### Center
|
||||
- **Claude Code** — Full PTY terminal integration, always visible
|
||||
- **Editor** — Syntax highlighting via tree-sitter
|
||||
- **Diff** — Side-by-side diff viewer
|
||||
- **Terminal** — Command execution
|
||||
|
||||
### Right Context
|
||||
- **Jira** — Issue display via CLI integration
|
||||
- **TODOs** — Code comments and TODO.md items
|
||||
- **Problems** — Linter errors and warnings
|
||||
|
||||
### Git Operations
|
||||
|
||||
Click buttons in the Git panel to delegate operations to Claude:
|
||||
|
||||
| Button | Skill | What Claude Does |
|
||||
|--------|-------|------------------|
|
||||
| Commit | `/commit` | Reviews changes, writes commit message |
|
||||
| Stash | `/stash` | Stashes working changes |
|
||||
| Pull | `/pull` | Pulls with rebase, helps resolve conflicts |
|
||||
| Push | `/push` | Pushes to remote, sets upstream if needed |
|
||||
|
||||
Skills are installed automatically to your project's `.claude/skills/` directory.
|
||||
|
||||
## Installation
|
||||
|
||||
### Requirements
|
||||
|
||||
- Python 3.12+
|
||||
- Git
|
||||
- Claude Code CLI (installed and authenticated)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd clide
|
||||
make setup
|
||||
make run
|
||||
```
|
||||
|
||||
Or install directly:
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
clide
|
||||
```
|
||||
|
||||
## Keybindings
|
||||
|
||||
All shortcuts use `Alt` to avoid conflicts with Claude Code input.
|
||||
|
||||
| Action | Binding |
|
||||
|--------|---------|
|
||||
| Toggle left sidebar | `Alt+B` |
|
||||
| Toggle right sidebar | `Alt+Shift+B` |
|
||||
| Toggle terminal | `` Alt+` `` |
|
||||
| Focus Claude | `Alt+1` |
|
||||
| Focus Editor | `Alt+2` |
|
||||
| Focus Terminal | `Alt+3` |
|
||||
| Toggle compact mode | `Alt+C` |
|
||||
| Select theme | `Alt+T` |
|
||||
| Quit | `Alt+Q` |
|
||||
|
||||
## Themes
|
||||
|
||||
22 built-in themes. Press `Alt+T` to switch.
|
||||
|
||||
| Category | Themes |
|
||||
|----------|--------|
|
||||
| Core | summer-night (default), summer-day |
|
||||
| Popular | one-dark, one-dark-pro, one-light, dracula, nord, gruvbox-dark, gruvbox-light |
|
||||
| Seasonal | winter-is-coming, monokai-winter, fall, dark-autumn |
|
||||
| Special | all-hallows-eve, halloween, christmas, santa-baby |
|
||||
| Hacker | pro-hacker, hacker-style |
|
||||
|
||||
Create custom themes in `~/.clide/themes/` as TOML files.
|
||||
|
||||
## Configuration
|
||||
|
||||
Settings stored in `~/.clide/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"theme": "summer-night",
|
||||
"compact_mode": false,
|
||||
"jira_enabled": false
|
||||
}
|
||||
```
|
||||
|
||||
Override with environment variables:
|
||||
|
||||
```bash
|
||||
CLIDE_THEME=dracula clide
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Library |
|
||||
|-----------|---------|
|
||||
| Runtime | Python 3.12+ |
|
||||
| TUI Framework | Textual |
|
||||
| CLI | Typer |
|
||||
| Data Validation | Pydantic v2 |
|
||||
| Extensions | Pluggy |
|
||||
| Syntax Highlighting | tree-sitter |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
make setup # Create venv, install deps
|
||||
make run # Run application
|
||||
make test # Run all tests
|
||||
make typecheck # Run mypy
|
||||
make lint # Run ruff
|
||||
make format # Format code
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [User Manual](docs/user-manual.md) — How to use Clide
|
||||
- [UI/UX Specification](docs/tui-ide-spec.md) — Design decisions
|
||||
- [Architecture](docs/ARCHITECTURE.md) — Technical overview
|
||||
- [Code Organization](docs/code-organization.md) — Project structure
|
||||
No build yet beyond the repo scaffold. Tier 0 acceptance: sidecar daemon runs, Flutter app connects, empty IDE shell appears with placeholders.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
MIT. See [`LICENSE`](LICENSE).
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
@@ -0,0 +1,30 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "cc0734ac716fbb8b90f3f9db8020958b1553afa7"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
|
||||
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
|
||||
- platform: web
|
||||
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
|
||||
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -0,0 +1,3 @@
|
||||
# clide_app
|
||||
|
||||
A new Flutter project.
|
||||
@@ -0,0 +1 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide_app/app.dart';
|
||||
import 'package:clide_app/builtin/default_layout/default_layout.dart';
|
||||
import 'package:clide_app/builtin/ipc_status/ipc_status.dart';
|
||||
import 'package:clide_app/builtin/theme_picker/theme_picker.dart';
|
||||
import 'package:clide_app/builtin/welcome/welcome.dart';
|
||||
import 'package:clide_app/kernel/kernel.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../test/helpers/fake_ipc.dart';
|
||||
|
||||
/// The load-bearing startup gate.
|
||||
///
|
||||
/// Tests the *real* `ClideApp` boot path with real bindings (not a
|
||||
/// mocked-up widget tree). Catches the class of regressions a widget
|
||||
/// test can't see: initialization-order bugs, asset-not-bundled,
|
||||
/// plugin-init failures, notifier-leak-on-boot.
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets(
|
||||
'clide app boots with classic 3-column layout + welcome + statusbar',
|
||||
(tester) async {
|
||||
final themes = [
|
||||
await const ThemeLoader().fromAsset(
|
||||
rootBundle,
|
||||
'lib/kernel/src/theme/themes/summer-night.yaml',
|
||||
),
|
||||
];
|
||||
final services = await KernelServices.boot(
|
||||
appDir: await Directory.systemTemp.createTemp('clide_intg_'),
|
||||
bundledThemes: themes,
|
||||
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
|
||||
preloadNamespaces: const [
|
||||
'builtin.welcome',
|
||||
'builtin.ipc-status',
|
||||
'builtin.theme-picker',
|
||||
'builtin.default-layout',
|
||||
],
|
||||
daemonClientFactory: (log, events) =>
|
||||
FakeDaemonClient(log: log, events: events),
|
||||
autoStartDaemonClient: false,
|
||||
);
|
||||
services.extensions
|
||||
..register(DefaultLayoutExtension())
|
||||
..register(WelcomeExtension())
|
||||
..register(IpcStatusExtension())
|
||||
..register(ThemePickerExtension());
|
||||
await services.extensions.activateAll();
|
||||
|
||||
await tester.pumpWidget(ClideApp(services: services));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Shell — three columns and a statusbar all materialize.
|
||||
expect(find.byType(RootLayout), findsOneWidget);
|
||||
expect(find.byType(SlotHost), findsWidgets);
|
||||
expect(find.byType(StatusbarHost), findsOneWidget);
|
||||
|
||||
// Welcome tab is mounted in the workspace.
|
||||
expect(find.text('clide'), findsWidgets);
|
||||
expect(find.text('Open project'), findsOneWidget);
|
||||
|
||||
// IPC status indicator reports disconnected (fake client never connects).
|
||||
expect(find.text('disconnected'), findsOneWidget);
|
||||
|
||||
await services.dispose();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide_app/app.dart';
|
||||
import 'package:clide_app/builtin/default_layout/default_layout.dart';
|
||||
import 'package:clide_app/builtin/ipc_status/ipc_status.dart';
|
||||
import 'package:clide_app/builtin/welcome/welcome.dart';
|
||||
import 'package:clide_app/kernel/kernel.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../test/helpers/fake_ipc.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('disable + re-enable an extension mounts/unmounts its UI',
|
||||
(tester) async {
|
||||
final themes = [
|
||||
await const ThemeLoader().fromAsset(
|
||||
rootBundle,
|
||||
'lib/kernel/src/theme/themes/summer-night.yaml',
|
||||
),
|
||||
];
|
||||
final services = await KernelServices.boot(
|
||||
appDir: await Directory.systemTemp.createTemp('clide_lc_'),
|
||||
bundledThemes: themes,
|
||||
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
|
||||
preloadNamespaces: const [
|
||||
'builtin.welcome',
|
||||
'builtin.ipc-status',
|
||||
'builtin.default-layout',
|
||||
],
|
||||
daemonClientFactory: (log, events) =>
|
||||
FakeDaemonClient(log: log, events: events),
|
||||
autoStartDaemonClient: false,
|
||||
);
|
||||
services.extensions
|
||||
..register(DefaultLayoutExtension())
|
||||
..register(WelcomeExtension())
|
||||
..register(IpcStatusExtension());
|
||||
await services.extensions.activateAll();
|
||||
|
||||
await tester.pumpWidget(ClideApp(services: services));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('disconnected'), findsOneWidget);
|
||||
|
||||
// Disable ipc-status; status item should disappear.
|
||||
await services.extensions.setEnabled('builtin.ipc-status', false);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('disconnected'), findsNothing);
|
||||
|
||||
// Re-enable; status item reappears.
|
||||
await services.extensions.setEnabled('builtin.ipc-status', true);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('disconnected'), findsOneWidget);
|
||||
|
||||
await services.dispose();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide_app/app.dart';
|
||||
import 'package:clide_app/builtin/default_layout/default_layout.dart';
|
||||
import 'package:clide_app/builtin/theme_picker/theme_picker.dart';
|
||||
import 'package:clide_app/builtin/welcome/welcome.dart';
|
||||
import 'package:clide_app/kernel/kernel.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../test/helpers/fake_ipc.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('theme.pick command opens modal; selecting dismisses it',
|
||||
(tester) async {
|
||||
final themes = [
|
||||
await const ThemeLoader().fromAsset(
|
||||
rootBundle,
|
||||
'lib/kernel/src/theme/themes/summer-night.yaml',
|
||||
),
|
||||
];
|
||||
final services = await KernelServices.boot(
|
||||
appDir: await Directory.systemTemp.createTemp('clide_theme_intg_'),
|
||||
bundledThemes: themes,
|
||||
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
|
||||
preloadNamespaces: const [
|
||||
'builtin.welcome',
|
||||
'builtin.theme-picker',
|
||||
'builtin.default-layout',
|
||||
],
|
||||
daemonClientFactory: (log, events) =>
|
||||
FakeDaemonClient(log: log, events: events),
|
||||
autoStartDaemonClient: false,
|
||||
);
|
||||
services.extensions
|
||||
..register(DefaultLayoutExtension())
|
||||
..register(WelcomeExtension())
|
||||
..register(ThemePickerExtension());
|
||||
await services.extensions.activateAll();
|
||||
|
||||
await tester.pumpWidget(ClideApp(services: services));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Invoke the command.
|
||||
await services.commands.execute('theme.pick');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Select theme'), findsOneWidget);
|
||||
expect(find.text('Cancel'), findsOneWidget);
|
||||
|
||||
// Dismiss via Cancel.
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Select theme'), findsNothing);
|
||||
|
||||
await services.dispose();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import 'package:clide_app/extension/src/contribution.dart';
|
||||
import 'package:clide_app/kernel/kernel.dart';
|
||||
import 'package:clide_app/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ClideApp extends StatelessWidget {
|
||||
const ClideApp({super.key, required this.services});
|
||||
|
||||
final KernelServices services;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideKernel(
|
||||
services: services,
|
||||
child: ClideTheme(
|
||||
controller: services.theme,
|
||||
child: _AppRoot(services: services),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AppRoot extends StatelessWidget {
|
||||
const _AppRoot({required this.services});
|
||||
final KernelServices services;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return WidgetsApp(
|
||||
title: 'clide',
|
||||
color: const Color(0xFF000000),
|
||||
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) =>
|
||||
PageRouteBuilder<T>(
|
||||
settings: settings,
|
||||
pageBuilder: (ctx, _, __) => builder(ctx),
|
||||
),
|
||||
home: _RootShell(services: services),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RootShell extends StatefulWidget {
|
||||
const _RootShell({required this.services});
|
||||
final KernelServices services;
|
||||
|
||||
@override
|
||||
State<_RootShell> createState() => _RootShellState();
|
||||
}
|
||||
|
||||
class _RootShellState extends State<_RootShell> {
|
||||
late final FocusNode _keyFocus;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_keyFocus = FocusNode()..requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_keyFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: tokens.globalForeground,
|
||||
fontSize: 13,
|
||||
fontFamilyFallback: const [
|
||||
'Inter',
|
||||
'Helvetica',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
],
|
||||
),
|
||||
child: KeyboardListener(
|
||||
focusNode: _keyFocus,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: ColoredBox(
|
||||
color: tokens.globalBackground,
|
||||
child: DialogHost(
|
||||
router: widget.services.dialog,
|
||||
child: const RootLayout(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onKey(KeyEvent event) {
|
||||
final binding = KeybindingResolver.fromKeyEvent(
|
||||
event,
|
||||
HardwareKeyboard.instance,
|
||||
);
|
||||
if (binding == null) return;
|
||||
final commandId = widget.services.keybindings.commandFor(binding);
|
||||
if (commandId == null) return;
|
||||
widget.services.commands.execute(commandId);
|
||||
}
|
||||
}
|
||||
|
||||
class RootLayout extends StatelessWidget {
|
||||
const RootLayout({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([kernel.panels, kernel.arrangement]),
|
||||
builder: (ctx, _) {
|
||||
final a = kernel.arrangement;
|
||||
final sidebarVisible = a.isVisible(Slots.sidebar);
|
||||
final contextVisible = a.isVisible(Slots.contextPanel);
|
||||
final statusVisible = a.isVisible(Slots.statusbar);
|
||||
final sidebarSize = a.sizeOf(Slots.sidebar) ?? 240;
|
||||
final contextSize = a.sizeOf(Slots.contextPanel) ?? 280;
|
||||
final statusHeight = a.sizeOf(Slots.statusbar) ?? 26;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
if (sidebarVisible) ...[
|
||||
SizedBox(
|
||||
width: sidebarSize,
|
||||
child: SlotHost(slot: Slots.sidebar),
|
||||
),
|
||||
DragResizeHandle(
|
||||
arrangement: a,
|
||||
slot: Slots.sidebar,
|
||||
axis: Axis.horizontal,
|
||||
),
|
||||
],
|
||||
const Expanded(child: SlotHost(slot: Slots.workspace)),
|
||||
if (contextVisible) ...[
|
||||
DragResizeHandle(
|
||||
arrangement: a,
|
||||
slot: Slots.contextPanel,
|
||||
axis: Axis.horizontal,
|
||||
),
|
||||
SizedBox(
|
||||
width: contextSize,
|
||||
child: SlotHost(slot: Slots.contextPanel),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (statusVisible)
|
||||
SizedBox(
|
||||
height: statusHeight,
|
||||
child: const StatusbarHost(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SlotHost extends StatelessWidget {
|
||||
const SlotHost({super.key, required this.slot});
|
||||
final SlotId slot;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([kernel.panels, kernel.i18n]),
|
||||
builder: (ctx, _) {
|
||||
final tabs = kernel.panels.tabsFor(slot);
|
||||
if (tabs.isEmpty) {
|
||||
return Container(color: tokens.panelBackground);
|
||||
}
|
||||
final activeId = kernel.panels.activeTabIn(slot) ?? tabs.first.id;
|
||||
final active = tabs.firstWhere(
|
||||
(t) => t.id == activeId,
|
||||
orElse: () => tabs.first,
|
||||
);
|
||||
return Container(
|
||||
color: tokens.panelBackground,
|
||||
child: Column(
|
||||
children: [
|
||||
ClideTabBar(
|
||||
items: [
|
||||
for (final t in tabs)
|
||||
ClideTabItem(id: t.id, title: _resolveTitle(ctx, t)),
|
||||
],
|
||||
activeId: active.id,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
),
|
||||
ClideDivider(),
|
||||
Expanded(child: active.build(ctx)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveTitle(BuildContext context, TabContribution t) {
|
||||
final key = t.titleKey;
|
||||
final ns = t.i18nNamespace;
|
||||
if (key == null || ns == null) return t.title;
|
||||
return ClideKernel.of(context).i18n.string(
|
||||
key,
|
||||
namespace: ns,
|
||||
placeholder: t.title,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StatusbarHost extends StatelessWidget {
|
||||
const StatusbarHost({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.panels,
|
||||
builder: (ctx, _) {
|
||||
final items = kernel.panels
|
||||
.contributionsFor(Slots.statusbar)
|
||||
.whereType<StatusItemContribution>()
|
||||
.toList();
|
||||
final left = items.where((i) => i.priority < 100).toList();
|
||||
final right = items.where((i) => i.priority >= 100).toList();
|
||||
return Container(
|
||||
color: tokens.statusBarBackground,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final item in left) item.build(ctx),
|
||||
const Spacer(),
|
||||
for (final item in right) item.build(ctx),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class CanvasExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.canvas';
|
||||
@override
|
||||
String get title => 'Canvas';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class ClaudeExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.claude';
|
||||
@override
|
||||
String get title => 'Claude Code';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-reserved stub. Will surface a sidebar tab with sub-tabs
|
||||
/// Settings / Skills / Agents / Hooks / MCP — `.claude/` as a
|
||||
/// first-class IDE surface. Orthogonal to pql; purely clide-internal.
|
||||
/// Commands: `claude.settings.open`, `claude.skills.new`,
|
||||
/// `claude.skills.edit`, `claude.agents.new`, `claude.agents.edit`,
|
||||
/// `claude.hooks.log`, `claude.mcp.status`.
|
||||
///
|
||||
/// Distinct from `builtin.claude`, which is reserved for Tier 1's
|
||||
/// "run Claude Code in a PTY pane."
|
||||
class ClaudeControlExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.claude-control';
|
||||
@override
|
||||
String get title => 'Claude control';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-reserved stub. Will surface a sidebar tab (filter by domain /
|
||||
/// status, backlinks from current file) + commands (`decisions.open`,
|
||||
/// `decisions.claim`, `decisions.amend`). Data source: `pql decisions
|
||||
/// …` once `Q-021` resolves; `tools/scripts/plan decisions …` until
|
||||
/// then.
|
||||
class DecisionsExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.decisions';
|
||||
@override
|
||||
String get title => 'Decisions';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
import 'package:clide_app/kernel/kernel.dart';
|
||||
|
||||
class DefaultLayoutExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.default-layout';
|
||||
@override
|
||||
String get title => 'Default layout';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
|
||||
LayoutPresetContribution? _preset;
|
||||
ClideExtensionContext? _ctx;
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
_preset ?? classicPreset(),
|
||||
CommandContribution(
|
||||
id: 'layout.reset',
|
||||
command: 'layout.reset',
|
||||
title: 'Layout: Reset to Classic',
|
||||
run: _reset,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
_ctx = ctx;
|
||||
_preset = classicPreset();
|
||||
// Register the preset's slots into the panel registry and apply
|
||||
// the arrangement so every SlotHost has something to render.
|
||||
ctx.arrangement.registerSlotsInto(ctx.panels, _preset!);
|
||||
ctx.arrangement.applyPreset(_preset!);
|
||||
}
|
||||
|
||||
Future<IpcResponse> _reset(List<String> args) async {
|
||||
final preset = _preset;
|
||||
final ctx = _ctx;
|
||||
if (preset == null || ctx == null) {
|
||||
return IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'default-layout not activated',
|
||||
),
|
||||
);
|
||||
}
|
||||
ctx.arrangement.applyPreset(preset);
|
||||
return IpcResponse.ok(id: '', data: {'preset': preset.id});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class DiffExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.diff';
|
||||
@override
|
||||
String get title => 'Diff';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class EditorExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.editor';
|
||||
@override
|
||||
String get title => 'Editor';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class ExtensionsUiExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.extensions-ui';
|
||||
@override
|
||||
String get title => 'Extensions UI';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class FilesExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.files';
|
||||
@override
|
||||
String get title => 'Files';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class GitExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.git';
|
||||
@override
|
||||
String get title => 'Git';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const ['builtin.diff'];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class GrammarsCoreExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.grammars.core';
|
||||
@override
|
||||
String get title => 'Core grammars';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const ['builtin.editor'];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class GraphExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.graph';
|
||||
@override
|
||||
String get title => 'Graph';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const ['builtin.pql'];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'src/extension.dart';
|
||||
export 'src/status_item.dart';
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:clide_app/builtin/ipc_status/src/status_item.dart';
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
import 'package:clide_app/kernel/kernel.dart';
|
||||
|
||||
class IpcStatusExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.ipc-status';
|
||||
@override
|
||||
String get title => 'Daemon connection';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
|
||||
DaemonClient? _ipc;
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
_ipc = ctx.ipc;
|
||||
}
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions {
|
||||
final ipc = _ipc;
|
||||
if (ipc == null) return const [];
|
||||
return [
|
||||
StatusItemContribution(
|
||||
id: 'ipc-status.indicator',
|
||||
priority: 100, // right-side
|
||||
listenable: ipc,
|
||||
build: (_) => IpcStatusItem(ipc: ipc),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:clide_app/kernel/kernel.dart';
|
||||
import 'package:clide_app/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class IpcStatusItem extends StatelessWidget {
|
||||
const IpcStatusItem({super.key, required this.ipc});
|
||||
|
||||
final DaemonClient ipc;
|
||||
|
||||
static const _ns = 'builtin.ipc-status';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([ipc, kernel.i18n]),
|
||||
builder: (ctx, _) {
|
||||
final connected = ipc.isConnected;
|
||||
final color = connected ? tokens.statusSuccess : tokens.statusError;
|
||||
final i = kernel.i18n;
|
||||
final label = connected
|
||||
? i.string('connected', namespace: _ns, placeholder: 'connected')
|
||||
: i.string('disconnected',
|
||||
namespace: _ns, placeholder: 'disconnected');
|
||||
final hint = connected
|
||||
? i.string('connected.hint',
|
||||
namespace: _ns,
|
||||
placeholder: 'clide daemon is reachable over the local socket')
|
||||
: i.string('disconnected.hint',
|
||||
namespace: _ns,
|
||||
placeholder:
|
||||
'clide daemon is not running — start it with `clide --daemon`');
|
||||
return Semantics(
|
||||
label: label,
|
||||
hint: hint,
|
||||
liveRegion: true,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideIcon(const PlugIcon(), size: 12, color: color),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(label, fontSize: 12, color: color),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class JiraExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.jira';
|
||||
@override
|
||||
String get title => 'Jira';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class KeybindingsUiExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.keybindings-ui';
|
||||
@override
|
||||
String get title => 'Keybindings UI';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class MarkdownExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.markdown';
|
||||
@override
|
||||
String get title => 'Markdown';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const ['builtin.editor'];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class PqlExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.pql';
|
||||
@override
|
||||
String get title => 'pql';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class ProblemsExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.problems';
|
||||
@override
|
||||
String get title => 'Problems';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class SettingsUiExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.settings-ui';
|
||||
@override
|
||||
String get title => 'Settings UI';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class TerminalExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.terminal';
|
||||
@override
|
||||
String get title => 'Terminal';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide_app/builtin/theme_picker/src/picker_view.dart';
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
class ThemePickerExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.theme-picker';
|
||||
@override
|
||||
String get title => 'Theme picker';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
|
||||
ClideExtensionContext? _ctx;
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
_ctx = ctx;
|
||||
}
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
CommandContribution(
|
||||
id: 'theme.pick',
|
||||
command: 'theme.pick',
|
||||
title: 'Theme: Pick…',
|
||||
defaultBinding: 'ctrl+k',
|
||||
run: _pick,
|
||||
),
|
||||
];
|
||||
|
||||
Future<IpcResponse> _pick(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) {
|
||||
return IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'theme-picker not activated',
|
||||
),
|
||||
);
|
||||
}
|
||||
final selected = await ctx.dialog.show<String>(
|
||||
(context, dismiss) => ThemePickerView(
|
||||
controller: ctx.theme,
|
||||
onDismiss: dismiss,
|
||||
),
|
||||
);
|
||||
return IpcResponse.ok(id: '', data: {
|
||||
'selected': selected ?? ctx.theme.currentName,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'package:clide_app/kernel/kernel.dart';
|
||||
import 'package:clide_app/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ThemePickerView extends StatefulWidget {
|
||||
const ThemePickerView({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
final ThemeController controller;
|
||||
final void Function([String? selected]) onDismiss;
|
||||
|
||||
static const ns = 'builtin.theme-picker';
|
||||
|
||||
@override
|
||||
State<ThemePickerView> createState() => _ThemePickerViewState();
|
||||
}
|
||||
|
||||
class _ThemePickerViewState extends State<ThemePickerView> {
|
||||
String? _hovered;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final themes = widget.controller.available;
|
||||
final currentName = widget.controller.currentName;
|
||||
final i = kernel.i18n;
|
||||
|
||||
return Semantics(
|
||||
container: true,
|
||||
label: i.string('modal.title',
|
||||
namespace: ThemePickerView.ns, placeholder: 'Select theme'),
|
||||
explicitChildNodes: true,
|
||||
child: ClideSurface(
|
||||
width: 420,
|
||||
color: tokens.modalSurfaceBackground,
|
||||
border: tokens.modalSurfaceBorder,
|
||||
padding: const EdgeInsets.all(16),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideText(
|
||||
i.string('modal.title',
|
||||
namespace: ThemePickerView.ns, placeholder: 'Select theme'),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClideDivider(),
|
||||
const SizedBox(height: 8),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 360),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final t in themes)
|
||||
_ThemeRow(
|
||||
name: t.name,
|
||||
displayName: t.displayName,
|
||||
selected: t.name == currentName,
|
||||
hovered: _hovered == t.name,
|
||||
hint: i.string('row.select.hint',
|
||||
namespace: ThemePickerView.ns,
|
||||
placeholder: 'Activate this theme'),
|
||||
onEnter: () => setState(() => _hovered = t.name),
|
||||
onExit: () => setState(() => _hovered = null),
|
||||
onTap: () {
|
||||
widget.controller.select(t.name);
|
||||
widget.onDismiss(t.name);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
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'),
|
||||
onPressed: () => widget.onDismiss(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeRow extends StatelessWidget {
|
||||
const _ThemeRow({
|
||||
required this.name,
|
||||
required this.displayName,
|
||||
required this.selected,
|
||||
required this.hovered,
|
||||
required this.hint,
|
||||
required this.onEnter,
|
||||
required this.onExit,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String displayName;
|
||||
final bool selected;
|
||||
final bool hovered;
|
||||
final String hint;
|
||||
final VoidCallback onEnter;
|
||||
final VoidCallback onExit;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@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;
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
label: displayName,
|
||||
hint: hint,
|
||||
onTap: onTap,
|
||||
excludeSemantics: true,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => onEnter(),
|
||||
onExit: (_) => onExit(),
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
color: bg,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
if (selected)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ClideIcon(const CheckIcon(), size: 12, color: fg),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: ClideText(displayName, color: fg),
|
||||
),
|
||||
ClideText(name, color: tokens.globalTextMuted, fontSize: 11),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'src/extension.dart';
|
||||
export 'src/picker_view.dart';
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-reserved stub. Will surface a sidebar tab (filtered list) + a
|
||||
/// workspace tab (kanban board) + commands (`tickets.open`,
|
||||
/// `tickets.new`, `tickets.move`, `tickets.block`). Data source: `pql
|
||||
/// ticket …` once `Q-021` resolves; `tools/scripts/plan ticket …` until
|
||||
/// then. Ticket persistence strategy open at `Q-022`.
|
||||
class TicketsExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.tickets';
|
||||
@override
|
||||
String get title => 'Tickets';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class TodosExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.todos';
|
||||
@override
|
||||
String get title => 'TODOs';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide_app/builtin/welcome/src/welcome_view.dart';
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
import 'package:clide_app/kernel/kernel.dart';
|
||||
|
||||
class WelcomeExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.welcome';
|
||||
@override
|
||||
String get title => 'Welcome';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'welcome.view',
|
||||
slot: Slots.workspace,
|
||||
title: 'Welcome',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -100, // anchor at the far left of the workspace tabs
|
||||
build: (_) => const WelcomeView(),
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'workspace.open-project',
|
||||
command: 'workspace.open-project',
|
||||
title: 'Workspace: Open project…',
|
||||
run: (_) async => IpcResponse.ok(
|
||||
id: '',
|
||||
data: const {'note': 'project picker lands in a later tier'},
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:clide_app/kernel/kernel.dart';
|
||||
import 'package:clide_app/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class WelcomeView extends StatelessWidget {
|
||||
const WelcomeView({super.key});
|
||||
|
||||
static const _ns = 'builtin.welcome';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.i18n,
|
||||
builder: (ctx, _) {
|
||||
final i = kernel.i18n;
|
||||
return ClideSurface(
|
||||
color: tokens.globalBackground,
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
ClideText(
|
||||
i.string('title', namespace: _ns, placeholder: 'clide'),
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w300,
|
||||
color: tokens.panelActiveBorder,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ClideText(
|
||||
i.string(
|
||||
'subtitle',
|
||||
namespace: _ns,
|
||||
placeholder: 'Flutter desktop IDE for Claude Code',
|
||||
),
|
||||
muted: true,
|
||||
fontSize: 14,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ClideButton(
|
||||
label: i.string(
|
||||
'open-project',
|
||||
namespace: _ns,
|
||||
placeholder: 'Open project',
|
||||
),
|
||||
semanticHint: i.string(
|
||||
'open-project.hint',
|
||||
namespace: _ns,
|
||||
placeholder:
|
||||
'Pick a git repository to open as the workspace',
|
||||
),
|
||||
variant: ClideButtonVariant.primary,
|
||||
onPressed: () {
|
||||
// project open UI lands with the project picker tier
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,11 @@
|
||||
/// clide extension contract.
|
||||
///
|
||||
/// ClideExtension = shipping unit. ContributionPoint = atom contributed
|
||||
/// into a kernel slot or service. One manifest may contribute N atoms
|
||||
/// across multiple slots.
|
||||
library;
|
||||
|
||||
export 'src/contribution.dart';
|
||||
export 'src/extension.dart';
|
||||
export 'src/host.dart';
|
||||
export 'src/manifest.dart';
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide_app/kernel/src/panels/slot_id.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// One atom contributed by a [ClideExtension]. Extensions declare N of
|
||||
/// these in a manifest; the kernel and slot hosts render them.
|
||||
///
|
||||
/// Adding a new contribution type: add a case to this sealed hierarchy,
|
||||
/// extend the host dispatch in the default-layout extension, and bump
|
||||
/// the extension manifest schema version.
|
||||
sealed class ContributionPoint {
|
||||
const ContributionPoint({required this.id});
|
||||
|
||||
/// Stable id for this contribution, unique within its extension.
|
||||
final String id;
|
||||
|
||||
/// The slot this contribution targets, or `null` for non-slot
|
||||
/// contributions (commands, events, grammars).
|
||||
SlotId? get slot => null;
|
||||
}
|
||||
|
||||
/// A tab in a slot that hosts tabs (sidebar / workspace / context).
|
||||
class TabContribution extends ContributionPoint {
|
||||
const TabContribution({
|
||||
required super.id,
|
||||
required this.slot,
|
||||
required this.title,
|
||||
required this.build,
|
||||
this.icon,
|
||||
this.priority = 0,
|
||||
this.fileGlobs = const [],
|
||||
this.listenable,
|
||||
this.titleKey,
|
||||
this.i18nNamespace,
|
||||
});
|
||||
|
||||
@override
|
||||
final SlotId slot;
|
||||
final String title;
|
||||
final WidgetBuilder build;
|
||||
final Object? icon;
|
||||
final int priority;
|
||||
final List<String> fileGlobs;
|
||||
final Listenable? listenable;
|
||||
|
||||
/// When set, the slot host resolves the display title via
|
||||
/// `i18n.string(titleKey, namespace: i18nNamespace, placeholder: title)`.
|
||||
/// [title] stays as the English fallback (also used in tests/logs).
|
||||
final String? titleKey;
|
||||
|
||||
/// The i18n namespace to look up [titleKey] in. Extensions usually
|
||||
/// pass their own `id`. Required when [titleKey] is set.
|
||||
final String? i18nNamespace;
|
||||
}
|
||||
|
||||
/// A status-bar item. Order is determined by [priority] within each
|
||||
/// alignment group; negative priorities float left, positive right.
|
||||
class StatusItemContribution extends ContributionPoint {
|
||||
const StatusItemContribution({
|
||||
required super.id,
|
||||
required this.build,
|
||||
this.priority = 0,
|
||||
this.listenable,
|
||||
});
|
||||
|
||||
@override
|
||||
SlotId get slot => Slots.statusbar;
|
||||
final WidgetBuilder build;
|
||||
final int priority;
|
||||
final Listenable? listenable;
|
||||
}
|
||||
|
||||
/// A button in the main toolbar.
|
||||
class ToolbarButtonContribution extends ContributionPoint {
|
||||
const ToolbarButtonContribution({
|
||||
required super.id,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.icon,
|
||||
this.tooltip,
|
||||
this.priority = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
SlotId get slot => Slots.toolbar;
|
||||
final String label;
|
||||
final Object? icon;
|
||||
final String? tooltip;
|
||||
final int priority;
|
||||
final VoidCallback onPressed;
|
||||
}
|
||||
|
||||
/// A command extensions register with [CommandRegistry]. Surfaced by the
|
||||
/// command palette, the keybinding resolver, and `clide` CLI subcommands.
|
||||
class CommandContribution extends ContributionPoint {
|
||||
const CommandContribution({
|
||||
required super.id,
|
||||
required this.command,
|
||||
required this.run,
|
||||
this.title,
|
||||
this.defaultBinding,
|
||||
});
|
||||
|
||||
final String command; // e.g. "git.commit"
|
||||
final String? title; // "Git: Commit staged"
|
||||
final String? defaultBinding; // e.g. "ctrl+shift+g"
|
||||
final Future<IpcResponse> Function(List<String> args) run;
|
||||
}
|
||||
|
||||
/// Registers an item in the OS tray / menu-bar.
|
||||
class TrayItemContribution extends ContributionPoint {
|
||||
const TrayItemContribution({
|
||||
required super.id,
|
||||
required this.label,
|
||||
required this.onSelected,
|
||||
this.priority = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
SlotId get slot => Slots.tray;
|
||||
final String label;
|
||||
final int priority;
|
||||
final VoidCallback onSelected;
|
||||
}
|
||||
|
||||
/// A named layout arrangement. One "classic" preset ships with
|
||||
/// `builtin.default-layout`; other presets can be contributed.
|
||||
class LayoutPresetContribution extends ContributionPoint {
|
||||
const LayoutPresetContribution({
|
||||
required super.id,
|
||||
required this.displayName,
|
||||
required this.slots,
|
||||
});
|
||||
|
||||
final String displayName;
|
||||
final List<LayoutSlot> slots;
|
||||
}
|
||||
|
||||
/// One slot in a [LayoutPresetContribution]. Describes where the slot
|
||||
/// appears and its initial size/visibility.
|
||||
class LayoutSlot {
|
||||
const LayoutSlot({
|
||||
required this.slot,
|
||||
required this.position,
|
||||
this.defaultSize,
|
||||
this.minSize,
|
||||
this.maxSize,
|
||||
this.visible = true,
|
||||
});
|
||||
|
||||
final SlotId slot;
|
||||
final SlotPosition position;
|
||||
final double? defaultSize;
|
||||
final double? minSize;
|
||||
final double? maxSize;
|
||||
final bool visible;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:clide_app/extension/src/contribution.dart';
|
||||
import 'package:clide_app/kernel/src/clipboard.dart';
|
||||
import 'package:clide_app/kernel/src/commands/palette.dart';
|
||||
import 'package:clide_app/kernel/src/commands/registry.dart';
|
||||
import 'package:clide_app/kernel/src/dialog.dart';
|
||||
import 'package:clide_app/kernel/src/events/bus.dart';
|
||||
import 'package:clide_app/kernel/src/files.dart';
|
||||
import 'package:clide_app/kernel/src/focus.dart';
|
||||
import 'package:clide_app/kernel/src/i18n/i18n.dart';
|
||||
import 'package:clide_app/kernel/src/ipc/client.dart';
|
||||
import 'package:clide_app/kernel/src/log.dart';
|
||||
import 'package:clide_app/kernel/src/net.dart';
|
||||
import 'package:clide_app/kernel/src/notify.dart';
|
||||
import 'package:clide_app/kernel/src/os.dart';
|
||||
import 'package:clide_app/kernel/src/panels/arrangement.dart';
|
||||
import 'package:clide_app/kernel/src/panels/registry.dart';
|
||||
import 'package:clide_app/kernel/src/project.dart';
|
||||
import 'package:clide_app/kernel/src/secrets.dart';
|
||||
import 'package:clide_app/kernel/src/settings.dart';
|
||||
import 'package:clide_app/kernel/src/theme/controller.dart';
|
||||
import 'package:clide_app/kernel/src/tray.dart';
|
||||
|
||||
/// One shipping unit. Built-in extensions compile in as Dart subclasses;
|
||||
/// third-party extensions run as Lua scripts wrapped by a `LuaExtension`
|
||||
/// adapter (Tier 6).
|
||||
abstract class ClideExtension {
|
||||
String get id;
|
||||
String get title;
|
||||
String get version;
|
||||
|
||||
/// IDs of other extensions that must be activated before this one.
|
||||
/// Missing deps → this extension is skipped at load with a warning.
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
/// The atoms this extension contributes.
|
||||
List<ContributionPoint> get contributions;
|
||||
|
||||
/// Called once after dependencies activate.
|
||||
Future<void> activate(ClideExtensionContext ctx) async {}
|
||||
|
||||
/// Called when the extension is disabled or the app shuts down.
|
||||
Future<void> deactivate() async {}
|
||||
}
|
||||
|
||||
/// Handed to every [ClideExtension.activate]. Lists every kernel service
|
||||
/// an extension may reach. The extension manager constructs a concrete
|
||||
/// instance with refs; tests can pass fakes.
|
||||
///
|
||||
/// The interface deliberately lists services individually rather than
|
||||
/// exposing a `KernelServices` aggregate — doing so would create an
|
||||
/// import cycle between the kernel facade and this file.
|
||||
abstract class ClideExtensionContext {
|
||||
String get id;
|
||||
|
||||
Logger get log;
|
||||
EventBus get events;
|
||||
SettingsStore get settings;
|
||||
ThemeController get theme;
|
||||
I18n get i18n;
|
||||
PanelRegistry get panels;
|
||||
LayoutArrangement get arrangement;
|
||||
CommandRegistry get commands;
|
||||
PaletteController get palette;
|
||||
ClideClipboard get clipboard;
|
||||
FileServices get files;
|
||||
Notifications get notify;
|
||||
DialogRouter get dialog;
|
||||
TrayRegistry get tray;
|
||||
SecretsVault get secrets;
|
||||
OsBridge get os;
|
||||
NetworkStatus get net;
|
||||
FocusTracker get focus;
|
||||
ProjectManager get project;
|
||||
DaemonClient get ipc;
|
||||
}
|
||||
|
||||
/// Sugar for i18n lookups scoped to this extension's namespace.
|
||||
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);
|
||||
|
||||
/// [t] with interpolation replacers.
|
||||
String tr(
|
||||
String key, {
|
||||
String? placeholder,
|
||||
List<I18nReplacer> replacers = const [],
|
||||
}) =>
|
||||
i18n.interpolated(
|
||||
key,
|
||||
namespace: id,
|
||||
placeholder: placeholder,
|
||||
replacers: replacers,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide_app/extension/src/manifest.dart';
|
||||
|
||||
/// Scans the third-party extensions root for `manifest.yaml` files.
|
||||
///
|
||||
/// Built-ins are registered by the app at boot; this scanner handles
|
||||
/// installed third-party extensions. Tier 0 returns an empty list
|
||||
/// until the Lua adapter lands — every call is safe to make anyway.
|
||||
class ExtensionScanner {
|
||||
const ExtensionScanner();
|
||||
|
||||
/// Typical install root: `~/.clide/extensions/<id>/manifest.yaml`.
|
||||
/// Override for tests.
|
||||
Directory defaultRoot() {
|
||||
final home = Platform.environment['HOME'] ?? '/tmp';
|
||||
return Directory('$home/.clide/extensions');
|
||||
}
|
||||
|
||||
Future<List<ExtensionManifest>> discover({Directory? root}) async {
|
||||
final dir = root ?? defaultRoot();
|
||||
if (!await dir.exists()) return const [];
|
||||
final out = <ExtensionManifest>[];
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is! Directory) continue;
|
||||
final m = File('${entity.path}/manifest.yaml');
|
||||
if (!await m.exists()) continue;
|
||||
try {
|
||||
out.add(await ExtensionManifest.fromFile(m));
|
||||
} on FormatException catch (_) {
|
||||
// skip malformed manifests; the extensions-ui will surface them
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
/// A parsed third-party extension manifest.
|
||||
///
|
||||
/// Built-in extensions don't need a manifest file — they compile in as
|
||||
/// Dart subclasses of [ClideExtension]. Third-party extensions ship a
|
||||
/// `manifest.yaml` under `~/.clide/extensions/<id>/` alongside their
|
||||
/// Lua entrypoint; this class parses and validates that file.
|
||||
class ExtensionManifest {
|
||||
const ExtensionManifest({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.version,
|
||||
required this.dependsOn,
|
||||
required this.entry,
|
||||
required this.schemaVersion,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String version;
|
||||
final List<String> dependsOn;
|
||||
final String entry; // relative path to lua entrypoint
|
||||
final int schemaVersion;
|
||||
|
||||
factory ExtensionManifest.fromYamlString(String text) {
|
||||
final doc = loadYaml(text);
|
||||
if (doc is! Map) {
|
||||
throw const FormatException('manifest root is not a map');
|
||||
}
|
||||
final id = doc['id'];
|
||||
if (id is! String || id.isEmpty) {
|
||||
throw const FormatException('manifest missing `id`');
|
||||
}
|
||||
final title = (doc['title'] as String?) ?? id;
|
||||
final version = (doc['version'] as String?) ?? '0.0.0';
|
||||
final entry = (doc['entry'] as String?) ?? 'extension.lua';
|
||||
final schemaVersion = (doc['schema_version'] as int?) ?? 1;
|
||||
final depsYaml = doc['depends_on'];
|
||||
final deps = <String>[];
|
||||
if (depsYaml is YamlList) {
|
||||
for (final d in depsYaml) {
|
||||
if (d is String) deps.add(d);
|
||||
}
|
||||
}
|
||||
return ExtensionManifest(
|
||||
id: id,
|
||||
title: title,
|
||||
version: version,
|
||||
dependsOn: deps,
|
||||
entry: entry,
|
||||
schemaVersion: schemaVersion,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<ExtensionManifest> fromFile(File f) async =>
|
||||
ExtensionManifest.fromYamlString(await f.readAsString());
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/// clide kernel — registries, stores, and shared singleton services
|
||||
/// consumed by every extension.
|
||||
///
|
||||
/// Admission rule: the kernel owns anything whose second concurrent user
|
||||
/// would create incoherent state or divergent UX. External-interfacing
|
||||
/// work generally belongs to extensions (git, pql, Linear, Jira);
|
||||
/// external-interfacing *singletons* (OS clipboard, tray, keychain)
|
||||
/// belong here.
|
||||
///
|
||||
/// Exports are added as each subsystem lands. See plan:
|
||||
/// /home/jeroenschweitzer/.claude/plans/i-want-to-discuss-cozy-zebra.md
|
||||
library;
|
||||
|
||||
export 'src/events/bus.dart';
|
||||
export 'src/events/types.dart';
|
||||
export 'src/ipc/client.dart';
|
||||
export 'src/log.dart';
|
||||
export 'src/settings.dart';
|
||||
export 'src/facade.dart';
|
||||
export 'src/clipboard.dart';
|
||||
export 'src/commands/keybindings.dart';
|
||||
export 'src/commands/palette.dart';
|
||||
export 'src/commands/registry.dart';
|
||||
export 'src/dialog.dart';
|
||||
export 'src/extensions_manager.dart';
|
||||
export 'src/files.dart';
|
||||
export 'src/focus.dart';
|
||||
export 'src/i18n/catalog_loader.dart';
|
||||
export 'src/i18n/fallback_chain.dart';
|
||||
export 'src/i18n/i18n.dart';
|
||||
export 'src/net.dart';
|
||||
export 'src/notify.dart';
|
||||
export 'src/os.dart';
|
||||
export 'src/panels/arrangement.dart';
|
||||
export 'src/project.dart';
|
||||
export 'src/secrets.dart';
|
||||
export 'src/tray.dart';
|
||||
export 'src/panels/drag_resize.dart';
|
||||
export 'src/panels/layout_preset.dart';
|
||||
export 'src/panels/registry.dart';
|
||||
export 'src/panels/slot_id.dart';
|
||||
export 'src/theme/contrast.dart';
|
||||
export 'src/theme/controller.dart';
|
||||
export 'src/theme/loader.dart';
|
||||
export 'src/theme/palette.dart';
|
||||
export 'src/theme/resolver.dart';
|
||||
export 'src/theme/semantic.dart';
|
||||
export 'src/theme/tokens.dart';
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart' as flutter_services;
|
||||
|
||||
/// Typed, per-content-kind clipboard with a plaintext fallback.
|
||||
///
|
||||
/// Extensions write typed values (`write<GitHunk>(hunk)`) and read in
|
||||
/// the same type (`readAs<GitHunk>()`). Anything with a `toPlain`
|
||||
/// callback also syncs to the OS clipboard so external apps see
|
||||
/// reasonable text. The history ring keeps the last [historyLimit]
|
||||
/// entries per type for quick recall.
|
||||
class ClideClipboard {
|
||||
ClideClipboard({this.historyLimit = 16});
|
||||
|
||||
final int historyLimit;
|
||||
final Map<Type, List<Object>> _history = {};
|
||||
|
||||
Future<void> write<T extends Object>(
|
||||
T value, {
|
||||
String Function(T)? toPlain,
|
||||
}) async {
|
||||
final bucket = _history.putIfAbsent(T, () => <Object>[]);
|
||||
bucket.insert(0, value);
|
||||
if (bucket.length > historyLimit) bucket.removeLast();
|
||||
if (toPlain != null) {
|
||||
await flutter_services.Clipboard.setData(
|
||||
flutter_services.ClipboardData(text: toPlain(value)));
|
||||
}
|
||||
}
|
||||
|
||||
T? readAs<T extends Object>() {
|
||||
final bucket = _history[T];
|
||||
if (bucket == null || bucket.isEmpty) return null;
|
||||
return bucket.first as T;
|
||||
}
|
||||
|
||||
List<T> historyOf<T extends Object>() {
|
||||
final bucket = _history[T];
|
||||
if (bucket == null) return const [];
|
||||
return bucket.cast<T>().toList(growable: false);
|
||||
}
|
||||
|
||||
Future<String?> readPlain() async {
|
||||
final d = await flutter_services.Clipboard.getData('text/plain');
|
||||
return d?.text;
|
||||
}
|
||||
|
||||
Future<void> writePlain(String text) async {
|
||||
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();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void clear() => _history.clear();
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Key combo: modifiers + primary key. Canonicalized on construction
|
||||
/// (modifiers sorted, lowercased) so equality works for lookup keys.
|
||||
@immutable
|
||||
class Keybinding {
|
||||
Keybinding({required Set<String> modifiers, required String key})
|
||||
: modifiers = _canonModifiers(modifiers),
|
||||
key = key.toLowerCase();
|
||||
|
||||
final List<String> modifiers;
|
||||
final String key;
|
||||
|
||||
static List<String> _canonModifiers(Set<String> m) {
|
||||
final normalized = m.map((s) => s.toLowerCase()).toSet().toList()..sort();
|
||||
return List.unmodifiable(normalized);
|
||||
}
|
||||
|
||||
/// Parse "ctrl+shift+g", "cmd+k", "alt+f4".
|
||||
static Keybinding parse(String spec) {
|
||||
if (spec.trim().isEmpty) {
|
||||
throw ArgumentError('empty keybinding');
|
||||
}
|
||||
final parts = spec.split('+').map((s) => s.trim()).toList();
|
||||
final key = parts.removeLast();
|
||||
if (key.isEmpty) {
|
||||
throw ArgumentError('keybinding is missing a key: "$spec"');
|
||||
}
|
||||
return Keybinding(modifiers: parts.toSet(), key: key);
|
||||
}
|
||||
|
||||
String get canonical {
|
||||
if (modifiers.isEmpty) return key;
|
||||
return '${modifiers.join('+')}+$key';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is Keybinding &&
|
||||
other.key == key &&
|
||||
listEquals(other.modifiers, modifiers);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(key, Object.hashAll(modifiers));
|
||||
|
||||
@override
|
||||
String toString() => 'Keybinding($canonical)';
|
||||
}
|
||||
|
||||
class KeybindingResolver {
|
||||
final Map<Keybinding, String> _bindings = {};
|
||||
|
||||
void bind(Keybinding b, String commandId) {
|
||||
_bindings[b] = commandId;
|
||||
}
|
||||
|
||||
void unbind(Keybinding b) {
|
||||
_bindings.remove(b);
|
||||
}
|
||||
|
||||
String? commandFor(Keybinding b) => _bindings[b];
|
||||
|
||||
Iterable<MapEntry<Keybinding, String>> get entries => _bindings.entries;
|
||||
|
||||
/// Map a Flutter [KeyEvent] to a [Keybinding] suitable for lookup.
|
||||
static Keybinding? fromKeyEvent(KeyEvent event, HardwareKeyboard keyboard) {
|
||||
if (event is! KeyDownEvent) return null;
|
||||
final label = event.logicalKey.keyLabel;
|
||||
if (label.isEmpty) return null;
|
||||
final mods = <String>{};
|
||||
if (keyboard.isControlPressed) mods.add('ctrl');
|
||||
if (keyboard.isShiftPressed) mods.add('shift');
|
||||
if (keyboard.isAltPressed) mods.add('alt');
|
||||
if (keyboard.isMetaPressed) mods.add('cmd');
|
||||
return Keybinding(modifiers: mods, key: label);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:clide_app/extension/src/contribution.dart';
|
||||
import 'package:clide_app/kernel/src/commands/registry.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class PaletteController extends ChangeNotifier {
|
||||
PaletteController(this._registry);
|
||||
|
||||
final CommandRegistry _registry;
|
||||
|
||||
bool _open = false;
|
||||
String _filter = '';
|
||||
|
||||
bool get isOpen => _open;
|
||||
String get filter => _filter;
|
||||
|
||||
void open() {
|
||||
if (_open) return;
|
||||
_open = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void close() {
|
||||
if (!_open) return;
|
||||
_open = false;
|
||||
_filter = '';
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void toggle() => _open ? close() : open();
|
||||
|
||||
void setFilter(String f) {
|
||||
if (_filter == f) return;
|
||||
_filter = f;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
List<CommandContribution> filtered() {
|
||||
if (_filter.isEmpty) return _registry.all.toList();
|
||||
final q = _filter.toLowerCase();
|
||||
return _registry.all.where((c) {
|
||||
final haystack = (c.title ?? c.command).toLowerCase();
|
||||
return haystack.contains(q);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<void> invoke(String command) async {
|
||||
close();
|
||||
await _registry.execute(command);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide_app/extension/src/contribution.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class CommandRegistry extends ChangeNotifier {
|
||||
final Map<String, CommandContribution> _byCommand = {};
|
||||
|
||||
void register(CommandContribution cmd) {
|
||||
_byCommand[cmd.command] = cmd;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void unregister(String command) {
|
||||
if (_byCommand.remove(command) != null) notifyListeners();
|
||||
}
|
||||
|
||||
Iterable<CommandContribution> get all => _byCommand.values;
|
||||
CommandContribution? get(String command) => _byCommand[command];
|
||||
|
||||
Future<IpcResponse> execute(
|
||||
String command, {
|
||||
List<String> args = const [],
|
||||
}) async {
|
||||
final c = _byCommand[command];
|
||||
if (c == null) {
|
||||
return IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.notFound,
|
||||
kind: IpcErrorKind.notFound,
|
||||
message: 'no such command: $command',
|
||||
),
|
||||
);
|
||||
}
|
||||
return c.run(args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
typedef DialogBuilder<T> = Widget Function(
|
||||
BuildContext context,
|
||||
void Function([T? result]) dismiss,
|
||||
);
|
||||
|
||||
/// Single-at-a-time modal router.
|
||||
///
|
||||
/// Extensions call [show] with a builder; the root widget (installed by
|
||||
/// [DialogHost]) listens and renders the current dialog over a dimmed
|
||||
/// backdrop. Only one dialog is active at a time — a second [show] call
|
||||
/// while one is open awaits until the first dismisses.
|
||||
class DialogRouter extends ChangeNotifier {
|
||||
DialogBuilder<Object?>? _current;
|
||||
Completer<Object?>? _completer;
|
||||
final List<_Queued> _queue = [];
|
||||
|
||||
DialogBuilder<Object?>? get current => _current;
|
||||
bool get isOpen => _current != null;
|
||||
|
||||
Future<T?> show<T extends Object>(DialogBuilder<T> builder) {
|
||||
final completer = Completer<T?>();
|
||||
final wrapped = _wrap<T>(builder);
|
||||
if (_current == null) {
|
||||
_current = wrapped;
|
||||
_completer = Completer<Object?>();
|
||||
// forward our generic completer to the typed one
|
||||
_completer!.future.then((v) {
|
||||
if (!completer.isCompleted) completer.complete(v as T?);
|
||||
});
|
||||
notifyListeners();
|
||||
} else {
|
||||
_queue.add(_Queued(wrapped, completer));
|
||||
}
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void dismiss([Object? result]) {
|
||||
if (_current == null) return;
|
||||
final c = _completer;
|
||||
_current = null;
|
||||
_completer = null;
|
||||
if (c != null && !c.isCompleted) c.complete(result);
|
||||
if (_queue.isNotEmpty) {
|
||||
final next = _queue.removeAt(0);
|
||||
_current = next.builder;
|
||||
_completer = Completer<Object?>();
|
||||
_completer!.future.then((v) {
|
||||
if (!next.completer.isCompleted) next.completer.complete(v);
|
||||
});
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
DialogBuilder<Object?> _wrap<T>(DialogBuilder<T> builder) {
|
||||
return (ctx, dismiss) => builder(ctx, ([T? v]) => dismiss(v));
|
||||
}
|
||||
}
|
||||
|
||||
class _Queued {
|
||||
_Queued(this.builder, this.completer);
|
||||
final DialogBuilder<Object?> builder;
|
||||
// ignore: strict_raw_type
|
||||
final Completer completer;
|
||||
}
|
||||
|
||||
/// Hosts the current dialog from [DialogRouter]. Place high in the tree
|
||||
/// (inside the WidgetsApp) so dialogs overlay every other surface.
|
||||
class DialogHost extends StatelessWidget {
|
||||
const DialogHost({
|
||||
super.key,
|
||||
required this.router,
|
||||
required this.child,
|
||||
this.backdropColor = const Color(0xC0000000),
|
||||
});
|
||||
|
||||
final DialogRouter router;
|
||||
final Widget child;
|
||||
final Color backdropColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
child,
|
||||
ListenableBuilder(
|
||||
listenable: router,
|
||||
builder: (ctx, _) {
|
||||
final b = router.current;
|
||||
if (b == null) return const SizedBox.shrink();
|
||||
return Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: backdropColor,
|
||||
child: Center(
|
||||
child: b(ctx, router.dismiss),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide_app/kernel/src/events/types.dart';
|
||||
|
||||
class EventBus {
|
||||
EventBus();
|
||||
|
||||
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);
|
||||
|
||||
void emit(ClideEvent event) {
|
||||
if (_controller.isClosed) return;
|
||||
_controller.add(ClideEventEnvelope(event, DateTime.now().toUtc()));
|
||||
}
|
||||
|
||||
Future<void> dispose() => _controller.close();
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
abstract class ClideEvent {
|
||||
const ClideEvent();
|
||||
|
||||
String get subsystem;
|
||||
String get kind;
|
||||
|
||||
Map<String, Object?> payload() => const {};
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ClideEventEnvelope {
|
||||
const ClideEventEnvelope(this.event, this.timestamp);
|
||||
|
||||
final ClideEvent event;
|
||||
final DateTime timestamp;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'v': 1,
|
||||
'subsystem': event.subsystem,
|
||||
'kind': event.kind,
|
||||
'ts': timestamp.toIso8601String(),
|
||||
'data': event.payload(),
|
||||
};
|
||||
}
|
||||
|
||||
class DaemonConnectionChanged extends ClideEvent {
|
||||
const DaemonConnectionChanged({required this.connected});
|
||||
final bool connected;
|
||||
@override
|
||||
String get subsystem => 'ipc';
|
||||
@override
|
||||
String get kind => 'connection-changed';
|
||||
@override
|
||||
Map<String, Object?> payload() => {'connected': connected};
|
||||
}
|
||||
|
||||
class ThemeChanged extends ClideEvent {
|
||||
const ThemeChanged({required this.themeName});
|
||||
final String themeName;
|
||||
@override
|
||||
String get subsystem => 'theme';
|
||||
@override
|
||||
String get kind => 'changed';
|
||||
@override
|
||||
Map<String, Object?> payload() => {'theme': themeName};
|
||||
}
|
||||
|
||||
class ProjectOpened extends ClideEvent {
|
||||
const ProjectOpened({required this.path});
|
||||
final String path;
|
||||
@override
|
||||
String get subsystem => 'project';
|
||||
@override
|
||||
String get kind => 'opened';
|
||||
@override
|
||||
Map<String, Object?> payload() => {'path': path};
|
||||
}
|
||||
|
||||
class ProjectClosed extends ClideEvent {
|
||||
const ProjectClosed();
|
||||
@override
|
||||
String get subsystem => 'project';
|
||||
@override
|
||||
String get kind => 'closed';
|
||||
}
|
||||
|
||||
class ExtensionActivated extends ClideEvent {
|
||||
const ExtensionActivated({required this.id});
|
||||
final String id;
|
||||
@override
|
||||
String get subsystem => 'extensions';
|
||||
@override
|
||||
String get kind => 'activated';
|
||||
@override
|
||||
Map<String, Object?> payload() => {'id': id};
|
||||
}
|
||||
|
||||
class ExtensionDeactivated extends ClideEvent {
|
||||
const ExtensionDeactivated({required this.id});
|
||||
final String id;
|
||||
@override
|
||||
String get subsystem => 'extensions';
|
||||
@override
|
||||
String get kind => 'deactivated';
|
||||
@override
|
||||
Map<String, Object?> payload() => {'id': id};
|
||||
}
|
||||
|
||||
/// Forwarded from the daemon. Feature extensions subscribe to this and
|
||||
/// narrow by subsystem+kind, or register a converter that emits a typed
|
||||
/// `ClideEvent` subclass into the bus.
|
||||
class DaemonEvent extends ClideEvent {
|
||||
const DaemonEvent({
|
||||
required this.subsystem,
|
||||
required this.kind,
|
||||
required this.data,
|
||||
required this.ts,
|
||||
});
|
||||
|
||||
@override
|
||||
final String subsystem;
|
||||
@override
|
||||
final String kind;
|
||||
final Map<String, Object?> data;
|
||||
final DateTime ts;
|
||||
|
||||
@override
|
||||
Map<String, Object?> payload() => {'ts': ts.toIso8601String(), ...data};
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide_app/extension/src/contribution.dart';
|
||||
import 'package:clide_app/extension/src/extension.dart';
|
||||
import 'package:clide_app/kernel/src/clipboard.dart';
|
||||
import 'package:clide_app/kernel/src/commands/keybindings.dart';
|
||||
import 'package:clide_app/kernel/src/commands/palette.dart';
|
||||
import 'package:clide_app/kernel/src/commands/registry.dart';
|
||||
import 'package:clide_app/kernel/src/dialog.dart';
|
||||
import 'package:clide_app/kernel/src/events/bus.dart';
|
||||
import 'package:clide_app/kernel/src/events/types.dart';
|
||||
import 'package:clide_app/kernel/src/files.dart';
|
||||
import 'package:clide_app/kernel/src/focus.dart';
|
||||
import 'package:clide_app/kernel/src/i18n/i18n.dart';
|
||||
import 'package:clide_app/kernel/src/ipc/client.dart';
|
||||
import 'package:clide_app/kernel/src/log.dart';
|
||||
import 'package:clide_app/kernel/src/net.dart';
|
||||
import 'package:clide_app/kernel/src/notify.dart';
|
||||
import 'package:clide_app/kernel/src/os.dart';
|
||||
import 'package:clide_app/kernel/src/panels/arrangement.dart';
|
||||
import 'package:clide_app/kernel/src/panels/registry.dart';
|
||||
import 'package:clide_app/kernel/src/project.dart';
|
||||
import 'package:clide_app/kernel/src/secrets.dart';
|
||||
import 'package:clide_app/kernel/src/settings.dart';
|
||||
import 'package:clide_app/kernel/src/theme/controller.dart';
|
||||
import 'package:clide_app/kernel/src/tray.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class ExtensionManager extends ChangeNotifier {
|
||||
ExtensionManager({
|
||||
required this.log,
|
||||
required this.events,
|
||||
required this.settings,
|
||||
required this.theme,
|
||||
required this.i18n,
|
||||
required this.panels,
|
||||
required this.arrangement,
|
||||
required this.commands,
|
||||
required this.palette,
|
||||
required this.keybindings,
|
||||
required this.clipboard,
|
||||
required this.files,
|
||||
required this.notify,
|
||||
required this.dialog,
|
||||
required this.tray,
|
||||
required this.secrets,
|
||||
required this.os,
|
||||
required this.net,
|
||||
required this.focus,
|
||||
required this.project,
|
||||
required this.ipc,
|
||||
});
|
||||
|
||||
final Logger log;
|
||||
final EventBus events;
|
||||
final SettingsStore settings;
|
||||
final ThemeController theme;
|
||||
final I18n i18n;
|
||||
final PanelRegistry panels;
|
||||
final LayoutArrangement arrangement;
|
||||
final CommandRegistry commands;
|
||||
final PaletteController palette;
|
||||
final KeybindingResolver keybindings;
|
||||
final ClideClipboard clipboard;
|
||||
final FileServices files;
|
||||
final Notifications notify;
|
||||
final DialogRouter dialog;
|
||||
final TrayRegistry tray;
|
||||
final SecretsVault secrets;
|
||||
final OsBridge os;
|
||||
final NetworkStatus net;
|
||||
final FocusTracker focus;
|
||||
final ProjectManager project;
|
||||
final DaemonClient ipc;
|
||||
|
||||
final Map<String, ClideExtension> _known = {};
|
||||
final Set<String> _activated = {};
|
||||
|
||||
void register(ClideExtension ext) {
|
||||
if (_known.containsKey(ext.id)) {
|
||||
log.warn('extensions', 'duplicate registration: ${ext.id}');
|
||||
return;
|
||||
}
|
||||
_known[ext.id] = ext;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Iterable<ClideExtension> get all => _known.values;
|
||||
bool isActivated(String id) => _activated.contains(id);
|
||||
|
||||
bool isEnabled(String id) {
|
||||
final v = settings.get<bool>('app.extensions.$id.enabled');
|
||||
return v ?? true;
|
||||
}
|
||||
|
||||
Future<void> setEnabled(String id, bool enabled) async {
|
||||
await settings.set<bool>('app.extensions.$id.enabled', enabled);
|
||||
if (enabled && !isActivated(id)) {
|
||||
await activate(id);
|
||||
} else if (!enabled && isActivated(id)) {
|
||||
await deactivate(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Activate every enabled extension in dependency order. Missing
|
||||
/// deps warn and skip.
|
||||
Future<void> activateAll() async {
|
||||
final order = _topoSort();
|
||||
for (final id in order) {
|
||||
if (!isEnabled(id)) continue;
|
||||
await activate(id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> activate(String id) async {
|
||||
if (_activated.contains(id)) return;
|
||||
final ext = _known[id];
|
||||
if (ext == null) {
|
||||
log.warn('extensions', 'unknown extension: $id');
|
||||
return;
|
||||
}
|
||||
for (final dep in ext.dependsOn) {
|
||||
if (!_activated.contains(dep)) {
|
||||
log.warn(
|
||||
'extensions', 'skipping ${ext.id}: dependency not activated: $dep');
|
||||
return;
|
||||
}
|
||||
}
|
||||
final ctx = _ExtensionContext(manager: this, id: ext.id);
|
||||
try {
|
||||
await ext.activate(ctx);
|
||||
for (final c in ext.contributions) {
|
||||
_applyContribution(c);
|
||||
}
|
||||
_activated.add(id);
|
||||
events.emit(ExtensionActivated(id: id));
|
||||
notifyListeners();
|
||||
log.info('extensions', 'activated $id');
|
||||
} catch (e, st) {
|
||||
log.error('extensions', 'activate failed for $id',
|
||||
error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deactivate(String id) async {
|
||||
if (!_activated.contains(id)) return;
|
||||
final ext = _known[id];
|
||||
if (ext == null) return;
|
||||
try {
|
||||
await ext.deactivate();
|
||||
for (final c in ext.contributions) {
|
||||
_removeContribution(c);
|
||||
}
|
||||
_activated.remove(id);
|
||||
events.emit(ExtensionDeactivated(id: id));
|
||||
notifyListeners();
|
||||
log.info('extensions', 'deactivated $id');
|
||||
} catch (e, st) {
|
||||
log.error('extensions', 'deactivate failed for $id',
|
||||
error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
void _applyContribution(ContributionPoint c) {
|
||||
switch (c) {
|
||||
case TabContribution _:
|
||||
case StatusItemContribution _:
|
||||
case ToolbarButtonContribution _:
|
||||
panels.contribute(c);
|
||||
case CommandContribution cmd:
|
||||
commands.register(cmd);
|
||||
final binding = cmd.defaultBinding;
|
||||
if (binding != null) {
|
||||
keybindings.bind(Keybinding.parse(binding), cmd.command);
|
||||
}
|
||||
case TrayItemContribution t:
|
||||
tray.add(t);
|
||||
case LayoutPresetContribution _:
|
||||
// Presets are consumed by the default-layout extension in its
|
||||
// own activate(); nothing for the kernel to do here.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _removeContribution(ContributionPoint c) {
|
||||
switch (c) {
|
||||
case TabContribution _:
|
||||
case StatusItemContribution _:
|
||||
case ToolbarButtonContribution _:
|
||||
panels.uncontribute(c.id);
|
||||
case CommandContribution cmd:
|
||||
commands.unregister(cmd.command);
|
||||
final binding = cmd.defaultBinding;
|
||||
if (binding != null) {
|
||||
keybindings.unbind(Keybinding.parse(binding));
|
||||
}
|
||||
case TrayItemContribution t:
|
||||
tray.remove(t.id);
|
||||
case LayoutPresetContribution _:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _topoSort() {
|
||||
final order = <String>[];
|
||||
final seen = <String>{};
|
||||
final visiting = <String>{};
|
||||
|
||||
void visit(String id) {
|
||||
if (seen.contains(id)) return;
|
||||
if (visiting.contains(id)) {
|
||||
log.warn('extensions', 'dependency cycle touching $id');
|
||||
return;
|
||||
}
|
||||
final ext = _known[id];
|
||||
if (ext == null) return;
|
||||
visiting.add(id);
|
||||
for (final dep in ext.dependsOn) {
|
||||
visit(dep);
|
||||
}
|
||||
visiting.remove(id);
|
||||
seen.add(id);
|
||||
order.add(id);
|
||||
}
|
||||
|
||||
for (final id in _known.keys) {
|
||||
visit(id);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
}
|
||||
|
||||
class _ExtensionContext implements ClideExtensionContext {
|
||||
_ExtensionContext({required this.manager, required this.id});
|
||||
final ExtensionManager manager;
|
||||
@override
|
||||
final String id;
|
||||
|
||||
@override
|
||||
Logger get log => manager.log;
|
||||
@override
|
||||
EventBus get events => manager.events;
|
||||
@override
|
||||
SettingsStore get settings => manager.settings;
|
||||
@override
|
||||
ThemeController get theme => manager.theme;
|
||||
@override
|
||||
I18n get i18n => manager.i18n;
|
||||
@override
|
||||
PanelRegistry get panels => manager.panels;
|
||||
@override
|
||||
LayoutArrangement get arrangement => manager.arrangement;
|
||||
@override
|
||||
CommandRegistry get commands => manager.commands;
|
||||
@override
|
||||
PaletteController get palette => manager.palette;
|
||||
@override
|
||||
ClideClipboard get clipboard => manager.clipboard;
|
||||
@override
|
||||
FileServices get files => manager.files;
|
||||
@override
|
||||
Notifications get notify => manager.notify;
|
||||
@override
|
||||
DialogRouter get dialog => manager.dialog;
|
||||
@override
|
||||
TrayRegistry get tray => manager.tray;
|
||||
@override
|
||||
SecretsVault get secrets => manager.secrets;
|
||||
@override
|
||||
OsBridge get os => manager.os;
|
||||
@override
|
||||
NetworkStatus get net => manager.net;
|
||||
@override
|
||||
FocusTracker get focus => manager.focus;
|
||||
@override
|
||||
ProjectManager get project => manager.project;
|
||||
@override
|
||||
DaemonClient get ipc => manager.ipc;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide_app/kernel/src/clipboard.dart';
|
||||
import 'package:clide_app/kernel/src/commands/keybindings.dart';
|
||||
import 'package:clide_app/kernel/src/commands/palette.dart';
|
||||
import 'package:clide_app/kernel/src/commands/registry.dart';
|
||||
import 'package:clide_app/kernel/src/dialog.dart';
|
||||
import 'package:clide_app/kernel/src/events/bus.dart';
|
||||
import 'package:clide_app/kernel/src/extensions_manager.dart';
|
||||
import 'package:clide_app/kernel/src/files.dart';
|
||||
import 'package:clide_app/kernel/src/focus.dart';
|
||||
import 'package:clide_app/kernel/src/i18n/catalog_loader.dart';
|
||||
import 'package:clide_app/kernel/src/i18n/i18n.dart';
|
||||
import 'package:clide_app/kernel/src/ipc/client.dart';
|
||||
import 'package:clide_app/kernel/src/log.dart';
|
||||
import 'package:clide_app/kernel/src/net.dart';
|
||||
import 'package:clide_app/kernel/src/notify.dart';
|
||||
import 'package:clide_app/kernel/src/os.dart';
|
||||
import 'package:clide_app/kernel/src/panels/arrangement.dart';
|
||||
import 'package:clide_app/kernel/src/panels/registry.dart';
|
||||
import 'package:clide_app/kernel/src/project.dart';
|
||||
import 'package:clide_app/kernel/src/secrets.dart';
|
||||
import 'package:clide_app/kernel/src/settings.dart';
|
||||
import 'package:clide_app/kernel/src/theme/controller.dart';
|
||||
import 'package:clide_app/kernel/src/theme/loader.dart';
|
||||
import 'package:clide_app/kernel/src/tray.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Aggregated kernel services. Feature code that runs outside a
|
||||
/// BuildContext (extensions, background tasks) holds a [KernelServices]
|
||||
/// ref directly; widget code reaches them via [ClideKernel.of].
|
||||
class KernelServices {
|
||||
KernelServices({
|
||||
required this.log,
|
||||
required this.settings,
|
||||
required this.events,
|
||||
required this.ipc,
|
||||
required this.theme,
|
||||
required this.i18n,
|
||||
required this.panels,
|
||||
required this.arrangement,
|
||||
required this.commands,
|
||||
required this.palette,
|
||||
required this.keybindings,
|
||||
required this.clipboard,
|
||||
required this.files,
|
||||
required this.notify,
|
||||
required this.dialog,
|
||||
required this.tray,
|
||||
required this.secrets,
|
||||
required this.os,
|
||||
required this.net,
|
||||
required this.focus,
|
||||
required this.project,
|
||||
required this.extensions,
|
||||
});
|
||||
|
||||
final Logger log;
|
||||
final SettingsStore settings;
|
||||
final EventBus events;
|
||||
final DaemonClient ipc;
|
||||
final ThemeController theme;
|
||||
final I18n i18n;
|
||||
final PanelRegistry panels;
|
||||
final LayoutArrangement arrangement;
|
||||
final CommandRegistry commands;
|
||||
final PaletteController palette;
|
||||
final KeybindingResolver keybindings;
|
||||
final ClideClipboard clipboard;
|
||||
final FileServices files;
|
||||
final Notifications notify;
|
||||
final DialogRouter dialog;
|
||||
final TrayRegistry tray;
|
||||
final SecretsVault secrets;
|
||||
final OsBridge os;
|
||||
final NetworkStatus net;
|
||||
final FocusTracker focus;
|
||||
final ProjectManager project;
|
||||
final ExtensionManager extensions;
|
||||
|
||||
static Future<KernelServices> boot({
|
||||
required Directory appDir,
|
||||
required List<ThemeDefinition> bundledThemes,
|
||||
required CatalogLoader i18nLoader,
|
||||
List<String> preloadNamespaces = const [],
|
||||
Locale defaultLocale = const Locale('en', 'US'),
|
||||
Locale? initialLocale,
|
||||
List<Locale> availableLocales = const [Locale('en', 'US')],
|
||||
String? socketPath,
|
||||
DaemonClient Function(Logger, EventBus)? daemonClientFactory,
|
||||
bool autoStartDaemonClient = true,
|
||||
}) async {
|
||||
final log = Logger();
|
||||
final events = EventBus();
|
||||
|
||||
final settings = SettingsStore(appDir: appDir);
|
||||
await settings.load();
|
||||
|
||||
final i18n = I18n(
|
||||
loader: i18nLoader,
|
||||
log: log,
|
||||
defaultLocale: defaultLocale,
|
||||
initialLocale: initialLocale,
|
||||
availableLocales: availableLocales,
|
||||
);
|
||||
for (final ns in preloadNamespaces) {
|
||||
await i18n.ensureNamespaceLoaded(ns);
|
||||
}
|
||||
|
||||
final theme = ThemeController(bundled: bundledThemes);
|
||||
final panels = PanelRegistry();
|
||||
final arrangement = LayoutArrangement();
|
||||
final commands = CommandRegistry();
|
||||
final keybindings = KeybindingResolver();
|
||||
final palette = PaletteController(commands);
|
||||
final clipboard = ClideClipboard();
|
||||
final files = FileServices(events);
|
||||
final notify = Notifications();
|
||||
final dialog = DialogRouter();
|
||||
final tray = TrayRegistry();
|
||||
final secrets = SecretsVault();
|
||||
final os = OsBridge(log: log, events: events);
|
||||
final net = NetworkStatus();
|
||||
final focus = FocusTracker();
|
||||
final project = ProjectManager(
|
||||
log: log,
|
||||
events: events,
|
||||
settings: settings,
|
||||
);
|
||||
final ipc = daemonClientFactory != null
|
||||
? daemonClientFactory(log, events)
|
||||
: DaemonClient(
|
||||
socketPath: socketPath ?? defaultSocketPath(),
|
||||
log: log,
|
||||
events: events,
|
||||
);
|
||||
final extensions = ExtensionManager(
|
||||
log: log,
|
||||
events: events,
|
||||
settings: settings,
|
||||
theme: theme,
|
||||
i18n: i18n,
|
||||
panels: panels,
|
||||
arrangement: arrangement,
|
||||
commands: commands,
|
||||
palette: palette,
|
||||
keybindings: keybindings,
|
||||
clipboard: clipboard,
|
||||
files: files,
|
||||
notify: notify,
|
||||
dialog: dialog,
|
||||
tray: tray,
|
||||
secrets: secrets,
|
||||
os: os,
|
||||
net: net,
|
||||
focus: focus,
|
||||
project: project,
|
||||
ipc: ipc,
|
||||
);
|
||||
|
||||
if (autoStartDaemonClient) {
|
||||
unawaited(ipc.start());
|
||||
}
|
||||
|
||||
return KernelServices(
|
||||
log: log,
|
||||
settings: settings,
|
||||
events: events,
|
||||
ipc: ipc,
|
||||
theme: theme,
|
||||
i18n: i18n,
|
||||
panels: panels,
|
||||
arrangement: arrangement,
|
||||
commands: commands,
|
||||
palette: palette,
|
||||
keybindings: keybindings,
|
||||
clipboard: clipboard,
|
||||
files: files,
|
||||
notify: notify,
|
||||
dialog: dialog,
|
||||
tray: tray,
|
||||
secrets: secrets,
|
||||
os: os,
|
||||
net: net,
|
||||
focus: focus,
|
||||
project: project,
|
||||
extensions: extensions,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await ipc.stop();
|
||||
ipc.dispose();
|
||||
settings.dispose();
|
||||
theme.dispose();
|
||||
panels.dispose();
|
||||
arrangement.dispose();
|
||||
commands.dispose();
|
||||
palette.dispose();
|
||||
i18n.dispose();
|
||||
notify.dispose();
|
||||
dialog.dispose();
|
||||
tray.dispose();
|
||||
net.dispose();
|
||||
focus.dispose();
|
||||
project.dispose();
|
||||
extensions.dispose();
|
||||
await log.dispose();
|
||||
await events.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class ClideKernel extends InheritedWidget {
|
||||
const ClideKernel({
|
||||
super.key,
|
||||
required this.services,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
final KernelServices services;
|
||||
|
||||
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.');
|
||||
}
|
||||
return w.services;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(ClideKernel oldWidget) =>
|
||||
services != oldWidget.services;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide_app/kernel/src/events/bus.dart';
|
||||
import 'package:clide_app/kernel/src/events/types.dart';
|
||||
import 'package:clide_app/kernel/src/panels/slot_id.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class FilesDropped extends ClideEvent {
|
||||
const FilesDropped({required this.paths, required this.slot});
|
||||
|
||||
final List<String> paths;
|
||||
final SlotId slot;
|
||||
|
||||
@override
|
||||
String get subsystem => 'files';
|
||||
@override
|
||||
String get kind => 'dropped';
|
||||
@override
|
||||
Map<String, Object?> payload() => {
|
||||
'paths': paths,
|
||||
'slot': slot.value,
|
||||
};
|
||||
}
|
||||
|
||||
/// Tier-0 stub for file pickers and drop targets.
|
||||
///
|
||||
/// Flutter desktop has no native picker API without a dep; rather than
|
||||
/// add one now, pickOpen/pickSave/pickDirectory throw UnimplementedError
|
||||
/// and the drop target is a no-op until we wire it through the
|
||||
/// platform channel. This lets the rest of the kernel compile and makes
|
||||
/// the service surface real.
|
||||
class FileServices {
|
||||
FileServices(this._events);
|
||||
final EventBus _events;
|
||||
|
||||
Future<List<String>> pickOpen({
|
||||
List<String> extensions = const [],
|
||||
bool multiple = false,
|
||||
}) async {
|
||||
throw UnimplementedError('pickOpen — wired in a later tier');
|
||||
}
|
||||
|
||||
Future<String?> pickSave({
|
||||
String? defaultName,
|
||||
List<String> extensions = const [],
|
||||
}) async {
|
||||
throw UnimplementedError('pickSave — wired in a later tier');
|
||||
}
|
||||
|
||||
Future<String?> pickDirectory() async {
|
||||
throw UnimplementedError('pickDirectory — wired in a later tier');
|
||||
}
|
||||
|
||||
/// Invoked by the platform drop-target wiring when files land on a
|
||||
/// slot. Emits a [FilesDropped] event; the slot-owning extension
|
||||
/// subscribes.
|
||||
@visibleForTesting
|
||||
void notifyDropped({required List<String> paths, required SlotId slot}) {
|
||||
_events.emit(FilesDropped(paths: paths, slot: slot));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:clide_app/kernel/src/panels/slot_id.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Tracks the currently focused contribution (tab id + slot). Backs
|
||||
/// `clide active`; extensions that need "which tab does the user care
|
||||
/// about right now?" read from here instead of poking Flutter's
|
||||
/// FocusScope directly.
|
||||
class FocusTracker extends ChangeNotifier {
|
||||
SlotId? _slot;
|
||||
String? _contributionId;
|
||||
|
||||
SlotId? get activeSlot => _slot;
|
||||
String? get activeContributionId => _contributionId;
|
||||
|
||||
void setActive({required SlotId slot, required String contributionId}) {
|
||||
if (_slot == slot && _contributionId == contributionId) return;
|
||||
_slot = slot;
|
||||
_contributionId = contributionId;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
if (_slot == null && _contributionId == null) return;
|
||||
_slot = null;
|
||||
_contributionId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"command.reset": { "translation": "Layout: Reset to Classic" },
|
||||
"preset.classic": { "translation": "Classic" }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"connected": { "translation": "connected" },
|
||||
"connected.hint": { "translation": "clide daemon is reachable over the local socket" },
|
||||
"disconnected": { "translation": "disconnected" },
|
||||
"disconnected.hint": { "translation": "clide daemon is not running — start it with `clide --daemon`" }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"command.pick": { "translation": "Theme: Pick…" },
|
||||
"modal.title": { "translation": "Select theme" },
|
||||
"modal.cancel": { "translation": "Cancel" },
|
||||
"modal.cancel.hint": { "translation": "Close the theme picker without changing the current theme" },
|
||||
"row.select.hint": { "translation": "Activate this theme" }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"title": { "translation": "clide" },
|
||||
"subtitle": { "translation": "Flutter desktop IDE for Claude Code" },
|
||||
"open-project": { "translation": "Open project" },
|
||||
"open-project.hint": { "translation": "Pick a git repository to open as the workspace" },
|
||||
"tab.title": { "translation": "Welcome" }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:clide_app/kernel/src/i18n/fallback_chain.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Loads catalog JSON for a given `(namespace, locale)` pair.
|
||||
///
|
||||
/// The file format (mirrors fframe verbatim):
|
||||
/// `{namespace}_{lang}_{country}.json` — or `{namespace}_{lang}.json`
|
||||
/// Content: `{ "key": { "translation": "...", ...extras }, ... }`.
|
||||
///
|
||||
/// Two reader shapes:
|
||||
/// * Asset bundle (built-in catalogs shipped under `lib/kernel/src/i18n/catalog/`).
|
||||
/// * Filesystem (third-party extensions under `~/.clide/extensions/<id>/`).
|
||||
///
|
||||
/// Missing files return an empty map — not an error. The fallback chain
|
||||
/// walker handles "nothing for this locale" by trying the next one.
|
||||
abstract class CatalogLoader {
|
||||
Future<Map<String, Object?>> load(String namespace, Locale locale);
|
||||
}
|
||||
|
||||
class AssetCatalogLoader implements CatalogLoader {
|
||||
AssetCatalogLoader({required this.bundle, this.rootDir = _defaultRoot});
|
||||
|
||||
final AssetBundle bundle;
|
||||
final String rootDir;
|
||||
|
||||
static const String _defaultRoot = 'lib/kernel/src/i18n/catalog';
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> load(String namespace, Locale locale) async {
|
||||
final suffix = FallbackChain.filenameSuffix(locale);
|
||||
final path = '$rootDir/${namespace}_$suffix.json';
|
||||
try {
|
||||
final text = await bundle.loadString(path);
|
||||
if (text.trim().isEmpty) return const {};
|
||||
final obj = jsonDecode(text);
|
||||
if (obj is Map) return obj.cast<String, Object?>();
|
||||
return const {};
|
||||
} on FlutterError {
|
||||
// Asset missing. Return empty map; fallback chain handles the miss.
|
||||
return const {};
|
||||
} on FormatException {
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FileCatalogLoader implements CatalogLoader {
|
||||
const FileCatalogLoader({required this.rootDir});
|
||||
|
||||
final Directory rootDir;
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> load(String namespace, Locale locale) async {
|
||||
final suffix = FallbackChain.filenameSuffix(locale);
|
||||
final f = File('${rootDir.path}/${namespace}_$suffix.json');
|
||||
if (!await f.exists()) return const {};
|
||||
try {
|
||||
final text = await f.readAsString();
|
||||
if (text.trim().isEmpty) return const {};
|
||||
final obj = jsonDecode(text);
|
||||
if (obj is Map) return obj.cast<String, Object?>();
|
||||
} on FormatException {
|
||||
// malformed — return empty; caller will fall back.
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Preloaded-in-memory loader for tests and synthesized catalogs.
|
||||
class InMemoryCatalogLoader implements CatalogLoader {
|
||||
InMemoryCatalogLoader(this._map);
|
||||
|
||||
final Map<String, Map<Locale, Map<String, Object?>>> _map;
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> load(String namespace, Locale locale) async {
|
||||
final byNs = _map[namespace];
|
||||
if (byNs == null) return const {};
|
||||
// match by canonical comparison so Locale("en") == registered Locale("en")
|
||||
for (final entry in byNs.entries) {
|
||||
if (_eq(entry.key, locale)) return entry.value;
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
|
||||
static bool _eq(Locale a, Locale b) =>
|
||||
a.languageCode == b.languageCode && a.countryCode == b.countryCode;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Resolves the ordered list of locales to try when looking up a key.
|
||||
///
|
||||
/// Order, starting from the current locale:
|
||||
/// 1. exact (language + country) — e.g. nl_NL
|
||||
/// 2. language-only — e.g. nl
|
||||
/// 3. default language + country — e.g. en_US
|
||||
/// 4. default language-only — e.g. en
|
||||
///
|
||||
/// Duplicates are removed while preserving order. `null` country code is
|
||||
/// canonicalized by omitting it (not empty string) so equality works.
|
||||
@immutable
|
||||
class FallbackChain {
|
||||
const FallbackChain({
|
||||
required this.current,
|
||||
required this.defaultLocale,
|
||||
});
|
||||
|
||||
final Locale current;
|
||||
final Locale defaultLocale;
|
||||
|
||||
List<Locale> resolve() {
|
||||
final out = <Locale>[];
|
||||
for (final l in [
|
||||
current,
|
||||
Locale(current.languageCode),
|
||||
defaultLocale,
|
||||
Locale(defaultLocale.languageCode),
|
||||
]) {
|
||||
final canon = _canon(l);
|
||||
if (!out.any((e) => _canon(e) == canon)) {
|
||||
out.add(l);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static String _canon(Locale l) {
|
||||
final country = l.countryCode;
|
||||
if (country == null || country.isEmpty) return l.languageCode;
|
||||
return '${l.languageCode}_$country';
|
||||
}
|
||||
|
||||
/// Canonical filename suffix for a locale, matching fframe: `en_us`
|
||||
/// (lowercase, country only when present).
|
||||
static String filenameSuffix(Locale l) {
|
||||
final lang = l.languageCode.toLowerCase();
|
||||
final country = l.countryCode?.toLowerCase();
|
||||
if (country == null || country.isEmpty) return lang;
|
||||
return '${lang}_$country';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:clide_app/kernel/src/i18n/catalog_loader.dart';
|
||||
import 'package:clide_app/kernel/src/i18n/fallback_chain.dart';
|
||||
import 'package:clide_app/kernel/src/log.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class I18nReplacer {
|
||||
const I18nReplacer({required this.from, required this.replace});
|
||||
final String from;
|
||||
final String replace;
|
||||
}
|
||||
|
||||
/// Text-driven i18n — fframe-style. Keys are strings, lookups are by
|
||||
/// `(namespace, key)`, missing keys fall back through a locale chain
|
||||
/// and finally to the caller-supplied placeholder.
|
||||
///
|
||||
/// Singleton-per-kernel: `kernel.i18n`. Extensions write:
|
||||
/// final t = ctx.i18n;
|
||||
/// t.string('key', placeholder: '...', namespace: ext.id);
|
||||
class I18n extends ChangeNotifier {
|
||||
I18n({
|
||||
required this.loader,
|
||||
required this.log,
|
||||
required Locale defaultLocale,
|
||||
Locale? initialLocale,
|
||||
List<Locale> availableLocales = const [Locale('en', 'US')],
|
||||
}) : _defaultLocale = defaultLocale,
|
||||
_current = initialLocale ?? defaultLocale,
|
||||
_available = List<Locale>.unmodifiable(availableLocales);
|
||||
|
||||
final CatalogLoader loader;
|
||||
final Logger log;
|
||||
|
||||
final Locale _defaultLocale;
|
||||
Locale _current;
|
||||
final List<Locale> _available;
|
||||
|
||||
/// namespace -> locale -> flat key map
|
||||
final Map<String, Map<Locale, Map<String, Object?>>> _cache = {};
|
||||
|
||||
/// Keys we've already warned about for a given (namespace, key, locale).
|
||||
/// Keeps the log quiet across repeated lookups.
|
||||
final Set<String> _warnedMisses = {};
|
||||
|
||||
Locale get currentLocale => _current;
|
||||
Locale get defaultLocale => _defaultLocale;
|
||||
List<Locale> get availableLocales => _available;
|
||||
|
||||
/// Register a catalog that was loaded outside of [loader] — e.g. by the
|
||||
/// ExtensionManager when a third-party extension activates.
|
||||
void registerCatalog(
|
||||
String namespace,
|
||||
Locale locale,
|
||||
Map<String, Object?> catalog,
|
||||
) {
|
||||
_cache.putIfAbsent(
|
||||
namespace, () => <Locale, Map<String, Object?>>{})[locale] = catalog;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Remove every entry for a namespace (extension deactivated).
|
||||
void unregisterCatalog(String namespace) {
|
||||
if (_cache.remove(namespace) != null) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the current locale and reload every already-cached namespace
|
||||
/// for the new chain. Listeners fire once at the end.
|
||||
Future<void> setLocale(Locale locale) async {
|
||||
if (locale == _current) return;
|
||||
_current = locale;
|
||||
_warnedMisses.clear();
|
||||
for (final ns in _cache.keys.toList()) {
|
||||
await _ensureLoaded(ns);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Eagerly load a namespace across the whole fallback chain. Safe to
|
||||
/// call more than once (subsequent calls only fill missing locales).
|
||||
Future<void> ensureNamespaceLoaded(String namespace) async {
|
||||
await _ensureLoaded(namespace);
|
||||
}
|
||||
|
||||
Future<void> _ensureLoaded(String namespace) async {
|
||||
final byLocale = _cache.putIfAbsent(
|
||||
namespace,
|
||||
() => <Locale, Map<String, Object?>>{},
|
||||
);
|
||||
final chain = FallbackChain(
|
||||
current: _current,
|
||||
defaultLocale: _defaultLocale,
|
||||
).resolve();
|
||||
for (final l in chain) {
|
||||
if (byLocale.containsKey(l)) continue;
|
||||
byLocale[l] = await loader.load(namespace, l);
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a key, walking the locale fallback chain. Returns the
|
||||
/// placeholder if nothing hits; returns the key itself when placeholder
|
||||
/// is null (developer fallback — keys are more useful than blanks).
|
||||
String string(
|
||||
String key, {
|
||||
required String namespace,
|
||||
String? placeholder,
|
||||
}) {
|
||||
final byLocale = _cache[namespace];
|
||||
if (byLocale == null) {
|
||||
_warnOnce(
|
||||
'$namespace::MISSING_NAMESPACE::$key',
|
||||
'i18n: namespace not registered: $namespace (key: $key)',
|
||||
);
|
||||
return placeholder ?? key;
|
||||
}
|
||||
|
||||
final chain = FallbackChain(
|
||||
current: _current,
|
||||
defaultLocale: _defaultLocale,
|
||||
).resolve();
|
||||
|
||||
for (final locale in chain) {
|
||||
final catalog = byLocale[locale];
|
||||
if (catalog == null) continue;
|
||||
final hit = _extract(catalog, key);
|
||||
if (hit != null) return hit;
|
||||
}
|
||||
|
||||
_warnOnce(
|
||||
'$namespace::${_current.languageCode}::$key',
|
||||
'i18n: missing key "$key" in namespace "$namespace" (locale ${_current.toString()})',
|
||||
);
|
||||
return placeholder ?? key;
|
||||
}
|
||||
|
||||
/// [string] + naive `replaceAll` interpolation per replacer.
|
||||
/// Matches fframe: replacers whose [from] isn't present are silent no-ops.
|
||||
String interpolated(
|
||||
String key, {
|
||||
required String namespace,
|
||||
String? placeholder,
|
||||
List<I18nReplacer> replacers = const [],
|
||||
}) {
|
||||
var out = string(key, namespace: namespace, placeholder: placeholder);
|
||||
for (final r in replacers) {
|
||||
out = out.replaceAll(r.from, r.replace);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Walks fframe's nested shape: `{ "translation": "..." }`. If the
|
||||
/// value is a plain string we accept that too (forward-compat).
|
||||
String? _extract(Map<String, Object?> catalog, String key) {
|
||||
final v = catalog[key];
|
||||
if (v == null) return null;
|
||||
if (v is String) return v;
|
||||
if (v is Map && v['translation'] is String) {
|
||||
return v['translation'] as String;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _warnOnce(String dedupeKey, String message) {
|
||||
if (_warnedMisses.add(dedupeKey)) {
|
||||
log.warn('i18n', message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide_app/kernel/src/events/bus.dart';
|
||||
import 'package:clide_app/kernel/src/events/types.dart';
|
||||
import 'package:clide_app/kernel/src/log.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class DaemonClient extends ChangeNotifier {
|
||||
DaemonClient({
|
||||
required this.socketPath,
|
||||
required Logger log,
|
||||
required EventBus events,
|
||||
}) : _log = log,
|
||||
_events = events;
|
||||
|
||||
final String socketPath;
|
||||
final Logger _log;
|
||||
final EventBus _events;
|
||||
|
||||
Socket? _socket;
|
||||
bool _connected = false;
|
||||
bool _disposed = false;
|
||||
Timer? _reconnectTimer;
|
||||
Duration _backoff = const Duration(milliseconds: 200);
|
||||
int _nextId = 0;
|
||||
final Map<String, Completer<IpcResponse>> _pending = {};
|
||||
|
||||
bool get isConnected => _connected;
|
||||
|
||||
Future<void> start() async {
|
||||
_disposed = false;
|
||||
await _connect();
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_disposed = true;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
final s = _socket;
|
||||
_socket = null;
|
||||
await s?.close();
|
||||
_failPending('client stopped');
|
||||
_setConnected(false);
|
||||
}
|
||||
|
||||
Future<IpcResponse> request(
|
||||
String cmd, {
|
||||
Map<String, Object?> args = const {},
|
||||
}) {
|
||||
if (!_connected || _socket == null) {
|
||||
return Future.value(IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'daemon not connected',
|
||||
hint: 'is `clide --daemon` running?',
|
||||
),
|
||||
));
|
||||
}
|
||||
final id = '${_nextId++}';
|
||||
final completer = Completer<IpcResponse>();
|
||||
_pending[id] = completer;
|
||||
final req = IpcRequest(id: id, cmd: cmd, args: args);
|
||||
_socket!.writeln(req.encode());
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
if (_disposed) return;
|
||||
try {
|
||||
final addr = InternetAddress(socketPath, type: InternetAddressType.unix);
|
||||
final socket = await Socket.connect(addr, 0);
|
||||
_socket = socket;
|
||||
_backoff = const Duration(milliseconds: 200);
|
||||
_setConnected(true);
|
||||
_log.info('ipc', 'connected to $socketPath');
|
||||
socket
|
||||
.cast<List<int>>()
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen(
|
||||
_handleLine,
|
||||
onDone: _handleDisconnect,
|
||||
onError: (Object e) {
|
||||
_log.warn('ipc', 'socket error', error: e);
|
||||
_handleDisconnect();
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.debug(
|
||||
'ipc', 'connect failed ($e); retry in ${_backoff.inMilliseconds}ms');
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleLine(String line) {
|
||||
if (line.isEmpty) return;
|
||||
try {
|
||||
final msg = IpcMessage.decode(line);
|
||||
switch (msg) {
|
||||
case IpcResponse r:
|
||||
final c = _pending.remove(r.id);
|
||||
if (c != null && !c.isCompleted) c.complete(r);
|
||||
case IpcEvent e:
|
||||
_events.emit(DaemonEvent(
|
||||
subsystem: e.subsystem,
|
||||
kind: e.kind,
|
||||
data: e.data,
|
||||
ts: e.timestamp,
|
||||
));
|
||||
case IpcRequest _:
|
||||
_log.warn('ipc', 'daemon sent a request — unexpected');
|
||||
}
|
||||
} on FormatException catch (e) {
|
||||
_log.warn('ipc', 'bad line from daemon: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDisconnect() {
|
||||
_socket = null;
|
||||
_failPending('daemon disconnected');
|
||||
_setConnected(false);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
|
||||
void _failPending(String reason) {
|
||||
final err = IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: reason,
|
||||
);
|
||||
for (final entry in _pending.entries) {
|
||||
if (!entry.value.isCompleted) {
|
||||
entry.value.complete(IpcResponse.err(id: entry.key, error: err));
|
||||
}
|
||||
}
|
||||
_pending.clear();
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
if (_disposed) return;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(_backoff, _connect);
|
||||
_backoff = Duration(
|
||||
milliseconds: math.min(_backoff.inMilliseconds * 2, 5000),
|
||||
);
|
||||
}
|
||||
|
||||
void _setConnected(bool v) {
|
||||
if (_connected == v) return;
|
||||
_connected = v;
|
||||
_events.emit(DaemonConnectionChanged(connected: v));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_reconnectTimer?.cancel();
|
||||
unawaited(_socket?.close());
|
||||
_socket = null;
|
||||
_failPending('client disposed');
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
enum LogLevel { trace, debug, info, warn, error }
|
||||
|
||||
class LogRecord {
|
||||
LogRecord({
|
||||
required this.level,
|
||||
required this.source,
|
||||
required this.message,
|
||||
required this.timestamp,
|
||||
this.error,
|
||||
this.stackTrace,
|
||||
});
|
||||
|
||||
final LogLevel level;
|
||||
final String source;
|
||||
final String message;
|
||||
final DateTime timestamp;
|
||||
final Object? error;
|
||||
final StackTrace? stackTrace;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final lv = level.name.toUpperCase().padRight(5);
|
||||
final buf =
|
||||
StringBuffer('${timestamp.toIso8601String()} $lv [$source] $message');
|
||||
if (error != null) buf.write(' | error=$error');
|
||||
return buf.toString();
|
||||
}
|
||||
}
|
||||
|
||||
typedef LogSink = void Function(LogRecord);
|
||||
|
||||
class Logger {
|
||||
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();
|
||||
|
||||
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 _emit(LogLevel level, String source, String message,
|
||||
{Object? error, StackTrace? stackTrace}) {
|
||||
if (level.index < minLevel.index) return;
|
||||
final rec = LogRecord(
|
||||
level: level,
|
||||
source: source,
|
||||
message: message,
|
||||
timestamp: DateTime.now().toUtc(),
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
for (final sink in _sinks) {
|
||||
try {
|
||||
sink(rec);
|
||||
} catch (_) {
|
||||
// a broken sink must not kill logging
|
||||
}
|
||||
}
|
||||
if (!_stream.isClosed) _stream.add(rec);
|
||||
}
|
||||
|
||||
Future<void> dispose() => _stream.close();
|
||||
}
|
||||
|
||||
void stderrSink(LogRecord r) {
|
||||
stderr.writeln(r);
|
||||
if (r.stackTrace != null) stderr.writeln(r.stackTrace);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum Reachability { online, offline, metered }
|
||||
|
||||
/// Tier-0 stub reachability observable. Hardcoded to `online`; real
|
||||
/// detection via a platform channel lands in a later tier.
|
||||
class NetworkStatus extends ChangeNotifier {
|
||||
Reachability _state = Reachability.online;
|
||||
Reachability get state => _state;
|
||||
bool get isOnline => _state != Reachability.offline;
|
||||
|
||||
@visibleForTesting
|
||||
void setState(Reachability r) {
|
||||
if (_state == r) return;
|
||||
_state = r;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum NotificationLevel { info, warning, error, success }
|
||||
|
||||
@immutable
|
||||
class ClideNotification {
|
||||
ClideNotification({
|
||||
required this.id,
|
||||
required this.level,
|
||||
required this.message,
|
||||
this.title,
|
||||
this.duration = const Duration(seconds: 4),
|
||||
}) : createdAt = DateTime.now().toUtc();
|
||||
|
||||
final String id;
|
||||
final NotificationLevel level;
|
||||
final String? title;
|
||||
final String message;
|
||||
final DateTime createdAt;
|
||||
final Duration duration;
|
||||
}
|
||||
|
||||
class Notifications extends ChangeNotifier {
|
||||
final List<ClideNotification> _active = [];
|
||||
final Map<String, Timer> _timers = {};
|
||||
int _seq = 0;
|
||||
|
||||
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 dismiss(String id) {
|
||||
_timers.remove(id)?.cancel();
|
||||
final before = _active.length;
|
||||
_active.removeWhere((n) => n.id == id);
|
||||
if (_active.length != before) notifyListeners();
|
||||
}
|
||||
|
||||
void _push(
|
||||
NotificationLevel level,
|
||||
String message, {
|
||||
String? title,
|
||||
Duration? duration,
|
||||
}) {
|
||||
final id = 'n${_seq++}';
|
||||
final n = ClideNotification(
|
||||
id: id,
|
||||
level: level,
|
||||
message: message,
|
||||
title: title,
|
||||
duration: duration ?? const Duration(seconds: 4),
|
||||
);
|
||||
_active.add(n);
|
||||
_timers[id] = Timer(n.duration, () => dismiss(id));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final t in _timers.values) {
|
||||
t.cancel();
|
||||
}
|
||||
_timers.clear();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user