@legendapp/create-spark 0.0.0-stage → 0.0.1-next.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -2
  3. package/dist/index.js +174697 -0
  4. package/dist/npm-bin/npm +6 -0
  5. package/dist/npm-bin/npm.cmd +1 -0
  6. package/package.json +30 -4
  7. package/templates/blank-typescript/.claude/skills/spark/SKILL.md +9 -0
  8. package/templates/blank-typescript/AGENTS.md +4 -0
  9. package/templates/blank-typescript/App.tsx +23 -0
  10. package/templates/blank-typescript/CLAUDE.md +4 -0
  11. package/templates/blank-typescript/README.md +15 -0
  12. package/templates/blank-typescript/app.json +11 -0
  13. package/templates/blank-typescript/assets/icon.png +0 -0
  14. package/templates/blank-typescript/desktop.config.json +21 -0
  15. package/templates/blank-typescript/gitignore +8 -0
  16. package/templates/blank-typescript/index.ts +5 -0
  17. package/templates/blank-typescript/metro.config.js +3 -0
  18. package/templates/blank-typescript/package.json +101 -0
  19. package/templates/blank-typescript/tsconfig.json +10 -0
  20. package/templates/diff-lite/App.tsx +34 -0
  21. package/templates/diff-lite/Git.macos.tsx +13 -0
  22. package/templates/diff-lite/Git.tsx +1 -0
  23. package/templates/diff-lite/compare.ts +12 -0
  24. package/templates/document-editor/App.tsx +34 -0
  25. package/templates/document-editor/Integration.macos.tsx +59 -0
  26. package/templates/document-editor/Integration.tsx +13 -0
  27. package/templates/document-editor/Integration.web.tsx +14 -0
  28. package/templates/document-editor/Integration.windows.tsx +59 -0
  29. package/templates/document-editor/document.ts +58 -0
  30. package/templates/document-editor/io.desktop.ts +27 -0
  31. package/templates/document-editor/io.macos.ts +7 -0
  32. package/templates/document-editor/io.ts +31 -0
  33. package/templates/document-editor/io.web.ts +21 -0
  34. package/templates/document-editor/io.windows.ts +1 -0
  35. package/templates/document-editor/sessions.ts +8 -0
  36. package/templates/document-editor/useDocument.ts +6 -0
  37. package/templates/example-shared/Lifecycle.desktop.tsx +25 -0
  38. package/templates/example-shared/Lifecycle.macos.tsx +1 -0
  39. package/templates/example-shared/Lifecycle.tsx +14 -0
  40. package/templates/example-shared/Lifecycle.web.tsx +20 -0
  41. package/templates/example-shared/Lifecycle.windows.tsx +1 -0
  42. package/templates/example-shared/OpenFiles.desktop.tsx +23 -0
  43. package/templates/example-shared/OpenFiles.macos.tsx +1 -0
  44. package/templates/example-shared/OpenFiles.tsx +2 -0
  45. package/templates/example-shared/OpenFiles.windows.tsx +1 -0
  46. package/templates/example-shared/document.ts +58 -0
  47. package/templates/example-shared/io.desktop.ts +27 -0
  48. package/templates/example-shared/io.macos.ts +7 -0
  49. package/templates/example-shared/io.ts +31 -0
  50. package/templates/example-shared/io.web.ts +21 -0
  51. package/templates/example-shared/io.windows.ts +1 -0
  52. package/templates/example-shared/lifecycle-types.ts +10 -0
  53. package/templates/example-shared/lifetime.ts +23 -0
  54. package/templates/example-shared/records.ts +51 -0
  55. package/templates/example-shared/storage.ts +6 -0
  56. package/templates/example-shared/useModel.ts +6 -0
  57. package/templates/music-lite/Activation.desktop.tsx +4 -0
  58. package/templates/music-lite/Activation.macos.tsx +1 -0
  59. package/templates/music-lite/Activation.tsx +1 -0
  60. package/templates/music-lite/Activation.windows.tsx +1 -0
  61. package/templates/music-lite/App.tsx +38 -0
  62. package/templates/music-lite/assets.desktop.ts +7 -0
  63. package/templates/music-lite/assets.macos.ts +1 -0
  64. package/templates/music-lite/assets.ts +18 -0
  65. package/templates/music-lite/assets.web.ts +37 -0
  66. package/templates/music-lite/assets.windows.ts +1 -0
  67. package/templates/music-lite/model.ts +91 -0
  68. package/templates/music-lite/store.ts +10 -0
  69. package/templates/notes-lite/Activation.desktop.tsx +5 -0
  70. package/templates/notes-lite/Activation.macos.tsx +1 -0
  71. package/templates/notes-lite/Activation.tsx +1 -0
  72. package/templates/notes-lite/Activation.windows.tsx +1 -0
  73. package/templates/notes-lite/App.tsx +92 -0
  74. package/templates/notes-lite/Desktop.desktop.ts +47 -0
  75. package/templates/notes-lite/Desktop.macos.ts +1 -0
  76. package/templates/notes-lite/Desktop.ts +7 -0
  77. package/templates/notes-lite/Desktop.windows.ts +1 -0
  78. package/templates/notes-lite/Theme.tsx +17 -0
  79. package/templates/notes-lite/model.ts +61 -0
  80. package/templates/notes-lite/search.ts +3 -0
  81. package/templates/notes-lite/session.ts +15 -0
  82. package/templates/notes-lite/store.ts +4 -0
  83. package/templates/notes-lite/window-session.ts +63 -0
  84. package/templates/universal/.claude/skills/spark/SKILL.md +9 -0
  85. package/templates/universal/AGENTS.md +4 -0
  86. package/templates/universal/App.tsx +57 -0
  87. package/templates/universal/CLAUDE.md +4 -0
  88. package/templates/universal/README.md +15 -0
  89. package/templates/universal/app.json +15 -0
  90. package/templates/universal/assets/icon.png +0 -0
  91. package/templates/universal/desktop.config.json +64 -0
  92. package/templates/universal/gitignore +10 -0
  93. package/templates/universal/global.css +31 -0
  94. package/templates/universal/index.ts +4 -0
  95. package/templates/universal/metro.config.js +7 -0
  96. package/templates/universal/package.json +115 -0
  97. package/templates/universal/react-native.config.js +2 -0
  98. package/templates/universal/tsconfig.json +10 -0
  99. package/templates/universal/uniwind-types.d.ts +10 -0
  100. package/templates/windows/.claude/skills/spark/SKILL.md +9 -0
  101. package/templates/windows/AGENTS.md +4 -0
  102. package/templates/windows/App.tsx +16 -0
  103. package/templates/windows/CLAUDE.md +4 -0
  104. package/templates/windows/README.md +15 -0
  105. package/templates/windows/app.json +11 -0
  106. package/templates/windows/assets/icon.png +0 -0
  107. package/templates/windows/desktop.config.json +10 -0
  108. package/templates/windows/gitignore +8 -0
  109. package/templates/windows/index.ts +3 -0
  110. package/templates/windows/metro.config.js +3 -0
  111. package/templates/windows/package.json +102 -0
  112. package/templates/windows/react-native.config.js +2 -0
  113. package/templates/windows/tsconfig.json +10 -0
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // npm 11's CLI; avoid installer-specific shell wrappers and global npm versions.
3
+ import { createRequire } from 'node:module';
4
+ import path from 'node:path';
5
+ const require = createRequire(import.meta.url);
6
+ require(path.join(path.dirname(require.resolve('npm/package.json')), 'bin/npm-cli.js'));
@@ -0,0 +1 @@
1
+ @node "%~dp0npm" %*
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@legendapp/create-spark",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1-next.5",
4
+ "description": "Create a Legend Spark app",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "create-spark": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "templates"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public",
16
+ "tag": "next"
17
+ },
18
+ "engines": {
19
+ "node": ">=24.19.0"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/LegendApp/legend-spark.git",
24
+ "directory": "packages/create-spark"
25
+ },
26
+ "dependencies": {
27
+ "cross-spawn": "7.0.6",
28
+ "expo-desktop": "1.0.0",
29
+ "npm": "11.21.1",
30
+ "yaml": "2.9.1"
31
+ }
32
+ }
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: spark
3
+ description: Develop, build, diagnose, or upgrade a Legend Spark desktop app.
4
+ ---
5
+
6
+ <!-- spark:skill:start -->
7
+ From the app root, read [Spark's app development skill](node_modules/@legendapp/spark/skills/spark/SKILL.md) before working on this app.
8
+ The installed Spark version owns these instructions; do not copy them here.
9
+ <!-- spark:skill:end -->
@@ -0,0 +1,4 @@
1
+ <!-- spark:skill:start -->
2
+ From the app root, read [Spark's app development skill](node_modules/@legendapp/spark/skills/spark/SKILL.md) before working on this app.
3
+ The installed Spark version owns these instructions; do not copy them here.
4
+ <!-- spark:skill:end -->
@@ -0,0 +1,23 @@
1
+ import React, { useState } from "react";
2
+ import { Button, StyleSheet, Text, View } from "react-native";
3
+ import { openFileDialog } from "@legendapp/spark/dialogs";
4
+ import { useMenu, type MenuRootItem } from "@legendapp/spark/menus";
5
+
6
+ const menus: MenuRootItem[] = [{ type: "submenu", id: "hello", label: "Hello", items: [{ type: "action", id: "greet", label: "Say Hello" }] }];
7
+ export default function App({ runtime }: { runtime?: { mode: string } }) {
8
+ const [message, setMessage] = useState("Ready");
9
+ useMenu({ id: "hello-world", items: menus, onAction: action => { if (action.itemId === "greet") setMessage("Hello from the native menu"); }, onError: error => setMessage(String(error)) });
10
+ async function chooseFile() {
11
+ try {
12
+ const files = await openFileDialog({ title: "Choose a file", multiple: false });
13
+ setMessage(files.canceled ? "Dialog cancelled" : files.paths.join(", "));
14
+ } catch (error) { setMessage(String(error)); }
15
+ }
16
+ return <View style={styles.root}>
17
+ <Text style={styles.title}>Hello, Legend Spark</Text>
18
+ <Text style={styles.text}>Runtime: {runtime?.mode ?? "unknown"}</Text>
19
+ <Button title="Choose a file" onPress={chooseFile} />
20
+ <Text style={styles.text} accessible accessibilityLabel={message}>{message}</Text>
21
+ </View>;
22
+ }
23
+ const styles = StyleSheet.create({ root: { flex: 1, alignItems: "center", justifyContent: "center", gap: 16, backgroundColor: "#f5f5f7" }, text: { color: "#18181b" }, title: { fontSize: 32, fontWeight: "600", color: "#18181b" } });
@@ -0,0 +1,4 @@
1
+ <!-- spark:skill:start -->
2
+ From the app root, read [Spark's app development skill](node_modules/@legendapp/spark/skills/spark/SKILL.md) before working on this app.
3
+ The installed Spark version owns these instructions; do not copy them here.
4
+ <!-- spark:skill:end -->
@@ -0,0 +1,15 @@
1
+ # Spark app
2
+
3
+ Run the app with `macos` using your chosen package manager:
4
+
5
+ ```sh
6
+ npm run macos
7
+ ```
8
+
9
+ macOS opens in Spark Runner. Windows requires a custom development build and is
10
+ an unverified preview. JavaScript edits use Fast Refresh. Use `npm run build` for
11
+ a standalone macOS app; native builds require Xcode and CocoaPods.
12
+
13
+ Use the equivalent `bun run`, `pnpm run`, or `yarn` commands for those managers.
14
+ See [Spark documentation](https://legend.so/spark) for configuration, upgrades,
15
+ platform prerequisites, and distribution.
@@ -0,0 +1,11 @@
1
+ {
2
+ "expo": {
3
+ "name": "HelloWorld",
4
+ "slug": "helloworld",
5
+ "version": "0.0.1",
6
+ "platforms": [
7
+ "macos"
8
+ ],
9
+ "icon": "./assets/icon.png"
10
+ }
11
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "./node_modules/@legendapp/spark/schema.json",
3
+ "name": "HelloWorld",
4
+ "version": "0.0.1",
5
+ "window": {
6
+ "size": {
7
+ "width": 1000,
8
+ "height": 700
9
+ },
10
+ "restoreBounds": true
11
+ },
12
+ "macos": {
13
+ "bundleIdentifier": "so.legend.spark.prototype.helloworld"
14
+ },
15
+ "platforms": [
16
+ "macos"
17
+ ],
18
+ "expo": {
19
+ "icon": "./assets/icon.png"
20
+ }
21
+ }
@@ -0,0 +1,8 @@
1
+ node_modules/
2
+ .threaded-runtime/
3
+ .spark/
4
+ macos/
5
+ dist/
6
+ app.json
7
+ windows/
8
+ .expo/
@@ -0,0 +1,5 @@
1
+ require("@legendapp/spark/runtime-entry");
2
+ if (!(globalThis as any).__THREADED_RUNTIME_ENV__) {
3
+ const { registerRootComponent } = require("expo");
4
+ registerRootComponent(require("./App").default);
5
+ }
@@ -0,0 +1,3 @@
1
+ const { makeMetroConfig } = require("expo-desktop-metro-config");
2
+ const { withDesktop } = require("@legendapp/spark/metro");
3
+ module.exports = withDesktop(makeMetroConfig(__dirname));
@@ -0,0 +1,101 @@
1
+ {
2
+ "name": "spark-template-blank-typescript",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "main": "index.ts",
6
+ "scripts": {
7
+ "start": "spark dev",
8
+ "macos": "spark dev",
9
+ "dev": "spark dev",
10
+ "build": "spark build",
11
+ "package": "spark package",
12
+ "doctor": "spark doctor",
13
+ "postinstall": "node node_modules/@legendapp/spark/init-template.cjs"
14
+ },
15
+ "dependencies": {
16
+ "@legendapp/spark": "0.0.1-next.5",
17
+ "expo": "54.0.37",
18
+ "expo-desktop": "1.0.0",
19
+ "expo-desktop-prebuild-config": "1.1.0",
20
+ "expo-desktop-config-plugins": "1.2.0",
21
+ "expo-desktop-metro-config": "54.81.0",
22
+ "expo-desktop-template-bare-minimum": "54.81.1",
23
+ "react": "19.1.4",
24
+ "react-dom": "19.1.4",
25
+ "react-native": "0.81.6",
26
+ "react-native-macos": "0.81.7",
27
+ "@react-native-community/cli": "20.1.3",
28
+ "typescript": "5.9.3",
29
+ "@types/react": "19.1.10",
30
+ "@react-native-runtimes/core": "0.1.0-alpha.2"
31
+ },
32
+ "overrides": {
33
+ "@expo/cli": "54.0.27",
34
+ "react-native-nitro-modules": "0.37.0",
35
+ "react-native-webview": "16.0.0",
36
+ "@op-engineering/op-sqlite": "18.2.1",
37
+ "expo-asset": "12.0.13",
38
+ "expo-constants": "18.0.14",
39
+ "expo-file-system": "19.0.24",
40
+ "expo-font": "14.0.12",
41
+ "expo-keep-awake": "15.0.8",
42
+ "expo-modules-core": "3.0.30",
43
+ "expo-desktop-modules-core": "54.0.14",
44
+ "expo-desktop-stubs": "54.0.14",
45
+ "react": "19.1.4",
46
+ "react-native": "0.81.6",
47
+ "react-native-macos": "0.81.7",
48
+ "react-native-windows": "0.81.35",
49
+ "postcss": "8.5.31",
50
+ "dompurify": "3.4.17",
51
+ "@tiptap/core": "3.32.0",
52
+ "@tiptap/extension-blockquote": "3.32.0",
53
+ "@tiptap/extension-bold": "3.32.0",
54
+ "@tiptap/extension-code": "3.32.0",
55
+ "@tiptap/extension-document": "3.32.0",
56
+ "@tiptap/extension-heading": "3.32.0",
57
+ "@tiptap/extension-history": "3.32.0",
58
+ "@tiptap/extension-image": "3.32.0",
59
+ "@tiptap/extension-italic": "3.32.0",
60
+ "@tiptap/extension-link": "3.32.0",
61
+ "@tiptap/extension-list": "3.32.0",
62
+ "@tiptap/extension-paragraph": "3.32.0",
63
+ "@tiptap/extension-strike": "3.32.0",
64
+ "@tiptap/extension-text": "3.32.0",
65
+ "@tiptap/extension-text-align": "3.32.0",
66
+ "@tiptap/extension-underline": "3.32.0",
67
+ "@tiptap/extensions": "3.32.0",
68
+ "@tiptap/pm": "3.32.0",
69
+ "@tiptap/react": "3.32.0",
70
+ "@tiptap/extension-bubble-menu": "3.32.0",
71
+ "@tiptap/extension-floating-menu": "3.32.0",
72
+ "fast-xml-parser": "5.11.2",
73
+ "react-dom": "19.1.4"
74
+ },
75
+ "expo": {
76
+ "install": {
77
+ "exclude": [
78
+ "react",
79
+ "react-dom",
80
+ "react-native"
81
+ ]
82
+ }
83
+ },
84
+ "allowScripts": {
85
+ "@legendapp/spark": true,
86
+ "react-native-enriched-markdown": true,
87
+ "fsevents": true
88
+ },
89
+ "trustedDependencies": [
90
+ "@legendapp/spark",
91
+ "react-native-enriched-markdown",
92
+ "fsevents"
93
+ ],
94
+ "pnpm": {
95
+ "onlyBuiltDependencies": [
96
+ "@legendapp/spark",
97
+ "react-native-enriched-markdown",
98
+ "fsevents"
99
+ ]
100
+ }
101
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "expo/tsconfig.base",
3
+ "compilerOptions": {
4
+ "strict": true
5
+ },
6
+ "include": [
7
+ "**/*.ts",
8
+ "**/*.tsx"
9
+ ]
10
+ }
@@ -0,0 +1,34 @@
1
+ import { useMemo, useState } from "react";
2
+ import { FlatList, SafeAreaView, StyleSheet, Text, View } from "react-native";
3
+ import { Button } from "@legendapp/spark/ui";
4
+ import { io } from "./shared/io";
5
+ import { Lifecycle } from "./shared/Lifecycle";
6
+ import { compare } from "./compare";
7
+ import { Git } from "./Git";
8
+ const clean = () => false, flush = async () => true;
9
+ export default function App({ windowId = "main" }: { windowId?: string }) {
10
+ const [left, setLeft] = useState({ name: "Original", text: "A shared application\nNative platform behavior\n" });
11
+ const [right, setRight] = useState({ name: "Changed", text: "A shared application\nNative platform behavior\nOne codebase\n" });
12
+ const [error, setError] = useState<string | null>(null);
13
+ const [patch, setPatch] = useState<string | null>(null);
14
+ const [busy, setBusy] = useState(false);
15
+ const commands = useMemo(() => [{ id: "open", title: "Open original…", key: "O", run: () => open("left") }], []);
16
+ async function open(side: "left" | "right") {
17
+ setBusy(true); setError(null);
18
+ try { const file = await io.open(); if (file) { if (file.text.length > 200_000) throw new Error("Choose a file smaller than 200,000 characters."); (side === "left" ? setLeft : setRight)({ name: file.file.name, text: file.text }); setPatch(null); } }
19
+ catch (error) { setError(String(error)); } finally { setBusy(false); }
20
+ }
21
+ const comparison = useMemo(() => { try { return { rows: compare(left.text, right.text), error: null }; } catch (error) { return { rows: [], error: String(error) }; } }, [left.text, right.text]);
22
+ const patchLines = useMemo(() => patch?.split("\n"), [patch]);
23
+ return <SafeAreaView style={styles.root}>
24
+ <Lifecycle title="Diff" windowId={windowId} flush={flush} dirty={clean} commands={commands} onError={setError} />
25
+ <Text style={styles.title}>Diff</Text><Text>{left.name} → {right.name}</Text>
26
+ <View style={styles.toolbar}><Button disabled={busy} onPress={() => { void open("left"); }}>Open original</Button><Button disabled={busy} onPress={() => { void open("right"); }}>Open changed</Button><Button onPress={() => { setLeft(right); setRight(left); setPatch(null); }}>Swap files</Button><Git onResult={setPatch} onError={setError} /></View>
27
+ <Text>− Removed · + Added · unchanged lines have no marker</Text>
28
+ {error || comparison.error ? <Text accessibilityRole="alert" style={styles.error}>{error ?? comparison.error}</Text> : null}
29
+ {patchLines ? <FlatList data={patchLines} keyExtractor={(_, index) => String(index)} renderItem={({ item }) => <Text selectable style={styles.code}>{item || " "}</Text>} /> : <FlatList data={comparison.rows} keyExtractor={(_, index) => String(index)} renderItem={({ item }) => <View style={[styles.line, item.kind === "added" && styles.added, item.kind === "removed" && styles.removed]}>
30
+ <Text style={styles.number}>{item.left ?? ""}</Text><Text style={styles.number}>{item.right ?? ""}</Text><Text selectable style={styles.code}>{item.kind === "added" ? "+ " : item.kind === "removed" ? "− " : " "}{item.text || " "}</Text>
31
+ </View>} />}
32
+ </SafeAreaView>;
33
+ }
34
+ const styles = StyleSheet.create({ root: { flex: 1, padding: 20, gap: 12, backgroundColor: "#faf9f6" }, title: { fontSize: 28, fontWeight: "600", color: "#222" }, toolbar: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, line: { flexDirection: "row", paddingVertical: 3 }, number: { width: 36, color: "#555", textAlign: "right", paddingRight: 8, fontSize: 12 }, code: { flex: 1, flexShrink: 1, fontFamily: "monospace", fontSize: 13, color: "#222" }, added: { backgroundColor: "#ddf4df" }, removed: { backgroundColor: "#f9dddd" }, error: { color: "#a32020" } });
@@ -0,0 +1,13 @@
1
+ import { useState } from "react";
2
+ import { Button } from "@legendapp/spark/ui";
3
+ import { openFileDialog } from "@legendapp/spark/dialogs";
4
+ import { runCommand } from "@legendapp/spark/processes";
5
+ export function Git({ onResult, onError }: { onResult(text: string): void; onError(message: string): void }) {
6
+ const [busy, setBusy] = useState(false);
7
+ return <Button disabled={busy} onPress={() => { setBusy(true); void (async () => {
8
+ const paths = await openFileDialog({ selection: "directories" }); if (paths.canceled) return;
9
+ const result = await runCommand({ executable: "/usr/bin/git", args: ["--no-pager", "diff", "--no-ext-diff", "--no-textconv", "--", "."], cwd: paths.paths[0], timeoutMs: 10_000 });
10
+ if (result.timedOut || result.outputTruncated || result.exitCode !== 0) throw new Error(result.stderr || "Git comparison failed or exceeded its output limit");
11
+ onResult(result.stdout || "No unstaged changes.");
12
+ })().catch(error => onError(String(error))).finally(() => setBusy(false)); }}>{busy ? "Reading Git…" : "Open Git changes"}</Button>;
13
+ }
@@ -0,0 +1 @@
1
+ export function Git(_props: { onResult(text: string): void; onError(message: string): void }) { return null; }
@@ -0,0 +1,12 @@
1
+ import { diffLines } from "diff";
2
+ export type DiffLine = { kind: "same" | "added" | "removed"; text: string; left: number | null; right: number | null };
3
+ export function compare(left: string, right: string): DiffLine[] {
4
+ if (left.length + right.length > 400_000) throw new Error("Choose text files smaller than 200,000 characters each for this example.");
5
+ const changes = diffLines(left, right, { timeout: 300 });
6
+ if (!changes) throw new Error("These files take too long to compare. Try a smaller selection.");
7
+ let a = 0, b = 0;
8
+ return changes.flatMap(change => {
9
+ const lines = change.value.split("\n"); if (lines.at(-1) === "") lines.pop();
10
+ return lines.map(text => ({ kind: change.added ? "added" as const : change.removed ? "removed" as const : "same" as const, text, left: change.added ? null : ++a, right: change.removed ? null : ++b }));
11
+ });
12
+ }
@@ -0,0 +1,34 @@
1
+ import { useState } from "react";
2
+ import { Platform, SafeAreaView, StyleSheet, Text, TextInput, View } from "react-native";
3
+ import { Button } from "@legendapp/spark/ui";
4
+ import { Integration } from "./Integration";
5
+ import { sessionFor } from "./sessions";
6
+ import { useDocument } from "./useDocument";
7
+ export default function App({ windowId = "main", windowProps }: { windowId?: string; windowProps?: { documentId?: string } }) {
8
+ const documentId = windowProps?.documentId ?? "main";
9
+ const [session] = useState(() => sessionFor(documentId));
10
+ const state = useDocument(session);
11
+ const [guardsReady, setGuardsReady] = useState(Platform.OS !== "macos");
12
+ const copy = ["ios", "android", "web"].includes(Platform.OS);
13
+ return <SafeAreaView style={styles.root}>
14
+ <View style={styles.header}>
15
+ <Text style={styles.title}>{state.file.name}{session.dirty ? " •" : ""}</Text>
16
+ <Text>{copy ? "Open imports a copy. Save exports your text." : state.file.location ?? "Choose a location when saving."}</Text>
17
+ <View style={styles.toolbar}>
18
+ <Button disabled={state.busy || !guardsReady} onPress={() => { void session.newDocument(); }}>New</Button>
19
+ <Button disabled={state.busy || !guardsReady} onPress={() => { void session.open(); }}>Open</Button>
20
+ <Button disabled={state.busy || !guardsReady} onPress={() => { void session.save(); }}>{copy ? "Save a copy" : "Save"}</Button>
21
+ {!copy ? <Button disabled={state.busy || !guardsReady} onPress={() => { void session.save(true); }}>Save As</Button> : null}
22
+ </View>
23
+ <Integration session={session} windowId={windowId} documentId={documentId} onReady={setGuardsReady} />
24
+ {state.error ? <Text accessibilityRole="alert" style={styles.error}>{state.error}</Text> : null}
25
+ </View>
26
+ <TextInput testID="document-body" accessibilityLabel="Document text" multiline editable={guardsReady} value={state.text} onChangeText={session.edit} textAlignVertical="top" style={styles.editor} autoCapitalize="none" autoCorrect={false} />
27
+ <Text style={styles.status}>{state.busy ? "Working…" : session.dirty ? "Unsaved changes" : "Saved"} · {state.text.length} characters</Text>
28
+ </SafeAreaView>;
29
+ }
30
+ const styles = StyleSheet.create({
31
+ root: { flex: 1, backgroundColor: "#faf9f6" }, header: { padding: 20, gap: 10 }, title: { fontSize: 24, fontWeight: "600", color: "#222" },
32
+ toolbar: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, editor: { flex: 1, marginHorizontal: 20, padding: 16, borderWidth: 1, borderColor: "#bbb", borderRadius: 6, backgroundColor: "white", color: "#222", fontSize: 16 },
33
+ error: { color: "#ad2020" }, status: { padding: 16, color: "#555" },
34
+ });
@@ -0,0 +1,59 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Text } from "react-native";
3
+ import { Button } from "@legendapp/spark/ui";
4
+ import { beforeWindowClose, openWindow, setWindowOptions, addWindowListener } from "@legendapp/spark/windows";
5
+ import { beforeQuit } from "@legendapp/spark/app";
6
+ import { useMenu, type MenuItem } from "@legendapp/spark/menus";
7
+ import { registerShortcut } from "@legendapp/spark/shortcuts";
8
+ import { subscribeToOpenRequests } from "@legendapp/spark/app/documents";
9
+ import { readText } from "@legendapp/spark/files";
10
+ import { sessions } from "./sessions";
11
+ import type { DocumentSession } from "./document";
12
+ const items: MenuItem[] = [{ type: "submenu", id: "file", target: { menu: "file" }, label: "File", items: [
13
+ { type: "action", id: "new", label: "New" }, { type: "action", id: "open", label: "Open…" }, { type: "action", id: "save", label: "Save" }, { type: "action", id: "saveAs", label: "Save As…" },
14
+ ] }];
15
+ let nextWindow = 0;
16
+ export function Integration({ session, windowId, documentId, onReady }: { session: DocumentSession; windowId: string; documentId: string; onReady: (ready: boolean) => void }) {
17
+ const [error, setError] = useState<string | null>(null);
18
+ const menu = useMenu({ id: `editor-${windowId}`, items, onAction: event => {
19
+ const actions: Record<string, () => unknown> = { new: () => session.newDocument(), open: () => session.open(), save: () => session.save(), saveAs: () => session.save(true) };
20
+ actions[event.itemId]?.();
21
+ }, onError: error => setError(String(error)) });
22
+ useEffect(() => {
23
+ if (menu.status !== "ready") return;
24
+ let disposed = false, focus: Awaited<ReturnType<typeof addWindowListener>> | undefined;
25
+ void addWindowListener(windowId, "focusChanged", event => { if (event.focused) void menu.menu.update({ items }).catch(error => setError(String(error))); }).then(value => { if (disposed) void value.remove(); else focus = value; }).catch(error => setError(String(error)));
26
+ return () => { disposed = true; void focus?.remove(); };
27
+ }, [menu, windowId]);
28
+ useEffect(() => {
29
+ let removed = false;
30
+ const cleanups: (() => void | Promise<void>)[] = [];
31
+ const retain = async (registration: Promise<{ remove(): void | Promise<void> }>) => {
32
+ const subscription = await registration;
33
+ if (removed) await subscription.remove(); else cleanups.push(() => subscription.remove());
34
+ };
35
+ const actions: Record<string, () => unknown> = { new: () => session.newDocument(), open: () => session.open(), save: () => session.save(), saveAs: () => session.save(true) };
36
+ const title = () => { const state = session.getSnapshot(); void setWindowOptions(windowId, { title: `${state.file.name}${session.dirty ? " •" : ""}` }).catch(e => setError(String(e))); };
37
+ title(); cleanups.push(session.subscribe(title));
38
+ void (async () => {
39
+ await retain(beforeWindowClose(windowId, session.canClose));
40
+ for (const [key, action] of Object.entries({ "Command+N": actions.new!, "Command+O": actions.open!, "Command+S": actions.save!, "Command+Shift+S": actions.saveAs! })) {
41
+ await retain(registerShortcut(key, () => { action(); }, { windowId }));
42
+ }
43
+ if (windowId === "main") {
44
+ await retain(beforeQuit(async () => { for (const document of sessions.values()) if (!await document.canClose()) return false; return true; }));
45
+ await retain(subscribeToOpenRequests(event => {
46
+ if (event.type !== "file") return;
47
+ const location = event.path;
48
+ void readText(location).then(text => session.load({ location, name: location.split("/").pop()! }, text)).catch(e => setError(String(e)));
49
+ }));
50
+ }
51
+ if (!removed) onReady(true);
52
+ })().catch(e => setError(String(e)));
53
+ return () => { removed = true; for (const cleanup of cleanups.reverse()) void Promise.resolve().then(cleanup).catch(console.error); };
54
+ }, [session, windowId, onReady]);
55
+ return <>
56
+ <Button onPress={() => { const id = `document-${++nextWindow}`; void openWindow({ id, title: session.getSnapshot().file.name, component: "main", size: { width: 800, height: 600 }, props: { windowId: id, windowProps: { documentId } } }).catch(e => setError(String(e))); }}>Open another view</Button>
57
+ {error ? <Text accessibilityRole="alert">{error}</Text> : null}
58
+ </>;
59
+ }
@@ -0,0 +1,13 @@
1
+ import { useEffect } from "react";
2
+ import { BackHandler } from "react-native";
3
+ import type { DocumentSession } from "./document";
4
+ export function Integration({ session }: { session: DocumentSession; windowId: string; documentId: string; onReady: (ready: boolean) => void }) {
5
+ useEffect(() => {
6
+ const back = BackHandler.addEventListener("hardwareBackPress", () => {
7
+ if (!session.dirty) return false;
8
+ void session.canClose().then(allow => { if (allow) BackHandler.exitApp(); }); return true;
9
+ });
10
+ return () => back.remove();
11
+ }, [session]);
12
+ return null;
13
+ }
@@ -0,0 +1,14 @@
1
+ import { useEffect } from "react";
2
+ import type { DocumentSession } from "./document";
3
+ export function Integration({ session }: { session: DocumentSession; windowId: string; documentId: string; onReady: (ready: boolean) => void }) {
4
+ useEffect(() => {
5
+ const unload = (event: BeforeUnloadEvent) => { if (session.dirty) { event.preventDefault(); event.returnValue = ""; } };
6
+ const key = (event: KeyboardEvent) => {
7
+ if (!(event.metaKey || event.ctrlKey) || !["s", "o"].includes(event.key.toLowerCase())) return;
8
+ event.preventDefault(); if (event.key.toLowerCase() === "s") void session.save(event.shiftKey); else void session.open();
9
+ };
10
+ window.addEventListener("beforeunload", unload); window.addEventListener("keydown", key);
11
+ return () => { window.removeEventListener("beforeunload", unload); window.removeEventListener("keydown", key); };
12
+ }, [session]);
13
+ return null;
14
+ }
@@ -0,0 +1,59 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Text } from "react-native";
3
+ import { Button } from "@legendapp/spark/ui";
4
+ import { beforeWindowClose, openWindow, setWindowOptions, addWindowListener } from "@legendapp/spark/windows";
5
+ import { beforeQuit } from "@legendapp/spark/app";
6
+ import { useMenu, type MenuItem } from "@legendapp/spark/menus";
7
+ import { registerShortcut } from "@legendapp/spark/shortcuts";
8
+ import { subscribeToOpenRequests } from "@legendapp/spark/app/documents";
9
+ import { readText } from "@legendapp/spark/files";
10
+ import { sessions } from "./sessions";
11
+ import type { DocumentSession } from "./document";
12
+ const items: MenuItem[] = [{ type: "submenu", id: "file", target: { menu: "file" }, label: "File", items: [
13
+ { type: "action", id: "new", label: "New" }, { type: "action", id: "open", label: "Open…" }, { type: "action", id: "save", label: "Save" }, { type: "action", id: "saveAs", label: "Save As…" },
14
+ ] }];
15
+ let nextWindow = 0;
16
+ export function Integration({ session, windowId, documentId, onReady }: { session: DocumentSession; windowId: string; documentId: string; onReady: (ready: boolean) => void }) {
17
+ const [error, setError] = useState<string | null>(null);
18
+ const menu = useMenu({ id: `editor-${windowId}`, items, onAction: event => {
19
+ const actions: Record<string, () => unknown> = { new: () => session.newDocument(), open: () => session.open(), save: () => session.save(), saveAs: () => session.save(true) };
20
+ actions[event.itemId]?.();
21
+ }, onError: error => setError(String(error)) });
22
+ useEffect(() => {
23
+ if (menu.status !== "ready") return;
24
+ let disposed = false, focus: Awaited<ReturnType<typeof addWindowListener>> | undefined;
25
+ void addWindowListener(windowId, "focusChanged", event => { if (event.focused) void menu.menu.update({ items }).catch(error => setError(String(error))); }).then(value => { if (disposed) void value.remove(); else focus = value; }).catch(error => setError(String(error)));
26
+ return () => { disposed = true; void focus?.remove(); };
27
+ }, [menu, windowId]);
28
+ useEffect(() => {
29
+ let removed = false;
30
+ const cleanups: (() => void | Promise<void>)[] = [];
31
+ const retain = async (registration: Promise<{ remove(): void | Promise<void> }>) => {
32
+ const subscription = await registration;
33
+ if (removed) await subscription.remove(); else cleanups.push(() => subscription.remove());
34
+ };
35
+ const actions: Record<string, () => unknown> = { new: () => session.newDocument(), open: () => session.open(), save: () => session.save(), saveAs: () => session.save(true) };
36
+ const title = () => { const state = session.getSnapshot(); void setWindowOptions(windowId, { title: `${state.file.name}${session.dirty ? " •" : ""}` }).catch(e => setError(String(e))); };
37
+ title(); cleanups.push(session.subscribe(title));
38
+ void (async () => {
39
+ await retain(beforeWindowClose(windowId, session.canClose));
40
+ for (const [key, action] of Object.entries({ "Control+N": actions.new!, "Control+O": actions.open!, "Control+S": actions.save!, "Control+Shift+S": actions.saveAs! })) {
41
+ await retain(registerShortcut(key, () => { action(); }, { windowId }));
42
+ }
43
+ if (windowId === "main") {
44
+ await retain(beforeQuit(async () => { for (const document of sessions.values()) if (!await document.canClose()) return false; return true; }));
45
+ await retain(subscribeToOpenRequests(event => {
46
+ if (event.type !== "file") return;
47
+ const location = event.path;
48
+ void readText(location).then(text => session.load({ location, name: location.split("/").pop()! }, text)).catch(e => setError(String(e)));
49
+ }));
50
+ }
51
+ if (!removed) onReady(true);
52
+ })().catch(e => setError(String(e)));
53
+ return () => { removed = true; for (const cleanup of cleanups.reverse()) void Promise.resolve().then(cleanup).catch(console.error); };
54
+ }, [session, windowId, onReady]);
55
+ return <>
56
+ <Button onPress={() => { const id = `document-${++nextWindow}`; void openWindow({ id, title: session.getSnapshot().file.name, component: "main", size: { width: 800, height: 600 }, props: { windowId: id, windowProps: { documentId } } }).catch(e => setError(String(e))); }}>Open another view</Button>
57
+ {error ? <Text accessibilityRole="alert">{error}</Text> : null}
58
+ </>;
59
+ }
@@ -0,0 +1,58 @@
1
+ export type FileReference = { name: string; location?: string; handle?: unknown };
2
+ export type DocumentState = { text: string; savedText: string; file: FileReference; busy: boolean; error: string | null; revision: number };
3
+ export type DocumentIO = {
4
+ open(): Promise<{ file: FileReference; text: string } | null>;
5
+ save(file: FileReference, text: string, expected: string, saveAs: boolean): Promise<FileReference | null>;
6
+ confirmDiscard(name: string): Promise<"save" | "discard" | "cancel">;
7
+ };
8
+ /** One document model is shared by its windows; I/O always works on a snapshot. */
9
+ export class DocumentSession {
10
+ private state: DocumentState = { text: "", savedText: "", file: { name: "Untitled.txt" }, busy: false, error: null, revision: 0 };
11
+ private listeners = new Set<() => void>();
12
+ private active = false;
13
+ constructor(readonly io: DocumentIO) {}
14
+ getSnapshot = () => this.state;
15
+ subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
16
+ private update(patch: Partial<DocumentState>) { this.state = { ...this.state, ...patch }; for (const listener of this.listeners) listener(); }
17
+ edit = (text: string) => { if (text !== this.state.text) this.update({ text, revision: this.state.revision + 1 }); };
18
+ get dirty() { return this.state.text !== this.state.savedText; }
19
+ private async operation(action: () => Promise<boolean>) {
20
+ if (this.active) return false;
21
+ this.active = true; this.update({ busy: true, error: null });
22
+ try { return await action(); }
23
+ catch (error) { this.update({ error: error instanceof Error ? error.message : String(error) }); return false; }
24
+ finally { this.active = false; this.update({ busy: false }); }
25
+ }
26
+ private async saveSnapshot(saveAs: boolean) {
27
+ const snapshot = this.state;
28
+ const file = await this.io.save(snapshot.file, snapshot.text, snapshot.savedText, saveAs);
29
+ if (!file) return false;
30
+ // Edits typed during an asynchronous save remain dirty.
31
+ this.update({ file, savedText: snapshot.text }); return true;
32
+ }
33
+ save = (saveAs = false) => this.operation(() => this.saveSnapshot(saveAs));
34
+ private async mayReplace() {
35
+ if (!this.dirty) return true;
36
+ const revision = this.state.revision;
37
+ const answer = await this.io.confirmDiscard(this.state.file.name);
38
+ if (answer === "cancel") return false;
39
+ if (answer === "save") return await this.saveSnapshot(false) && !this.dirty;
40
+ return revision === this.state.revision;
41
+ }
42
+ canClose = () => this.operation(() => this.mayReplace());
43
+ newDocument = () => this.operation(async () => {
44
+ if (!await this.mayReplace()) return false;
45
+ this.update({ text: "", savedText: "", file: { name: "Untitled.txt" }, revision: this.state.revision + 1 }); return true;
46
+ });
47
+ open = () => this.operation(async () => {
48
+ if (!await this.mayReplace()) return false;
49
+ const revision = this.state.revision;
50
+ const loaded = await this.io.open();
51
+ if (!loaded || revision !== this.state.revision) return false;
52
+ this.update({ file: loaded.file, text: loaded.text, savedText: loaded.text, revision: revision + 1 }); return true;
53
+ });
54
+ load = (file: FileReference, text: string) => this.operation(async () => {
55
+ if (!await this.mayReplace()) return false;
56
+ this.update({ file, text, savedText: text, revision: this.state.revision + 1 }); return true;
57
+ });
58
+ }
@@ -0,0 +1,27 @@
1
+ import { openFileDialog, saveFileDialog } from "@legendapp/spark/dialogs";
2
+ import { readText, writeText, writeTextIfUnchanged } from "@legendapp/spark/files";
3
+ import { Alert } from "react-native";
4
+ import type { DocumentIO } from "./document";
5
+ export const io: DocumentIO = {
6
+ async open() {
7
+ const paths = await openFileDialog({ filters: [{ extensions: ["txt", "md", "json"] }], multiple: false });
8
+ if (paths.canceled) return null;
9
+ const location = paths.paths[0];
10
+ return { file: { location, name: location.split(/[\\/]/).pop()! }, text: await readText(location) };
11
+ },
12
+ async save(file, text, expected, saveAs) {
13
+ const result = saveAs || !file.location ? await saveFileDialog({ defaultName: file.name, filters: [{ extensions: ["txt", "md", "json"] }] }) : { canceled: false as const, path: file.location };
14
+ if (result.canceled) return null;
15
+ const location = result.path;
16
+ if (!location) return null;
17
+ if (location === file.location) {
18
+ if (!(await writeTextIfUnchanged(location, expected, text)).written) throw new Error("The file changed outside the editor. Use Save As to keep your edits in another file.");
19
+ } else await writeText(location, text);
20
+ return { location, name: location.split(/[\\/]/).pop()! };
21
+ },
22
+ confirmDiscard: name => new Promise(resolve => Alert.alert("Unsaved changes", `Save changes to ${name}?`, [
23
+ { text: "Cancel", style: "cancel", onPress: () => resolve("cancel") },
24
+ { text: "Discard", style: "destructive", onPress: () => resolve("discard") },
25
+ { text: "Save", onPress: () => resolve("save") },
26
+ ], { cancelable: false })),
27
+ };