feat(frontend): Svelte editor, live preview and Git panel

- CodeMirror 6 editor with a debounced Mermaid 11 preview (zoom, pan,
  auto fit-to-view and inline error reporting)
- project start screen with recent projects, sidebar diagram explorer,
  toolbar and a Git panel (status, commit, history, branches)
- SVG/PNG export, per-project theme switching, toasts
- rune-based central store orchestrating all backend calls
- view modes (Code / Split / Preview) plus a viewer mode with a
  slide-in quick-edit drawer (Cmd/Ctrl+E)
This commit is contained in:
2026-05-22 16:27:28 +03:00
parent 890390bc65
commit 29bf6438b3
17 changed files with 2277 additions and 0 deletions

235
src/App.svelte Normal file
View File

@@ -0,0 +1,235 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { store } from './lib/store.svelte';
import StartScreen from './lib/components/StartScreen.svelte';
import Sidebar from './lib/components/Sidebar.svelte';
import Toolbar from './lib/components/Toolbar.svelte';
import Editor from './lib/components/Editor.svelte';
import Preview from './lib/components/Preview.svelte';
import GitPanel from './lib/components/GitPanel.svelte';
import Toasts from './lib/components/Toasts.svelte';
// Editor/preview split ratio (fraction taken by the editor).
let ratio = $state(0.42);
let splitting = $state(false);
let splitHost = $state<HTMLDivElement>();
function startSplit(e: MouseEvent) {
splitting = true;
e.preventDefault();
}
function moveSplit(e: MouseEvent) {
if (!splitting || !splitHost) return;
const rect = splitHost.getBoundingClientRect();
ratio = Math.min(0.8, Math.max(0.2, (e.clientX - rect.left) / rect.width));
}
function endSplit() {
splitting = false;
}
function onKey(e: KeyboardEvent) {
if (store.view !== 'editor') return;
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
void store.save();
}
// ⌘/Ctrl+E: jump into viewer mode and toggle the quick-edit drawer.
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'e') {
e.preventDefault();
if (store.layout !== 'preview') store.layout = 'preview';
store.editDrawer = !store.editDrawer;
}
if (e.key === 'Escape' && store.editDrawer) {
store.editDrawer = false;
}
}
</script>
<svelte:window onkeydown={onKey} onmousemove={moveSplit} onmouseup={endSplit} />
{#if store.view === 'start'}
<StartScreen />
{:else}
<div class="app">
<Toolbar />
<div class="body">
<Sidebar />
{#if store.activeId}
<div class="split" bind:this={splitHost} class:splitting>
{#if store.layout !== 'preview'}
<div
class="pane"
style={store.layout === 'split' ? `width: ${ratio * 100}%` : 'flex: 1'}
>
<Editor />
</div>
{/if}
{#if store.layout === 'split'}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="gutter"
role="separator"
aria-orientation="vertical"
tabindex="-1"
onmousedown={startSplit}
></div>
{/if}
{#if store.layout !== 'code'}
<div class="pane preview-pane">
<Preview />
</div>
{/if}
{#if store.layout === 'preview'}
{#if store.editDrawer}
<div class="edit-drawer" transition:fly={{ x: -360, duration: 180 }}>
<div class="drawer-head">
<span class="drawer-title">{store.activeName}</span>
<div class="drawer-actions">
<button onclick={() => store.save()} disabled={!store.dirty}>Save</button>
<button class="ghost icon" title="Close (Esc)" onclick={() => (store.editDrawer = false)}>✕</button>
</div>
</div>
<div class="drawer-body">
<Editor />
</div>
</div>
{:else}
<button
class="fab"
title="Quick edit (⌘E)"
onclick={() => (store.editDrawer = true)}
>
✎ Edit
{#if store.dirty}<span class="fab-dot"></span>{/if}
</button>
{/if}
{/if}
</div>
{:else}
<div class="no-diagram">
<div class="muted">No diagram open.</div>
<div class="faint">Pick one from the sidebar, or create a new diagram with .</div>
</div>
{/if}
{#if store.showGit}
<GitPanel />
{/if}
</div>
</div>
{/if}
<Toasts />
<style>
.app {
height: 100%;
display: flex;
flex-direction: column;
}
.body {
flex: 1;
display: flex;
min-height: 0;
}
.split {
flex: 1;
display: flex;
min-width: 0;
position: relative;
}
.edit-drawer {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: clamp(360px, 42%, 640px);
display: flex;
flex-direction: column;
background: var(--bg-elev);
border-right: 1px solid var(--border-strong);
box-shadow: 8px 0 32px rgba(0, 0, 0, 0.45);
z-index: 20;
}
.drawer-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 8px 8px 14px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.drawer-title {
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drawer-actions {
display: flex;
gap: 6px;
align-items: center;
}
.drawer-body {
flex: 1;
min-height: 0;
}
.fab {
position: absolute;
left: 16px;
bottom: 16px;
z-index: 25;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 16px;
border-radius: 999px;
background: var(--accent);
border-color: var(--accent);
color: #fff;
font-weight: 600;
box-shadow: 0 6px 22px rgba(124, 92, 255, 0.45);
}
.fab:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.fab-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--amber);
}
.split.splitting {
cursor: col-resize;
user-select: none;
}
.pane {
min-width: 0;
height: 100%;
overflow: hidden;
}
.preview-pane {
flex: 1;
}
.gutter {
width: 5px;
flex-shrink: 0;
background: var(--border);
cursor: col-resize;
transition: background 0.12s ease;
}
.gutter:hover {
background: var(--accent);
}
.no-diagram {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
text-align: center;
}
</style>

135
src/app.css Normal file
View File

@@ -0,0 +1,135 @@
:root {
--bg: #0f1115;
--bg-elev: #171a21;
--bg-elev-2: #1e222b;
--bg-input: #11141a;
--border: #2a2f3a;
--border-strong: #3a4150;
--text: #e6e9ef;
--text-dim: #9aa3b2;
--text-faint: #6b7280;
--accent: #7c5cff;
--accent-hover: #8e72ff;
--accent-soft: rgba(124, 92, 255, 0.16);
--green: #3fb950;
--red: #f85149;
--amber: #d29922;
--radius: 8px;
--radius-sm: 5px;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color-scheme: dark;
font-family: var(--sans);
font-size: 14px;
color: var(--text);
background: var(--bg);
}
* {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
margin: 0;
}
body {
overflow: hidden;
-webkit-user-select: none;
user-select: none;
}
button {
font-family: inherit;
font-size: 13px;
color: var(--text);
background: var(--bg-elev-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 12px;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
white-space: nowrap;
}
button:hover {
background: var(--border);
}
button:active {
transform: translateY(0.5px);
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: white;
font-weight: 600;
}
button.primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
button.ghost {
background: transparent;
border-color: transparent;
}
button.ghost:hover {
background: var(--bg-elev-2);
}
button.icon {
padding: 5px 8px;
line-height: 1;
}
button.danger:hover {
background: rgba(248, 81, 73, 0.16);
border-color: var(--red);
color: var(--red);
}
input,
textarea,
select {
font-family: inherit;
font-size: 13px;
color: var(--text);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 7px 10px;
width: 100%;
outline: none;
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--accent);
}
::placeholder {
color: var(--text-faint);
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 6px;
border: 2px solid var(--bg);
}
::-webkit-scrollbar-thumb:hover {
background: #4a5263;
}
.muted {
color: var(--text-dim);
}
.faint {
color: var(--text-faint);
}

74
src/lib/api.ts Normal file
View File

@@ -0,0 +1,74 @@
import { invoke } from '@tauri-apps/api/core';
import type {
BranchInfo,
CommitInfo,
DiagramInfo,
FileStatus,
OpenedProject,
ProjectRecord,
} from './types';
// Thin, typed wrappers over the Rust command surface. Argument keys use
// camelCase; Tauri maps them onto the snake_case Rust parameters.
export const api = {
// ---- Projects -------------------------------------------------------
listProjects: () => invoke<ProjectRecord[]>('list_projects'),
createProject: (parentDir: string, name: string, description: string) =>
invoke<OpenedProject>('create_project', { parentDir, name, description }),
openProject: (path: string) => invoke<OpenedProject>('open_project', { path }),
forgetProject: (id: string) => invoke<void>('forget_project', { id }),
// ---- Diagrams -------------------------------------------------------
listDiagrams: (path: string) => invoke<DiagramInfo[]>('list_diagrams', { path }),
readDiagram: (path: string, rel: string) =>
invoke<string>('read_diagram', { path, rel }),
saveDiagram: (path: string, rel: string, content: string) =>
invoke<void>('save_diagram', { path, rel, content }),
createDiagram: (path: string, name: string) =>
invoke<DiagramInfo>('create_diagram', { path, name }),
renameDiagram: (path: string, rel: string, newName: string) =>
invoke<DiagramInfo>('rename_diagram', { path, rel, newName }),
deleteDiagram: (path: string, rel: string) =>
invoke<void>('delete_diagram', { path, rel }),
// ---- Git ------------------------------------------------------------
gitStatus: (path: string) => invoke<FileStatus[]>('git_status', { path }),
gitCommit: (path: string, message: string) =>
invoke<string>('git_commit', { path, message }),
gitHistory: (path: string, limit?: number) =>
invoke<CommitInfo[]>('git_history', { path, limit }),
gitBranches: (path: string) => invoke<BranchInfo[]>('git_branches', { path }),
gitCurrentBranch: (path: string) =>
invoke<string>('git_current_branch', { path }),
gitCreateBranch: (path: string, name: string) =>
invoke<void>('git_create_branch', { path, name }),
gitCheckoutBranch: (path: string, name: string) =>
invoke<void>('git_checkout_branch', { path, name }),
// ---- Export & settings ---------------------------------------------
writeTextFile: (path: string, contents: string) =>
invoke<void>('write_text_file', { path, contents }),
writeBinaryFile: (path: string, contents: number[]) =>
invoke<void>('write_binary_file', { path, contents }),
getSetting: (key: string) => invoke<string | null>('get_setting', { key }),
setSetting: (key: string, value: string) =>
invoke<void>('set_setting', { key, value }),
};

View File

@@ -0,0 +1,107 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { EditorState } from '@codemirror/state';
import {
EditorView,
keymap,
lineNumbers,
highlightActiveLine,
highlightActiveLineGutter,
drawSelection,
highlightSpecialChars,
} from '@codemirror/view';
import {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} from '@codemirror/commands';
import { bracketMatching, indentOnInput } from '@codemirror/language';
import { store } from '../store.svelte';
let host: HTMLDivElement;
let view: EditorView | undefined;
const theme = EditorView.theme(
{
'&': {
height: '100%',
fontSize: '13.5px',
backgroundColor: 'var(--bg-input)',
color: 'var(--text)',
},
'.cm-content': {
fontFamily: 'var(--mono)',
caretColor: 'var(--accent)',
padding: '12px 0',
},
'.cm-scroller': { fontFamily: 'var(--mono)', lineHeight: '1.55' },
'.cm-gutters': {
backgroundColor: 'var(--bg-input)',
color: 'var(--text-faint)',
border: 'none',
},
'.cm-activeLineGutter': { backgroundColor: 'transparent', color: 'var(--text-dim)' },
'.cm-activeLine': { backgroundColor: 'rgba(255,255,255,0.03)' },
'&.cm-focused .cm-cursor': { borderLeftColor: 'var(--accent)' },
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection': {
backgroundColor: 'var(--accent-soft) !important',
},
'.cm-matchingBracket': { backgroundColor: 'var(--accent-soft)', outline: 'none' },
},
{ dark: true }
);
onMount(() => {
view = new EditorView({
parent: host,
state: EditorState.create({
doc: store.content,
extensions: [
lineNumbers(),
highlightActiveLineGutter(),
highlightSpecialChars(),
history(),
drawSelection(),
indentOnInput(),
bracketMatching(),
highlightActiveLine(),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
theme,
EditorView.lineWrapping,
EditorView.updateListener.of((u) => {
if (u.docChanged) {
store.content = u.state.doc.toString();
}
}),
],
}),
});
return () => view?.destroy();
});
// When a different diagram is loaded, replace the whole document. Depend only
// on activeId; read content untracked so typing doesn't reset the editor.
$effect(() => {
void store.activeId; // re-run only when the selected diagram changes
if (!view) return;
untrack(() => {
const incoming = store.content;
if (incoming !== view!.state.doc.toString()) {
view!.dispatch({
changes: { from: 0, to: view!.state.doc.length, insert: incoming },
});
}
});
});
</script>
<div class="editor" bind:this={host}></div>
<style>
.editor {
height: 100%;
overflow: hidden;
background: var(--bg-input);
}
</style>

View File

@@ -0,0 +1,268 @@
<script lang="ts">
import { store } from '../store.svelte';
import Modal from './Modal.svelte';
let message = $state('');
let newBranch = $state<string | null>(null);
const hasChanges = $derived(store.changes.length > 0 || store.dirty);
async function commit() {
const msg = message.trim();
if (!msg) return;
await store.commit(msg);
message = '';
}
async function onBranchChange(e: Event) {
const name = (e.target as HTMLSelectElement).value;
if (name && name !== store.branch) await store.checkoutBranch(name);
}
async function createBranch() {
const name = (newBranch ?? '').trim();
if (!name) return;
await store.createBranch(name);
await store.checkoutBranch(name);
newBranch = null;
}
function ago(iso: string): string {
if (!iso) return '';
const then = new Date(iso).getTime();
const s = Math.max(0, Math.floor((Date.now() - then) / 1000));
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 30) return `${d}d ago`;
return new Date(iso).toLocaleDateString();
}
</script>
<aside class="git">
<div class="branch-row">
<span class="branch-icon" title="Current branch"></span>
<select value={store.branch} onchange={onBranchChange} title="Switch branch">
{#each store.branches as b (b.name)}
<option value={b.name}>{b.name}</option>
{/each}
{#if store.branches.length === 0}
<option value={store.branch}>{store.branch}</option>
{/if}
</select>
<button class="ghost icon" title="New branch" onclick={() => (newBranch = '')}></button>
</div>
<div class="commit-box">
<div class="changes">
{#if hasChanges}
<span class="count">{store.changes.length || (store.dirty ? 1 : 0)} change(s)</span>
<ul>
{#each store.changes as c (c.path)}
<li><span class="badge {c.status}">{c.status[0].toUpperCase()}</span>{c.path}</li>
{/each}
{#if store.changes.length === 0 && store.dirty}
<li class="faint">unsaved edits in current diagram</li>
{/if}
</ul>
{:else}
<span class="clean faint">✓ Working tree clean</span>
{/if}
</div>
<textarea
bind:value={message}
rows="2"
placeholder="Commit message…"
onkeydown={(e) => (e.metaKey || e.ctrlKey) && e.key === 'Enter' && commit()}
></textarea>
<button class="primary" onclick={commit} disabled={!message.trim() || !hasChanges}>
Commit
</button>
</div>
<div class="hist-head">History</div>
<div class="history">
{#each store.history as c (c.id)}
<div class="commit" title={`${c.message}\n${c.author} <${c.email}>`}>
<code class="sha">{c.short_id}</code>
<div class="meta">
<div class="msg">{c.message}</div>
<div class="sub faint">{c.author} · {ago(c.time)}</div>
</div>
</div>
{/each}
{#if store.history.length === 0}
<div class="empty faint">No commits yet.</div>
{/if}
</div>
</aside>
{#if newBranch !== null}
<Modal title="New branch" onClose={() => (newBranch = null)}>
<label class="fld">
<span>Branch name</span>
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
bind:value={newBranch}
placeholder="e.g. feature/new-layout"
onkeydown={(e) => e.key === 'Enter' && createBranch()}
/>
</label>
<div class="actions">
<button onclick={() => (newBranch = null)}>Cancel</button>
<button class="primary" onclick={createBranch} disabled={!newBranch.trim()}>
Create &amp; switch
</button>
</div>
</Modal>
{/if}
<style>
.git {
width: 280px;
flex-shrink: 0;
background: var(--bg-elev);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.branch-row {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.branch-icon {
color: var(--accent);
}
.commit-box {
padding: 12px;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 8px;
}
.changes {
font-size: 12px;
}
.count {
color: var(--amber);
font-weight: 600;
}
.changes ul {
list-style: none;
margin: 6px 0 0;
padding: 0;
max-height: 120px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 3px;
}
.changes li {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--mono);
font-size: 11.5px;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
display: inline-grid;
place-items: center;
width: 16px;
height: 16px;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.badge.added {
background: var(--green);
}
.badge.modified {
background: var(--amber);
}
.badge.deleted {
background: var(--red);
}
.badge.renamed,
.badge.changed {
background: var(--accent);
}
textarea {
resize: vertical;
font-family: var(--mono);
font-size: 12px;
}
.hist-head {
padding: 12px 12px 6px;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-faint);
}
.history {
flex: 1;
overflow-y: auto;
padding: 0 8px 12px;
}
.commit {
display: flex;
gap: 8px;
padding: 8px;
border-radius: var(--radius-sm);
}
.commit:hover {
background: var(--bg-elev-2);
}
.sha {
font-family: var(--mono);
font-size: 11px;
color: var(--accent);
flex-shrink: 0;
padding-top: 1px;
}
.meta {
overflow: hidden;
}
.msg {
font-size: 12.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
font-size: 11px;
margin-top: 2px;
}
.empty {
padding: 16px;
text-align: center;
font-size: 12px;
}
.fld {
display: flex;
flex-direction: column;
gap: 6px;
}
.fld span {
font-size: 12px;
color: var(--text-dim);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
onClose: () => void;
children: Snippet;
}
let { title, onClose, children }: Props = $props();
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={onKey} />
<div class="backdrop" onclick={onClose} role="presentation">
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1">
<header>
<h2>{title}</h2>
<button class="ghost icon" onclick={onClose} aria-label="Close"></button>
</header>
<div class="body">
{@render children()}
</div>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
display: grid;
place-items: center;
z-index: 900;
animation: fade 0.12s ease;
}
.modal {
width: min(440px, 92vw);
background: var(--bg-elev);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
@keyframes fade {
from {
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,239 @@
<script lang="ts">
import { tick } from 'svelte';
import { store } from '../store.svelte';
import { renderMermaid } from '../mermaid';
let svg = $state('');
let error = $state('');
let rendering = $state(false);
let zoom = $state(1);
let pan = $state({ x: 0, y: 0 });
let canvasEl = $state<HTMLDivElement>();
let timer: ReturnType<typeof setTimeout> | undefined;
let fittedFor = '';
// Debounced re-render whenever the source or theme changes.
$effect(() => {
const code = store.content;
const theme = store.theme;
clearTimeout(timer);
timer = setTimeout(() => void run(code, theme), 220);
return () => clearTimeout(timer);
});
// Re-fit when the available space changes (layout / panel toggles).
$effect(() => {
void store.layout;
void store.showGit;
tick().then(fit);
});
async function run(code: string, theme: string) {
rendering = true;
const result = await renderMermaid(code, theme);
rendering = false;
if ('svg' in result) {
svg = result.svg;
store.lastSvg = result.svg;
error = '';
// Auto-fit the first time each diagram renders.
if (store.activeId !== fittedFor) {
fittedFor = store.activeId ?? '';
await tick();
fit();
}
} else {
error = result.error;
}
}
function svgDims(s: string): { w: number; h: number } {
const vb = s.match(/viewBox="([\d.\s-]+)"/);
if (vb) {
const p = vb[1].trim().split(/\s+/).map(Number);
if (p.length === 4 && p.every((n) => !Number.isNaN(n))) return { w: p[2], h: p[3] };
}
return { w: 0, h: 0 };
}
function zoomBy(factor: number) {
zoom = Math.min(6, Math.max(0.1, zoom * factor));
}
/** Scale the diagram so it fits within the visible canvas. */
function fit() {
if (!canvasEl || !svg) return;
const { w, h } = svgDims(svg);
if (!w || !h) return;
const margin = store.theme !== 'dark' ? 72 : 32;
const cw = canvasEl.clientWidth - margin;
const ch = canvasEl.clientHeight - margin;
if (cw <= 0 || ch <= 0) return;
zoom = Math.max(0.1, Math.min(cw / w, ch / h, 3));
pan = { x: 0, y: 0 };
}
function reset() {
zoom = 1;
pan = { x: 0, y: 0 };
}
// Drag-to-pan.
let dragging = $state(false);
let last = { x: 0, y: 0 };
function onDown(e: MouseEvent) {
dragging = true;
last = { x: e.clientX, y: e.clientY };
}
function onMove(e: MouseEvent) {
if (!dragging) return;
pan = { x: pan.x + (e.clientX - last.x), y: pan.y + (e.clientY - last.y) };
last = { x: e.clientX, y: e.clientY };
}
function onUp() {
dragging = false;
}
function onWheel(e: WheelEvent) {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
zoomBy(e.deltaY < 0 ? 1.1 : 0.9);
}
}
</script>
<div class="preview">
<div class="ctrls">
{#if rendering}<span class="dot" title="Rendering…"></span>{/if}
<button class="ghost icon" title="Zoom out" onclick={() => zoomBy(0.83)}></button>
<button class="ghost zoom" title="Reset to 100%" onclick={reset}
>{Math.round(zoom * 100)}%</button
>
<button class="ghost icon" title="Zoom in" onclick={() => zoomBy(1.2)}>+</button>
<button class="ghost icon" title="Fit to view" onclick={fit}>⤢</button>
</div>
<div
bind:this={canvasEl}
class="canvas"
class:grab={!dragging}
class:grabbing={dragging}
onmousedown={onDown}
onmousemove={onMove}
onmouseup={onUp}
onmouseleave={onUp}
onwheel={onWheel}
role="presentation"
>
{#if error}
<div class="error">
<div class="error-title">Diagram error</div>
<pre>{error}</pre>
</div>
{:else if svg}
<div
class="stage"
class:paper={store.theme !== 'dark'}
style="transform: translate({pan.x}px, {pan.y}px) scale({zoom});"
>
{@html svg}
</div>
{:else}
<div class="empty faint">Start typing to see your diagram.</div>
{/if}
</div>
</div>
<style>
.preview {
position: relative;
height: 100%;
background:
radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.05) 1px, transparent 0) 0 0 /
22px 22px,
var(--bg);
overflow: hidden;
}
.ctrls {
position: absolute;
top: 10px;
right: 12px;
display: flex;
align-items: center;
gap: 4px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 3px 5px;
z-index: 5;
}
.zoom {
font-size: 12px;
color: var(--text-dim);
min-width: 38px;
text-align: center;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
margin-right: 4px;
animation: pulse 1s infinite;
}
@keyframes pulse {
50% {
opacity: 0.3;
}
}
.canvas {
height: 100%;
display: grid;
place-items: center;
overflow: hidden;
}
.canvas.grab {
cursor: grab;
}
.canvas.grabbing {
cursor: grabbing;
}
.stage {
transform-origin: center center;
transition: transform 0.05s linear;
}
/* Light Mermaid themes are designed for a white page, so give them a
"paper" sheet; the dark theme renders directly on the dark canvas. */
.stage.paper {
background: #ffffff;
padding: 28px;
border-radius: 10px;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.45);
}
.stage :global(svg) {
display: block;
max-width: none;
height: auto;
}
.empty {
font-size: 14px;
}
.error {
max-width: 80%;
background: rgba(248, 81, 73, 0.08);
border: 1px solid rgba(248, 81, 73, 0.4);
border-radius: var(--radius);
padding: 16px 18px;
color: var(--red);
}
.error-title {
font-weight: 600;
margin-bottom: 8px;
}
.error pre {
margin: 0;
white-space: pre-wrap;
font-family: var(--mono);
font-size: 12px;
color: #ffb4ae;
}
</style>

View File

@@ -0,0 +1,213 @@
<script lang="ts">
import { store } from '../store.svelte';
import Modal from './Modal.svelte';
type Dialog =
| { mode: 'new'; value: string }
| { mode: 'rename'; rel: string; value: string }
| null;
let dialog = $state<Dialog>(null);
function openNew() {
dialog = { mode: 'new', value: '' };
}
function openRename(rel: string, current: string) {
dialog = { mode: 'rename', rel, value: current };
}
async function submit() {
if (!dialog || !dialog.value.trim()) return;
const value = dialog.value.trim();
if (dialog.mode === 'new') {
await store.createDiagram(value);
} else {
await store.renameDiagram(dialog.rel, value);
}
dialog = null;
}
async function confirmDelete(rel: string, name: string) {
if (confirm(`Delete diagram "${name}"? This cannot be undone.`)) {
await store.deleteDiagram(rel);
}
}
</script>
<aside class="sidebar">
<div class="project-head">
<div class="proj-name" title={store.project?.config.project.name}>
{store.project?.config.project.name}
</div>
<button class="ghost icon" title="Close project" onclick={() => store.closeProject()}>
</button>
</div>
<div class="section-head">
<span>Diagrams</span>
<button class="ghost icon" title="New diagram" onclick={openNew}></button>
</div>
<div class="list">
{#each store.diagrams as d (d.id)}
<div
class="item"
class:active={store.activeId === d.id}
onclick={() => store.selectDiagram(d.id)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && store.selectDiagram(d.id)}
>
<span class="glyph"></span>
<span class="label" title={d.name}>{d.name}</span>
<span class="row-actions">
<button
class="ghost icon mini"
title="Rename"
onclick={(e) => {
e.stopPropagation();
openRename(d.id, d.name);
}}>✎</button
>
<button
class="ghost icon mini danger"
title="Delete"
onclick={(e) => {
e.stopPropagation();
confirmDelete(d.id, d.name);
}}>🗑</button
>
</span>
</div>
{/each}
{#if store.diagrams.length === 0}
<div class="empty faint">No diagrams yet.<br />Click to add one.</div>
{/if}
</div>
</aside>
{#if dialog}
<Modal
title={dialog.mode === 'new' ? 'New diagram' : 'Rename diagram'}
onClose={() => (dialog = null)}
>
<label class="fld">
<span>Title</span>
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
bind:value={dialog.value}
placeholder="e.g. Login flow"
onkeydown={(e) => e.key === 'Enter' && submit()}
/>
</label>
<div class="actions">
<button onclick={() => (dialog = null)}>Cancel</button>
<button class="primary" onclick={submit} disabled={!dialog.value.trim()}>
{dialog.mode === 'new' ? 'Create' : 'Rename'}
</button>
</div>
</Modal>
{/if}
<style>
.sidebar {
width: 248px;
flex-shrink: 0;
background: var(--bg-elev);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.project-head {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 12px 10px;
border-bottom: 1px solid var(--border);
}
.proj-name {
font-weight: 600;
font-size: 14px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 12px 6px;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-faint);
}
.list {
flex: 1;
overflow-y: auto;
padding: 2px 6px 10px;
}
.item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-dim);
}
.item:hover {
background: var(--bg-elev-2);
color: var(--text);
}
.item.active {
background: var(--accent-soft);
color: var(--text);
}
.glyph {
color: var(--accent);
font-size: 9px;
}
.label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.row-actions {
display: none;
gap: 2px;
}
.item:hover .row-actions {
display: flex;
}
.mini {
padding: 2px 5px;
font-size: 11px;
}
.empty {
text-align: center;
padding: 24px 10px;
font-size: 12px;
line-height: 1.6;
}
.fld {
display: flex;
flex-direction: column;
gap: 6px;
}
.fld span {
font-size: 12px;
color: var(--text-dim);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,251 @@
<script lang="ts">
import { open } from '@tauri-apps/plugin-dialog';
import { store } from '../store.svelte';
import Modal from './Modal.svelte';
let showNew = $state(false);
let name = $state('');
let description = $state('');
let location = $state('');
async function pickLocation() {
const dir = await open({ directory: true, multiple: false, title: 'Choose a location' });
if (typeof dir === 'string') location = dir;
}
async function pickAndOpen() {
const dir = await open({ directory: true, multiple: false, title: 'Open Mermix project' });
if (typeof dir === 'string') await store.openProject(dir);
}
async function create() {
if (!name.trim() || !location) return;
await store.createProject(location, name.trim(), description.trim());
showNew = false;
name = '';
description = '';
location = '';
}
function ago(iso: string | null): string {
if (!iso) return '';
return new Date(iso).toLocaleString();
}
$effect(() => {
void store.loadProjects();
});
</script>
<div class="start">
<div class="hero">
<h1>Mermix</h1>
<p class="muted">Mermaid diagram editor &amp; viewer with Git-backed projects.</p>
<div class="cta">
<button class="primary" onclick={() => (showNew = true)}> New Project</button>
<button onclick={pickAndOpen}>Open Project…</button>
</div>
</div>
<div class="recent">
<div class="recent-head">Recent projects</div>
{#if store.projects.length === 0}
<div class="empty faint">
No projects yet. Create one to get started — it becomes a Git repository on disk.
</div>
{:else}
<div class="grid">
{#each store.projects as p (p.id)}
<div
class="card"
role="button"
tabindex="0"
onclick={() => store.openProject(p.path)}
onkeydown={(e) => e.key === 'Enter' && store.openProject(p.path)}
>
<div class="card-top">
<span class="card-name">{p.name}</span>
<button
class="ghost icon mini"
title="Remove from list"
onclick={(e) => {
e.stopPropagation();
store.forgetProject(p.id);
}}>✕</button
>
</div>
<div class="card-path faint" title={p.path}>{p.path}</div>
<div class="card-time faint">Opened {ago(p.last_opened_at)}</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{#if showNew}
<Modal title="New project" onClose={() => (showNew = false)}>
<label class="fld">
<span>Name</span>
<!-- svelte-ignore a11y_autofocus -->
<input autofocus bind:value={name} placeholder="My diagrams" />
</label>
<label class="fld">
<span>Description <em class="faint">(optional)</em></span>
<input bind:value={description} placeholder="What this project is about" />
</label>
<label class="fld">
<span>Location</span>
<div class="pick">
<input value={location} readonly placeholder="Choose a parent folder…" />
<button onclick={pickLocation}>Browse…</button>
</div>
{#if location && name.trim()}
<small class="faint">Creates <code>{location}/{slug(name)}</code></small>
{/if}
</label>
<div class="actions">
<button onclick={() => (showNew = false)}>Cancel</button>
<button class="primary" onclick={create} disabled={!name.trim() || !location || store.busy}>
{store.busy ? 'Creating…' : 'Create'}
</button>
</div>
</Modal>
{/if}
<script lang="ts" module>
function slug(s: string): string {
return (
s
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'untitled'
);
}
</script>
<style>
.start {
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 24px 48px;
}
.hero {
text-align: center;
padding: 72px 0 40px;
max-width: 560px;
}
h1 {
font-size: 44px;
margin: 0 0 8px;
background: linear-gradient(120deg, #c4b5fd, #7c5cff);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero p {
font-size: 15px;
margin: 0 0 24px;
}
.cta {
display: flex;
gap: 10px;
justify-content: center;
}
.cta button {
padding: 9px 18px;
font-size: 14px;
}
.recent {
width: 100%;
max-width: 880px;
}
.recent-head {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: 12px;
}
.empty {
padding: 32px;
text-align: center;
border: 1px dashed var(--border);
border-radius: var(--radius);
font-size: 13px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
cursor: pointer;
transition: border-color 0.12s ease, transform 0.12s ease;
}
.card:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.card-name {
font-weight: 600;
font-size: 15px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-path {
font-size: 11px;
margin-top: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-time {
font-size: 11px;
margin-top: 8px;
}
.mini {
padding: 2px 6px;
font-size: 11px;
}
.fld {
display: flex;
flex-direction: column;
gap: 6px;
}
.fld > span {
font-size: 12px;
color: var(--text-dim);
}
.pick {
display: flex;
gap: 8px;
}
.pick button {
flex-shrink: 0;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
code {
font-family: var(--mono);
font-size: 11px;
}
</style>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { store } from '../store.svelte';
</script>
<div class="toasts">
{#each store.toasts as t (t.id)}
<div class="toast {t.kind}">{t.msg}</div>
{/each}
</div>
<style>
.toasts {
position: fixed;
bottom: 18px;
right: 18px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 1000;
pointer-events: none;
}
.toast {
background: var(--bg-elev-2);
border: 1px solid var(--border-strong);
border-left: 3px solid var(--accent);
border-radius: var(--radius-sm);
padding: 10px 14px;
font-size: 13px;
max-width: 340px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.4);
animation: slide 0.18s ease;
}
.toast.success {
border-left-color: var(--green);
}
.toast.error {
border-left-color: var(--red);
}
@keyframes slide {
from {
opacity: 0;
transform: translateY(8px);
}
}
</style>

View File

@@ -0,0 +1,162 @@
<script lang="ts">
import { store } from '../store.svelte';
import { exportSvg, exportPng } from '../export';
import type { MermaidTheme } from '../types';
const themes: MermaidTheme[] = ['default', 'neutral', 'dark', 'forest', 'base'];
async function doExport(kind: 'svg' | 'png') {
if (!store.lastSvg) {
store.notify('Nothing to export yet', 'error');
return;
}
const name = store.activeName || 'diagram';
try {
const ok =
kind === 'svg'
? await exportSvg(store.lastSvg, name)
: await exportPng(store.lastSvg, name);
if (ok) store.notify(`Exported ${kind.toUpperCase()}`, 'success');
} catch (e) {
store.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
</script>
<header class="toolbar">
<div class="left">
<span class="logo">Mermix</span>
<span class="divider"></span>
{#if store.activeId}
<span class="active-name">{store.activeName}</span>
{#if store.dirty}<span class="dirty" title="Unsaved changes"></span>{/if}
{:else}
<span class="faint">No diagram selected</span>
{/if}
</div>
<div class="right">
<div class="seg" role="group" aria-label="Layout">
<button class:on={store.layout === 'code'} title="Code only" onclick={() => (store.layout = 'code')}>Code</button>
<button class:on={store.layout === 'split'} title="Split view" onclick={() => (store.layout = 'split')}>Split</button>
<button class:on={store.layout === 'preview'} title="Preview only" onclick={() => (store.layout = 'preview')}>Preview</button>
</div>
<span class="divider"></span>
<button onclick={() => store.save()} disabled={!store.activeId || !store.dirty}>
Save <kbd>⌘S</kbd>
</button>
<span class="divider"></span>
<label class="theme">
<span class="faint">Theme</span>
<select
value={store.theme}
onchange={(e) => store.setTheme((e.target as HTMLSelectElement).value)}
>
{#each themes as t}<option value={t}>{t}</option>{/each}
</select>
</label>
<div class="export">
<button onclick={() => doExport('svg')} disabled={!store.lastSvg}>Export SVG</button>
<button onclick={() => doExport('png')} disabled={!store.lastSvg}>PNG</button>
</div>
<button
class="ghost icon"
class:on={store.showGit}
title="Toggle Git panel"
onclick={() => (store.showGit = !store.showGit)}>⎇</button
>
</div>
</header>
<style>
.toolbar {
height: 46px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
}
.left,
.right {
display: flex;
align-items: center;
gap: 10px;
}
.logo {
font-weight: 700;
letter-spacing: 0.01em;
background: linear-gradient(120deg, #a78bfa, #7c5cff);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.divider {
width: 1px;
height: 20px;
background: var(--border);
}
.active-name {
font-weight: 500;
}
.dirty {
color: var(--amber);
font-size: 11px;
}
.theme {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.theme select {
width: auto;
padding: 5px 8px;
}
.export {
display: flex;
gap: 4px;
}
kbd {
font-family: var(--mono);
font-size: 10px;
color: var(--text-faint);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1px 4px;
margin-left: 2px;
}
.icon.on {
color: var(--accent);
background: var(--accent-soft);
}
.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: 6px 11px;
font-size: 12px;
color: var(--text-dim);
}
.seg button:hover {
background: var(--bg-elev-2);
}
.seg button.on {
background: var(--accent-soft);
color: var(--text);
}
</style>

75
src/lib/export.ts Normal file
View File

@@ -0,0 +1,75 @@
import { save } from '@tauri-apps/plugin-dialog';
import { api } from './api';
function svgDimensions(svg: string): { w: number; h: number } {
const vb = svg.match(/viewBox="([\d.\s-]+)"/);
if (vb) {
const parts = vb[1].trim().split(/\s+/).map(Number);
if (parts.length === 4 && parts.every((n) => !Number.isNaN(n))) {
return { w: parts[2], h: parts[3] };
}
}
return { w: 1024, h: 768 };
}
/** Ensure the SVG root has explicit pixel width/height so it rasterizes. */
function withExplicitSize(svg: string, w: number, h: number): string {
return svg
.replace(/(<svg[^>]*?)\swidth="[^"]*"/, '$1')
.replace(/(<svg[^>]*?)\sheight="[^"]*"/, '$1')
.replace(/<svg/, `<svg width="${w}" height="${h}"`);
}
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Failed to rasterize SVG'));
img.src = url;
});
}
async function svgToPng(svg: string, scale: number): Promise<Uint8Array> {
const { w, h } = svgDimensions(svg);
const sized = withExplicitSize(svg, w, h);
const blob = new Blob([sized], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
try {
const img = await loadImage(url);
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(w * scale));
canvas.height = Math.max(1, Math.round(h * scale));
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D context unavailable');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const pngBlob: Blob = await new Promise((res, rej) =>
canvas.toBlob((b) => (b ? res(b) : rej(new Error('PNG encode failed'))), 'image/png')
);
return new Uint8Array(await pngBlob.arrayBuffer());
} finally {
URL.revokeObjectURL(url);
}
}
export async function exportSvg(svg: string, name: string): Promise<boolean> {
const path = await save({
defaultPath: `${name}.svg`,
filters: [{ name: 'SVG image', extensions: ['svg'] }],
});
if (!path) return false;
await api.writeTextFile(path, svg);
return true;
}
export async function exportPng(svg: string, name: string, scale = 2): Promise<boolean> {
const path = await save({
defaultPath: `${name}.png`,
filters: [{ name: 'PNG image', extensions: ['png'] }],
});
if (!path) return false;
const bytes = await svgToPng(svg, scale);
await api.writeBinaryFile(path, Array.from(bytes));
return true;
}

35
src/lib/mermaid.ts Normal file
View File

@@ -0,0 +1,35 @@
import mermaid from 'mermaid';
let currentTheme = '';
function configure(theme: string) {
if (theme === currentTheme) return;
currentTheme = theme;
mermaid.initialize({
startOnLoad: false,
theme: theme as any,
securityLevel: 'loose',
fontFamily: 'inherit',
flowchart: { useMaxWidth: true, htmlLabels: true },
});
}
let renderSeq = 0;
export type RenderResult = { svg: string } | { error: string };
/** Render Mermaid source to an SVG string, capturing syntax errors. */
export async function renderMermaid(code: string, theme: string): Promise<RenderResult> {
configure(theme);
if (!code.trim()) return { svg: '' };
const id = `mmx-render-${++renderSeq}`;
try {
const { svg } = await mermaid.render(id, code);
return { svg };
} catch (e) {
return { error: e instanceof Error ? e.message : String(e) };
} finally {
// Mermaid may leave a stray measurement node behind on failure.
document.getElementById(`d${id}`)?.remove();
}
}

295
src/lib/store.svelte.ts Normal file
View File

@@ -0,0 +1,295 @@
import { api } from './api';
import type {
BranchInfo,
CommitInfo,
DiagramInfo,
FileStatus,
OpenedProject,
ProjectRecord,
} from './types';
export type Toast = { id: number; msg: string; kind: 'info' | 'error' | 'success' };
/**
* Central application state. A single instance (`store`) is shared across all
* components; its `$state` fields drive Svelte 5 reactivity.
*/
class Store {
// Registry / navigation
projects = $state<ProjectRecord[]>([]);
project = $state<OpenedProject | null>(null);
view = $state<'start' | 'editor'>('start');
// Diagrams
diagrams = $state<DiagramInfo[]>([]);
activeId = $state<string | null>(null);
content = $state('');
savedContent = $state('');
// Git
branch = $state('main');
branches = $state<BranchInfo[]>([]);
history = $state<CommitInfo[]>([]);
changes = $state<FileStatus[]>([]);
// UI
theme = $state('default');
busy = $state(false);
toasts = $state<Toast[]>([]);
/** Most recent successfully rendered SVG, used for export. */
lastSvg = $state('');
/** Whether the Git side panel is visible. */
showGit = $state(true);
/** Workspace layout: editor only, side-by-side, or preview only. */
layout = $state<'code' | 'split' | 'preview'>('split');
/** Quick-edit drawer over the preview (viewer mode). */
editDrawer = $state(false);
private toastSeq = 0;
get dirty(): boolean {
return this.content !== this.savedContent;
}
get path(): string | null {
return this.project?.record.path ?? null;
}
get activeName(): string {
return this.diagrams.find((d) => d.id === this.activeId)?.name ?? '';
}
notify(msg: string, kind: Toast['kind'] = 'info') {
const id = ++this.toastSeq;
this.toasts = [...this.toasts, { id, msg, kind }];
setTimeout(() => {
this.toasts = this.toasts.filter((t) => t.id !== id);
}, kind === 'error' ? 6000 : 3000);
}
private fail(err: unknown) {
const msg = typeof err === 'string' ? err : err instanceof Error ? err.message : String(err);
this.notify(msg, 'error');
}
async loadProjects() {
try {
this.projects = await api.listProjects();
} catch (e) {
this.fail(e);
}
}
async createProject(parentDir: string, name: string, description: string) {
this.busy = true;
try {
const opened = await api.createProject(parentDir, name, description);
await this.adopt(opened);
await this.loadProjects();
this.notify(`Created project "${opened.config.project.name}"`, 'success');
} catch (e) {
this.fail(e);
} finally {
this.busy = false;
}
}
async openProject(path: string) {
this.busy = true;
try {
const opened = await api.openProject(path);
await this.adopt(opened);
await this.loadProjects();
} catch (e) {
this.fail(e);
} finally {
this.busy = false;
}
}
async forgetProject(id: string) {
try {
await api.forgetProject(id);
await this.loadProjects();
} catch (e) {
this.fail(e);
}
}
closeProject() {
this.project = null;
this.diagrams = [];
this.activeId = null;
this.content = '';
this.savedContent = '';
this.history = [];
this.branches = [];
this.changes = [];
this.view = 'start';
}
private async adopt(opened: OpenedProject) {
this.project = opened;
this.diagrams = opened.diagrams;
this.branch = opened.branch;
this.theme = opened.config.defaults.theme || 'default';
this.view = 'editor';
await this.refreshGit();
if (this.diagrams.length > 0) {
await this.selectDiagram(this.diagrams[0].id);
} else {
this.activeId = null;
this.content = '';
this.savedContent = '';
}
}
async selectDiagram(rel: string) {
if (!this.path) return;
if (this.dirty) await this.save(true);
try {
const content = await api.readDiagram(this.path, rel);
this.activeId = rel;
this.content = content;
this.savedContent = content;
} catch (e) {
this.fail(e);
}
}
async createDiagram(name: string) {
if (!this.path) return;
try {
const info = await api.createDiagram(this.path, name);
this.diagrams = await api.listDiagrams(this.path);
await this.selectDiagram(info.id);
await this.refreshGit();
} catch (e) {
this.fail(e);
}
}
async renameDiagram(rel: string, newName: string) {
if (!this.path) return;
try {
const info = await api.renameDiagram(this.path, rel, newName);
this.diagrams = await api.listDiagrams(this.path);
if (this.activeId === rel) this.activeId = info.id;
await this.refreshGit();
} catch (e) {
this.fail(e);
}
}
async deleteDiagram(rel: string) {
if (!this.path) return;
try {
await api.deleteDiagram(this.path, rel);
this.diagrams = await api.listDiagrams(this.path);
if (this.activeId === rel) {
if (this.diagrams.length > 0) {
await this.selectDiagram(this.diagrams[0].id);
} else {
this.activeId = null;
this.content = '';
this.savedContent = '';
}
}
await this.refreshGit();
} catch (e) {
this.fail(e);
}
}
async save(silent = false) {
if (!this.path || !this.activeId) return;
try {
await api.saveDiagram(this.path, this.activeId, this.content);
this.savedContent = this.content;
// Refresh the title if the frontmatter changed.
this.diagrams = await api.listDiagrams(this.path);
await this.refreshStatus();
if (!silent) this.notify('Saved', 'success');
} catch (e) {
this.fail(e);
}
}
async refreshStatus() {
if (!this.path) return;
try {
this.changes = await api.gitStatus(this.path);
} catch (e) {
this.fail(e);
}
}
async refreshGit() {
if (!this.path) return;
try {
[this.branch, this.branches, this.history, this.changes] = await Promise.all([
api.gitCurrentBranch(this.path),
api.gitBranches(this.path),
api.gitHistory(this.path, 100),
api.gitStatus(this.path),
]);
} catch (e) {
this.fail(e);
}
}
async commit(message: string) {
if (!this.path) return;
if (this.dirty) await this.save(true);
try {
await api.gitCommit(this.path, message);
await this.refreshGit();
this.notify('Committed changes', 'success');
} catch (e) {
this.fail(e);
}
}
async createBranch(name: string) {
if (!this.path) return;
try {
await api.gitCreateBranch(this.path, name);
await this.refreshGit();
this.notify(`Branch "${name}" created`, 'success');
} catch (e) {
this.fail(e);
}
}
async checkoutBranch(name: string) {
if (!this.path) return;
if (this.dirty) await this.save(true);
try {
await api.gitCheckoutBranch(this.path, name);
this.diagrams = await api.listDiagrams(this.path);
await this.refreshGit();
// Reload the active diagram content from the checked-out branch.
if (this.activeId && this.diagrams.some((d) => d.id === this.activeId)) {
const content = await api.readDiagram(this.path, this.activeId);
this.content = content;
this.savedContent = content;
} else if (this.diagrams.length > 0) {
await this.selectDiagram(this.diagrams[0].id);
}
this.notify(`Switched to "${name}"`, 'success');
} catch (e) {
this.fail(e);
}
}
async setTheme(theme: string) {
this.theme = theme;
try {
await api.setSetting('theme', theme);
} catch {
/* non-fatal */
}
}
}
export const store = new Store();

59
src/lib/types.ts Normal file
View File

@@ -0,0 +1,59 @@
// TypeScript mirrors of the Rust structs returned by Tauri commands.
export interface ProjectRecord {
id: string;
name: string;
path: string;
created_at: string;
last_opened_at: string | null;
}
export interface ProjectMeta {
id: string;
name: string;
description: string;
created_at: string;
}
export interface ProjectDefaults {
theme: string;
}
export interface ProjectConfig {
project: ProjectMeta;
defaults: ProjectDefaults;
}
export interface DiagramInfo {
id: string;
name: string;
updated_at: string;
}
export interface OpenedProject {
record: ProjectRecord;
config: ProjectConfig;
diagrams: DiagramInfo[];
branch: string;
}
export interface CommitInfo {
id: string;
short_id: string;
message: string;
author: string;
email: string;
time: string;
}
export interface BranchInfo {
name: string;
is_head: boolean;
}
export interface FileStatus {
path: string;
status: string;
}
export type MermaidTheme = 'default' | 'neutral' | 'dark' | 'forest' | 'base';

9
src/main.ts Normal file
View File

@@ -0,0 +1,9 @@
import { mount } from 'svelte';
import './app.css';
import App from './App.svelte';
const app = mount(App, {
target: document.getElementById('app')!,
});
export default app;

2
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />