| ... | ... | @@ -1,19 +1,19 @@ |
| 1 | | import { |
| 2 | | type FormEvent, |
| 3 | | useCallback, |
| 4 | | useEffect, |
| 5 | | useMemo, |
| 6 | | useState, |
| 7 | | } from "react"; |
| 1 | import { type FormEvent, useMemo, useState } from "react"; |
| 8 | 2 | |
| 9 | | import type { Todo } from "@acme/contracts"; |
| 3 | import type { TerminalCommandResult } from "@acme/contracts"; |
| 10 | 4 | |
| 11 | | function formatCreatedAt(isoDate: string): string { |
| 5 | interface TerminalEntry { |
| 6 | id: string; |
| 7 | command: string; |
| 8 | result: TerminalCommandResult; |
| 9 | createdAt: string; |
| 10 | } |
| 11 | |
| 12 | function formatTimestamp(isoDate: string): string { |
| 12 | 13 | return new Intl.DateTimeFormat(undefined, { |
| 13 | | month: "short", |
| 14 | | day: "numeric", |
| 15 | 14 | hour: "numeric", |
| 16 | 15 | minute: "2-digit", |
| 16 | second: "2-digit", |
| 17 | 17 | }).format(new Date(isoDate)); |
| 18 | 18 | } |
| 19 | 19 | |
| ... | ... | @@ -27,102 +27,52 @@ function readNativeApi() { |
| 27 | 27 | |
| 28 | 28 | export default function App() { |
| 29 | 29 | const api = useMemo(() => readNativeApi(), []); |
| 30 | | const [todos, setTodos] = useState<Todo[]>([]); |
| 31 | | const [title, setTitle] = useState(""); |
| 32 | | const [isLoading, setIsLoading] = useState(true); |
| 30 | const [command, setCommand] = useState(""); |
| 31 | const [history, setHistory] = useState<TerminalEntry[]>([]); |
| 32 | const [isRunning, setIsRunning] = useState(false); |
| 33 | 33 | const [error, setError] = useState<string | null>(null); |
| 34 | 34 | |
| 35 | | const loadTodos = useCallback(async () => { |
| 36 | | if (!api) { |
| 37 | | return; |
| 38 | | } |
| 39 | | |
| 40 | | setError(null); |
| 41 | | |
| 42 | | try { |
| 43 | | const nextTodos = await api.todos.list(); |
| 44 | | setTodos(nextTodos); |
| 45 | | } catch (loadError) { |
| 46 | | const message = |
| 47 | | loadError instanceof Error |
| 48 | | ? loadError.message |
| 49 | | : "Could not fetch todos."; |
| 50 | | setError(message); |
| 51 | | } finally { |
| 52 | | setIsLoading(false); |
| 53 | | } |
| 54 | | }, [api]); |
| 55 | | |
| 56 | | useEffect(() => { |
| 57 | | void loadTodos(); |
| 58 | | }, [loadTodos]); |
| 59 | | |
| 60 | 35 | const onSubmit = async (event: FormEvent<HTMLFormElement>) => { |
| 61 | 36 | event.preventDefault(); |
| 62 | 37 | |
| 63 | | if (!api) { |
| 38 | if (!api || isRunning) { |
| 64 | 39 | return; |
| 65 | 40 | } |
| 66 | 41 | |
| 67 | | const trimmed = title.trim(); |
| 42 | const trimmed = command.trim(); |
| 68 | 43 | if (!trimmed) { |
| 69 | 44 | return; |
| 70 | 45 | } |
| 71 | 46 | |
| 72 | 47 | setError(null); |
| 48 | setIsRunning(true); |
| 73 | 49 | |
| 74 | 50 | try { |
| 75 | | const nextTodos = await api.todos.add({ title: trimmed }); |
| 76 | | setTodos(nextTodos); |
| 77 | | setTitle(""); |
| 78 | | } catch (addError) { |
| 79 | | const message = |
| 80 | | addError instanceof Error ? addError.message : "Could not add todo."; |
| 81 | | setError(message); |
| 82 | | } |
| 83 | | }; |
| 84 | | |
| 85 | | const onToggle = async (id: string) => { |
| 86 | | if (!api) { |
| 87 | | return; |
| 88 | | } |
| 89 | | |
| 90 | | setError(null); |
| 91 | | |
| 92 | | try { |
| 93 | | const nextTodos = await api.todos.toggle(id); |
| 94 | | setTodos(nextTodos); |
| 95 | | } catch (toggleError) { |
| 51 | const result = await api.terminal.run({ command: trimmed }); |
| 52 | setHistory((previous) => [ |
| 53 | ...previous, |
| 54 | { |
| 55 | id: crypto.randomUUID(), |
| 56 | command: trimmed, |
| 57 | result, |
| 58 | createdAt: new Date().toISOString(), |
| 59 | }, |
| 60 | ]); |
| 61 | setCommand(""); |
| 62 | } catch (runError) { |
| 96 | 63 | const message = |
| 97 | | toggleError instanceof Error |
| 98 | | ? toggleError.message |
| 99 | | : "Could not toggle todo."; |
| 100 | | setError(message); |
| 101 | | } |
| 102 | | }; |
| 103 | | |
| 104 | | const onRemove = async (id: string) => { |
| 105 | | if (!api) { |
| 106 | | return; |
| 107 | | } |
| 108 | | |
| 109 | | setError(null); |
| 110 | | |
| 111 | | try { |
| 112 | | const nextTodos = await api.todos.remove(id); |
| 113 | | setTodos(nextTodos); |
| 114 | | } catch (removeError) { |
| 115 | | const message = |
| 116 | | removeError instanceof Error |
| 117 | | ? removeError.message |
| 118 | | : "Could not remove todo."; |
| 64 | runError instanceof Error |
| 65 | ? runError.message |
| 66 | : "Could not execute command."; |
| 119 | 67 | setError(message); |
| 68 | } finally { |
| 69 | setIsRunning(false); |
| 120 | 70 | } |
| 121 | 71 | }; |
| 122 | 72 | |
| 123 | 73 | if (!api) { |
| 124 | 74 | return ( |
| 125 | | <main className="mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-8"> |
| 75 | <main className="mx-auto flex min-h-screen w-full max-w-4xl items-center justify-center px-8"> |
| 126 | 76 | <section className="rounded-3xl border border-amber-200 bg-amber-50 px-6 py-5 text-amber-900 shadow-soft"> |
| 127 | 77 | <h1 className="text-lg font-semibold">Native bridge unavailable</h1> |
| 128 | 78 | <p className="mt-2 text-sm"> |
| ... | ... | @@ -134,100 +84,102 @@ export default function App() { |
| 134 | 84 | } |
| 135 | 85 | |
| 136 | 86 | return ( |
| 137 | | <main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col px-6 py-12 text-slate-900 sm:px-10"> |
| 138 | | <header className="rounded-3xl border border-slate-200/80 bg-white/80 p-7 shadow-soft backdrop-blur"> |
| 139 | | <h1 className="text-3xl font-semibold tracking-tight"> |
| 140 | | Long-Horizon TODOs |
| 87 | <main className="mx-auto flex min-h-screen w-full max-w-5xl flex-col px-6 py-10 text-slate-900 sm:px-10"> |
| 88 | <header className="rounded-3xl border border-slate-800/80 bg-slate-900 p-7 text-slate-100 shadow-soft"> |
| 89 | <p className="text-xs uppercase tracking-[0.18em] text-emerald-300"> |
| 90 | Demo Terminal |
| 91 | </p> |
| 92 | <h1 className="mt-2 font-mono text-3xl font-semibold tracking-tight"> |
| 93 | Shell Command Runner |
| 141 | 94 | </h1> |
| 142 | | <p className="mt-2 text-sm text-slate-600"> |
| 143 | | Renderer is isolated. All native access is routed through a typed |
| 144 | | preload API. |
| 95 | <p className="mt-3 text-sm text-slate-300"> |
| 96 | Runs one command at a time in your shell and prints stdout/stderr. |
| 145 | 97 | </p> |
| 146 | 98 | |
| 147 | 99 | <form className="mt-6 flex gap-3" onSubmit={onSubmit}> |
| 148 | 100 | <input |
| 149 | | value={title} |
| 150 | | onChange={(event) => setTitle(event.target.value)} |
| 151 | | placeholder="Write a task..." |
| 152 | | className="h-11 flex-1 rounded-xl border border-slate-300 bg-white px-4 text-sm outline-none ring-0 transition focus:border-slate-500" |
| 153 | | maxLength={280} |
| 101 | value={command} |
| 102 | onChange={(event) => setCommand(event.target.value)} |
| 103 | placeholder="e.g. ls -la" |
| 104 | className="h-11 flex-1 rounded-xl border border-emerald-500/50 bg-slate-950 px-4 font-mono text-sm text-emerald-100 outline-none ring-0 transition focus:border-emerald-300" |
| 105 | maxLength={4000} |
| 154 | 106 | /> |
| 155 | 107 | <button |
| 156 | 108 | type="submit" |
| 157 | | className="h-11 rounded-xl bg-slate-900 px-5 text-sm font-medium text-white transition hover:bg-slate-700" |
| 109 | className="h-11 rounded-xl bg-emerald-500 px-5 text-sm font-semibold text-slate-950 transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:bg-emerald-800 disabled:text-emerald-300" |
| 110 | disabled={isRunning} |
| 111 | > |
| 112 | {isRunning ? "Running..." : "Run"} |
| 113 | </button> |
| 114 | <button |
| 115 | type="button" |
| 116 | className="h-11 rounded-xl border border-slate-600 px-5 text-sm font-medium text-slate-200 transition hover:border-slate-400 hover:text-white disabled:cursor-not-allowed disabled:opacity-50" |
| 117 | disabled={history.length === 0 || isRunning} |
| 118 | onClick={() => setHistory([])} |
| 158 | 119 | > |
| 159 | | Add |
| 120 | Clear |
| 160 | 121 | </button> |
| 161 | 122 | </form> |
| 162 | 123 | </header> |
| 163 | 124 | |
| 164 | | <section className="mt-6 rounded-3xl border border-slate-200/70 bg-white/80 p-4 shadow-soft backdrop-blur sm:p-6"> |
| 165 | | {isLoading ? ( |
| 166 | | <p className="text-sm text-slate-500">Loading todos...</p> |
| 167 | | ) : null} |
| 125 | {error ? ( |
| 126 | <div className="mt-6 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700"> |
| 127 | {error} |
| 128 | </div> |
| 129 | ) : null} |
| 168 | 130 | |
| 169 | | {error ? ( |
| 170 | | <div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700"> |
| 171 | | {error} |
| 131 | <section className="mt-6 flex-1 space-y-4"> |
| 132 | {history.length === 0 ? ( |
| 133 | <div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-4 py-6 text-sm text-slate-500"> |
| 134 | No commands run yet. |
| 172 | 135 | </div> |
| 173 | 136 | ) : null} |
| 174 | 137 | |
| 175 | | {!isLoading && todos.length === 0 ? ( |
| 176 | | <p className="rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-5 text-sm text-slate-500"> |
| 177 | | No todos yet. Add one above. |
| 178 | | </p> |
| 179 | | ) : null} |
| 180 | | |
| 181 | | <ul className="space-y-3"> |
| 182 | | {todos.map((todo) => ( |
| 183 | | <li |
| 184 | | key={todo.id} |
| 185 | | className="flex items-center justify-between rounded-2xl border border-slate-200 bg-white px-4 py-3" |
| 186 | | > |
| 187 | | <button |
| 188 | | type="button" |
| 189 | | onClick={() => { |
| 190 | | void onToggle(todo.id); |
| 191 | | }} |
| 192 | | className="flex flex-1 items-center gap-3 text-left" |
| 193 | | > |
| 194 | | <span |
| 195 | | className={[ |
| 196 | | "mt-0.5 inline-block h-5 w-5 rounded-full border transition", |
| 197 | | todo.completed |
| 198 | | ? "border-emerald-600 bg-emerald-500" |
| 199 | | : "border-slate-300 bg-white", |
| 200 | | ].join(" ")} |
| 201 | | /> |
| 202 | | <span> |
| 203 | | <p |
| 204 | | className={[ |
| 205 | | "text-sm font-medium", |
| 206 | | todo.completed |
| 207 | | ? "text-slate-400 line-through" |
| 208 | | : "text-slate-800", |
| 209 | | ].join(" ")} |
| 210 | | > |
| 211 | | {todo.title} |
| 138 | {history.map((entry) => ( |
| 139 | <article |
| 140 | key={entry.id} |
| 141 | className="overflow-hidden rounded-2xl border border-slate-700 bg-slate-950 shadow-soft" |
| 142 | > |
| 143 | <header className="flex items-center justify-between border-b border-slate-800 px-4 py-3 font-mono text-xs text-slate-300"> |
| 144 | <p>$ {entry.command}</p> |
| 145 | <p>{formatTimestamp(entry.createdAt)}</p> |
| 146 | </header> |
| 147 | |
| 148 | <div className="space-y-3 px-4 py-4 font-mono text-sm"> |
| 149 | {entry.result.stdout ? ( |
| 150 | <div> |
| 151 | <p className="mb-1 text-xs uppercase tracking-wide text-emerald-300"> |
| 152 | stdout |
| 212 | 153 | </p> |
| 213 | | <p className="text-xs text-slate-500"> |
| 214 | | Created {formatCreatedAt(todo.createdAt)} |
| 154 | <pre className="whitespace-pre-wrap break-words text-emerald-100"> |
| 155 | {entry.result.stdout} |
| 156 | </pre> |
| 157 | </div> |
| 158 | ) : null} |
| 159 | |
| 160 | {entry.result.stderr ? ( |
| 161 | <div> |
| 162 | <p className="mb-1 text-xs uppercase tracking-wide text-rose-300"> |
| 163 | stderr |
| 215 | 164 | </p> |
| 216 | | </span> |
| 217 | | </button> |
| 218 | | |
| 219 | | <button |
| 220 | | type="button" |
| 221 | | onClick={() => { |
| 222 | | void onRemove(todo.id); |
| 223 | | }} |
| 224 | | className="rounded-lg border border-rose-200 px-3 py-1.5 text-xs font-medium text-rose-600 transition hover:bg-rose-50" |
| 225 | | > |
| 226 | | Remove |
| 227 | | </button> |
| 228 | | </li> |
| 229 | | ))} |
| 230 | | </ul> |
| 165 | <pre className="whitespace-pre-wrap break-words text-rose-200"> |
| 166 | {entry.result.stderr} |
| 167 | </pre> |
| 168 | </div> |
| 169 | ) : null} |
| 170 | |
| 171 | {!entry.result.stdout && !entry.result.stderr ? ( |
| 172 | <p className="text-slate-400">(no output)</p> |
| 173 | ) : null} |
| 174 | </div> |
| 175 | |
| 176 | <footer className="border-t border-slate-800 bg-slate-900/60 px-4 py-2 font-mono text-xs text-slate-400"> |
| 177 | exit={entry.result.code ?? "null"} signal= |
| 178 | {entry.result.signal ?? "null"} timedOut= |
| 179 | {entry.result.timedOut ? "yes" : "no"} |
| 180 | </footer> |
| 181 | </article> |
| 182 | ))} |
| 231 | 183 | </section> |
| 232 | 184 | </main> |
| 233 | 185 | ); |