Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/live-config-reload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Live-reload the theme and chrome when the config changes while running in `--watch` mode. The viewer now polls the global (`~/.config/hunk/config.toml`) and repo-local (`.hunk/config.toml`) config files alongside the diff input, and reloads through the existing reload path on change — so edits to `[custom_theme]`, layout, and other view options take effect without restarting the viewer.
65 changes: 63 additions & 2 deletions src/core/watch.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { computeWatchSignature } from "./watch";
import { computeConfigSignature, computeWatchSignature } from "./watch";
import type { CliInput } from "./types";

const tempDirs: string[] = [];
Expand Down Expand Up @@ -153,3 +153,64 @@ describe("computeWatchSignature", () => {
expect(changedSignature).not.toEqual(initialSignature);
});
});

describe("computeConfigSignature", () => {
const previousXdg = process.env.XDG_CONFIG_HOME;

afterEach(() => {
if (previousXdg === undefined) {
delete process.env.XDG_CONFIG_HOME;
} else {
process.env.XDG_CONFIG_HOME = previousXdg;
}
});

test("marks a missing global config without throwing", () => {
const configHome = realpathSync(mkdtempSync(join(tmpdir(), "hunk-cfg-missing-")));
tempDirs.push(configHome);
process.env.XDG_CONFIG_HOME = configHome;

const nonRepo = realpathSync(mkdtempSync(join(tmpdir(), "hunk-cfg-nonrepo-")));
tempDirs.push(nonRepo);

const signature = computeConfigSignature(nonRepo);

expect(signature).toContain(join(configHome, "hunk", "config.toml"));
expect(signature).toContain(":missing");
});

test("changes when the global config file changes", () => {
const configHome = realpathSync(mkdtempSync(join(tmpdir(), "hunk-cfg-change-")));
tempDirs.push(configHome);
process.env.XDG_CONFIG_HOME = configHome;
mkdirSync(join(configHome, "hunk"), { recursive: true });
const configPath = join(configHome, "hunk", "config.toml");

const nonRepo = realpathSync(mkdtempSync(join(tmpdir(), "hunk-cfg-change-cwd-")));
tempDirs.push(nonRepo);

writeFileSync(configPath, 'theme = "custom"\n');
const initialSignature = computeConfigSignature(nonRepo);

writeFileSync(configPath, 'theme = "custom"\nborderless = true\n');
const changedSignature = computeConfigSignature(nonRepo);

expect(initialSignature).not.toContain(":missing");
expect(changedSignature).not.toEqual(initialSignature);
});

test("includes the repo-local .hunk/config.toml when inside a repo", () => {
const configHome = realpathSync(mkdtempSync(join(tmpdir(), "hunk-cfg-repo-home-")));
tempDirs.push(configHome);
process.env.XDG_CONFIG_HOME = configHome;

const repo = createTempRepo("hunk-cfg-repo-");
mkdirSync(join(repo, ".hunk"), { recursive: true });
writeFileSync(join(repo, ".hunk", "config.toml"), "menu_bar = false\n");

const signature = computeConfigSignature(repo);

expect(signature).toContain(join(repo, ".hunk", "config.toml"));
expect(signature).not.toContain(`${join(repo, ".hunk", "config.toml")}:missing`);
});
});
28 changes: 27 additions & 1 deletion src/core/watch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import fs from "node:fs";
import { createVcsWatchSignature, getConfiguredVcsAdapter, operationFromInput } from "./vcs";
import path from "node:path";
import { resolveGlobalConfigPath } from "./paths";
import {
createVcsWatchSignature,
findVcsRepoRootCandidate,
getConfiguredVcsAdapter,
operationFromInput,
} from "./vcs";
import type { CliInput } from "./types";

/** Return whether the current input can be rebuilt from files or VCS state without rereading stdin. */
Expand Down Expand Up @@ -55,3 +62,22 @@ export function computeWatchSignature(input: CliInput) {

return parts.join("\n---\n");
}

/** Compute a change-detection signature over Hunk's config files (global +
* repo-local), so a viewer in --watch mode can notice theme/chrome edits and
* live-reload — mirrors the diff-input watcher but for configuration. */
export function computeConfigSignature(cwd: string = process.cwd()) {
const parts: string[] = [];

const globalConfigPath = resolveGlobalConfigPath();
if (globalConfigPath) {
parts.push(statSignature(globalConfigPath));
}

const repoRoot = findVcsRepoRootCandidate(cwd);
if (repoRoot) {
parts.push(statSignature(path.join(repoRoot, ".hunk", "config.toml")));
}

return parts.join("\n---\n");
}
29 changes: 21 additions & 8 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
import { useRenderer, useTerminalDimensions } from "@opentui/react";
import { Suspense, lazy, useCallback, useEffect, useMemo, useState, useRef } from "react";
import type { AppBootstrap, CliInput, LayoutMode, UserNoteLineTarget } from "../core/types";
import { canReloadInput, computeWatchSignature } from "../core/watch";
import { canReloadInput, computeConfigSignature, computeWatchSignature } from "../core/watch";
import type { HunkSessionBrokerClient, ReloadedSessionResult } from "../hunk-session/types";
import { MenuBar } from "./components/chrome/MenuBar";
import { StatusBar } from "./components/chrome/StatusBar";
Expand Down Expand Up @@ -583,6 +583,12 @@ export function App({
triggerRefreshCurrentInput,
]);

// In --watch mode, auto-reload when either the diff input OR Hunk's config files
// (~/.config/hunk/config.toml, repo-local .hunk/config.toml) change. Folding the
// config watch into the same loop keeps a single in-flight guard, so a config
// reload can never race the diff reload. Reusing refreshCurrentInput means the
// reload re-reads config — so theme/chrome palette edits repaint live — while
// preserving the active view options (and any CLI overrides).
useEffect(() => {
if (!watchEnabled) {
return;
Expand All @@ -591,10 +597,12 @@ export function App({
let cancelled = false;
let polling = false;
let refreshing = false;
let lastSignature: string;
let lastInputSignature: string;
let lastConfigSignature: string;

try {
lastSignature = computeWatchSignature(bootstrap.input);
lastInputSignature = computeWatchSignature(bootstrap.input);
lastConfigSignature = computeConfigSignature();
} catch (error) {
console.error("Failed to initialize watch mode.", error);
return;
Expand All @@ -608,20 +616,25 @@ export function App({
polling = true;

try {
const nextSignature = computeWatchSignature(bootstrap.input);
if (nextSignature !== lastSignature) {
lastSignature = nextSignature;
const nextInputSignature = computeWatchSignature(bootstrap.input);
const nextConfigSignature = computeConfigSignature();
if (
nextInputSignature !== lastInputSignature ||
nextConfigSignature !== lastConfigSignature
) {
lastInputSignature = nextInputSignature;
lastConfigSignature = nextConfigSignature;
refreshing = true;
void refreshCurrentInput()
.catch((error) => {
console.error("Failed to auto-reload the current diff.", error);
console.error("Failed to auto-reload after a watched change.", error);
})
.finally(() => {
refreshing = false;
});
}
} catch (error) {
console.error("Failed to poll watch mode input.", error);
console.error("Failed to poll watch mode.", error);
} finally {
polling = false;
}
Expand Down