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
2 changes: 1 addition & 1 deletion benchmarks/highlight-prefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import React from "react";
import { testRender } from "@opentui/react/test-utils";
import { parseDiffFromFile } from "@pierre/diffs";
import { act } from "react";
import { AppHost } from "../src/ui/App";
import { AppHost } from "../src/ui/AppHost";
import type { AppBootstrap, DiffFile } from "../src/core/types";

function createDiffFile(index: number, marker: string): DiffFile {
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/large-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { performance } from "perf_hooks";
import React from "react";
import { testRender } from "@opentui/react/test-utils";
import { act } from "react";
import { AppHost } from "../src/ui/App";
import { AppHost } from "../src/ui/AppHost";
import {
createLargeSplitStreamBootstrap,
DEFAULT_FILE_COUNT,
Expand Down
2 changes: 1 addition & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { pagePlainText } from "./core/pager";
import { shutdownSession } from "./core/shutdown";
import { prepareStartupPlan } from "./core/startup";
import { resolveStartupUpdateNotice } from "./core/updateNotice";
import { AppHost } from "./ui/App";
import { AppHost } from "./ui/AppHost";
import { HunkHostClient } from "./mcp/client";
import { serveHunkMcpServer } from "./mcp/server";
import { createInitialSessionSnapshot, createSessionRegistration } from "./mcp/sessionRegistration";
Expand Down
81 changes: 1 addition & 80 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,9 @@ import {
useState,
useRef,
} from "react";
import { resolveConfiguredCliInput } from "../core/config";
import { loadAppBootstrap } from "../core/loaders";
import { resolveRuntimeCliInput } from "../core/terminal";
import type { AppBootstrap, CliInput, LayoutMode } from "../core/types";
import { canReloadInput, computeWatchSignature } from "../core/watch";
import type { UpdateNotice } from "../core/updateNotice";
import { HunkHostClient } from "../mcp/client";
import {
createInitialSessionSnapshot,
updateSessionRegistration,
} from "../mcp/sessionRegistration";
import type { ReloadedSessionResult } from "../mcp/types";
import { MenuBar } from "./components/chrome/MenuBar";
import { StatusBar } from "./components/chrome/StatusBar";
Expand All @@ -35,7 +27,6 @@ import { FilesPane } from "./components/panes/FilesPane";
import { PaneDivider } from "./components/panes/PaneDivider";
import { useHunkSessionBridge } from "./hooks/useHunkSessionBridge";
import { useMenuController } from "./hooks/useMenuController";
import { useStartupUpdateNotice } from "./hooks/useStartupUpdateNotice";
import { buildAppMenus } from "./lib/appMenus";
import { buildSidebarEntries, filterReviewFiles, mergeFileAnnotationsByFileId } from "./lib/files";
import { buildAnnotatedHunkCursors, buildHunkCursors, findNextHunkCursor } from "./lib/hunks";
Expand Down Expand Up @@ -85,7 +76,7 @@ function withCurrentViewOptions(
}

/** Orchestrate global app state, layout, navigation, and pane coordination. */
function App({
export function App({
bootstrap,
hostClient,
noticeText,
Expand Down Expand Up @@ -1044,73 +1035,3 @@ function App({
</box>
);
}

/** Keep one live Hunk app mounted while allowing daemon-driven session reloads. */
export function AppHost({
bootstrap,
hostClient,
onQuit = () => process.exit(0),
startupNoticeResolver,
}: {
bootstrap: AppBootstrap;
hostClient?: HunkHostClient;
onQuit?: () => void;
startupNoticeResolver?: () => Promise<UpdateNotice | null>;
}) {
const [activeBootstrap, setActiveBootstrap] = useState(bootstrap);
const [appVersion, setAppVersion] = useState(0);
const startupNoticeText = useStartupUpdateNotice({
enabled: !bootstrap.input.options.pager,
resolver: startupNoticeResolver,
});

const reloadSession = useCallback(
async (nextInput: CliInput, options?: { resetApp?: boolean; sourcePath?: string }) => {
const runtimeInput = resolveRuntimeCliInput(nextInput);
const configuredInput = resolveConfiguredCliInput(runtimeInput, {
cwd: options?.sourcePath,
}).input;
const nextBootstrap = await loadAppBootstrap(configuredInput, {
cwd: options?.sourcePath,
});
const nextSnapshot = createInitialSessionSnapshot(nextBootstrap);

let sessionId = "local-session";
if (hostClient) {
const nextRegistration = updateSessionRegistration(
hostClient.getRegistration(),
nextBootstrap,
);
sessionId = nextRegistration.sessionId;
hostClient.replaceSession(nextRegistration, nextSnapshot);
}

setActiveBootstrap(nextBootstrap);
if (options?.resetApp !== false) {
setAppVersion((current) => current + 1);
}

return {
sessionId,
inputKind: nextBootstrap.input.kind,
title: nextBootstrap.changeset.title,
sourceLabel: nextBootstrap.changeset.sourceLabel,
fileCount: nextBootstrap.changeset.files.length,
selectedFilePath: nextSnapshot.selectedFilePath,
selectedHunkIndex: nextSnapshot.selectedHunkIndex,
};
},
[hostClient],
);

return (
<App
key={appVersion}
bootstrap={activeBootstrap}
hostClient={hostClient}
noticeText={startupNoticeText}
onQuit={onQuit}
onReloadSession={reloadSession}
/>
);
}
92 changes: 92 additions & 0 deletions src/ui/AppHost.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { useCallback, useState } from "react";
import { resolveConfiguredCliInput } from "../core/config";
import { loadAppBootstrap } from "../core/loaders";
import { resolveRuntimeCliInput } from "../core/terminal";
import type { AppBootstrap, CliInput } from "../core/types";
import type { UpdateNotice } from "../core/updateNotice";
import { HunkHostClient } from "../mcp/client";
import {
createInitialSessionSnapshot,
updateSessionRegistration,
} from "../mcp/sessionRegistration";
import { App } from "./App";
import { useStartupUpdateNotice } from "./hooks/useStartupUpdateNotice";

/** Keep one live Hunk app mounted while allowing daemon-driven session reloads. */
export function AppHost({
bootstrap,
hostClient,
onQuit = () => process.exit(0),
startupNoticeResolver,
}: {
bootstrap: AppBootstrap;
hostClient?: HunkHostClient;
onQuit?: () => void;
startupNoticeResolver?: () => Promise<UpdateNotice | null>;
}) {
const [activeBootstrap, setActiveBootstrap] = useState(bootstrap);
const [appVersion, setAppVersion] = useState(0);
const startupNoticeText = useStartupUpdateNotice({
enabled: !bootstrap.input.options.pager,
resolver: startupNoticeResolver,
});

const reloadSession = useCallback(
async (nextInput: CliInput, options?: { resetApp?: boolean; sourcePath?: string }) => {
// Re-run the same startup normalization pipeline used on first launch so reloads honor
// runtime defaults and config layering instead of assuming `nextInput` is already final.
// `sourcePath` matters for daemon-driven reloads that ask Hunk to reopen content from a
// different working directory than the process originally started in.
const runtimeInput = resolveRuntimeCliInput(nextInput);
const configuredInput = resolveConfiguredCliInput(runtimeInput, {
cwd: options?.sourcePath,
}).input;
const nextBootstrap = await loadAppBootstrap(configuredInput, {
cwd: options?.sourcePath,
});
const nextSnapshot = createInitialSessionSnapshot(nextBootstrap);

let sessionId = "local-session";
if (hostClient) {
// Keep the daemon-facing session registration in sync with whatever the UI is about to
// show. Replacing both registration and snapshot here means external session commands see
// the new source, title, and selection baseline immediately after reload.
const nextRegistration = updateSessionRegistration(
hostClient.getRegistration(),
nextBootstrap,
);
sessionId = nextRegistration.sessionId;
hostClient.replaceSession(nextRegistration, nextSnapshot);
}

setActiveBootstrap(nextBootstrap);
if (options?.resetApp !== false) {
// Bumping the key forces a full App remount. Callers that pass `resetApp: false` get a
// soft reload that preserves in-memory UI state like selection, filter text, and pane size.
setAppVersion((current) => current + 1);
}

return {
sessionId,
inputKind: nextBootstrap.input.kind,
title: nextBootstrap.changeset.title,
sourceLabel: nextBootstrap.changeset.sourceLabel,
fileCount: nextBootstrap.changeset.files.length,
selectedFilePath: nextSnapshot.selectedFilePath,
selectedHunkIndex: nextSnapshot.selectedHunkIndex,
};
},
[hostClient],
);

return (
<App
key={appVersion}
bootstrap={activeBootstrap}
hostClient={hostClient}
noticeText={startupNoticeText}
onQuit={onQuit}
onReloadSession={reloadSession}
/>
);
}
2 changes: 1 addition & 1 deletion test/app-interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { HunkSessionRegistration, SessionServerMessage } from "../src/mcp/t
import type { AppBootstrap, DiffFile, LayoutMode } from "../src/core/types";

const { loadAppBootstrap } = await import("../src/core/loaders");
const { AppHost } = await import("../src/ui/App");
const { AppHost } = await import("../src/ui/AppHost");

function createDiffFile(
id: string,
Expand Down
2 changes: 1 addition & 1 deletion test/app-responsive.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { parseDiffFromFile } from "@pierre/diffs";
import { act } from "react";
import type { AppBootstrap, DiffFile, LayoutMode } from "../src/core/types";

const { AppHost } = await import("../src/ui/App");
const { AppHost } = await import("../src/ui/AppHost");

function createDiffFile(
id: string,
Expand Down
2 changes: 1 addition & 1 deletion test/ui-components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { act, createRef, type ReactNode } from "react";
import type { AppBootstrap, DiffFile } from "../src/core/types";
import { resolveTheme } from "../src/ui/themes";

const { AppHost } = await import("../src/ui/App");
const { AppHost } = await import("../src/ui/AppHost");
const { buildSidebarEntries } = await import("../src/ui/lib/files");
const { HelpDialog } = await import("../src/ui/components/chrome/HelpDialog");
const { FilesPane } = await import("../src/ui/components/panes/FilesPane");
Expand Down
2 changes: 1 addition & 1 deletion test/ui-scroll-regression.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { AppBootstrap } from "../src/core/types";

mock.restore();

const { AppHost } = await import("../src/ui/App");
const { AppHost } = await import("../src/ui/AppHost");

function createScrollBootstrap(): AppBootstrap {
const before = Array.from(
Expand Down
2 changes: 1 addition & 1 deletion test/vertical-scrollbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { parseDiffFromFile } from "@pierre/diffs";
import { act } from "react";
import type { AppBootstrap, DiffFile } from "../src/core/types";

const { AppHost } = await import("../src/ui/App");
const { AppHost } = await import("../src/ui/AppHost");

function createDiffFile(id: string, path: string, before: string, after: string): DiffFile {
const metadata = parseDiffFromFile(
Expand Down
Loading