Compare commits

...

10 Commits

Author SHA1 Message Date
dd371c404f feat: add Gitea Actions CI workflow
Some checks failed
CI / lint-and-typecheck (push) Successful in 40s
CI / build-frontend (push) Failing after 32s
Includes lint/typechecking (svelte-check) and frontend build stages,
triggered on push/PR to main/master/develop branches.
2026-05-23 12:06:00 +03:00
27b88917ed docs: simplify README.md for clarity and consistency 2026-05-23 10:12:53 +03:00
6488acc7b9 feat(optimize): support state diagrams in the Optimize panel
Extend the Diagram Doctor beyond flowcharts to stateDiagram / stateDiagram-v2.

- New parseStateDiagram: states → nodes, transitions → edges in source order
  (including `[*]` pseudo-transitions, so linkStyle indices stay aligned);
  `[*]` itself is never a node. Composite states map to groups, notes are
  skipped. A parseDiagram() dispatcher feeds analyze/transforms.
- Fixes are tailored per kind (verified empirically against Mermaid 11):
  * ELK is flowchart-only (state diagrams reject `layout: elk`), so it's not
    offered and the no-ELK score penalty is skipped for state diagrams.
  * Spacing uses the `state` config key; direction is set via a `direction`
    statement rather than the header.
  * State diagrams can't style individual transitions — a `linkStyle` line
    renders as a stray node — so the dim-hubs fix and focus mode fade the
    busy *states* (e.g. CANCELLED) via classDef/class instead of dimming edges.
    Flowcharts keep edge dimming via linkStyle.
- Panel notice now covers flowcharts & state diagrams.

Verified in-browser: state-diagram dim/focus render with zero junk nodes;
flowchart ELK + linkStyle dimming still intact.
2026-05-22 22:25:51 +03:00
e1b5f31f87 feat(ui): toggle the diagram-list sidebar
Add a ☰ toolbar button (and ⌘/Ctrl+B shortcut) to show/hide the left sidebar
for a wider editing/preview area. The preview re-fits when the sidebar — or the
Optimize panel — toggles, so the diagram stays centred.
2026-05-22 21:50:06 +03:00
c35960761b feat(git): sync with a remote — fetch, pull and push
Add remote sync to the Git panel: set/edit an origin URL, then fetch, pull
(fast-forward) and push, with live ahead/behind counts against the
remote-tracking branch.

Backend:
- git_ops: remote_status (git2 ahead/behind + upstream), set_remote, and
  fetch/pull/push that shell out to the system git CLI so they reuse the
  user's existing credentials (SSH agent, keychain / credential helpers)
  instead of reimplementing libgit2 credential callbacks. Pull is --ff-only
  to avoid leaving a conflicted tree from the GUI; push uses --set-upstream.
- New commands git_remote_status/set_remote/fetch/pull/push, registered.
- Test: push to a bare local remote, then assert upstream + ahead/behind.

Frontend:
- RemoteStatus type, api wrappers, store state (remote, syncing) with
  refreshRemote wired into refreshGit; pull reloads diagrams + open buffer.
- GitPanel remote section: URL, ahead/behind badges, Fetch/Pull/Push, and a
  set-remote dialog.

cargo test (7 passing incl. roundtrip), svelte-check and build all green.
2026-05-22 21:32:48 +03:00
6f23c620c1 feat(toolbar): copy diagram to clipboard as PNG
Add a "⧉ Copy" button that places the current diagram on the system clipboard
as a PNG via the async Clipboard API (ClipboardItem). The write is invoked
synchronously with the PNG handed over as a promise, so it stays inside the
click gesture WebKit requires.

PNG export and clipboard copy now re-render through renderRaster(), which
forces htmlLabels off via an injected init directive. Flowcharts otherwise emit
<foreignObject> HTML labels that taint the canvas and make toBlob()/clipboard
writes fail — this also fixes pre-existing flowchart PNG export. The live
preview and SVG export keep the richer foreignObject labels.

Verified end-to-end: renderRaster yields a foreignObject-free SVG, it
rasterizes without tainting, and clipboard.write resolves on a real click.
2026-05-22 21:12:54 +03:00
3918d5b8d2 fix(preview): keep diagram centred in split view
After pinning the SVG to its intrinsic size, the stage's layout box is often
wider than a narrow (split) canvas. CSS grid centring doesn't centre an
oversized item — it anchored the stage's left edge to the canvas, pushing the
transform-origin (and the scaled-down diagram) far to the right.

Centre the stage with absolute positioning at left/top 50% plus a
translate(-50%,-50%) prefix on the transform, so centring is independent of the
unscaled size. The grid still centres the empty/error messages. Verified the
diagram and the placeholder sit at offset 0 in a 691px split pane.
2026-05-22 20:41:09 +03:00
9f4929bffa fix(preview): pin SVG to intrinsic size for all diagram types
The previous fix only disabled useMaxWidth for flowcharts, so sequence (and
other) diagrams still rendered responsively and fit shrank them to a tiny
speck. Normalise every rendered SVG to its viewBox pixel size (stripping
width:100% / max-width) in the render path, so fit/zoom is exact regardless of
diagram type. Drops the now-redundant per-type flowchart flag.
2026-05-22 20:34:30 +03:00
49b6c5191e fix(preview): smooth trackpad zoom, crisp panning, correct fit on large diagrams
- Pan no longer ghosts/stutters: drop the always-on transform transition
  (it interpolated against every mousemove) in favour of will-change layer
  promotion; a short transition is pulsed only for button zoom / fit.
- Trackpad zoom is now continuous and cursor-anchored: zoom factor is
  exponential in wheel delta (clamped) instead of a fixed 10% per event, so
  macOS pinch feels smooth; mouse-wheel deltas are tamed by the same curve.
  Two-finger scroll now pans.
- Fit no longer ends up tiny on big diagrams: render flowcharts with
  useMaxWidth:false so the SVG's laid-out size equals its viewBox, making the
  fit/zoom maths exact (previously the SVG was shrunk responsively *and* again
  by fit). Lower the zoom floor so very large diagrams can fit fully.
2026-05-22 20:30:29 +03:00
bbc12ee4be feat(optimize): Diagram Doctor panel to declutter tangled flowcharts
Add an Optimize side panel that analyses the active flowchart and applies
one-click, reversible source rewrites to make dense graphs readable.

- optimize.ts: pure, label-aware flowchart parser + metrics, a 0-100
  readability score, and transforms — ELK layered layout, node/rank
  spacing + curved edges, de-emphasising cross-cutting hub edges
  (event bus / audit / cost-style fan-ins), duplicate-edge removal,
  direction toggle, and a non-destructive focus variant.
- mermaid.ts: register @mermaid-js/layout-elk so `layout: elk` renders.
- OptimizePanel.svelte + Toolbar toggle; panels are mutually exclusive.
- Preview: render the focus variant when a node is spotlighted, with a
  clear-focus pill.
- store/Editor: applySource() bumps a revision so programmatic rewrites
  re-sync CodeMirror and stay undoable with the editor's history.
2026-05-22 20:11:49 +03:00
20 changed files with 2279 additions and 62 deletions

71
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,71 @@
name: CI
run-name: CI (${{ gitea.ref_name }})
on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master]
jobs:
lint-and-typecheck:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Cache node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
- name: Svelte check (type checking)
run: npm run check
- name: Lint (if configured)
run: npx svelte-check --tsconfig ./tsconfig.json
continue-on-error: true
build-frontend:
runs-on: ubuntu-latest
needs: [lint-and-typecheck]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Cache node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
- name: Build frontend (Vite)
run: npx vite build
- name: Upload dist artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: dist/
retention-days: 7

View File

@@ -1,29 +1,45 @@
# Mermix # Mermix
A cross-platform **Mermaid diagram editor & viewer** with **Git-backed projects**. A cross-platform Mermaid diagram editor and viewer with Git-backed projects.
Organize many diagrams into projects, edit them with a live preview, export to Organize many diagrams into projects, edit them with a live preview, export to
SVG/PNG, and version everything with real Git branches and commits — all in a SVG or PNG, and version everything with real Git branches and commits in a
native desktop app. native desktop app.
Built with **Rust + Tauri 2**, **SQLite (sqlx)**, **git2**, and a **Svelte 5 + Built with Rust + Tauri 2, SQLite (sqlx), git2, and a Svelte 5 + TypeScript
TypeScript** frontend using **CodeMirror 6** and **Mermaid 11**. frontend using CodeMirror 6 and Mermaid 11.
--- ---
## Features ## Features
- **Projects** — each project is a folder on disk that is a real Git repository - Projects: Each project is a folder on disk that is a real Git repository
with a `mermix.toml` config and a `diagrams/` directory of `.mmd` files. with a `mermix.toml` config and a `diagrams/` directory of `.mmd` files.
- **Many diagrams per project** with a sidebar explorer; create, rename, delete. - Many diagrams per project: Create, rename, and delete diagrams via the
- **Live editor + preview** — CodeMirror source on the left, debounced Mermaid sidebar explorer.
render on the right, with zoom/pan and inline syntax-error reporting. - Live editor and preview: CodeMirror source on the left, debounced Mermaid
- **Git version control built in** — view the working-tree status, write commit render on the right, with zoom, pan, and inline syntax-error reporting.
messages, browse history, create branches and switch between them. Each branch - Git version control: View working-tree status, write commit messages,
keeps its own set of diagrams, just like normal Git. browse history, create and switch branches. Each branch keeps its own
- **Export** the current diagram to **SVG** or **PNG** (2× scale). set of diagrams, just like normal Git.
- **Project registry** — recently opened projects are remembered in a local - Remote sync: Configure an origin remote, then fetch, pull (fast-forward),
and push from the Git panel with live ahead/behind counts. Network
operations use your system git, reusing existing credentials (SSH agent,
keychain, credential helpers) — no separate login.
- Optimize panel (Diagram Doctor): Analyze a tangled flowchart or state
diagram and declutter it in one click. Provides a readability score and
metrics (hubs, density, cross-group edges), plus source rewrites for:
ELK layered layout (flowcharts), extra spacing, layout-direction toggle,
de-emphasizing cross-cutting hubs (e.g. event-bus fan-ins, faded sink
states), and removing duplicate edges. A non-destructive focus mode
spotlights any node and its neighbors so you can read a dense graph
without changing it. Every rewrite lands in the editor and is reversible
with Command + Z.
- Export the current diagram to SVG or PNG (2x scale), or copy a PNG
straight to the clipboard. PNG export re-renders flowcharts with text
labels so the bitmap rasterizes cleanly across platforms.
- Project registry: Recently opened projects are remembered in a local
SQLite database so you can jump back in from the start screen. SQLite database so you can jump back in from the start screen.
- **Themes** — switch the Mermaid theme (default / neutral / dark / forest / - Themes: Switch the Mermaid theme (default, neutral, dark, forest, or
base) per project. base) per project.
## Architecture ## Architecture
@@ -35,9 +51,10 @@ Mermix/
│ ├─ lib/ │ ├─ lib/
│ │ ├─ api.ts # typed wrappers over Tauri commands │ │ ├─ api.ts # typed wrappers over Tauri commands
│ │ ├─ store.svelte.ts # central rune-based app state │ │ ├─ store.svelte.ts # central rune-based app state
│ │ ├─ mermaid.ts # render + error capture │ │ ├─ mermaid.ts # render + error capture (registers ELK layout)
│ │ ├─ optimize.ts # flowchart analysis + declutter transforms (pure)
│ │ ├─ export.ts # SVG / PNG export via save dialog │ │ ├─ export.ts # SVG / PNG export via save dialog
│ │ └─ components/ # Sidebar, Editor, Preview, GitPanel, Toolbar, … │ │ └─ components/ # Sidebar, Editor, Preview, GitPanel, OptimizePanel, …
│ └─ main.ts │ └─ main.ts
└─ src-tauri/ # Rust backend └─ src-tauri/ # Rust backend
├─ src/ ├─ src/
@@ -52,10 +69,10 @@ Mermix/
└─ tauri.conf.json └─ tauri.conf.json
``` ```
**Data model.** Mermix keeps one small app-level SQLite database (in the OS app Data model: A single app-level SQLite database (in the OS app data directory)
data directory) that only tracks the *registry* of known projects and user tracks the project registry and user settings. All content lives on disk
settings. All real content lives on disk inside each project's Git repository — inside each project's Git repository — projects are portable, inspectable,
so projects are portable, inspectable, and work with any other Git tooling. and work with any other Git tooling.
A project on disk looks like: A project on disk looks like:
@@ -72,17 +89,17 @@ my-diagrams/
## Prerequisites ## Prerequisites
- **Rust** (stable) and **Cargo** - Rust (stable) and Cargo
- **Node.js** 18+ and npm - Node.js 18+ and npm
- Platform webview deps: - Platform webview dependencies:
- **macOS** — nothing extra (system WebKit) - macOS: nothing extra (system WebKit)
- **Windows** — WebView2 (preinstalled on Windows 11) - Windows: WebView2 (preinstalled on Windows 11)
- **Linux** — `webkit2gtk` and related packages (see Tauri docs) - Linux: webkit2gtk and related packages (see Tauri docs)
## Getting started ## Getting started
```bash ```bash
npm install # install frontend deps + Tauri CLI npm install # install frontend dependencies and Tauri CLI
npm run app:dev # run the desktop app in dev mode (hot reload) npm run app:dev # run the desktop app in dev mode (hot reload)
npm run app:build # build a production bundle for your platform npm run app:build # build a production bundle for your platform
@@ -92,7 +109,7 @@ Other useful scripts:
```bash ```bash
npm run dev # frontend only (Vite) on http://localhost:1420 npm run dev # frontend only (Vite) on http://localhost:1420
npm run build # type-check + build the frontend bundle npm run build # type-check and build the frontend bundle
npm run check # svelte-check type checking npm run check # svelte-check type checking
``` ```
@@ -105,22 +122,25 @@ cd src-tauri && cargo test
## Keyboard shortcuts ## Keyboard shortcuts
| Shortcut | Action | | Shortcut | Action |
| ------------------- | --------------------- | | ---------------------- | ---------------------- |
| `⌘/Ctrl + S` | Save current diagram | | Command / Ctrl + S | Save current diagram |
| `⌘/Ctrl + Enter` | Commit (in commit box)| | Command / Ctrl + Enter | Commit (in commit box) |
| `⌘/Ctrl + scroll` | Zoom the preview | | Command / Ctrl + scroll| Zoom the preview |
## How Git is used ## How Git is used
- Creating a project runs `git init` (default branch `main`) and makes an - Creating a project runs `git init` (default branch `main`) and makes an
initial commit. initial commit.
- **Commit** stages every change (adds, edits, deletes) and records it with the - Commit stages every change (adds, edits, deletes) and records it with
message you type; the author defaults to your global Git identity, falling back your message. The author defaults to your global Git identity, falling
to `Mermix <mermix@localhost>` if none is configured. back to `Mermix <mermix@localhost>` if none is configured.
- **Branches** are listed in the Git panel; create one and Mermix checks it out - Branches are listed in the Git panel; creating one checks it out
for you. Switching branches reloads the diagram list from that branch. automatically. Switching branches reloads the diagram list.
- **History** shows the most recent commits with short SHA, message, author and - History shows recent commits with short SHA, message, author, and
relative time. relative time.
- Remote sync configures an origin remote. Fetch updates remote-tracking
refs; pull is fast-forward only; push uses `--set-upstream`. All
require the system `git` binary on PATH.
## License ## License

