Skip to content
Merged
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/quiet-themes-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Show a transient startup footer notice when deprecated `custom_theme.syntax` colors are translated into approximate Shiki scopes.
4 changes: 4 additions & 0 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
saveViewPreferencesPromptPreference,
} from "./config";
import { loadAppBootstrap } from "./loaders";
import { LEGACY_CUSTOM_SYNTAX_NOTICE, LEGACY_CUSTOM_SYNTAX_NOTICES } from "./startupNotice";

const tempDirs: string[] = [];

Expand Down Expand Up @@ -265,6 +266,7 @@ describe("config resolution", () => {
"string.quoted": "#fedcba",
},
});
expect(resolved.startupNotices).toBeUndefined();
});

test.each(["github-dark-default", "github-light-default", "dracula", "catppuccin-mocha"])(
Expand Down Expand Up @@ -373,6 +375,8 @@ describe("config resolution", () => {
comment: "#eeeeee",
"punctuation.definition.comment": "#ffffff",
});
expect(resolved.startupNotices).toBe(LEGACY_CUSTOM_SYNTAX_NOTICES);
expect(resolved.startupNotices).toEqual([LEGACY_CUSTOM_SYNTAX_NOTICE]);
});

test("rejects theme = custom when no [custom_theme] table is configured", () => {
Expand Down
28 changes: 22 additions & 6 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { BUNDLED_SHIKI_THEME_IDS } from "../ui/lib/shikiThemes";
import { normalizeBuiltInThemeId } from "../ui/themes";
import { LEGACY_CUSTOM_SYNTAX_COLOR_KEYS, resolveSyntaxScopeOverrides } from "./legacySyntaxScopes";
import { resolveGlobalConfigPath } from "./paths";
import { LEGACY_CUSTOM_SYNTAX_NOTICES, type StartupNotice } from "./startupNotice";
import { detectVcs, findVcsRepoRootCandidate, getDefaultVcsAdapter, isVcsId } from "./vcs";
import type {
CliInput,
Expand Down Expand Up @@ -86,6 +87,7 @@ interface ConfigResolutionOptions {
interface HunkConfigResolution {
input: CliInput;
customTheme?: CustomThemeConfig;
startupNotices?: readonly StartupNotice[];
globalConfigPath?: string;
repoConfigPath?: string;
viewPreferencesConfigPath?: string;
Expand Down Expand Up @@ -227,11 +229,16 @@ function readCustomSyntaxScopes(
return Object.keys(syntaxScopes).length > 0 ? syntaxScopes : undefined;
}

/** Read the optional config-defined custom theme palette from one TOML object level. */
function readCustomTheme(source: Record<string, unknown>): CustomThemeConfig | undefined {
interface CustomThemeLayer {
customTheme?: CustomThemeConfig;
usesLegacySyntax: boolean;
}

/** Read one config layer's optional custom theme and compatibility metadata. */
function readCustomTheme(source: Record<string, unknown>): CustomThemeLayer {
const customThemeSource = source.custom_theme;
if (!isRecord(customThemeSource)) {
return undefined;
return { usesLegacySyntax: false };
}

const legacySyntaxSource = customThemeSource.syntax;
Expand Down Expand Up @@ -271,7 +278,10 @@ function readCustomTheme(source: Record<string, unknown>): CustomThemeConfig | u
customTheme.syntaxScopes = syntaxScopes;
}

return customTheme;
return {
customTheme,
usesLegacySyntax: Boolean(legacySyntax),
};
}

/** Merge partial custom theme layers while keeping exact syntax scope overrides field-based. */
Expand Down Expand Up @@ -488,6 +498,7 @@ export function resolveConfiguredCliInput(
const repoConfigPath = repoRoot ? join(repoRoot, ".hunk", "config.toml") : undefined;
const userConfigPath = resolveGlobalConfigPath(env);
let resolvedCustomTheme: CustomThemeConfig | undefined;
let usesLegacyCustomSyntax = false;

let resolvedOptions: CommonOptions = {
mode: DEFAULT_VIEW_PREFERENCES.mode,
Expand All @@ -511,14 +522,18 @@ export function resolveConfiguredCliInput(

if (userConfigPath) {
const userConfig = readTomlRecord(userConfigPath);
const themeLayer = readCustomTheme(userConfig);
resolvedOptions = mergeOptions(resolvedOptions, resolveConfigLayer(userConfig, input));
resolvedCustomTheme = mergeCustomTheme(resolvedCustomTheme, readCustomTheme(userConfig));
resolvedCustomTheme = mergeCustomTheme(resolvedCustomTheme, themeLayer.customTheme);
usesLegacyCustomSyntax ||= themeLayer.usesLegacySyntax;
}

if (repoConfigPath) {
const repoConfig = readTomlRecord(repoConfigPath);
const themeLayer = readCustomTheme(repoConfig);
resolvedOptions = mergeOptions(resolvedOptions, resolveConfigLayer(repoConfig, input));
resolvedCustomTheme = mergeCustomTheme(resolvedCustomTheme, readCustomTheme(repoConfig));
resolvedCustomTheme = mergeCustomTheme(resolvedCustomTheme, themeLayer.customTheme);
usesLegacyCustomSyntax ||= themeLayer.usesLegacySyntax;
}

resolvedOptions = mergeOptions(resolvedOptions, input.options);
Expand Down Expand Up @@ -552,6 +567,7 @@ export function resolveConfiguredCliInput(
options: resolvedOptions,
},
customTheme: resolvedCustomTheme,
startupNotices: usesLegacyCustomSyntax ? LEGACY_CUSTOM_SYNTAX_NOTICES : undefined,
globalConfigPath: userConfigPath,
repoConfigPath,
// Persist in the repo config only when the repo already has one; otherwise keep personal view
Expand Down
29 changes: 29 additions & 0 deletions src/core/startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,35 @@ describe("startup planning", () => {
});
});

test("carries configured startup notices into the app bootstrap", async () => {
const cliInput: CliInput = {
kind: "patch",
file: "-",
options: {},
};
const startupNotice = {
key: "deprecated:custom-theme-syntax",
message: "Legacy custom theme syntax detected",
};

const plan = await prepareStartupPlan(["bun", "hunk", "patch", "-"], {
parseCliImpl: async () => cliInput as ParsedCliInput,
resolveRuntimeCliInputImpl: (input) => input,
resolveConfiguredCliInputImpl: (input) =>
({
input,
startupNotices: [startupNotice],
}) as never,
loadAppBootstrapImpl: async (input) => createBootstrap(input),
usesPipedPatchInputImpl: () => false,
});

expect(plan.kind).toBe("app");
if (plan.kind === "app") {
expect(plan.bootstrap.startupNotices).toEqual([startupNotice]);
}
});

test("rejects watch mode for stdin-backed patch inputs", async () => {
const cliInput: CliInput = {
kind: "patch",
Expand Down
1 change: 1 addition & 0 deletions src/core/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ export async function prepareStartupPlan(
}

bootstrap.initialThemeMode = initialThemeMode ?? bootstrap.initialThemeMode;
bootstrap.startupNotices = configured.startupNotices;
bootstrap.viewPreferencesConfigPath = configured.viewPreferencesConfigPath;

controllingTerminal ??= usesPipedPatchInputImpl(cliInput) ? openControllingTerminalImpl() : null;
Expand Down
15 changes: 15 additions & 0 deletions src/core/startupNotice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/** One transient message shown in the footer during app startup. */
export interface StartupNotice {
key: string;
message: string;
}

/** Warn when Hunk had to approximate deprecated semantic syntax colors. */
export const LEGACY_CUSTOM_SYNTAX_NOTICE: StartupNotice = {
key: "deprecated:custom-theme-syntax",
message:
"Deprecated [custom_theme.syntax] translated approximately • migrate to [custom_theme.syntax_scopes]",
};

/** Reuse one array identity so unchanged config reloads do not restart the notice queue. */
export const LEGACY_CUSTOM_SYNTAX_NOTICES: readonly StartupNotice[] = [LEGACY_CUSTOM_SYNTAX_NOTICE];
2 changes: 2 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { FileDiffMetadata } from "@pierre/diffs";
import type { FileSourceFetcher } from "./fileSource";
import type { StartupNotice } from "./startupNotice";

export type LayoutMode = "auto" | "split" | "stack";
export type VcsMode = string;
Expand Down Expand Up @@ -403,5 +404,6 @@ export interface AppBootstrap {
initialShowMenuBar?: boolean;
initialShowAgentNotes?: boolean;
initialCopyDecorations?: boolean;
startupNotices?: readonly StartupNotice[];
viewPreferencesConfigPath?: string;
}
14 changes: 5 additions & 9 deletions src/core/updateNotice.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { resolveHunkStatePath } from "./paths";
import type { StartupNotice } from "./startupNotice";
import { resolveCliVersion, UNKNOWN_CLI_VERSION } from "./version";

const DIST_TAGS_URL = "https://registry.npmjs.org/-/package/hunkdiff/dist-tags";
Expand All @@ -19,11 +20,6 @@ interface PersistedStartupState {
export type UpdateChannel = "latest" | "beta";
export type InstallSource = "npm" | "homebrew";

export interface UpdateNotice {
key: string;
message: string;
}

type FetchImpl = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;

interface ParsedDistTags {
Expand Down Expand Up @@ -91,7 +87,7 @@ function createUpdateNotice(
version: string,
channel: UpdateChannel,
installSource: InstallSource,
): UpdateNotice {
): StartupNotice {
const command = commandForChannel(channel, installSource);
return {
key: `${channel}:${version}`,
Expand All @@ -113,7 +109,7 @@ function selectUpdateNotice(
installedVersion: string,
distTags: ParsedDistTags,
installSource: InstallSource,
): UpdateNotice | null {
): StartupNotice | null {
if (!isComparableInstalledVersion(installedVersion)) {
return null;
}
Expand Down Expand Up @@ -221,7 +217,7 @@ function startupUpdateNoticeDisabled(env: NodeJS.ProcessEnv = process.env) {
}

/** Resolve the one-time copied-skill refresh notice shown after a version change. */
function resolveStartupSkillRefreshNotice(deps: UpdateNoticeDeps = {}): UpdateNotice | null {
function resolveStartupSkillRefreshNotice(deps: UpdateNoticeDeps = {}): StartupNotice | null {
const resolveInstalledVersion = deps.resolveInstalledVersion ?? resolveCliVersion;
const installedVersion = resolveInstalledVersion();
if (installedVersion === UNKNOWN_CLI_VERSION) {
Expand Down Expand Up @@ -254,7 +250,7 @@ function resolveStartupSkillRefreshNotice(deps: UpdateNoticeDeps = {}): UpdateNo
/** Resolve the transient startup notice directly from local state or npm dist-tags. */
export async function resolveStartupUpdateNotice(
deps: UpdateNoticeDeps = {},
): Promise<UpdateNotice | null> {
): Promise<StartupNotice | null> {
const env = deps.env ?? process.env;
if (startupUpdateNoticeDisabled(env)) {
return null;
Expand Down
22 changes: 22 additions & 0 deletions src/ui/AppHost.interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
HunkSessionServerMessage,
HunkSessionSnapshot,
} from "../hunk-session/types";
import { LEGACY_CUSTOM_SYNTAX_NOTICE } from "../core/startupNotice";
import type { AppBootstrap, LayoutMode } from "../core/types";
import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap";
import { capturedTestColorToHex } from "../../test/helpers/test-color-helpers";
Expand Down Expand Up @@ -726,6 +727,27 @@ describe("App interactions", () => {
}
});

test("shows configured deprecation notices in the startup footer", async () => {
const setup = await testRender(
<AppHost
bootstrap={{
...createSingleFileBootstrap(),
startupNotices: [LEGACY_CUSTOM_SYNTAX_NOTICE],
}}
/>,
{ width: 240, height: 24 },
);

try {
await flush(setup);
expect(setup.captureCharFrame()).toContain(LEGACY_CUSTOM_SYNTAX_NOTICE.message);
} finally {
await act(async () => {
setup.renderer.destroy();
});
}
});

test("Shift-M hides the menu bar without disabling F10 menus", async () => {
const setup = await testRender(<AppHost bootstrap={createSingleFileBootstrap()} />, {
width: 240,
Expand Down
12 changes: 7 additions & 5 deletions src/ui/AppHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { useCallback, useState } from "react";
import { resolveConfiguredCliInput } from "../core/config";
import { loadAppBootstrap } from "../core/loaders";
import { resolveRuntimeCliInput } from "../core/terminal";
import type { StartupNotice } from "../core/startupNotice";
import type { AppBootstrap, CliInput } from "../core/types";
import type { UpdateNotice } from "../core/updateNotice";
import {
createInitialSessionSnapshot,
updateSessionRegistration,
Expand All @@ -14,7 +14,7 @@ import {
} from "../hunk-session/sessionFileBounds";
import type { HunkSessionBrokerClient } from "../hunk-session/types";
import { App } from "./App";
import { useStartupUpdateNotice } from "./hooks/useStartupUpdateNotice";
import { useStartupNotices } from "./hooks/useStartupNotices";
import type { WatchedInputRuntime } from "./hooks/useWatchedInput";

/** Keep one live Hunk app mounted while allowing daemon-driven session reloads. */
Expand All @@ -28,16 +28,17 @@ export function AppHost({
bootstrap: AppBootstrap;
hostClient?: HunkSessionBrokerClient;
onQuit?: () => void;
startupNoticeResolver?: () => Promise<UpdateNotice | null>;
startupNoticeResolver?: () => Promise<StartupNotice | null>;
watchRuntime?: WatchedInputRuntime;
}) {
const [activeBootstrap, setActiveBootstrap] = useState(bootstrap);
const [appVersion, setAppVersion] = useState(0);
const [sessionFileBounds] = useState(() =>
createSessionReloadBounds(bootstrap, { cwd: bootstrap.reloadContext.cwd }),
);
const startupNoticeText = useStartupUpdateNotice({
enabled: !bootstrap.input.options.pager,
const startupNoticeText = useStartupNotices({
enabled: !activeBootstrap.input.options.pager,
notices: activeBootstrap.startupNotices,
resolver: startupNoticeResolver,
});

Expand All @@ -56,6 +57,7 @@ export function AppHost({
cwd,
customTheme: configured.customTheme,
});
nextBootstrap.startupNotices = configured.startupNotices;
nextBootstrap.viewPreferencesConfigPath = configured.viewPreferencesConfigPath;
const nextSnapshot = createInitialSessionSnapshot(nextBootstrap);

Expand Down
Loading