diff --git a/src/App.svelte b/src/App.svelte new file mode 100644 index 0000000..25598f9 --- /dev/null +++ b/src/App.svelte @@ -0,0 +1,235 @@ + + + + +{#if store.view === 'start'} + +{:else} +
+ +
+ + {#if store.activeId} +
+ {#if store.layout !== 'preview'} +
+ +
+ {/if} + {#if store.layout === 'split'} + + + {/if} + {#if store.layout !== 'code'} +
+ +
+ {/if} + + {#if store.layout === 'preview'} + {#if store.editDrawer} +
+
+ ✎ {store.activeName} +
+ + +
+
+
+ +
+
+ {:else} + + {/if} + {/if} +
+ {:else} +
+
No diagram open.
+
Pick one from the sidebar, or create a new diagram with +.
+
+ {/if} + {#if store.showGit} + + {/if} +
+
+{/if} + + + + diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..a03568c --- /dev/null +++ b/src/app.css @@ -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); +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..14e8cf1 --- /dev/null +++ b/src/lib/api.ts @@ -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('list_projects'), + + createProject: (parentDir: string, name: string, description: string) => + invoke('create_project', { parentDir, name, description }), + + openProject: (path: string) => invoke('open_project', { path }), + + forgetProject: (id: string) => invoke('forget_project', { id }), + + // ---- Diagrams ------------------------------------------------------- + listDiagrams: (path: string) => invoke('list_diagrams', { path }), + + readDiagram: (path: string, rel: string) => + invoke('read_diagram', { path, rel }), + + saveDiagram: (path: string, rel: string, content: string) => + invoke('save_diagram', { path, rel, content }), + + createDiagram: (path: string, name: string) => + invoke('create_diagram', { path, name }), + + renameDiagram: (path: string, rel: string, newName: string) => + invoke('rename_diagram', { path, rel, newName }), + + deleteDiagram: (path: string, rel: string) => + invoke('delete_diagram', { path, rel }), + + // ---- Git ------------------------------------------------------------ + gitStatus: (path: string) => invoke('git_status', { path }), + + gitCommit: (path: string, message: string) => + invoke('git_commit', { path, message }), + + gitHistory: (path: string, limit?: number) => + invoke('git_history', { path, limit }), + + gitBranches: (path: string) => invoke('git_branches', { path }), + + gitCurrentBranch: (path: string) => + invoke('git_current_branch', { path }), + + gitCreateBranch: (path: string, name: string) => + invoke('git_create_branch', { path, name }), + + gitCheckoutBranch: (path: string, name: string) => + invoke('git_checkout_branch', { path, name }), + + // ---- Export & settings --------------------------------------------- + writeTextFile: (path: string, contents: string) => + invoke('write_text_file', { path, contents }), + + writeBinaryFile: (path: string, contents: number[]) => + invoke('write_binary_file', { path, contents }), + + getSetting: (key: string) => invoke('get_setting', { key }), + + setSetting: (key: string, value: string) => + invoke('set_setting', { key, value }), +}; diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte new file mode 100644 index 0000000..8d7e78f --- /dev/null +++ b/src/lib/components/Editor.svelte @@ -0,0 +1,107 @@ + + +
+ + diff --git a/src/lib/components/GitPanel.svelte b/src/lib/components/GitPanel.svelte new file mode 100644 index 0000000..5557cde --- /dev/null +++ b/src/lib/components/GitPanel.svelte @@ -0,0 +1,268 @@ + + + + +{#if newBranch !== null} + (newBranch = null)}> + +
+ + +
+
+{/if} + + diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte new file mode 100644 index 0000000..8ce10b8 --- /dev/null +++ b/src/lib/components/Modal.svelte @@ -0,0 +1,73 @@ + + + + + + + diff --git a/src/lib/components/Preview.svelte b/src/lib/components/Preview.svelte new file mode 100644 index 0000000..85fa2eb --- /dev/null +++ b/src/lib/components/Preview.svelte @@ -0,0 +1,239 @@ + + +
+
+ {#if rendering}{/if} + + + + +
+ + +
+ + diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte new file mode 100644 index 0000000..688daa2 --- /dev/null +++ b/src/lib/components/Sidebar.svelte @@ -0,0 +1,213 @@ + + + + +{#if dialog} + (dialog = null)} + > + +
+ + +
+
+{/if} + + diff --git a/src/lib/components/StartScreen.svelte b/src/lib/components/StartScreen.svelte new file mode 100644 index 0000000..acebf90 --- /dev/null +++ b/src/lib/components/StartScreen.svelte @@ -0,0 +1,251 @@ + + +
+
+

Mermix

+

Mermaid diagram editor & viewer with Git-backed projects.

+
+ + +
+
+ +
+
Recent projects
+ {#if store.projects.length === 0} +
+ No projects yet. Create one to get started — it becomes a Git repository on disk. +
+ {:else} +
+ {#each store.projects as p (p.id)} +
store.openProject(p.path)} + onkeydown={(e) => e.key === 'Enter' && store.openProject(p.path)} + > +
+ {p.name} + +
+
{p.path}
+
Opened {ago(p.last_opened_at)}
+
+ {/each} +
+ {/if} +
+
+ +{#if showNew} + (showNew = false)}> + + + +
+ + +
+
+{/if} + + + + diff --git a/src/lib/components/Toasts.svelte b/src/lib/components/Toasts.svelte new file mode 100644 index 0000000..3bf3e0e --- /dev/null +++ b/src/lib/components/Toasts.svelte @@ -0,0 +1,45 @@ + + +
+ {#each store.toasts as t (t.id)} +
{t.msg}
+ {/each} +
+ + diff --git a/src/lib/components/Toolbar.svelte b/src/lib/components/Toolbar.svelte new file mode 100644 index 0000000..2bd19f3 --- /dev/null +++ b/src/lib/components/Toolbar.svelte @@ -0,0 +1,162 @@ + + +
+
+ + + {#if store.activeId} + {store.activeName} + {#if store.dirty}{/if} + {:else} + No diagram selected + {/if} +
+ +
+
+ + + +
+ + + + + + + + + +
+ + +
+ + +
+
+ + diff --git a/src/lib/export.ts b/src/lib/export.ts new file mode 100644 index 0000000..967a9d1 --- /dev/null +++ b/src/lib/export.ts @@ -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(/(]*?)\swidth="[^"]*"/, '$1') + .replace(/(]*?)\sheight="[^"]*"/, '$1') + .replace(/ { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/lib/mermaid.ts b/src/lib/mermaid.ts new file mode 100644 index 0000000..e237cbb --- /dev/null +++ b/src/lib/mermaid.ts @@ -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 { + 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(); + } +} diff --git a/src/lib/store.svelte.ts b/src/lib/store.svelte.ts new file mode 100644 index 0000000..fe463b1 --- /dev/null +++ b/src/lib/store.svelte.ts @@ -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([]); + project = $state(null); + view = $state<'start' | 'editor'>('start'); + + // Diagrams + diagrams = $state([]); + activeId = $state(null); + content = $state(''); + savedContent = $state(''); + + // Git + branch = $state('main'); + branches = $state([]); + history = $state([]); + changes = $state([]); + + // UI + theme = $state('default'); + busy = $state(false); + toasts = $state([]); + /** 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(); diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..d0c25b2 --- /dev/null +++ b/src/lib/types.ts @@ -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'; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..c049299 --- /dev/null +++ b/src/main.ts @@ -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; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..4078e74 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +///