A live Unity pane for Claude Code: editor state, test runs with progress and history, builds, console errors and git, with one-click setup (Unity CLI, Pipeline…

A Claude Code mod that keeps your Unity editor in view while Claude works on your game: whether the editor is up, compiling or in Play mode; test runs with progress, ETA and history; builds; console errors; and git. When something is missing (the Unity CLI, the Pipeline package, the project's Unity version, or the editor isn't open), the pane says so and fixes it in one click.
<img src="docs/media/ready.png" alt="Unity Watch docked beside the transcript: the project, four status cards (Unity ready, tests, project with 2 changes, build available), recent activity and the action block with Run all tests, Build Android and Recompile" width="420">
<a href="#install">Install</a> · <a href="#what-you-see">What you see</a> · <a href="#use">Use</a> · <a href="#what-it-can-reach">What it can reach</a> · <a href="#configure">Configure</a> · <a href="#troubleshooting">Troubleshooting</a>
Inside Claude Code (2.1.289 or later):
/plugin marketplace add TheoGegout/claude-unity-watch
/plugin install unity-watch@claude-unity-watch
/reload-plugins
Then, in a Unity project, type unity (or /unity).
claude plugin marketplace add TheoGegout/claude-unity-watch
claude plugin install unity-watch@claude-unity-watch
Or load it straight from a clone, for one session:
git clone https://github.com/TheoGegout/claude-unity-watch
claude --plugin-dir ./claude-unity-watch
Mods are an early-access Claude Code feature and their API can change between releases. If something breaks, see Troubleshooting.
| Part | Shows |
|---|---|
| Header | the editor's state: ready, compiling, in Play, starting, not responding, closed |
| Project | name, branch, changed files, last commit and its age |
| Unity card | the editor version, the Unity CLI version, whether the Pipeline package is there |
| Tests card | the running suite with a progress bar and the time left (from the longest run seen), or the last result, and the last 12 runs as ■ |
| Project card | branch and commit, console errors |
| Build card | the build in progress, or the newest .apk/.aab in Builds/ with its size and age |
| Details | ▸ Unity / Tests / Project / Build open each card's details: paths, the last runs with their dates, the last commit, console errors, the last build's result |
| Failed tests | each failed test by name, after a run with failures |
| Recent activity | what happened and when: project loaded, Unity opened or closed, tests started and finished, builds, failed compiles, the buttons you pressed |
| Action block | the one thing that matters now and its button: install what is missing, open Unity, stop a run, unblock a stuck runner, or run the tests and build when all is ready |
| Unity closed | Starting | Ready |
|---|---|---|
| <img src="docs/media/closed.png" alt="The pane with Unity closed: the action block says Unity is closed and offers Open Unity" width="260"> | <img src="docs/media/starting.png" alt="The pane while Unity starts: the action block says Unity is starting" width="260"> | <img src="docs/media/ready.png" alt="The pane once Unity answers: all systems ready, with Run all tests, Build Android and Recompile" width="260"> |
It draws with your Claude Code theme's own colours, light or dark, in the desktop app, VS Code, the phone and the terminal. Where the surface draws SVG it adds icons; the terminal shows the same pane in text.
The pane refreshes itself: the editor, tests and build every 5 seconds, git, the console and the toolchain every 30. Toasts tell you when a run or a build ends, a compile fails, or the editor stops answering, and the status line carries a one-line summary.
| Command | Does |
|---|---|
unity or /unity | opens the pane (in a Unity project; elsewhere unity is a normal prompt) |
| Button | Key | Does | Shown when |
|---|---|---|---|
| Install the Unity CLI | runs Unity's official install script | the CLI is missing | |
| Install Unity <version> | installs the exact editor version the project asks for | it is not installed | |
| Install the Pipeline package | adds com.unity.pipeline, which the CLI drives the editor through | the project lacks it | |
| Open Unity | o | starts the right editor on this project; never a second one | the editor is closed |
| Run all tests | t | starts the PlayMode (or EditMode) suite | the editor is up and idle |
| Build Android | b | builds Builds/<project>.apk | the editor is up and idle |
| Recompile | r | refreshes assets and recompiles scripts, for edits made while Unity was in the background | the editor is up and idle |
| Stop the tests | s | cancels the running suite; press twice within 5 seconds, so a stray click never throws away a long run | a suite is running |
| Unblock | u | cancels a stuck runner, reopens the first build scene, deletes leftover InitTestScene files | the runner is stuck, or test scenes stayed behind |
While a suite or a build runs, nothing that could disturb it is offered: no recompile (a domain reload under the test runner can take the editor down), no second run, no build.
Everything stays on your machine.
| It | What |
|---|---|
| reads | the project's ProjectSettings/ProjectVersion.txt, Packages/manifest.json and editor log; lists Assets/ (leftover test scenes) and Builds/ |
| runs, on its own | unity --version, unity editors --installed, unity cmd editor_status / test_status / build_status / console / recompile_status, git status, git log -1, and tasklist or pgrep to tell a busy editor from a closed one |
| runs, when you press a button | the editor itself, unity install, unity pipeline install, Unity's install script (downloaded from Unity's CDN), and the unity cmd behind each button |
| stores | per project: the last 12 test results and the longest run's length, for the ETA |
It never sends your project anywhere, never runs tests or builds by itself, and calls no model. claude plugin validate . prints exactly what it hooks and calls.
In /config, or under pluginConfigs["unity-watch"].options in settings.json:
| Option | Default | Meaning |
|---|---|---|
language | auto | en, fr, or your system's language |
openOnStart | true | open the pane when a session starts in a Unity project |
statusLine | true | the one-line summary in the status line |
testMode | playmode | which suite Run all tests starts: playmode or editmode |
buildTarget | Android | off hides the build button |
The pane says the folder is not a Unity project. Start Claude Code in the project's folder (the one with Assets/ and ProjectSettings/) or a folder right inside it.
"Not responding". The editor is busy (importing, a dialog open) or stuck. The pane checks that a Unity process still holds the project before it says "closed", and comes back by itself once the editor answers.
Tests stuck on "running". The pane notices a run that claims to run while the editor sits idle with a silent log, and offers Unblock.
Something else. Look in the transcript for a dim line starting unity-watch:; it names the hook that failed. Please open an issue with it, your claude --version and your Unity version.
Windows is the most tested. macOS and Linux paths (the CLI under ~/.unity or ~/.local/bin, opening the editor, the install script) are supported but less exercised.
The pane talks to the editor through the Unity CLI and the com.unity.pipeline package, which runs a small server inside the editor.
| File | Holds |
|---|---|
hooks/register.tsx | the hooks, the polling, the buttons' actions and the drawing |
hooks/core.ts | every decision as pure functions: how a reading merges with what the buttons wrote meanwhile, what each card and the action block say, which buttons a state allows, how the CLI's and git's answers parse |
hooks/strings.ts | every word, in English and French |
hooks/art.ts | theme colour keys and the SVG icons |
types/index.d.ts | the state contract |
tests/ | 22 tests: the pure decisions, and the pane drawn over a fake Unity project (ready, closed, running tests, failed tests, missing CLI, package or editor) on the terminal and the desktop |
State lives in $.state atoms and every read is merged over defaults, so a missing or older field never breaks the pane.
claude --plugin-dir . # load it; edits hot-reload
claude plugin validate .
claude plugin test .
npx -p typescript tsc -p . # after the first load, which writes .claude-plugin/types/
See CONTRIBUTING.md. Changes are listed in CHANGELOG.md.
hooks/register.tsx 891 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DetailsView, RunRecord, SetupState, TestRun, UnitySnapshot } from '../types'
5import { K, icon, logo, statusMark, toneGlyphText, toneKey } from './art'
6import type { IconName, Tone } from './art'
7import {
8 BUILD_GRACE_MS, DETAIL_VIEWS, GHOST_MS, HISTORY_SIZE, actionFor, ago, bar, buttonsFor, canUnblock, capitalize, cardsFor,
9 clock, compileFailedIn, currentGroup, detailRows, detailsTitle, editorBadge, filled, initial, isHungAnswer,
10 mergePoll, minutes, note, parseCliOutput, parseConsole, parseGit, readConfig, shortTest, statusLine, toneGlyph, working,
11} from './core'
12import type { ActionKey, CliAnswer, Config, SuiteRecord, View } from './core'
13import { pick } from './strings'
14import type { BusyKind, Lang, Strings } from './strings'
15
16// The Unity project the session sits in, watched through the Unity CLI and the
17// editor's Pipeline package: the editor's state, the test runner, the build,
18// the console, the last APK/AAB and the git tree, in a pane, on the status line
19// and as toasts. When something is missing (the CLI, the Pipeline package, the
20// project's editor version, a running editor) the pane says so and offers the
21// one-click fix. The decisions live in core.ts; this file reads and draws.
22
23const PANE = 'unity'
24const POLL_MS = 5000
25/** Slow facts (git, console, setup) are read every this many polls. */
26const SLOW_EVERY = 6
27const LOG_TAIL_LINES = 400
28/** Below this many cells across, the dashboard stacks its cards two by two. */
29const NARROW_COLUMNS = 90
30/** How long a first press on "Stop the tests" waits for the second. */
31const ARM_MS = 5000
32
33// Unity's own installers; with no channel set they take the stable release, or the beta until one ships.
34const CLI_INSTALL_WINDOWS = 'irm https://public-cdn.cloud.unity3d.com/hub/prod/cli/install.ps1 | iex'
35const CLI_INSTALL_UNIX = 'curl -fsSL https://public-cdn.cloud.unity3d.com/hub/prod/cli/install.sh | bash'
36
37const snapshot = atom({ plugin: 'unity-watch', key: 'snapshot' } as const, initial)
38/** Which card's details the dashboard shows under the cards, if any. */
39const details = atom({ plugin: 'unity-watch', key: 'details' } as const, 'none' as DetailsView)
40
41// ---------- module state: a reload starts it over, and session.start finds it again ----------
42
43let cfg: Config = readConfig({})
44/** The words in use; `auto` settles on the machine's language at session start. */
45let t: Strings = pick('en')
46let isWindows = false
47let root: string | null = null
48let projectName = ''
49let cliPath: string | null = null
50let editorVersion: string | undefined
51let editorPath: string | undefined
52/** The editor log this machine writes to: the project's own, or the editor's default one. */
53let logPath: string | undefined
54
55let polling = false
56let tick = 0
57let ghostSince = 0
58let lastLogMtime = 0
59let lastLogChange = 0
60/** Polls in a row the editor did not answer: one miss is a busy editor (an import, a domain reload), two a closed one. */
61let misses = 0
62/** The action a button started in this module's life (start() clears the state's copy a reload leaves). */
63let acting: BusyKind | null = null
64/** Until when a first press on "Stop the tests" waits for the second one (ms since epoch). */
65let armedUntil = 0
66
67const slash = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
68const historyKey = () => `history:${root}`
69const suiteKey = () => `suite:${root}`
70
71function view(now: number): View {
72 return { t, testMode: cfg.testMode, buildTarget: cfg.buildTarget, editorVersion, cliPath, logPath, root, armed: armedUntil > now }
73}
74
75// ---------- the machine, the project and the toolchain ----------
76
77/** The machine's language: the POSIX locale variables, or Windows' UI culture. */
78async function detectLanguage($: EngineInterface): Promise<Lang> {
79 // Each name spelled out: the engine lists the variables a mod reads from its literal calls.
80 const locales = [await $.env.get('LC_ALL'), await $.env.get('LC_MESSAGES'), await $.env.get('LANG'), await $.env.get('LANGUAGE')]
81 for (const v of locales) {
82 if (v && v.length >= 2 && v !== 'C' && v !== 'POSIX') return v.toLowerCase().startsWith('fr') ? 'fr' : 'en'
83 }
84 if (isWindows) {
85 try {
86 const run = await $.process.run(['powershell', '-NoProfile', '-Command', '(Get-UICulture).TwoLetterISOLanguageName'], { timeoutMs: 10_000 })
87 return run.stdout.trim().toLowerCase() === 'fr' ? 'fr' : 'en'
88 } catch {
89 // keep English
90 }
91 }
92 return 'en'
93}
94
95/** The session's folder (or its parent) when it is a Unity project: ProjectSettings/ProjectVersion.txt and Assets/. */
96async function findProject($: EngineInterface): Promise<void> {
97 const cwd = slash(await $.session.cwd())
98 for (const dir of [cwd, cwd.replace(/\/[^/]+$/, '')]) {
99 try {
100 const version = await $.fs.read(`${dir}/ProjectSettings/ProjectVersion.txt`)
101 const assets = await $.fs.stat(`${dir}/Assets`)
102 if (assets.kind !== 'dir') continue
103 root = dir
104 projectName = dir.split('/').pop() ?? 'Unity'
105 editorVersion = /m_EditorVersion:\s*(\S+)/.exec(version)?.[1]
106 return
107 } catch {
108 // not this one
109 }
110 }
111 root = null
112}
113
114/** The Unity CLI: where Unity's installers put it on each system, then whatever `unity` the PATH gives. */
115async function findCli($: EngineInterface): Promise<string | undefined> {
116 const local = await $.env.get('LOCALAPPDATA')
117 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
118 const candidates = [
119 local ? `${slash(local)}/Unity/bin/unity.exe` : '',
120 home ? `${slash(home)}/.unity/bin/unity` : '',
121 home ? `${slash(home)}/.local/bin/unity` : '',
122 'unity',
123 ].filter(c => c.length > 0)
124 for (const c of candidates) {
125 try {
126 const run = await $.process.run([c, '--version'], { timeoutMs: 15_000 })
127 if (run.exitCode === 0) {
128 cliPath = c
129 return run.stdout.trim().split('\n')[0]
130 }
131 } catch {
132 // not there
133 }
134 }
135 cliPath = null
136 return undefined
137}
138
139async function hasPipelinePackage($: EngineInterface): Promise<boolean> {
140 if (!root) return false
141 try {
142 return (await $.fs.read(`${root}/Packages/manifest.json`)).includes('"com.unity.pipeline"')
143 } catch {
144 return false
145 }
146}
147
148/** The installed editor of the project's version, from the CLI's list. */
149async function findEditor($: EngineInterface): Promise<void> {
150 editorPath = undefined
151 if (!cliPath || !editorVersion) return
152 try {
153 const run = await $.process.run([cliPath, 'editors', '--installed', '--json', '--no-banner'], { timeoutMs: 30_000 })
154 const parsed = JSON.parse(run.stdout) as { data?: { version?: string; location?: string }[] }
155 editorPath = parsed.data?.find(ed => ed.version === editorVersion)?.location
156 } catch {
157 // keep none
158 }
159}
160
161async function readSetup($: EngineInterface): Promise<SetupState> {
162 const cliVersion = await findCli($)
163 await findEditor($)
164 return { hasCli: cliPath !== null, cliVersion, hasPipeline: await hasPipelinePackage($), editorVersion, editorPath }
165}
166
167/** The newest of the editor logs this system may write; the ghost check needs to see it move. */
168async function findLog($: EngineInterface): Promise<void> {
169 const local = await $.env.get('LOCALAPPDATA')
170 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
171 const candidates = [
172 `${root}/Logs/Editor.log`,
173 local ? `${slash(local)}/Unity/Editor/Editor.log` : '',
174 home ? `${slash(home)}/Library/Logs/Unity/Editor.log` : '',
175 home ? `${slash(home)}/.config/unity3d/Editor.log` : '',
176 ].filter(c => c.length > 0)
177 let best: { path: string; mtimeMs: number } | undefined
178 for (const path of candidates) {
179 try {
180 const st = await $.fs.stat(path)
181 if (!best || st.mtimeMs > best.mtimeMs) best = { path, mtimeMs: st.mtimeMs }
182 } catch {
183 // not on this system
184 }
185 }
186 logPath = best?.path
187}
188
189async function logTail($: EngineInterface): Promise<string> {
190 if (!logPath) return ''
191 try {
192 return (await $.fs.read(logPath)).split('\n').slice(-LOG_TAIL_LINES).join('\n')
193 } catch {
194 return ''
195 }
196}
197
198/** An editor holds the project open: its lock file is there and a Unity process runs. */
199async function editorRunning($: EngineInterface): Promise<boolean> {
200 try {
201 await $.fs.stat(`${root}/Temp/UnityLockfile`)
202 } catch {
203 return false
204 }
205 try {
206 const run = isWindows
207 ? await $.process.run(['tasklist', '/FI', 'IMAGENAME eq Unity.exe', '/NH'], { timeoutMs: 10_000 })
208 : await $.process.run(['pgrep', '-x', 'Unity'], { timeoutMs: 10_000 })
209 // tasklist also lists the lowercase unity.exe of the CLI: only the editor's own capitalised name counts.
210 return isWindows ? /^Unity\.exe\s/m.test(run.stdout) : run.exitCode === 0
211 } catch {
212 return false
213 }
214}
215
216/** An editor command through the Pipeline package (`unity cmd <args>`). */
217async function cli($: EngineInterface, args: string[], timeoutMs = 60_000): Promise<CliAnswer> {
218 if (!cliPath || !root) return { ok: false, error: t.cliMissing }
219 try {
220 const run = await $.process.run([cliPath, 'cmd', ...args, '--no-banner', '--json'], { cwd: root, timeoutMs })
221 return parseCliOutput(run.stdout, t.failed)
222 } catch (err) {
223 return { ok: false, error: String(err) }
224 }
225}
226
227// ---------- polling ----------
228
229/** Reads everything the pane shows; `full` also reads the slow facts (git, console, setup) right away. */
230async function poll($: EngineInterface, full = false) {
231 if (polling || !root) return
232 const projectRoot = root
233 polling = true
234 try {
235 tick++
236 const slow = full || tick % SLOW_EVERY === 1
237 const prev = filled(await read($, snapshot))
238 const now = await $.clock.now()
239 const sameProject = prev.project?.root === projectRoot
240 const base = sameProject ? prev : { ...initial, project: { root: projectRoot, name: projectName } }
241 // `activity` collects this poll's own lines; mergePoll puts them on top of what stands then.
242 const next: UnitySnapshot = { ...base, updatedAt: now, activity: [] }
243 const clear: BusyKind[] = []
244 let leaveNote: string | undefined
245
246 if (next.history.length === 0) {
247 const saved = (await $.store.get(historyKey())) as RunRecord[] | undefined
248 if (Array.isArray(saved)) next.history = saved
249 }
250 // The toolchain rarely changes: read it at the start, slowly after, and right away while something is missing.
251 const incomplete = !prev.setup || !prev.setup.hasCli || !prev.setup.hasPipeline || !prev.setup.editorPath
252 if (slow || incomplete || !sameProject || !cliPath) next.setup = await readSetup($)
253 const before = sameProject ? prev.setup : undefined
254 if (next.setup?.hasCli && !before?.hasCli) note(next, 'ok', t.dash.cliReady, t.dash.cliReadyDetail(next.setup.cliVersion ?? '', next.setup.hasPipeline), now)
255 if (next.setup?.editorPath && !before?.editorPath) note(next, 'ok', t.dash.unityFound, t.dash.unityFoundDetail(next.setup.editorVersion ?? ''), now)
256
257 // Disk facts first: they hold even with the editor closed.
258 next.apk = await newestBuild($)
259 const prevApk = sameProject ? prev.apk : undefined
260 if (next.apk && next.apk.mtimeMs !== prevApk?.mtimeMs) {
261 note(next, 'ok', prevApk ? t.dash.newBuild : t.dash.buildFound, t.dash.buildDetail(next.apk.name, Math.round(next.apk.size / 1048576)), now)
262 }
263 if (slow || !logPath) await findLog($)
264 if (logPath) {
265 try {
266 const log = await $.fs.stat(logPath)
267 if (log.mtimeMs !== lastLogMtime) {
268 lastLogMtime = log.mtimeMs
269 lastLogChange = now
270 }
271 } catch {
272 logPath = undefined
273 }
274 }
275 try {
276 const assets = await $.fs.list(`${projectRoot}/Assets`)
277 next.initScenes = assets.filter(f => f.name.startsWith('InitTestScene') && f.name.endsWith('.unity')).length
278 } catch {
279 next.initScenes = 0
280 }
281 if (slow || !next.git || !sameProject) next.git = (await readGit($)) ?? (sameProject ? next.git : undefined)
282 if (!sameProject) note(next, 'info', t.dash.projectLoaded, t.dash.projectLoadedDetail(projectName, next.git?.branch ?? '—'), now)
283
284 const editor = cliPath ? await cli($, ['editor_status'], 20_000) : { ok: false, error: t.cliMissing }
285 if (!editor.ok) {
286 misses++
287 // One missed answer while the editor was up is a busy editor: keep what was known and look again.
288 const blip = misses < 2 && prev.isReachable && sameProject
289 if (!blip) {
290 next.isReachable = false
291 // The editor's process still up on this project is a busy or stuck editor, not a closed one.
292 next.isHung = isHungAnswer(editor) || (await editorRunning($))
293 next.isCompiling = false
294 next.status = undefined
295 next.isGhost = false
296 if (prev.isReachable && sameProject) {
297 note(next, next.isHung ? 'warn' : 'bad', next.isHung ? t.dash.unityHung : t.dash.unityClosed, undefined, now)
298 $.ui.toast(prev.tests.state === 'running' ? t.crashedDuringTests : next.isHung ? t.hung : t.closedOrCrashed, { timeoutMs: 10_000 })
299 }
300 if (next.tests.state === 'running' && !next.isHung) next.tests = { ...next.tests, state: 'idle' }
301 }
302 } else {
303 misses = 0
304 const e = (editor.result ?? {}) as { status?: string; playMode?: string; compiling?: boolean; domainReloadInProgress?: boolean }
305 next.isReachable = true
306 next.isHung = false
307 next.status = e.status
308 next.playMode = e.playMode
309 const compiling = Boolean(e.compiling || e.domainReloadInProgress)
310 // The compile result: when a compile just ended, and now and then for one too short to be seen.
311 if (!compiling && (prev.isCompiling || slow || !sameProject)) {
312 if (await checkCompile($, next, now)) leaveNote = t.compileFailedNote
313 }
314 next.isCompiling = compiling
315 if (!prev.isReachable && prev.updatedAt > 0 && sameProject) $.ui.toast(t.answers)
316 if (!prev.isReachable || !sameProject) {
317 // Back from closed (even if it looked busy while it started) reads "opened"; back from a busy spell, "answers again".
318 const lastDown = (prev.activity ?? []).find(a => a.title === t.dash.unityClosed || a.title === t.dash.unityHung)?.title
319 const title = !sameProject || prev.updatedAt === 0 ? t.dash.unityAnswers
320 : lastDown === t.dash.unityClosed || (!lastDown && !prev.isHung) ? t.dash.unityOpened : t.dash.unityBack
321 note(next, 'ok', title, next.setup?.editorVersion ? `Unity ${next.setup.editorVersion}` : undefined, now)
322 }
323 clear.push('open')
324
325 await pollTests($, prev, next, now, slow)
326 if (await pollBuild($, prev, next, now, slow)) clear.push('build')
327 if (slow || !next.console) next.console = (await readConsole($)) ?? next.console
328 }
329
330 await update($, snapshot, cur => mergePoll(cur, next, { root: projectRoot, prev, clear, leaveNote, now, t }))
331 $.ui.status(cfg.statusLine ? statusLine(t, filled(await read($, snapshot)), now) : undefined)
332 } finally {
333 polling = false
334 }
335}
336
337async function newestBuild($: EngineInterface): Promise<UnitySnapshot['apk']> {
338 try {
339 const files = (await $.fs.list(`${root}/Builds`)).filter(f => f.kind === 'file' && /\.(apk|aab)$/i.test(f.name))
340 const top = [...files].sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
341 return top ? { mtimeMs: top.mtimeMs, size: top.size, name: top.name } : undefined
342 } catch {
343 return undefined
344 }
345}
346
347async function readGit($: EngineInterface) {
348 if (!root) return undefined
349 try {
350 const st = await $.process.run(['git', 'status', '--porcelain', '-b'], { cwd: root, timeoutMs: 20_000 })
351 if (st.exitCode !== 0) return undefined
352 const log = await $.process.run(['git', 'log', '-1', '--format=%h|%ct|%s'], { cwd: root, timeoutMs: 10_000 })
353 return parseGit(st.stdout, log.exitCode === 0 ? log.stdout : undefined)
354 } catch {
355 return undefined
356 }
357}
358
359async function readConsole($: EngineInterface) {
360 const c = await cli($, ['console'], 20_000)
361 return c.ok ? parseConsole(c.result) : undefined
362}
363
364/** Reads the last compile's result into `next`; true when it newly failed. */
365async function checkCompile($: EngineInterface, next: UnitySnapshot, now: number): Promise<boolean> {
366 const rc = await cli($, ['recompile_status'], 20_000)
367 if (!rc.ok) return false
368 const failed = compileFailedIn(rc.result)
369 const was = next.compileFailed === true
370 next.compileFailed = failed
371 if (!failed || was) return false
372 note(next, 'bad', t.dash.compileFailed, t.compileFailedNote, now)
373 $.ui.toast(t.compileFailedToast, { timeoutMs: 10_000 })
374 return true
375}
376
377async function pollTests($: EngineInterface, prev: UnitySnapshot, next: UnitySnapshot, now: number, slow: boolean) {
378 const ts = await cli($, ['test_status'], 30_000)
379 // No answer says nothing about the run: a busy editor misses one now and then mid-suite.
380 if (!ts.ok) return
381 const r = ts.result as
382 | { status?: string; duration?: number; summary?: { total?: number; passed?: number; failed?: number }; results?: { FullName: string; Status: string }[] }
383 | undefined
384 const status = r?.status
385 if (status === 'running') {
386 const starting = prev.tests.state !== 'running'
387 const tests: TestRun = { state: 'running', startedAt: starting ? now : prev.tests.startedAt, group: prev.tests.group }
388 // The log can be large after a long run: read its tail only now and then.
389 if (starting || slow) tests.group = currentGroup(await logTail($)) ?? prev.tests.group
390 if (starting) note(next, 'info', t.dash.testsStarted, tests.group, now)
391 next.tests = tests
392 // A run that says "running" with the editor in edit mode and a log gone quiet is the runner's ghost;
393 // without a log to watch there is no telling, so never.
394 const quiet = lastLogChange > 0 && now - lastLogChange > GHOST_MS
395 if (next.playMode === 'stopped' && quiet) {
396 if (ghostSince === 0) ghostSince = now
397 } else {
398 ghostSince = 0
399 }
400 next.isGhost = ghostSince > 0 && now - ghostSince > GHOST_MS
401 if (next.isGhost && !prev.isGhost) $.ui.toast(t.testsStuckToast, { timeoutMs: 10_000 })
402 return
403 }
404 ghostSince = 0
405 next.isGhost = false
406 if (status === 'completed' || status === 'cancelled' || status === 'failed') {
407 const ended = prev.tests.state === 'running'
408 next.tests = {
409 state: 'done',
410 total: r?.summary?.total,
411 passed: r?.summary?.passed,
412 failed: r?.summary?.failed,
413 durationS: r?.duration,
414 // A run that ended before this session saw it has no known end time.
415 endedAt: ended ? now : prev.tests.endedAt,
416 failedNames: (r?.results ?? []).filter(x => x.Status !== 'Passed').map(x => x.FullName),
417 group: prev.tests.group,
418 }
419 if (!ended) return
420 const failed = r?.summary?.failed ?? 0
421 const total = r?.summary?.total ?? 0
422 const passed = r?.summary?.passed ?? 0
423 if (status === 'cancelled') note(next, 'warn', t.dash.testsCancelled, undefined, now)
424 else note(next, failed === 0 ? 'ok' : 'bad', failed === 0 ? t.dash.testsPassed : t.dash.testsFailed, t.dash.testsDetail(passed, total, minutes(r?.duration ?? 0)), now)
425 $.ui.toast(status === 'cancelled' ? t.testsCancelled : failed === 0 ? t.testsPassed(passed, total) : t.testsFailed(failed, total), { timeoutMs: 12_000 })
426 if (status !== 'cancelled' && total > 0) {
427 next.history = [...next.history, { at: now, total, passed, failed, durationS: r?.duration ?? 0 }].slice(-HISTORY_SIZE)
428 await $.store.set(historyKey(), next.history)
429 // A filtered run says nothing of the full suite's length: keep the run with the most tests.
430 const suite = (await $.store.get(suiteKey())) as SuiteRecord | undefined
431 if (r?.duration && (!suite || total >= suite.total * 0.9)) await $.store.set(suiteKey(), { total, durationS: r.duration })
432 }
433 return
434 }
435 if (prev.tests.state === 'running') next.tests = { ...prev.tests, state: 'idle' }
436}
437
438/** Follows the build a button started; true once it has ended. */
439async function pollBuild($: EngineInterface, prev: UnitySnapshot, next: UnitySnapshot, now: number, slow: boolean): Promise<boolean> {
440 // Ask only around a build: the editor answers the last one forever.
441 if (prev.build.state !== 'building' && prev.busy !== 'build' && !slow) return false
442 const bs = await cli($, ['build_status'], 20_000)
443 if (!bs.ok) return false
444 const r = bs.result as { status?: string; result?: string; totalErrors?: number; buildTimeMs?: number } | undefined
445 if (r?.status === 'building' || r?.status === 'queued') {
446 if (prev.build.state !== 'building') note(next, 'info', t.dash.buildStarted, cfg.buildTarget, now)
447 next.build = { state: 'building', startedAt: prev.build.state === 'building' ? (prev.build.startedAt ?? now) : now }
448 return false
449 }
450 if (r?.status !== 'completed') return false
451 // Right after the button, the editor may still answer the previous build's result.
452 if (prev.build.state === 'building' && prev.build.startedAt && now - prev.build.startedAt < BUILD_GRACE_MS) return false
453 next.build = { state: 'done', result: r.result, errors: r.totalErrors, durationS: r.buildTimeMs ? r.buildTimeMs / 1000 : prev.build.durationS }
454 if (prev.build.state !== 'building') return prev.busy === 'build'
455 const ok = r.result === 'Succeeded'
456 note(next, ok ? 'ok' : 'bad', ok ? t.dash.buildSucceeded : t.dash.buildFailed, r.buildTimeMs ? minutes(r.buildTimeMs / 1000) : r.result, now)
457 $.ui.toast(ok ? t.buildDone : t.buildFailed(r.result ?? t.failed), { timeoutMs: 12_000 })
458 return true
459}
460
461// ---------- the buttons' actions ----------
462
463async function beginBusy($: EngineInterface, busy: BusyKind): Promise<boolean> {
464 // Claimed before any await: two quick presses never both start.
465 if (acting) {
466 $.ui.toast(t.alreadyBusy(t.busy[acting]))
467 return false
468 }
469 acting = busy
470 const s = filled(await read($, snapshot))
471 if (s.busy) {
472 acting = null
473 $.ui.toast(t.alreadyBusy(t.busy[s.busy as BusyKind] ?? s.busy))
474 return false
475 }
476 const at = await $.clock.now()
477 await update($, snapshot, v => ({ ...v, busy, busySince: at, note: undefined }))
478 return true
479}
480
481async function endBusy($: EngineInterface, busy: BusyKind, text: string, keepBusy = false) {
482 if (acting === busy) acting = null
483 const at = await $.clock.now()
484 await update($, snapshot, v => {
485 const release = v.busy === busy && !keepBusy
486 const s = { ...filled(v), busy: release ? undefined : v.busy, busySince: release ? undefined : v.busySince, note: text }
487 // Tests and builds log their own start and end once the editor reports them.
488 if (busy !== 'tests' && busy !== 'build') note(s, 'info', capitalize(t.busy[busy]), text, at)
489 return s
490 })
491 $.ui.toast(text)
492 await poll($)
493}
494
495const psQuote = (s: string) => `'${s.replace(/'/g, "''")}'`
496const shQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
497
498/** Starts a program detached from this session (Unity, an installer window), on each system its own way. */
499async function launchDetached($: EngineInterface, file: string, args: string[]): Promise<boolean> {
500 let command: string[]
501 if (isWindows) {
502 const list = args.length > 0 ? ` -ArgumentList ${args.map(psQuote).join(',')}` : ''
503 command = ['powershell', '-NoProfile', '-Command', `Start-Process -FilePath ${psQuote(file)}${list}`]
504 } else {
505 // A macOS editor is an app bundle, opened by `open`; elsewhere the binary runs in the background.
506 const app = /^(.*?\.app)(\/|$)/.exec(file)?.[1]
507 command = app ? ['open', '-n', app, '--args', ...args] : ['sh', '-c', `nohup ${[file, ...args].map(shQuote).join(' ')} >/dev/null 2>&1 &`]
508 }
509 try {
510 return (await $.process.run(command, { timeoutMs: 30_000 })).exitCode === 0
511 } catch {
512 return false
513 }
514}
515
516async function openUnity($: EngineInterface) {
517 if (!root) return
518 if (!(await beginBusy($, 'open'))) return
519 // Never a second editor on a project that is already open.
520 if ((await cli($, ['editor_status'], 15_000)).ok) {
521 await endBusy($, 'open', t.alreadyOpen)
522 return
523 }
524 if (!editorPath) await findEditor($)
525 if (!editorPath) {
526 await endBusy($, 'open', t.editorNotInstalled(editorVersion ?? ''))
527 return
528 }
529 // -projectPath wants the system's own spelling; Start-Process needs the quotes to keep a path with spaces whole.
530 const projectArg = isWindows ? `"${root.replace(/\//g, '\\')}"` : root
531 const ok = await launchDetached($, editorPath, ['-projectPath', projectArg])
532 // Stays busy until the editor answers (a poll clears it); a minute or two on a big project.
533 await endBusy($, 'open', ok ? t.unityStarting : t.unityLaunchFailed, ok)
534}
535
536async function installEditor($: EngineInterface) {
537 if (!cliPath || !editorVersion) return
538 if (!(await beginBusy($, 'install-editor'))) return
539 if (isWindows) {
540 // A visible window: the download is long and the person follows it there.
541 const ok = await launchDetached($, 'powershell', ['-NoExit', '-Command', `& ${psQuote(cliPath)} install ${editorVersion}`])
542 await endBusy($, 'install-editor', ok ? t.editorInstallStarted(editorVersion) : t.editorInstallFailed)
543 return
544 }
545 await update($, snapshot, v => ({ ...v, note: t.editorInstallRunning(editorVersion ?? '') }))
546 let text: string
547 try {
548 const run = await $.process.run([cliPath, 'install', editorVersion, '--no-banner'], { timeoutMs: 2 * 3600_000 })
549 text = run.exitCode === 0 ? t.editorInstallDone(editorVersion) : t.editorInstallFailed
550 } catch {
551 text = t.editorInstallFailed
552 }
553 await update($, snapshot, v => ({ ...v, setup: undefined }))
554 await endBusy($, 'install-editor', text)
555}
556
557async function installCli($: EngineInterface) {
558 if (!(await beginBusy($, 'install-cli'))) return
559 let text: string
560 try {
561 const command = isWindows
562 ? ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', CLI_INSTALL_WINDOWS]
563 : ['bash', '-c', CLI_INSTALL_UNIX]
564 const run = await $.process.run(command, { timeoutMs: 600_000 })
565 const version = await findCli($)
566 text = version ? t.cliInstalled(version) : t.cliInstallNotFound(run.exitCode)
567 } catch (err) {
568 text = t.cliInstallFailed(String(err))
569 }
570 await update($, snapshot, v => ({ ...v, setup: undefined }))
571 await endBusy($, 'install-cli', text)
572}
573
574async function installPipeline($: EngineInterface) {
575 if (!root || !cliPath) return
576 if (!(await beginBusy($, 'install-pipeline'))) return
577 let text: string
578 try {
579 const run = await $.process.run([cliPath, 'pipeline', 'install', '--project-path', root, '--no-banner'], { timeoutMs: 300_000 })
580 text = run.exitCode === 0 ? t.pipelineAdded : t.pipelineRefused((run.stderr || run.stdout).trim().split('\n').pop() ?? String(run.exitCode))
581 } catch (err) {
582 text = t.pipelineFailed(String(err))
583 }
584 await update($, snapshot, v => ({ ...v, setup: undefined }))
585 await endBusy($, 'install-pipeline', text)
586}
587
588async function recompile($: EngineInterface) {
589 if (!(await beginBusy($, 'recompile'))) return
590 // A compile mid-suite reloads the domain under the test runner and can take the editor down.
591 if (working(filled(await read($, snapshot)))) {
592 await endBusy($, 'recompile', t.waitForWork)
593 return
594 }
595 // The domain reload a compile starts can cut the editor's reply: a timeout here still means the request went through.
596 const r = await cli($, ['eval', '--code',
597 'UnityEditor.AssetDatabase.Refresh(); UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); return 1;'], 15_000)
598 const asked = r.ok || /timed out|timeout|connection|reset|closed/i.test(r.error ?? '')
599 await endBusy($, 'recompile', asked ? t.recompileRequested : t.recompileRefused(r.error ?? ''))
600}
601
602async function runAllTests($: EngineInterface) {
603 if (!(await beginBusy($, 'tests'))) return
604 const s = filled(await read($, snapshot))
605 let text: string
606 if (s.tests.state === 'running') text = t.suiteAlreadyRunning
607 else if (s.build.state === 'building') text = t.waitForBuild
608 else if (s.playMode === 'playing' || s.playMode === 'paused') text = t.stopPlayFirst
609 else {
610 const r = await cli($, ['run_tests', '--async_tests', 'true', '--mode', cfg.testMode])
611 text = r.ok ? t.suiteStarted(cfg.testMode === 'editmode' ? 'EditMode' : 'PlayMode') : t.runRefused(r.error ?? '')
612 }
613 await endBusy($, 'tests', text)
614}
615
616/** Stops a running suite; the first press only arms it, so a stray click never throws away a long run. */
617async function cancelTests($: EngineInterface) {
618 const now = await $.clock.now()
619 if (armedUntil < now) {
620 armedUntil = now + ARM_MS
621 await update($, snapshot, v => ({ ...v, note: t.confirmCancelNote }))
622 // Redraw once the window closes, so the button reads "Stop the tests" again.
623 $.clock.after(ARM_MS + 100, () => void disarm($))
624 return
625 }
626 armedUntil = 0
627 if (!(await beginBusy($, 'cancel'))) return
628 const r = await cli($, ['cancel_tests'], 30_000)
629 await endBusy($, 'cancel', r.ok ? t.testsStopping : t.runRefused(r.error ?? ''))
630}
631
632async function disarm($: EngineInterface) {
633 await update($, snapshot, v => ({ ...v, note: v.note === t.confirmCancelNote ? undefined : v.note }))
634}
635
636async function unblock($: EngineInterface) {
637 if (!(await beginBusy($, 'unblock'))) return
638 // Cancelling a live run is not unblocking it.
639 if (!canUnblock(filled(await read($, snapshot)))) {
640 await endBusy($, 'unblock', t.waitForWork)
641 return
642 }
643 await cli($, ['cancel_tests'])
644 // Back on the first scene of the build list, and no InitTestScene left behind by an aborted run.
645 const r = await cli($, ['eval', '--code',
646 'var b=UnityEditor.EditorBuildSettings.scenes; ' +
647 'if(b.Length>0 && UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene().path!=b[0].path) UnityEditor.SceneManagement.EditorSceneManager.OpenScene(b[0].path); ' +
648 'foreach(var g in UnityEditor.AssetDatabase.FindAssets("InitTestScene", new[]{"Assets"})){var p=UnityEditor.AssetDatabase.GUIDToAssetPath(g); ' +
649 'if(p.StartsWith("Assets/InitTestScene")) UnityEditor.AssetDatabase.DeleteAsset(p);} return 1;'])
650 await endBusy($, 'unblock', r.ok ? t.unblocked : t.unblockPartial(r.error ?? ''))
651}
652
653async function buildPlayer($: EngineInterface) {
654 if (cfg.buildTarget === 'off') return
655 if (!(await beginBusy($, 'build'))) return
656 if (working(filled(await read($, snapshot)))) {
657 await endBusy($, 'build', t.testsRunningNoBuild)
658 return
659 }
660 const r = await cli($, ['build', '--confirm', 'true', '--outputPath', `Builds/${projectName}.apk`, '--target', cfg.buildTarget])
661 if (!r.ok) {
662 await endBusy($, 'build', t.buildRefused(r.error ?? ''))
663 return
664 }
665 const startedAt = await $.clock.now()
666 await update($, snapshot, v => ({ ...v, build: { state: 'building' as const, startedAt } }))
667 // Stays busy until pollBuild sees the build end.
668 await endBusy($, 'build', t.buildStarted(cfg.buildTarget), true)
669}
670
671async function runAction($: EngineInterface, k: ActionKey) {
672 switch (k) {
673 case 'install-cli': return installCli($)
674 case 'install-editor': return installEditor($)
675 case 'install-pipeline': return installPipeline($)
676 case 'open-unity': return openUnity($)
677 case 'recompile': return recompile($)
678 case 'tests': return runAllTests($)
679 case 'build': return buildPlayer($)
680 case 'unblock': return unblock($)
681 case 'cancel-tests': return cancelTests($)
682 }
683}
684
685async function toggleDetails($: EngineInterface, which: Exclude<DetailsView, 'none'>) {
686 await update($, details, cur => (cur === which ? 'none' : which))
687}
688
689async function openPane($: EngineInterface, focus: boolean) {
690 if (focus) await $.ui.open({ id: PANE, title: `Unity · ${projectName}`, focus: true })
691 else await $.ui.open({ id: PANE, title: `Unity · ${projectName}` })
692 void poll($)
693}
694
695async function start($: EngineInterface) {
696 isWindows = Boolean(await $.env.get('LOCALAPPDATA')) || (await $.env.get('OS')) === 'Windows_NT'
697 t = pick(cfg.language === 'auto' ? await detectLanguage($) : cfg.language)
698 await $.command.register({ name: 'unity', description: t.commandDescription })
699 await findProject($)
700 // A reload drops the module's pending actions: a busy flag left in the session's state would never clear.
701 await update($, snapshot, v => ({ ...filled(v), busy: undefined, busySince: undefined }))
702 if (root) {
703 $.clock.every(POLL_MS, () => void poll($))
704 if (cfg.openOnStart) void openPane($, false)
705 else void poll($)
706 }
707}
708
709// ---------- the hooks ----------
710
711export const register: Register = (on, options) => {
712 cfg = readConfig(options)
713 if (cfg.language !== 'auto') t = pick(cfg.language)
714
715 on('session.start', async ($, e, next) => {
716 await start($)
717 return next(e)
718 })
719
720 on('command.run', { command: 'unity' }, async $ => {
721 if (!root) await findProject($)
722 if (!root) return { text: t.notUnityProject }
723 await openPane($, true)
724 return { text: t.paneOpened }
725 })
726
727 // Typing just "unity" in a Unity project opens the pane instead of asking the model.
728 on('prompt.submit', async ($, e, next) => {
729 if (root && e.text.trim().toLowerCase() === 'unity') {
730 await openPane($, true)
731 return { drop: t.paneOpened }
732 }
733 return next(e)
734 })
735
736 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
737 const els = $.ui.resolve(e)
738 const { Box, Text, Button } = els
739 try {
740 const s = filled(await read($, snapshot))
741 if (!root) return <Text color={K.muted}>{t.notUnityProjectShort}</Text>
742 const now = await $.clock.now()
743 const suite = (await $.store.get(suiteKey())) as SuiteRecord | undefined
744 const cols = e.props.bodyColumns ?? 60
745 const narrow = cols < NARROW_COLUMNS
746 const v = view(now)
747 const action = actionFor(v, s, now, suite)
748 const ed = editorBadge(t, s)
749 const g = s.git
750 const shown = await read($, details)
751 const activity = s.activity ?? []
752 const failedNames = s.tests.state === 'done' ? (s.tests.failedNames ?? []) : []
753
754 // Icons where the surface draws SVG (desktop, editor, phone); a coloured glyph in the terminal.
755 const Svg = 'Svg' in els ? els.Svg : undefined
756 // An Svg with an empty alt is drawn as nothing: each icon names what it shows.
757 const glyph = (name: IconName, tone: Tone, size: number, alt: string) =>
758 Svg ? <Svg source={icon(name, tone, size)} alt={alt} width={size} height={size} /> : null
759 const mark = (tone: Tone, name: IconName, size: number, alt: string) =>
760 Svg
761 ? <Svg source={statusMark(tone, name, size)} alt={alt} width={size} height={size} />
762 : <Text color={toneKey(tone)} bold>{toneGlyphText(tone)}</Text>
763 const panel = { borderStyle: 'round', borderColor: K.frame, paddingX: 1 } as const
764
765 const heroLine = [
766 g ? t.dash.filesChanged(g.dirty) : t.dash.noRepo,
767 g?.lastCommit?.split(' ')[0],
768 g?.commitAt ? ago(t, g.commitAt, now) : undefined,
769 ].filter(Boolean).join(' · ')
770
771 // Two cards a row when narrow, four when wide; each a framed column of the theme's colours.
772 const barWidth = Math.max(8, Math.floor(cols / (narrow ? 2 : 4)) - 8)
773 const cardViews = cardsFor(v, s, now, suite).map(c => (
774 <Box key={`card-${c.title}`} {...panel} flexDirection="column" width={narrow ? '48%' : '23%'} flexGrow={1}>
775 <Box flexDirection="row" alignItems="center" columnGap={1}>
776 {glyph(c.icon, 'muted', 20, c.title)}
777 <Text color={K.text} bold>{c.title}</Text>
778 </Box>
779 <Text color={toneKey(c.badge.tone)} bold>● {c.badge.text}</Text>
780 <Text color={K.text} wrap="truncate-end">{c.detail}</Text>
781 {c.progress !== undefined ? <Text color={toneKey(c.badge.tone)}>{bar(c.progress, barWidth)}</Text> : null}
782 {c.history && c.history.length > 0
783 ? <Text>{c.history.map((ok, i) => <Text key={`h${i}`} color={toneKey(ok ? 'ok' : 'bad')}>■</Text>)}</Text>
784 : c.footer.text
785 ? <Text color={toneKey(c.footer.tone)} wrap="truncate-end">{c.footer.text}</Text>
786 : null}
787 </Box>
788 ))
789
790 const buttons = buttonsFor(v, s, action).map(b => (
791 <Button key={b.key} variant={b.primary ? 'primary' : undefined} hotkey={b.hotkey} onPress={() => runAction($, b.action)}>{b.label}</Button>
792 ))
793 const busyLine = s.busy ? `◐ ${t.busy[s.busy as BusyKind] ?? s.busy}…` : undefined
794
795 return (
796 <Box flexDirection="column" rowGap={1}>
797 <Box flexDirection="row" alignItems="center" columnGap={1}>
798 {Svg ? <Svg source={logo(20)} alt="Unity Watch" width={20} height={20} /> : null}
799 <Text color={K.accent} bold>UNITY WATCH</Text>
800 <Box flexGrow={1} />
801 <Text color={toneKey(ed.tone)} bold>● {ed.text}</Text>
802 </Box>
803
804 <Box {...panel} flexDirection="row" alignItems="center" columnGap={2} paddingY={Svg ? 1 : 0}>
805 {glyph('cube', 'accent', 40, projectName)}
806 <Box flexDirection="column" flexShrink={1}>
807 <Text color={K.muted}>{t.dash.currentProject}</Text>
808 <Text>
809 <Text color={K.text} bold>{projectName}</Text>
810 {g?.branch ? <Text color={K.accent}>{` · ${g.branch}`}</Text> : null}
811 </Text>
812 <Text color={K.muted} wrap="truncate-end">{heroLine}</Text>
813 </Box>
814 </Box>
815
816 <Box flexDirection="row" alignItems="center" columnGap={1}>
817 {glyph('pulse', 'accent', 16, t.dash.systemState)}
818 <Text color={K.text} bold>{t.dash.systemState}</Text>
819 </Box>
820 <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>{cardViews}</Box>
821
822 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
823 {DETAIL_VIEWS.map(w => (
824 <Button key={`details-${w}`} plain dimColor={shown !== w} onPress={() => toggleDetails($, w)}>
825 {`${shown === w ? '▾' : '▸'} ${detailsTitle(t, w)}`}
826 </Button>
827 ))}
828 </Box>
829 {shown !== 'none'
830 ? <Box {...panel} flexDirection="column">
831 <Text color={K.text} bold>{detailsTitle(t, shown)}</Text>
832 {detailRows(v, s, shown, now, suite).map((row, i) => (
833 <Box key={`d${i}`} flexDirection="row" columnGap={2}>
834 <Box width={16} flexShrink={0}><Text color={K.muted}>{row[0]}</Text></Box>
835 <Box flexShrink={1}><Text color={row[2] ? toneKey(row[2]) : K.text} wrap="wrap">{row[1]}</Text></Box>
836 </Box>
837 ))}
838 </Box>
839 : null}
840
841 {failedNames.length > 0
842 ? <Box {...panel} borderColor={toneKey('bad')} flexDirection="column">
843 <Text color={toneKey('bad')} bold>{t.dash.failedTests}</Text>
844 {failedNames.slice(0, 8).map((n, i) => <Text key={`ft${i}`} color={K.text} wrap="truncate-end">✗ {shortTest(n)}</Text>)}
845 {failedNames.length > 8 ? <Text color={K.muted}>+{failedNames.length - 8}</Text> : null}
846 </Box>
847 : null}
848
849 <Box {...panel} flexDirection="column">
850 <Box flexDirection="row" columnGap={1}>
851 <Text color={K.text} bold>{t.dash.recentActivity}</Text>
852 <Box flexGrow={1} />
853 {activity[0] ? <Text color={K.muted}>{ago(t, activity[0].at, now)}</Text> : null}
854 </Box>
855 {activity.length === 0 ? <Text color={K.muted}>{t.dash.noActivity}</Text> : null}
856 {activity.slice(0, narrow ? 4 : 6).map((a, i) => (
857 <Box key={`a${i}`} flexDirection="row" alignItems="center" columnGap={2} marginTop={Svg ? 1 : 0}>
858 {mark(a.tone, toneGlyph(a.tone), 26, a.title)}
859 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
860 <Text color={K.text} bold wrap="truncate-end">{a.title}</Text>
861 {a.detail ? <Text color={K.muted} wrap="truncate-end">{a.detail}</Text> : null}
862 </Box>
863 <Text color={K.muted}>{clock(a.at)}</Text>
864 </Box>
865 ))}
866 </Box>
867
868 <Box {...panel} borderColor={toneKey(action.tone)} paddingY={Svg ? 1 : 0}
869 flexDirection={narrow ? 'column' : 'row'} alignItems={narrow ? 'flex-start' : 'center'} columnGap={2} rowGap={1}>
870 <Box flexDirection="row" alignItems="center" columnGap={2} flexGrow={1} flexShrink={1}>
871 {mark(action.tone, action.glyph, 48, action.head)}
872 <Box flexDirection="column" flexShrink={1}>
873 <Text color={toneKey(action.tone)} bold>{action.head}</Text>
874 <Text color={K.muted} wrap="wrap">{busyLine ?? action.sub}</Text>
875 </Box>
876 </Box>
877 <Box flexDirection="row" flexWrap="wrap" columnGap={1} alignSelf={narrow ? 'center' : 'auto'} justifyContent="center">
878 {buttons}
879 </Box>
880 </Box>
881
882 </Box>
883 )
884 } catch (err) {
885 // Shown in the pane itself: a refused or failing drawing otherwise leaves it blank.
886 $.ui.log(`unity-watch: ${String(err)}`)
887 return <Text color={toneKey('bad')}>{t.paneError(String(err))}</Text>
888 }
889 })
890}
891hooks/art.ts 76 lines1// Colours and icons. Text and borders name the person's theme colours, so the
2// pane follows a light, dark or colour-blind theme like the app around it. An
3// SVG cannot name theme keys, so icons use mid tones that read on light and
4// dark backgrounds alike.
5
6export type Tone = 'ok' | 'warn' | 'bad' | 'info' | 'muted' | 'accent'
7
8/** A tone as one of the theme's own colour keys, for Text and Box borders. */
9export const toneKey = (tone: Tone): string =>
10 tone === 'ok' ? 'success' : tone === 'warn' ? 'warning' : tone === 'bad' ? 'error' : tone === 'info' ? 'suggestion' : tone === 'accent' ? 'claude' : 'inactive'
11
12/** The theme keys the pane draws text and frames with. */
13export const K = { text: 'text', muted: 'inactive', frame: 'inactive', accent: 'claude' } as const
14
15/** Mid tones for SVG, readable on light and dark backgrounds. */
16const SVG_TONES: Record<Tone, string> = {
17 ok: '#16a34a',
18 warn: '#d97706',
19 bad: '#dc2626',
20 info: '#2563eb',
21 muted: '#6b7280',
22 accent: '#ea580c',
23}
24
25export const toneHex = (tone: Tone) => SVG_TONES[tone]
26
27// ---------- icons (24 × 24, stroked) ----------
28
29export type IconName = 'cube' | 'flask' | 'folder' | 'phone' | 'pulse' | 'check' | 'cross' | 'alert' | 'clock' | 'play' | 'download'
30
31const ICON_PATHS: Record<IconName, string> = {
32 cube: 'M12 2.5 20.5 7.25v9.5L12 21.5 3.5 16.75v-9.5Z M3.5 7.25 12 12l8.5-4.75 M12 12v9.5',
33 flask: 'M9 3h6 M10 3v6.2L4.6 18.4A1.8 1.8 0 0 0 6.2 21h11.6a1.8 1.8 0 0 0 1.6-2.6L14 9.2V3 M7.2 15h9.6',
34 folder: 'M3 7a2 2 0 0 1 2-2h4.2l2 2.2H19a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z',
35 phone: 'M8 2.5h8a2 2 0 0 1 2 2v15a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-15a2 2 0 0 1 2-2Z M11 18.5h2',
36 pulse: 'M2 12h4.5l2.5-7 4.5 14 3-7H22',
37 check: 'M5 12.5l4.5 4.5L19 7.5',
38 cross: 'M6.5 6.5l11 11 M17.5 6.5l-11 11',
39 alert: 'M12 7.5v6 M12 17h.01',
40 clock: 'M12 7v5l3.2 2 M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z',
41 play: 'M8 5.5v13l10.5-6.5Z',
42 download: 'M12 4v11 M7 10.5l5 5 5-5 M5 20h14',
43}
44
45/** One icon as a `<g>` at (x, y), `size` px. */
46function iconAt(name: IconName, x: number, y: number, size: number, color: string, width = 1.8): string {
47 const k = size / 24
48 return `<g transform="translate(${x} ${y}) scale(${k})" fill="none" stroke="${color}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"><path d="${ICON_PATHS[name]}"/></g>`
49}
50
51/** A lone icon, as its own SVG document. */
52export function icon(name: IconName, tone: Tone, size = 20): string {
53 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24">${iconAt(name, 0, 0, 24, toneHex(tone))}</svg>`
54}
55
56/** The mod's mark: an orange cube in a hexagon. */
57export function logo(size = 28): string {
58 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 32 32">
59<defs><linearGradient id="lg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#ffb35c"/><stop offset="1" stop-color="#f26b0f"/></linearGradient></defs>
60<path d="M16 1.5 28.5 8.75v14.5L16 30.5 3.5 23.25V8.75Z" fill="url(#lg)"/>
61<path d="M16 7.5 23.5 11.75v8.5L16 24.5 8.5 20.25v-8.5Z M8.5 11.75 16 16l7.5-4.25 M16 16v8.5" fill="none" stroke="#1a0d02" stroke-width="2" stroke-linejoin="round"/>
62</svg>`
63}
64
65/** A round status mark: a ring of `tone` around its glyph, `size` px. */
66export function statusMark(tone: Tone, glyph: IconName, size = 56): string {
67 const c = toneHex(tone)
68 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 64 64">
69<circle cx="32" cy="32" r="28" fill="${c}" fill-opacity="0.14" stroke="${c}" stroke-width="3"/>
70${iconAt(glyph, 18, 18, 28, c, 2.6)}
71</svg>`
72}
73
74/** The terminal's stand-in for a status mark. */
75export const toneGlyphText = (tone: Tone) => (tone === 'ok' ? '✓' : tone === 'bad' ? '✕' : tone === 'warn' ? '!' : '◷')
76hooks/core.ts 553 lines1// Everything the pane decides, as pure functions of plain values: how a poll's
2// reading merges with what the buttons wrote meanwhile, what each card and the
3// action block say, which buttons a state allows, and how the CLI's and git's
4// answers parse. register.tsx does the I/O and the drawing; tests/ drive these.
5
6import type { ActivityItem, ConsoleState, DetailsView, GitState, UnitySnapshot } from '../types'
7import type { IconName, Tone } from './art'
8
9/** One status card, as the pane draws it. */
10export type CardInput = {
11 icon: IconName
12 title: string
13 badge: { text: string; tone: Tone }
14 detail: string
15 footer: { text: string; tone: Tone }
16 /** 0..1: a bar under the detail line. */
17 progress?: number
18 /** Recent results, oldest first: squares in the footer instead of its text. */
19 history?: boolean[]
20}
21import type { BusyKind, Lang, Strings } from './strings'
22
23export const ACTIVITY_SIZE = 8
24export const HISTORY_SIZE = 12
25/** A "running" run with the editor stopped and a silent log this long is a ghost. */
26export const GHOST_MS = 60_000
27/** How long after the Build button an old "completed" answer is taken for the previous build. */
28export const BUILD_GRACE_MS = 15_000
29
30/** How long each action may hold the pane busy before a poll lets it go (a crash, a lost reply). */
31export const BUSY_LIMIT_MS: Record<BusyKind, number> = {
32 open: 5 * 60_000,
33 'install-editor': 3 * 3600_000,
34 'install-cli': 11 * 60_000,
35 'install-pipeline': 6 * 60_000,
36 recompile: 60_000,
37 tests: 2 * 60_000,
38 unblock: 2 * 60_000,
39 build: 3 * 3600_000,
40 cancel: 60_000,
41}
42
43export const initial: UnitySnapshot = {
44 isReachable: false,
45 isHung: false,
46 isCompiling: false,
47 isGhost: false,
48 tests: { state: 'idle' },
49 history: [],
50 build: { state: 'idle' },
51 initScenes: 0,
52 updatedAt: 0,
53}
54
55/**
56 * The snapshot with every field present. The host keeps the value across
57 * reloads, so one written by an older version of this mod lacks newer fields.
58 */
59export function filled(s: Partial<UnitySnapshot> | undefined): UnitySnapshot {
60 return {
61 ...initial,
62 ...s,
63 tests: s?.tests ?? initial.tests,
64 history: Array.isArray(s?.history) ? s.history : [],
65 build: s?.build ?? initial.build,
66 initScenes: s?.initScenes ?? 0,
67 }
68}
69
70/** Puts a line at the top of the pane's recent activity. */
71export function note(s: UnitySnapshot, tone: ActivityItem['tone'], title: string, detail: string | undefined, at: number) {
72 s.activity = [{ at, tone, title, detail }, ...(s.activity ?? [])].slice(0, ACTIVITY_SIZE)
73}
74
75// ---------- configuration ----------
76
77export type Config = {
78 language: 'auto' | Lang
79 openOnStart: boolean
80 statusLine: boolean
81 testMode: 'playmode' | 'editmode'
82 buildTarget: 'Android' | 'off'
83}
84
85export function readConfig(options: Readonly<Record<string, unknown>>): Config {
86 const str = (k: string) => (typeof options[k] === 'string' ? (options[k] as string) : undefined)
87 const bool = (k: string, d: boolean) => (typeof options[k] === 'boolean' ? (options[k] as boolean) : d)
88 const language = str('language')
89 return {
90 language: language === 'en' || language === 'fr' ? language : 'auto',
91 openOnStart: bool('openOnStart', true),
92 statusLine: bool('statusLine', true),
93 testMode: str('testMode') === 'editmode' ? 'editmode' : 'playmode',
94 buildTarget: str('buildTarget') === 'off' ? 'off' : 'Android',
95 }
96}
97
98/** What the drawing functions need beside the snapshot: the words, the settings, and what the module found. */
99export type View = {
100 t: Strings
101 testMode: Config['testMode']
102 buildTarget: Config['buildTarget']
103 editorVersion?: string
104 cliPath?: string | null
105 logPath?: string
106 root?: string | null
107 /** "Stop the tests" was pressed once and waits for the second press. */
108 armed?: boolean
109}
110
111// ---------- formatting ----------
112
113export function clock(ms: number): string {
114 const d = new Date(ms)
115 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
116}
117
118export function minutes(s: number): string {
119 if (s < 60) return `${Math.round(s)} s`
120 const m = Math.round(s / 60)
121 return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')}`
122}
123
124export function ago(t: Strings, ms: number, now: number): string {
125 const s = Math.max(0, (now - ms) / 1000)
126 if (s < 45) return t.justNow
127 if (s < 3600) return t.minutesAgo(Math.round(s / 60))
128 if (s < 86_400) return t.hoursAgo(Math.floor(s / 3600))
129 return t.onDate(new Date(ms).toLocaleDateString(t.dateLocale), clock(ms))
130}
131
132export function bar(ratio: number, width: number): string {
133 const r = Math.min(1, Math.max(0, ratio))
134 const full = Math.round(r * width)
135 return '█'.repeat(full) + '░'.repeat(Math.max(0, width - full))
136}
137
138/** "Ns.Class.Method_name" reads better as "Class › Method name". */
139export function shortTest(name: string): string {
140 const dot = name.lastIndexOf('.')
141 if (dot < 0) return name.replace(/_/g, ' ')
142 return `${name.slice(0, dot).split('.').pop()} › ${name.slice(dot + 1).replace(/_/g, ' ')}`
143}
144
145export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
146
147/** The title the "starting Unity" activity line carries. */
148export const openingTitle = (t: Strings) => capitalize(t.busy.open)
149
150// ---------- parsing ----------
151
152export type CliAnswer = { ok: boolean; result?: unknown; error?: string }
153
154/** `unity cmd … --json`: success, and the result, which some commands give as JSON text. */
155export function parseCliOutput(stdout: string, failed: string): CliAnswer {
156 const parsed = JSON.parse(stdout) as {
157 success?: boolean
158 data?: { result?: unknown } | null
159 errors?: { message?: string }[]
160 }
161 if (!parsed.success) return { ok: false, error: parsed.errors?.[0]?.message ?? failed }
162 let result = parsed.data?.result
163 if (typeof result === 'string') {
164 try {
165 result = JSON.parse(result)
166 } catch {
167 // plain text result: keep it as is
168 }
169 }
170 return { ok: true, result }
171}
172
173/** The CLI found an editor process, but its pipeline server did not answer: busy, a modal, or hung. */
174export const isHungAnswer = (a: CliAnswer) => !a.ok && /Found a running Unity Editor|not responding|timed out|timeout/i.test(a.error ?? '')
175
176/** The test class the log names last: the one a running suite is in. */
177export function currentGroup(tail: string): string | undefined {
178 const all = [...tail.matchAll(/Assets\/[^\s:()]*?(\w+Tests?)\.cs/g)]
179 return all.length > 0 ? all[all.length - 1]?.[1] : undefined
180}
181
182/** `git status --porcelain -b` and `git log -1 --format=%h|%ct|%s`. */
183export function parseGit(status: string, log: string | undefined): GitState {
184 const lines = status.split('\n').filter(l => l.trim().length > 0)
185 const head = lines[0] ?? ''
186 const [hash, time, ...subject] = log ? log.trim().split('|') : []
187 return {
188 branch: /^## (?:No commits yet on )?([^.\s]+)/.exec(head)?.[1],
189 ahead: Number(/ahead (\d+)/.exec(head)?.[1] ?? 0),
190 behind: Number(/behind (\d+)/.exec(head)?.[1] ?? 0),
191 dirty: Math.max(0, lines.length - 1),
192 lastCommit: hash ? `${hash} ${subject.join('|')}` : undefined,
193 commitAt: time ? Number(time) * 1000 : undefined,
194 }
195}
196
197/** The `console` command's entries, counted. */
198export function parseConsole(result: unknown): ConsoleState | undefined {
199 if (typeof result !== 'object' || result === null) return undefined
200 const r = result as Record<string, unknown>
201 const items = (r.entries ?? r.logs ?? r.messages ?? []) as { type?: string; message?: string }[]
202 if (!Array.isArray(items)) return undefined
203 const kind = (k?: string) => (k ?? '').toLowerCase()
204 const errs = items.filter(i => ['error', 'exception', 'assert'].includes(kind(i.type)))
205 const warns = items.filter(i => kind(i.type) === 'warning')
206 const last = errs.slice(-3).reverse().map(i => (i.message ?? '').split('\n')[0]?.slice(0, 140) ?? '')
207 return { errors: errs.length, warnings: warns.length, last }
208}
209
210export const compileFailedIn = (result: unknown) => JSON.stringify(result ?? null).includes('"compilationFailed":true')
211
212// ---------- one poll's write ----------
213
214export type PollMerge = {
215 /** The project the poll read. */
216 root: string
217 /** The snapshot the poll started from. */
218 prev: UnitySnapshot
219 /** The busy flags the poll saw end (the editor answered, the build ended). */
220 clear: BusyKind[]
221 /** A note the poll leaves (a failed compile). */
222 leaveNote?: string
223 now: number
224 t: Strings
225}
226
227/**
228 * The snapshot a poll writes: its reading (`next`, whose `activity` holds only
229 * its own new lines) over what stands now (`cur`). A poll takes seconds; what
230 * the buttons wrote meanwhile (busy, note, their activity lines, a build they
231 * started, a setup they reset) wins over the poll's stale copy of it.
232 */
233export function mergePoll(cur: Partial<UnitySnapshot> | undefined, next: UnitySnapshot, m: PollMerge): UnitySnapshot {
234 const c = filled(cur)
235 const keep = c.project?.root === m.root
236 const out: UnitySnapshot = { ...next }
237 let activity = [...(next.activity ?? []), ...(keep ? (c.activity ?? []) : [])]
238 // The editor is up: a "starting Unity…" line is history, not news.
239 if (out.isReachable) activity = activity.filter(x => x.title !== openingTitle(m.t))
240 out.activity = activity.slice(0, ACTIVITY_SIZE)
241 out.busy = keep ? c.busy : undefined
242 out.busySince = keep ? c.busySince : undefined
243 out.note = m.leaveNote ?? (keep ? c.note : undefined)
244 const busy = out.busy as BusyKind | undefined
245 const limit = busy ? (BUSY_LIMIT_MS[busy] ?? 60_000) : 0
246 if (busy && (m.clear.includes(busy) || (out.busySince !== undefined && m.now - out.busySince > limit))) {
247 out.busy = undefined
248 out.busySince = undefined
249 }
250 if (keep && c.build.state === 'building' && m.prev.build.state !== 'building' && next.build.state !== 'building') out.build = c.build
251 if (keep && c.setup === undefined && m.prev.setup !== undefined) out.setup = undefined
252 return out
253}
254
255// ---------- what the state allows ----------
256
257/** A suite or a build is running: nothing may recompile, start another run or build over it. */
258export const working = (s: UnitySnapshot) => s.tests.state === 'running' || s.build.state === 'building'
259
260/** A stuck runner, or test scenes left behind by a run that is over (a live run has its own). */
261export const canUnblock = (s: UnitySnapshot) => s.isGhost || (s.initScenes > 0 && s.tests.state !== 'running')
262
263export function editorWord(t: Strings, s: UnitySnapshot): string {
264 if (s.updatedAt === 0) return '…'
265 if (!s.isReachable) return s.isHung ? t.notResponding : t.closed
266 if (s.isCompiling) return t.compiling
267 if (s.playMode === 'playing') return t.inPlay
268 if (s.playMode === 'paused') return t.paused
269 return t.ready
270}
271
272/** The editor's state as a badge (short) and a header label (long). */
273export function editorBadge(t: Strings, s: UnitySnapshot): { text: string; long: string; tone: Tone } {
274 if (s.updatedAt === 0) return { text: '…', long: '…', tone: 'muted' }
275 if (s.busy === 'open' && !s.isReachable) return { text: t.dash.badgeStarting, long: t.dash.badgeStarting, tone: 'warn' }
276 if (!s.isReachable) {
277 return s.isHung
278 ? { text: t.dash.badgeHung, long: t.dash.notResponding, tone: 'warn' }
279 : { text: t.dash.badgeClosed, long: t.dash.disconnected, tone: 'bad' }
280 }
281 if (s.isCompiling) return { text: t.dash.badgeCompiling, long: t.dash.connected, tone: 'warn' }
282 if (s.playMode === 'playing') return { text: t.dash.badgePlay, long: t.dash.connected, tone: 'info' }
283 if (s.playMode === 'paused') return { text: t.dash.badgePaused, long: t.dash.connected, tone: 'info' }
284 return { text: t.dash.badgeReady, long: t.dash.connected, tone: 'ok' }
285}
286
287/** The longest run seen (most tests), the ETA's base for the next one. */
288export type SuiteRecord = { total: number; durationS: number }
289
290export function testsProgress(t: Strings, s: UnitySnapshot, now: number, suite: SuiteRecord | undefined) {
291 const el = s.tests.startedAt ? (now - s.tests.startedAt) / 1000 : 0
292 const ratio = suite && suite.durationS > 0 ? Math.min(0.99, el / suite.durationS) : undefined
293 const pct = ratio !== undefined ? `${Math.round(ratio * 100)} %` : minutes(el)
294 const left = suite ? t.remaining(minutes(Math.max(0, suite.durationS - el))) : ''
295 return { ratio, elapsedS: el, text: `${pct}${left}` }
296}
297
298export function statusLine(t: Strings, s: UnitySnapshot, now: number): string {
299 const parts = [`Unity ${editorWord(t, s)}`]
300 if (s.tests.state === 'running') {
301 const el = s.tests.startedAt ? (now - s.tests.startedAt) / 1000 : 0
302 parts.push(s.isGhost ? t.testsStuck : t.testsFor(minutes(el)))
303 } else if (s.tests.state === 'done' && s.tests.total !== undefined) {
304 parts.push(t.testsFor(`${s.tests.passed}/${s.tests.total}${(s.tests.failed ?? 0) > 0 ? ' ✗' : ' ✓'}`))
305 }
306 if (s.build.state === 'building') parts.push(t.buildRunning)
307 else if (s.apk) parts.push(t.lastBuildAt(clock(s.apk.mtimeMs)))
308 if (s.console && s.console.errors > 0) parts.push(t.errorsShort(s.console.errors))
309 return parts.join(' · ')
310}
311
312// ---------- the action block and its buttons ----------
313
314export type ActionKey =
315 | 'install-cli'
316 | 'install-editor'
317 | 'install-pipeline'
318 | 'open-unity'
319 | 'recompile'
320 | 'tests'
321 | 'build'
322 | 'unblock'
323 | 'cancel-tests'
324
325export const HOTKEYS: Partial<Record<ActionKey, string>> = {
326 'open-unity': 'o',
327 recompile: 'r',
328 tests: 't',
329 build: 'b',
330 unblock: 'u',
331 'cancel-tests': 's',
332}
333
334export function actionLabel(v: View, k: ActionKey): string {
335 const t = v.t
336 switch (k) {
337 case 'install-cli': return t.installCli
338 case 'install-editor': return t.installEditor(v.editorVersion ?? '')
339 case 'install-pipeline': return t.installPipeline
340 case 'open-unity': return t.openUnity
341 case 'recompile': return t.recompile
342 case 'tests': return t.allTests
343 case 'build': return `${t.build} ${v.buildTarget}`
344 case 'unblock': return t.unblock
345 case 'cancel-tests': return v.armed ? t.confirmCancel : t.cancelTests
346 }
347}
348
349export type Action = { tone: Tone; glyph: IconName; head: string; sub: string; keys: ActionKey[] }
350
351/** The block at the bottom: the one thing that matters now, and the buttons for it. */
352export function actionFor(v: View, s: UnitySnapshot, now: number, suite: SuiteRecord | undefined): Action {
353 const t = v.t
354 const setup = s.setup
355 // Before the first reading nothing is known: above all, no "closed" and no Open Unity.
356 if (s.updatedAt === 0 || !setup) return { tone: 'info', glyph: 'clock', head: t.dash.headLoading, sub: t.dash.subLoading, keys: [] }
357 if (!setup.hasCli) return { tone: 'bad', glyph: 'download', head: t.dash.headCli, sub: t.dash.subCli, keys: ['install-cli'] }
358 if (!setup.editorPath) {
359 return { tone: 'warn', glyph: 'download', head: t.dash.headEditor(v.editorVersion ?? '?'), sub: t.dash.subEditor, keys: ['install-editor'] }
360 }
361 if (!setup.hasPipeline) return { tone: 'warn', glyph: 'download', head: t.dash.headPipeline, sub: t.dash.subPipeline, keys: ['install-pipeline'] }
362 if (!s.isReachable && s.busy === 'open') return { tone: 'warn', glyph: 'clock', head: t.dash.headStarting, sub: t.dash.subStarting, keys: [] }
363 if (!s.isReachable && s.isHung) return { tone: 'warn', glyph: 'alert', head: t.dash.headHung, sub: t.dash.subHung, keys: [] }
364 if (!s.isReachable) return { tone: 'bad', glyph: 'play', head: t.dash.headClosed, sub: t.dash.subClosed, keys: ['open-unity'] }
365 if (s.isGhost) return { tone: 'bad', glyph: 'alert', head: t.dash.headStuck, sub: t.dash.subStuck, keys: ['unblock'] }
366 if (s.tests.state === 'running') {
367 const sub = [s.tests.group, testsProgress(t, s, now, suite).text].filter(Boolean).join(' · ')
368 return { tone: 'warn', glyph: 'clock', head: t.dash.headTests, sub, keys: ['cancel-tests'] }
369 }
370 if (s.build.state === 'building') {
371 return { tone: 'warn', glyph: 'clock', head: t.dash.headBuild, sub: s.build.startedAt ? minutes((now - s.build.startedAt) / 1000) : '', keys: [] }
372 }
373 if (s.compileFailed) return { tone: 'bad', glyph: 'cross', head: t.dash.headCompile, sub: s.console?.last[0] ?? t.dash.subCompile, keys: ['recompile'] }
374 if (s.tests.state === 'done' && (s.tests.failed ?? 0) > 0) {
375 return { tone: 'bad', glyph: 'cross', head: t.dash.headFailed(s.tests.failed ?? 0), sub: t.dash.subFailed, keys: ['tests'] }
376 }
377 const keys: ActionKey[] = v.buildTarget === 'off' ? ['tests'] : ['tests', 'build']
378 // Errors a run or a script logged: worth a look, but nothing is broken.
379 if (s.console && s.console.errors > 0) {
380 return { tone: 'warn', glyph: 'alert', head: t.dash.headConsole(s.console.errors), sub: s.console.last[0] ?? '', keys }
381 }
382 return { tone: 'ok', glyph: 'check', head: t.dash.headReady, sub: t.dash.subReady, keys }
383}
384
385export type ButtonSpec = { key: string; action: ActionKey; label: string; hotkey?: string; primary: boolean }
386
387/** The action block's buttons, then the others the state allows; never one that would disturb a run or a build. */
388export function buttonsFor(v: View, s: UnitySnapshot, action: Action): ButtonSpec[] {
389 const out: ButtonSpec[] = action.keys.map(k => ({ key: k, action: k, label: actionLabel(v, k), hotkey: HOTKEYS[k], primary: true }))
390 if (!s.isReachable) return out
391 for (const k of ['tests', 'build', 'recompile', 'unblock'] as const) {
392 if (action.keys.includes(k)) continue
393 if (k === 'build' && v.buildTarget === 'off') continue
394 if (k === 'unblock' ? !canUnblock(s) : working(s)) continue
395 out.push({ key: `more-${k}`, action: k, label: actionLabel(v, k), hotkey: HOTKEYS[k], primary: false })
396 }
397 return out
398}
399
400export const toneGlyph = (tone: Tone): IconName => (tone === 'ok' ? 'check' : tone === 'bad' ? 'cross' : tone === 'warn' ? 'alert' : 'clock')
401
402// ---------- the cards ----------
403
404/** The four status cards: Unity, tests, project, build. */
405export function cardsFor(v: View, s: UnitySnapshot, now: number, suite: SuiteRecord | undefined): CardInput[] {
406 const t = v.t
407 const setup = s.setup
408 const ed = editorBadge(t, s)
409 const cliVersion = (setup?.cliVersion ?? '').replace(/^[^\d]*/, '')
410 const unity: CardInput = {
411 icon: 'cube',
412 title: t.dash.unity,
413 badge: { text: ed.text, tone: ed.tone },
414 detail: setup?.editorPath || !setup ? `Unity ${v.editorVersion ?? '?'}` : t.dash.noEditor(v.editorVersion ?? '?'),
415 footer: !setup ? { text: '…', tone: 'muted' }
416 : !setup.hasCli ? { text: t.dash.noCli, tone: 'bad' }
417 : !setup.hasPipeline ? { text: t.dash.noPipeline, tone: 'warn' }
418 : { text: t.dash.cliFooter(cliVersion), tone: 'ok' },
419 }
420
421 const history = s.history.map(h => h.failed === 0)
422 let tests: CardInput
423 if (s.tests.state === 'running') {
424 const p = testsProgress(t, s, now, suite)
425 tests = {
426 icon: 'flask',
427 title: t.dash.tests,
428 badge: s.isGhost ? { text: t.dash.badgeStuck, tone: 'bad' } : { text: t.dash.badgeRunning, tone: 'warn' },
429 detail: p.text,
430 progress: p.ratio,
431 footer: { text: s.tests.group ?? '', tone: 'info' },
432 }
433 } else if (s.tests.state === 'done' && s.tests.total !== undefined) {
434 const failed = s.tests.failed ?? 0
435 tests = {
436 icon: 'flask',
437 title: t.dash.tests,
438 badge: failed === 0 ? { text: `${s.tests.passed}/${s.tests.total}`, tone: 'ok' } : { text: t.dash.badgeFailed(failed), tone: 'bad' },
439 detail: [s.tests.durationS ? minutes(s.tests.durationS) : '', s.tests.endedAt ? ago(t, s.tests.endedAt, now) : ''].filter(Boolean).join(' · '),
440 footer: { text: '', tone: 'muted' },
441 history,
442 }
443 } else {
444 tests = { icon: 'flask', title: t.dash.tests, badge: { text: t.dash.badgeNone, tone: 'muted' }, detail: t.dash.noRun, footer: { text: '', tone: 'muted' }, history }
445 }
446
447 const g = s.git
448 const consoleFooter: CardInput['footer'] = !s.console ? { text: t.dash.consoleUnknown, tone: 'muted' }
449 : s.console.errors > 0 ? { text: t.dash.consoleErrors(s.console.errors), tone: 'bad' }
450 : { text: t.dash.consoleOk, tone: 'ok' }
451 const project: CardInput = g
452 ? {
453 icon: 'folder',
454 title: t.dash.project,
455 badge: g.dirty === 0 ? { text: t.dash.badgeClean, tone: 'ok' } : { text: t.dash.badgeChanges(g.dirty), tone: 'warn' },
456 detail: `${g.branch ?? '?'} · ${g.lastCommit?.split(' ')[0] ?? ''}${g.ahead > 0 ? ` ↑${g.ahead}` : ''}${g.behind > 0 ? ` ↓${g.behind}` : ''}`,
457 footer: consoleFooter,
458 }
459 : { icon: 'folder', title: t.dash.project, badge: { text: t.dash.badgeNone, tone: 'muted' }, detail: t.dash.noRepo, footer: consoleFooter }
460
461 let build: CardInput
462 if (s.build.state === 'building') {
463 build = {
464 icon: 'phone',
465 title: t.dash.build,
466 badge: { text: t.dash.badgeRunning, tone: 'warn' },
467 detail: s.build.startedAt ? minutes((now - s.build.startedAt) / 1000) : '…',
468 footer: { text: v.buildTarget.toUpperCase(), tone: 'warn' },
469 }
470 } else if (s.apk) {
471 const lastFailed = s.build.state === 'done' && s.build.result !== 'Succeeded'
472 build = {
473 icon: 'phone',
474 title: t.dash.build,
475 badge: lastFailed ? { text: t.dash.badgeBuildFailed, tone: 'bad' } : { text: t.dash.badgeAvailable, tone: 'ok' },
476 detail: `${s.apk.name} · ${Math.round(s.apk.size / 1048576)} ${t.megabytes}`,
477 footer: { text: ago(t, s.apk.mtimeMs, now).toUpperCase(), tone: lastFailed ? 'bad' : 'ok' },
478 }
479 } else {
480 build = {
481 icon: 'phone',
482 title: t.dash.build,
483 badge: { text: t.dash.badgeNone, tone: 'muted' },
484 detail: t.dash.noBuildFile,
485 footer: { text: v.buildTarget.toUpperCase(), tone: 'muted' },
486 }
487 }
488 return [unity, tests, project, build]
489}
490
491// ---------- a card's details ----------
492
493export const DETAIL_VIEWS = ['unity', 'tests', 'project', 'build'] as const
494
495export function detailsTitle(t: Strings, v: DetailsView): string {
496 return v === 'unity' ? t.dash.unity : v === 'tests' ? t.dash.tests : v === 'project' ? t.dash.project : v === 'build' ? t.dash.build : ''
497}
498
499export type DetailRow = [label: string, value: string, tone?: Tone]
500
501/** One card's details as label / value / tone rows. */
502export function detailRows(v: View, s: UnitySnapshot, which: DetailsView, now: number, suite: SuiteRecord | undefined): DetailRow[] {
503 const t = v.t
504 const d = t.dash
505 const rows: DetailRow[] = []
506 if (which === 'unity') {
507 const setup = s.setup
508 rows.push([d.dState, editorWord(t, s), editorBadge(t, s).tone])
509 rows.push([d.dVersion, v.editorVersion ?? '?'])
510 rows.push([d.dEditor, setup?.editorPath ?? d.dMissing, setup?.editorPath ? undefined : 'bad'])
511 rows.push([d.dCli, setup?.hasCli ? `${setup.cliVersion ?? ''} · ${v.cliPath ?? ''}` : d.dMissing, setup?.hasCli ? undefined : 'bad'])
512 rows.push([d.dPipeline, setup?.hasPipeline ? d.dInstalled : d.dMissing, setup?.hasPipeline ? 'ok' : 'warn'])
513 rows.push([d.dCompile, s.compileFailed ? d.dFailed : s.isCompiling ? t.compiling : d.dOk, s.compileFailed ? 'bad' : 'ok'])
514 if (v.logPath) rows.push([d.dLog, v.logPath])
515 } else if (which === 'tests') {
516 if (s.tests.state === 'running') {
517 rows.push([d.dState, `${t.testsRunning} · ${testsProgress(t, s, now, suite).text}`, 'warn'])
518 if (s.tests.group) rows.push([d.dGroup, s.tests.group])
519 }
520 if (suite) rows.push([d.dSuite, `${suite.total} tests · ${minutes(suite.durationS)}`])
521 rows.push([d.dMode, v.testMode === 'editmode' ? 'EditMode' : 'PlayMode'])
522 for (const h of [...s.history].reverse().slice(0, 6)) {
523 rows.push([`${new Date(h.at).toLocaleDateString(t.dateLocale)} ${clock(h.at)}`, `${h.passed}/${h.total} · ${minutes(h.durationS)}`, h.failed === 0 ? 'ok' : 'bad'])
524 }
525 if (s.history.length === 0 && s.tests.state !== 'running') rows.push([d.dRuns, d.noRun])
526 for (const n of (s.tests.state === 'done' ? (s.tests.failedNames ?? []) : []).slice(0, 5)) rows.push(['✗', shortTest(n), 'bad'])
527 } else if (which === 'project') {
528 const g = s.git
529 if (g) {
530 rows.push([d.dBranch, `${g.branch ?? '?'}${g.ahead > 0 ? ` ↑${g.ahead}` : ''}${g.behind > 0 ? ` ↓${g.behind}` : ''}`])
531 rows.push([d.dChanges, d.filesChanged(g.dirty), g.dirty === 0 ? 'ok' : 'warn'])
532 if (g.lastCommit) rows.push([d.dCommit, `${g.lastCommit}${g.commitAt ? ` · ${ago(t, g.commitAt, now)}` : ''}`])
533 } else rows.push([d.dBranch, d.noRepo])
534 rows.push([d.dPath, v.root ?? ''])
535 if (s.console) {
536 rows.push([d.dConsole, `${t.consoleErrors(s.console.errors)}${t.consoleWarnings(s.console.warnings)}`, s.console.errors > 0 ? 'bad' : 'ok'])
537 for (const m of s.console.last) rows.push(['✗', m, 'bad'])
538 }
539 if (s.initScenes > 0) rows.push([d.dLeftovers, t.leftoverScenes(s.initScenes), 'warn'])
540 } else if (which === 'build') {
541 rows.push([d.dTarget, v.buildTarget])
542 if (s.build.state === 'building') {
543 rows.push([d.dState, `${t.building} · ${s.build.startedAt ? minutes((now - s.build.startedAt) / 1000) : ''}`, 'warn'])
544 } else if (s.build.state === 'done') {
545 const parts = `${s.build.result ?? '?'}${s.build.durationS ? ` · ${minutes(s.build.durationS)}` : ''}${s.build.errors ? ` · ${s.build.errors} err.` : ''}`
546 rows.push([d.dLastBuild, parts, s.build.result === 'Succeeded' ? 'ok' : 'bad'])
547 }
548 if (s.apk) rows.push([d.dFile, `Builds/${s.apk.name} · ${Math.round(s.apk.size / 1048576)} ${t.megabytes} · ${ago(t, s.apk.mtimeMs, now)}`])
549 else rows.push([d.dFile, d.noBuildFile])
550 }
551 return rows
552}
553hooks/strings.ts 486 lines1// Every word the mod shows, in English and French. The pane, the status line,
2// the toasts and the notes all read from the table `pick` returns.
3
4export type Lang = 'en' | 'fr'
5
6/** The actions that hold the pane busy; their label is shown while they run. */
7export type BusyKind =
8 | 'open'
9 | 'install-editor'
10 | 'install-cli'
11 | 'install-pipeline'
12 | 'recompile'
13 | 'tests'
14 | 'unblock'
15 | 'build'
16 | 'cancel'
17
18const en = {
19 // the command and the prompt shortcut
20 commandDescription: "Unity pane for this project: editor, tests, build, console, git",
21 notUnityProject: 'This folder is not a Unity project (no ProjectSettings/ProjectVersion.txt).',
22 notUnityProjectShort: 'This folder is not a Unity project.',
23 paneOpened: 'Unity pane opened.',
24 paneError: (err: string) => `Unity pane: ${err}`,
25
26 // time
27 justNow: 'just now',
28 minutesAgo: (m: number) => `${m} min ago`,
29 hoursAgo: (h: number) => `${h} h ago`,
30 onDate: (date: string, time: string) => `on ${date} at ${time}`,
31 dateLocale: 'en-GB',
32
33 // the editor in one word
34 notResponding: 'not responding',
35 closed: 'closed',
36 compiling: 'compiling',
37 inPlay: 'in Play',
38 paused: 'paused',
39 ready: 'ready',
40
41 // toasts
42 crashedDuringTests: 'Unity stopped answering in the middle of a test run: crashed?',
43 hung: 'Unity stopped answering (busy or stuck).',
44 closedOrCrashed: 'Unity was closed or crashed.',
45 answers: 'Unity is answering.',
46 compileFailedNote: 'Compilation failed: see the Unity console.',
47 compileFailedToast: 'Unity: compilation failed.',
48 testsStuckToast: 'Unity tests are stuck: press Unblock in the pane.',
49 testsCancelled: 'Unity tests cancelled.',
50 testsPassed: (p: number, t: number) => `✓ Unity tests: ${p}/${t} passed.`,
51 testsFailed: (f: number, t: number) => `✗ Unity tests: ${f} failed out of ${t}.`,
52 buildDone: '✓ Build finished.',
53 buildFailed: (r: string) => `✗ Build: ${r}.`,
54 failed: 'failed',
55
56 // the status line
57 testsStuck: 'tests stuck',
58 testsFor: (d: string) => `tests ${d}`,
59 buildRunning: 'build running',
60 lastBuildAt: (t: string) => `build ${t}`,
61 errorsShort: (n: number) => `${n} error${n === 1 ? '' : 's'}`,
62
63 // busy labels
64 busy: {
65 open: 'starting Unity',
66 'install-editor': 'installing Unity',
67 'install-cli': 'installing the CLI',
68 'install-pipeline': 'installing the Pipeline package',
69 recompile: 'recompiling',
70 tests: 'starting the tests',
71 unblock: 'unblocking',
72 build: 'building',
73 cancel: 'stopping the tests',
74 } satisfies Record<BusyKind, string>,
75 alreadyBusy: (what: string) => `Unity is busy (${what}).`,
76
77 // notes left by the buttons
78 cliMissing: 'Unity CLI missing',
79 editorNotInstalled: (v: string) => `Unity ${v} is not installed: press "Install Unity".`,
80 unityStarting: 'Unity is starting…',
81 alreadyOpen: 'Unity is already open on this project.',
82 unityLaunchFailed: 'Unity could not be launched.',
83 editorInstallStarted: (v: string) => `Installing Unity ${v} in a separate window.`,
84 editorInstallRunning: (v: string) => `Installing Unity ${v} in the background (this takes a while).`,
85 editorInstallFailed: 'The install could not start.',
86 editorInstallDone: (v: string) => `Unity ${v} installed.`,
87 cliInstalled: (v: string) => `Unity CLI installed (${v}).`,
88 cliInstallNotFound: (code: number) => `Install finished (code ${code}) but the CLI is still not found.`,
89 cliInstallFailed: (err: string) => `CLI install failed: ${err}`,
90 pipelineAdded: 'Pipeline package added: Unity loads it the next time it opens or when you switch back to its window.',
91 pipelineRefused: (msg: string) => `Package install refused: ${msg}`,
92 pipelineFailed: (err: string) => `Package install failed: ${err}`,
93 recompileRequested: 'Recompile requested.',
94 recompileRefused: (e: string) => `Recompile refused: ${e}`,
95 suiteAlreadyRunning: 'A test run is already going.',
96 waitForBuild: 'A build is running: wait for it to finish.',
97 waitForWork: 'Tests or a build are running: wait for them to finish.',
98 stopPlayFirst: 'The editor is in Play mode: stop it first.',
99 suiteStarted: (mode: string) => `Full ${mode} suite started.`,
100 runRefused: (e: string) => `Run refused: ${e}`,
101 unblocked: 'Runner unblocked, first build scene reopened, leftover test scenes deleted.',
102 unblockPartial: (e: string) => `Cleanup incomplete: ${e}`,
103 testsRunningNoBuild: 'Tests are running: wait for them before building.',
104 buildRefused: (e: string) => `Build refused: ${e}`,
105 buildStarted: (target: string) => `${target} build started.`,
106
107 // the pane
108 installCli: 'Install the Unity CLI',
109 installEditor: (v: string) => `Install Unity ${v}`,
110 installPipeline: 'Install the Pipeline package',
111 openUnity: 'Open Unity',
112
113 testsRunning: '◐ running',
114 remaining: (d: string) => ` · ~${d} left`,
115 group: 'group: ',
116 passed: ' passed',
117 history: 'history ',
118
119 building: '◐ building',
120 megabytes: 'MB',
121
122 console: 'console ',
123 consoleErrors: (n: number) => `${n} error${n === 1 ? '' : 's'}`,
124 consoleWarnings: (n: number) => ` · ${n} warning${n === 1 ? '' : 's'}`,
125 leftoverScenes: (n: number) => `⚠ ${n} leftover test scene${n === 1 ? '' : 's'}`,
126
127 updatedAt: (t: string) => `updated ${t}`,
128
129 recompile: 'Recompile',
130 allTests: 'Run all tests',
131 build: 'Build',
132 unblock: 'Unblock',
133 cancelTests: 'Stop the tests',
134 confirmCancel: 'Press again to stop',
135 confirmCancelNote: 'Press Stop the tests again within 5 s to stop the run.',
136 testsStopping: 'Tests stopped: the runner is leaving Play mode.',
137
138 // the desktop dashboard
139 dash: {
140 connected: 'Connected',
141 disconnected: 'Disconnected',
142 notResponding: 'Not responding',
143 currentProject: 'CURRENT PROJECT',
144 filesChanged: (n: number) => (n === 0 ? 'no changes' : `${n} file${n === 1 ? '' : 's'} changed`),
145 systemState: 'SYSTEM STATE',
146 recentActivity: 'RECENT ACTIVITY',
147 noActivity: 'Nothing yet: events show up here as they happen.',
148 failedTests: 'FAILED TESTS',
149 unity: 'UNITY',
150 tests: 'TESTS',
151 project: 'PROJECT',
152 build: 'BUILD',
153 badgeReady: 'READY',
154 badgeCompiling: 'COMPILING',
155 badgePlay: 'IN PLAY',
156 badgePaused: 'PAUSED',
157 badgeClosed: 'CLOSED',
158 badgeStarting: 'STARTING',
159 badgeHung: 'NOT RESPONDING',
160 badgeRunning: 'RUNNING',
161 badgeStuck: 'STUCK',
162 badgeFailed: (n: number) => `${n} FAILED`,
163 badgeNone: 'NONE',
164 badgeClean: 'CLEAN',
165 badgeChanges: (n: number) => `${n} CHANGE${n === 1 ? '' : 'S'}`,
166 badgeAvailable: 'AVAILABLE',
167 badgeBuildFailed: 'FAILED',
168 noEditor: (v: string) => `Unity ${v} missing`,
169 cliFooter: (v: string) => `CLI ${v}`.toUpperCase(),
170 noCli: 'NO CLI',
171 noPipeline: 'NO PIPELINE PACKAGE',
172 noRun: 'no run yet',
173 noRepo: 'no git repository',
174 consoleOk: '0 ERRORS',
175 consoleErrors: (n: number) => `${n} ERROR${n === 1 ? '' : 'S'}`,
176 consoleUnknown: 'CONSOLE —',
177 noBuildFile: 'nothing in Builds/',
178 // the action block
179 headCli: 'THE UNITY CLI IS MISSING',
180 subCli: 'Install it once: Unity Watch drives the editor through it.',
181 headEditor: (v: string) => `UNITY ${v} IS NOT INSTALLED`,
182 subEditor: 'This project needs this exact editor version.',
183 headPipeline: 'THE PIPELINE PACKAGE IS MISSING',
184 subPipeline: 'The CLI talks to the editor through this package.',
185 headClosed: 'UNITY IS CLOSED',
186 subClosed: 'Open it on this project in one click.',
187 headStarting: 'UNITY IS STARTING',
188 subStarting: 'This takes a minute or two on a big project.',
189 headHung: 'UNITY IS NOT RESPONDING',
190 subHung: 'Busy importing, a dialog is open, or it is stuck.',
191 headStuck: 'THE TEST RUNNER IS STUCK',
192 subStuck: 'It says "running" but nothing moves.',
193 headTests: 'TESTS RUNNING',
194 headBuild: 'BUILD RUNNING',
195 headCompile: 'COMPILE ERRORS',
196 subCompile: 'Fix them before running tests or building.',
197 headFailed: (n: number) => `${n} TEST${n === 1 ? '' : 'S'} FAILED`,
198 subFailed: 'See the list above.',
199 headReady: 'ALL SYSTEMS READY',
200 headLoading: 'READING THE PROJECT…',
201 subLoading: 'The editor, the CLI and git, a few seconds.',
202 headConsole: (n: number) => `${n} ERROR${n === 1 ? '' : 'S'} IN THE CONSOLE`,
203 dState: 'State',
204 dVersion: 'Version',
205 dEditor: 'Editor',
206 dCli: 'CLI',
207 dPipeline: 'Pipeline',
208 dCompile: 'Compile',
209 dLog: 'Log',
210 dMissing: 'missing',
211 dInstalled: 'installed',
212 dFailed: 'failed',
213 dOk: 'ok',
214 dGroup: 'Group',
215 dSuite: 'Full suite',
216 dMode: 'Mode',
217 dRuns: 'Runs',
218 dBranch: 'Branch',
219 dChanges: 'Changes',
220 dCommit: 'Last commit',
221 dPath: 'Folder',
222 dConsole: 'Console',
223 dLeftovers: 'Leftovers',
224 dTarget: 'Target',
225 dLastBuild: 'Last build',
226 dFile: 'File',
227 subReady: 'You can run the tests or make a build.',
228 // activity lines
229 projectLoaded: 'Project loaded',
230 projectLoadedDetail: (name: string, branch: string) => `${name} on ${branch}`,
231 unityFound: 'Unity found',
232 unityFoundDetail: (v: string) => `Unity ${v} is installed on this machine.`,
233 cliReady: 'Unity CLI ready',
234 cliReadyDetail: (v: string, pipeline: boolean) => `CLI ${v}${pipeline ? ', Pipeline package installed.' : ', Pipeline package missing.'}`,
235 buildFound: 'Build found',
236 newBuild: 'New build',
237 buildDetail: (name: string, mb: number) => `${name} (${mb} MB)`,
238 unityAnswers: 'Unity is answering',
239 unityOpened: 'Unity opened',
240 unityBack: 'Unity answers again',
241 unityClosed: 'Unity closed',
242 unityHung: 'Unity stopped answering',
243 testsStarted: 'Tests started',
244 testsPassed: 'Tests passed',
245 testsFailed: 'Tests failed',
246 testsCancelled: 'Tests cancelled',
247 testsDetail: (p: number, total: number, d: string) => `${p}/${total} passed in ${d}.`,
248 buildStarted: 'Build started',
249 buildSucceeded: 'Build succeeded',
250 buildFailed: 'Build failed',
251 compileFailed: 'Compilation failed',
252 },
253}
254
255export type Strings = typeof en
256
257const fr: Strings = {
258 commandDescription: 'Panneau Unity du projet : éditeur, tests, build, console, git',
259 notUnityProject: "Ce dossier n'est pas un projet Unity (pas de ProjectSettings/ProjectVersion.txt).",
260 notUnityProjectShort: "Ce dossier n'est pas un projet Unity.",
261 paneOpened: 'Panneau Unity ouvert.',
262 paneError: err => `Panneau Unity : ${err}`,
263
264 justNow: "à l'instant",
265 minutesAgo: m => `il y a ${m} min`,
266 hoursAgo: h => `il y a ${h} h`,
267 onDate: (date, time) => `le ${date} à ${time}`,
268 dateLocale: 'fr-FR',
269
270 notResponding: 'ne répond pas',
271 closed: 'fermé',
272 compiling: 'compile',
273 inPlay: 'en Play',
274 paused: 'en pause',
275 ready: 'prêt',
276
277 crashedDuringTests: 'Unity ne répond plus en pleine suite de tests : planté ?',
278 hung: 'Unity ne répond plus (occupé ou bloqué).',
279 closedOrCrashed: 'Unity est fermé ou a planté.',
280 answers: 'Unity répond.',
281 compileFailedNote: 'Compilation en échec : voir la console Unity.',
282 compileFailedToast: 'Unity : la compilation a échoué.',
283 testsStuckToast: 'Tests Unity bloqués : bouton Débloquer dans le panneau.',
284 testsCancelled: 'Tests Unity annulés.',
285 testsPassed: (p, t) => `✓ Tests Unity : ${p}/${t} réussis.`,
286 testsFailed: (f, t) => `✗ Tests Unity : ${f} échec(s) sur ${t}.`,
287 buildDone: '✓ Build terminé.',
288 buildFailed: r => `✗ Build : ${r}.`,
289 failed: 'échec',
290
291 testsStuck: 'tests bloqués',
292 testsFor: d => `tests ${d}`,
293 buildRunning: 'build en cours',
294 lastBuildAt: t => `build ${t}`,
295 errorsShort: n => `${n} erreur(s)`,
296
297 busy: {
298 open: 'ouverture de Unity',
299 'install-editor': 'installation de Unity',
300 'install-cli': 'installation du CLI',
301 'install-pipeline': 'installation du paquet Pipeline',
302 recompile: 'recompilation',
303 tests: 'lancement des tests',
304 unblock: 'déblocage',
305 build: 'build',
306 cancel: 'arrêt des tests',
307 },
308 alreadyBusy: what => `Unity est occupé (${what}).`,
309
310 cliMissing: 'CLI Unity absent',
311 editorNotInstalled: v => `Unity ${v} n'est pas installé : bouton « Installer Unity ».`,
312 unityStarting: 'Unity démarre…',
313 alreadyOpen: 'Unity est déjà ouvert sur ce projet.',
314 unityLaunchFailed: "Unity n'a pas pu être lancé.",
315 editorInstallStarted: v => `Installation de Unity ${v} lancée dans une fenêtre à part.`,
316 editorInstallRunning: v => `Installation de Unity ${v} en arrière-plan (c'est long).`,
317 editorInstallFailed: "L'installation n'a pas pu démarrer.",
318 editorInstallDone: v => `Unity ${v} installé.`,
319 cliInstalled: v => `CLI Unity installé (${v}).`,
320 cliInstallNotFound: code => `Installation terminée (code ${code}) mais le CLI reste introuvable.`,
321 cliInstallFailed: err => `Installation du CLI échouée : ${err}`,
322 pipelineAdded: 'Paquet Pipeline ajouté : Unity le charge à la prochaine ouverture ou en revenant sur sa fenêtre.',
323 pipelineRefused: msg => `Installation du paquet refusée : ${msg}`,
324 pipelineFailed: err => `Installation du paquet échouée : ${err}`,
325 recompileRequested: 'Recompilation demandée.',
326 recompileRefused: e => `Recompilation refusée : ${e}`,
327 suiteAlreadyRunning: 'Une suite tourne déjà.',
328 waitForBuild: 'Un build est en cours : attends sa fin.',
329 waitForWork: 'Des tests ou un build tournent : attends leur fin.',
330 stopPlayFirst: "L'éditeur est en Play : arrête-le d'abord.",
331 suiteStarted: mode => `Suite ${mode} complète lancée.`,
332 runRefused: e => `Lancement refusé : ${e}`,
333 unblocked: 'Runner débloqué, première scène du build rouverte, scènes de test supprimées.',
334 unblockPartial: e => `Nettoyage incomplet : ${e}`,
335 testsRunningNoBuild: 'Des tests tournent : attends leur fin avant le build.',
336 buildRefused: e => `Build refusé : ${e}`,
337 buildStarted: target => `Build ${target} lancé.`,
338
339 installCli: 'Installer le CLI Unity',
340 installEditor: v => `Installer Unity ${v}`,
341 installPipeline: 'Installer le paquet Pipeline',
342 openUnity: 'Ouvrir Unity',
343
344 testsRunning: '◐ en cours',
345 remaining: d => ` · reste ~${d}`,
346 group: 'groupe : ',
347 passed: ' réussis',
348 history: 'historique ',
349
350 building: '◐ build en cours',
351 megabytes: 'Mo',
352
353 console: 'console ',
354 consoleErrors: n => `${n} erreur(s)`,
355 consoleWarnings: n => ` · ${n} avert.`,
356 leftoverScenes: n => `⚠ ${n} scène(s) de test restée(s)`,
357
358 updatedAt: t => `mis à jour ${t}`,
359
360 recompile: 'Recompiler',
361 allTests: 'Tous les tests',
362 build: 'Build',
363 unblock: 'Débloquer',
364 cancelTests: 'Arrêter les tests',
365 confirmCancel: "Confirmer l'arrêt",
366 confirmCancelNote: 'Appuie encore sur Arrêter les tests dans les 5 s pour arrêter le run.',
367 testsStopping: 'Tests arrêtés : le runner sort du mode Play.',
368
369 dash: {
370 connected: 'Connecté',
371 disconnected: 'Déconnecté',
372 notResponding: 'Ne répond pas',
373 currentProject: 'PROJET ACTUEL',
374 filesChanged: n => (n === 0 ? 'aucune modification' : `${n} fichier${n === 1 ? '' : 's'} modifié${n === 1 ? '' : 's'}`),
375 systemState: 'ÉTAT DU SYSTÈME',
376 recentActivity: 'ACTIVITÉ RÉCENTE',
377 noActivity: "Rien encore : les événements apparaissent ici au fil de l'eau.",
378 failedTests: 'TESTS EN ÉCHEC',
379 unity: 'UNITY',
380 tests: 'TESTS',
381 project: 'PROJET',
382 build: 'BUILD',
383 badgeReady: 'PRÊT',
384 badgeCompiling: 'COMPILE',
385 badgePlay: 'EN PLAY',
386 badgePaused: 'EN PAUSE',
387 badgeClosed: 'FERMÉ',
388 badgeStarting: 'DÉMARRE',
389 badgeHung: 'NE RÉPOND PAS',
390 badgeRunning: 'EN COURS',
391 badgeStuck: 'BLOQUÉS',
392 badgeFailed: n => `${n} ÉCHEC${n === 1 ? '' : 'S'}`,
393 badgeNone: 'AUCUN',
394 badgeClean: 'PROPRE',
395 badgeChanges: n => `${n} MODIF${n === 1 ? '' : 'S'}`,
396 badgeAvailable: 'DISPONIBLE',
397 badgeBuildFailed: 'ÉCHEC',
398 noEditor: v => `Unity ${v} absent`,
399 cliFooter: v => `CLI ${v}`.toUpperCase(),
400 noCli: 'CLI ABSENT',
401 noPipeline: 'PAQUET PIPELINE ABSENT',
402 noRun: 'aucun run',
403 noRepo: 'pas de dépôt git',
404 consoleOk: '0 ERREUR',
405 consoleErrors: n => `${n} ERREUR${n === 1 ? '' : 'S'}`,
406 consoleUnknown: 'CONSOLE —',
407 noBuildFile: 'rien dans Builds/',
408 headCli: 'LE CLI UNITY EST ABSENT',
409 subCli: "À installer une fois : Unity Watch pilote l'éditeur avec.",
410 headEditor: v => `UNITY ${v} N'EST PAS INSTALLÉ`,
411 subEditor: 'Ce projet demande exactement cette version.',
412 headPipeline: 'LE PAQUET PIPELINE EST ABSENT',
413 subPipeline: "Le CLI parle à l'éditeur par ce paquet.",
414 headClosed: 'UNITY EST FERMÉ',
415 subClosed: 'Ouvre-le sur ce projet en un clic.',
416 headStarting: 'UNITY DÉMARRE',
417 subStarting: 'Une minute ou deux sur un gros projet.',
418 headHung: 'UNITY NE RÉPOND PAS',
419 subHung: 'Import en cours, fenêtre de dialogue ouverte, ou bloqué.',
420 headStuck: 'LE RUNNER DE TESTS EST BLOQUÉ',
421 subStuck: 'Il dit « en cours » mais rien ne bouge.',
422 headTests: 'TESTS EN COURS',
423 headBuild: 'BUILD EN COURS',
424 headCompile: 'ERREURS DE COMPILATION',
425 subCompile: 'À corriger avant les tests ou un build.',
426 headFailed: n => `${n} TEST${n === 1 ? '' : 'S'} EN ÉCHEC`,
427 subFailed: 'Voir la liste au-dessus.',
428 headReady: 'TOUS LES SYSTÈMES SONT PRÊTS',
429 headLoading: 'LECTURE DU PROJET…',
430 subLoading: "L'éditeur, le CLI et git, quelques secondes.",
431 headConsole: n => `${n} ERREUR${n === 1 ? '' : 'S'} DANS LA CONSOLE`,
432 dState: 'État',
433 dVersion: 'Version',
434 dEditor: 'Éditeur',
435 dCli: 'CLI',
436 dPipeline: 'Pipeline',
437 dCompile: 'Compilation',
438 dLog: 'Log',
439 dMissing: 'absent',
440 dInstalled: 'installé',
441 dFailed: 'en échec',
442 dOk: 'ok',
443 dGroup: 'Groupe',
444 dSuite: 'Suite complète',
445 dMode: 'Mode',
446 dRuns: 'Runs',
447 dBranch: 'Branche',
448 dChanges: 'Modifs',
449 dCommit: 'Dernier commit',
450 dPath: 'Dossier',
451 dConsole: 'Console',
452 dLeftovers: 'Restes',
453 dTarget: 'Cible',
454 dLastBuild: 'Dernier build',
455 dFile: 'Fichier',
456 subReady: 'Tu peux lancer les tests ou générer un build.',
457 projectLoaded: 'Projet chargé',
458 projectLoadedDetail: (name, branch) => `${name} sur ${branch}`,
459 unityFound: 'Unity détecté',
460 unityFoundDetail: v => `Unity ${v} est installé sur cette machine.`,
461 cliReady: 'CLI Unity prêt',
462 cliReadyDetail: (v, pipeline) => `CLI ${v}${pipeline ? ', paquet Pipeline installé.' : ', paquet Pipeline absent.'}`,
463 buildFound: 'Build trouvé',
464 newBuild: 'Nouveau build',
465 buildDetail: (name, mb) => `${name} (${mb} Mo)`,
466 unityAnswers: 'Unity répond',
467 unityOpened: 'Unity ouvert',
468 unityBack: 'Unity répond à nouveau',
469 unityClosed: 'Unity fermé',
470 unityHung: 'Unity ne répond plus',
471 testsStarted: 'Tests lancés',
472 testsPassed: 'Tests réussis',
473 testsFailed: 'Tests en échec',
474 testsCancelled: 'Tests annulés',
475 testsDetail: (p, total, d) => `${p}/${total} réussis en ${d}.`,
476 buildStarted: 'Build lancé',
477 buildSucceeded: 'Build réussi',
478 buildFailed: 'Build en échec',
479 compileFailed: 'Compilation en échec',
480 },
481}
482
483export function pick(lang: Lang): Strings {
484 return lang === 'fr' ? fr : en
485}
486types/index.d.ts 119 lines1/** What the Unity test runner is doing, as the pane shows it. */
2export type TestRun = {
3 state: 'idle' | 'running' | 'done'
4 /** When this mod first saw the run going (ms since epoch). */
5 startedAt?: number
6 /** The test class the editor log names last while it runs. */
7 group?: string
8 total?: number
9 passed?: number
10 failed?: number
11 durationS?: number
12 /** When the last run ended (ms since epoch). */
13 endedAt?: number
14 /** Full names of the failed tests of the last finished run. */
15 failedNames?: string[]
16}
17
18/** One finished test run, for the history strip. */
19export type RunRecord = {
20 at: number
21 total: number
22 passed: number
23 failed: number
24 durationS: number
25}
26
27/** The last Android build the editor reports. */
28export type BuildRun = {
29 state: 'idle' | 'building' | 'done'
30 result?: string
31 errors?: number
32 startedAt?: number
33 durationS?: number
34}
35
36/** The git tree of the project. */
37export type GitState = {
38 branch?: string
39 ahead: number
40 behind: number
41 dirty: number
42 lastCommit?: string
43 /** When the last commit was made (ms since epoch). */
44 commitAt?: number
45}
46
47/** What the Unity console holds. */
48export type ConsoleState = {
49 errors: number
50 warnings: number
51 /** First line of the latest errors, newest first. */
52 last: string[]
53}
54
55/** What this machine and project have of the Unity toolchain. */
56export type SetupState = {
57 /** The Unity CLI answers (`unity --version`). */
58 hasCli: boolean
59 cliVersion?: string
60 /** The project's manifest lists com.unity.pipeline, which the CLI drives the editor through. */
61 hasPipeline: boolean
62 /** The editor version ProjectSettings/ProjectVersion.txt asks for. */
63 editorVersion?: string
64 /** That version's Unity.exe, when installed. */
65 editorPath?: string
66}
67
68/** One line of the pane's recent activity. */
69export type ActivityItem = {
70 at: number
71 tone: 'ok' | 'warn' | 'bad' | 'info'
72 title: string
73 detail?: string
74}
75
76/** One poll of everything the pane shows. */
77export type UnitySnapshot = {
78 /** The Unity project the session sits in; absent outside one. */
79 project?: { root: string; name: string }
80 setup?: SetupState
81 /** False when no editor answers the pipeline (closed, crashed or hung). */
82 isReachable: boolean
83 /** An editor process is there but its pipeline does not answer (busy importing, a modal, hung). */
84 isHung: boolean
85 status?: string
86 playMode?: string
87 isCompiling: boolean
88 /** A run that says "running" while the editor sits in edit mode with a silent log. */
89 isGhost: boolean
90 tests: TestRun
91 history: RunRecord[]
92 build: BuildRun
93 apk?: { mtimeMs: number; size: number; name: string }
94 git?: GitState
95 console?: ConsoleState
96 /** Leftover InitTestScene files in Assets. */
97 initScenes: number
98 /** An action a button started and is still waiting on. */
99 busy?: string
100 /** When that action started (ms since epoch); a poll lets a stale one go. */
101 busySince?: number
102 /** The last compile the editor reports failed. */
103 compileFailed?: boolean
104 /** The last thing an action said. */
105 note?: string
106 /** What happened lately, newest first. */
107 activity?: ActivityItem[]
108 updatedAt: number
109}
110
111/** Which card's details the dashboard shows. */
112export type DetailsView = 'none' | 'unity' | 'tests' | 'project' | 'build'
113
114declare module 'claude-code' {
115 interface PluginState {
116 'unity-watch': { snapshot: UnitySnapshot; details: DetailsView }
117 }
118}
119