Compare commits
5
Commits
226520cfce
...
77c2f25eaf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77c2f25eaf | ||
|
|
ad1261fdc2 | ||
|
|
8a0ca92a1f | ||
|
|
cf9a510e6c | ||
|
|
2d08c421ae |
@@ -0,0 +1,193 @@
|
||||
---
|
||||
name: pql
|
||||
description: >
|
||||
Query and plan against a markdown vault via the pql CLI. Two surfaces:
|
||||
(1) structural queries — frontmatter, wikilinks, tags, headings, Bases,
|
||||
DSL — use when the user asks about vault contents ("which notes…", "find
|
||||
where…", "what tags", "who links to X", "run a Base", "query the vault");
|
||||
(2) planning — decision records, tickets, project status — use when the
|
||||
user asks about decisions, tickets, work items, or project planning
|
||||
("sync decisions", "create a ticket", "what's the plan status", "show
|
||||
D-5", "board"). Requires `pql` on PATH. JSON on stdout; exit 2 = zero
|
||||
matches (not an error).
|
||||
---
|
||||
|
||||
# pql — vault queries + project planning
|
||||
|
||||
`pql` indexes a vault into SQLite and exposes structural queries plus a
|
||||
planning layer for decision records and tickets. One binary, two surfaces.
|
||||
|
||||
## Precondition
|
||||
|
||||
```bash
|
||||
command -v pql
|
||||
```
|
||||
|
||||
If absent, tell the user to install from
|
||||
https://github.com/postmeridiem/pql/releases/latest. Don't install it
|
||||
yourself. Don't fall back to grep unless the user explicitly asks.
|
||||
|
||||
## First touch: learn the vault
|
||||
|
||||
```bash
|
||||
pql schema
|
||||
```
|
||||
|
||||
Returns one row per frontmatter key with observed types and file counts.
|
||||
Run once per session before writing queries.
|
||||
|
||||
---
|
||||
|
||||
## Surface 1: Vault queries
|
||||
|
||||
### Subcommands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `pql files [glob]` | List indexed files; optional glob filter |
|
||||
| `pql tags [--sort count]` | Distinct tags with counts |
|
||||
| `pql backlinks <path>` | Files linking TO a path |
|
||||
| `pql outlinks <path>` | Links FROM a file |
|
||||
| `pql meta <path>` | Frontmatter + tags + outlinks + headings for one file |
|
||||
| `pql schema` | Typed frontmatter schema |
|
||||
| `pql base <name>` | Execute an Obsidian .base file |
|
||||
| `pql shell` | Interactive REPL (indexes once, then query per line) |
|
||||
| `pql query "<DSL>"` | SQL-derived DSL for complex queries |
|
||||
| `pql doctor` | Resolved vault/config/DB/index state |
|
||||
|
||||
### DSL examples
|
||||
|
||||
```sql
|
||||
SELECT name, fm.date WHERE fm.type = 'meeting' ORDER BY fm.date DESC LIMIT 10
|
||||
SELECT path WHERE 'project' IN tags ORDER BY path
|
||||
SELECT name, fm.prior_job WHERE fm.type = 'council-member' ORDER BY name
|
||||
```
|
||||
|
||||
Use `--file q.pql` or `--stdin` for long queries. Don't interpolate vault
|
||||
content into the command line.
|
||||
|
||||
### Query cookbook
|
||||
|
||||
- **Files in folder** → `pql files 'sessions/*'`
|
||||
- **Top tags** → `pql tags --sort count --limit 20`
|
||||
- **What links to X?** → `pql backlinks members/vaasa/persona.md`
|
||||
- **Date range** → `pql query "SELECT name, fm.date WHERE fm.date BETWEEN '2024-01-01' AND '2024-12-31'"`
|
||||
- **Run a Base** → `pql base council-sessions`
|
||||
- **Inspect one file** → `pql meta members/vaasa/persona.md --pretty`
|
||||
|
||||
---
|
||||
|
||||
## Surface 2: Planning (decisions + tickets)
|
||||
|
||||
Planning state lives in `<vault>/.pql/pql.db` (user-authored state, not a
|
||||
cache). Decision records come from `decisions/*.md`; tickets are
|
||||
SQLite-native.
|
||||
|
||||
### Decision subcommands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `pql decisions sync` | Parse decisions/*.md → upsert into pql.db |
|
||||
| `pql decisions validate` | Dry-run parse; exits non-zero on malformed records |
|
||||
| `pql decisions claim <D\|Q\|R> <domain> "title"` | Print next available ID |
|
||||
| `pql decisions list [--type X] [--domain X] [--status X]` | List decisions |
|
||||
| `pql decisions show <id> [--with-refs] [--with-tickets]` | Show with joins |
|
||||
| `pql decisions coverage` | Confirmed decisions without tickets |
|
||||
| `pql decisions refs <id>` | Cross-references involving a decision |
|
||||
|
||||
Always `pql decisions sync` before querying if decisions/*.md may have changed.
|
||||
|
||||
### Ticket subcommands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `pql ticket new <type> "title" [--decision D-NNN] [--priority P]` | Create (emits T-NNN) |
|
||||
| `pql ticket list [--status S] [--team T] [--assigned A] [--label L]` | List with filters |
|
||||
| `pql ticket show <id> [--with-decision] [--with-blockers] [--with-children]` | Show with joins |
|
||||
| `pql ticket status <id[,id,...]> <new-status>` | Transition (batch via comma-separated IDs) |
|
||||
| `pql ticket assign <id> <agent>` | Set assignee |
|
||||
| `pql ticket block <id> --by <other>` | Add blocker |
|
||||
| `pql ticket unblock <id> --from <other>` | Remove blocker |
|
||||
| `pql ticket team <id> <team>` | Set team |
|
||||
| `pql ticket label <id> add\|rm <label>` | Manage labels |
|
||||
| `pql ticket board [--team T]` | Kanban board view |
|
||||
|
||||
Ticket types: initiative, epic, story, task, bug.
|
||||
Status flow: backlog → ready → in_progress → review → done (also cancelled).
|
||||
|
||||
### Plan subcommands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `pql plan status` | Dashboard: decision counts, open Qs, ticket summary, coverage gaps |
|
||||
| `pql plan export [--to FILE]` | Snapshot planning state to JSON (default: `pql-plan.json`) |
|
||||
| `pql plan import [--from FILE]` | Restore planning state from a JSON snapshot |
|
||||
|
||||
### Versioning planning state
|
||||
|
||||
Planning state lives in `pql.db` (gitignored). To version it in git,
|
||||
use `pql plan export` to write a committed JSON snapshot. pql does NOT
|
||||
do this automatically — the user decides when and how to trigger it:
|
||||
|
||||
- Pre-push hook: `.githooks/pre-push` calls `pql plan export && git add pql-plan.json`
|
||||
- Sprint close: a skill or script exports + commits on milestone
|
||||
- Manual: run `pql plan export` before committing when state changed
|
||||
|
||||
On a fresh clone, `pql plan import` restores from the snapshot.
|
||||
|
||||
### Planning cookbook
|
||||
|
||||
- **Sync and list confirmed** → `pql decisions sync && pql decisions list --type confirmed`
|
||||
- **Show with refs** → `pql decisions show D-5 --with-refs --pretty`
|
||||
- **Create ticket** → `pql ticket new task "implement X" --decision D-5`
|
||||
- **Batch close** → `pql ticket status T-1,T-2,T-3 done`
|
||||
- **Coverage gaps** → `pql decisions coverage`
|
||||
- **Dashboard** → `pql plan status --pretty`
|
||||
- **Snapshot for git** → `pql plan export`
|
||||
|
||||
---
|
||||
|
||||
## Output contract (both surfaces)
|
||||
|
||||
- **stdout:** JSON array (default); `--jsonl` for one object/line; `--pretty`; `--limit N`.
|
||||
- **stderr:** JSON diagnostics `{"level":"…","code":"pql.<phase>.<kind>","msg":"…"}`.
|
||||
- **Exit codes:**
|
||||
- `0` — success, ≥1 result
|
||||
- `2` — zero matches (not an error — say "no matches", not "failed")
|
||||
- `64` — bad flag
|
||||
- `65` — parse/compile error (pass stderr back)
|
||||
- `66` — vault/config not found
|
||||
- `69` — unavailable
|
||||
- `70` — internal error
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Don't pipe to `jq` for simple projections — use `--limit`, `--pretty`, `--jsonl`.
|
||||
- Don't chain `pql files` + `pql meta` — one `pql query` with WHERE.
|
||||
- Don't parse errors — pass stderr diagnostics back directly.
|
||||
- Don't forget `pql decisions sync` before querying decisions.
|
||||
- Don't try to install or upgrade pql — instruct the user if missing.
|
||||
|
||||
## When NOT to use
|
||||
|
||||
- **Body text search** → `grep`/`rg`.
|
||||
- **Reading file contents** → `Read` tool.
|
||||
- **Code structure** → tree-sitter / LSP.
|
||||
- **Modifying vault files** → `Write`/`Edit`. pql doesn't write to vault content.
|
||||
|
||||
## Permissions
|
||||
|
||||
The consuming project's `.claude/settings.json` should allow:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["Bash(pql)", "Bash(pql *)"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Updating the skill
|
||||
|
||||
`pql skill status` reports drift. `pql skill install` writes/updates;
|
||||
`--force` overrides hand-edits. `pql doctor` also surfaces skill state.
|
||||
@@ -2,11 +2,12 @@
|
||||
name: theme-ui
|
||||
description: >-
|
||||
Token selection guide for clide UI development. Use when building or
|
||||
modifying widgets, panels, pane chrome, status indicators, or any
|
||||
visual surface. Ensures correct background, border, text, and hover
|
||||
modifying widgets, panels, pane chrome, status indicators, icons, or
|
||||
any visual surface. Ensures correct background, border, text, and hover
|
||||
tokens are applied per surface type. Triggers on: new widget code,
|
||||
theme-related changes, "which token", "what color", color/background
|
||||
questions, visual inconsistency fixes, new panel/pane/view development.
|
||||
questions, visual inconsistency fixes, new panel/pane/view development,
|
||||
adding or looking up Phosphor icons, icon codepoints.
|
||||
---
|
||||
|
||||
# Theme-UI — token selection for clide surfaces
|
||||
@@ -167,6 +168,31 @@ types, priority levels) should NOT add tokens to `SurfaceTokens`. Instead:
|
||||
This keeps the core token surface lean and lets each extension own its
|
||||
palette. The pattern scales to any extension needing domain colors.
|
||||
|
||||
## Icons — Phosphor Icons
|
||||
|
||||
The app bundles Phosphor Icons (v2.0.8, MIT) as TTF fonts at
|
||||
`assets/fonts/phosphor/` (regular, bold, fill weights).
|
||||
|
||||
**Codepoint reference:** `assets/fonts/phosphor/codepoints.csv` —
|
||||
full mapping of all 1512 icon codepoints to kebab-case and PascalCase
|
||||
names. Read this file to look up any icon by name or codepoint.
|
||||
|
||||
**Adding an icon:** find the codepoint in `codepoints.csv`, then add
|
||||
a `static const` entry to `PhosphorIcons` in
|
||||
`lib/widgets/src/icons/phosphor.dart`:
|
||||
|
||||
```dart
|
||||
static const arrowClockwise = PhosphorIconPainter(0xe036);
|
||||
```
|
||||
|
||||
Only add icons we actually use — don't bulk-import the full set.
|
||||
|
||||
**Using an icon:** `ClideIcon(PhosphorIcons.arrowClockwise, size: 13)`
|
||||
or as a `TabContribution` icon field: `icon: PhosphorIcons.lightbulb`.
|
||||
|
||||
**Bold weight:** pass `family: 'Phosphor-Bold'` to `PhosphorIconPainter`.
|
||||
Fill weight: `family: 'Phosphor-Fill'`.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Borrowing another surface's token (`sidebarBackground` for hat bar)
|
||||
|
||||
@@ -92,6 +92,38 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
`.md` files) on the right. Clicking a result opens it in the
|
||||
context panel markdown viewer with bidirectional focus highlighting.
|
||||
|
||||
- Kernel scheduler service with tiered timers (1min, 10min, 15min,
|
||||
1hr, midnight) running on a background isolate. Emits `SchedulerTick`
|
||||
events on the `DaemonBus`. Extensions subscribe by tier (T-61).
|
||||
|
||||
- Auto-refresh for sidebar panels: decisions refresh on file changes
|
||||
to `decisions/*.md` and on 1-minute scheduler tick; tickets refresh
|
||||
on 1-minute tick and on status change events (T-62).
|
||||
|
||||
- Manual refresh button (arrowClockwise icon) in decisions, tickets,
|
||||
and pql markdown panels (T-63).
|
||||
|
||||
- `ClideAccordion` shared widget — extracted from tickets and
|
||||
decisions views. Supports optional `leading` widget (color dot).
|
||||
Pin/focus accordion logic: manually toggled sections are pinned;
|
||||
the focused item's section auto-opens; unpinned sections without
|
||||
focus auto-collapse.
|
||||
|
||||
- Ticket status buttons wired to `pql ticket status` — clicking a
|
||||
status in the detail view transitions the ticket, refreshes the
|
||||
sidebar list, and scrolls the ticket into its new section.
|
||||
|
||||
- `pql.tickets.status` IPC command accepting a list of IDs for
|
||||
batch status transitions.
|
||||
|
||||
- Ticket sidebar sections split into individual statuses: IN
|
||||
PROGRESS, REVIEW, READY, BACKLOG, DONE, CANCELLED (was four
|
||||
coarse groups).
|
||||
|
||||
- Phosphor Icons codepoint reference CSV at
|
||||
`assets/fonts/phosphor/codepoints.csv` — full mapping of all
|
||||
1512 icon glyphs to kebab-case and PascalCase names.
|
||||
|
||||
- Graph view in context panel — lists files with inbound/outbound
|
||||
link counts from `pql search --connections` (T-39).
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,8 +19,12 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
String _filter = '';
|
||||
String? _focusedId;
|
||||
final _focusedKey = GlobalKey();
|
||||
final Set<String> _expanded = {'confirmed'};
|
||||
final Set<String> _pinned = {'confirmed'};
|
||||
StreamSubscription<Message>? _focusSub;
|
||||
StreamSubscription<DaemonEvent>? _fileSub;
|
||||
StreamSubscription<SchedulerTick>? _schedulerSub;
|
||||
bool _refreshing = false;
|
||||
bool _pendingRefresh = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
@@ -28,11 +32,28 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
if (_focusSub == null) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
_focusSub = kernel.messages.subscribe(publisher: 'builtin.decisions', channel: 'focus').listen(_onFocus);
|
||||
_fileSub = kernel.events.on<DaemonEvent>().where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && _isDecisionPath(e.data['path'] as String? ?? '')).listen((_) => _refresh());
|
||||
_schedulerSub = kernel.events.on<SchedulerTick>().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh());
|
||||
}
|
||||
if (!_loading || _decisions.isNotEmpty) return;
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
static bool _isDecisionPath(String path) => path.startsWith('decisions/') && path.endsWith('.md');
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (!mounted) return;
|
||||
if (_refreshing) { _pendingRefresh = true; return; }
|
||||
_refreshing = true;
|
||||
_pendingRefresh = false;
|
||||
await _load();
|
||||
_refreshing = false;
|
||||
if (_pendingRefresh && mounted) {
|
||||
_pendingRefresh = false;
|
||||
unawaited(_refresh());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
await kernel.ipc.request('pql.decisions.sync');
|
||||
@@ -59,18 +80,22 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
@override
|
||||
void dispose() {
|
||||
_focusSub?.cancel();
|
||||
_fileSub?.cancel();
|
||||
_schedulerSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _isSectionExpanded(String type) {
|
||||
if (_pinned.contains(type)) return true;
|
||||
if (_focusedId == null) return false;
|
||||
final entry = _decisions.where((d) => d.id == _focusedId).firstOrNull;
|
||||
return (entry?.type ?? 'confirmed') == type;
|
||||
}
|
||||
|
||||
void _onFocus(Message msg) {
|
||||
final id = msg.data['id'] as String?;
|
||||
if (id == null || id == _focusedId) return;
|
||||
final entry = _decisions.where((d) => d.id == id).firstOrNull;
|
||||
final section = entry?.type ?? 'confirmed';
|
||||
setState(() {
|
||||
_focusedId = id;
|
||||
_expanded.add(section);
|
||||
});
|
||||
setState(() => _focusedId = id);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final ctx = _focusedKey.currentContext;
|
||||
if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3);
|
||||
@@ -79,10 +104,10 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
|
||||
void _toggleSection(String key) {
|
||||
setState(() {
|
||||
if (_expanded.contains(key)) {
|
||||
_expanded.remove(key);
|
||||
if (_pinned.contains(key)) {
|
||||
_pinned.remove(key);
|
||||
} else {
|
||||
_expanded.add(key);
|
||||
_pinned.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -107,31 +132,43 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ClideFilterBox(hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v)),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: ClideFilterBox(hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ClideTappable(
|
||||
onTap: _refreshing ? null : _refresh,
|
||||
tooltip: 'Refresh decisions',
|
||||
builder: (ctx, hovered, _) => ClideIcon(PhosphorIcons.arrowClockwise, size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (confirmed.isNotEmpty) _AccordionSection(
|
||||
label: 'CONFIRMED', count: confirmed.length, tokens: tokens,
|
||||
color: typeColors.confirmed,
|
||||
expanded: hasFilter || _expanded.contains('confirmed'),
|
||||
if (confirmed.isNotEmpty) ClideAccordion(
|
||||
label: 'CONFIRMED', count: confirmed.length,
|
||||
leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.confirmed, shape: BoxShape.circle)),
|
||||
expanded: hasFilter || _isSectionExpanded('confirmed'),
|
||||
onToggle: () => _toggleSection('confirmed'),
|
||||
children: [for (final d in confirmed) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)],
|
||||
),
|
||||
if (questions.isNotEmpty) _AccordionSection(
|
||||
label: 'QUESTIONS', count: questions.length, tokens: tokens,
|
||||
color: typeColors.question,
|
||||
expanded: hasFilter || _expanded.contains('question'),
|
||||
if (questions.isNotEmpty) ClideAccordion(
|
||||
label: 'QUESTIONS', count: questions.length,
|
||||
leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.question, shape: BoxShape.circle)),
|
||||
expanded: hasFilter || _isSectionExpanded('question'),
|
||||
onToggle: () => _toggleSection('question'),
|
||||
children: [for (final d in questions) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)],
|
||||
),
|
||||
if (rejected.isNotEmpty) _AccordionSection(
|
||||
label: 'REJECTED', count: rejected.length, tokens: tokens,
|
||||
color: typeColors.rejected,
|
||||
expanded: hasFilter || _expanded.contains('rejected'),
|
||||
if (rejected.isNotEmpty) ClideAccordion(
|
||||
label: 'REJECTED', count: rejected.length,
|
||||
leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.rejected, shape: BoxShape.circle)),
|
||||
expanded: hasFilter || _isSectionExpanded('rejected'),
|
||||
onToggle: () => _toggleSection('rejected'),
|
||||
children: [for (final d in rejected) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)],
|
||||
),
|
||||
@@ -161,46 +198,6 @@ class _DecisionEntry {
|
||||
);
|
||||
}
|
||||
|
||||
class _AccordionSection extends StatelessWidget {
|
||||
const _AccordionSection({
|
||||
required this.label, required this.count, required this.tokens,
|
||||
required this.color, required this.expanded, required this.onToggle,
|
||||
required this.children,
|
||||
});
|
||||
final String label;
|
||||
final int count;
|
||||
final SurfaceTokens tokens;
|
||||
final Color color;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggle;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideTappable(
|
||||
onTap: onToggle,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.only(left: 4, top: 10, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideIcon(expanded ? PhosphorIcons.caretDown : PhosphorIcons.caretRight, size: 10, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 6),
|
||||
Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
ClideText('$label · $count', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (expanded) ...children,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DecisionCard extends StatelessWidget {
|
||||
const _DecisionCard({required this.entry, required this.tokens, required this.typeColors, this.focused = false, this.focusKey});
|
||||
final _DecisionEntry entry;
|
||||
|
||||
@@ -21,6 +21,7 @@ class _PqlPanelViewState extends State<PqlPanelView> {
|
||||
String? _focusedPath;
|
||||
final _focusedKey = GlobalKey();
|
||||
StreamSubscription<Message>? _focusSub;
|
||||
StreamSubscription<DaemonEvent>? _fileSub;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
@@ -42,11 +43,17 @@ class _PqlPanelViewState extends State<PqlPanelView> {
|
||||
if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3);
|
||||
});
|
||||
});
|
||||
_fileSub = kernel.events.on<DaemonEvent>().where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && (e.data['path'] as String? ?? '').endsWith('.md')).listen((_) {
|
||||
if (_controller?.view == PqlView.markdown) {
|
||||
unawaited(_controller!.loadMarkdownFiles());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusSub?.cancel();
|
||||
_fileSub?.cancel();
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -153,9 +153,12 @@ class _StatusControls extends StatelessWidget {
|
||||
for (final s in _statuses) ...[
|
||||
Expanded(
|
||||
child: ClideTappable(
|
||||
onTap: () async {
|
||||
await controller.ipc.request('pql.tickets.status', args: {'ids': detail.id, 'status': s});
|
||||
await controller.load(detail.id);
|
||||
onTap: detail.status == s ? null : () async {
|
||||
final resp = await controller.ipc.request('pql.tickets.status', args: {'ids': [detail.id], 'status': s});
|
||||
if (resp.ok) {
|
||||
controller.messages.publish('builtin.tickets', 'changed', {'id': detail.id});
|
||||
await controller.load(detail.id);
|
||||
}
|
||||
},
|
||||
builder: (ctx, hovered, _) {
|
||||
final active = detail.status == s;
|
||||
|
||||
@@ -20,31 +20,40 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
String _filter = '';
|
||||
String? _focusedId;
|
||||
final _focusedKey = GlobalKey();
|
||||
final Set<String> _expanded = {'active', 'backlog'};
|
||||
final Set<String> _pinned = {'in_progress', 'ready', 'backlog'};
|
||||
StreamSubscription<Message>? _focusSub;
|
||||
StreamSubscription<SchedulerTick>? _schedulerSub;
|
||||
StreamSubscription<Message>? _changedSub;
|
||||
bool _refreshing = false;
|
||||
bool _pendingRefresh = false;
|
||||
|
||||
bool _isSectionExpanded(String status) {
|
||||
if (_pinned.contains(status)) return true;
|
||||
if (_focusedId == null) return false;
|
||||
final entry = _tickets.where((t) => t.id == _focusedId).firstOrNull;
|
||||
return _sectionForStatus(entry?.status) == status;
|
||||
}
|
||||
|
||||
void _toggle(String key) {
|
||||
setState(() {
|
||||
if (_expanded.contains(key)) _expanded.remove(key); else _expanded.add(key);
|
||||
if (_pinned.contains(key)) {
|
||||
_pinned.remove(key);
|
||||
} else {
|
||||
_pinned.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static String _sectionForStatus(String? status) => switch (status) {
|
||||
'in_progress' => 'active',
|
||||
'backlog' || 'ready' => 'backlog',
|
||||
'done' => 'done',
|
||||
_ => 'other',
|
||||
};
|
||||
static String _sectionForStatus(String? status) => status ?? 'backlog';
|
||||
|
||||
void _onFocus(Message msg) {
|
||||
final id = msg.data['id'] as String?;
|
||||
if (id == null || id == _focusedId) return;
|
||||
final entry = _tickets.where((t) => t.id == id).firstOrNull;
|
||||
final section = _sectionForStatus(entry?.status);
|
||||
setState(() {
|
||||
_focusedId = id;
|
||||
_expanded.add(section);
|
||||
});
|
||||
_scrollToFocused(id);
|
||||
}
|
||||
|
||||
void _scrollToFocused(String id) {
|
||||
setState(() => _focusedId = id);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final ctx = _focusedKey.currentContext;
|
||||
if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3);
|
||||
@@ -57,6 +66,13 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
if (_focusSub == null) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
_focusSub = kernel.messages.subscribe(publisher: 'builtin.tickets', channel: 'focus').listen(_onFocus);
|
||||
_changedSub = kernel.messages.subscribe(publisher: 'builtin.tickets', channel: 'changed').listen((msg) {
|
||||
final id = msg.data['id'] as String?;
|
||||
unawaited(_refresh().then((_) {
|
||||
if (id != null && mounted) _scrollToFocused(id);
|
||||
}));
|
||||
});
|
||||
_schedulerSub = kernel.events.on<SchedulerTick>().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh());
|
||||
}
|
||||
if (!_loading || _tickets.isNotEmpty) return;
|
||||
unawaited(_load());
|
||||
@@ -65,9 +81,24 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
@override
|
||||
void dispose() {
|
||||
_focusSub?.cancel();
|
||||
_changedSub?.cancel();
|
||||
_schedulerSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (!mounted) return;
|
||||
if (_refreshing) { _pendingRefresh = true; return; }
|
||||
_refreshing = true;
|
||||
_pendingRefresh = false;
|
||||
await _load();
|
||||
_refreshing = false;
|
||||
if (_pendingRefresh && mounted) {
|
||||
_pendingRefresh = false;
|
||||
unawaited(_refresh());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request('pql.tickets.list');
|
||||
@@ -101,27 +132,62 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
final hasFilter = lf.isNotEmpty;
|
||||
final filtered = hasFilter ? _tickets.where((t) => t.id.toLowerCase().contains(lf) || t.title.toLowerCase().contains(lf) || (t.status ?? '').contains(lf) || (t.type ?? '').contains(lf)).toList() : _tickets;
|
||||
|
||||
final active = filtered.where((t) => t.status == 'in_progress').toList();
|
||||
final backlog = filtered.where((t) => t.status == 'backlog' || t.status == 'ready').toList();
|
||||
final done = filtered.where((t) => t.status == 'done').toList();
|
||||
final other = filtered.where((t) => t.status == 'cancelled' || t.status == 'review').toList();
|
||||
const sections = [
|
||||
('in_progress', 'IN PROGRESS'),
|
||||
('review', 'REVIEW'),
|
||||
('ready', 'READY'),
|
||||
('backlog', 'BACKLOG'),
|
||||
('done', 'DONE'),
|
||||
('cancelled', 'CANCELLED'),
|
||||
];
|
||||
|
||||
final byStatus = <String, List<_TicketEntry>>{};
|
||||
for (final t in filtered) {
|
||||
(byStatus[t.status ?? 'backlog'] ??= []).add(t);
|
||||
}
|
||||
|
||||
final isDark = ClideTheme.of(context).dark;
|
||||
final typeColors = TicketTypeColors.forTheme(dark: isDark);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ClideFilterBox(hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v)),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: ClideFilterBox(hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ClideTappable(
|
||||
onTap: _refreshing ? null : _refresh,
|
||||
tooltip: 'Refresh tickets',
|
||||
builder: (ctx, hovered, _) => ClideIcon(PhosphorIcons.arrowClockwise, size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (active.isNotEmpty) _AccordionSection(label: 'ACTIVE', count: active.length, tokens: tokens, expanded: hasFilter || _expanded.contains('active'), onToggle: () => _toggle('active'), children: [for (final t in active) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId, focusKey: t.id == _focusedId ? _focusedKey : null)]),
|
||||
if (backlog.isNotEmpty) _AccordionSection(label: 'BACKLOG', count: backlog.length, tokens: tokens, expanded: hasFilter || _expanded.contains('backlog'), onToggle: () => _toggle('backlog'), children: [for (final t in backlog) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId, focusKey: t.id == _focusedId ? _focusedKey : null)]),
|
||||
if (done.isNotEmpty) _AccordionSection(label: 'DONE', count: done.length, tokens: tokens, expanded: hasFilter || _expanded.contains('done'), onToggle: () => _toggle('done'), children: [for (final t in done) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId, focusKey: t.id == _focusedId ? _focusedKey : null)]),
|
||||
if (other.isNotEmpty) _AccordionSection(label: 'OTHER', count: other.length, tokens: tokens, expanded: hasFilter || _expanded.contains('other'), onToggle: () => _toggle('other'), children: [for (final t in other) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId, focusKey: t.id == _focusedId ? _focusedKey : null)]),
|
||||
for (final (status, label) in sections)
|
||||
if (byStatus[status] case final items? when items.isNotEmpty)
|
||||
ClideAccordion(
|
||||
label: label,
|
||||
count: items.length,
|
||||
expanded: hasFilter || _isSectionExpanded(status),
|
||||
onToggle: () => _toggle(status),
|
||||
children: [
|
||||
for (final t in items)
|
||||
_TicketCard(
|
||||
entry: t,
|
||||
tokens: tokens,
|
||||
typeColors: typeColors,
|
||||
focused: t.id == _focusedId,
|
||||
focusKey: t.id == _focusedId ? _focusedKey : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -150,39 +216,6 @@ class _TicketEntry {
|
||||
);
|
||||
}
|
||||
|
||||
class _AccordionSection extends StatelessWidget {
|
||||
const _AccordionSection({required this.label, required this.count, required this.tokens, required this.expanded, required this.onToggle, required this.children});
|
||||
final String label;
|
||||
final int count;
|
||||
final SurfaceTokens tokens;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggle;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideTappable(
|
||||
onTap: onToggle,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.only(left: 4, top: 10, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideIcon(expanded ? PhosphorIcons.caretDown : PhosphorIcons.caretRight, size: 10, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 6),
|
||||
ClideText('$label · $count', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (expanded) ...children,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TicketCard extends StatelessWidget {
|
||||
const _TicketCard({required this.entry, required this.tokens, required this.typeColors, this.focused = false, this.focusKey});
|
||||
final _TicketEntry entry;
|
||||
|
||||
@@ -34,6 +34,7 @@ export 'src/notify.dart';
|
||||
export 'src/os.dart';
|
||||
export 'src/panels/arrangement.dart';
|
||||
export 'src/project.dart';
|
||||
export 'src/scheduler.dart';
|
||||
export 'src/secrets.dart';
|
||||
export 'src/tray.dart';
|
||||
export 'src/panels/drag_resize.dart';
|
||||
|
||||
@@ -22,6 +22,7 @@ import 'package:clide/kernel/src/os.dart';
|
||||
import 'package:clide/kernel/src/panels/arrangement.dart';
|
||||
import 'package:clide/kernel/src/panels/registry.dart';
|
||||
import 'package:clide/kernel/src/project.dart';
|
||||
import 'package:clide/kernel/src/scheduler.dart';
|
||||
import 'package:clide/kernel/src/secrets.dart';
|
||||
import 'package:clide/kernel/src/settings.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
@@ -61,6 +62,7 @@ class KernelServices {
|
||||
required this.extensions,
|
||||
required this.window,
|
||||
required this.toolCheck,
|
||||
required this.scheduler,
|
||||
});
|
||||
|
||||
final Logger log;
|
||||
@@ -88,6 +90,7 @@ class KernelServices {
|
||||
final ExtensionManager extensions;
|
||||
final WindowControls window;
|
||||
final ToolCheck toolCheck;
|
||||
final SchedulerService scheduler;
|
||||
|
||||
static Future<KernelServices> boot({
|
||||
required Directory appDir,
|
||||
@@ -136,6 +139,8 @@ class KernelServices {
|
||||
final focus = FocusTracker();
|
||||
final window = WindowControls();
|
||||
final toolCheck = ToolCheck();
|
||||
final scheduler = SchedulerService(events);
|
||||
scheduler.start();
|
||||
final project = ProjectManager(
|
||||
log: log,
|
||||
events: events,
|
||||
@@ -203,6 +208,7 @@ class KernelServices {
|
||||
extensions: extensions,
|
||||
window: window,
|
||||
toolCheck: toolCheck,
|
||||
scheduler: scheduler,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,6 +229,7 @@ class KernelServices {
|
||||
focus.dispose();
|
||||
project.dispose();
|
||||
extensions.dispose();
|
||||
scheduler.dispose();
|
||||
await log.dispose();
|
||||
messages.dispose();
|
||||
await events.dispose();
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/types.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum SchedulerTier {
|
||||
oneMinute(Duration(minutes: 1)),
|
||||
tenMinutes(Duration(minutes: 10)),
|
||||
fifteenMinutes(Duration(minutes: 15)),
|
||||
oneHour(Duration(hours: 1)),
|
||||
midnight(Duration(minutes: 1));
|
||||
|
||||
const SchedulerTier(this.interval);
|
||||
final Duration interval;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SchedulerTick extends ClideEvent {
|
||||
const SchedulerTick({required this.tier});
|
||||
final SchedulerTier tier;
|
||||
|
||||
@override
|
||||
String get subsystem => 'scheduler';
|
||||
@override
|
||||
String get kind => 'tick';
|
||||
@override
|
||||
Map<String, Object?> payload() => {'tier': tier.name};
|
||||
}
|
||||
|
||||
class SchedulerService {
|
||||
SchedulerService(this._events);
|
||||
final DaemonBus _events;
|
||||
|
||||
Isolate? _isolate;
|
||||
ReceivePort? _port;
|
||||
StreamSubscription<dynamic>? _sub;
|
||||
|
||||
void start() {
|
||||
if (_isolate != null) return;
|
||||
_port = ReceivePort();
|
||||
_sub = _port!.listen((msg) {
|
||||
if (msg is String) {
|
||||
final tier = SchedulerTier.values.firstWhere((t) => t.name == msg);
|
||||
_events.emit(SchedulerTick(tier: tier));
|
||||
}
|
||||
});
|
||||
Isolate.spawn(_isolateEntry, _port!.sendPort).then((iso) => _isolate = iso);
|
||||
}
|
||||
|
||||
static void _isolateEntry(SendPort send) {
|
||||
int lastDay = DateTime.now().day;
|
||||
for (final tier in SchedulerTier.values) {
|
||||
if (tier == SchedulerTier.midnight) {
|
||||
Timer.periodic(const Duration(minutes: 1), (_) {
|
||||
final day = DateTime.now().day;
|
||||
if (day != lastDay) {
|
||||
lastDay = day;
|
||||
send.send(tier.name);
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
Timer.periodic(tier.interval, (_) => send.send(tier.name));
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
_port?.close();
|
||||
_isolate?.kill(priority: Isolate.immediate);
|
||||
_isolate = null;
|
||||
_port = null;
|
||||
_sub = null;
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,21 @@ void registerPqlCommands(DaemonDispatcher d, PqlClient pql) {
|
||||
}
|
||||
});
|
||||
|
||||
d.register('pql.tickets.status', (req) async {
|
||||
final rawIds = req.args['ids'];
|
||||
final ids = rawIds is List ? rawIds.cast<String>() : rawIds is String ? [rawIds] : <String>[];
|
||||
final status = req.args['status'] as String?;
|
||||
if (ids.isEmpty || status == null || status.isEmpty) {
|
||||
return _userError(req.id, 'pql.tickets.status requires ids and status');
|
||||
}
|
||||
try {
|
||||
final result = await pql.ticketSetStatus(ids, status);
|
||||
return IpcResponse.ok(id: req.id, data: {'tickets': result});
|
||||
} on PqlException catch (e) {
|
||||
return _pqlError(req.id, e);
|
||||
}
|
||||
});
|
||||
|
||||
d.register('pql.tickets.board', (req) async {
|
||||
try {
|
||||
final board = await pql.ticketBoard(
|
||||
|
||||
@@ -8,6 +8,8 @@ library;
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class PqlException implements Exception {
|
||||
const PqlException(this.message, {this.exitCode = 1, this.stderr = ''});
|
||||
final String message;
|
||||
@@ -133,6 +135,10 @@ class PqlClient {
|
||||
return _runObject(args);
|
||||
}
|
||||
|
||||
Future<List<Map<String, Object?>>> ticketSetStatus(List<String> ids, String status) async {
|
||||
return _runList(['ticket', 'status', ids.join(','), status]);
|
||||
}
|
||||
|
||||
Future<List<Map<String, Object?>>> ticketBoard({String? team}) async {
|
||||
final args = ['ticket', 'board'];
|
||||
if (team != null) args.addAll(['--team', team]);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/widgets/src/clide_icon.dart';
|
||||
import 'package:clide/widgets/src/clide_tappable.dart';
|
||||
import 'package:clide/widgets/src/clide_text.dart';
|
||||
import 'package:clide/widgets/src/icons/phosphor.dart';
|
||||
import 'package:clide/widgets/src/typography.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ClideAccordion extends StatelessWidget {
|
||||
const ClideAccordion({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.count,
|
||||
required this.expanded,
|
||||
required this.onToggle,
|
||||
required this.children,
|
||||
this.leading,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int count;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggle;
|
||||
final List<Widget> children;
|
||||
final Widget? leading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideTappable(
|
||||
onTap: onToggle,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.only(left: 4, top: 10, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideIcon(expanded ? PhosphorIcons.caretDown : PhosphorIcons.caretRight, size: 10, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 6),
|
||||
if (leading != null) ...[leading!, const SizedBox(width: 6)],
|
||||
ClideText('$label · $count', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (expanded) ...children,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ abstract class PhosphorIcons {
|
||||
static const pencilSimple = PhosphorIconPainter(0xe3b4);
|
||||
static const eye = PhosphorIconPainter(0xe220);
|
||||
static const eyeSlash = PhosphorIconPainter(0xe224);
|
||||
static const arrowClockwise = PhosphorIconPainter(0xe036);
|
||||
static const arrowsOutSimple = PhosphorIconPainter(0xe0a6);
|
||||
static const arrowsInSimple = PhosphorIconPainter(0xe09e);
|
||||
static const list = PhosphorIconPainter(0xe2f0);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
/// Flutter chrome widgets directly.
|
||||
library;
|
||||
|
||||
export 'src/clide_accordion.dart';
|
||||
export 'src/clide_button.dart';
|
||||
export 'src/clide_column_hat.dart';
|
||||
export 'src/clide_code_block.dart';
|
||||
|
||||
Reference in New Issue
Block a user