From 9f4eb3cce1a22b9b4269205dfe96584dfb4ed37e Mon Sep 17 00:00:00 2001 From: yoratoni Date: Mon, 17 Aug 2026 15:14:48 +0200 Subject: [PATCH 1/5] chore: small comment fix --- eslint.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index fd9189d..d72e730 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,7 +2,7 @@ import nitpicker from "@alien_intelligence/eslint-plugin-nitpicker" import tsParser from "@typescript-eslint/parser" /** - * ESLint flat config, layers Nitpicker on top of Biome for nit-level enforcement, + * ESLint flat config, it exists only to host Nitpicker for nit-level enforcement, * Biome still owns formatting and the broader lint rules. */ export default [ From 9a479f90285370930fc390af92e104cbb142ab2b Mon Sep 17 00:00:00 2001 From: yoratoni Date: Fri, 28 Aug 2026 23:48:48 +0200 Subject: [PATCH 2/5] chore: nitpicker setup & clean up --- .vscode/settings.json | 7 +- eslint.config.mjs | 7 +- package.json | 2 +- src/main/config/windows.ts | 2 +- src/main/lib/compileDb/paths.ts | 2 +- src/main/lib/compileDb/service.ts | 6 +- src/main/lib/constants.ts | 64 +++++++++++++++---- src/main/lib/dialogs/index.ts | 16 +++-- src/main/lib/gitCommands/exec.ts | 2 +- src/main/lib/lfsCommands/handlers.ts | 12 +++- src/main/lib/mcp/service.ts | 6 +- src/main/lib/projects/service.ts | 2 +- src/main/lib/shells/service.ts | 6 +- src/main/lib/updater/service.ts | 4 +- src/main/lib/utils/async.ts | 1 - src/main/types/projects.d.ts | 4 +- src/renderer/components/contexts/Project.tsx | 18 +++--- src/renderer/components/contexts/Status.tsx | 13 +++- src/renderer/components/contexts/TreeView.tsx | 16 +++-- .../components/fields/StatusCompileDb.tsx | 5 +- .../components/fields/StatusUpdate.tsx | 2 +- src/renderer/components/spaces/Workspace.tsx | 14 ++-- .../components/ui/TileProgressBar.tsx | 2 +- src/renderer/config/preferences.ts | 24 +++++-- src/renderer/hooks/usePreferencesDraft.tsx | 9 ++- src/renderer/hooks/useResizablePaneWidth.tsx | 6 +- src/renderer/hooks/useUpdaterStatusTask.tsx | 2 +- src/renderer/hooks/useWindowStates.tsx | 6 +- src/renderer/lib/utils/menus.ts | 7 +- yarn.lock | 8 +-- 30 files changed, 198 insertions(+), 77 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 7f05584..30c35ea 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,6 +12,9 @@ "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true }, + "[json]": { + "editor.defaultFormatter": "vscode.json-language-features" + }, // Exclude common build and cache directories from the file explorer "files.exclude": { "**/.git": true, @@ -102,9 +105,7 @@ "webfonts", "wmii" ], - "[json]": { - "editor.defaultFormatter": "vscode.json-language-features" - }, + // Import settings "javascript.preferences.importModuleSpecifier": "shortest", "typescript.preferences.importModuleSpecifier": "non-relative" } diff --git a/eslint.config.mjs b/eslint.config.mjs index d72e730..8fae07d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,12 +14,7 @@ export default [ sourceType: "module", }, plugins: { nitpicker }, - rules: { - ...nitpicker.configs.recommended.rules, - // TODO: re-enable once the upstream `e.charAt is not a function` crash is fixed, - // it currently trips on any TS `import type` line and aborts the whole lint run. - "nitpicker/no-british-english": "off", - }, + rules: nitpicker.configs.recommended.rules, }, { files: ["src/renderer/**/*.tsx", "src/renderer/**/*.ts"], diff --git a/package.json b/package.json index 2a9822f..bc8a112 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "styled-components": "^6.4.3" }, "devDependencies": { - "@alien_intelligence/eslint-plugin-nitpicker": "^0.2.0", + "@alien_intelligence/eslint-plugin-nitpicker": "^0.7.3", "@biomejs/biome": "^2.5.2", "@tailwindcss/vite": "^4.3.1", "@types/node": "^24.12.2", diff --git a/src/main/config/windows.ts b/src/main/config/windows.ts index b6312af..6b90e50 100644 --- a/src/main/config/windows.ts +++ b/src/main/config/windows.ts @@ -61,7 +61,7 @@ const WINDOWS_CONFIG: { icon: appIcon, webPreferences: WEB_PREFERENCES, }, - // Sized per variant by the dialogs service, from `DIALOGS_CONFIG` + // Sized per variant by the dialogs service, from "DIALOGS_CONFIG" dialog: SECONDARY_WINDOW, preferences: { ...SECONDARY_WINDOW, diff --git a/src/main/lib/compileDb/paths.ts b/src/main/lib/compileDb/paths.ts index 91593c6..190aaa5 100644 --- a/src/main/lib/compileDb/paths.ts +++ b/src/main/lib/compileDb/paths.ts @@ -62,7 +62,7 @@ export function classifySourcePath(relativePath: string): CompileDbFileKind | nu const extension = path.posix.extname(posixPath).toLowerCase() - // A plugin's own descriptor sits above its `Source` tree rather than inside it, + // A plugin's own descriptor sits above its "Source" tree rather than inside it, // and enabling a module in one moves the build with no source file moving if (extension === COMPILE_DB_CONFIG.pluginDescriptorExtension) { return isInsidePlugins(posixPath) ? "descriptor" : null diff --git a/src/main/lib/compileDb/service.ts b/src/main/lib/compileDb/service.ts index ef6c22a..6aac629 100644 --- a/src/main/lib/compileDb/service.ts +++ b/src/main/lib/compileDb/service.ts @@ -288,7 +288,11 @@ export class CompileDbService { if (unrealEngineRoot !== this._engineRootOverride) { this._engineRootOverride = unrealEngineRoot this._context = null - compileDbStore.set({ engineRoot: null, target: null, lastError: null }) + compileDbStore.set({ + engineRoot: null, + target: null, + lastError: null, + }) } if (isCompileDbAutoRegenEnabled === this._isEnabled) return diff --git a/src/main/lib/constants.ts b/src/main/lib/constants.ts index 589ef91..c41b3bc 100644 --- a/src/main/lib/constants.ts +++ b/src/main/lib/constants.ts @@ -131,18 +131,60 @@ const CONSTANTS = { */ terminals: { win32: [ - { command: "wt.exe", args: ["-d"], appendDir: true }, - { command: "cmd.exe", args: ["/c", "start", "", "cmd.exe"], appendDir: false }, + { + command: "wt.exe", + args: ["-d"], + appendDir: true, + }, + { + command: "cmd.exe", + args: ["/c", "start", "", "cmd.exe"], + appendDir: false, + }, + ], + darwin: [ + { + command: "open", + args: ["-a", "Terminal"], + appendDir: true, + }, ], - darwin: [{ command: "open", args: ["-a", "Terminal"], appendDir: true }], linux: [ - { command: "x-terminal-emulator", args: [], appendDir: false }, - { command: "gnome-terminal", args: [], appendDir: false }, - { command: "konsole", args: [], appendDir: false }, - { command: "xfce4-terminal", args: [], appendDir: false }, - { command: "alacritty", args: [], appendDir: false }, - { command: "kitty", args: [], appendDir: false }, - { command: "xterm", args: [], appendDir: false }, + { + command: "x-terminal-emulator", + args: [], + appendDir: false, + }, + { + command: "gnome-terminal", + args: [], + appendDir: false, + }, + { + command: "konsole", + args: [], + appendDir: false, + }, + { + command: "xfce4-terminal", + args: [], + appendDir: false, + }, + { + command: "alacritty", + args: [], + appendDir: false, + }, + { + command: "kitty", + args: [], + appendDir: false, + }, + { + command: "xterm", + args: [], + appendDir: false, + }, ], } as Record, }, @@ -158,7 +200,7 @@ const CONSTANTS = { contentDir: "Content", assetExtension: ".uasset", levelExtension: ".umap", - // The Unreal MCP exposes editor tools through a `call_tool` dispatcher rather + // The Unreal MCP exposes editor tools through a "call_tool" dispatcher rather // than as first-class MCP tools, so each entry pairs the toolset owning it with // its short name dispatcher: "call_tool", diff --git a/src/main/lib/dialogs/index.ts b/src/main/lib/dialogs/index.ts index a2403e9..4a8718c 100644 --- a/src/main/lib/dialogs/index.ts +++ b/src/main/lib/dialogs/index.ts @@ -61,7 +61,7 @@ class DialogRegistry { } /** - * Drops the entry for a closed window, cancelling any still-pending + * Drops the entry for a closed window, canceling any still-pending * awaiter, called from the window's `closed` handler. * @param id The window id. */ @@ -137,7 +137,7 @@ export function createDialogWindow( * (a close counts as a cancel). * @param parent The window that owns the dialog, or `null` to show it detached. * @param options The dialog contents and variant. - * @returns `true` when confirmed, `false` when cancelled or closed. + * @returns `true` when confirmed, `false` when canceled or closed. */ export function openDialog(parent: BrowserWindow | null, options: DialogOptions): Promise { return new Promise(resolve => { @@ -155,11 +155,19 @@ export function registerDialogsHandlers() { ) ipcMain.on(CONSTANTS.ipc.dialogsMessage, (event, title: string, message: string) => { - openDialog(BrowserWindow.fromWebContents(event.sender), { variant: "message", title, message }) + openDialog(BrowserWindow.fromWebContents(event.sender), { + variant: "message", + title, + message, + }) }) ipcMain.on(CONSTANTS.ipc.dialogsError, (event, title: string, message: string) => { - openDialog(BrowserWindow.fromWebContents(event.sender), { variant: "error", title, message }) + openDialog(BrowserWindow.fromWebContents(event.sender), { + variant: "error", + title, + message, + }) }) ipcMain.on(CONSTANTS.ipc.dialogsErrorWithDetails, (event, title: string, message: string, details: string) => { diff --git a/src/main/lib/gitCommands/exec.ts b/src/main/lib/gitCommands/exec.ts index fb3f229..5a6b11e 100644 --- a/src/main/lib/gitCommands/exec.ts +++ b/src/main/lib/gitCommands/exec.ts @@ -47,7 +47,7 @@ export async function runGit(args: string[], options: RunGitOptions): Promise lockPaths(dir, paths, (done, total) => { if (!requestId || event.sender.isDestroyed()) return - const payload: LfsLockProgress = { requestId, done, total } + const payload: LfsLockProgress = { + requestId, + done, + total, + } event.sender.send(CONSTANTS.ipc.lfsCommandsLockProgress, payload) }), ) @@ -39,7 +43,11 @@ export function registerLfsCommandsHandlers() { (event, dir: string, paths: string[], force: boolean | undefined, requestId: string | null) => unlockPaths(dir, paths, force, (done, total) => { if (!requestId || event.sender.isDestroyed()) return - const payload: LfsLockProgress = { requestId, done, total } + const payload: LfsLockProgress = { + requestId, + done, + total, + } event.sender.send(CONSTANTS.ipc.lfsCommandsLockProgress, payload) }), ) diff --git a/src/main/lib/mcp/service.ts b/src/main/lib/mcp/service.ts index 09c2403..7fe1462 100644 --- a/src/main/lib/mcp/service.ts +++ b/src/main/lib/mcp/service.ts @@ -128,7 +128,11 @@ export class McpService { if (hasEndpointChanged) { mcpClient.resetSession() - mcpStore.set({ status: "disconnected", server: null, error: null }) + mcpStore.set({ + status: "disconnected", + server: null, + error: null, + }) } if (hasIntervalChanged && this._probeTimer) this._restartTimer() diff --git a/src/main/lib/projects/service.ts b/src/main/lib/projects/service.ts index d0a8b1c..f363ad9 100644 --- a/src/main/lib/projects/service.ts +++ b/src/main/lib/projects/service.ts @@ -93,7 +93,7 @@ export async function addLocalProject(window: BrowserWindow | null): Promise { */ function trySpawn(command: string, args: string[], cwd: string): Promise { return new Promise(resolve => { - const child = spawn(command, args, { cwd, detached: true, stdio: "ignore" }) + const child = spawn(command, args, { + cwd, + detached: true, + stdio: "ignore", + }) child.once("spawn", () => { // Lets the terminal outlive the app rather than dying alongside it diff --git a/src/main/lib/updater/service.ts b/src/main/lib/updater/service.ts index 292778d..b5c160f 100644 --- a/src/main/lib/updater/service.ts +++ b/src/main/lib/updater/service.ts @@ -105,7 +105,7 @@ export async function checkForUpdates(isManualCheck = false) { try { await autoUpdater.checkForUpdates() } catch { - // Swallowed on purpose: electron-updater emits `error` before rethrowing, so + // Swallowed on purpose: electron-updater emits "error" before rethrowing, so // the listener has already recorded it and reacting here would broadcast twice } } @@ -136,7 +136,7 @@ export async function downloadUpdate() { try { await autoUpdater.downloadUpdate() } catch { - // Swallowed for the same reason as in `checkForUpdates`, the `error` listener owns it + // Swallowed for the same reason as in "checkForUpdates", the "error" listener owns it } } diff --git a/src/main/lib/utils/async.ts b/src/main/lib/utils/async.ts index 4f644c7..ef92828 100644 --- a/src/main/lib/utils/async.ts +++ b/src/main/lib/utils/async.ts @@ -16,7 +16,6 @@ export async function parallelLimit( /** * Runs the worker on the next item in the queue. - * @returns A promise that resolves when the worker is done. */ const run = async (): Promise => { while (cursor < items.length) { diff --git a/src/main/types/projects.d.ts b/src/main/types/projects.d.ts index 100ff21..ede0622 100644 --- a/src/main/types/projects.d.ts +++ b/src/main/types/projects.d.ts @@ -11,11 +11,11 @@ export type Project = { } /** - * Why opening a project did not result in an open repository, `cancelled` when + * Why opening a project did not result in an open repository, `canceled` when * the picker was dismissed, `not-found`, `not-a-repository`, and `not-a-ue-project` * cover the disk, git, and UE root-file checks respectively. */ -export type OpenProjectFailureReason = "cancelled" | "not-found" | "not-a-repository" | "not-a-ue-project" +export type OpenProjectFailureReason = "canceled" | "not-found" | "not-a-repository" | "not-a-ue-project" /** * The outcome of attempting to open or add a project. diff --git a/src/renderer/components/contexts/Project.tsx b/src/renderer/components/contexts/Project.tsx index b129749..a5bae36 100644 --- a/src/renderer/components/contexts/Project.tsx +++ b/src/renderer/components/contexts/Project.tsx @@ -63,7 +63,7 @@ export default function ProjectProvider({ children }: ProjectProviderProps) { if (result.ok) { setCurrentProject(result.project) - } else if (result.reason !== "cancelled") { + } else if (result.reason !== "canceled") { const message = CONSTANTS.PROJECT_OPEN_FAILURE_MESSAGES[result.reason] ?? "The project couldn't be opened." window.api.dialogs.errorWithDetails( @@ -74,8 +74,8 @@ export default function ProjectProvider({ children }: ProjectProviderProps) { } await refreshRecentProjects() - } catch (err) { - const details = err instanceof Error ? err.message : String(err) + } catch (error) { + const details = error instanceof Error ? error.message : String(error) window.api.dialogs.errorWithDetails( "Can't open project", "An unexpected error occurred while opening the project.", @@ -167,21 +167,21 @@ export default function ProjectProvider({ children }: ProjectProviderProps) { return } - let cancelled = false + let canceled = false window.api.gitCommands .getRemoteUrl(currentProject.path) .then(url => { - if (cancelled) return + if (canceled) return setRemoteUrl(url) }) .catch(() => { - if (cancelled) return + if (canceled) return setRemoteUrl(null) }) return () => { - cancelled = true + canceled = true } }, [currentProject?.path]) @@ -199,8 +199,8 @@ export default function ProjectProvider({ children }: ProjectProviderProps) { if (window.api.preferences.initial.startupBehavior === "reopen-last" && projects[0]) { await openProject(projects[0].path) } - } catch (err) { - const details = err instanceof Error ? err.message : String(err) + } catch (error) { + const details = error instanceof Error ? error.message : String(error) window.api.dialogs.errorWithDetails( "Failed to load recent projects", "The recent projects list couldn't be loaded on startup.", diff --git a/src/renderer/components/contexts/Status.tsx b/src/renderer/components/contexts/Status.tsx index 2a48f5b..99b5c73 100644 --- a/src/renderer/components/contexts/Status.tsx +++ b/src/renderer/components/contexts/Status.tsx @@ -62,7 +62,14 @@ export default function StatusProvider({ children }: StatusProviderProps) { nextId.current += 1 const id = `task-${nextId.current}` - setTasks(previous => [...previous, { id, label, progress: null }]) + setTasks(previous => [ + ...previous, + { + id, + label, + progress: null, + }, + ]) return { setLabel: nextLabel => @@ -112,6 +119,10 @@ export default function StatusProvider({ children }: StatusProviderProps) { [startTask], ) + /** + * The value handed to consumers, memoized so a provider re-render that leaves + * the tasks and their helpers untouched does not invalidate every consumer. + */ const value = useMemo( () => ({ tasks, diff --git a/src/renderer/components/contexts/TreeView.tsx b/src/renderer/components/contexts/TreeView.tsx index 7b0abd3..7db8d9a 100644 --- a/src/renderer/components/contexts/TreeView.tsx +++ b/src/renderer/components/contexts/TreeView.tsx @@ -135,12 +135,12 @@ export default function TreeViewProvider({ children }: TreeViewProviderProps) { // Hydrate the persisted search view state once on mount, so the search // scope survives across sessions useEffect(() => { - let cancelled = false + let canceled = false window.api.viewState .get() .then(view => { - if (cancelled) return + if (canceled) return setIsRegex(view.searchIsRegex) setIsAdvancedOpen(view.isAdvancedSearchOpened) @@ -153,7 +153,7 @@ export default function TreeViewProvider({ children }: TreeViewProviderProps) { }) return () => { - cancelled = true + canceled = true } }, []) @@ -179,7 +179,11 @@ export default function TreeViewProvider({ children }: TreeViewProviderProps) { [debouncedQuery, isRegex, debouncedInclude, debouncedExclude], ) - const isSearching = filters.query.trim().length > 0 + /** + * Whether a query is currently narrowing the tree, it keys off the debounced + * value so a keystroke still in flight does not flip the tree. + */ + const isSearching = useMemo(() => filters.query.trim().length > 0, [filters.query]) /** * The tree the user actually sees, optionally pruned to files locked by the @@ -387,6 +391,10 @@ export default function TreeViewProvider({ children }: TreeViewProviderProps) { reveal(menu.node.path) }, [menu, dismissMenu, reveal]) + /** + * The value handed to consumers, memoized so a provider re-render that leaves + * the selection and the visible tree untouched does not invalidate every consumer. + */ const value = useMemo( () => ({ expandedPaths, diff --git a/src/renderer/components/fields/StatusCompileDb.tsx b/src/renderer/components/fields/StatusCompileDb.tsx index 3a0b3b3..c4ea3f4 100644 --- a/src/renderer/components/fields/StatusCompileDb.tsx +++ b/src/renderer/components/fields/StatusCompileDb.tsx @@ -23,7 +23,10 @@ export default function StatusCompileDbField({ className }: StatusCompileDbField /** * Whether the last regeneration failed. */ - const hasFailed = Boolean(state?.lastError || state?.lastResult?.ok === false) + const hasFailed = useMemo( + () => Boolean(state?.lastError || state?.lastResult?.ok === false), + [state?.lastError, state?.lastResult?.ok], + ) /** * The chip label built from the current watcher state, `null` while the first diff --git a/src/renderer/components/fields/StatusUpdate.tsx b/src/renderer/components/fields/StatusUpdate.tsx index 5bfa676..00fc2b2 100644 --- a/src/renderer/components/fields/StatusUpdate.tsx +++ b/src/renderer/components/fields/StatusUpdate.tsx @@ -19,7 +19,7 @@ export default function StatusUpdateField() { * The download progress as a rounded percentage, `0` before the first * `download-progress` event lands so the bar starts empty. */ - const downloadPercentage = Math.round(state?.progress?.percent ?? 0) + const downloadPercentage = useMemo(() => Math.round(state?.progress?.percent ?? 0), [state?.progress?.percent]) /** * Runs the action that matches the current state. diff --git a/src/renderer/components/spaces/Workspace.tsx b/src/renderer/components/spaces/Workspace.tsx index 7270292..c133abd 100644 --- a/src/renderer/components/spaces/Workspace.tsx +++ b/src/renderer/components/spaces/Workspace.tsx @@ -1,3 +1,4 @@ +import { cn } from "@cybearl/cypack/frontend" import useResizablePaneWidth from "@renderer/hooks/useResizablePaneWidth" import useWindowWidth from "@renderer/hooks/useWindowWidth" import { useCallback, useEffect, useMemo } from "react" @@ -41,15 +42,15 @@ export default function Workspace() { // Load the persisted files pane width once on mount, clamped by the hook useEffect(() => { - let cancelled = false + let canceled = false window.api.viewState.get().then(view => { - if (cancelled) return + if (canceled) return setWidth(view.filesPaneWidth) }) return () => { - cancelled = true + canceled = true } }, [setWidth]) @@ -59,7 +60,12 @@ export default function Workspace() {
-
+
diff --git a/src/renderer/components/ui/TileProgressBar.tsx b/src/renderer/components/ui/TileProgressBar.tsx index a00cb43..85501d3 100644 --- a/src/renderer/components/ui/TileProgressBar.tsx +++ b/src/renderer/components/ui/TileProgressBar.tsx @@ -4,7 +4,7 @@ import styled, { css } from "styled-components" /** * Recolors the tiles to `theme.borderDark` when muted, so - * the inactive bar shows as light shadow-grey. + * the inactive bar shows as light shadow-gray. */ const Wrapper = styled.div<{ $muted: boolean }>` display: contents; diff --git a/src/renderer/config/preferences.ts b/src/renderer/config/preferences.ts index f99005f..b65cd8e 100644 --- a/src/renderer/config/preferences.ts +++ b/src/renderer/config/preferences.ts @@ -3,10 +3,26 @@ * displays rather than the milliseconds the preference stores. */ const PREFERENCES_CONFIG = { - autoLockIntervalSeconds: { min: 5, max: 600, step: 5 }, - mcpProbeIntervalSeconds: { min: 1, max: 120, step: 1 }, - compileDbDebounceSeconds: { min: 1, max: 60, step: 1 }, - updaterCheckIntervalHours: { min: 1, max: 168, step: 1 }, + autoLockIntervalSeconds: { + min: 5, + max: 600, + step: 5, + }, + mcpProbeIntervalSeconds: { + min: 1, + max: 120, + step: 1, + }, + compileDbDebounceSeconds: { + min: 1, + max: 60, + step: 1, + }, + updaterCheckIntervalHours: { + min: 1, + max: 168, + step: 1, + }, } export default PREFERENCES_CONFIG diff --git a/src/renderer/hooks/usePreferencesDraft.tsx b/src/renderer/hooks/usePreferencesDraft.tsx index 76cee76..7b1a9c5 100644 --- a/src/renderer/hooks/usePreferencesDraft.tsx +++ b/src/renderer/hooks/usePreferencesDraft.tsx @@ -23,8 +23,6 @@ export default function usePreferencesDraft(): UsePreferencesDraftResult { const { preferences } = usePreferences() const [draft, setDraft] = useState(preferences) - const isDirty = !arePreferencesEqual(draft, preferences) - /** * Records an edit to one field of the draft. * @param key The field to change. @@ -42,5 +40,10 @@ export default function usePreferencesDraft(): UsePreferencesDraftResult { await window.api.preferences.set(draft) }, [draft]) - return { draft, isDirty, setField, apply } + return { + draft, + isDirty: !arePreferencesEqual(draft, preferences), + setField, + apply, + } } diff --git a/src/renderer/hooks/useResizablePaneWidth.tsx b/src/renderer/hooks/useResizablePaneWidth.tsx index b68a91e..3cdd1af 100644 --- a/src/renderer/hooks/useResizablePaneWidth.tsx +++ b/src/renderer/hooks/useResizablePaneWidth.tsx @@ -117,5 +117,9 @@ export default function useResizablePaneWidth({ document.addEventListener("mouseup", handleUp) }, []) - return { width, setWidth, handleDragStart } + return { + width, + setWidth, + handleDragStart, + } } diff --git a/src/renderer/hooks/useUpdaterStatusTask.tsx b/src/renderer/hooks/useUpdaterStatusTask.tsx index 942d2d1..89c339f 100644 --- a/src/renderer/hooks/useUpdaterStatusTask.tsx +++ b/src/renderer/hooks/useUpdaterStatusTask.tsx @@ -14,7 +14,7 @@ export default function useUpdaterStatusTask() { */ const taskRef = useRef(null) - // Depends on `startTask` alone, which is stable, since the context value itself + // Depends on "startTask" alone, which is stable, since the context value itself // changes on every task update and would otherwise tear the task down mid-download useEffect(() => { const unsubscribe = window.api.updater.onStateChange(state => { diff --git a/src/renderer/hooks/useWindowStates.tsx b/src/renderer/hooks/useWindowStates.tsx index 22fc2ab..105399a 100644 --- a/src/renderer/hooks/useWindowStates.tsx +++ b/src/renderer/hooks/useWindowStates.tsx @@ -12,16 +12,16 @@ export default function useWindowStates() { // Fetch the initial state on mount and subscribe to subsequent changes so // the first render already reflects the actual window focus useEffect(() => { - let cancelled = false + let canceled = false window.api.windows.getState().then(state => { - if (!cancelled) setStates(state) + if (!canceled) setStates(state) }) const unsubscribe = window.api.windows.onStateChange(setStates) return () => { - cancelled = true + canceled = true unsubscribe() } }, []) diff --git a/src/renderer/lib/utils/menus.ts b/src/renderer/lib/utils/menus.ts index d2de921..701ed8a 100644 --- a/src/renderer/lib/utils/menus.ts +++ b/src/renderer/lib/utils/menus.ts @@ -35,7 +35,12 @@ export function buildComboSignature(combo: KeyCombo): string { * @returns The canonical signature string. */ function parseAccelerator(accelerator: string): string { - const combo: KeyCombo = { primary: false, shift: false, alt: false, key: "" } + const combo: KeyCombo = { + primary: false, + shift: false, + alt: false, + key: "", + } for (const token of accelerator.split("+")) { switch (token.trim().toLowerCase()) { diff --git a/yarn.lock b/yarn.lock index 693a128..e25b7c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,10 +7,10 @@ resolved "https://registry.yarnpkg.com/7zip-bin/-/7zip-bin-5.2.0.tgz#7a03314684dd6572b7dfa89e68ce31d60286854d" integrity sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A== -"@alien_intelligence/eslint-plugin-nitpicker@^0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@alien_intelligence/eslint-plugin-nitpicker/-/eslint-plugin-nitpicker-0.2.0.tgz#afe513228c7c1132b5420ed7265ac5c283e4edb6" - integrity sha512-d8kwa0JvSf3Fqiw22F+Ggffo7V90Z3zh8ezB89ANvAO95pJBEEs67HS8yz7ea2DCVonOr4748VqXIXIrOWt5Dg== +"@alien_intelligence/eslint-plugin-nitpicker@^0.7.3": + version "0.7.3" + resolved "https://registry.yarnpkg.com/@alien_intelligence/eslint-plugin-nitpicker/-/eslint-plugin-nitpicker-0.7.3.tgz#2767fd72220ae055d9a31b6394b0caa41aaf4780" + integrity sha512-PowCkuFPZknuR3RuY29WloDdM0vh51W6wxfLJ97NEiqdFwjH3nYZjjG78ihZubH9XSooJqliqcD66Jv19uR8Wg== dependencies: "@typescript-eslint/utils" "^8.18.0" From 0ec7f5ad09ef37db8443ddeeb491638efff28729 Mon Sep 17 00:00:00 2001 From: yoratoni Date: Tue, 15 Sep 2026 22:25:10 +0200 Subject: [PATCH 3/5] chore: enabled the nitpicker's breathing and design rule sets Bumped the nitpicker from 0.7.3 to 0.10.1 and turned its breathing rules on across the sources and its design ones on the renderer's components, sparing the ui primitives that wrap react95 chrome. Swept what the new rules flagged: blank lines between a statement and the return that follows it, and JSDoc on the type fields, constructors and inline IPC listeners that carried none. Kept "no-raw-control-element" as a warning with an allow list for the two places a flat button is deliberate, the react95 Button is beveled window chrome and reads wrong as a list row or a theme option. --- eslint.config.mjs | 18 ++++++++++++++++++ package.json | 2 +- src/main/index.ts | 10 +++++++++- src/main/lib/compileDb/paths.ts | 1 + src/main/lib/compileDb/service.ts | 7 +++---- src/main/lib/compileDb/watch.ts | 6 +++--- src/main/lib/gitCommands/parse.ts | 1 + src/main/lib/lfsCommands/service.ts | 2 ++ src/main/lib/mcp/client.ts | 5 +++++ src/main/lib/stores/json.ts | 5 +++++ src/main/lib/stores/observable.ts | 5 +++++ src/main/lib/updater/store.ts | 3 +++ src/main/types/lfsCommands.d.ts | 12 ++++++++++++ src/preload/lib/lockProgress.ts | 6 ++++++ src/preload/routes/autoLock.ts | 9 ++++++++- src/preload/routes/compileDb.ts | 9 ++++++++- src/preload/routes/mcp.ts | 9 ++++++++- src/preload/routes/preferences.ts | 9 ++++++++- src/preload/routes/updater.ts | 9 ++++++++- src/preload/routes/windows.ts | 9 ++++++++- src/renderer/components/contexts/Project.tsx | 5 +++++ src/renderer/components/contexts/Status.tsx | 1 + src/renderer/components/contexts/TreeView.tsx | 1 + .../components/menus/TreeViewContextMenu.tsx | 4 +++- src/renderer/hooks/useWindowWidth.tsx | 7 ++++++- src/renderer/lib/utils/lockStates.ts | 3 +++ src/renderer/lib/utils/search.ts | 1 + yarn.lock | 8 ++++---- 28 files changed, 146 insertions(+), 21 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 8fae07d..ddd2b39 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,8 +16,26 @@ export default [ plugins: { nitpicker }, rules: nitpicker.configs.recommended.rules, }, + { + files: ["src/**/*.ts", "src/**/*.tsx"], + ...nitpicker.configs.breathing, + }, { files: ["src/renderer/**/*.tsx", "src/renderer/**/*.ts"], ...nitpicker.configs.react, }, + { + files: ["src/renderer/**/*.tsx"], + ignores: ["src/renderer/components/ui/**"], + ...nitpicker.configs.design, + rules: { + ...nitpicker.configs.design.rules, + // The react95 "Button" is beveled window chrome, a flat list row or theme + // option is a "
+ + + {/* biome-ignore lint/a11y/noLabelWithoutControl: React95 renders the input inside, out of the linter's reach */} + +
) } diff --git a/src/renderer/config/menus.ts b/src/renderer/config/menus.ts index 7d6acab..67cae43 100644 --- a/src/renderer/config/menus.ts +++ b/src/renderer/config/menus.ts @@ -17,6 +17,7 @@ export type MenuAction = | { type: "shell:open-external"; url: string } | { type: "shell:show-folder"; path: string } | { type: "shell:open-terminal"; path: string } + | { type: "shell:open-editor"; path: string } | { type: "search:toggle-regex" } | { type: "search:toggle-advanced" } | { type: "lfs:toggle-show-my-locks" } @@ -315,6 +316,12 @@ export function buildTopLevelMenus( isDisabled: !currentProject, action: currentProject ? { type: "shell:open-terminal", path: currentProject.path } : undefined, }, + { + type: "item", + label: "Open in Code Editor", + isDisabled: !currentProject, + action: currentProject ? { type: "shell:open-editor", path: currentProject.path } : undefined, + }, { type: "item", label: "View on Git Source", diff --git a/src/renderer/hooks/useMenuActions.tsx b/src/renderer/hooks/useMenuActions.tsx index e18d1f5..03731e1 100644 --- a/src/renderer/hooks/useMenuActions.tsx +++ b/src/renderer/hooks/useMenuActions.tsx @@ -73,6 +73,12 @@ export default function useMenuActions(): UseMenuActionsResult { .openTerminal(action.path) .catch(error => reportError("Can't open a terminal", error)) + break + case "shell:open-editor": + window.api.shells + .openEditor(action.path) + .catch(error => reportError("Can't open a code editor", error)) + break case "search:toggle-regex": setIsRegex(!isRegex) diff --git a/tests/unit/shells.test.ts b/tests/unit/shells.test.ts new file mode 100644 index 0000000..3237c93 --- /dev/null +++ b/tests/unit/shells.test.ts @@ -0,0 +1,38 @@ +import { expandEnvPlaceholders } from "@main/lib/shells/editors" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +describe("expandEnvPlaceholders", () => { + const originalEnv = process.env + + beforeEach(() => { + process.env = { + ...originalEnv, + LOCALAPPDATA: "C:\\Users\\dev\\AppData\\Local", + EMPTYVAR: "", + } + }) + + afterEach(() => { + process.env = originalEnv + }) + + it("expands a placeholder from the environment", () => { + expect(expandEnvPlaceholders("%LOCALAPPDATA%\\Programs\\cursor\\Cursor.exe")).toBe( + "C:\\Users\\dev\\AppData\\Local\\Programs\\cursor\\Cursor.exe", + ) + }) + + it("leaves a candidate holding no placeholder alone", () => { + expect(expandEnvPlaceholders("/Applications/Cursor.app")).toBe("/Applications/Cursor.app") + expect(expandEnvPlaceholders("code")).toBe("code") + }) + + it("rules out a candidate whose variable is unset or empty", () => { + expect(expandEnvPlaceholders("%NOT_A_REAL_VARIABLE%\\Code.exe")).toBeNull() + expect(expandEnvPlaceholders("%EMPTYVAR%\\Code.exe")).toBeNull() + }) + + it("rules the whole candidate out when only one of its variables is unset", () => { + expect(expandEnvPlaceholders("%LOCALAPPDATA%\\%NOT_A_REAL_VARIABLE%\\Code.exe")).toBeNull() + }) +}) From f58843ed878e3b69a7543405b28e3afc78f5c25d Mon Sep 17 00:00:00 2001 From: yoratoni Date: Tue, 15 Sep 2026 22:25:32 +0200 Subject: [PATCH 5/5] feat: opened the update dialog from the status bar chip Made the update chip clickable the way the auto-lock and C++ database ones already are, so the release notes can be read before committing to the update rather than the download starting on the spot. Stopped the button's click from reaching the chip behind it, so the verb that was pressed is the one that runs. --- .../components/fields/StatusUpdate.tsx | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/src/renderer/components/fields/StatusUpdate.tsx b/src/renderer/components/fields/StatusUpdate.tsx index 00fc2b2..5d51b81 100644 --- a/src/renderer/components/fields/StatusUpdate.tsx +++ b/src/renderer/components/fields/StatusUpdate.tsx @@ -1,7 +1,7 @@ import downloadIcon from "@react95-icons/Download_16x16_4.png" import useUpdaterState from "@renderer/hooks/useUpdaterState" import { buildUpdaterStatusLabel } from "@renderer/lib/utils/updater" -import { useCallback, useMemo } from "react" +import { type MouseEvent, useCallback, useMemo } from "react" import { Button } from "react95" import StatusBarFrame from "@/renderer/components/frames/StatusBar" import TileProgressBar from "@/renderer/components/ui/TileProgressBar" @@ -21,27 +21,47 @@ export default function StatusUpdateField() { */ const downloadPercentage = useMemo(() => Math.round(state?.progress?.percent ?? 0), [state?.progress?.percent]) + /** + * Opens the update dialog, the chip only advertises the version and the dialog is + * where its release notes can be read before committing to the update. + */ + const handleOpenDialog = useCallback(() => { + window.api.updater.openDialog() + }, []) + /** * Runs the action that matches the current state. + * @param event The click event, kept from reaching the chip behind the button so the + * verb the user pressed is the one that runs. */ - const handleAction = useCallback(() => { - if (state?.status === "downloaded") { - window.api.updater.install() - return - } + const handleAction = useCallback( + (event: MouseEvent) => { + event.stopPropagation() - if (state?.canAutoInstall) { - window.api.updater.download() - return - } + if (state?.status === "downloaded") { + window.api.updater.install() + return + } - window.api.updater.openDialog() - }, [state?.canAutoInstall, state?.status]) + if (state?.canAutoInstall) { + window.api.updater.download() + return + } + + window.api.updater.openDialog() + }, + [state?.canAutoInstall, state?.status], + ) if (!state || !label) return null return ( - + {state.status === "downloading" ? ( ) : (