Compare commits
10 Commits
b648ee904d
...
dd371c404f
| Author | SHA1 | Date | |
|---|---|---|---|
| dd371c404f | |||
| 27b88917ed | |||
| 6488acc7b9 | |||
| e1b5f31f87 | |||
| c35960761b | |||
| 6f23c620c1 | |||
| 3918d5b8d2 | |||
| 9f4929bffa | |||
| 49b6c5191e | |||
| bbc12ee4be |
71
.gitea/workflows/ci.yaml
Normal file
71
.gitea/workflows/ci.yaml
Normal 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
|
||||||
98
README.md
98
README.md
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -104,23 +121,26 @@ 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
20
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
<Sidebar />
|
{#if store.showSidebar}
|
||||||
|
<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}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
431
src/lib/components/OptimizePanel.svelte
Normal file
431
src/lib/components/OptimizePanel.svelte
Normal 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>
|
||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
948
src/lib/optimize.ts
Normal 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} | ||||||