@legendapp/create-spark 0.0.0-stage → 0.0.1-next.6
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.
- package/LICENSE +21 -0
- package/README.md +17 -2
- package/dist/index.js +174697 -0
- package/dist/npm-bin/npm +6 -0
- package/dist/npm-bin/npm.cmd +1 -0
- package/dist/release.json +42 -0
- package/package.json +30 -4
- package/templates/blank-typescript/.claude/skills/spark/SKILL.md +9 -0
- package/templates/blank-typescript/AGENTS.md +4 -0
- package/templates/blank-typescript/App.tsx +23 -0
- package/templates/blank-typescript/CLAUDE.md +4 -0
- package/templates/blank-typescript/README.md +15 -0
- package/templates/blank-typescript/app.json +11 -0
- package/templates/blank-typescript/assets/icon.png +0 -0
- package/templates/blank-typescript/desktop.config.json +21 -0
- package/templates/blank-typescript/gitignore +8 -0
- package/templates/blank-typescript/index.ts +5 -0
- package/templates/blank-typescript/metro.config.js +3 -0
- package/templates/blank-typescript/package.json +101 -0
- package/templates/blank-typescript/tsconfig.json +10 -0
- package/templates/diff-lite/App.tsx +34 -0
- package/templates/diff-lite/Git.macos.tsx +13 -0
- package/templates/diff-lite/Git.tsx +1 -0
- package/templates/diff-lite/compare.ts +12 -0
- package/templates/document-editor/App.tsx +34 -0
- package/templates/document-editor/Integration.macos.tsx +59 -0
- package/templates/document-editor/Integration.tsx +13 -0
- package/templates/document-editor/Integration.web.tsx +14 -0
- package/templates/document-editor/Integration.windows.tsx +59 -0
- package/templates/document-editor/document.ts +58 -0
- package/templates/document-editor/io.desktop.ts +27 -0
- package/templates/document-editor/io.macos.ts +7 -0
- package/templates/document-editor/io.ts +31 -0
- package/templates/document-editor/io.web.ts +21 -0
- package/templates/document-editor/io.windows.ts +1 -0
- package/templates/document-editor/sessions.ts +8 -0
- package/templates/document-editor/useDocument.ts +6 -0
- package/templates/example-shared/Lifecycle.desktop.tsx +25 -0
- package/templates/example-shared/Lifecycle.macos.tsx +1 -0
- package/templates/example-shared/Lifecycle.tsx +14 -0
- package/templates/example-shared/Lifecycle.web.tsx +20 -0
- package/templates/example-shared/Lifecycle.windows.tsx +1 -0
- package/templates/example-shared/OpenFiles.desktop.tsx +23 -0
- package/templates/example-shared/OpenFiles.macos.tsx +1 -0
- package/templates/example-shared/OpenFiles.tsx +2 -0
- package/templates/example-shared/OpenFiles.windows.tsx +1 -0
- package/templates/example-shared/document.ts +58 -0
- package/templates/example-shared/io.desktop.ts +27 -0
- package/templates/example-shared/io.macos.ts +7 -0
- package/templates/example-shared/io.ts +31 -0
- package/templates/example-shared/io.web.ts +21 -0
- package/templates/example-shared/io.windows.ts +1 -0
- package/templates/example-shared/lifecycle-types.ts +10 -0
- package/templates/example-shared/lifetime.ts +23 -0
- package/templates/example-shared/records.ts +51 -0
- package/templates/example-shared/storage.ts +6 -0
- package/templates/example-shared/useModel.ts +6 -0
- package/templates/music-lite/Activation.desktop.tsx +4 -0
- package/templates/music-lite/Activation.macos.tsx +1 -0
- package/templates/music-lite/Activation.tsx +1 -0
- package/templates/music-lite/Activation.windows.tsx +1 -0
- package/templates/music-lite/App.tsx +38 -0
- package/templates/music-lite/assets.desktop.ts +7 -0
- package/templates/music-lite/assets.macos.ts +1 -0
- package/templates/music-lite/assets.ts +18 -0
- package/templates/music-lite/assets.web.ts +37 -0
- package/templates/music-lite/assets.windows.ts +1 -0
- package/templates/music-lite/model.ts +91 -0
- package/templates/music-lite/store.ts +10 -0
- package/templates/notes-lite/Activation.desktop.tsx +5 -0
- package/templates/notes-lite/Activation.macos.tsx +1 -0
- package/templates/notes-lite/Activation.tsx +1 -0
- package/templates/notes-lite/Activation.windows.tsx +1 -0
- package/templates/notes-lite/App.tsx +92 -0
- package/templates/notes-lite/Desktop.desktop.ts +47 -0
- package/templates/notes-lite/Desktop.macos.ts +1 -0
- package/templates/notes-lite/Desktop.ts +7 -0
- package/templates/notes-lite/Desktop.windows.ts +1 -0
- package/templates/notes-lite/Theme.tsx +17 -0
- package/templates/notes-lite/model.ts +61 -0
- package/templates/notes-lite/search.ts +3 -0
- package/templates/notes-lite/session.ts +15 -0
- package/templates/notes-lite/store.ts +4 -0
- package/templates/notes-lite/window-session.ts +63 -0
- package/templates/universal/.claude/skills/spark/SKILL.md +9 -0
- package/templates/universal/AGENTS.md +4 -0
- package/templates/universal/App.tsx +57 -0
- package/templates/universal/CLAUDE.md +4 -0
- package/templates/universal/README.md +15 -0
- package/templates/universal/app.json +15 -0
- package/templates/universal/assets/icon.png +0 -0
- package/templates/universal/desktop.config.json +64 -0
- package/templates/universal/gitignore +10 -0
- package/templates/universal/global.css +31 -0
- package/templates/universal/index.ts +4 -0
- package/templates/universal/metro.config.js +7 -0
- package/templates/universal/package.json +115 -0
- package/templates/universal/react-native.config.js +2 -0
- package/templates/universal/tsconfig.json +10 -0
- package/templates/universal/uniwind-types.d.ts +10 -0
- package/templates/windows/.claude/skills/spark/SKILL.md +9 -0
- package/templates/windows/AGENTS.md +4 -0
- package/templates/windows/App.tsx +16 -0
- package/templates/windows/CLAUDE.md +4 -0
- package/templates/windows/README.md +15 -0
- package/templates/windows/app.json +11 -0
- package/templates/windows/assets/icon.png +0 -0
- package/templates/windows/desktop.config.json +10 -0
- package/templates/windows/gitignore +8 -0
- package/templates/windows/index.ts +3 -0
- package/templates/windows/metro.config.js +3 -0
- package/templates/windows/package.json +102 -0
- package/templates/windows/react-native.config.js +2 -0
- package/templates/windows/tsconfig.json +10 -0
package/dist/npm-bin/npm
ADDED
|
@@ -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" %*
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": 1,
|
|
3
|
+
"version": "0.0.1-next.6",
|
|
4
|
+
"revision": "155e88cb303e467315dbb65871422c92977665fa",
|
|
5
|
+
"packages": {
|
|
6
|
+
"@react-native-runtimes/core": {
|
|
7
|
+
"url": "https://github.com/LegendApp/legend-spark/releases/download/v0.0.1-next.6/runtimes-985164a87eb0.tgz",
|
|
8
|
+
"size": 53886,
|
|
9
|
+
"sha256": "138644fcfaf60b172c9f206c66c6d160515dd8803c70264687d0f503294c9c0b"
|
|
10
|
+
},
|
|
11
|
+
"react-native-nitro-modules": {
|
|
12
|
+
"url": "https://github.com/LegendApp/legend-spark/releases/download/v0.0.1-next.6/react-native-nitro-modules-0.37.0-02b4dff54381.tgz",
|
|
13
|
+
"size": 145600,
|
|
14
|
+
"sha256": "02b4dff54381bbd4640eb4067e1dc82d7a79ff7e7f83b4357a4265177722d999"
|
|
15
|
+
},
|
|
16
|
+
"@op-engineering/op-sqlite": {
|
|
17
|
+
"url": "https://github.com/LegendApp/legend-spark/releases/download/v0.0.1-next.6/op-engineering-op-sqlite-18.2.1-dcc9490310bc.tgz",
|
|
18
|
+
"size": 112508535,
|
|
19
|
+
"sha256": "dcc9490310bc74c869ba7cf5e846a8966fab7c3871c4a536482c20be237894fd"
|
|
20
|
+
},
|
|
21
|
+
"react-native-webview": {
|
|
22
|
+
"url": "https://github.com/LegendApp/legend-spark/releases/download/v0.0.1-next.6/react-native-webview-16.0.0-b887e540eca8.tgz",
|
|
23
|
+
"size": 116289,
|
|
24
|
+
"sha256": "b887e540eca87c6817de6b38c4e8f4076e3a65e0f7fa63f0c5882e1155447a59"
|
|
25
|
+
},
|
|
26
|
+
"expo-audio": {
|
|
27
|
+
"url": "https://github.com/LegendApp/legend-spark/releases/download/v0.0.1-next.6/expo-audio-1.1.1-aff5604f2c4c.tgz",
|
|
28
|
+
"size": 381841,
|
|
29
|
+
"sha256": "aff5604f2c4cab23299a71e37e802a572b2e66e2e2250b20a261e664d5346693"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"runners": {
|
|
33
|
+
"macos-arm64": {
|
|
34
|
+
"url": "https://github.com/LegendApp/legend-spark/releases/download/v0.0.1-next.6/spark-runner-macos-arm64.zip",
|
|
35
|
+
"size": 20509040,
|
|
36
|
+
"sha256": "02b2da92e3a6661e03cee55304a498d185b306f2df03d0909ac0a3553a2f7fff",
|
|
37
|
+
"app": "SparkRunner.app",
|
|
38
|
+
"teamId": "56DD97VT22",
|
|
39
|
+
"fingerprint": "83b0e267a932be31d5366ea61f40e3609f79a99c01bc7fd69bb966e683d9968b"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@legendapp/create-spark",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1-next.6",
|
|
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,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,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.
|
|
Binary file
|
|
@@ -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,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.6",
|
|
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,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
|
+
}
|