diff --git "a/.agents/.cursor/plans/memory_parit\303\244t_eb\342\206\222tauri_4c9226e7.plan.md" "b/.agents/.cursor/plans/memory_parit\303\244t_eb\342\206\222tauri_4c9226e7.plan.md" new file mode 100644 index 0000000..f580d05 --- /dev/null +++ "b/.agents/.cursor/plans/memory_parit\303\244t_eb\342\206\222tauri_4c9226e7.plan.md" @@ -0,0 +1,286 @@ +--- +name: Memory Parität EB→Tauri +overview: "BLXCode-eb definiert das Zielbild: Dual-Scope (Workspace + Global unter `~/.blxcode/`), Unterordner als Kategorien, Category-Hubs im Graph, Frontmatter und scope-präfixierte Wikilinks. BLXCode hat bereits workspace-scoped Kategorien und einen ähnlichen Memory-Tab, fehlt aber fast die gesamte Global-/Scope-Schicht — die soll 1:1 nach dem EB-Referenzcode portiert werden." +todos: + - id: backend-scope-types + content: memory.rs in Module splitten; MemoryScope, Global-Roots, erweiterte NoteMeta/MemoryListResponse, memory_status/bootstrap + status: pending + - id: backend-crud-graph + content: Alle memory_* Commands mit scope; frontmatter + wikilinks + graph Hubs/crossScope portieren + status: pending + - id: bridge-ipc + content: tauri_bridge + invoke-Args für scope; note_key/parse_note_key; memory_list Response anpassen + status: pending + - id: ui-files-sidebar + content: "workbench/memory/ EB-Struktur; Dual-Scope-Sidebar, useMemory-äquivalent, scope:path, Bootstrap Global" + status: pending + - id: remove-legacy-migration + content: migrate_legacy_memory aus agents_layout entfernen; Tests/Doku anpassen + status: pending + - id: ui-graph-search + content: "memory_graph: Hub-Nodes, scope-aware Klicks; Search-Filter workspace:cat / global:cat" + status: pending + - id: agent-export + content: Agent-Tools/Prompt/Context mit scope; Export/Import/Pointer-UI; i18n + Tests + Doku + status: pending +isProject: false +--- + +# Memory-System: Parität BLXCode-eb → BLXCode (Tauri) + +## Referenz (Source of Truth) + +BLXCode-eb implementiert das vollständige Modell. Diese Dateien sind die Spezifikation zum Portieren: + +| Bereich | EB-Referenz | +|---------|-------------| +| Typen/Konstanten | [`src/shared/memory.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/shared/memory.ts) | +| Pfade & Scopes | [`src/bun/memory/paths.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/bun/memory/paths.ts) | +| CRUD/List/Graph | [`src/bun/memory/store.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/bun/memory/store.ts) | +| Graph-Builder | [`src/bun/memory/graph.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/bun/memory/graph.ts) | +| Wikilinks | [`src/bun/memory/wikilinks.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/bun/memory/wikilinks.ts) | +| Frontmatter | [`src/bun/memory/frontmatter.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/bun/memory/frontmatter.ts) | +| UI-Sidebar/Kategorien | [`src/views/.../Memory/files/MemorySidebar.tsx`](c:/Users/quork/Entwicklung/BLXCode-eb/src/views/app/layout/SidePanel/Memory/files/MemorySidebar.tsx), [`memoryScopeCategories.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/views/app/layout/SidePanel/Memory/memoryScopeCategories.ts) | +| Graph-UI | [`MemoryGraphView.tsx`](c:/Users/quork/Entwicklung/BLXCode-eb/src/views/app/layout/SidePanel/Memory/graph/MemoryGraphView.tsx) | + +## Ist-Zustand BLXCode (Gap-Analyse) + +```mermaid +flowchart LR + subgraph eb [BLXCode-eb] + WS["workspace\n.agents/memory"] + GL["global\n~/.blxcode/memory"] + EBList["memoryList\nnotes + subcategories x2"] + EBGraph["Graph + CategoryHubs\ncrossScope edges"] + end + subgraph blx [BLXCode heute] + WSonly["nur workspace"] + CatOK["Unterordner = Kategorien\nmemory_list_categories"] + GraphSimple["Graph ohne Hubs\nkein scope auf Nodes"] + end +``` + +**Bereits vorhanden (behalten/erweitern):** +- Workspace-Roots: `.agents/memory/`, `.agents/learnings/` ([`memory.rs`](c:/Users/quork/Entwicklung/BLXCode/src-tauri/src/memory.rs)) +- Dynamische Kategorien via Unterordner + `.gitkeep` (`memory_list_categories`, `memory_create_category`) +- Memory-Tab mit Files / Graph / Search ([`memory_panel.rs`](c:/Users/quork/Entwicklung/BLXCode/src/workbench/memory_panel.rs)) +- `graph_category_for` identisch zu EB (erster Pfadsegment = Kategorie) + +**Fehlt für 1:1-Parität:** + +| Feature | EB | BLXCode | +|---------|----|---------| +| `MemoryScope` (`workspace` \| `global`) | überall | fehlt | +| Global-Roots `~/.blxcode/{memory,learnings}` | ja | nein | +| `memoryList` → `{ notes, memorySubcategories }` pro Scope | ja | getrennte Calls, nur workspace | +| `NoteMeta`: `scope`, `title`, `enabled`, `tags`, `category`, `isLearning`, `isOverview` | ja | nur `path`, `name`, `size`, `modified`, `isTemplate` | +| Frontmatter (`title`, `enabled`, `tags`) | ja | nein | +| Wikilinks `[[global:…]]` / `[[workspace:…]]` | ja | nur basename/pfad | +| Graph: Category-Hub-Knoten (`hub:cat`), `crossScope`, `hubScopes` | ja | nein | +| Sidebar: zwei Sektionen (Projekt / Global) | ja | eine flache Kategorie-Liste | +| Note-Keys `scope:path` | ja | nur `path` | +| `memory_bootstrap` / `memory_status` (global) | ja | nur `workspace_ensure_agents` | +| Export/Import/Pointers mit Scope-Auswahl | UI + RPC | Backend teils da, UI fehlt | + +--- + +## Entscheidungen (abgestimmt) + +| Thema | Entscheidung | +|-------|----------------| +| Workspace-Legacy `/.blxcode/memory/` | **Entfernen** — keine automatische Migration mehr nach `.agents/memory/` | +| Global Memory | **1:1 EB:** `~/.blxcode/memory` + `~/.blxcode/learnings` (User-Home, nicht im Projektordner) | +| `memory_list` API | **Breaking Change** — nur noch `{ notes, memorySubcategories }`, alle Aufrufer anpassen | +| UI-Struktur | **Volle EB-Parität** — `src/workbench/memory/` mit Untermodulen (panel, files/, graph/, search/, dialogs) statt monolithischem `memory_panel.rs` | +| Kategorie-Einstellungen | Alte Keys `decisions` → **`workspace:decisions`** beim Laden; Global-Keys `global:…` | + +--- + +## Ziel-Architektur + +```mermaid +flowchart TB + subgraph storage [Dateisystem] + WSM["workspace/.agents/memory"] + WSL["workspace/.agents/learnings"] + GLM["~/.blxcode/memory"] + GLL["~/.blxcode/learnings"] + end + subgraph tauri [src-tauri/src/memory/] + paths[paths.rs] + store[store.rs] + graph[graph.rs] + wikilinks[wikilinks.rs] + frontmatter[frontmatter.rs] + end + subgraph ui [src/workbench/memory/] + panel[memory_panel / useMemory-äquivalent] + sidebar[Scope-Sektionen + Kategorien] + graphUi[memory_graph mit Hubs] + end + WSM --> paths + WSL --> paths + GLM --> paths + GLL --> paths + paths --> store + store --> graph + wikilinks --> graph + store --> panel + graph --> graphUi +``` + +**Pfad-Regeln (wie EB):** +- API-Pfad `learnings/foo.md` → Kategorie `learnings`, Scope je nach Root +- `decisions/note.md` unter memory → Kategorie `decisions` +- Root-`.md` → Kategorie `memory` +- Unterordner von `.agents/memory` (ohne `_templates`, ohne `.`) → `memorySubcategories` +- Node-IDs: `{scope}:{apiPath}`; Hub-IDs: `hub:{category}` + +--- + +## Implementierung (Phasen) + +### Phase 1 — Backend: Scope & Typen + +**Refactor** [`src-tauri/src/memory.rs`](c:/Users/quork/Entwicklung/BLXCode/src-tauri/src/memory.rs) in Module (analog EB): + +- `memory/paths.rs` — `get_global_roots()`, `get_workspace_roots()`, `list_memory_subcategories()`, `graph_category_for()`, `node_id`/`parse_node_id`, `validate_category_name` +- `memory/types.rs` — Serde-Structs spiegeln [`shared/memory.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/shared/memory.ts): `MemoryScope`, `NoteMeta`, `MemoryListResponse`, `GraphNode` (+ `isCategoryHub`, `hubScopes`), `GraphEdge` (+ `crossScope`), `SearchHit` (+ `scope`, `category`), `MemoryStatusResponse` + +**Neue/erweiterte Commands** in [`lib.rs`](c:/Users/quork/Entwicklung/BLXCode/src-tauri/src/lib.rs): + +- `memory_status(workspace_cwd?)` → workspace + global Ordner-Status +- `memory_bootstrap(target, workspace_cwd?)` — `workspace` | `global` | `all` (Seed README in memory/learnings wie EB `store.ts`) +- `memory_list` → `{ notes, memorySubcategories: { workspace, global } }` (ersetzt flaches `Vec`) +- Alle bestehenden Commands um Parameter `scope: MemoryScope` erweitern (`memory_read`, `write`, `create`, `delete`, `rename`, `create_category`, `graph`, `search`, `backlinks`, `export`, `import`) + +**`collect_notes`:** Notizen aus beiden Roots sammeln, `scope` setzen, Frontmatter parsen, `category`/`isLearning`/`isOverview`/`enabled`/`title`/`tags` befüllen (Logik aus EB `collectNotes` in `store.ts`). + +**Kompatibilität:** Bestehende Workspace-Dateien unter `.agents/` bleiben unverändert; Global wird bei erstem Bootstrap unter `~/.blxcode/` angelegt. + +**Legacy entfernen:** `migrate_legacy_memory` und `LEGACY_MEMORY_REL` aus [`agents_layout.rs`](c:/Users/quork/Entwicklung/BLXCode/src-tauri/src/agents_layout.rs) streichen; Test `memory_list_includes_learnings_and_legacy_migrates` ersetzen. Nutzer mit altem `/.blxcode/memory/` müssen Inhalt **manuell** nach `.agents/memory/` verschieben (einmalig, in Doku erwähnen). + +### Phase 2 — Graph, Wikilinks, Suche + +Port aus EB: + +- [`graph.rs`](c:/Users/quork/Entwicklung/BLXCode-eb/src/bun/memory/graph.ts): Hub-Knoten, Note→Hub-Kanten, Wikilink-Auflösung, `orphan`, `crossScope` +- [`wikilinks.rs`](c:/Users/quork/Entwicklung/BLXCode-eb/src/bun/memory/wikilinks.ts): `global:`/`workspace:`-Prefix, Auflösung über `scopePaths`-Map beider Scopes +- `memory_graph(workspace_cwd)` nutzt dieselbe Hub-Map-Logik wie `memoryGraph` in `store.ts` (Zeilen 434–496) + +`memory_backlinks` → `Vec<{ scope, path }>` statt nur Pfad-Strings. + +`memory_search` → Treffer mit `scope` + `category` für Filter `workspace:decisions` etc. + +### Phase 3 — Tauri-Bridge & Frontend-Typen + +[`src/tauri_bridge.rs`](c:/Users/quork/Entwicklung/BLXCode/src/tauri_bridge.rs): + +- Alle `memory_*`-Wrapper um `scope` ergänzen +- `memory_list` → neues Response-Struct +- Hilfsfunktionen wie EB [`memoryRpc.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/views/app/lib/memoryRpc.ts): `note_key(scope, path)`, `parse_note_key` + +Optional kleines Rust→WASM-geteiltes Modul oder Duplikat der Konstanten in [`src/config/app.config.rs`](c:/Users/quork/Entwicklung/BLXCode/src/config/app.config.rs): `CATEGORY_HUB_PREFIX`, `MEMORY_GRAPH_MODE_KEY` (bereits vorhanden prüfen). + +### Phase 4 — Memory-UI (Files-Tab) + +**Neue Modulstruktur** (analog EB `SidePanel/Memory/`), Leptos statt React: + +``` +src/workbench/memory/ + mod.rs — MemoryPanel, TabBar (files|graph|search) + use_memory.rs — State, load/save, bootstrap (≈ useMemory.ts) + scope_categories.rs — build_scope_categories (≈ memoryScopeCategories.ts) + files/ + sidebar.rs — Dual-Scope-Sektionen + Kategorie-Gruppen + editor.rs + new_category_dialog.rs + graph/ — ggf. memory_graph/ hierher ziehen oder re-export + search/ + dialogs/ — export, import, pointers, no-folder +``` + +[`memory_panel.rs`](c:/Users/quork/Entwicklung/BLXCode/src/workbench/memory_panel.rs) wird auf dünnen Re-Export reduziert oder entfernt. + +1. **State:** `memorySubcategories: Record>`, `activeKey: Option` (`scope:path`), Bootstrap beim Panel-Mount (`memory_bootstrap("all")`) +2. **`build_scope_categories`** — Port von [`memoryScopeCategories.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/views/app/layout/SidePanel/Memory/memoryScopeCategories.ts): learnings immer, + Unterordner + Kategorien aus Notizen +3. **Sidebar:** Zwei collapsible Sektionen „Projekt“ / „Global“ (i18n-Keys neu in [`src/i18n/`](c:/Users/quork/Entwicklung/BLXCode/src/i18n/)), pro Scope: + - Root-Notizen (`category == "memory"`) + - Collapsible Gruppen pro Kategorie (`groups_open` Key: `scope:category`) + - „Neue Kategorie“ pro Scope + - Global-Sektion: „Global Memory anlegen“ wenn noch nicht gebootstrappt (wie `MemoryNoFolderState` / `showCreateGlobal` in EB) +4. **Editor:** Frontmatter-aware Titel, `enabled`-Toggle, Wikilink-Klick mit Scope-Auflösung (`memory_resolve_link` neu, analog EB) +5. **`memory_note_groups`:** um `scope` erweitern oder durch EB-äquivalente Gruppierung ersetzen + +### Phase 5 — Graph-Tab + +[`src/workbench/memory_graph/mod.rs`](c:/Users/quork/Entwicklung/BLXCode/src/workbench/memory_graph/mod.rs): + +- Node-IDs als `scope:path`; Hub-Klicks → `overviewPathForCategory` + `preferredScopeForHub` (Port aus `shared/memory.ts`) +- `GraphNode.category` + Hub-Styling (Farben aus bestehenden `memory_category_settings`, Key ggf. `global:decisions`) +- `graph_selected_node` / Preview / Handoff: Scope mitgeben +- 2D/3D: Hub-Knoten visuell unterscheiden (EB trennt Hub vs. Note) + +### Phase 6 — Search-Tab + +- Scope-/Kategorie-Filter-Chips (`all` | `workspace:cat` | `global:cat`) wie [`MemorySearchView.tsx`](c:/Users/quork/Entwicklung/BLXCode-eb/src/views/app/layout/SidePanel/Memory/search/MemorySearchView.tsx) +- Treffer-Öffnen mit korrektem Scope + +### Phase 7 — Agent-Integration + +- **Tools** [`src-tauri/src/agent/tools.rs`](c:/Users/quork/Entwicklung/BLXCode/src-tauri/src/agent/tools.rs): `scope`-Parameter bei allen Memory-Tools; Pfade als `global:learnings/…` oder absoluter Workspace-Pfad dokumentieren +- **Client tools** [`client_tools.rs`](c:/Users/quork/Entwicklung/BLXCode/src-tauri/src/agent/client_tools.rs): Kontext-Items mit Scope-Prefix in `paths`/`source` +- **System prompt** + [`harness_skills/memory.md`](c:/Users/quork/Entwicklung/BLXCode/src-tauri/src/agent/harness_skills/memory.md): Global vs. Workspace, Kategorien, Hub-Graph +- **Deep links** [`state.rs`](c:/Users/quork/Entwicklung/BLXCode/src/workbench/state.rs): `pending_memory_note` → `(scope, path)` + +### Phase 8 — Export/Import/Pointer-UI + +Backend existiert teils; ergänzen: + +- Bridge-Wrapper für `memory_export` / `memory_import` (falls noch `dead_code`) +- Dialoge analog EB [`MemoryExportDialog.tsx`](c:/Users/quork/Entwicklung/BLXCode-eb/src/views/app/layout/SidePanel/Memory/MemoryExportDialog.tsx) / Import + Pointer-Installer im Memory-Panel-Menü + +### Phase 9 — Tests & Doku + +- Rust-Tests in `memory/`: Scope-Trennung, `list_memory_subcategories`, Hub-Graph, cross-scope Wikilink, bootstrap global +- [`docs/user/memory-and-tasks.md`](c:/Users/quork/Entwicklung/BLXCode/docs/user/memory-and-tasks.md) + [`docs/developer/tauri-ipc.md`](c:/Users/quork/Entwicklung/BLXCode/docs/developer/tauri-ipc.md) aktualisieren + +--- + +## Wichtige Portierungs-Details (nicht vergessen) + +1. **Learnings** bleiben separater Root (nicht Unterordner von memory), API-Prefix `learnings/` — in beiden Repos gleich. +2. **Reservierte Kategorien:** `_templates`, `memory`, `learnings` — keine neuen Ordner mit diesen Namen. +3. **Overview-Notizen:** `README.md` in Kategorie-Ordner → `isOverview`, Hub-Label im Graph. +4. **Learning-Dateinamen:** `learning-.md` (EB validiert in `validateLearningName`). +5. **Kategorie-Einstellungen:** Beim Deserialisieren/Lesen alte Keys ohne `:` → `workspace:`; neue Keys immer `workspace:…` oder `global:…`. + +--- + +## Zwei verschiedene `.blxcode`-Pfade (häufige Verwechslung) + +| Pfad | Bedeutung | Nach Umsetzung | +|------|-----------|----------------| +| `/.blxcode/memory/` | **Altes** projekt-lokales Memory (frühere BLXCode-Generation) | **Nicht mehr** automatisch gelesen/migriert — manuell nach `.agents/memory/` verschieben | +| `~/.blxcode/memory/` + `~/.blxcode/learnings/` | **Global Memory** (EB-Modell, app-weit) | Neuer Scope `global` in UI/Graph/Agent | + +Global Memory liegt absichtlich **nicht** im Workspace, sondern im Home-Verzeichnis — wie in EB. + +--- + +## Nicht im Scope (bewusst) + +- Semantische Suche / Embeddings +- Rules/Skills/Plans in Memory-Graph (EB schließt das ebenfalls aus) +- Automatische Migration von `/.blxcode/memory/` (bewusst entfernt, siehe Entscheidungen) + +--- + +## Empfohlene Reihenfolge & Risiko + +1. Backend + Legacy-Entfernung + `memory_list` Breaking Change +2. Bridge +3. Neues `workbench/memory/`-Modul (Files → Graph → Search) +4. Agent, Export/Import, Doku + +Größter Aufwand: UI-Neustrukturierung + Scope durchgängig; dafür kein paralleles Pflegen von Alt-`memory_panel.rs`. diff --git a/.agents/.cursor/plans/phase5-6-handoff-for-codex.md b/.agents/.cursor/plans/phase5-6-handoff-for-codex.md new file mode 100644 index 0000000..c1022c1 --- /dev/null +++ b/.agents/.cursor/plans/phase5-6-handoff-for-codex.md @@ -0,0 +1,327 @@ +# Handoff: Memory System — Phases 5 & 6 + +## Context + +This is BLXCode (Tauri 2 + Leptos 0.8 CSR), a desktop IDE. +Phases 1–4 of the memory-parity plan are **complete and compiling**. +This document tells you exactly what is left for Phases 5 and 6 and where every relevant piece of code lives. + +--- + +## What Was Done (Phases 1–4) + +### Backend (`src-tauri/src/memory/`) +- `memory.rs` was replaced by a full module: `types.rs`, `paths.rs`, `frontmatter.rs`, `wikilinks.rs`, `graph.rs`, `store.rs`, `mod.rs` +- `MemoryScope { Workspace, Global }` (serde: `"workspace"` / `"global"`) runs everywhere +- Global roots: `~/.blxcode/memory/` and `~/.blxcode/learnings/` (via `dirs::home_dir()`) +- New commands registered in `src-tauri/src/lib.rs`: `memory_status`, `memory_bootstrap` +- All CRUD commands have `scope: MemoryScope` parameter +- `memory_list` returns `MemoryListResponse { notes: Vec, memory_subcategories: MemorySubcategories { workspace, global } }` +- `memory_backlinks` returns `Vec` +- Graph builder creates hub nodes (`id: "hub:{category}"`, `is_category_hub: true`) and cross-scope edges +- Legacy migration (`migrate_legacy_memory`) removed from `agents_layout.rs` + +### Frontend (`src/`) +- `src/tauri_bridge.rs`: all memory types updated, `MemoryScope` enum, `note_key(scope, path)`, `parse_note_key(key)`, `memory_bootstrap`, `memory_status` +- `src/workbench/memory_panel.rs`: dual-scope sidebar implemented + - `MemoryState` has `active_scope`, `global_subcategories`, `global_bootstrapped`, `backlinks: Vec` + - Files view has two sections: "Projekt" (workspace) and "Global" + - Global section shows "Create Global Memory" button when not bootstrapped + - All CRUD ops (create note/category, rename, delete, backlink click, context menu) use `note.scope` + - Workspace bootstrap called on workspace change +- `src/workbench/memory_graph/mod.rs`: still uses **hardcoded `MemoryScope::Workspace`** in two places (intentional Phase 4 deferral) + +--- + +## Current State of `memory_graph/mod.rs` + +File: `src/workbench/memory_graph/mod.rs` + +Two hardcoded workspace fallbacks that need to be fixed in Phase 5: + +**Line ~810** (in the "Open in Files" button click handler): +```rust +expand_files_group_for_path(state.clone(), &MemoryScope::Workspace, &path); +load_note(state.clone(), ws, MemoryScope::Workspace, path); +``` + +**Line ~858** (inside `open_graph_preview`): +```rust +match tauri_bridge::memory_read(&ws, &MemoryScope::Workspace, &path).await { +``` + +The `GraphPreviewState.path` signal currently holds just the bare API path (`"decisions/note.md"`), without scope. The graph node ID is `"{scope}:{path}"` (see `GraphNode.id` in `tauri_bridge.rs`). The scope must be recovered from the node ID when a node is clicked, so it can be threaded through to `load_note`, `memory_read`, etc. + +--- + +## Phase 5 — Graph-Tab: Scope-Aware Clicks + Hub-Node Styling + +### Goal +1. Node clicks carry the correct scope (workspace or global) +2. Hub nodes are visually distinct from regular note nodes +3. Cross-scope edges are optionally styled differently + +### Key Types (already defined in `src/tauri_bridge.rs`) + +```rust +pub struct GraphNode { + pub id: String, // "workspace:decisions/note.md" or "hub:decisions" + pub scope: MemoryScope, + pub path: String, // bare API path, e.g. "decisions/note.md" + pub label: String, + pub tags: Vec, + pub orphan: bool, + pub category: String, + pub is_category_hub: Option, // true for hub nodes + pub hub_scopes: Option>, + pub color: Option, +} + +pub struct GraphEdge { + pub source: String, + pub target: String, + pub cross_scope: bool, +} +``` + +### Changes Required in `src/workbench/memory_graph/mod.rs` + +#### 1. `GraphPreviewState` — add scope field + +```rust +struct GraphPreviewState { + open: RwSignal, + scope: RwSignal, // ADD THIS + path: RwSignal>, + label: RwSignal, + content: RwSignal, + loading: RwSignal, +} +``` + +Initialize `scope: RwSignal::new(MemoryScope::Workspace)` in `GraphPreviewState::new()`. + +#### 2. `open_graph_preview` — derive scope from node ID + +The function signature is `fn open_graph_preview(state: MemoryState, preview: GraphPreviewState, path: String)`. +The `path` argument is currently the bare API path. Change the call site to pass the node **ID** (which includes the scope prefix), then parse it: + +```rust +fn open_graph_preview(state: MemoryState, preview: GraphPreviewState, node_id: String) { + // node_id is either "workspace:some/path.md" or "hub:category" or "global:some/path.md" + let (scope, path) = tauri_bridge::parse_note_key(&node_id) + .unwrap_or((MemoryScope::Workspace, node_id.clone())); + + state.graph_selected_node.set(Some(node_id)); + preview.open.set(true); + preview.scope.set(scope.clone()); + preview.path.set(Some(path.clone())); + // ... rest unchanged, but use `scope` instead of MemoryScope::Workspace for memory_read + match tauri_bridge::memory_read(&ws, &scope, &path).await { ... } +} +``` + +For hub nodes (`parse_note_key` returns `None` because the ID is `"hub:category"`, not `"scope:path"`): show a hub summary panel (list of notes in this category) instead of reading a file. Check: `if node_id.starts_with("hub:")`. + +#### 3. "Open in Files" button — pass scope correctly + +```rust +expand_files_group_for_path(state.clone(), &preview.scope.get_untracked(), &path); +load_note(state.clone(), ws, preview.scope.get_untracked(), path); +``` + +#### 4. `navigate_to_graph_node` in `src/workbench/memory_graph/mod.rs` + +This public function is called from `memory_panel.rs` when the user clicks "show in graph" for the active note. It currently takes a plain path. Update to also accept scope so it can set the correct selected node: + +Current signature (approx.): +```rust +pub(crate) fn navigate_to_graph_node(state: MemoryState, path: String) +``` + +Change to: +```rust +pub(crate) fn navigate_to_graph_node(state: MemoryState, scope: MemoryScope, path: String) +``` + +And form the node ID: `let node_id = tauri_bridge::note_key(&scope, &path);` +Then set `state.graph_selected_node.set(Some(node_id))`. + +Update all call sites in `memory_panel.rs` — search for `navigate_to_graph_node` to find them. + +#### 5. Hub Node Visual Styling + +In the graph renderer (JavaScript side in `public/` or wherever the 3D/2D graph is rendered), hub nodes can be detected by their ID prefix `"hub:"`. + +On the Leptos side, the node list passed to the JS graph should tag hub nodes. `GraphNode.is_category_hub` is already `Some(true)` for hubs. The existing graph bridge in `memory_graph/mod.rs` serializes the graph data to JS — check how nodes are passed and add a `hub` CSS class or color override. + +Look for the JS graph integration in `public/` (likely `public/memory_graph.js` or similar) and check if there is already a hub-specific rendering path. The `GraphNode.color` field can be set by the backend but may also be overridden in JS. Hub nodes should be rendered larger and with a folder/category icon. + +If the graph rendering is done entirely in WASM/Leptos without JS: add a `class:memory-graph-node--hub=move || node.is_category_hub.unwrap_or(false)` to the node element and style in `styles.css`. + +#### 6. Cross-scope Edge Styling + +`GraphEdge.cross_scope: bool` is already in the data. Style cross-scope edges with a dashed line or different color. Pass this through to the graph JS as an edge attribute. + +--- + +## Phase 6 — Search-Tab: Scope-Aware Filter Chips + +### Current State + +File: `src/workbench/memory_panel.rs`, component `MemorySearchView` (around line 1830+). + +Current filter chips are hardcoded to `"memory"` and `"learnings"` strings, which only cover workspace. The filter functions `memory_hit_count`, `learnings_hit_count`, `search_hit_category`, `filter_search_hits` all use string-comparison against these two categories. + +`SearchHit` (in `src/tauri_bridge.rs`) already has: +```rust +pub struct SearchHit { + pub scope: MemoryScope, + pub path: String, + pub line: u32, + pub snippet: String, + pub category: String, // e.g. "decisions", "learnings", "memory" +} +``` + +### Changes Required + +#### 1. Replace hardcoded filter chips with dynamic scope+category chips + +Instead of a static "Memory" / "Learnings" split, build dynamic chips from the actual search results: + +``` +[All (N)] [Workspace (N)] [Global (N)] [workspace:decisions (N)] [global:memory (N)] ... +``` + +The chip key format is: +- `None` → All results +- `Some("workspace")` → all workspace hits +- `Some("global")` → all global hits +- `Some("workspace:decisions")` → workspace hits in category "decisions" +- `Some("global:learnings")` → global learnings + +Build chips dynamically from `SearchHit.scope` + `SearchHit.category`. + +#### 2. Update `filter_search_hits` + +```rust +fn filter_search_hits(hits: Vec, filter: Option) -> Vec { + match filter.as_deref() { + None => hits, + Some("workspace") => hits.into_iter().filter(|h| h.scope == MemoryScope::Workspace).collect(), + Some("global") => hits.into_iter().filter(|h| h.scope == MemoryScope::Global).collect(), + Some(key) => { + // "workspace:decisions" or "global:learnings" + if let Some((scope_str, cat)) = key.split_once(':') { + let target_scope = match scope_str { + "workspace" => MemoryScope::Workspace, + "global" => MemoryScope::Global, + _ => return hits, + }; + hits.into_iter() + .filter(|h| h.scope == target_scope && h.category == cat) + .collect() + } else { + hits + } + } + } +} +``` + +#### 3. Update `load_note` call in search results + +The search hit click currently calls: +```rust +load_note(s.clone(), ws, sc.clone(), p.clone()); +``` +This is already correct (Phase 4 set `h.scope` into `sc`). Verify it's still wired up correctly. + +#### 4. Build dynamic filter chip list + +Add a helper: +```rust +fn search_scope_categories(hits: &[SearchHit]) -> Vec<(String, usize)> { + // Returns (chip_key, count) pairs for All + scope-level + scope:category level + // Only include chips where count > 0 +} +``` + +Render them in the `MemorySearchView` filter bar instead of the current hardcoded buttons. + +#### 5. i18n + +Add keys for "Workspace" and "Global" scope labels used in filter chips: +- `MemSearchFilterWorkspace` +- `MemSearchFilterGlobal` + +Add to `src/i18n/keys.rs` and all locale files in `src/i18n/locales/`. See how `MemGlobalCreate` was added in Phase 4 for the pattern. + +--- + +## Reference: EB Implementation + +The BLXCode-eb reference lives at `c:\Users\quork\Entwicklung\BLXCode-eb\`. + +For Phase 5: +- `src/views/app/layout/SidePanel/Memory/graph/MemoryGraphView.tsx` — hub node rendering, scope-aware click handlers +- `src/shared/memory.ts` — `overviewPathForCategory`, `preferredScopeForHub`, `CATEGORY_HUB_PREFIX = "hub:"` + +For Phase 6: +- `src/views/app/layout/SidePanel/Memory/search/MemorySearchView.tsx` — scope+category filter chips +- `src/shared/memory.ts` — filter chip logic + +--- + +## Compile Instructions + +```powershell +# Check frontend only (fast) +cargo check -p blxcode-ui --target wasm32-unknown-unknown + +# Check backend only +cargo check -p blxcode + +# Run all tests +cargo test --workspace + +# Full dev build +cargo tauri dev +``` + +There are currently no compile errors. There are some `dead_code` / `unused_import` warnings in `src-tauri/src/memory/store.rs` and `tauri_bridge.rs` (`parse_note_key`) — these are harmless leftovers. + +--- + +## Files to Touch + +| File | Why | +|------|-----| +| `src/workbench/memory_graph/mod.rs` | Phase 5: scope-aware node clicks, hub styling, `navigate_to_graph_node` signature | +| `src/workbench/memory_panel.rs` | Phase 5: update `navigate_to_graph_node` call sites; Phase 6: `MemorySearchView` filter chips | +| `src/i18n/keys.rs` | Phase 6: add `MemSearchFilterWorkspace`, `MemSearchFilterGlobal` | +| `src/i18n/locales/*.rs` | Phase 6: add translations for all 13 locales | +| `styles.css` | Phase 5: hub node CSS classes (if rendered in Leptos) | +| `public/` (JS graph) | Phase 5: hub node visual distinction in graph renderer | + +Do **not** touch: +- `src-tauri/src/memory/` — backend is complete +- `src/tauri_bridge.rs` — bridge is complete (`parse_note_key` is already there) +- `src-tauri/src/lib.rs` — all commands registered + +--- + +## Acceptance Criteria + +**Phase 5 done when:** +- Clicking a global graph node opens it in the editor with `MemoryScope::Global` +- Clicking a hub node shows a summary (or a list of notes in that category) — not a 404 +- Hub nodes are visually distinct from note nodes (size, shape, or color) +- The "Open in Files" button in the graph preview navigates to the correct scope section + +**Phase 6 done when:** +- Search results from both workspace and global memory appear +- Filter chips allow narrowing by scope (`workspace` / `global`) and by scope+category +- Clicking a search result opens the note with the correct scope +- `cargo check -p blxcode-ui --target wasm32-unknown-unknown` has no errors diff --git a/.agents/learnings/LEARNINGS.md b/.agents/learnings/LEARNINGS.md deleted file mode 100644 index e6cecf9..0000000 --- a/.agents/learnings/LEARNINGS.md +++ /dev/null @@ -1,12 +0,0 @@ -# Learnings - -This directory is the persistent knowledge base for AI coding agents working on -this repository. Use it to capture facts, decisions, conventions, pitfalls, and -resolved mistakes that are useful beyond a single task. - -Keep this file as the overview and index. Store individual learnings in separate -Markdown files inside `.agents/learnings/`. - -## Index - -_(Add learnings here as `[[learnings/topic-filename|Short title]]` — one line per topic.)_ diff --git a/.agents/learnings/README.md b/.agents/learnings/README.md new file mode 100644 index 0000000..238c001 --- /dev/null +++ b/.agents/learnings/README.md @@ -0,0 +1,28 @@ +--- +title: Learnings +enabled: true +tags: [] +--- +# Learnings + +This folder is a **growing knowledge base of resolved problems** - concrete things you discovered the hard way and want future agents (human or AI) to find. + +Each learning is a separate markdown file named `learning-.md`. Together they form a searchable log of "the thing we now know that we didn't know before." + +Keep this file as the overview and index. Store individual learnings in separate Markdown files inside `.agents/learnings/`. + +## When to record a learning + +- A bug you fixed that wasn't obvious from the symptom +- A workaround for a quirk in a dependency, runtime, or environment +- A migration step that required out-of-band knowledge + +## Tips for AI agents + +- Search here before debugging - someone may have hit this already. +- When you fix a non-trivial bug, propose a new learning entry. +- Keep one learning per file; cross-link related ones via `[[wikilinks]]`. + +## Index + +_(Add learnings here as `[[learnings/topic-filename|Short title]]` - one line per topic.)_ diff --git a/.agents/memory/README.md b/.agents/memory/README.md new file mode 100644 index 0000000..813c477 --- /dev/null +++ b/.agents/memory/README.md @@ -0,0 +1,28 @@ +--- +title: Memory +enabled: true +tags: [] +--- +# Memory + +This folder stores **durable, project-wide notes** that should survive across coding sessions and be available to every AI agent or teammate working in this repo. + +## What belongs here + +- Architectural decisions and the reasoning behind them +- Conventions, patterns and "house style" that aren't obvious from the code +- Reference material: external systems, where to find logs / dashboards +- Known-good workflows ("how we deploy", "how we cut a release") +- User-specific preferences for collaboration + +## What does **not** belong here + +- Ephemeral todos for the current task (use a Plan or task list instead) +- Things already documented in code, `CLAUDE.md`, or commit messages +- Time-sensitive snapshots (`git log` is authoritative for history) + +## Tips for AI agents + +- Read existing notes before answering questions about this project. +- When you learn something non-obvious, propose adding it here. +- Prefer updating an existing note over creating a near-duplicate. diff --git a/.agents/plans/PLANS.md b/.agents/plans/PLANS.md index 366799b..435b91a 100644 --- a/.agents/plans/PLANS.md +++ b/.agents/plans/PLANS.md @@ -6,6 +6,7 @@ Persistent plans for multi-step work on **blxcode**. Individual plans live as Ma | Status | Plan | Description | |--------|------|-------------| +| planned | [linux-browser-iframe-boot-fix.md](linux-browser-iframe-boot-fix.md) | Linux Boot-Crash Fix: sticky Lazy Mount für BrowserTabDock + iframe-src-Gating (WebKitGTK nested iframe) | | planned | [performance-optimization.md](performance-optimization.md) | Performance-Audit: Agent-Streaming hot path, Auto-Save-Kaskade, Backend-Blocking, Boot/CDN, Terminal-Refit; Phasen P0–P3 | | planned | [security-hardening.md](security-hardening.md) | Security-Audit: Subagent shell_write-Bypass, Shell-Allowlist, XSS/CSP, Runtime-Tool-Allowlist, Symlink/URL/PTY-Hardening; Phasen P0–P3 | | planned | [v2-roadmap.md](v2-roadmap.md) | V2-Roadmap (Trust Repair): plan_context-Bug, Konversations-Persistenz, Kanban-MVP, web_fetch, Docs-Sync, CI; Phasen V2.0–V2.3+ | diff --git a/.agents/plans/linux-browser-iframe-boot-fix.md b/.agents/plans/linux-browser-iframe-boot-fix.md new file mode 100644 index 0000000..7ceb93b --- /dev/null +++ b/.agents/plans/linux-browser-iframe-boot-fix.md @@ -0,0 +1,31 @@ +# Linux BrowserTabDock Boot-Crash Fix + +## Summary + +Behebt den Linux-only Boot-Crash: `BrowserTabDock` wurde beim Workbench-Start per CSS versteckt, mountete aber sofort ` diff --git a/src/workbench/chat_markdown.rs b/src/workbench/chat_markdown.rs index 5df9e12..e799556 100644 --- a/src/workbench/chat_markdown.rs +++ b/src/workbench/chat_markdown.rs @@ -29,11 +29,8 @@ fn strip_known_prefixes(path: &str) -> Cow<'_, str> { Cow::Borrowed(t) } -const MEMORY_PATH_PREFIXES: &[&str] = &[ - ".agents/learnings/", - ".agents/memory/", - ".blxcode/memory/", -]; +const MEMORY_PATH_PREFIXES: &[&str] = + &[".agents/learnings/", ".agents/memory/", ".blxcode/memory/"]; fn memory_rel_from_display_path(display_path: &str) -> Option { let t = display_path.trim(); @@ -174,6 +171,22 @@ pub fn preprocess_agent_chat_markdown(src: &str) -> String { out } +fn strip_yaml_frontmatter(src: &str) -> &str { + let rest = src + .strip_prefix("---\r\n") + .or_else(|| src.strip_prefix("---\n")); + let Some(rest) = rest else { + return src; + }; + + for marker in ["\n---\r\n", "\n---\n", "\n...\r\n", "\n...\n"] { + if let Some(pos) = rest.find(marker) { + return &rest[pos + marker.len()..]; + } + } + src +} + /// Strips `blx-open` from fenced-block info lines (first token only) and records whether each /// fence should start expanded. fn normalize_blx_open_fenced_markers(md: &str) -> (String, Vec) { @@ -487,9 +500,7 @@ fn postprocess_html_inline_code_memory_paths(html: &mut String) { rest = &rest[en + 7..]; let trimmed = inner.trim(); if !inner.contains('\n') - && MEMORY_PATH_PREFIXES - .iter() - .any(|p| trimmed.starts_with(p)) + && MEMORY_PATH_PREFIXES.iter().any(|p| trimmed.starts_with(p)) && trimmed.to_ascii_lowercase().ends_with(".md") { if let Some(rel) = memory_rel_from_display_path(trimmed) { @@ -510,7 +521,7 @@ fn postprocess_html_inline_code_memory_paths(html: &mut String) { #[must_use] pub fn render_markdown_to_html(src: &str) -> String { - let prepped = preprocess_agent_chat_markdown(src); + let prepped = preprocess_agent_chat_markdown(strip_yaml_frontmatter(src)); let (md_for_cmark, fence_expand) = normalize_blx_open_fenced_markers(&prepped); let mut opts = Options::empty(); opts.insert(Options::ENABLE_TABLES); @@ -583,6 +594,23 @@ fn main() {} assert!(!html.contains("```")); } + #[test] + fn render_strips_yaml_frontmatter() { + let md = "---\ntitle: Learnings\nenabled: true\n---\n# Learnings\n"; + let html = render_markdown_to_html(md); + assert!(!html.contains("title:")); + assert!(!html.contains("enabled:")); + assert!(html.contains("

