MULTI-BOARD !

This commit is contained in:
Haapy
2026-05-15 11:57:12 +00:00
parent a831b22213
commit 66ea8b2b69
11 changed files with 746 additions and 94 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Infinite</title> <title>Infinite</title>
<script type="module" crossorigin src="/assets/index-DxdHmf_Q.js"></script> <script type="module" crossorigin src="/assets/index-DaPgpxZP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BZPHLDSC.css"> <link rel="stylesheet" crossorigin href="/assets/index-DJpJgVAz.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -2,7 +2,10 @@ mod pty;
mod storage; mod storage;
use pty::{pty_kill, pty_resize, pty_spawn, pty_write, PtyState}; use pty::{pty_kill, pty_resize, pty_spawn, pty_write, PtyState};
use storage::{board_load, board_save}; use storage::{
board_create, board_delete, board_load, board_rename, board_save, boards_load_index,
boards_set_current,
};
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
@@ -35,8 +38,13 @@ pub fn run() {
pty_write, pty_write,
pty_resize, pty_resize,
pty_kill, pty_kill,
boards_load_index,
board_save, board_save,
board_load, board_load,
board_create,
board_rename,
board_delete,
boards_set_current,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -1,32 +1,119 @@
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use uuid::Uuid;
fn board_path(app: &AppHandle) -> Result<PathBuf, String> { #[derive(Serialize, Deserialize, Clone)]
let dir = app #[serde(rename_all = "camelCase")]
pub struct BoardMeta {
pub id: String,
pub name: String,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct BoardIndex {
pub version: u32,
pub current_board_id: String,
pub boards: Vec<BoardMeta>,
}
fn data_dir(app: &AppHandle) -> Result<PathBuf, String> {
let d = app
.path() .path()
.app_data_dir() .app_data_dir()
.map_err(|e| format!("app_data_dir: {e}"))?; .map_err(|e| format!("app_data_dir: {e}"))?;
fs::create_dir_all(&dir).map_err(|e| format!("create_dir_all: {e}"))?; fs::create_dir_all(&d).map_err(|e| format!("create_dir_all: {e}"))?;
Ok(dir.join("board.json")) Ok(d)
} }
#[tauri::command] fn boards_dir(app: &AppHandle) -> Result<PathBuf, String> {
pub fn board_save(app: AppHandle, state: Value) -> Result<(), String> { let d = data_dir(app)?.join("boards");
let path = board_path(&app)?; fs::create_dir_all(&d).map_err(|e| format!("create boards dir: {e}"))?;
Ok(d)
}
fn index_path(app: &AppHandle) -> Result<PathBuf, String> {
Ok(data_dir(app)?.join("boards.json"))
}
fn board_file(app: &AppHandle, id: &str) -> Result<PathBuf, String> {
if id.is_empty() || id.contains('/') || id.contains('\\') || id.contains("..") {
return Err(format!("invalid board id: {id:?}"));
}
Ok(boards_dir(app)?.join(format!("{id}.json")))
}
fn atomic_write(path: &Path, body: &str) -> Result<(), String> {
let tmp = path.with_extension("json.tmp"); let tmp = path.with_extension("json.tmp");
let body = serde_json::to_string_pretty(&state).map_err(|e| e.to_string())?;
fs::write(&tmp, body).map_err(|e| format!("write tmp: {e}"))?; fs::write(&tmp, body).map_err(|e| format!("write tmp: {e}"))?;
// Atomic replace so a crash mid-write can't corrupt the existing file. fs::rename(&tmp, path).map_err(|e| format!("rename: {e}"))?;
fs::rename(&tmp, &path).map_err(|e| format!("rename: {e}"))?;
Ok(()) Ok(())
} }
fn empty_board_json() -> String {
serde_json::to_string_pretty(&serde_json::json!({
"version": 1,
"cards": [],
"viewport": { "x": 0, "y": 0, "scale": 1 },
"maxZ": 0,
}))
.unwrap()
}
fn write_index(app: &AppHandle, idx: &BoardIndex) -> Result<(), String> {
let body = serde_json::to_string_pretty(idx).map_err(|e| format!("serialize index: {e}"))?;
atomic_write(&index_path(app)?, &body)
}
fn read_index(app: &AppHandle) -> Result<BoardIndex, String> {
let path = index_path(app)?;
if !path.exists() {
// First run, or upgrading from the pre-multi-board single-file layout.
// Migrate the old board.json (if any) into boards/default.json.
let legacy = data_dir(app)?.join("board.json");
let default_id = "default".to_string();
let default_file = board_file(app, &default_id)?;
if legacy.exists() && !default_file.exists() {
let body = fs::read_to_string(&legacy).map_err(|e| format!("read legacy: {e}"))?;
atomic_write(&default_file, &body)?;
let _ = fs::remove_file(&legacy);
} else if !default_file.exists() {
atomic_write(&default_file, &empty_board_json())?;
}
let idx = BoardIndex {
version: 1,
current_board_id: default_id.clone(),
boards: vec![BoardMeta {
id: default_id,
name: "Default".to_string(),
}],
};
write_index(app, &idx)?;
return Ok(idx);
}
let body = fs::read_to_string(&path).map_err(|e| format!("read index: {e}"))?;
serde_json::from_str(&body).map_err(|e| format!("parse index: {e}"))
}
#[tauri::command] #[tauri::command]
pub fn board_load(app: AppHandle) -> Result<Option<Value>, String> { pub fn boards_load_index(app: AppHandle) -> Result<BoardIndex, String> {
let path = board_path(&app)?; read_index(&app)
}
#[tauri::command]
pub fn board_save(app: AppHandle, id: String, state: Value) -> Result<(), String> {
let path = board_file(&app, &id)?;
let body = serde_json::to_string_pretty(&state).map_err(|e| format!("serialize: {e}"))?;
atomic_write(&path, &body)
}
#[tauri::command]
pub fn board_load(app: AppHandle, id: String) -> Result<Option<Value>, String> {
let path = board_file(&app, &id)?;
if !path.exists() { if !path.exists() {
return Ok(None); return Ok(None);
} }
@@ -34,3 +121,58 @@ pub fn board_load(app: AppHandle) -> Result<Option<Value>, String> {
let v = serde_json::from_str(&body).map_err(|e| format!("parse: {e}"))?; let v = serde_json::from_str(&body).map_err(|e| format!("parse: {e}"))?;
Ok(Some(v)) Ok(Some(v))
} }
#[tauri::command]
pub fn board_create(app: AppHandle, name: String) -> Result<BoardMeta, String> {
let id = Uuid::new_v4().to_string();
let mut idx = read_index(&app)?;
let meta = BoardMeta {
id: id.clone(),
name,
};
idx.boards.push(meta.clone());
write_index(&app, &idx)?;
atomic_write(&board_file(&app, &id)?, &empty_board_json())?;
Ok(meta)
}
#[tauri::command]
pub fn board_rename(app: AppHandle, id: String, name: String) -> Result<(), String> {
let mut idx = read_index(&app)?;
let b = idx
.boards
.iter_mut()
.find(|b| b.id == id)
.ok_or_else(|| format!("board {id} not found"))?;
b.name = name;
write_index(&app, &idx)
}
#[tauri::command]
pub fn board_delete(app: AppHandle, id: String) -> Result<BoardIndex, String> {
let mut idx = read_index(&app)?;
if idx.boards.len() <= 1 {
return Err("can't delete the last board".to_string());
}
let before = idx.boards.len();
idx.boards.retain(|b| b.id != id);
if idx.boards.len() == before {
return Err(format!("board {id} not found"));
}
if idx.current_board_id == id {
idx.current_board_id = idx.boards[0].id.clone();
}
write_index(&app, &idx)?;
let _ = fs::remove_file(board_file(&app, &id)?);
Ok(idx)
}
#[tauri::command]
pub fn boards_set_current(app: AppHandle, id: String) -> Result<(), String> {
let mut idx = read_index(&app)?;
if !idx.boards.iter().any(|b| b.id == id) {
return Err(format!("board {id} not found"));
}
idx.current_board_id = id;
write_index(&app, &idx)
}

View File

@@ -1,9 +1,19 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Canvas } from "./canvas/Canvas"; import { Canvas } from "./canvas/Canvas";
import type { BoardState, Card } from "./canvas/types"; import { BoardSwitcher } from "./canvas/BoardSwitcher";
import type { BoardIndex, BoardState, Card } from "./canvas/types";
import { board } from "./ipc"; import { board } from "./ipc";
const DEFAULT_BOARD: BoardState = { const SAVE_DEBOUNCE_MS = 400;
const DEFAULT_STATE: BoardState = {
version: 1,
cards: [],
viewport: { x: 0, y: 0, scale: 1 },
maxZ: 0,
};
const WELCOME_NOTE = (): BoardState => ({
version: 1, version: 1,
cards: [ cards: [
{ {
@@ -23,9 +33,9 @@ const DEFAULT_BOARD: BoardState = {
], ],
viewport: { x: 0, y: 0, scale: 1 }, viewport: { x: 0, y: 0, scale: 1 },
maxZ: 1, maxZ: 1,
}; });
// Live PTY processes die with the previous app session — wipe ptyIds so each // Live PTY processes die with the previous session — wipe ptyIds so each
// terminal card respawns a fresh shell on mount. // terminal card respawns a fresh shell on mount.
function sanitize(state: BoardState): BoardState { function sanitize(state: BoardState): BoardState {
return { return {
@@ -37,25 +47,149 @@ function sanitize(state: BoardState): BoardState {
} }
export function App() { export function App() {
const [initial, setInitial] = useState<BoardState | null>(null); const [index, setIndex] = useState<BoardIndex | null>(null);
const [initialState, setInitialState] = useState<BoardState | null>(null);
const latestStateRef = useRef<BoardState | null>(null);
const saveTimerRef = useRef<number | null>(null);
const currentIdRef = useRef<string>("");
// Initial load.
useEffect(() => { useEffect(() => {
let cancelled = false; (async () => {
board try {
.load() const idx = await board.loadIndex();
.then((loaded) => { const loaded = await board.load(idx.currentBoardId);
if (cancelled) return; currentIdRef.current = idx.currentBoardId;
setInitial(loaded ? sanitize(loaded) : DEFAULT_BOARD); setIndex(idx);
}) setInitialState(loaded ? sanitize(loaded) : WELCOME_NOTE());
.catch((err) => { } catch (err) {
console.error("board_load failed", err); console.error("boards init failed", err);
if (!cancelled) setInitial(DEFAULT_BOARD); currentIdRef.current = "default";
setIndex({
version: 1,
currentBoardId: "default",
boards: [{ id: "default", name: "Default" }],
}); });
return () => { setInitialState(WELCOME_NOTE());
cancelled = true; }
}; })();
}, []); }, []);
if (!initial) return null; const flush = useCallback(async () => {
return <Canvas initial={initial} />; if (saveTimerRef.current !== null) {
window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
const id = currentIdRef.current;
const state = latestStateRef.current;
if (id && state) {
try {
await board.save(id, state);
} catch (err) {
console.error("board_save flush failed", err);
}
}
}, []);
const onCanvasChange = useCallback((state: BoardState) => {
latestStateRef.current = state;
if (saveTimerRef.current !== null) window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = window.setTimeout(() => {
saveTimerRef.current = null;
const id = currentIdRef.current;
const s = latestStateRef.current;
if (id && s) {
board.save(id, s).catch((err) => console.error("board_save failed", err));
}
}, SAVE_DEBOUNCE_MS);
}, []);
const switchTo = useCallback(
async (id: string) => {
if (!index || id === index.currentBoardId) return;
await flush();
await board.setCurrent(id);
const loaded = await board.load(id);
latestStateRef.current = null;
currentIdRef.current = id;
setIndex({ ...index, currentBoardId: id });
setInitialState(loaded ? sanitize(loaded) : DEFAULT_STATE);
},
[index, flush],
);
const createBoard = useCallback(
async (name: string) => {
if (!index) return;
await flush();
const meta = await board.create(name);
await board.setCurrent(meta.id);
latestStateRef.current = null;
currentIdRef.current = meta.id;
setIndex({
...index,
currentBoardId: meta.id,
boards: [...index.boards, meta],
});
setInitialState(DEFAULT_STATE);
},
[index, flush],
);
const renameBoard = useCallback(async (id: string, name: string) => {
await board.rename(id, name);
setIndex((idx) =>
idx
? { ...idx, boards: idx.boards.map((b) => (b.id === id ? { ...b, name } : b)) }
: idx,
);
}, []);
const deleteBoard = useCallback(
async (id: string) => {
if (!index) return;
const wasCurrent = id === index.currentBoardId;
const newIndex = await board.delete(id);
if (wasCurrent) {
const loaded = await board.load(newIndex.currentBoardId);
latestStateRef.current = null;
currentIdRef.current = newIndex.currentBoardId;
setIndex(newIndex);
setInitialState(loaded ? sanitize(loaded) : DEFAULT_STATE);
} else {
setIndex(newIndex);
}
},
[index],
);
// Best-effort flush on tab/window close.
useEffect(() => {
const onBeforeUnload = () => {
if (saveTimerRef.current !== null) {
window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
const id = currentIdRef.current;
const s = latestStateRef.current;
if (id && s) board.save(id, s).catch(() => {});
};
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, []);
if (!index || !initialState) return null;
return (
<>
<BoardSwitcher
index={index}
onSwitch={switchTo}
onCreate={createBoard}
onRename={renameBoard}
onDelete={deleteBoard}
/>
<Canvas key={index.currentBoardId} initial={initialState} onChange={onCanvasChange} />
</>
);
} }

View File

@@ -0,0 +1,199 @@
import { useEffect, useRef, useState } from "react";
import type { BoardIndex, BoardMeta } from "./types";
interface Props {
index: BoardIndex;
onSwitch: (id: string) => void;
onCreate: (name: string) => void;
onRename: (id: string, name: string) => void;
onDelete: (id: string) => void;
}
export function BoardSwitcher({ index, onSwitch, onCreate, onRename, onDelete }: Props) {
const [open, setOpen] = useState(false);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDocDown = (e: MouseEvent) => {
if (!containerRef.current?.contains(e.target as Node)) {
setOpen(false);
setRenamingId(null);
setCreating(false);
}
};
document.addEventListener("mousedown", onDocDown);
return () => document.removeEventListener("mousedown", onDocDown);
}, [open]);
const current = index.boards.find((b) => b.id === index.currentBoardId);
const handleDelete = (b: BoardMeta) => {
if (index.boards.length <= 1) return;
if (!confirm(`Delete board "${b.name}"? This can't be undone.`)) return;
onDelete(b.id);
};
return (
<div className="board-switcher" ref={containerRef}>
<button
type="button"
className="board-switcher-trigger"
onClick={() => setOpen((v) => !v)}
>
<span className="board-switcher-name">{current?.name ?? "—"}</span>
<span className="board-switcher-caret"></span>
</button>
{open && (
<div className="board-switcher-menu">
{index.boards.map((b) => (
<BoardRow
key={b.id}
board={b}
current={b.id === index.currentBoardId}
renaming={renamingId === b.id}
canDelete={index.boards.length > 1}
onSwitch={() => {
setOpen(false);
onSwitch(b.id);
}}
onStartRename={() => setRenamingId(b.id)}
onCommitRename={(name) => {
setRenamingId(null);
if (name.trim() && name !== b.name) onRename(b.id, name.trim());
}}
onCancelRename={() => setRenamingId(null)}
onDelete={() => handleDelete(b)}
/>
))}
<div className="board-switcher-divider" />
{creating ? (
<NewBoardRow
onCommit={(name) => {
setCreating(false);
setOpen(false);
if (name.trim()) onCreate(name.trim());
}}
onCancel={() => setCreating(false)}
/>
) : (
<button
type="button"
className="board-switcher-new"
onClick={() => setCreating(true)}
>
+ New board
</button>
)}
</div>
)}
</div>
);
}
function BoardRow(props: {
board: BoardMeta;
current: boolean;
renaming: boolean;
canDelete: boolean;
onSwitch: () => void;
onStartRename: () => void;
onCommitRename: (name: string) => void;
onCancelRename: () => void;
onDelete: () => void;
}) {
const { board, current, renaming, canDelete } = props;
const inputRef = useRef<HTMLInputElement>(null);
const [draft, setDraft] = useState(board.name);
useEffect(() => {
if (renaming) {
setDraft(board.name);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
}, [renaming, board.name]);
if (renaming) {
return (
<div className="board-switcher-row">
<input
ref={inputRef}
className="board-switcher-input"
value={draft}
spellCheck={false}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") props.onCommitRename(draft);
else if (e.key === "Escape") props.onCancelRename();
}}
onBlur={() => props.onCommitRename(draft)}
/>
</div>
);
}
return (
<div
className={`board-switcher-row ${current ? "is-current" : ""}`}
onContextMenu={(e) => {
e.preventDefault();
props.onStartRename();
}}
>
<button
type="button"
className="board-switcher-name-btn"
onClick={props.onSwitch}
>
{board.name}
{current && <span className="board-switcher-dot"></span>}
</button>
{canDelete && (
<button
type="button"
className="board-switcher-delete"
aria-label="Delete board"
onClick={props.onDelete}
>
×
</button>
)}
</div>
);
}
function NewBoardRow({
onCommit,
onCancel,
}: {
onCommit: (name: string) => void;
onCancel: () => void;
}) {
const [draft, setDraft] = useState("");
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
ref.current?.focus();
}, []);
return (
<div className="board-switcher-row">
<input
ref={ref}
className="board-switcher-input"
value={draft}
placeholder="Board name"
spellCheck={false}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onCommit(draft);
else if (e.key === "Escape") onCancel();
}}
onBlur={() => onCommit(draft)}
/>
</div>
);
}

View File

@@ -3,19 +3,19 @@ import type { BoardState, Card, Viewport } from "./types";
import { ViewportContext } from "./viewport"; import { ViewportContext } from "./viewport";
import { NoteCardView } from "./cards/NoteCardView"; import { NoteCardView } from "./cards/NoteCardView";
import { TerminalCardView } from "./cards/TerminalCardView"; import { TerminalCardView } from "./cards/TerminalCardView";
import { board, pty } from "../ipc"; import { pty } from "../ipc";
import "./canvas.css"; import "./canvas.css";
const MIN_SCALE = 0.1; const MIN_SCALE = 0.1;
const MAX_SCALE = 4; const MAX_SCALE = 4;
const ZOOM_SENSITIVITY = 0.0015; const ZOOM_SENSITIVITY = 0.0015;
const SAVE_DEBOUNCE_MS = 400;
interface CanvasProps { interface CanvasProps {
initial: BoardState; initial: BoardState;
onChange: (state: BoardState) => void;
} }
export function Canvas({ initial }: CanvasProps) { export function Canvas({ initial, onChange }: CanvasProps) {
const [cards, setCards] = useState<Card[]>(initial.cards); const [cards, setCards] = useState<Card[]>(initial.cards);
const [vp, setVp] = useState<Viewport>(initial.viewport); const [vp, setVp] = useState<Viewport>(initial.viewport);
const [spaceHeld, setSpaceHeld] = useState(false); const [spaceHeld, setSpaceHeld] = useState(false);
@@ -27,19 +27,16 @@ export function Canvas({ initial }: CanvasProps) {
Math.max(initial.maxZ, initial.cards.reduce((m, c) => Math.max(m, c.z), 0)), Math.max(initial.maxZ, initial.cards.reduce((m, c) => Math.max(m, c.z), 0)),
); );
// Auto-save the board whenever cards or viewport change. Debounced so a // Emit board state changes upward; App owns the debounced save.
// pan/drag/typing burst writes once after motion stops. const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => { useEffect(() => {
const handle = window.setTimeout(() => { onChangeRef.current({
const state: BoardState = {
version: 1, version: 1,
cards, cards,
viewport: vp, viewport: vp,
maxZ: maxZRef.current, maxZ: maxZRef.current,
}; });
board.save(state).catch((err) => console.error("board_save failed", err));
}, SAVE_DEBOUNCE_MS);
return () => window.clearTimeout(handle);
}, [cards, vp]); }, [cards, vp]);
useEffect(() => { useEffect(() => {

View File

@@ -211,7 +211,7 @@
.toolbar { .toolbar {
position: fixed; position: fixed;
top: 12px; top: 12px;
left: 12px; left: 220px;
display: flex; display: flex;
gap: 6px; gap: 6px;
padding: 6px; padding: 6px;
@@ -220,6 +220,160 @@
z-index: 1000; z-index: 1000;
} }
.board-switcher {
position: fixed;
top: 12px;
left: 12px;
z-index: 1001;
}
.board-switcher-trigger {
display: flex;
align-items: center;
gap: 8px;
background: rgba(0, 0, 0, 0.6);
color: var(--text);
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 7px 12px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
min-width: 180px;
max-width: 260px;
}
.board-switcher-trigger:hover {
border-color: var(--accent);
}
.board-switcher-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
.board-switcher-caret {
opacity: 0.6;
font-size: 10px;
}
.board-switcher-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 220px;
background: var(--card-bg, #2a2a32);
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 4px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
gap: 1px;
}
.board-switcher-row {
display: flex;
align-items: center;
border-radius: 5px;
padding: 0 2px 0 0;
}
.board-switcher-row:hover {
background: rgba(255, 255, 255, 0.06);
}
.board-switcher-row.is-current {
background: rgba(255, 255, 255, 0.04);
}
.board-switcher-name-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
background: transparent;
border: none;
color: var(--text);
font-family: inherit;
font-size: 12px;
text-align: left;
padding: 7px 10px;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.board-switcher-dot {
font-size: 8px;
opacity: 0.6;
}
.board-switcher-delete {
flex-shrink: 0;
width: 22px;
height: 22px;
background: transparent;
border: none;
border-radius: 4px;
color: var(--text);
opacity: 0;
font-size: 14px;
line-height: 1;
cursor: pointer;
padding: 0;
}
.board-switcher-row:hover .board-switcher-delete {
opacity: 0.5;
}
.board-switcher-delete:hover {
background: rgba(255, 80, 80, 0.25);
opacity: 1 !important;
}
.board-switcher-input {
flex: 1;
background: transparent;
border: 1px solid var(--accent, #4f87ff);
border-radius: 5px;
color: var(--text);
font-family: inherit;
font-size: 12px;
padding: 6px 10px;
outline: none;
}
.board-switcher-divider {
height: 1px;
background: var(--card-border);
margin: 4px 2px;
}
.board-switcher-new {
background: transparent;
border: none;
color: var(--text);
font-family: inherit;
font-size: 12px;
text-align: left;
padding: 7px 10px;
border-radius: 5px;
cursor: pointer;
opacity: 0.75;
}
.board-switcher-new:hover {
background: rgba(255, 255, 255, 0.06);
opacity: 1;
}
.toolbar button { .toolbar button {
background: var(--card-bg); background: var(--card-bg);
color: var(--text); color: var(--text);

View File

@@ -44,3 +44,14 @@ export interface BoardState {
viewport: Viewport; viewport: Viewport;
maxZ: number; maxZ: number;
} }
export interface BoardMeta {
id: string;
name: string;
}
export interface BoardIndex {
version: number;
currentBoardId: string;
boards: BoardMeta[];
}

View File

@@ -1,6 +1,6 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { BoardState } from "./canvas/types"; import type { BoardIndex, BoardMeta, BoardState } from "./canvas/types";
export interface PtySpawnOpts { export interface PtySpawnOpts {
cols: number; cols: number;
@@ -51,6 +51,13 @@ export function stringToBase64(s: string): string {
} }
export const board = { export const board = {
save: (state: BoardState) => invoke<void>("board_save", { state }), loadIndex: () => invoke<BoardIndex>("boards_load_index"),
load: () => invoke<BoardState | null>("board_load"), setCurrent: (id: string) => invoke<void>("boards_set_current", { id }),
load: (id: string) => invoke<BoardState | null>("board_load", { id }),
save: (id: string, state: BoardState) =>
invoke<void>("board_save", { id, state }),
create: (name: string) => invoke<BoardMeta>("board_create", { name }),
rename: (id: string, name: string) =>
invoke<void>("board_rename", { id, name }),
delete: (id: string) => invoke<BoardIndex>("board_delete", { id }),
}; };