SLOPSHOPPER

unity-watch

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…

newpanecommandtoaststatusprompt
v0.3.0MITupdated 2026-10-07TheoGegout/claude-unity-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · unity-watch
│ ┃ unity ✕ › fix the failing auth test and add an audit log call │ ┃ This folder is not a Unity project. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /unity │ ⎿ unity-watch: This folder is not a Unity project (no ProjectSetti │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · unity
This folder is not a Unity project.
README

Unity Watch

License: MIT Claude Code 2.1.287+

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>

Install

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.

What you see

PartShows
Headerthe editor's state: ready, compiling, in Play, starting, not responding, closed
Projectname, branch, changed files, last commit and its age
Unity cardthe editor version, the Unity CLI version, whether the Pipeline package is there
Tests cardthe 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 cardbranch and commit, console errors
Build cardthe 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 testseach failed test by name, after a run with failures
Recent activitywhat happened and when: project loaded, Unity opened or closed, tests started and finished, builds, failed compiles, the buttons you pressed
Action blockthe 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 closedStartingReady
<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.

Use

CommandDoes
unity or /unityopens the pane (in a Unity project; elsewhere unity is a normal prompt)
ButtonKeyDoesShown when
Install the Unity CLIruns Unity's official install scriptthe CLI is missing
Install Unity &lt;version&gt;installs the exact editor version the project asks forit is not installed
Install the Pipeline packageadds com.unity.pipeline, which the CLI drives the editor throughthe project lacks it
Open Unityostarts the right editor on this project; never a second onethe editor is closed
Run all testststarts the PlayMode (or EditMode) suitethe editor is up and idle
Build Androidbbuilds Builds/<project>.apkthe editor is up and idle
Recompilerrefreshes assets and recompiles scripts, for edits made while Unity was in the backgroundthe editor is up and idle
Stop the testsscancels the running suite; press twice within 5 seconds, so a stray click never throws away a long runa suite is running
Unblockucancels a stuck runner, reopens the first build scene, deletes leftover InitTestScene filesthe 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.

What it can reach

Everything stays on your machine.

ItWhat
readsthe project's ProjectSettings/ProjectVersion.txt, Packages/manifest.json and editor log; lists Assets/ (leftover test scenes) and Builds/
runs, on its ownunity --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 buttonthe editor itself, unity install, unity pipeline install, Unity's install script (downloaded from Unity's CDN), and the unity cmd behind each button
storesper 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.

Configure

In /config, or under pluginConfigs["unity-watch"].options in settings.json:

OptionDefaultMeaning
languageautoen, fr, or your system's language
openOnStarttrueopen the pane when a session starts in a Unity project
statusLinetruethe one-line summary in the status line
testModeplaymodewhich suite Run all tests starts: playmode or editmode
buildTargetAndroidoff hides the build button

Troubleshooting

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.

How it works

The pane talks to the editor through the Unity CLI and the com.unity.pipeline package, which runs a small server inside the editor.

FileHolds
hooks/register.tsxthe hooks, the polling, the buttons' actions and the drawing
hooks/core.tsevery 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.tsevery word, in English and French
hooks/art.tstheme colour keys and the SVG icons
types/index.d.tsthe 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.

Develop

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.

License

MIT

Source 5 files
hooks/register.tsx 891 lines
1import { 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}
891
hooks/art.ts 76 lines
1// 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' ? '!' : '◷')
76
hooks/core.ts 553 lines
1// 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}
553
hooks/strings.ts 486 lines
1// 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}
486
types/index.d.ts 119 lines
1/** 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