Learnings

")); + } + + #[test] + fn render_keeps_horizontal_rule_without_frontmatter_close() { + let md = "---\n# Title\n"; + let html = render_markdown_to_html(md); + assert!(html.contains("Title")); + } + #[test] fn vague_markdown_fence_shows_inferred_rust_in_summary() { let html = r#"
fn main() {
diff --git a/src/workbench/file_diff_section/file-diff-section.css b/src/workbench/file_diff_section/file-diff-section.css
index b320c95..28a1f34 100644
--- a/src/workbench/file_diff_section/file-diff-section.css
+++ b/src/workbench/file_diff_section/file-diff-section.css
@@ -1,8 +1,4 @@
 .sidebar-view-section--sb-diff {
-  flex: 0 0 auto;
-}
-
-.sidebar-view-section--sb-diff.sidebar-view-section--open {
   flex: 1 1 auto;
   min-height: 0;
 }
@@ -19,6 +15,57 @@
   padding: 0.15rem 0 0.3rem;
 }
 
+.file-diff-section__group {
+  list-style: none;
+}
+
+.file-diff-section__group-toggle {
+  display: flex;
+  align-items: center;
+  gap: 0.35rem;
+  width: 100%;
+  min-height: 22px;
+  padding: 0.1rem 0.35rem 0.1rem 0.25rem;
+  border: none;
+  background: var(--surface-2, var(--overlay-1));
+  color: var(--text-muted);
+  font: inherit;
+  font-size: 0.66rem;
+  font-weight: 600;
+  letter-spacing: 0.04em;
+  text-transform: uppercase;
+  text-align: left;
+  cursor: pointer;
+}
+
+.file-diff-section__group-toggle:hover,
+.file-diff-section__group-toggle:focus-visible {
+  background: var(--overlay-2);
+  color: var(--text);
+  outline: none;
+}
+
+.file-diff-section__group-title {
+  flex: 1 1 auto;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.file-diff-section__group-chev {
+  flex-shrink: 0;
+  font-size: 0.62rem;
+  line-height: 1;
+  opacity: 0.75;
+}
+
+.file-diff-section__group-list {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
+
 .file-diff-section__row {
   display: flex;
   align-items: center;
diff --git a/src/workbench/file_diff_section/mod.rs b/src/workbench/file_diff_section/mod.rs
index 8694128..28960a8 100644
--- a/src/workbench/file_diff_section/mod.rs
+++ b/src/workbench/file_diff_section/mod.rs
@@ -7,7 +7,8 @@ use crate::i18n::I18nKey;
 use crate::service::I18nService;
 use crate::tauri_bridge::{
     git_stage_file, git_status_changes, git_status_watch_start, git_status_watch_stop,
-    git_unstage_file, listen_git_status_dirty, ChangedFile, TauriEventListener, GIT_MISSING_CODE,
+    git_unstage_file, listen_git_status_dirty, ChangedFile, LineStats, TauriEventListener,
+    GIT_MISSING_CODE,
 };
 use crate::workbench::sidebar_view_section::{SidebarSectionIconBtn, SidebarViewSection};
 use crate::workbench::WorkbenchService;
@@ -215,29 +216,12 @@ fn FileDiffBody(
     reload: Callback<()>,
 ) -> impl IntoView {
     let i18n = expect_context::();
+    let staged_open = RwSignal::new(true);
+    let unstaged_open = RwSignal::new(true);
 
     view! {
         
- "…"

- } - .into_any(); - }; - if list.is_empty() { - return view! { - - } - .into_any(); - } - view! { }.into_any() - } - > + + + +
} } +#[derive(Clone, Copy, PartialEq, Eq)] +enum DiffGroupVariant { + Staged, + Unstaged, +} + +fn partition_entries(entries: &[ChangedFile]) -> (Vec, Vec) { + let mut staged = Vec::new(); + let mut unstaged = Vec::new(); + for entry in entries { + if entry.staged { + staged.push(entry.clone()); + } + if entry.unstaged { + unstaged.push(entry.clone()); + } + } + (staged, unstaged) +} + #[component] -fn FileDiffList(entries: Vec, reload: Callback<()>) -> impl IntoView { - let wb = expect_context::(); +fn FileDiffList( + entries: RwSignal>>, + reload: Callback<()>, + staged_open: RwSignal, + unstaged_open: RwSignal, +) -> impl IntoView { + let i18n = expect_context::(); + + view! { + {move || match entries.get() { + None => { + view! { }.into_any() + } + Some(list) if list.is_empty() => { + view! { + + } + .into_any() + } + Some(list) => { + let (staged, unstaged) = partition_entries(&list); + view! { +
    + + +
+ } + .into_any() + } + }} + } +} + +#[component] +fn FileDiffGroup( + variant: DiffGroupVariant, + entries: Vec, + open: RwSignal, + reload: Callback<()>, +) -> impl IntoView { let i18n = expect_context::(); + if entries.is_empty() { + return ().into_any(); + } + + let count = entries.len(); + let panel_id = match variant { + DiffGroupVariant::Staged => "file-diff-staged", + DiffGroupVariant::Unstaged => "file-diff-unstaged", + }; + let title_base = move || match variant { + DiffGroupVariant::Staged => i18n.tr(I18nKey::SbDiffGroupStaged)(), + DiffGroupVariant::Unstaged => i18n.tr(I18nKey::SbDiffGroupUnstaged)(), + }; + let title = move || format!("{} ({count})", title_base()); view! { -
    +
  • + + + + +
  • + } + .into_any() +} + +#[component] +fn FileDiffGroupList( + entries: Vec, + variant: DiffGroupVariant, + reload: Callback<()>, + panel_id: &'static str, +) -> impl IntoView { + view! { +
      I18nKey::SbDiffStatusAdded, - "deleted" => I18nKey::SbDiffStatusDeleted, - "renamed" => I18nKey::SbDiffStatusRenamed, - "untracked" => I18nKey::SbDiffStatusUntracked, - "conflicted" => I18nKey::SbDiffStatusConflicted, - _ => I18nKey::SbDiffStatusModified, - }; - let status_marker = status_marker_for(&status_kind); - let added = entry.added_lines; - let removed = entry.removed_lines; - let row_class = format!( - "file-diff-section__row file-diff-section__row--{status_kind}" - ); - let marker_class = format!( - "file-diff-section__status file-diff-section__status--{status_kind}" - ); - let on_open = move |_| { - let workspace_id = wb.active_id().get_untracked(); - let Some(ws_id) = workspace_id else { - return; - }; - wb.open_center_diff_tab(ws_id, rel_for_open.clone(), staged && !unstaged); - }; - let on_stage = { - let rel = rel_for_stage.clone(); - move |ev: web_sys::MouseEvent| { - ev.stop_propagation(); - let Some(cwd) = wb.default_workspace_cwd() else { - return; - }; - let rel = rel.clone(); - let reload = reload; - spawn_local(async move { - let _ = git_stage_file(cwd, rel).await; - reload.run(()); - }); - } - }; - let on_unstage = { - let rel = rel_for_unstage.clone(); - move |ev: web_sys::MouseEvent| { - ev.stop_propagation(); - let Some(cwd) = wb.default_workspace_cwd() else { - return; - }; - let rel = rel.clone(); - let reload = reload; - spawn_local(async move { - let _ = git_unstage_file(cwd, rel).await; - reload.run(()); - }); - } - }; - let stage_aria_label = { - let prefix = i18n.tr(I18nKey::SbDiffStageAriaPrefix)(); - let path = rel.clone(); - move || format!("{prefix} {path}") - }; - let unstage_aria_label = { - let prefix = i18n.tr(I18nKey::SbDiffUnstageAriaPrefix)(); - let path = rel.clone(); - move || format!("{prefix} {path}") - }; - let status_title_fn = i18n.tr(status_label_key); - let status_title = StoredValue::new(status_title_fn()); view! { -
    • - -
      - - - - - - -
      -
    • + } } /> @@ -394,6 +397,147 @@ fn FileDiffList(entries: Vec, reload: Callback<()>) -> impl IntoVie } } +#[component] +fn FileDiffRow( + entry: ChangedFile, + variant: DiffGroupVariant, + reload: Callback<()>, +) -> impl IntoView { + let wb = expect_context::(); + let i18n = expect_context::(); + + let rel = entry.rel_path.clone(); + let rel_for_open = rel.clone(); + let rel_for_stage = rel.clone(); + let rel_for_unstage = rel.clone(); + let staged = entry.staged; + let unstaged = entry.unstaged; + let status_kind = entry.status.clone(); + let stats: Option = match variant { + DiffGroupVariant::Staged => entry.staged_stats, + DiffGroupVariant::Unstaged => entry.unstaged_stats, + }; + let added = stats.as_ref().map(|s| s.added).unwrap_or(0); + let removed = stats.as_ref().map(|s| s.removed).unwrap_or(0); + let open_staged = variant == DiffGroupVariant::Staged; + let status_label_key = match status_kind.as_str() { + "added" => I18nKey::SbDiffStatusAdded, + "deleted" => I18nKey::SbDiffStatusDeleted, + "renamed" => I18nKey::SbDiffStatusRenamed, + "untracked" => I18nKey::SbDiffStatusUntracked, + "conflicted" => I18nKey::SbDiffStatusConflicted, + _ => I18nKey::SbDiffStatusModified, + }; + let status_marker = status_marker_for(&status_kind); + let row_class = format!("file-diff-section__row file-diff-section__row--{status_kind}"); + let marker_class = format!("file-diff-section__status file-diff-section__status--{status_kind}"); + let on_open = move |_| { + let workspace_id = wb.active_id().get_untracked(); + let Some(ws_id) = workspace_id else { + return; + }; + wb.open_center_diff_tab(ws_id, rel_for_open.clone(), open_staged); + }; + let on_stage = { + let rel = rel_for_stage.clone(); + move |ev: web_sys::MouseEvent| { + ev.stop_propagation(); + let Some(cwd) = wb.default_workspace_cwd() else { + return; + }; + let rel = rel.clone(); + let reload = reload; + spawn_local(async move { + let _ = git_stage_file(cwd, rel).await; + reload.run(()); + }); + } + }; + let on_unstage = { + let rel = rel_for_unstage.clone(); + move |ev: web_sys::MouseEvent| { + ev.stop_propagation(); + let Some(cwd) = wb.default_workspace_cwd() else { + return; + }; + let rel = rel.clone(); + let reload = reload; + spawn_local(async move { + let _ = git_unstage_file(cwd, rel).await; + reload.run(()); + }); + } + }; + let stage_aria_label = { + let prefix = i18n.tr(I18nKey::SbDiffStageAriaPrefix)(); + let path = rel.clone(); + move || format!("{prefix} {path}") + }; + let unstage_aria_label = { + let prefix = i18n.tr(I18nKey::SbDiffUnstageAriaPrefix)(); + let path = rel.clone(); + move || format!("{prefix} {path}") + }; + let status_title_fn = i18n.tr(status_label_key); + let status_title = StoredValue::new(status_title_fn()); + + view! { +
    • + +
      + + + + + + +
      +
    • + } +} + fn status_marker_for(kind: &str) -> &'static str { match kind { "modified" => "M", diff --git a/src/workbench/file_preview/code_context_menu.rs b/src/workbench/file_preview/code_context_menu.rs index 6725fee..f7fe34a 100644 --- a/src/workbench/file_preview/code_context_menu.rs +++ b/src/workbench/file_preview/code_context_menu.rs @@ -13,9 +13,7 @@ use crate::i18n::I18nKey; use crate::service::I18nService; -use crate::workbench::agent_context_handoff::{ - WorkspaceTerminalGroup, WorkspaceTerminalTarget, -}; +use crate::workbench::agent_context_handoff::{WorkspaceTerminalGroup, WorkspaceTerminalTarget}; use leptos::prelude::*; /// Action emitted from a menu item. The parent component holds the diff --git a/src/workbench/file_preview/code_view.rs b/src/workbench/file_preview/code_view.rs index f4e6f42..4bac378 100644 --- a/src/workbench/file_preview/code_view.rs +++ b/src/workbench/file_preview/code_view.rs @@ -9,8 +9,7 @@ use crate::i18n::I18nKey; use crate::service::I18nService; use crate::tauri_bridge::{is_tauri_shell, pty_write, read_workspace_text_file}; use crate::workbench::agent_context_handoff::{ - file_snippet_context_item, list_terminal_targets_all_workspaces, - render_file_snippet_envelope, + file_snippet_context_item, list_terminal_targets_all_workspaces, render_file_snippet_envelope, }; use crate::workbench::file_preview::code_context_menu::{ CodeContextMenu, CodeContextMenuState, CodeMenuAction, @@ -169,42 +168,36 @@ pub fn CodeView( // Window-level mouseup ends any in-progress drag, even if the pointer // left the code area. We register once and clean up with on_cleanup. - let mouseup_handle = leptos::leptos_dom::helpers::window_event_listener_untyped( - "mouseup", - move |_| { + let mouseup_handle = + leptos::leptos_dom::helpers::window_event_listener_untyped("mouseup", move |_| { if drag_anchor.get_untracked().is_some() { drag_anchor.set(None); drag_moved.set(false); } - }, - ); + }); on_cleanup(move || drop(mouseup_handle)); // Click anywhere closes the menu. We listen at window level instead of // installing per-element listeners so the menu closes consistently for // every dismissal path (clicking another row, the page background, etc.). - let click_close_handle = leptos::leptos_dom::helpers::window_event_listener_untyped( - "mousedown", - move |_| { + let click_close_handle = + leptos::leptos_dom::helpers::window_event_listener_untyped("mousedown", move |_| { if menu_state.get_untracked().is_some() { menu_state.set(None); } - }, - ); + }); on_cleanup(move || drop(click_close_handle)); // Escape key also closes the menu. - let escape_handle = leptos::leptos_dom::helpers::window_event_listener_untyped( - "keydown", - move |ev| { + let escape_handle = + leptos::leptos_dom::helpers::window_event_listener_untyped("keydown", move |ev| { let Some(kev) = ev.dyn_ref::() else { return; }; if kev.key() == "Escape" && menu_state.get_untracked().is_some() { menu_state.set(None); } - }, - ); + }); on_cleanup(move || drop(escape_handle)); let rel_path_for_actions = rel_path.clone(); @@ -442,7 +435,11 @@ fn handle_menu_action( language, &plain_lines, range, - if cross { Some(&preview_workspace_label) } else { None }, + if cross { + Some(&preview_workspace_label) + } else { + None + }, ); let payload = if snippet.ends_with('\n') { snippet @@ -458,15 +455,13 @@ fn handle_menu_action( let b64 = base64::engine::general_purpose::STANDARD.encode(payload.as_bytes()); match pty_write(session_id, b64).await { Ok(()) => { - let msg = i18n_for_msg - .tr(I18nKey::CodeViewToastSnippetInsertedTerminal)() + let msg = i18n_for_msg.tr(I18nKey::CodeViewToastSnippetInsertedTerminal)() .replace("{terminal}", &target_label) .replace("{workspace}", &ws_label); toast.success(msg); } Err(e) => { - let msg = i18n_for_msg - .tr(I18nKey::CodeViewToastInsertFailed)() + let msg = i18n_for_msg.tr(I18nKey::CodeViewToastInsertFailed)() .replace("{error}", &e); toast.error(msg); } @@ -484,7 +479,11 @@ fn handle_menu_action( language, &plain_lines, range, - if cross { Some(&preview_workspace_label) } else { None }, + if cross { + Some(&preview_workspace_label) + } else { + None + }, ); let target_workspace_root = wb.workspaces().with_untracked(|all| { all.iter() @@ -505,7 +504,11 @@ fn handle_menu_action( range, language, &snippet, - if cross { Some(&preview_workspace_label) } else { None }, + if cross { + Some(&preview_workspace_label) + } else { + None + }, ); let toast = toast; let i18n_for_msg = i18n; @@ -516,15 +519,13 @@ fn handle_menu_action( let b64 = base64::engine::general_purpose::STANDARD.encode(envelope.as_bytes()); match pty_write(session_id, b64).await { Ok(()) => { - let msg = i18n_for_msg - .tr(I18nKey::CodeViewToastEnvelopeInsertedTerminal)() + let msg = i18n_for_msg.tr(I18nKey::CodeViewToastEnvelopeInsertedTerminal)() .replace("{terminal}", &target_label) .replace("{workspace}", &ws_label); toast.success(msg); } Err(e) => { - let msg = i18n_for_msg - .tr(I18nKey::CodeViewToastInsertFailed)() + let msg = i18n_for_msg.tr(I18nKey::CodeViewToastInsertFailed)() .replace("{error}", &e); toast.error(msg); } @@ -541,7 +542,11 @@ fn handle_menu_action( language, &plain_lines, range, - if cross { Some(&preview_workspace_label) } else { None }, + if cross { + Some(&preview_workspace_label) + } else { + None + }, ); let item_label = if range.0 == range.1 { format!("Snippet · {}:{}", rel_path, range.0) @@ -555,24 +560,21 @@ fn handle_menu_action( language, &item_label, &snippet, - if cross { Some(&preview_workspace_label) } else { None }, + if cross { + Some(&preview_workspace_label) + } else { + None + }, ); wb.upsert_workspace_agent_context(workspace_id, item); - let msg = i18n - .tr(I18nKey::CodeViewToastAgentAttached)() + let msg = i18n.tr(I18nKey::CodeViewToastAgentAttached)() .replace("{workspace}", &workspace_label); toast.success(msg); } CodeMenuAction::CopySnippet => { let cross_label_unused = (); // always preview workspace for clipboard let _ = cross_label_unused; - let snippet = build_file_snippet_block( - rel_path, - language, - &plain_lines, - range, - None, - ); + let snippet = build_file_snippet_block(rel_path, language, &plain_lines, range, None); copy_to_clipboard(snippet, i18n, toast, I18nKey::CodeViewToastCopiedSnippet); } CodeMenuAction::CopyRange => { @@ -612,8 +614,7 @@ fn copy_to_clipboard(text: String, i18n: I18nService, toast: ToastService, succe .and_then(|v| v.as_string()) }) .unwrap_or_else(|| "unknown".to_string()); - let msg = i18n_for_msg - .tr(I18nKey::CodeViewToastClipboardFailed)() + let msg = i18n_for_msg.tr(I18nKey::CodeViewToastClipboardFailed)() .replace("{error}", &err); toast.error(msg); } diff --git a/src/workbench/file_preview/hljs_glue.rs b/src/workbench/file_preview/hljs_glue.rs index 4068830..6377762 100644 --- a/src/workbench/file_preview/hljs_glue.rs +++ b/src/workbench/file_preview/hljs_glue.rs @@ -62,8 +62,8 @@ pub async fn ensure_hljs_loaded() -> Result<(), String> { pub async fn highlight(code: &str, language: &str) -> Result { ensure_hljs_loaded().await?; let hljs = hljs_global().ok_or("hljs not available")?; - let highlight_fn = Reflect::get(&hljs, &JsValue::from_str("highlight")) - .map_err(|_| "no hljs.highlight")?; + let highlight_fn = + Reflect::get(&hljs, &JsValue::from_str("highlight")).map_err(|_| "no hljs.highlight")?; let highlight_fn: Function = highlight_fn .dyn_into() .map_err(|_| "hljs.highlight not callable")?; @@ -75,12 +75,8 @@ pub async fn highlight(code: &str, language: &str) -> Result { &JsValue::from_str(language), ) .map_err(|_| "set language")?; - Reflect::set( - &opts, - &JsValue::from_str("ignoreIllegals"), - &JsValue::TRUE, - ) - .map_err(|_| "set ignoreIllegals")?; + Reflect::set(&opts, &JsValue::from_str("ignoreIllegals"), &JsValue::TRUE) + .map_err(|_| "set ignoreIllegals")?; let result = highlight_fn .call2(&hljs, &JsValue::from_str(code), &opts) diff --git a/src/workbench/file_preview/markdown_view.rs b/src/workbench/file_preview/markdown_view.rs index c361547..119acbb 100644 --- a/src/workbench/file_preview/markdown_view.rs +++ b/src/workbench/file_preview/markdown_view.rs @@ -43,10 +43,8 @@ fn render_markdown(src: &str) -> String { Event::End(TagEnd::CodeBlock) if in_mermaid => { let escaped = html_escape(&mermaid_buf); buffered.push(Event::Html( - format!( - r#"
      {escaped}
      "# - ) - .into(), + format!(r#"
      {escaped}
      "#) + .into(), )); in_mermaid = false; mermaid_buf.clear(); @@ -137,6 +135,36 @@ fn policy_hero(kind: PolicyKind) -> PolicyHero { subtitle_key: I18nKey::FilePreviewPolicyReadmeSubtitle, modifier: "readme", }, + PolicyKind::Support => PolicyHero { + icon: icondata::LuLifeBuoy, + title_key: I18nKey::FilePreviewPolicySupportTitle, + subtitle_key: I18nKey::FilePreviewPolicySupportSubtitle, + modifier: "support", + }, + PolicyKind::Agents => PolicyHero { + icon: icondata::LuBot, + title_key: I18nKey::FilePreviewPolicyAgentsTitle, + subtitle_key: I18nKey::FilePreviewPolicyAgentsSubtitle, + modifier: "agents", + }, + PolicyKind::Claude => PolicyHero { + icon: icondata::LuSparkles, + title_key: I18nKey::FilePreviewPolicyClaudeTitle, + subtitle_key: I18nKey::FilePreviewPolicyClaudeSubtitle, + modifier: "claude", + }, + PolicyKind::Codex => PolicyHero { + icon: icondata::LuCode, + title_key: I18nKey::FilePreviewPolicyCodexTitle, + subtitle_key: I18nKey::FilePreviewPolicyCodexSubtitle, + modifier: "codex", + }, + PolicyKind::Gemini => PolicyHero { + icon: icondata::LuSparkles, + title_key: I18nKey::FilePreviewPolicyGeminiTitle, + subtitle_key: I18nKey::FilePreviewPolicyGeminiSubtitle, + modifier: "gemini", + }, } } diff --git a/src/workbench/file_preview/mermaid_glue.rs b/src/workbench/file_preview/mermaid_glue.rs index 6dc1d6d..b3fd4df 100644 --- a/src/workbench/file_preview/mermaid_glue.rs +++ b/src/workbench/file_preview/mermaid_glue.rs @@ -13,13 +13,15 @@ const MERMAID_SRC: &str = "/public/vendor/mermaid/mermaid.min.js"; fn mermaid_global() -> Option { let w = web_sys::window()?; - Reflect::get(&w, &JsValue::from_str("mermaid")).ok().and_then(|v| { - if v.is_undefined() || v.is_null() { - None - } else { - Some(v) - } - }) + Reflect::get(&w, &JsValue::from_str("mermaid")) + .ok() + .and_then(|v| { + if v.is_undefined() || v.is_null() { + None + } else { + Some(v) + } + }) } /// Returns `Ok(())` once `globalThis.mermaid` exists. Inserts a `