20
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"@codemirror/state": "^6.4.1", "@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.28.0", "@codemirror/view": "^6.28.0",
"@lezer/highlight": "^1.2.0", "@lezer/highlight": "^1.2.0",
"@mermaid-js/layout-elk": "^0.2.1",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
@@ -625,6 +626,19 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@mermaid-js/layout-elk": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@mermaid-js/layout-elk/-/layout-elk-0.2.1.tgz",
"integrity": "sha512-MX9jwhMyd5zDcFsYcl3duDUkKhjVRUCGEQrdCeNV5hCIR6+3FuDDbRbFmvVbAu15K1+juzsYGG+K8MDvCY1Amg==",
"license": "MIT",
"dependencies": {
"d3": "^7.9.0",
"elkjs": "^0.9.3"
},
"peerDependencies": {
"mermaid": "^11.0.2"
}
},
"node_modules/@mermaid-js/parser": { "node_modules/@mermaid-js/parser": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz",
@@ -2216,6 +2230,12 @@
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
} }
}, },
"node_modules/elkjs": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz",
"integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==",
"license": "EPL-2.0"
},
"node_modules/es-toolkit": { "node_modules/es-toolkit": {
"version": "1.46.1", "version": "1.46.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",

View File

@@ -20,6 +20,7 @@
"@codemirror/state": "^6.4.1", "@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.28.0", "@codemirror/view": "^6.28.0",
"@lezer/highlight": "^1.2.0", "@lezer/highlight": "^1.2.0",
"@mermaid-js/layout-elk": "^0.2.1",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",

View File

@@ -6,7 +6,7 @@ use tauri::State;
use crate::db::{self, ProjectRecord}; use crate::db::{self, ProjectRecord};
use crate::diagram::{self, DiagramInfo}; use crate::diagram::{self, DiagramInfo};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::git_ops::{self, BranchInfo, CommitInfo, FileStatus}; use crate::git_ops::{self, BranchInfo, CommitInfo, FileStatus, RemoteStatus};
use crate::project::{self, ProjectConfig}; use crate::project::{self, ProjectConfig};
use crate::state::AppState; use crate::state::AppState;
@@ -176,6 +176,37 @@ pub fn git_checkout_branch(path: String, name: String) -> Result<()> {
git_ops::checkout_branch(Path::new(&path), &name) git_ops::checkout_branch(Path::new(&path), &name)
} }
// ---- Remote sync ----------------------------------------------------------
#[tauri::command]
pub fn git_remote_status(path: String) -> Result<RemoteStatus> {
git_ops::remote_status(Path::new(&path))
}
#[tauri::command]
pub fn git_set_remote(path: String, url: String) -> Result<()> {
let url = url.trim();
if url.is_empty() {
return Err(AppError::other("remote URL cannot be empty"));
}
git_ops::set_remote(Path::new(&path), url)
}
#[tauri::command]
pub fn git_fetch(path: String) -> Result<String> {
git_ops::fetch(Path::new(&path))
}
#[tauri::command]
pub fn git_pull(path: String) -> Result<String> {
git_ops::pull(Path::new(&path))
}
#[tauri::command]
pub fn git_push(path: String) -> Result<String> {
git_ops::push(Path::new(&path))
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Export helpers & settings // Export helpers & settings
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -1,4 +1,5 @@
use std::path::Path; use std::path::Path;
use std::process::Command;
use git2::{ use git2::{
BranchType, Commit, IndexAddOption, Repository, RepositoryInitOptions, Signature, StatusOptions, BranchType, Commit, IndexAddOption, Repository, RepositoryInitOptions, Signature, StatusOptions,
@@ -30,6 +31,21 @@ pub struct FileStatus {
pub status: String, pub status: String,
} }
/// Snapshot of the relationship between the current branch and its remote.
///
/// `ahead`/`behind` are measured against the remote-tracking ref, so they
/// reflect the last fetch — they update after `fetch`/`pull`/`push`.
#[derive(Debug, Serialize)]
pub struct RemoteStatus {
pub has_remote: bool,
pub remote: Option<String>,
pub url: Option<String>,
/// Upstream ref shorthand, e.g. `origin/main`, if the branch tracks one.
pub upstream: Option<String>,
pub ahead: usize,
pub behind: usize,
}
/// Initialize a fresh repository with `main` as the default branch. /// Initialize a fresh repository with `main` as the default branch.
pub fn init_repo(dir: &Path) -> Result<Repository> { pub fn init_repo(dir: &Path) -> Result<Repository> {
let mut opts = RepositoryInitOptions::new(); let mut opts = RepositoryInitOptions::new();
@@ -188,3 +204,138 @@ pub fn status(dir: &Path) -> Result<Vec<FileStatus>> {
} }
Ok(out) Ok(out)
} }
// ---------------------------------------------------------------------------
// Remotes & sync
//
// Network operations (fetch/pull/push) shell out to the system `git` CLI so
// they transparently reuse the user's existing credentials — SSH agent keys,
// macOS keychain / credential helpers, and host-key trust — rather than
// reimplementing all of that through libgit2's credential callbacks. Read-only
// introspection (remote URL, ahead/behind) stays on git2 since it needs no
// network and no auth.
// ---------------------------------------------------------------------------
/// Pick the remote to sync with: `origin` if present, otherwise the first one.
fn pick_remote(repo: &Repository) -> Result<Option<String>> {
let remotes = repo.remotes()?;
if remotes.iter().flatten().any(|r| r == "origin") {
return Ok(Some("origin".to_string()));
}
Ok(remotes.get(0).map(|s| s.to_string()))
}
/// Describe how the current branch relates to its remote-tracking branch.
pub fn remote_status(dir: &Path) -> Result<RemoteStatus> {
let repo = Repository::open(dir)?;
let Some(name) = pick_remote(&repo)? else {
return Ok(RemoteStatus {
has_remote: false,
remote: None,
url: None,
upstream: None,
ahead: 0,
behind: 0,
});
};
let url = repo
.find_remote(&name)
.ok()
.and_then(|r| r.url().map(|s| s.to_string()));
let mut upstream = None;
let (mut ahead, mut behind) = (0usize, 0usize);
if let Ok(head) = repo.head() {
if let (Some(local_oid), Some(shorthand)) = (head.target(), head.shorthand()) {
if let Ok(branch) = repo.find_branch(shorthand, BranchType::Local) {
if let Ok(up) = branch.upstream() {
upstream = up.name().ok().flatten().map(|s| s.to_string());
if let Some(up_oid) = up.get().target() {
if let Ok((a, b)) = repo.graph_ahead_behind(local_oid, up_oid) {
ahead = a;
behind = b;
}
}
}
}
}
}
Ok(RemoteStatus {
has_remote: true,
remote: Some(name),
url,
upstream,
ahead,
behind,
})
}
/// Add (or update) the `origin` remote URL.
pub fn set_remote(dir: &Path, url: &str) -> Result<()> {
let repo = Repository::open(dir)?;
if repo.find_remote("origin").is_ok() {
repo.remote_set_url("origin", url)?;
} else {
repo.remote("origin", url)?;
}
Ok(())
}
/// Resolve the remote to operate on, erroring clearly if none is configured.
fn remote_or_err(dir: &Path) -> Result<String> {
let repo = Repository::open(dir)?;
pick_remote(&repo)?.ok_or_else(|| AppError::other("No remote configured. Add one first."))
}
/// Run a `git` subcommand in `dir`, returning trimmed output or a clear error.
fn run_git(dir: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.current_dir(dir)
.args(args)
.output()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
AppError::other("Git is not installed or not on PATH; it's required to sync.")
} else {
AppError::Io(e)
}
})?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.success() {
// git writes most progress to stderr, so combine both streams.
Ok(format!("{stdout}{stderr}").trim().to_string())
} else {
let msg = if !stderr.trim().is_empty() {
stderr.trim()
} else {
stdout.trim()
};
Err(AppError::other(msg.to_string()))
}
}
/// Fetch refs from the remote (updates remote-tracking branches; no merge).
pub fn fetch(dir: &Path) -> Result<String> {
let remote = remote_or_err(dir)?;
run_git(dir, &["fetch", &remote])
}
/// Pull the current branch, fast-forward only — never leaves a half-merged or
/// conflicted working tree from the GUI. Divergent history errors out so the
/// user can reconcile deliberately.
pub fn pull(dir: &Path) -> Result<String> {
let remote = remote_or_err(dir)?;
let branch = current_branch(dir)?;
run_git(dir, &["pull", "--ff-only", &remote, &branch])
}
/// Push the current branch, setting it as the upstream.
pub fn push(dir: &Path) -> Result<String> {
let remote = remote_or_err(dir)?;
let branch = current_branch(dir)?;
run_git(dir, &["push", "--set-upstream", &remote, &branch])
}

View File

