Compact editor UI, move LSP info to settings, redesign status badges

Claude-Session: https://claude.ai/code/session_01PoLmSR1pFVyHf8i5b1rNWk
This commit is contained in:
2026-07-21 15:56:40 -04:00
parent a16edeb876
commit 5f6b2b3883
3 changed files with 210 additions and 73 deletions
+6 -20
View File
@@ -5,9 +5,7 @@
import { import {
insertText, insertText,
prefixLines, prefixLines,
redoEdit,
setTypstConfig, setTypstConfig,
undoEdit,
wrapSelection, wrapSelection,
} from "$lib/ts/editor-actions"; } from "$lib/ts/editor-actions";
import { app } from "$lib/ts/state.svelte"; import { app } from "$lib/ts/state.svelte";
@@ -37,10 +35,10 @@
icon: string, icon: string,
label: string, label: string,
run: () => void, run: () => void,
size = "text-base", size = "text-sm",
)} )}
<button <button
class="rounded p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] disabled:opacity-40 disabled:hover:bg-transparent" class="rounded p-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] disabled:opacity-40 disabled:hover:bg-transparent"
title={label} title={label}
aria-label={label} aria-label={label}
{disabled} {disabled}
@@ -51,17 +49,12 @@
{/snippet} {/snippet}
{#snippet divider()} {#snippet divider()}
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--color-line)]"></div> <div class="mx-0.5 h-3.5 w-px shrink-0 bg-[var(--color-line)]"></div>
{/snippet} {/snippet}
<div <div
class="scroll-thin flex shrink-0 items-center gap-0.5 overflow-x-auto border-b border-[var(--color-line)] bg-[var(--color-surface)] px-2 py-1" class="scroll-thin flex shrink-0 items-center gap-px overflow-x-auto border-b border-[var(--color-line)] bg-[var(--color-surface)] px-1.5 py-0.5"
> >
{@render action("ph:arrow-counter-clockwise", "Undo", () => undoEdit(view))}
{@render action("ph:arrow-clockwise", "Redo", () => redoEdit(view))}
{@render divider()}
{@render action("ph:text-h", "Heading", () => prefixLines(view, "= ", "Heading"))} {@render action("ph:text-h", "Heading", () => prefixLines(view, "= ", "Heading"))}
{@render action("ph:text-b", "Bold", () => wrapSelection(view, "*", "*", "bold"))} {@render action("ph:text-b", "Bold", () => wrapSelection(view, "*", "*", "bold"))}
{@render action("ph:text-italic", "Italic", () => {@render action("ph:text-italic", "Italic", () =>
@@ -103,7 +96,7 @@
{@render divider()} {@render divider()}
<select <select
class="max-w-36 rounded border border-[var(--color-line)] bg-[var(--color-surface)] px-1.5 py-1 text-xs text-[var(--color-ink)] focus:border-[var(--color-accent)] focus:outline-none disabled:opacity-40" class="max-w-24 rounded border border-[var(--color-line)] bg-[var(--color-surface)] px-1 py-0.5 text-xs text-[var(--color-ink)] focus:border-[var(--color-accent)] focus:outline-none disabled:opacity-40"
aria-label="Document font" aria-label="Document font"
{disabled} {disabled}
bind:value={selectedFont} bind:value={selectedFont}
@@ -118,14 +111,7 @@
{/each} {/each}
</select> </select>
<button {@render action("ph:file-text", "Page settings", onpagesettings)}
class="flex items-center gap-1.5 rounded px-2 py-1 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] disabled:opacity-40"
{disabled}
onclick={onpagesettings}
>
<Icon icon="ph:file-text" />
Page
</button>
<div class="flex-1"></div> <div class="flex-1"></div>
</div> </div>
+50 -2
View File
@@ -34,6 +34,7 @@
| "appearance" | "appearance"
| "accessibility" | "accessibility"
| "account" | "account"
| "lsp"
| "about"; | "about";
const sections: { id: Section; label: string; icon: string }[] = [ const sections: { id: Section; label: string; icon: string }[] = [
@@ -41,9 +42,17 @@
{ id: "appearance", label: "Appearance", icon: "ph:palette" }, { id: "appearance", label: "Appearance", icon: "ph:palette" },
{ id: "accessibility", label: "Accessibility", icon: "ph:wheelchair" }, { id: "accessibility", label: "Accessibility", icon: "ph:wheelchair" },
{ id: "account", label: "Account", icon: "ph:user-circle" }, { id: "account", label: "Account", icon: "ph:user-circle" },
{ id: "lsp", label: "Language Server", icon: "ph:plugs-connected" },
{ id: "about", label: "About", icon: "ph:info" }, { id: "about", label: "About", icon: "ph:info" },
]; ];
const lspLabel: Record<string, string> = {
off: "Off",
starting: "Starting…",
on: "Running",
unavailable: "Unavailable",
};
const accentPresets = [ const accentPresets = [
"#3b6cf6", "#3b6cf6",
"#7c5cfc", "#7c5cfc",
@@ -227,7 +236,9 @@
{/each} {/each}
</select> </select>
<span class="text-[var(--color-ink-muted)]"> <span class="text-[var(--color-ink-muted)]">
Saves the file being edited after you stop typing. Saves the file being edited after you stop typing. Cloud-linked
files always save to disk every few seconds regardless of this
setting, since they sync live and have no manual Save button.
</span> </span>
</div> </div>
</div> </div>
@@ -460,6 +471,42 @@
</span> </span>
</div> </div>
</div> </div>
{:else if section === "lsp"}
<div class="flex flex-col gap-5">
<div class="flex items-center gap-3 rounded-md border border-[var(--color-line)] bg-[var(--color-surface-muted)] p-3">
<span
class="h-2.5 w-2.5 rounded-full
{app.lspStatus === 'on'
? 'bg-[var(--color-success)]'
: app.lspStatus === 'starting'
? 'bg-[var(--color-accent)]'
: 'bg-[var(--color-ink-muted)]'}"
></span>
<div class="text-xs">
<p class="font-medium">{lspLabel[app.lspStatus]}</p>
<p class="text-[var(--color-ink-muted)]">
Reflects the file currently open in the editor.
</p>
</div>
</div>
<p class="text-xs text-[var(--color-ink-muted)]">
Typst Desktop uses <span class="font-medium">tinymist</span>, the
official Typst language server, to provide autocomplete, diagnostics,
and hover info while editing. It must be installed and available on
your system PATH — Typst Desktop does not bundle or install it for
you.
</p>
{#if app.lspStatus === "unavailable"}
<div
class="rounded-md border border-[var(--color-danger)]/30 bg-[var(--color-danger)]/10 p-3 text-xs text-[var(--color-danger)]"
>
tinymist could not be started. Confirm it's installed and on your
PATH, then reopen the file.
</div>
{/if}
</div>
{:else} {:else}
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -521,7 +568,8 @@
{@const draftless = {@const draftless =
section === "about" || section === "about" ||
section === "appearance" || section === "appearance" ||
section === "accessibility"} section === "accessibility" ||
section === "lsp"}
<button <button
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]" class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
onclick={onclose} onclick={onclose}
+154 -51
View File
@@ -3,6 +3,7 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { save } from "@tauri-apps/plugin-dialog"; import { save } from "@tauri-apps/plugin-dialog";
import { writeImage, writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeImage, writeText } from "@tauri-apps/plugin-clipboard-manager";
import { Image } from "@tauri-apps/api/image";
import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { getCurrentWebview } from "@tauri-apps/api/webview"; import { getCurrentWebview } from "@tauri-apps/api/webview";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
@@ -23,7 +24,7 @@
import PageSettingsModal from "$lib/components/PageSettingsModal.svelte"; import PageSettingsModal from "$lib/components/PageSettingsModal.svelte";
import type { EditorView } from "@codemirror/view"; import type { EditorView } from "@codemirror/view";
import { insertText } from "$lib/ts/editor-actions"; import { insertText, redoEdit, undoEdit } from "$lib/ts/editor-actions";
import * as api from "$lib/ts/api"; import * as api from "$lib/ts/api";
import type { BrowseEntry, CloudFile, CloudFolder } from "$lib/ts/api"; import type { BrowseEntry, CloudFile, CloudFolder } from "$lib/ts/api";
@@ -43,6 +44,8 @@
refreshEntries, refreshEntries,
refreshTarget, refreshTarget,
removeDownloadedDocument, removeDownloadedDocument,
resolveCollabConflict,
cancelCollabConflict,
runSync, runSync,
saveAndCompile, saveAndCompile,
scheduleAutosave, scheduleAutosave,
@@ -90,6 +93,16 @@
let selectedIsDir = $state(false); let selectedIsDir = $state(false);
let treeDropTarget = $state<string | null>(null); let treeDropTarget = $state<string | null>(null);
const SIDEBAR_COLLAPSED_KEY = "editor-sidebar-collapsed";
let sidebarCollapsed = $state(
localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true",
);
function toggleSidebar() {
sidebarCollapsed = !sidebarCollapsed;
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(sidebarCollapsed));
}
const selectedFolder = $derived( const selectedFolder = $derived(
!selectedEntry !selectedEntry
? "" ? ""
@@ -371,13 +384,28 @@
} }
} }
async function pngBytesToRgba(bytes: Uint8Array) {
const blob = new Blob([bytes], { type: "image/png" });
const bitmap = await createImageBitmap(blob);
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext("2d");
if (!context) throw new Error("Could not create canvas context");
context.drawImage(bitmap, 0, 0);
const { data } = context.getImageData(0, 0, canvas.width, canvas.height);
return { rgba: new Uint8Array(data.buffer), width: canvas.width, height: canvas.height };
}
async function copyAs(format: string) { async function copyAs(format: string) {
if (!app.target) return; if (!app.target) return;
try { try {
if (format === "png") { if (format === "png") {
const bytes = await api.renderTargetPng(app.target.path); const bytes = await api.renderTargetPng(app.target.path);
await writeImage(new Uint8Array(bytes)); const { rgba, width, height } = await pngBytesToRgba(new Uint8Array(bytes));
const image = await Image.new(rgba, width, height);
await writeImage(image);
} else if (format === "svg") { } else if (format === "svg") {
const svg = app.compiled?.pages[0]; const svg = app.compiled?.pages[0];
if (!svg) { if (!svg) {
@@ -506,22 +534,41 @@
}; };
}); });
const lspLabel: Record<string, string> = {
off: "LSP off",
starting: "LSP starting",
on: "LSP ready",
unavailable: "LSP unavailable",
};
const wsLabel: Record<string, string> = { const wsLabel: Record<string, string> = {
connected: "Live sync", connected: "Live sync",
connecting: "Connecting…", connecting: "Connecting…",
offline: "Offline (polling)", offline: "Offline (polling)",
}; };
const collabLabel: Record<string, string> = {
connected: "Live",
connecting: "Connecting…",
offline: "Offline — editing locally",
};
</script> </script>
<svelte:window on:keydown={handleKeydown} /> <svelte:window on:keydown={handleKeydown} />
{#snippet statusBadge(
icon: string,
label: string,
tone: "success" | "accent" | "muted",
spin = false,
)}
<span
class="flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium
{tone === 'success'
? 'bg-[var(--color-success)]/10 text-[var(--color-success)]'
: tone === 'accent'
? 'bg-[var(--color-accent)]/10 text-[var(--color-accent)]'
: 'bg-[var(--color-surface-sunken)] text-[var(--color-ink-muted)]'}"
>
<Icon {icon} class="text-xs {spin ? 'animate-spin' : ''}" />
{label}
</span>
{/snippet}
<div class="flex h-screen flex-col"> <div class="flex h-screen flex-col">
<header <header
data-tauri-drag-region data-tauri-drag-region
@@ -535,15 +582,31 @@
<Icon icon="ph:arrow-left" /> <Icon icon="ph:arrow-left" />
Files Files
</button> </button>
{#if editorView}
<button
class="rounded-md p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
title="Undo"
aria-label="Undo"
onclick={() => undoEdit(editorView)}
>
<Icon icon="ph:arrow-counter-clockwise" class="text-sm" />
</button>
<button
class="rounded-md p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
title="Redo"
aria-label="Redo"
onclick={() => redoEdit(editorView)}
>
<Icon icon="ph:arrow-clockwise" class="text-sm" />
</button>
{/if}
<span data-tauri-drag-region class="text-sm font-medium"> <span data-tauri-drag-region class="text-sm font-medium">
{app.target?.path.split("/").pop()} {app.target?.path.split("/").pop()}
</span> </span>
{#if app.target?.standalone} {#if app.target?.standalone}
<span {@render statusBadge("ph:file", "Single file", "muted")}
class="rounded bg-[var(--color-surface-sunken)] px-1.5 py-0.5 text-[10px] text-[var(--color-ink-muted)]"
>
single file
</span>
{/if} {/if}
{#if app.dirty} {#if app.dirty}
<span class="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)]"></span> <span class="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)]"></span>
@@ -579,45 +642,53 @@
{/if} {/if}
{#if app.view === "editor"} {#if app.view === "editor"}
<span {#if app.account && (app.target?.cloud_project_id || app.documentLink) && !app.collabIntent}
class="flex items-center gap-1 text-[10px] text-[var(--color-ink-muted)]" <span title="Cloud sync connection">
title="Typst language server (tinymist)" {@render statusBadge(
> app.wsStatus === "connected"
<span ? "ph:cloud-check"
class="h-1.5 w-1.5 rounded-full : app.wsStatus === "connecting"
{app.lspStatus === 'on' ? "ph:circle-notch"
? 'bg-[var(--color-success)]' : "ph:cloud-slash",
: app.lspStatus === 'starting' wsLabel[app.wsStatus] ?? "Offline (polling)",
? 'bg-[var(--color-accent)]' app.wsStatus === "connected"
: 'bg-[var(--color-ink-muted)]'}" ? "success"
></span> : app.wsStatus === "connecting"
{lspLabel[app.lspStatus]} ? "accent"
</span> : "muted",
app.wsStatus === "connecting",
{#if app.account} )}
<span
class="flex items-center gap-1 text-[10px] text-[var(--color-ink-muted)]"
title="Cloud sync connection"
>
<span
class="h-1.5 w-1.5 rounded-full
{app.wsStatus === 'connected'
? 'bg-[var(--color-success)]'
: app.wsStatus === 'connecting'
? 'bg-[var(--color-accent)]'
: 'bg-[var(--color-ink-muted)]'}"
></span>
{wsLabel[app.wsStatus] ?? "Offline (polling)"}
</span> </span>
{/if} {/if}
<button {#if app.collabIntent}
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]" <span title="Realtime cloud sync">
onclick={saveAndCompile} {@render statusBadge(
> app.collabStatus === "connected"
<Icon icon="ph:floppy-disk" /> ? "ph:broadcast"
Save : app.collabStatus === "connecting"
</button> ? "ph:circle-notch"
: "ph:wifi-slash",
collabLabel[app.collabStatus ?? "offline"],
app.collabStatus === "connected"
? "success"
: app.collabStatus === "connecting"
? "accent"
: "muted",
app.collabStatus === "connecting",
)}
</span>
{/if}
{#if !app.collabIntent}
<button
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={saveAndCompile}
>
<Icon icon="ph:floppy-disk" />
Save
</button>
{/if}
<div class="group relative"> <div class="group relative">
<button <button
@@ -661,7 +732,7 @@
</div> </div>
</div> </div>
{#if app.target?.cloud_project_id || app.documentLink} {#if (app.target?.cloud_project_id || app.documentLink) && !app.collabIntent}
<button <button
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50" class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
disabled={app.syncing} disabled={app.syncing}
@@ -789,7 +860,20 @@
/> />
</div> </div>
{:else} {:else}
{#if !app.target?.standalone} {#if !app.target?.standalone && sidebarCollapsed}
<div
class="flex w-8 shrink-0 flex-col items-center border-r border-[var(--color-line)] bg-[var(--color-surface)] py-1.5"
>
<button
class="rounded p-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
title="Show files"
aria-label="Show files"
onclick={toggleSidebar}
>
<Icon icon="ph:sidebar-simple" class="text-base" />
</button>
</div>
{:else if !app.target?.standalone}
<div <div
class="flex w-56 shrink-0 flex-col border-r border-[var(--color-line)] bg-[var(--color-surface)]" class="flex w-56 shrink-0 flex-col border-r border-[var(--color-line)] bg-[var(--color-surface)]"
> >
@@ -801,6 +885,14 @@
> >
{selectedFolder ? selectedFolder : "Files"} {selectedFolder ? selectedFolder : "Files"}
</span> </span>
<button
class="shrink-0 rounded p-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
title="Hide files"
aria-label="Hide files"
onclick={toggleSidebar}
>
<Icon icon="ph:sidebar-simple" class="text-sm" />
</button>
</div> </div>
<FileTree <FileTree
@@ -855,6 +947,9 @@
filePath={app.activePath} filePath={app.activePath}
targetPath={app.target?.path ?? ""} targetPath={app.target?.path ?? ""}
diagnostics={app.diagnostics} diagnostics={app.diagnostics}
collab={app.collab
? { text: app.collab.text, awareness: app.collab.provider.awareness }
: null}
onchange={(value) => { onchange={(value) => {
app.editorContent = value; app.editorContent = value;
app.dirty = true; app.dirty = true;
@@ -1148,3 +1243,11 @@
onclose={close} onclose={close}
/> />
{/if} {/if}
{#if app.collabConflict}
<ConflictModal
conflicts={[app.collabConflict]}
onresolve={(resolutions) => resolveCollabConflict(resolutions[0]?.content ?? "")}
onclose={cancelCollabConflict}
/>
{/if}