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:
235
src/App.svelte
Normal file
235
src/App.svelte
Normal 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
135
src/app.css
Normal 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
74
src/lib/api.ts
Normal 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 }),
|
||||
};
|
||||
107
src/lib/components/Editor.svelte
Normal file
107
src/lib/components/Editor.svelte
Normal 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>
|
||||
268
src/lib/components/GitPanel.svelte
Normal file
268
src/lib/components/GitPanel.svelte
Normal 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 & 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>
|
||||
73
src/lib/components/Modal.svelte
Normal file
73
src/lib/components/Modal.svelte
Normal 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>
|
||||
239
src/lib/components/Preview.svelte
Normal file
239
src/lib/components/Preview.svelte
Normal 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>
|
||||
213
src/lib/components/Sidebar.svelte
Normal file
213
src/lib/components/Sidebar.svelte
Normal 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>
|
||||
251
src/lib/components/StartScreen.svelte
Normal file
251
src/lib/components/StartScreen.svelte
Normal 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 & 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>
|
||||
45
src/lib/components/Toasts.svelte
Normal file
45
src/lib/components/Toasts.svelte
Normal 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>
|
||||
162
src/lib/components/Toolbar.svelte
Normal file
162
src/lib/components/Toolbar.svelte
Normal 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
75
src/lib/export.ts
Normal 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
35
src/lib/mermaid.ts
Normal 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
295
src/lib/store.svelte.ts
Normal 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
59
src/lib/types.ts
Normal 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
9
src/main.ts
Normal 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
2
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user