@@ -63,6 +63,11 @@ pub fn run() {
commands::git_current_branch, commands::git_current_branch,
commands::git_create_branch, commands::git_create_branch,
commands::git_checkout_branch, commands::git_checkout_branch,
commands::git_remote_status,
commands::git_set_remote,
commands::git_fetch,
commands::git_pull,
commands::git_push,
commands::write_text_file, commands::write_text_file,
commands::write_binary_file, commands::write_binary_file,
commands::get_setting, commands::get_setting,

View File

@@ -152,3 +152,51 @@ fn git_commit_and_branching() {
assert_eq!(git_ops::current_branch(&dir).unwrap(), "main"); assert_eq!(git_ops::current_branch(&dir).unwrap(), "main");
assert_eq!(git_ops::history(&dir, 10).unwrap().len(), 2); assert_eq!(git_ops::history(&dir, 10).unwrap().len(), 2);
} }
fn git_available() -> bool {
std::process::Command::new("git")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[test]
fn remote_push_and_status_roundtrip() {
// Network ops shell out to git; skip cleanly where the CLI is unavailable.
if !git_available() {
return;
}
let tmp = TempDir::new();
let dir = tmp.project();
project::create_project(&dir, "Proj", "").unwrap();
// No remote yet.
let st = git_ops::remote_status(&dir).unwrap();
assert!(!st.has_remote);
// Point origin at a fresh bare repo acting as the remote.
let remote_path = tmp.0.join("remote.git");
git2::Repository::init_bare(&remote_path).unwrap();
git_ops::set_remote(&dir, remote_path.to_str().unwrap()).unwrap();
let st = git_ops::remote_status(&dir).unwrap();
assert!(st.has_remote);
assert_eq!(st.remote.as_deref(), Some("origin"));
assert_eq!(st.upstream, None, "no upstream before the first push");
// Push main; afterwards the branch tracks origin and is in sync.
git_ops::push(&dir).unwrap();
let st = git_ops::remote_status(&dir).unwrap();
assert!(st.upstream.is_some(), "push --set-upstream sets tracking");
assert_eq!((st.ahead, st.behind), (0, 0));
// A local commit puts us one ahead; a fetch is a clean no-op.
diagram::create_diagram(&dir, "Second").unwrap();
git_ops::commit_all(&dir, "second").unwrap();
let st = git_ops::remote_status(&dir).unwrap();
assert_eq!(st.ahead, 1);
assert_eq!(st.behind, 0);
git_ops::fetch(&dir).unwrap();
}

View File

@@ -7,6 +7,7 @@
import Editor from './lib/components/Editor.svelte'; import Editor from './lib/components/Editor.svelte';
import Preview from './lib/components/Preview.svelte'; import Preview from './lib/components/Preview.svelte';
import GitPanel from './lib/components/GitPanel.svelte'; import GitPanel from './lib/components/GitPanel.svelte';
import OptimizePanel from './lib/components/OptimizePanel.svelte';
import Toasts from './lib/components/Toasts.svelte'; import Toasts from './lib/components/Toasts.svelte';
// Editor/preview split ratio (fraction taken by the editor). // Editor/preview split ratio (fraction taken by the editor).
@@ -33,6 +34,11 @@
e.preventDefault(); e.preventDefault();
void store.save(); void store.save();
} }
// ⌘/Ctrl+B: toggle the diagram-list sidebar.
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'b') {
e.preventDefault();
store.showSidebar = !store.showSidebar;
}
// ⌘/Ctrl+E: jump into viewer mode and toggle the quick-edit drawer. // ⌘/Ctrl+E: jump into viewer mode and toggle the quick-edit drawer.
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'e') { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'e') {
e.preventDefault(); e.preventDefault();
@@ -53,7 +59,9 @@
<div class="app"> <div class="app">
<Toolbar /> <Toolbar />
<div class="body"> <div class="body">
{#if store.showSidebar}
<Sidebar /> <Sidebar />
{/if}
{#if store.activeId} {#if store.activeId}
<div class="split" bind:this={splitHost} class:splitting> <div class="split" bind:this={splitHost} class:splitting>
{#if store.layout !== 'preview'} {#if store.layout !== 'preview'}
@@ -112,6 +120,9 @@
<div class="faint">Pick one from the sidebar, or create a new diagram with .</div> <div class="faint">Pick one from the sidebar, or create a new diagram with .</div>
</div> </div>
{/if} {/if}
{#if store.showOptimize}
<OptimizePanel />
{/if}
{#if store.showGit} {#if store.showGit}
<GitPanel /> <GitPanel />
{/if} {/if}

View File

@@ -6,6 +6,7 @@ import type {
FileStatus, FileStatus,
OpenedProject, OpenedProject,
ProjectRecord, ProjectRecord,
RemoteStatus,
} from './types'; } from './types';
// Thin, typed wrappers over the Rust command surface. Argument keys use // Thin, typed wrappers over the Rust command surface. Argument keys use
@@ -60,6 +61,18 @@ export const api = {
gitCheckoutBranch: (path: string, name: string) => gitCheckoutBranch: (path: string, name: string) =>
invoke<void>('git_checkout_branch', { path, name }), invoke<void>('git_checkout_branch', { path, name }),
// ---- Remote sync ----------------------------------------------------
gitRemoteStatus: (path: string) => invoke<RemoteStatus>('git_remote_status', { path }),
gitSetRemote: (path: string, url: string) =>
invoke<void>('git_set_remote', { path, url }),
gitFetch: (path: string) => invoke<string>('git_fetch', { path }),
gitPull: (path: string) => invoke<string>('git_pull', { path }),
gitPush: (path: string) => invoke<string>('git_push', { path }),
// ---- Export & settings --------------------------------------------- // ---- Export & settings ---------------------------------------------
writeTextFile: (path: string, contents: string) => writeTextFile: (path: string, contents: string) =>
invoke<void>('write_text_file', { path, contents }), invoke<void>('write_text_file', { path, contents }),

View File

@@ -128,10 +128,12 @@
return () => view?.destroy(); return () => view?.destroy();
}); });
// When a different diagram is loaded, replace the whole document. Depend only // Replace the whole document when a different diagram is loaded (activeId) or
// on activeId; read content untracked so typing doesn't reset the editor. // when content is swapped programmatically (revision, e.g. an applied
// optimization). Read content untracked so plain typing never resets it.
$effect(() => { $effect(() => {
void store.activeId; // re-run only when the selected diagram changes void store.activeId; // re-run when the selected diagram changes
void store.revision; // …or when an optimization rewrites the source.
if (!view) return; if (!view) return;
untrack(() => { untrack(() => {
const incoming = store.content; const incoming = store.content;

View File

@@ -4,9 +4,25 @@
let message = $state(''); let message = $state('');
let newBranch = $state<string | null>(null); let newBranch = $state<string | null>(null);
let remoteUrl = $state<string | null>(null);
const hasChanges = $derived(store.changes.length > 0 || store.dirty); const hasChanges = $derived(store.changes.length > 0 || store.dirty);
async function saveRemote() {
const url = (remoteUrl ?? '').trim();
if (!url) return;
await store.setRemote(url);
remoteUrl = null;
}
function shortUrl(url: string): string {
// Strip protocol/host noise to a recognisable "owner/repo".
return url
.replace(/^git@[^:]+:/, '')
.replace(/^[a-z]+:\/\/[^/]+\//, '')
.replace(/\.git$/, '');
}
async function commit() { async function commit() {
const msg = message.trim(); const msg = message.trim();
if (!msg) return; if (!msg) return;
@@ -56,6 +72,44 @@
<button class="ghost icon" title="New branch" onclick={() => (newBranch = '')}></button> <button class="ghost icon" title="New branch" onclick={() => (newBranch = '')}></button>
</div> </div>
<div class="remote-row">
{#if store.remote?.has_remote}
<div class="remote-info">
<span class="remote-icon" title="Remote"></span>
<span class="remote-name" title={store.remote.url ?? ''}>
{store.remote.url ? shortUrl(store.remote.url) : store.remote.remote}
</span>
{#if store.remote.ahead}
<span class="badge-count ahead" title="{store.remote.ahead} commit(s) to push">
{store.remote.ahead}
</span>
{/if}
{#if store.remote.behind}
<span class="badge-count behind" title="{store.remote.behind} commit(s) to pull">
{store.remote.behind}
</span>
{/if}
<button
class="ghost icon mini"
title="Edit remote URL"
onclick={() => (remoteUrl = store.remote?.url ?? '')}>✎</button
>
</div>
<div class="sync-btns">
<button onclick={() => store.fetch()} disabled={store.syncing}>Fetch</button>
<button onclick={() => store.pull()} disabled={store.syncing}>
Pull{store.remote.behind ? ` ↓${store.remote.behind}` : ''}
</button>
<button class="primary" onclick={() => store.push()} disabled={store.syncing}>
Push{store.remote.ahead ? ` ${store.remote.ahead}` : ''}
</button>
</div>
{#if store.syncing}<div class="syncing faint">Syncing…</div>{/if}
{:else if store.remote}
<button class="add-remote" onclick={() => (remoteUrl = '')}>☁ Add remote…</button>
{/if}
</div>
<div class="commit-box"> <div class="commit-box">
<div class="changes"> <div class="changes">
{#if hasChanges} {#if hasChanges}
@@ -121,6 +175,29 @@
</Modal> </Modal>
{/if} {/if}
{#if remoteUrl !== null}
<Modal title="Remote repository" onClose={() => (remoteUrl = null)}>
<label class="fld">
<span>Remote URL (origin)</span>
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
bind:value={remoteUrl}
placeholder="git@github.com:user/repo.git"
onkeydown={(e) => e.key === 'Enter' && saveRemote()}
/>
</label>
<p class="hint faint">
Uses your existing Git credentials (SSH keys / keychain). Push and pull
authenticate just like the <code>git</code> command line.
</p>
<div class="actions">
<button onclick={() => (remoteUrl = null)}>Cancel</button>
<button class="primary" onclick={saveRemote} disabled={!remoteUrl.trim()}>Save</button>
</div>
</Modal>
{/if}
<style> <style>
.git { .git {
width: 280px; width: 280px;
@@ -141,6 +218,79 @@
.branch-icon { .branch-icon {
color: var(--accent); color: var(--accent);
} }
.remote-row {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 8px;
}
.remote-info {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.remote-icon {
color: var(--accent);
}
.remote-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--mono);
font-size: 11.5px;
color: var(--text-dim);
}
.badge-count {
font-size: 11px;
font-weight: 600;
font-variant-numeric: tabular-nums;
padding: 0 5px;
border-radius: 999px;
}
.badge-count.ahead {
color: var(--green);
background: rgba(63, 185, 80, 0.14);
}
.badge-count.behind {
color: var(--amber);
background: rgba(210, 153, 34, 0.14);
}
.sync-btns {
display: flex;
gap: 4px;
}
.sync-btns button {
flex: 1;
padding: 6px 4px;
font-size: 12px;
}
.syncing {
font-size: 11px;
text-align: center;
}
.add-remote {
width: 100%;
background: transparent;
border-style: dashed;
color: var(--text-dim);
font-size: 12px;
}
.add-remote:hover {
color: var(--text);
border-color: var(--accent);
}
.hint {
margin: 0;
font-size: 11.5px;
line-height: 1.45;
}
.hint code {
font-family: var(--mono);
color: var(--accent);
}
.commit-box { .commit-box {
padding: 12px; padding: 12px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);

View File

@@ -0,0 +1,431 @@
<script lang="ts">
import { store } from '../store.svelte';
import {
analyze,
autoOptimize,
setDirection,
type Fix,
type FlowNode,
} from '../optimize';
// Re-analyse whenever the source changes; pure & cheap.
const a = $derived(analyze(store.content));
const DIRECTIONS = ['TB', 'LR', 'BT', 'RL'];
// Nodes for the focus picker, hubs first.
const focusable = $derived.by<FlowNode[]>(() => {
if (!a.graph) return [];
return [...a.graph.nodes.values()].sort(
(x, y) => y.inDeg + y.outDeg - (x.inDeg + x.outDeg)
);
});
function applyFix(f: Fix) {
if (!f.apply) return;
store.applySource(f.apply(store.content));
store.notify(`Applied — ${f.title}`, 'success');
}
function runAuto() {
store.applySource(autoOptimize(store.content));
store.notify('Applied all safe optimizations', 'success');
}
function setDir(d: string) {
if (a.graph?.direction === d) return;
store.applySource(setDirection(store.content, d));
}
function focus(id: string) {
store.focusNode = store.focusNode === id ? null : id;
if (store.focusNode && store.layout === 'code') store.layout = 'split';
}
</script>
<aside class="optimize">
<header class="head">
<span class="title">✨ Optimize</span>
<button
class="ghost icon"
title="Close"
onclick={() => (store.showOptimize = false)}>✕</button
>
</header>
{#if !store.activeId || !store.content.trim()}
<div class="notice faint">Open a diagram to analyse it.</div>
{:else if !a.supported}
<div class="notice">
<div class="muted">Decluttering is built for flowcharts & state diagrams.</div>
<p class="faint">
This looks like a <code>{a.kind || 'different'}</code> diagram, which
can't be analysed here yet.
</p>
</div>
{:else if a.metrics && a.graph}
<!-- Readability score -->
<div class="score-row">
<div class="gauge" data-rating={a.rating.toLowerCase()}>
<span class="num">{a.score}</span>
</div>
<div class="score-meta">
<div class="rating" data-rating={a.rating.toLowerCase()}>{a.rating}</div>
<div class="faint">readability score</div>
{#if a.graph.usesElk}<div class="elk-badge">ELK layout on</div>{/if}
</div>
</div>
<button
class="primary auto"
onclick={runAuto}
disabled={a.fixes.filter((f) => f.apply).length === 0}
>
Auto-optimize ✦
</button>
<!-- Metrics -->
<div class="metrics">
<div class="metric"><b>{a.metrics.nodes}</b><span>nodes</span></div>
<div class="metric"><b>{a.metrics.edges}</b><span>edges</span></div>
<div class="metric"><b>{a.metrics.density.toFixed(1)}</b><span>edges/node</span></div>
<div class="metric"><b>{a.metrics.maxDegree}</b><span>busiest hub</span></div>
<div class="metric"><b>{a.metrics.interGroupEdges}</b><span>cross-group</span></div>
<div class="metric"><b>{a.metrics.subgraphs}</b><span>groups</span></div>
</div>
<!-- Layout direction -->
<div class="dir">
<span class="lbl faint">Direction</span>
<div class="seg">
{#each DIRECTIONS as d}
<button class:on={a.graph.direction === d} onclick={() => setDir(d)}>{d}</button>
{/each}
</div>
</div>
<!-- Suggested fixes -->
<div class="section-head">Suggestions</div>
<div class="fixes">
{#each a.fixes as f (f.id)}
<div class="fix" data-sev={f.severity}>
<div class="fix-head">
<span class="dot"></span>
<span class="fix-title">{f.title}</span>
{#if f.apply}
<button class="apply" onclick={() => applyFix(f)}>Apply</button>
{:else}
<span class="tag">tip</span>
{/if}
</div>
<p class="fix-detail">{f.detail}</p>
</div>
{/each}
{#if a.fixes.length === 0}
<div class="clean faint">✓ Nothing to declutter — this reads cleanly.</div>
{/if}
</div>
<!-- Focus reader -->
<div class="section-head">
Focus a node
{#if store.focusNode}
<button class="ghost mini clear" onclick={() => (store.focusNode = null)}>
clear
</button>
{/if}
</div>
<p class="hint faint">Spotlight one node and its neighbours in the preview.</p>
<div class="focus-list">
{#each focusable.slice(0, 12) as n (n.id)}
<button
class="chip"
class:on={store.focusNode === n.id}
title={n.label}
onclick={() => focus(n.id)}
>
{n.label}<span class="deg">{n.inDeg + n.outDeg}</span>
</button>
{/each}
</div>
{/if}
</aside>
<style>
.optimize {
width: 312px;
flex-shrink: 0;
background: var(--bg-elev);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 10px 10px 14px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--bg-elev);
z-index: 2;
}
.title {
font-weight: 600;
font-size: 13px;
}
.notice {
padding: 18px 14px;
font-size: 13px;
line-height: 1.5;
}
.notice code {
font-family: var(--mono);
color: var(--accent);
}
/* Score */
.score-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px;
}
.gauge {
width: 60px;
height: 60px;
border-radius: 50%;
display: grid;
place-items: center;
flex-shrink: 0;
border: 3px solid var(--border-strong);
background: var(--bg-input);
}
.gauge .num {
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.gauge[data-rating='clear'] {
border-color: var(--green);
}
.gauge[data-rating='readable'] {
border-color: var(--accent);
}
.gauge[data-rating='busy'] {
border-color: var(--amber);
}
.gauge[data-rating='tangled'] {
border-color: var(--red);
}
.rating {
font-weight: 600;
font-size: 15px;
}
.rating[data-rating='clear'] {
color: var(--green);
}
.rating[data-rating='readable'] {
color: var(--accent);
}
.rating[data-rating='busy'] {
color: var(--amber);
}
.rating[data-rating='tangled'] {
color: var(--red);
}
.elk-badge {
margin-top: 4px;
display: inline-block;
font-size: 10px;
color: var(--green);
border: 1px solid var(--green);
border-radius: 3px;
padding: 1px 5px;
}
.auto {
margin: 0 14px 6px;
width: calc(100% - 28px);
}
/* Metrics */
.metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
padding: 10px 14px 6px;
}
.metric {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 6px;
text-align: center;
}
.metric b {
display: block;
font-size: 17px;
font-variant-numeric: tabular-nums;
}
.metric span {
font-size: 10.5px;
color: var(--text-faint);
}
/* Direction */
.dir {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
}
.lbl {
font-size: 12px;
}
.seg {
display: inline-flex;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.seg button {
border: none;
border-radius: 0;
background: transparent;
padding: 5px 9px;
font-size: 11.5px;
color: var(--text-dim);
font-family: var(--mono);
}
.seg button:hover {
background: var(--bg-elev-2);
}
.seg button.on {
background: var(--accent-soft);
color: var(--text);
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px 4px;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-faint);
}
.hint {
margin: 0;
padding: 0 14px 8px;
font-size: 11.5px;
line-height: 1.4;
}
/* Fixes */
.fixes {
display: flex;
flex-direction: column;
gap: 7px;
padding: 2px 14px 6px;
}
.fix {
background: var(--bg-input);
border: 1px solid var(--border);
border-left-width: 3px;
border-radius: var(--radius-sm);
padding: 9px 10px;
}
.fix[data-sev='high'] {
border-left-color: var(--red);
}
.fix[data-sev='medium'] {
border-left-color: var(--amber);
}
.fix[data-sev='low'] {
border-left-color: var(--text-faint);
}
.fix-head {
display: flex;
align-items: center;
gap: 7px;
}
.fix-title {
flex: 1;
font-size: 12.5px;
font-weight: 600;
line-height: 1.3;
}
.apply {
padding: 3px 10px;
font-size: 11.5px;
flex-shrink: 0;
}
.tag {
font-size: 10px;
color: var(--text-faint);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1px 5px;
flex-shrink: 0;
}
.fix-detail {
margin: 6px 0 0;
font-size: 11.5px;
line-height: 1.45;
color: var(--text-dim);
}
.clean {
padding: 10px 0;
font-size: 12.5px;
}
/* Focus chips */
.focus-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
padding: 0 14px 16px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 100%;
padding: 4px 6px 4px 9px;
font-size: 11.5px;
border-radius: 999px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip .deg {
font-size: 10px;
color: var(--text-faint);
background: var(--bg);
border-radius: 999px;
padding: 0 5px;
min-width: 16px;
text-align: center;
}
.chip.on {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.chip.on .deg {
color: #fff;
background: rgba(255, 255, 255, 0.2);
}
.mini {
padding: 1px 6px;
font-size: 10px;
text-transform: none;
letter-spacing: 0;
}
.clear {
color: var(--accent);
}
</style>

View File

@@ -2,6 +2,7 @@
import { tick } from 'svelte'; import { tick } from 'svelte';
import { store } from '../store.svelte'; import { store } from '../store.svelte';
import { renderMermaid } from '../mermaid'; import { renderMermaid } from '../mermaid';
import { applyFocus } from '../optimize';
let svg = $state(''); let svg = $state('');
let error = $state(''); let error = $state('');
@@ -12,19 +13,25 @@
let timer: ReturnType<typeof setTimeout> | undefined; let timer: ReturnType<typeof setTimeout> | undefined;
let fittedFor = ''; let fittedFor = '';
// Debounced re-render whenever the source or theme changes. // Debounced re-render whenever the source, theme or focus target changes.
// Focus mode renders a transient spotlighted variant without touching the
// saved source.
$effect(() => { $effect(() => {
const code = store.content; const code = store.content;
const theme = store.theme; const theme = store.theme;
const focus = store.focusNode;
clearTimeout(timer); clearTimeout(timer);
timer = setTimeout(() => void run(code, theme), 220); const toRender = focus ? applyFocus(code, focus) : code;
timer = setTimeout(() => void run(toRender, theme), focus ? 60 : 220);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}); });
// Re-fit when the available space changes (layout / panel toggles). // Re-fit when the available space changes (layout / panel / sidebar toggles).
$effect(() => { $effect(() => {
void store.layout; void store.layout;
void store.showGit; void store.showGit;
void store.showOptimize;
void store.showSidebar;
tick().then(fit); tick().then(fit);
}); });
@@ -56,8 +63,41 @@
return { w: 0, h: 0 }; return { w: 0, h: 0 };
} }
const MIN_ZOOM = 0.04;
const MAX_ZOOM = 8;
const clampZoom = (z: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
// A short transition is nice for button zooms but causes ghosting/stutter
// during drag-pan and pinch — so it's only enabled in bursts via pulse().
let animate = $state(false);
let animTimer: ReturnType<typeof setTimeout> | undefined;
function pulse() {
animate = true;
clearTimeout(animTimer);
animTimer = setTimeout(() => (animate = false), 180);
}
function zoomBy(factor: number) { function zoomBy(factor: number) {
zoom = Math.min(6, Math.max(0.1, zoom * factor)); pulse();
zoom = clampZoom(zoom * factor);
}
/** Zoom toward a screen point so the diagram under the cursor stays put. */
function zoomTo(next: number, clientX: number, clientY: number) {
const z2 = clampZoom(next);
if (!canvasEl || z2 === zoom) {
zoom = z2;
return;
}
const rect = canvasEl.getBoundingClientRect();
// Cursor offset from the canvas centre, where the (centred) stage sits.
const mx = clientX - rect.left - rect.width / 2;
const my = clientY - rect.top - rect.height / 2;
pan = {
x: mx - (mx - pan.x) * (z2 / zoom),
y: my - (my - pan.y) * (z2 / zoom),
};
zoom = z2;
} }
/** Scale the diagram so it fits within the visible canvas. */ /** Scale the diagram so it fits within the visible canvas. */
@@ -65,15 +105,17 @@
if (!canvasEl || !svg) return; if (!canvasEl || !svg) return;
const { w, h } = svgDims(svg); const { w, h } = svgDims(svg);
if (!w || !h) return; if (!w || !h) return;
pulse();
const margin = store.theme !== 'dark' ? 72 : 32; const margin = store.theme !== 'dark' ? 72 : 32;
const cw = canvasEl.clientWidth - margin; const cw = canvasEl.clientWidth - margin;
const ch = canvasEl.clientHeight - margin; const ch = canvasEl.clientHeight - margin;
if (cw <= 0 || ch <= 0) return; if (cw <= 0 || ch <= 0) return;
zoom = Math.max(0.1, Math.min(cw / w, ch / h, 3)); zoom = Math.max(MIN_ZOOM, Math.min(cw / w, ch / h, 3));
pan = { x: 0, y: 0 }; pan = { x: 0, y: 0 };
} }
function reset() { function reset() {
pulse();
zoom = 1; zoom = 1;
pan = { x: 0, y: 0 }; pan = { x: 0, y: 0 };
} }
@@ -94,14 +136,28 @@
dragging = false; dragging = false;
} }
function onWheel(e: WheelEvent) { function onWheel(e: WheelEvent) {
// macOS pinch-to-zoom arrives as ctrl+wheel; ⌘/Ctrl+wheel zooms too.
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
e.preventDefault(); e.preventDefault();
zoomBy(e.deltaY < 0 ? 1.1 : 0.9); // Continuous, exponential zoom — proportional to the gesture so the
// trackpad feels smooth instead of jumping in fixed 10% steps. The
// exp curve also tames a mouse wheel's large deltas.
const factor = Math.min(2, Math.max(0.5, Math.exp(-e.deltaY * 0.0015)));
zoomTo(zoom * factor, e.clientX, e.clientY);
} else {
// Two-finger scroll pans the canvas.
e.preventDefault();
pan = { x: pan.x - e.deltaX, y: pan.y - e.deltaY };
} }
} }
</script> </script>
<div class="preview"> <div class="preview">
{#if store.focusNode}
<button class="focus-pill" title="Clear focus" onclick={() => (store.focusNode = null)}>
◎ Focus: {store.focusNode} <span class="x"></span>
</button>
{/if}
<div class="ctrls"> <div class="ctrls">
{#if rendering}<span class="dot" title="Rendering…"></span>{/if} {#if rendering}<span class="dot" title="Rendering…"></span>{/if}
<button class="ghost icon" title="Zoom out" onclick={() => zoomBy(0.83)}></button> <button class="ghost icon" title="Zoom out" onclick={() => zoomBy(0.83)}></button>
@@ -133,7 +189,8 @@
<div <div
class="stage" class="stage"
class:paper={store.theme !== 'dark'} class:paper={store.theme !== 'dark'}
style="transform: translate({pan.x}px, {pan.y}px) scale({zoom});" class:animate
style="transform: translate(-50%, -50%) translate({pan.x}px, {pan.y}px) scale({zoom});"
> >
{@html svg} {@html svg}
</div> </div>
@@ -153,6 +210,27 @@
var(--bg); var(--bg);
overflow: hidden; overflow: hidden;
} }
.focus-pill {
position: absolute;
top: 10px;
left: 12px;
z-index: 6;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 12px;
border-radius: 999px;
background: var(--accent-soft);
border: 1px solid var(--accent);
color: var(--text);
font-size: 12px;
}
.focus-pill .x {
color: var(--text-faint);
}
.focus-pill:hover .x {
color: var(--red);
}
.ctrls { .ctrls {
position: absolute; position: absolute;
top: 10px; top: 10px;
@@ -187,6 +265,9 @@
} }
.canvas { .canvas {
height: 100%; height: 100%;
/* `position: relative` makes this the containing block for the absolutely
centred .stage; the grid still centres the empty/error messages. */
position: relative;
display: grid; display: grid;
place-items: center; place-items: center;
overflow: hidden; overflow: hidden;
@@ -198,8 +279,21 @@
cursor: grabbing; cursor: grabbing;
} }
.stage { .stage {
/* Centre via the canvas centre regardless of the (unscaled) stage size —
grid centring left an oversized stage anchored off to the right. The
transform prepends translate(-50%, -50%) to recentre it. */
position: absolute;
left: 50%;
top: 50%;
transform-origin: center center; transform-origin: center center;
transition: transform 0.05s linear; /* Promote to its own compositor layer so panning doesn't ghost/stutter.
No transition by default → drag-pan and pinch track the input 1:1. */
will-change: transform;
backface-visibility: hidden;
}
/* Re-enabled in short bursts for button zoom / fit, never during gestures. */
.stage.animate {
transition: transform 0.18s ease-out;
} }
/* Light Mermaid themes are designed for a white page, so give them a /* Light Mermaid themes are designed for a white page, so give them a
"paper" sheet; the dark theme renders directly on the dark canvas. */ "paper" sheet; the dark theme renders directly on the dark canvas. */

View File

@@ -1,8 +1,18 @@
<script lang="ts"> <script lang="ts">
import { store } from '../store.svelte'; import { store } from '../store.svelte';
import { exportSvg, exportPng } from '../export'; import { exportSvg, exportPng, copyPng } from '../export';
import { renderRaster } from '../mermaid';
import type { MermaidTheme } from '../types'; import type { MermaidTheme } from '../types';
// PNG and clipboard need a raster-safe SVG (text labels, not foreignObject),
// so re-render the current source rather than reuse the rich preview SVG.
async function rasterSvg(): Promise<string> {
const r = await renderRaster(store.content, store.theme);
if ('error' in r) throw new Error(r.error);
if (!r.svg) throw new Error('Nothing to render yet');
return r.svg;
}
const themes: MermaidTheme[] = ['default', 'neutral', 'dark', 'forest', 'base']; const themes: MermaidTheme[] = ['default', 'neutral', 'dark', 'forest', 'base'];
async function doExport(kind: 'svg' | 'png') { async function doExport(kind: 'svg' | 'png') {
@@ -14,17 +24,38 @@
try { try {
const ok = const ok =
kind === 'svg' kind === 'svg'
? await exportSvg(store.lastSvg, name) ? await exportSvg(store.lastSvg, name) // vector: keep rich labels
: await exportPng(store.lastSvg, name); : await exportPng(await rasterSvg(), name);
if (ok) store.notify(`Exported ${kind.toUpperCase()}`, 'success'); if (ok) store.notify(`Exported ${kind.toUpperCase()}`, 'success');
} catch (e) { } catch (e) {
store.notify(e instanceof Error ? e.message : String(e), 'error'); store.notify(e instanceof Error ? e.message : String(e), 'error');
} }
} }
let copying = $state(false);
function doCopy() {
if (!store.lastSvg) {
store.notify('Nothing to copy yet', 'error');
return;
}
copying = true;
// Call copyPng synchronously (passing the render as a promise) so the
// clipboard write stays inside the click gesture WebKit requires.
copyPng(rasterSvg())
.then(() => store.notify('Copied PNG to clipboard', 'success'))
.catch((e) => store.notify(e instanceof Error ? e.message : String(e), 'error'))
.finally(() => (copying = false));
}
</script> </script>
<header class="toolbar"> <header class="toolbar">
<div class="left"> <div class="left">
<button
class="ghost icon"
class:on={store.showSidebar}
title="Toggle diagram list (⌘B)"
onclick={() => (store.showSidebar = !store.showSidebar)}>☰</button
>
<span class="logo">Mermix</span> <span class="logo">Mermix</span>
<span class="divider"></span> <span class="divider"></span>
{#if store.activeId} {#if store.activeId}
@@ -63,13 +94,33 @@
<div class="export"> <div class="export">
<button onclick={() => doExport('svg')} disabled={!store.lastSvg}>Export SVG</button> <button onclick={() => doExport('svg')} disabled={!store.lastSvg}>Export SVG</button>
<button onclick={() => doExport('png')} disabled={!store.lastSvg}>PNG</button> <button onclick={() => doExport('png')} disabled={!store.lastSvg}>PNG</button>
<button
onclick={doCopy}
disabled={!store.lastSvg || copying}
title="Copy PNG to clipboard"
>
{copying ? 'Copying…' : '⧉ Copy'}
</button>
</div> </div>
<button
class="ghost icon"
class:on={store.showOptimize}
title="Optimize diagram"
onclick={() => {
store.showOptimize = !store.showOptimize;
if (store.showOptimize) store.showGit = false;
}}>✨</button
>
<button <button
class="ghost icon" class="ghost icon"
class:on={store.showGit} class:on={store.showGit}
title="Toggle Git panel" title="Toggle Git panel"
onclick={() => (store.showGit = !store.showGit)}>⎇</button onclick={() => {
store.showGit = !store.showGit;
if (store.showGit) store.showOptimize = false;
}}>⎇</button
> >
</div> </div>
</header> </header>

View File

@@ -29,7 +29,7 @@ function loadImage(url: string): Promise<HTMLImageElement> {
}); });
} }
async function svgToPng(svg: string, scale: number): Promise<Uint8Array> { async function svgToPngBlob(svg: string, scale: number): Promise<Blob> {
const { w, h } = svgDimensions(svg); const { w, h } = svgDimensions(svg);
const sized = withExplicitSize(svg, w, h); const sized = withExplicitSize(svg, w, h);
const blob = new Blob([sized], { type: 'image/svg+xml;charset=utf-8' }); const blob = new Blob([sized], { type: 'image/svg+xml;charset=utf-8' });
@@ -44,15 +44,19 @@ async function svgToPng(svg: string, scale: number): Promise<Uint8Array> {
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const pngBlob: Blob = await new Promise((res, rej) => return await new Promise<Blob>((res, rej) =>
canvas.toBlob((b) => (b ? res(b) : rej(new Error('PNG encode failed'))), 'image/png') canvas.toBlob((b) => (b ? res(b) : rej(new Error('PNG encode failed'))), 'image/png')
); );
return new Uint8Array(await pngBlob.arrayBuffer());
} finally { } finally {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
} }
async function svgToPng(svg: string, scale: number): Promise<Uint8Array> {
const blob = await svgToPngBlob(svg, scale);
return new Uint8Array(await blob.arrayBuffer());
}
export async function exportSvg(svg: string, name: string): Promise<boolean> { export async function exportSvg(svg: string, name: string): Promise<boolean> {
const path = await save({ const path = await save({
defaultPath: `${name}.svg`, defaultPath: `${name}.svg`,
@@ -73,3 +77,20 @@ export async function exportPng(svg: string, name: string, scale = 2): Promise<b
await api.writeBinaryFile(path, Array.from(bytes)); await api.writeBinaryFile(path, Array.from(bytes));
return true; return true;
} }
/**
* Rasterize the diagram and place it on the system clipboard as a PNG image.
*
* Accepts the SVG as a value or a promise: the PNG `Blob` is handed to
* `ClipboardItem` as a promise and `clipboard.write` is invoked synchronously,
* so WebKit still treats the write as part of the originating click gesture
* (required) while awaiting the async render/rasterization.
*/
export async function copyPng(svg: string | Promise<string>, scale = 2): Promise<void> {
if (typeof ClipboardItem === 'undefined' || !navigator.clipboard?.write) {
throw new Error('Copying images to the clipboard is not supported here');
}
const blob = Promise.resolve(svg).then((s) => svgToPngBlob(s, scale));
const item = new ClipboardItem({ 'image/png': blob });
await navigator.clipboard.write([item]);
}

View File

@@ -1,4 +1,10 @@
import mermaid from 'mermaid'; import mermaid from 'mermaid';
import elkLayouts from '@mermaid-js/layout-elk';
import { mergeInit } from './optimize';
// Register the ELK layout engine so diagrams (and the Optimize panel's "Switch
// to ELK layout" fix) can request `layout: elk` for far cleaner dense graphs.
mermaid.registerLayoutLoaders(elkLayouts);
let currentTheme = ''; let currentTheme = '';
@@ -10,7 +16,37 @@ function configure(theme: string) {
theme: theme as any, theme: theme as any,
securityLevel: 'loose', securityLevel: 'loose',
fontFamily: 'inherit', fontFamily: 'inherit',
flowchart: { useMaxWidth: true, htmlLabels: true }, flowchart: { htmlLabels: true },
});
}
/**
* Force an SVG to its intrinsic pixel size (from the viewBox), stripping the
* responsive `width:100%` / `max-width` that Mermaid emits by default.
*
* Mermaid's `useMaxWidth` makes diagrams shrink to their container, but the
* preview applies its own zoom/fit transform — so a responsive SVG ends up
* scaled down twice and renders tiny. Pinning the laid-out size to the viewBox
* keeps fit/zoom exact for *every* diagram type (sequence, class, …), which
* per-type `useMaxWidth` flags can't guarantee uniformly.
*/
function pinSize(svg: string): string {
const vb = svg.match(/viewBox="([\d.\s-]+)"/);
if (!vb) return svg;
const p = vb[1].trim().split(/\s+/).map(Number);
if (p.length !== 4 || p.some(Number.isNaN)) return svg;
const [, , w, h] = p;
return svg.replace(/<svg\b[^>]*>/, (tag) => {
let t = tag.replace(/\s(?:width|height)="[^"]*"/g, '');
t = t.replace(/\sstyle="([^"]*)"/, (_m, s: string) => {
const cleaned = s
.split(';')
.map((d) => d.trim())
.filter((d) => d && !/^(?:max-width|width)\s*:/i.test(d))
.join('; ');
return cleaned ? ` style="${cleaned}"` : '';
});
return t.replace(/<svg\b/, `<svg width="${w}" height="${h}"`);
}); });
} }
@@ -25,7 +61,7 @@ export async function renderMermaid(code: string, theme: string): Promise<Render
const id = `mmx-render-${++renderSeq}`; const id = `mmx-render-${++renderSeq}`;
try { try {
const { svg } = await mermaid.render(id, code); const { svg } = await mermaid.render(id, code);
return { svg }; return { svg: pinSize(svg) };
} catch (e) { } catch (e) {
return { error: e instanceof Error ? e.message : String(e) }; return { error: e instanceof Error ? e.message : String(e) };
} finally { } finally {
@@ -33,3 +69,17 @@ export async function renderMermaid(code: string, theme: string): Promise<Render
document.getElementById(`d${id}`)?.remove(); document.getElementById(`d${id}`)?.remove();
} }
} }
/**
* Render an SVG suitable for raster export (PNG / clipboard).
*
* Flowcharts render labels as `<foreignObject>` HTML by default, which taints
* the `<canvas>` they're drawn onto and makes `toBlob()` / clipboard image
* writes fail. Forcing `htmlLabels: false` (per-render, via an injected init
* directive — global preview config stays rich) emits plain `<text>` labels
* that rasterize cleanly across engines. Other diagram types are unaffected.
*/
export function renderRaster(code: string, theme: string): Promise<RenderResult> {
if (!code.trim()) return Promise.resolve({ svg: '' });
return renderMermaid(mergeInit(code, { htmlLabels: false }), theme);
}

948
src/lib/optimize.ts Normal file
View File

@@ -0,0 +1,948 @@
/**
* Diagram Doctor — analysis and decluttering for Mermaid flowcharts.
*
* Everything here is pure (no DOM, no Svelte, no Mermaid runtime) so it can be
* reasoned about and tested in isolation. The module does three things:
*
* 1. `parseFlowchart` — turn flowchart/graph source into a small graph model.
* 2. `analyze` — derive metrics, a readability score, and a list of
* concrete fixes (each a `source -> source` transform).
* 3. transforms — the rewrites the panel applies: ELK layout, spacing,
* dimming busy hub edges, removing duplicate edges, and
* a non-destructive "focus" variant for reading.
*
* Parsing is line-based and label-aware (see `maskLabels`) rather than a full
* grammar; it is deliberately forgiving and degrades gracefully on syntax it
* does not recognise. The layout/spacing transforms never depend on edge
* parsing, so they stay correct even when an exotic edge confuses the parser.
*/
// ---------------------------------------------------------------------------
// Model
// ---------------------------------------------------------------------------
export interface FlowNode {
id: string;
label: string;
subgraph: string | null; // id of the enclosing subgraph, if any
inDeg: number;
outDeg: number;
/** Number of bidirectional (`<-->`) edges touching this node. */
bidirDeg: number;
}
export interface FlowEdge {
from: string;
to: string;
bidir: boolean;
/** 0-based line in the source where the edge was declared. */
line: number;
}
export interface SubgraphInfo {
id: string;
title: string;
}
export type DiagramKind = 'flowchart' | 'graph' | 'stateDiagram';
export interface FlowGraph {
kind: DiagramKind;
direction: string;
/** 0-based index of the header line (`flowchart TB` / `stateDiagram-v2`). */
headerLine: number;
nodes: Map<string, FlowNode>;
/** Edges in Mermaid link order (matches `linkStyle` indexing). */
edges: FlowEdge[];
subgraphs: SubgraphInfo[];
usesElk: boolean;
hasSpacing: boolean;
}
export interface Metrics {
nodes: number;
edges: number;
subgraphs: number;
/** edges / nodes — how "wired" the graph is. */
density: number;
maxDegree: number;
/** Nodes ranked by total degree (hubs first). */
hubs: FlowNode[];
/** High in-degree sinks fed from several places (Audit/Cost-style). */
sinkHubs: FlowNode[];
/**
* Cross-cutting infrastructure worth fading: pure sinks (Audit) and shared
* buses (EventBus) — not forward-flow orchestrators like OMS.
*/
dimHubs: FlowNode[];
/** Edges whose endpoints live in different groups — a crossing proxy. */
interGroupEdges: number;
duplicateEdges: number;
}
export type FixSeverity = 'high' | 'medium' | 'low';
export interface Fix {
id: string;
title: string;
detail: string;
severity: FixSeverity;
/** When present, applying the fix returns rewritten source. */
apply?: (src: string) => string;
}
export interface Analysis {
supported: boolean;
/** Set when `supported` is false (non-flowchart diagram). */
kind?: string;
graph?: FlowGraph;
metrics?: Metrics;
score: number;
rating: 'Clear' | 'Readable' | 'Busy' | 'Tangled';
fixes: Fix[];
}
const HUB_DEGREE = 6; // total degree at/above which a node is a "hub"
const SINK_INDEG = 4; // in-degree at/above which a sink is "high traffic"
// ---------------------------------------------------------------------------
// Lexical helpers
// ---------------------------------------------------------------------------
/**
* Return a same-length copy of `line` with arrow/separator characters that sit
* *inside* a node label (`[...]`, `(...)`, `{...}`, `"..."`) neutralised to
* `_`. This lets us split a line on real link operators and `&` separators
* without being fooled by punctuation in labels like `[Public & Partner API]`
* or `[ETA / Promise Engine]`.
*/
export function maskLabels(line: string): string {
const out = line.split('');
let depth = 0;
let inQuote = false;
const NEUTRAL = /[-=.<>|&~]/;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (inQuote) {
if (c === '"') inQuote = false;
else if (NEUTRAL.test(c)) out[i] = '_';
continue;
}
if (c === '"') {
inQuote = true;
continue;
}
if (c === '[' || c === '(' || c === '{') {
depth++;
continue;
}
if (c === ']' || c === ')' || c === '}') {
if (depth > 0) depth--;
continue;
}
if (depth > 0 && NEUTRAL.test(c)) out[i] = '_';
}
return out.join('');
}
// Matches a flowchart link operator: -->, ---, -.->, ==>, --o, x--x, <-->, ~~~,
// with arbitrary length. Used on a *masked* line so label text never matches.
const LINK_RE = /<{0,2}[ox]?[-=.~]{2,}[->ox]{0,2}/g;
// Lines that start with one of these keywords are statements, not node defs.
const STATEMENT_RE =
/^(?:subgraph|end|direction|style|classDef|class|click|linkStyle|%%)\b/;
function isBidir(arrow: string): boolean {
if (arrow.includes('<')) return true;
const head = arrow[0];
const tail = arrow[arrow.length - 1];
return (head === 'o' || head === 'x') && (tail === 'o' || tail === 'x');
}
/** Parse a single node reference (`OMS[OMS / Orchestrator]`) into id + label. */
function parseNodeRef(token: string): { id: string; label: string } | null {
const m = token.trim().match(/^([A-Za-z0-9_][\w.-]*)\s*(.*)$/);
if (!m) return null;
const id = m[1];
let label = '';
const rest = m[2].trim();
if (rest) {
// Strip the outermost shape delimiters and quotes to recover the text.
label = rest
.replace(/^[[({<>/\\]+/, '')
.replace(/[\])}>/\\]+$/, '')
.replace(/^"|"$/g, '')
.trim();
}
return { id, label };
}
/** Split a segment like `A[x] & B(y)` into its `&`-separated node references. */
function splitNodeList(segment: string): string[] {
const masked = maskLabels(segment);
const parts: string[] = [];
let start = 0;
for (let i = 0; i < masked.length; i++) {
if (masked[i] === '&') {
parts.push(segment.slice(start, i));
start = i + 1;
}
}
parts.push(segment.slice(start));
return parts.map((p) => p.trim()).filter(Boolean);
}
// ---------------------------------------------------------------------------
// Parsing
// ---------------------------------------------------------------------------
/** Length (in lines) of a leading YAML frontmatter block, or 0 if none. */
function frontmatterLines(src: string): number {
const m = src.match(/^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/);
if (!m) return 0;
return m[0].replace(/\r\n/g, '\n').replace(/\n$/, '').split('\n').length;
}
/**
* Parse flowchart/graph source into a {@link FlowGraph}. Returns `null` for any
* other diagram type (sequence, class, …) or empty input.
*/
export function parseFlowchart(src: string): FlowGraph | null {
const lines = src.replace(/\r\n/g, '\n').split('\n');
const fmEnd = frontmatterLines(src);
// Locate the header line.
let headerLine = -1;
let kind: 'flowchart' | 'graph' | null = null;
let direction = 'TB';
for (let i = fmEnd; i < lines.length; i++) {
const t = lines[i].trim();
if (!t || t.startsWith('%%')) continue;
const h = t.match(/^(flowchart|graph)\b\s*([A-Za-z]{2})?/);
if (h) {
headerLine = i;
kind = h[1] as 'flowchart' | 'graph';
if (h[2]) direction = h[2].toUpperCase();
break;
}
// First meaningful line isn't a flowchart header → not our diagram.
return null;
}
if (kind === null) return null;
const usesElk =
/(?:layout|defaultRenderer)["']?\s*[:=]\s*["']?elk/i.test(src);
const hasSpacing = /\b(?:nodeSpacing|rankSpacing)\b/.test(src);
const nodes = new Map<string, FlowNode>();
const edges: FlowEdge[] = [];
const subgraphs: SubgraphInfo[] = [];
const stack: string[] = []; // current subgraph nesting
const touchNode = (id: string, label: string) => {
let n = nodes.get(id);
if (!n) {
n = {
id,
label: label || id,
subgraph: stack.length ? stack[stack.length - 1] : null,
inDeg: 0,
outDeg: 0,
bidirDeg: 0,
};
nodes.set(id, n);
} else if (label && (n.label === n.id || !n.label)) {
n.label = label;
}
return n;
};
for (let i = headerLine + 1; i < lines.length; i++) {
const raw = lines[i];
const t = raw.trim();
if (!t || t.startsWith('%%')) continue;
const sg = t.match(/^subgraph\s+(.+)$/);
if (sg) {
const ref = parseNodeRef(sg[1]);
const id = ref ? ref.id : `sg${subgraphs.length}`;
const title = ref && ref.label ? ref.label : id;
subgraphs.push({ id, title });
stack.push(id);
continue;
}
if (/^end\b/.test(t)) {
stack.pop();
continue;
}
if (STATEMENT_RE.test(t)) continue; // style/class/click/linkStyle/direction
// Edge statement? Strip pipe labels first, then look for link operators.
const noPipes = t.replace(/\|[^|]*\|/g, ' ');
const masked = maskLabels(noPipes);
const ops = [...masked.matchAll(LINK_RE)];
if (ops.length === 0) {
// Standalone node declaration line (possibly several, space separated).
const ref = parseNodeRef(t);
if (ref) touchNode(ref.id, ref.label);
continue;
}
// Slice the original line at the masked operator positions to recover the
// node-list segments between successive arrows.
const segments: string[] = [];
const arrows: string[] = [];
let cursor = 0;
for (const op of ops) {
const idx = op.index ?? 0;
segments.push(noPipes.slice(cursor, idx));
arrows.push(op[0]);
cursor = idx + op[0].length;
}
segments.push(noPipes.slice(cursor));
// Register every node, then connect consecutive segments (a-chain),
// expanding `&` lists in source × target order (Mermaid's link order).
const nodeLists = segments.map(splitNodeList);
for (const list of nodeLists) {
for (const ref of list) {
const p = parseNodeRef(ref);
if (p) touchNode(p.id, p.label);
}
}
for (let s = 0; s < arrows.length; s++) {
const bidir = isBidir(arrows[s]);
for (const aRef of nodeLists[s]) {
for (const bRef of nodeLists[s + 1]) {
const a = parseNodeRef(aRef);
const b = parseNodeRef(bRef);
if (!a || !b) continue;
edges.push({ from: a.id, to: b.id, bidir, line: i });
}
}
}
}
// Degree counts.
for (const e of edges) {
const f = nodes.get(e.from);
const t = nodes.get(e.to);
if (f) {
f.outDeg++;
if (e.bidir) {
f.inDeg++;
f.bidirDeg++;
}
}
if (t) {
t.inDeg++;
if (e.bidir) {
t.outDeg++;
t.bidirDeg++;
}
}
}
return {
kind,
direction,
headerLine,
nodes,
edges,
subgraphs,
usesElk,
hasSpacing,
};
}
// ---------------------------------------------------------------------------
// State diagrams
// ---------------------------------------------------------------------------
const STATE_ARROW = /--+>/;
/** Strip a `:::class` suffix and whitespace from a transition endpoint token. */
function stateToken(tok: string): string {
const t = tok.trim();
if (t === '[*]') return '[*]';
return t.replace(/:::.*$/, '').trim();
}
/** Parse a state declaration body: `"Label" as ID`, `ID`, or `ID: ...`. */
function parseStateDecl(s: string): { id: string; label: string } {
const quotedAs = s.match(/^"([^"]*)"\s+as\s+([A-Za-z0-9_]\S*)/);
if (quotedAs) return { id: quotedAs[2], label: quotedAs[1] };
const idM = s.match(/^([A-Za-z0-9_]\S*)/);
return { id: idM ? idM[1] : s.trim(), label: '' };
}
/**
* Parse `stateDiagram` / `stateDiagram-v2` source. States become nodes and
* transitions become edges in source order — including `[*]` pseudo-state
* transitions, which keeps `linkStyle` indices aligned (verified: Mermaid emits
* one link per transition, `[*]` included). `[*]` itself is never a node.
*/
export function parseStateDiagram(src: string): FlowGraph | null {
const lines = src.replace(/\r\n/g, '\n').split('\n');
const fmEnd = frontmatterLines(src);
let headerLine = -1;
for (let i = fmEnd; i < lines.length; i++) {
const t = lines[i].trim();
if (!t || t.startsWith('%%')) continue;
if (/^stateDiagram(?:-v2)?\b/.test(t)) {
headerLine = i;
break;
}
return null;
}
if (headerLine < 0) return null;
const hasSpacing = /\b(?:nodeSpacing|rankSpacing)\b/.test(src);
let direction = 'TB';
const nodes = new Map<string, FlowNode>();
const edges: FlowEdge[] = [];
const subgraphs: SubgraphInfo[] = [];
const stack: string[] = []; // composite-state nesting
let inNote = false;
const touch = (id: string, label?: string) => {
if (id === '[*]' || !id) return;
let n = nodes.get(id);
if (!n) {
n = {
id,
label: label || id,
subgraph: stack.length ? stack[stack.length - 1] : null,
inDeg: 0,
outDeg: 0,
bidirDeg: 0,
};
nodes.set(id, n);
} else if (label && (n.label === n.id || !n.label)) {
n.label = label;
}
};
for (let i = headerLine + 1; i < lines.length; i++) {
const t = lines[i].trim();
if (!t || t.startsWith('%%')) continue;
// Notes (single-line `note … : text`, or a block until `end note`).
if (inNote) {
if (/^end\s+note\b/.test(t)) inNote = false;
continue;
}
if (/^note\b/.test(t)) {
if (!t.includes(':')) inNote = true;
continue;
}
const dirM = t.match(/^direction\s+([A-Za-z]{2})\b/);
if (dirM) {
direction = dirM[1].toUpperCase();
continue;
}
// Composite state open: `state Name {` / `state "Label" as Name {`.
const compM = t.match(/^state\s+(.+?)\s*\{$/);
if (compM) {
const ref = parseStateDecl(compM[1]);
touch(ref.id, ref.label);
subgraphs.push({ id: ref.id, title: ref.label || ref.id });
stack.push(ref.id);
continue;
}
if (t === '}') {
stack.pop();
continue;
}
if (/^(?:classDef|class|style|click|linkStyle)\b/.test(t)) continue;
if (t.includes(':::') && !STATE_ARROW.test(t)) continue; // `X:::cls`
if (STATE_ARROW.test(t)) {
const arrow = t.match(STATE_ARROW)![0];
const ai = t.indexOf(arrow);
const from = stateToken(t.slice(0, ai));
let rhs = t.slice(ai + arrow.length).trim();
const ci = rhs.indexOf(':');
if (ci >= 0) rhs = rhs.slice(0, ci); // drop the transition label
const to = stateToken(rhs);
touch(from);
touch(to);
edges.push({ from, to, bidir: false, line: i });
continue;
}
// `state "Label" as ID` / `state ID`.
const stateDeclM = t.match(/^state\s+(.+)$/);
if (stateDeclM) {
const ref = parseStateDecl(stateDeclM[1]);
touch(ref.id, ref.label);
continue;
}
// `ID : description`.
const labelM = t.match(/^([A-Za-z0-9_]\S*)\s*:\s*(.+)$/);
if (labelM) {
touch(labelM[1], labelM[2].trim());
continue;
}
// Bare state name.
const bare = t.match(/^([A-Za-z0-9_]\S*)$/);
if (bare) touch(bare[1]);
}
for (const e of edges) {
const f = nodes.get(e.from);
const tt = nodes.get(e.to);
if (f) f.outDeg++;
if (tt) tt.inDeg++;
}
return {
kind: 'stateDiagram',
direction,
headerLine,
nodes,
edges,
subgraphs,
usesElk: false,
hasSpacing,
};
}
/** Parse any supported diagram (flowchart/graph or state diagram). */
export function parseDiagram(src: string): FlowGraph | null {
return parseFlowchart(src) ?? parseStateDiagram(src);
}
// ---------------------------------------------------------------------------
// Metrics & scoring
// ---------------------------------------------------------------------------
function groupOf(g: FlowGraph, id: string): string {
return g.nodes.get(id)?.subgraph ?? '∅';
}
export function computeMetrics(g: FlowGraph): Metrics {
const all = [...g.nodes.values()];
const byDegree = (n: FlowNode) => n.inDeg + n.outDeg;
const hubs = all
.filter((n) => byDegree(n) >= HUB_DEGREE)
.sort((a, b) => byDegree(b) - byDegree(a));
const sinkHubs = all
.filter((n) => n.inDeg >= SINK_INDEG)
.sort((a, b) => b.inDeg - a.inDeg);
// Fade-worthy infrastructure: heavy receivers (in ≥ 2× out) or shared buses
// (many bidirectional links). Orchestrators that mostly *emit* are excluded.
const dimHubs = sinkHubs.filter(
(n) => n.outDeg * 2 <= n.inDeg || n.bidirDeg >= 5
);
let interGroupEdges = 0;
for (const e of g.edges) {
if (groupOf(g, e.from) !== groupOf(g, e.to)) interGroupEdges++;
}
// Exact duplicate edges (same direction, same endpoints) beyond the first.
const seen = new Set<string>();
let duplicateEdges = 0;
for (const e of g.edges) {
const key = `${e.from}${e.to}${e.bidir ? 1 : 0}`;
if (seen.has(key)) duplicateEdges++;
else seen.add(key);
}
const nodes = g.nodes.size;
return {
nodes,
edges: g.edges.length,
subgraphs: g.subgraphs.length,
density: nodes ? g.edges.length / nodes : 0,
maxDegree: all.reduce((m, n) => Math.max(m, byDegree(n)), 0),
hubs,
sinkHubs,
dimHubs,
interGroupEdges,
duplicateEdges,
};
}
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
export function scoreOf(g: FlowGraph, m: Metrics): number {
let score = 100;
score -= clamp((m.density - 1.2) * 18, 0, 30); // dense wiring
score -= clamp((m.maxDegree - HUB_DEGREE) * 4, 0, 24); // oversized hubs
if (m.edges > 0) {
score -= clamp((m.interGroupEdges / m.edges - 0.4) * 50, 0, 20); // crossings
}
// The ELK penalty only applies where ELK is actually available (flowcharts).
if (g.kind !== 'stateDiagram' && !g.usesElk) {
score -= clamp((m.edges - 25) * 0.5, 0, 16); // dense + no ELK
}
score -= clamp(m.duplicateEdges * 3, 0, 12);
return Math.round(clamp(score, 0, 100));
}
function ratingOf(score: number): Analysis['rating'] {
if (score >= 80) return 'Clear';
if (score >= 60) return 'Readable';
if (score >= 40) return 'Busy';
return 'Tangled';
}
// ---------------------------------------------------------------------------
// Top-level analysis
// ---------------------------------------------------------------------------
export function analyze(src: string): Analysis {
const g = parseDiagram(src);
if (!g) {
const head = src.replace(/^---[\s\S]*?---\s*/, '').trim().split(/\s+/)[0] || '';
return { supported: false, kind: head, score: 0, rating: 'Clear', fixes: [] };
}
const isFlow = g.kind === 'flowchart' || g.kind === 'graph';
const m = computeMetrics(g);
const score = scoreOf(g, m);
const fixes: Fix[] = [];
// ELK is a flowchart-only layout engine in Mermaid; state diagrams reject it.
if (isFlow && !g.usesElk && (m.nodes >= 12 || m.edges >= 20)) {
fixes.push({
id: 'elk',
title: 'Switch to the ELK layout engine',
detail:
'ELK is a layered auto-layout that untangles dense graphs far better ' +
'than the default. Usually the single biggest readability win.',
severity: 'high',
apply: applyElkLayout,
});
}
if (!g.hasSpacing && (m.density >= 1.3 || m.maxDegree >= HUB_DEGREE)) {
fixes.push({
id: 'spacing',
title: 'Add breathing room between nodes',
detail:
'Increase node/rank spacing so transitions are easier to follow where ' +
'they bunch up.',
severity: 'medium',
apply: applySpacing,
});
}
if (m.dimHubs.length > 0) {
const ids = m.dimHubs.map((n) => n.id);
const names = m.dimHubs.slice(0, 4).map((n) => n.label).join(', ');
const plural = m.dimHubs.length > 1;
fixes.push(
isFlow
? {
id: 'dim-hubs',
title: `De-emphasise ${m.dimHubs.length} cross-cutting hub${plural ? 's' : ''}`,
detail:
`Shared infrastructure (${names}) wires up to everything and ` +
'dominates the picture. Fading those edges lets the main flow read ' +
'clearly while keeping every connection intact.',
severity: m.dimHubs.length >= 3 ? 'high' : 'medium',
apply: (src) => dimHubEdges(src, ids),
}
: {
id: 'dim-hubs',
title: `Fade ${m.dimHubs.length} sink state${plural ? 's' : ''}`,
detail:
`Terminal states (${names}) collect transitions from everywhere. ` +
'Greying them out pulls focus onto the main flow. (State diagrams ' +
"can't style individual transitions, so the states are faded.)",
severity: 'medium',
apply: (src) => dimHubStates(src, ids),
}
);
}
if (m.duplicateEdges > 0) {
fixes.push({
id: 'dedupe',
title: `Remove ${m.duplicateEdges} duplicate edge${
m.duplicateEdges > 1 ? 's' : ''
}`,
detail: 'Identical connections are declared more than once.',
severity: 'low',
apply: removeDuplicateEdges,
});
}
if (m.maxDegree >= 10) {
const worst = m.hubs[0];
fixes.push({
id: 'split-advice',
title: `"${worst.label}" connects to ${m.maxDegree} nodes`,
detail:
'A hub this large is hard to lay out cleanly. Consider splitting the ' +
'diagram by concern (e.g. a separate data-flow view) or routing ' +
'through an intermediate node.',
severity: 'low',
});
}
return { supported: true, graph: g, metrics: m, score, rating: ratingOf(score), fixes };
}
// ---------------------------------------------------------------------------
// Source transforms
// ---------------------------------------------------------------------------
function deepMerge(a: Record<string, any>, b: Record<string, any>): Record<string, any> {
const out: Record<string, any> = { ...a };
for (const [k, v] of Object.entries(b)) {
out[k] = v && typeof v === 'object' && !Array.isArray(v) && typeof out[k] === 'object'
? deepMerge(out[k], v)
: v;
}
return out;
}
/** Best-effort parse of a Mermaid init directive's relaxed-JSON body. */
function parseLooseObject(body: string): Record<string, any> | null {
try {
return JSON.parse(body);
} catch {
try {
// Quote bare keys and convert single quotes; good enough for our own output.
const normalised = body
.replace(/'/g, '"')
.replace(/([{,]\s*)([A-Za-z_][\w-]*)\s*:/g, '$1"$2":');
return JSON.parse(normalised);
} catch {
return null;
}
}
}
/**
* Merge `patch` into the diagram's `%%{init: …}%%` config directive, creating
* one (just above the header) if absent. Used by the ELK and spacing fixes so
* they compose instead of fighting each other.
*/
export function mergeInit(src: string, patch: Record<string, any>): string {
const lines = src.replace(/\r\n/g, '\n').split('\n');
const initIdx = lines.findIndex((l) => /%%\{\s*init\s*:/.test(l));
if (initIdx >= 0) {
const m = lines[initIdx].match(/%%\{\s*init\s*:\s*([\s\S]*?)\}%%/);
const existing = (m && parseLooseObject(m[1])) || {};
const merged = deepMerge(existing, patch);
lines[initIdx] = `%%{init: ${JSON.stringify(merged)}}%%`;
return lines.join('\n');
}
const g = parseDiagram(src);
const at = g ? g.headerLine : frontmatterLines(src);
lines.splice(at, 0, `%%{init: ${JSON.stringify(patch)}}%%`);
return lines.join('\n');
}
export function applyElkLayout(src: string): string {
return mergeInit(src, { layout: 'elk' });
}
export function applySpacing(src: string): string {
// Spacing lives under a per-diagram config key; state diagrams also lack a
// `curve` option.
const patch =
parseDiagram(src)?.kind === 'stateDiagram'
? { state: { nodeSpacing: 60, rankSpacing: 80 } }
: { flowchart: { nodeSpacing: 55, rankSpacing: 70, curve: 'basis' } };
return mergeInit(src, patch);
}
export function setDirection(src: string, dir: string): string {
const g = parseDiagram(src);
if (!g) return src;
const lines = src.replace(/\r\n/g, '\n').split('\n');
if (g.kind === 'stateDiagram') {
// State diagrams set direction with a `direction XX` statement, not on the
// header. Update an existing one or insert just after the header.
const di = lines.findIndex(
(l, idx) => idx > g.headerLine && /^\s*direction\s+[A-Za-z]{2}\b/.test(l)
);
if (di >= 0) {
lines[di] = lines[di].replace(/(direction\s+)[A-Za-z]{2}/, `$1${dir}`);
} else {
lines.splice(g.headerLine + 1, 0, ` direction ${dir}`);
}
return lines.join('\n');
}
lines[g.headerLine] = lines[g.headerLine].replace(
/^(\s*)(flowchart|graph)\b\s*([A-Za-z]{2})?/,
`$1$2 ${dir}`
);
return lines.join('\n');
}
const DIM_MARK = '%% ─ Mermix: de-emphasised hub edges (re-run Optimize to refresh) ─';
/** Strip any Mermix-managed dim block so transforms stay idempotent. */
function stripDimBlock(src: string): string {
return src
.replace(
new RegExp(`\\n*${escapeRe(DIM_MARK)}\\n(?:linkStyle[^\\n]*\\n?)+`, 'g'),
'\n'
)
.replace(/\n{3,}/g, '\n\n');
}
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Append a `linkStyle` rule that fades every edge touching one of `hubIds`,
* so the primary flow stands out. Re-applying refreshes the same block.
*/
export function dimHubEdges(src: string, hubIds: string[]): string {
const base = stripDimBlock(src);
const g = parseDiagram(base);
if (!g) return src;
const targets = new Set(hubIds);
const idx: number[] = [];
g.edges.forEach((e, i) => {
if (targets.has(e.from) || targets.has(e.to)) idx.push(i);
});
if (idx.length === 0) return base;
const rule = `linkStyle ${idx.join(',')} stroke:#3a3f4b,stroke-width:1px,stroke-opacity:0.28;`;
return `${base.replace(/\n+$/, '')}\n\n${DIM_MARK}\n${rule}\n`;
}
const STATE_DIM_MARK = '%% ─ Mermix: faded sink states (re-run Optimize to refresh) ─';
/** Strip any Mermix-managed state-dim block so transforms stay idempotent. */
function stripStateDimBlock(src: string): string {
return src
.replace(
new RegExp(
`\\n*${escapeRe(STATE_DIM_MARK)}\\n(?:classDef mmxHubDim[^\\n]*\\n?|class [^\\n]*mmxHubDim[^\\n]*\\n?)+`,
'g'
),
'\n'
)
.replace(/\n{3,}/g, '\n\n');
}
/**
* Fade sink states via `classDef`/`class`. State diagrams have no per-transition
* styling (and a `linkStyle` line renders as a stray node), so for them we
* de-emphasise the busy *states* — e.g. a CANCELLED everything funnels into.
*/
export function dimHubStates(src: string, ids: string[]): string {
const base = stripStateDimBlock(src);
const g = parseDiagram(base);
if (!g) return src;
const valid = ids.filter((id) => g.nodes.has(id));
if (valid.length === 0) return base;
return (
[
base.replace(/\n+$/, ''),
'',
STATE_DIM_MARK,
'classDef mmxHubDim fill:#1b1f2a,stroke:#2a2f3a,color:#5b6573',
`class ${valid.join(',')} mmxHubDim`,
].join('\n') + '\n'
);
}
/**
* Drop edge statements that repeat an earlier identical one (whitespace and
* pipe-labelinsensitive). Only whole single-edge lines are removed; lines
* with `&` lists or multiple chained arrows are left untouched.
*/
export function removeDuplicateEdges(src: string): string {
const lines = src.replace(/\r\n/g, '\n').split('\n');
const seen = new Set<string>();
const out: string[] = [];
for (const line of lines) {
const t = line.trim();
const masked = maskLabels(t.replace(/\|[^|]*\|/g, ' '));
const ops = [...masked.matchAll(LINK_RE)];
const single = ops.length === 1 && !masked.includes('&');
if (single && !STATEMENT_RE.test(t)) {
const key = t.replace(/\s+/g, ' ');
if (seen.has(key)) continue; // skip the duplicate line entirely
seen.add(key);
}
out.push(line);
}
return out.join('\n');
}
/** Run the safe fixes (ELK + spacing + dim hubs + dedupe) in one pass. */
export function autoOptimize(src: string): string {
const a = analyze(src);
if (!a.supported || !a.metrics) return src;
let out = src;
const isFlow = a.graph!.kind === 'flowchart' || a.graph!.kind === 'graph';
if (isFlow && !a.graph!.usesElk) out = applyElkLayout(out);
if (!a.graph!.hasSpacing) out = applySpacing(out);
if (a.metrics.duplicateEdges > 0) out = removeDuplicateEdges(out);
if (a.metrics.dimHubs.length > 0) {
const ids = a.metrics.dimHubs.map((n) => n.id);
out = isFlow ? dimHubEdges(out, ids) : dimHubStates(out, ids);
}
return out;
}
// ---------------------------------------------------------------------------
// Focus mode (non-destructive — used only for the preview render)
// ---------------------------------------------------------------------------
/**
* Produce a transient variant of `src` that spotlights `nodeId` and its direct
* neighbours, fading everything else. Reuses the parse + linkStyle machinery so
* it survives any flowchart the analyzer understands. Returns `src` unchanged
* if the node can't be found.
*/
export function applyFocus(src: string, nodeId: string): string {
const base = stripStateDimBlock(stripDimBlock(src));
const g = parseDiagram(base);
if (!g || !g.nodes.has(nodeId)) return src;
const neighbours = new Set<string>([nodeId]);
const incident: number[] = [];
const other: number[] = [];
g.edges.forEach((e, i) => {
if (e.from === nodeId || e.to === nodeId) {
incident.push(i);
neighbours.add(e.from);
neighbours.add(e.to);
} else {
other.push(i);
}
});
const dimNodes = [...g.nodes.keys()].filter((id) => !neighbours.has(id));
const parts = [base.replace(/\n+$/, ''), '', `%% ─ Mermix focus: ${nodeId}`];
parts.push('classDef mmxDim fill:#161922,stroke:#262b36,color:#4a5263');
parts.push('classDef mmxFocus fill:#2c2350,stroke:#a78bfa,color:#fff,stroke-width:2px');
if (dimNodes.length) parts.push(`class ${dimNodes.join(',')} mmxDim`);
parts.push(`class ${nodeId} mmxFocus`);
// State diagrams can't style transitions (a `linkStyle` line renders as a
// stray node), so fade by state only there; flowcharts also fade the edges.
if (g.kind !== 'stateDiagram') {
if (other.length) {
parts.push(`linkStyle ${other.join(',')} stroke:#1e222b,stroke-opacity:0.1;`);
}
if (incident.length) {
parts.push(`linkStyle ${incident.join(',')} stroke:#a78bfa,stroke-width:2px;`);
}
}
return parts.join('\n') + '\n';
}

View File

@@ -6,6 +6,7 @@ import type {
FileStatus, FileStatus,
OpenedProject, OpenedProject,
ProjectRecord, ProjectRecord,
RemoteStatus,
} from './types'; } from './types';
export type Toast = { id: number; msg: string; kind: 'info' | 'error' | 'success' }; export type Toast = { id: number; msg: string; kind: 'info' | 'error' | 'success' };
@@ -31,6 +32,9 @@ class Store {
branches = $state<BranchInfo[]>([]); branches = $state<BranchInfo[]>([]);
history = $state<CommitInfo[]>([]); history = $state<CommitInfo[]>([]);
changes = $state<FileStatus[]>([]); changes = $state<FileStatus[]>([]);
remote = $state<RemoteStatus | null>(null);
/** True while a fetch/pull/push is in flight. */
syncing = $state(false);
// UI // UI
theme = $state('default'); theme = $state('default');
@@ -38,8 +42,19 @@ class Store {
toasts = $state<Toast[]>([]); toasts = $state<Toast[]>([]);
/** Most recent successfully rendered SVG, used for export. */ /** Most recent successfully rendered SVG, used for export. */
lastSvg = $state(''); lastSvg = $state('');
/** Whether the left sidebar (diagram list) is visible. */
showSidebar = $state(true);
/** Whether the Git side panel is visible. */ /** Whether the Git side panel is visible. */
showGit = $state(true); showGit = $state(true);
/** Whether the Optimize (Diagram Doctor) side panel is visible. */
showOptimize = $state(false);
/** Node id spotlighted in the preview by focus mode, or null. */
focusNode = $state<string | null>(null);
/**
* Bumped whenever `content` is replaced programmatically (e.g. an applied
* optimization). The editor watches this to re-sync its document.
*/
revision = $state(0);
/** Workspace layout: editor only, side-by-side, or preview only. */ /** Workspace layout: editor only, side-by-side, or preview only. */
layout = $state<'code' | 'split' | 'preview'>('split'); layout = $state<'code' | 'split' | 'preview'>('split');
/** Quick-edit drawer over the preview (viewer mode). */ /** Quick-edit drawer over the preview (viewer mode). */
@@ -125,6 +140,8 @@ class Store {
this.history = []; this.history = [];
this.branches = []; this.branches = [];
this.changes = []; this.changes = [];
this.remote = null;
this.focusNode = null;
this.view = 'start'; this.view = 'start';
} }
@@ -152,6 +169,7 @@ class Store {
this.activeId = rel; this.activeId = rel;
this.content = content; this.content = content;
this.savedContent = content; this.savedContent = content;
this.focusNode = null;
} catch (e) { } catch (e) {
this.fail(e); this.fail(e);
} }
@@ -201,6 +219,17 @@ class Store {
} }
} }
/**
* Replace the editor content programmatically (used by the Optimize panel).
* Bumps `revision` so the CodeMirror view re-syncs and the change lands in
* its undo history — applying a fix stays reversible with ⌘Z.
*/
applySource(next: string) {
if (next === this.content) return;
this.content = next;
this.revision++;
}
async save(silent = false) { async save(silent = false) {
if (!this.path || !this.activeId) return; if (!this.path || !this.activeId) return;
try { try {
@@ -227,11 +256,12 @@ class Store {
async refreshGit() { async refreshGit() {
if (!this.path) return; if (!this.path) return;
try { try {
[this.branch, this.branches, this.history, this.changes] = await Promise.all([ [this.branch, this.branches, this.history, this.changes, this.remote] = await Promise.all([
api.gitCurrentBranch(this.path), api.gitCurrentBranch(this.path),
api.gitBranches(this.path), api.gitBranches(this.path),
api.gitHistory(this.path, 100), api.gitHistory(this.path, 100),
api.gitStatus(this.path), api.gitStatus(this.path),
api.gitRemoteStatus(this.path),
]); ]);
} catch (e) { } catch (e) {
this.fail(e); this.fail(e);
@@ -282,6 +312,66 @@ class Store {
} }
} }
// ---- Remote sync ----------------------------------------------------
async setRemote(url: string) {
if (!this.path) return;
try {
await api.gitSetRemote(this.path, url);
this.remote = await api.gitRemoteStatus(this.path);
this.notify('Remote saved', 'success');
} catch (e) {
this.fail(e);
}
}
fetch() {
return this.sync('fetch');
}
pull() {
return this.sync('pull');
}
push() {
return this.sync('push');
}
/** Shared fetch/pull/push flow: run, then refresh local + remote state. */
private async sync(op: 'fetch' | 'pull' | 'push') {
if (!this.path || this.syncing) return;
// Don't lose buffered edits before a push or a (fast-forward) pull.
if (op !== 'fetch' && this.dirty) await this.save(true);
this.syncing = true;
try {
if (op === 'fetch') await api.gitFetch(this.path);
else if (op === 'pull') await api.gitPull(this.path);
else await api.gitPush(this.path);
// A pull can change files on disk; reload the diagram list and the open
// diagram's content so the editor reflects what was merged in.
if (op === 'pull') {
this.diagrams = await api.listDiagrams(this.path);
if (this.activeId && this.diagrams.some((d) => d.id === this.activeId)) {
const content = await api.readDiagram(this.path, this.activeId);
this.content = content;
this.savedContent = content;
this.revision++;
} else if (this.diagrams.length > 0) {
await this.selectDiagram(this.diagrams[0].id);
}
}
await this.refreshGit();
const where = this.remote?.remote ?? 'remote';
const verb = op === 'fetch' ? 'Fetched from' : op === 'pull' ? 'Pulled from' : 'Pushed to';
this.notify(`${verb} ${where}`, 'success');
} catch (e) {
this.fail(e);
} finally {
this.syncing = false;
}
}
async setTheme(theme: string) { async setTheme(theme: string) {
this.theme = theme; this.theme = theme;
try { try {

View File

@@ -56,4 +56,13 @@ export interface FileStatus {
status: string; status: string;
} }
export interface RemoteStatus {
has_remote: boolean;
remote: string | null;
url: string | null;
upstream: string | null;
ahead: number;
behind: number;
}
export type MermaidTheme = 'default' | 'neutral' | 'dark' | 'forest' | 'base'; export type MermaidTheme = 'default' | 'neutral' | 'dark' | 'forest' | 'base';