What persists: every card (kind, position, size, title, note text, ptyId, z-order) + viewport pan/zoom + maxZ counter,

in a single board.json under app_data_dir() (~/.local/share/{appName}/board.json on Linux).

  Write pattern: tmp file + atomic rename, debounced 400ms. A crash mid-write can't corrupt the existing file; worst
  case you lose ~400ms of recent edits.

  Read pattern: load on app start, gate the Canvas render on the result. Missing file → default welcome board. Parse
  error → log and fall back to default (won't overwrite the bad file until the user makes changes; actually it will save
   over it after the next change — if you want to preserve a corrupt file for recovery that's a one-line tweak).

  Terminal caveat: live PTY processes die with the app session, so sanitize() in App.tsx clears every terminal card's
  ptyId on load. Each terminal card respawns a fresh shell on mount via the existing pty.spawn path. The card's
  position, size, and title are preserved.
This commit is contained in:
Haapy
2026-05-15 11:40:36 +00:00
parent 595666e94b
commit 092d27beab
13 changed files with 311 additions and 71 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-D6iqmLrm.js"></script> <script type="module" crossorigin src="/assets/index-DxdHmf_Q.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BRF-Y_tu.css"> <link rel="stylesheet" crossorigin href="/assets/index-BZPHLDSC.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,6 +1,8 @@
mod pty; mod pty;
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};
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
@@ -33,6 +35,8 @@ pub fn run() {
pty_write, pty_write,
pty_resize, pty_resize,
pty_kill, pty_kill,
board_save,
board_load,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

36
src-tauri/src/storage.rs Normal file
View File

@@ -0,0 +1,36 @@
use std::fs;
use std::path::PathBuf;
use serde_json::Value;
use tauri::{AppHandle, Manager};
fn board_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| format!("app_data_dir: {e}"))?;
fs::create_dir_all(&dir).map_err(|e| format!("create_dir_all: {e}"))?;
Ok(dir.join("board.json"))
}
#[tauri::command]
pub fn board_save(app: AppHandle, state: Value) -> Result<(), String> {
let path = board_path(&app)?;
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}"))?;
// Atomic replace so a crash mid-write can't corrupt the existing file.
fs::rename(&tmp, &path).map_err(|e| format!("rename: {e}"))?;
Ok(())
}
#[tauri::command]
pub fn board_load(app: AppHandle) -> Result<Option<Value>, String> {
let path = board_path(&app)?;
if !path.exists() {
return Ok(None);
}
let body = fs::read_to_string(&path).map_err(|e| format!("read: {e}"))?;
let v = serde_json::from_str(&body).map_err(|e| format!("parse: {e}"))?;
Ok(Some(v))
}

View File

@@ -1,7 +1,11 @@
import { useEffect, useState } from "react";
import { Canvas } from "./canvas/Canvas"; import { Canvas } from "./canvas/Canvas";
import type { Card } from "./canvas/types"; import type { BoardState, Card } from "./canvas/types";
import { board } from "./ipc";
const initialCards: Card[] = [ const DEFAULT_BOARD: BoardState = {
version: 1,
cards: [
{ {
id: "welcome", id: "welcome",
kind: "note", kind: "note",
@@ -16,8 +20,42 @@ const initialCards: Card[] = [
"Zoom: Ctrl + scroll\n\n" + "Zoom: Ctrl + scroll\n\n" +
"Use the toolbar to add notes and terminals.", "Use the toolbar to add notes and terminals.",
}, },
]; ],
viewport: { x: 0, y: 0, scale: 1 },
maxZ: 1,
};
// Live PTY processes die with the previous app session — wipe ptyIds so each
// terminal card respawns a fresh shell on mount.
function sanitize(state: BoardState): BoardState {
return {
...state,
cards: state.cards.map((c): Card =>
c.kind === "terminal" ? { ...c, ptyId: "" } : c,
),
};
}
export function App() { export function App() {
return <Canvas initialCards={initialCards} />; const [initial, setInitial] = useState<BoardState | null>(null);
useEffect(() => {
let cancelled = false;
board
.load()
.then((loaded) => {
if (cancelled) return;
setInitial(loaded ? sanitize(loaded) : DEFAULT_BOARD);
})
.catch((err) => {
console.error("board_load failed", err);
if (!cancelled) setInitial(DEFAULT_BOARD);
});
return () => {
cancelled = true;
};
}, []);
if (!initial) return null;
return <Canvas initial={initial} />;
} }

View File

@@ -1,30 +1,47 @@
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState, useCallback } from "react";
import type { Card, Viewport } from "./types"; 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 "./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 {
initialCards: Card[]; initial: BoardState;
} }
export function Canvas({ initialCards }: CanvasProps) { export function Canvas({ initial }: CanvasProps) {
const [cards, setCards] = useState<Card[]>(initialCards); const [cards, setCards] = useState<Card[]>(initial.cards);
const [vp, setVp] = useState<Viewport>({ x: 0, y: 0, scale: 1 }); const [vp, setVp] = useState<Viewport>(initial.viewport);
const [spaceHeld, setSpaceHeld] = useState(false); const [spaceHeld, setSpaceHeld] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const panState = useRef<{ startX: number; startY: number; vpX: number; vpY: number } | null>( const panState = useRef<{ startX: number; startY: number; vpX: number; vpY: number } | null>(
null, null,
); );
const maxZRef = useRef( const maxZRef = useRef(
initialCards.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
// pan/drag/typing burst writes once after motion stops.
useEffect(() => {
const handle = window.setTimeout(() => {
const state: BoardState = {
version: 1,
cards,
viewport: vp,
maxZ: maxZRef.current,
};
board.save(state).catch((err) => console.error("board_save failed", err));
}, SAVE_DEBOUNCE_MS);
return () => window.clearTimeout(handle);
}, [cards, vp]);
useEffect(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.code === "Space" && !e.repeat) setSpaceHeld(true); if (e.code === "Space" && !e.repeat) setSpaceHeld(true);
@@ -99,6 +116,26 @@ export function Canvas({ initialCards }: CanvasProps) {
setCards((cs) => cs.map((c) => (c.id === id ? ({ ...c, ...patch } as Card) : c))); setCards((cs) => cs.map((c) => (c.id === id ? ({ ...c, ...patch } as Card) : c)));
}; };
const deleteCard = (id: string) => {
setCards((cs) => {
const target = cs.find((c) => c.id === id);
if (target?.kind === "terminal" && target.ptyId) {
pty.kill(target.ptyId).catch(() => {});
}
return cs.filter((c) => c.id !== id);
});
};
const bringToFront = (id: string) => {
setCards((cs) => {
const target = cs.find((c) => c.id === id);
if (!target || target.z === maxZRef.current) return cs;
maxZRef.current += 1;
const newZ = maxZRef.current;
return cs.map((c) => (c.id === id ? ({ ...c, z: newZ } as Card) : c));
});
};
const addCardAtCenter = (build: (id: string, x: number, y: number, z: number) => Card) => { const addCardAtCenter = (build: (id: string, x: number, y: number, z: number) => Card) => {
const w = window.innerWidth / 2; const w = window.innerWidth / 2;
const h = window.innerHeight / 2; const h = window.innerHeight / 2;
@@ -154,10 +191,26 @@ export function Canvas({ initialCards }: CanvasProps) {
<ViewportContext.Provider value={vp}> <ViewportContext.Provider value={vp}>
{cards.map((c) => { {cards.map((c) => {
if (c.kind === "note") { if (c.kind === "note") {
return <NoteCardView key={c.id} card={c} onUpdate={(p) => updateCard(c.id, p)} />; return (
<NoteCardView
key={c.id}
card={c}
onUpdate={(p) => updateCard(c.id, p)}
onClose={() => deleteCard(c.id)}
onFocus={() => bringToFront(c.id)}
/>
);
} }
if (c.kind === "terminal") { if (c.kind === "terminal") {
return <TerminalCardView key={c.id} card={c} onUpdate={(p) => updateCard(c.id, p)} />; return (
<TerminalCardView
key={c.id}
card={c}
onUpdate={(p) => updateCard(c.id, p)}
onClose={() => deleteCard(c.id)}
onFocus={() => bringToFront(c.id)}
/>
);
} }
return null; return null;
})} })}

View File

@@ -52,7 +52,7 @@
} }
.card-header { .card-header {
padding: 0.46em 0.77em; padding: 0.31em 0.46em 0.31em 0.77em;
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
border-bottom: calc(1px * var(--scale, 1)) solid var(--card-border); border-bottom: calc(1px * var(--scale, 1)) solid var(--card-border);
font-size: 0.85em; font-size: 0.85em;
@@ -61,6 +61,51 @@
overflow: hidden; overflow: hidden;
border-top-left-radius: inherit; border-top-left-radius: inherit;
border-top-right-radius: inherit; border-top-right-radius: inherit;
display: flex;
align-items: center;
gap: 0.46em;
}
.card-title {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-family: inherit;
font-size: inherit;
padding: 0.15em 0;
cursor: text;
}
.card-title::placeholder {
color: var(--text);
opacity: 0.35;
}
.card-close {
flex-shrink: 0;
width: 1.6em;
height: 1.6em;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 4px;
color: var(--text);
opacity: 0.5;
font-size: 1.2em;
line-height: 1;
cursor: pointer;
padding: 0;
font-family: inherit;
}
.card-close:hover {
background: rgba(255, 80, 80, 0.25);
opacity: 1;
} }
.card-body { .card-body {

View File

@@ -0,0 +1,33 @@
import React from "react";
interface Props {
title: string;
placeholder: string;
onTitleChange: (next: string) => void;
onClose: () => void;
dragProps: { onPointerDown: (e: React.PointerEvent) => void };
}
export function CardHeader({ title, placeholder, onTitleChange, onClose, dragProps }: Props) {
return (
<div className="card-header" {...dragProps}>
<input
className="card-title"
value={title}
placeholder={placeholder}
spellCheck={false}
onChange={(e) => onTitleChange(e.target.value)}
onPointerDown={(e) => e.stopPropagation()}
/>
<button
type="button"
className="card-close"
aria-label="Close"
onClick={onClose}
onPointerDown={(e) => e.stopPropagation()}
>
×
</button>
</div>
);
}

View File

@@ -3,16 +3,19 @@ import type { NoteCard } from "../types";
import { useViewport } from "../viewport"; import { useViewport } from "../viewport";
import { useDragHandle } from "../useDragHandle"; import { useDragHandle } from "../useDragHandle";
import { ResizeHandles } from "./ResizeHandles"; import { ResizeHandles } from "./ResizeHandles";
import { CardHeader } from "./CardHeader";
import { useMarkdownEditor } from "./useMarkdownEditor"; import { useMarkdownEditor } from "./useMarkdownEditor";
interface Props { interface Props {
card: NoteCard; card: NoteCard;
onUpdate: (patch: Partial<NoteCard>) => void; onUpdate: (patch: Partial<NoteCard>) => void;
onClose: () => void;
onFocus: () => void;
} }
const BODY_FONT_BASE = 13; const BODY_FONT_BASE = 13;
export function NoteCardView({ card, onUpdate }: Props) { export function NoteCardView({ card, onUpdate, onClose, onFocus }: Props) {
const vp = useViewport(); const vp = useViewport();
const drag = useDragHandle(card, (p) => onUpdate(p)); const drag = useDragHandle(card, (p) => onUpdate(p));
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
@@ -21,6 +24,7 @@ export function NoteCardView({ card, onUpdate }: Props) {
return ( return (
<div <div
className="card note-card" className="card note-card"
onPointerDownCapture={onFocus}
style={ style={
{ {
left: vp.x + card.x * vp.scale, left: vp.x + card.x * vp.scale,
@@ -33,9 +37,13 @@ export function NoteCardView({ card, onUpdate }: Props) {
} as React.CSSProperties } as React.CSSProperties
} }
> >
<div className="card-header" {...drag}> <CardHeader
note title={card.title ?? ""}
</div> placeholder="Note"
onTitleChange={(t) => onUpdate({ title: t })}
onClose={onClose}
dragProps={drag}
/>
<div <div
className="card-body note-body" className="card-body note-body"
ref={editorRef} ref={editorRef}

View File

@@ -6,6 +6,7 @@ import type { TerminalCard } from "../types";
import { useViewport } from "../viewport"; import { useViewport } from "../viewport";
import { useDragHandle } from "../useDragHandle"; import { useDragHandle } from "../useDragHandle";
import { ResizeHandles } from "./ResizeHandles"; import { ResizeHandles } from "./ResizeHandles";
import { CardHeader } from "./CardHeader";
import { pty, base64ToBytes, stringToBase64 } from "../../ipc"; import { pty, base64ToBytes, stringToBase64 } from "../../ipc";
const BASE_FONT_SIZE = 13; const BASE_FONT_SIZE = 13;
@@ -13,9 +14,11 @@ const BASE_FONT_SIZE = 13;
interface Props { interface Props {
card: TerminalCard; card: TerminalCard;
onUpdate: (patch: Partial<TerminalCard>) => void; onUpdate: (patch: Partial<TerminalCard>) => void;
onClose: () => void;
onFocus: () => void;
} }
export function TerminalCardView({ card, onUpdate }: Props) { export function TerminalCardView({ card, onUpdate, onClose, onFocus }: Props) {
const vp = useViewport(); const vp = useViewport();
const drag = useDragHandle(card, (p) => onUpdate(p)); const drag = useDragHandle(card, (p) => onUpdate(p));
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -106,6 +109,7 @@ export function TerminalCardView({ card, onUpdate }: Props) {
return ( return (
<div <div
className="card terminal-card" className="card terminal-card"
onPointerDownCapture={onFocus}
style={ style={
{ {
left: vp.x + card.x * vp.scale, left: vp.x + card.x * vp.scale,
@@ -117,9 +121,13 @@ export function TerminalCardView({ card, onUpdate }: Props) {
} as React.CSSProperties } as React.CSSProperties
} }
> >
<div className="card-header" {...drag}> <CardHeader
terminal title={card.title ?? ""}
</div> placeholder="Terminal"
onTitleChange={(t) => onUpdate({ title: t })}
onClose={onClose}
dragProps={drag}
/>
<div <div
className="card-body terminal-body" className="card-body terminal-body"
ref={containerRef} ref={containerRef}

View File

@@ -15,11 +15,13 @@ export interface BaseCard {
export interface NoteCard extends BaseCard { export interface NoteCard extends BaseCard {
kind: "note"; kind: "note";
text: string; text: string;
title?: string;
} }
export interface TerminalCard extends BaseCard { export interface TerminalCard extends BaseCard {
kind: "terminal"; kind: "terminal";
ptyId: string; ptyId: string;
title?: string;
} }
export interface ThumbnailCard extends BaseCard { export interface ThumbnailCard extends BaseCard {
@@ -35,3 +37,10 @@ export interface Viewport {
y: number; y: number;
scale: number; scale: number;
} }
export interface BoardState {
version: number;
cards: Card[];
viewport: Viewport;
maxZ: number;
}

View File

@@ -1,5 +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";
export interface PtySpawnOpts { export interface PtySpawnOpts {
cols: number; cols: number;
@@ -48,3 +49,8 @@ export function base64ToBytes(b64: string): Uint8Array {
export function stringToBase64(s: string): string { export function stringToBase64(s: string): string {
return bytesToBase64(new TextEncoder().encode(s)); return bytesToBase64(new TextEncoder().encode(s));
} }
export const board = {
save: (state: BoardState) => invoke<void>("board_save", { state }),
load: () => invoke<BoardState | null>("board_load"),
};