Add M3: APPDATA persistence + presets + per-pane distro/label
Backend:
- save_workspace / load_workspace Tauri commands writing to
%APPDATA%\com.megaproxy.tiletopia\workspace.json with atomic
tmp+rename. Path from app.path().app_config_dir() (no dirs crate).
Layout helpers:
- tree.ts: changeDistro (with id swap to force XtermPane remount via
{#key}), changeLabel, presetSingle / TwoColumns / ThreeColumns /
TwoRows / TwoByTwo.
- New ops.ts with PaneOps interface bundling split / close /
setDistro / setLabel / distros, drilled through Pane chain
instead of individual callbacks.
UI:
- LeafPane: in-toolbar editable label (click to rename, Enter
saves, Esc cancels) and distro chip popover. Picking a different
distro respawns the pane.
- App.svelte: migrated from localStorage to APPDATA via the new
Tauri commands, debounced 500ms. One-time localStorage migration
on boot. Split inherits parent's distro+cwd. Titlebar preset
buttons with confirm when replacing >1 pane.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1869d08181
commit
64b90ebddb
10 changed files with 434 additions and 74 deletions
|
|
@ -1,44 +1,134 @@
|
|||
<script lang="ts">
|
||||
import type { LeafNode, NodeId, Orientation } from "./tree";
|
||||
import type { LeafNode } from "./tree";
|
||||
import type { PaneOps } from "./ops";
|
||||
import XtermPane from "../../components/XtermPane.svelte";
|
||||
|
||||
let {
|
||||
leaf,
|
||||
onSplit,
|
||||
onClose,
|
||||
ops,
|
||||
}: {
|
||||
leaf: LeafNode;
|
||||
onSplit: (leafId: NodeId, orientation: Orientation) => void;
|
||||
onClose: (leafId: NodeId) => void;
|
||||
ops: PaneOps;
|
||||
} = $props();
|
||||
|
||||
let status = $state("starting…");
|
||||
let statusOk = $state(true);
|
||||
|
||||
// ---- label editing -------------------------------------------------------
|
||||
let editingLabel = $state(false);
|
||||
let labelDraft = $state("");
|
||||
let labelInputEl: HTMLInputElement | null = $state(null);
|
||||
|
||||
function startEditLabel() {
|
||||
labelDraft = leaf.label ?? "";
|
||||
editingLabel = true;
|
||||
// Focus the input after Svelte renders it.
|
||||
queueMicrotask(() => labelInputEl?.select());
|
||||
}
|
||||
|
||||
function commitLabel() {
|
||||
if (!editingLabel) return;
|
||||
ops.setLabel(leaf.id, labelDraft);
|
||||
editingLabel = false;
|
||||
}
|
||||
|
||||
function cancelLabel() {
|
||||
editingLabel = false;
|
||||
}
|
||||
|
||||
function onLabelKey(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitLabel();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelLabel();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- distro popover ------------------------------------------------------
|
||||
let distroOpen = $state(false);
|
||||
|
||||
function toggleDistroMenu(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
distroOpen = !distroOpen;
|
||||
}
|
||||
|
||||
function pickDistro(d: string) {
|
||||
distroOpen = false;
|
||||
if (d !== leaf.distro) ops.setDistro(leaf.id, d);
|
||||
}
|
||||
|
||||
// Dismiss popover on outside click.
|
||||
$effect(() => {
|
||||
if (!distroOpen) return;
|
||||
const onDocClick = () => (distroOpen = false);
|
||||
window.addEventListener("click", onDocClick);
|
||||
return () => window.removeEventListener("click", onDocClick);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="leaf">
|
||||
<div class="pane-toolbar">
|
||||
<span class="pane-label">
|
||||
{leaf.label ?? leaf.distro ?? "(default)"}
|
||||
{#if editingLabel}
|
||||
<input
|
||||
class="label-input"
|
||||
bind:this={labelInputEl}
|
||||
bind:value={labelDraft}
|
||||
onkeydown={onLabelKey}
|
||||
onblur={commitLabel}
|
||||
placeholder="(label)"
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
class="pane-label"
|
||||
onclick={startEditLabel}
|
||||
title="Click to rename pane"
|
||||
>
|
||||
{leaf.label ?? "(unnamed)"}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<span class="distro-wrap">
|
||||
<button
|
||||
class="distro-chip"
|
||||
onclick={toggleDistroMenu}
|
||||
title="Change distro (respawns the pane)"
|
||||
>
|
||||
{leaf.distro ?? "(default)"} ▾
|
||||
</button>
|
||||
{#if distroOpen}
|
||||
<div class="distro-menu" onclick={(e) => e.stopPropagation()} role="menu" tabindex="-1" onkeydown={() => {}}>
|
||||
{#each ops.distros as d}
|
||||
<button
|
||||
class="distro-menu-item"
|
||||
class:active={d === leaf.distro}
|
||||
onclick={() => pickDistro(d)}
|
||||
>{d}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span class="pane-status {statusOk ? 'ok' : 'err'}">{status}</span>
|
||||
|
||||
<span class="pane-actions">
|
||||
<button
|
||||
class="pane-btn"
|
||||
title="Split right"
|
||||
onclick={() => onSplit(leaf.id, "h")}
|
||||
onclick={() => ops.split(leaf.id, "h")}
|
||||
aria-label="Split right"
|
||||
>⇥</button>
|
||||
<button
|
||||
class="pane-btn"
|
||||
title="Split down"
|
||||
onclick={() => onSplit(leaf.id, "v")}
|
||||
onclick={() => ops.split(leaf.id, "v")}
|
||||
aria-label="Split down"
|
||||
>⇣</button>
|
||||
<button
|
||||
class="pane-btn close"
|
||||
title="Close pane"
|
||||
onclick={() => onClose(leaf.id)}
|
||||
onclick={() => ops.close(leaf.id)}
|
||||
aria-label="Close pane"
|
||||
>×</button>
|
||||
</span>
|
||||
|
|
@ -75,16 +165,93 @@
|
|||
font-size: 11px;
|
||||
color: #aaa;
|
||||
user-select: none;
|
||||
min-height: 22px;
|
||||
min-height: 24px;
|
||||
}
|
||||
.pane-label {
|
||||
font: inherit;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-weight: 600;
|
||||
color: #ccc;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
cursor: text;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
}
|
||||
.pane-label:hover {
|
||||
background: #222;
|
||||
border-color: #2a2a2a;
|
||||
}
|
||||
.label-input {
|
||||
font: inherit;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: #0c0c0c;
|
||||
border: 1px solid #3a5a8c;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
outline: none;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.distro-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.distro-chip {
|
||||
font: inherit;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 10px;
|
||||
background: #222;
|
||||
color: #88c;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.distro-chip:hover {
|
||||
background: #2a2a3a;
|
||||
color: #aac;
|
||||
}
|
||||
.distro-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 2px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
min-width: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2px;
|
||||
}
|
||||
.distro-menu-item {
|
||||
font: inherit;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
color: #ccc;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.distro-menu-item:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
.distro-menu-item.active {
|
||||
background: #1a3a5c;
|
||||
color: #cce6ff;
|
||||
}
|
||||
|
||||
.pane-status {
|
||||
margin-left: auto;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
|
|
@ -94,6 +261,7 @@
|
|||
}
|
||||
.pane-status.ok { color: #6c6; }
|
||||
.pane-status.err { color: #d66; }
|
||||
|
||||
.pane-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
|
|
|
|||
|
|
@ -1,23 +1,22 @@
|
|||
<script lang="ts">
|
||||
import type { TreeNode, NodeId, Orientation } from "./tree";
|
||||
import type { TreeNode } from "./tree";
|
||||
import type { PaneOps } from "./ops";
|
||||
import SplitNode from "./SplitNode.svelte";
|
||||
import LeafPane from "./LeafPane.svelte";
|
||||
|
||||
let {
|
||||
node,
|
||||
onSplit,
|
||||
onClose,
|
||||
ops,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
onSplit: (leafId: NodeId, orientation: Orientation) => void;
|
||||
onClose: (leafId: NodeId) => void;
|
||||
ops: PaneOps;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if node.kind === "split"}
|
||||
<SplitNode {node} {onSplit} {onClose} />
|
||||
<SplitNode {node} {ops} />
|
||||
{:else}
|
||||
{#key node.id}
|
||||
<LeafPane leaf={node} {onSplit} {onClose} />
|
||||
<LeafPane leaf={node} {ops} />
|
||||
{/key}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
<script lang="ts">
|
||||
import type { SplitNode, NodeId, Orientation } from "./tree";
|
||||
import type { SplitNode } from "./tree";
|
||||
import type { PaneOps } from "./ops";
|
||||
import Pane from "./Pane.svelte";
|
||||
|
||||
let {
|
||||
node,
|
||||
onSplit,
|
||||
onClose,
|
||||
ops,
|
||||
}: {
|
||||
node: SplitNode;
|
||||
onSplit: (leafId: NodeId, orientation: Orientation) => void;
|
||||
onClose: (leafId: NodeId) => void;
|
||||
ops: PaneOps;
|
||||
} = $props();
|
||||
|
||||
let containerEl: HTMLDivElement;
|
||||
|
|
@ -28,7 +27,6 @@
|
|||
const pos = isH ? e.clientX - rect.left : e.clientY - rect.top;
|
||||
const size = isH ? rect.width : rect.height;
|
||||
if (size <= 0) return;
|
||||
// Account for the gutter being centered on the cursor.
|
||||
const r = Math.max(0.05, Math.min(0.95, pos / size));
|
||||
node.ratio = r;
|
||||
}
|
||||
|
|
@ -47,7 +45,7 @@
|
|||
bind:this={containerEl}
|
||||
>
|
||||
<div class="side" style="flex: {node.ratio}">
|
||||
<Pane node={node.a} {onSplit} {onClose} />
|
||||
<Pane node={node.a} {ops} />
|
||||
</div>
|
||||
<div
|
||||
class="gutter"
|
||||
|
|
@ -62,7 +60,7 @@
|
|||
onpointercancel={onPointerUp}
|
||||
></div>
|
||||
<div class="side" style="flex: {1 - node.ratio}">
|
||||
<Pane node={node.b} {onSplit} {onClose} />
|
||||
<Pane node={node.b} {ops} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
15
src/lib/layout/ops.ts
Normal file
15
src/lib/layout/ops.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { NodeId, Orientation } from "./tree";
|
||||
|
||||
/**
|
||||
* Bundle of operations + data that any pane in the tree may need.
|
||||
* Passed down through Pane / SplitNode / LeafPane to avoid per-callback
|
||||
* prop drilling.
|
||||
*/
|
||||
export interface PaneOps {
|
||||
split: (leafId: NodeId, orientation: Orientation) => void;
|
||||
close: (leafId: NodeId) => void;
|
||||
setDistro: (leafId: NodeId, distro: string) => void;
|
||||
setLabel: (leafId: NodeId, label: string | undefined) => void;
|
||||
/** All distros known to the backend; populated once at app start. */
|
||||
distros: string[];
|
||||
}
|
||||
|
|
@ -108,6 +108,66 @@ export function findLeaf(root: TreeNode, leafId: NodeId): LeafNode | null {
|
|||
return findLeaf(root.a, leafId) ?? findLeaf(root.b, leafId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the distro on a leaf. The leaf gets a **new id** so the rendering
|
||||
* layer's `{#key node.id}` block remounts XtermPane — the old PTY is killed
|
||||
* and a fresh one spawns with the new distro.
|
||||
*/
|
||||
export function changeDistro(
|
||||
root: TreeNode,
|
||||
leafId: NodeId,
|
||||
distro: string,
|
||||
): TreeNode {
|
||||
return replaceById(root, leafId, (node) => {
|
||||
if (node.kind !== "leaf") return node;
|
||||
return { ...node, id: newId(), distro };
|
||||
});
|
||||
}
|
||||
|
||||
/** Set or clear a leaf's label. Does NOT remount (label is metadata only). */
|
||||
export function changeLabel(
|
||||
root: TreeNode,
|
||||
leafId: NodeId,
|
||||
label: string | undefined,
|
||||
): TreeNode {
|
||||
return replaceById(root, leafId, (node) => {
|
||||
if (node.kind !== "leaf") return node;
|
||||
const trimmed = label?.trim();
|
||||
return { ...node, label: trimmed ? trimmed : undefined };
|
||||
});
|
||||
}
|
||||
|
||||
// ---- preset layouts --------------------------------------------------------
|
||||
|
||||
type LeafDefaults = Partial<Omit<LeafNode, "kind" | "id">>;
|
||||
|
||||
export function presetSingle(d: LeafDefaults = {}): TreeNode {
|
||||
return newLeaf(d);
|
||||
}
|
||||
|
||||
export function presetTwoColumns(d: LeafDefaults = {}): TreeNode {
|
||||
return newSplit("h", newLeaf(d), newLeaf(d));
|
||||
}
|
||||
|
||||
export function presetThreeColumns(d: LeafDefaults = {}): TreeNode {
|
||||
// Even thirds: outer split at 1/3, inner split at 1/2.
|
||||
return newSplit(
|
||||
"h",
|
||||
newLeaf(d),
|
||||
newSplit("h", newLeaf(d), newLeaf(d), 0.5),
|
||||
1 / 3,
|
||||
);
|
||||
}
|
||||
|
||||
export function presetTwoRows(d: LeafDefaults = {}): TreeNode {
|
||||
return newSplit("v", newLeaf(d), newLeaf(d));
|
||||
}
|
||||
|
||||
export function presetTwoByTwo(d: LeafDefaults = {}): TreeNode {
|
||||
const row = () => newSplit("h", newLeaf(d), newLeaf(d));
|
||||
return newSplit("v", row(), row());
|
||||
}
|
||||
|
||||
/** Number of leaves in the tree. */
|
||||
export function leafCount(root: TreeNode): number {
|
||||
if (root.kind === "leaf") return 1;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue