Skip to content

About

Desktop spreadsheet in Tauri 2 + React 19: canvas grid, HyperFormula engine, ~120 extra functions, XLSX/CSV I/O

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Sheets Clone

A desktop spreadsheet built with Tauri 2, React 19, TypeScript, Zustand and HyperFormula. The grid is rendered to a <canvas> so large sheets stay smooth, formula evaluation runs in a Web Worker, and the whole thing ships as a native desktop binary. The formula language aims to be compatible with the functions people already know from Excel and Google Sheets.

Sheets Clone with a small demo sales table

Demo data only.

The app also runs as a plain web app (npm run dev) with a reduced feature set — anything that needs the real filesystem degrades to browser download / file-picker flows.


Quick start

npm ci
npm run dev            # web-only, http://localhost:5173
npm run tauri:dev      # full desktop app (requires the Rust toolchain)

Prerequisites

Requirement Notes
Node.js 20.19+ or 22.12+ Floor set by Vite 8; .nvmrc pins 22 (active LTS). ESM-only project ("type": "module").
Rust + Cargo Only needed for tauri:dev / tauri:build. CI builds on stable; rust-toolchain.toml pins the channel.
Linux system deps webkit2gtk-4.1, libsoup-3.0, librsvg2-dev — see the Tauri prerequisites

npm run dev alone needs no Rust toolchain.


Scripts

Script What it does
npm run dev Vite dev server (web-only mode)
npm run build Type-agnostic production bundle into dist/
npm run tauri:dev Build the frontend and launch the native window
npm run tauri:build Produce distributable bundles (deb / AppImage / dmg / msi)
npm run build:release Compile the desktop binary only (tauri build --no-bundle)
npm test Vitest unit + integration suite
npm run test:watch Vitest in watch mode
npm run test:rust cargo test for the Rust backend
npm run typecheck tsc --noEmit over src/
npm run lint ESLint (includes React Compiler diagnostics)
npm run lint:fix ESLint with --fix
npm run check Everything CI runs: typecheck → lint → test → build
npm run collab-server Local WebSocket relay for real-time collaboration

Architecture

┌──────────────────────────── Tauri window ────────────────────────────┐
│                                                                      │
│  React 19 frontend (src/)                                            │
│  ┌────────────────────────────────────────────────────────────────┐  │
│  │ App.tsx            menus, global keyboard map, startup import   │  │
│  │ components/        CanvasGrid + ~40 dialogs / panels            │  │
│  │ store/             Zustand store — the single source of truth   │  │
│  │ core/              workbook model, cell types, pivot, rich text │  │
│  │ engine/            HyperFormula wrapper + 6 function plugins    │  │
│  │ utils/             IndexedDB persistence, dialog service        │  │
│  │ collab/            WebSocket client for shared editing          │  │
│  │ bridge/            typed wrapper over Tauri `invoke`            │  │
│  └────────────────────────────────────────────────────────────────┘  │
│                                 │ IPC                                │
│  Rust backend (src-tauri/src/)  ▼                                    │
│  ┌────────────────────────────────────────────────────────────────┐  │
│  │ main.rs            command handlers, single-instance, file args │  │
│  │ io/filesystem.rs   path validation (home-directory jail)        │  │
│  │ io/xlsx.rs         .xlsx / legacy .xls import                   │  │
│  │ io/csv_io.rs       CSV import / export                          │  │
│  │ cells/             server-side workbook storage                 │  │
│  └────────────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────────────┘

Where state lives

The frontend Zustand store is the source of truth. The Rust side keeps its own WorkbookStorage, but the frontend never syncs edits back into it — this is why write_document takes the serialized payload as an argument rather than reading AppState (see the comment on that command). Keep this in mind before wiring any new feature through the Rust workbook state.

A consequence worth knowing before you touch src-tauri: of the 20 registered commands, the frontend only ever calls five — write_document, read_document, get_startup_import, read_import_file, and the dialog helpers. The cell, sheet and CSV/XLSX commands (and the cells/ + io/csv_io modules behind them) are reachable over IPC but unreferenced by the UI, which is where the backend's dead_code warnings come from. They are kept — and hardened against malformed input — rather than deleted, because the XLSX/XLS import path is actively being developed. Do not assume a Rust command is exercised just because it exists.

Formula evaluation

src/engine/formulaEngine.ts wraps HyperFormula and registers six function plugins on top of its built-ins (statistics, finance, text/info, database, dynamic arrays, operators — roughly 120 extra functions, plus LET, LAMBDA and the MAP/REDUCE/SCAN/BYROW/ BYCOL/MAKEARRAY family implemented in the engine itself). Heavy recalculation is offloaded to formulaWorker.ts via Comlink.

Persistence

  • Auto-save writes to IndexedDB, chunked (50k cells per record) and double-buffered across two generations so an interrupted save can never corrupt the last good snapshot. See src/utils/persistence.ts.
  • Explicit save (Ctrl+S) writes a JSON document through the Rust write_document command, path-validated to stay inside the user's home directory.

Feature overview

Grid — canvas rendering, frozen panes, merged cells, row/column grouping, hidden rows/columns, resizable headers, zoom (50–200%), dark mode.

Formulas — HyperFormula core plus ~120 extra functions, named ranges, dynamic arrays, formula auditing (precedents/dependents), a cell watch panel, and a function browser.

Data — sort, filter views, remove duplicates, data validation, conditional formatting, pivot tables (dialog + live side panel), charts.

Collaboration — optional WebSocket relay with remote cursors and per-cell broadcast.

I/O — import/export XLSX, legacy XLS import, CSV/TSV, JSON; export to HTML and PDF; OS file associations and --import <path> on the command line.

Productivity — command palette (Ctrl+K), find & replace, go-to-cell, comments, version history, protected ranges, paste special, workbook compare, custom functions.


Collaboration server

npm run collab-server                                  # ws://127.0.0.1:9753
PORT=9000 npm run collab-server                        # custom port
HOST=0.0.0.0 COLLAB_TOKEN=secret npm run collab-server # expose on the network

The relay broadcasts every message to every other client in the same ?room=. It is a development convenience, not a production service, so it is deliberately hard to expose by accident:

Control Behaviour
Bind address Loopback (127.0.0.1) unless HOST says otherwise
Non-loopback bind Refuses to start without COLLAB_TOKEN
COLLAB_TOKEN Required as ?token=…, compared in constant time
Origin Browser handshakes must match the allowlist (extend with COLLAB_ALLOWED_ORIGINS)
Limits 500 rooms, 32 clients/room, 1000 total, 5 MB/s and 200 msg/s per client
GET /healthz { ok } always; room/client counts only with the token

The Origin check matters more than it looks: WebSocket handshakes are exempt from CORS, so without it any web page the user happens to visit could open ws://localhost:9753, send {"type":"sync_request"} and receive the whole workbook from a real peer. Requests with no Origin header (native clients, tests) are still accepted, since only browsers send one.

ws:// is unencrypted — put a TLS-terminating proxy in front and use wss:// for anything beyond a trusted LAN.


Testing

npm run check            # what CI runs: typecheck, lint, test, build
npm test                 # vitest across engine, store, core, utils, collab
npm run test:rust        # Rust backend integration tests

There is no DOM test environment installed, so .tsx components are not directly testable. The convention is to extract the logic instead: src/core/htmlExport.ts, src/core/csvExport.ts, src/utils/focus.ts and src/utils/url.ts all exist because the behaviour worth testing was pulled out of a component or a store action into a pure module.

src/collab/collabServer.test.ts is a Node-environment integration test that boots the real relay over a socket; it is excluded from tsconfig.app.json (which is browser-typed) but Vitest still discovers and runs it.


Project conventions

  • No new dependencies without a clear reason. The bundle is already dominated by HyperFormula and SheetJS.
  • Errors are never silently swallowed. Surface them through toastMessage or the dialog service.
  • Never call native alert / confirm / prompt. Use showAlert / showConfirm / showPrompt from src/utils/dialogService.ts — the natives block the UI thread, ignore the theme, and prompt() is unimplemented in WKWebView (macOS builds).
  • Structural mutations (insert/delete row/column) live in one place in spreadsheetStore.ts and are shared by the actions and by undo/redo, so a change and its reversal cannot drift apart.
  • Path validation is mandatory for every Rust command that touches the filesystem — call io::filesystem::validate_path first, and check_readable_size before reading. User-facing errors carry display_name(path), not the absolute path.
  • Never interpolate a cell value into HTML by hand. Cell contents, sheet names and colours all arrive from imported files and collaboration peers. Use escapeHtml / safeCssColor from src/utils/html.ts, or better, the builders in src/core/htmlExport.ts.
  • Never write a raw value into a CSV field. Use csvField() from src/core/csvExport.ts; it handles RFC 4180 quoting and neutralizes values that a receiving spreadsheet would execute as a formula.
  • Validate every URL before opening or storing it with safeExternalUrl from src/utils/url.ts — format.hyperlink is attacker-influenced.
  • The window-level key handler must not claim keys that belong to a focused control. isNonGridControlFocused in src/utils/focus.ts is the gate; without it, Tab and Enter are stolen from every button in the app.

Desktop launcher

launch-release.sh is meant to be bound to a desktop shortcut, and is what the Open With entry installed by scripts/install-desktop-entry.sh (Linux/XDG) runs. It, which starts the compiled binary at src-tauri/target/release/spreadsheet-backend.

The binary embeds the frontend at compile time. A binary older than the sources therefore launches a stale app with no indication that anything is wrong. To stop that happening silently, launch-release.sh compares the binary's mtime against src/, public/, index.html, package.json, vite.config.ts and src-tauri/ before launching, and rebuilds when they have moved on. Test files are excluded, so editing a *.test.ts does not trigger a rebuild.

Invocation Behaviour
./launch-release.sh Rebuild if stale (desktop notification), then launch
./launch-release.sh -n Launch immediately, stale or not
./launch-release.sh -B Rebuild even if current
SPREADSHEET_AUTO_BUILD=0 Make -n the default

When nothing has changed the check costs ~20 ms. A rebuild takes a couple of minutes and is serialised with flock, so launching twice will not start two builds. If a build fails, the previous binary is launched anyway and a notification points at $TMPDIR/spreadsheet-build.log — a working old app beats no app.

launch.sh is the development launcher (tauri dev, hot reload). It is not bound to any shortcut.

Two traps worth knowing about

Build the binary with tauri build, not cargo build. Tauri's custom-protocol feature is not in the crate's default features, so a plain cargo build --release produces a binary that loads devUrl (localhost:5173) instead of the embedded frontend — it opens to "Could not connect to localhost: Connection refused" unless a dev server happens to be running. npm run build:release uses tauri build --no-bundle, which sets the feature and skips the bundling stage (the AppImage step downloads linuxdeploy at build time and hangs without network access).

A desktop launcher inherits none of your shell. With a bare PATH, node resolves to the distro node, which may be older than Vite 8 accepts and Vite 8 refuses to run on (CustomEvent is not defined). scripts/launch-common.sh puts a Node matching .nvmrc on PATH before invoking npm; both launch scripts source it.

Checking which build is running: the launch log records it.

tail -3 "${TMPDIR:-/tmp}/spreadsheet-launch-release.log"
# ... launched release binary (built 2026-08-20 13:30:18) with PID 3757707

Note that pgrep -x spreadsheet-backend never matches — Linux truncates the process name to 15 characters. Use pgrep -f target/release/spreadsheet-backend.


Continuous integration

.github/workflows/ci.yml runs on every push to main and every pull request:

  • Frontend — npm ci → typecheck → lint → test → build.
  • Backend — cargo test, plus an advisory cargo clippy.

npm run check runs the frontend half locally, so "it passes on my machine" means the same thing CI means.

Two deliberate choices:

  • Lint is gated at --max-warnings 66, not zero. The React Compiler diagnostics are configured as warnings on purpose (see the rationale comment in eslint.config.js); the cap stops the count from growing without demanding a big-bang cleanup first.
  • Clippy is advisory. The Rust side carries dead_code warnings for commands the frontend no longer calls — see Where state lives. Gating on them would mean deleting that surface in the same change that turns the gate on.

Toolchains are pinned: .nvmrc (Node 22) and rust-toolchain.toml (stable).


Troubleshooting

Symptom Fix
tauri:dev fails on libsoup / webkit2gtk Install the Tauri Linux prerequisites; export PKG_CONFIG_PATH if your pkg-config files live somewhere non-standard
Blank window after tauri:dev Vite must be listening on 5173 first; strictPort is on, so a stale process on that port will break the launch
The launcher opens an old version The binary embeds the frontend; rebuild with npm run build:release. launch-release.sh now does this automatically — see Desktop launcher
App opens to "Could not connect to localhost" The binary was built with cargo build instead of tauri build, so it wants a dev server. Rebuild with npm run build:release
The launcher appears to do nothing An instance is already running and the single-instance plugin forwarded to its window. pgrep -f target/release/spreadsheet-backend
Auto-save toast says storage is full Export the workbook and delete old saved workbooks — IndexedDB quota is per-origin

Status / limitations

This is a personal project that I use day to day, not a finished product. Known gaps:

  • Dynamic arrays and lambda helpers are sheet-local. App-level dynamic arrays (FILTER, SORT, UNIQUE, TRANSPOSE, SPLIT) and MAP/REDUCE/SCAN/... resolve ranges on the current sheet only, so a cross-sheet reference like Sheet1!A2:A40 reads the wrong sheet. The plugin array functions (HSTACK, SORTN, ...) do handle cross-sheet refs.
  • SUMPRODUCT cannot do range arithmetic (a HyperFormula limitation) — split it into separate terms.
  • Functions that need an external service are not implemented: AI, GOOGLEFINANCE, GOOGLETRANSLATE, DETECTLANGUAGE, IMPORTDATA/IMPORTFEED/IMPORTHTML/IMPORTXML, GETPIVOTDATA.
  • Forwarding a file to an already-running instance (--import on a second launch) is has been unreliable — the Rust side emits open-file, but the import has not always been applied. If a file does not appear, close the app and launch it with the file instead.
  • File access from the desktop app is restricted to the user's home directory by design.
  • Only Linux builds are exercised regularly; macOS/Windows bundles are configured but untested.
  • No web fonts are bundled; the UI uses the system font stack (Inter if installed).

License

MIT — see LICENSE. Not affiliated with or endorsed by Google, Microsoft or any other spreadsheet vendor; product names are used only to describe formula compatibility.

About

Desktop spreadsheet in Tauri 2 + React 19: canvas grid, HyperFormula engine, ~120 extra functions, XLSX/CSV I/O

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages