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
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ members = [".", "plugins/nightcrow-recovery", "plugins/nightcrow-memory"]
# was never built at — a class of drift the release check could only report
# after the fact.
[workspace.package]
version = "0.1.15"
version = "0.1.16"

[package]
name = "nightcrow"
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ request body 상한은 route마다 다르다. 대부분은 작은 control payloa

## Browser state and frontend

`viewer.json`에 session accent·`upper_pct`와 project별 last view/maximize를 저장한다. sidebar width는 픽셀값이라 화면 하나의 사실이므로 서버에 두지 않고 브라우저 `localStorage`가 기기별로 가진다. active repo는 absolute worktree path로 저장하고 응답에서 opaque id로 변환한다. 값은 서버·client 양쪽에서 clamp하며 view path/oid/tab/face는 저장·복원 경계에서 sanitize한다. TUI의 `workspace.json`과 viewer preference는 별도 소유다.
`viewer.json`에 session accent·`upper_pct`와 project별 last view/maximize를 저장한다. sidebar width는 픽셀값이라 화면 하나의 사실이므로 서버에 두지 않고 브라우저 `localStorage`가 기기별로 가진다. 좁은 화면의 Repo/Content/Terminal 선택도 브라우저 `localStorage`에 저장해 새로고침 뒤 복원하며, 공유 저장소 view 상태에는 넣지 않는다. active repo는 absolute worktree path로 저장하고 응답에서 opaque id로 변환한다. 값은 서버·client 양쪽에서 clamp하며 view path/oid/tab/face는 저장·복원 경계에서 sanitize한다. TUI의 `workspace.json`과 viewer preference는 별도 소유다.

키보드는 `document` capture 단계의 결정점 하나만 둔다. 두 listener는 키가 소비되었는지에 합의할 수 없어, 지는 쪽이 pane에 필요한 키를 먹거나 앱 명령을 escape sequence로 흘린다. 명령은 물리 키가 아니라 semantic action id로 registry에 두고 keyboard·help·버튼이 같은 표를 읽는다. TUI의 Rust key table을 복제하지 않고, 브라우저가 의미를 유지·재해석·미지원하는지를 registry가 기록한다. terminal panel의 명령은 page 아래에 있으므로 panel이 intent bus에 등록하며, 그 등록 여부가 availability의 단일 근거다. TUI hint bar에 대응하는 web hint line은 같은 registry와 availability에서 순수 함수로 만들어지며, leader의 armed 상태는 keystroke가 읽는 ref의 mirror로만 렌더한다. leader 선호는 client-local per-browser 값이므로 `viewer.json`이나 session이 아니라 browser storage에 둔다. React 화면은 page 조립, reusable components, hooks, pure `lib`, API/wire 모듈을 분리한다. terminal WebSocket decode/encode는 한 경계에서 discriminated union으로 검증한다. 큰 diff/raw file은 viewport와 overscan만 DOM에 두고, 작은 파일은 native selection·find·accessibility를 보존한다. ErrorBoundary는 lazy chunk 실패가 전체 page unmount로 보이지 않게 하며, server build id와 content-hashed bundle을 비교해 stale page를 reload시킨다. DOM hook 테스트는 happy-dom, pure utility는 node 환경에서 실행한다. 빌드된 `viewer-ui/dist`는 runtime에 포함되므로 Node 없는 `cargo install`도 동작해야 한다.

Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion viewer-ui/dist/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions viewer-ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion viewer-ui/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "nightcrow-viewer-ui",
"private": true,
"version": "0.1.15",
"version": "0.1.16",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
5 changes: 3 additions & 2 deletions viewer-ui/src/hooks/git/useRepoPaneActions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMemo, useRef, useState } from "react";
import type { Status } from "../../api";
import type { MobileView, Pane } from "../../types";
import type { Pane } from "../../types";
import { useMobileView } from "../ui/mobileView";
import type { CommitDrillDown } from "./useLog";
import { usePaneOpeners } from "./usePaneOpeners";

Expand All @@ -24,7 +25,7 @@ export function useRepoPaneActions({
setCommitDrillDown,
status,
}: RepoPaneActionsArgs) {
const [mobileView, setMobileView] = useState<MobileView>("files");
const { mobileView, setMobileView } = useMobileView();
const [previewRendered, setPreviewRendered] = useState(true);
const statusRef = useRef(status);
statusRef.current = status;
Expand Down
57 changes: 52 additions & 5 deletions viewer-ui/src/hooks/git/useRepoWorkspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,38 @@
// somebody switches lists with a diff open.

import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useRepoWorkspace } from "./useRepoWorkspace";
import type { ShellLayout } from "../ui/useShellLayout";
import type { Diff } from "../../api";
import { api, type Diff, type RepoView } from "../../api";
import type { Maximized, Pane } from "../../types";
import { stubLocalStorage } from "../../lib/shared/fakeStorage";

vi.mock("../../api", () => ({
api: {
log: vi.fn(() => Promise.resolve({ commits: [], truncated: false })),
setRepoView: vi.fn(() => Promise.resolve({})),
diff: vi.fn(() => new Promise(() => {})),
file: vi.fn(() => new Promise(() => {})),
},
subscribeStatus: vi.fn(() => () => {}),
isUnauthorized: () => false,
}));

afterEach(cleanup);
beforeEach(stubLocalStorage);

afterEach(() => {
cleanup();
localStorage.clear();
});

/** A content pane with something in it, so emptying it is observable. */
const OPEN_DIFF: Pane = {
kind: "diff",
value: { path: "a.ts", hunks: [], binary: false } as unknown as Diff,
};

function mount(remember = vi.fn()) {
function mount(remember = vi.fn(), remembered?: RepoView) {
let maximized: Maximized = "none";
return renderHook(() =>
useRepoWorkspace({
Expand All @@ -49,7 +57,7 @@ function mount(remember = vi.fn()) {
},
view: {
known: true,
remembered: undefined,
remembered,
latest: () => undefined,
remember,
},
Expand Down Expand Up @@ -102,4 +110,43 @@ describe("useRepoWorkspace 목록 선택", () => {

expect(result.current.repoShell!.filePane.pane.kind).toBe("diff");
});

it.each(["openDiff", "openFile"] as const)(
"%s로_콘텐츠를_열면_모바일_content_화면을_선택한다",
(action) => {
const { result } = mount();

act(() => result.current.repoShell!.sidebar[action]("src/main.ts"));

expect(result.current.repoShell!.layout.mobileView).toBe("diff");
},
);

it("터미널_화면은_재마운트와_기억된_파일_복원_뒤에도_유지된다", async () => {
const remembered: RepoView = {
tab: "status",
file: { path: "src/main.ts", commit: null, face: "source" },
tree_expanded: [],
};
vi.mocked(api.file).mockResolvedValue({
path: "src/main.ts",
lines: [],
truncated: false,
});

const first = mount(vi.fn(), remembered);
await act(async () => {
await Promise.resolve();
});
act(() => first.result.current.repoShell!.layout.setMobileView("terminal"));
first.unmount();

const refreshed = mount(vi.fn(), remembered);
await act(async () => {
await Promise.resolve();
});

expect(refreshed.result.current.repoShell!.filePane.pane.kind).toBe("file");
expect(refreshed.result.current.repoShell!.layout.mobileView).toBe("terminal");
});
});
77 changes: 77 additions & 0 deletions viewer-ui/src/hooks/ui/mobileView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// @vitest-environment happy-dom

import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { stubLocalStorage } from "../../lib/shared/fakeStorage";
import { useMobileView } from "./mobileView";

const STORAGE_KEY = "nightcrow.mobileView";

beforeEach(stubLocalStorage);

afterEach(() => {
cleanup();
stubLocalStorage();
localStorage.clear();
});

describe("useMobileView", () => {
it.each(["files", "diff", "terminal"] as const)(
"%s 화면을 새로 마운트해도 복원한다",
(view) => {
const first = renderHook(() => useMobileView());
act(() => first.result.current.setMobileView(view));
expect(first.result.current.mobileView).toBe(view);
first.unmount();

const refreshed = renderHook(() => useMobileView());

expect(refreshed.result.current.mobileView).toBe(view);
expect(localStorage.getItem(STORAGE_KEY)).toBe(view);
},
);

it("허용되지_않은_저장값은_files로_대체한다", () => {
localStorage.setItem(STORAGE_KEY, "content");

const { result } = renderHook(() => useMobileView());

expect(result.current.mobileView).toBe("files");
});

it("저장소_접근이_실패해도_현재_페이지의_탐색은_동작한다", () => {
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
get() {
throw new Error("storage disabled");
},
});

const { result } = renderHook(() => useMobileView());

expect(result.current.mobileView).toBe("files");
expect(() =>
act(() => result.current.setMobileView("terminal")),
).not.toThrow();
expect(result.current.mobileView).toBe("terminal");
});

it("저장만_실패해도_화면_선택은_즉시_바뀐다", () => {
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: () => "files",
setItem: () => {
throw new Error("storage quota exceeded");
},
},
});

const { result } = renderHook(() => useMobileView());

expect(() =>
act(() => result.current.setMobileView("diff")),
).not.toThrow();
expect(result.current.mobileView).toBe("diff");
});
});
33 changes: 33 additions & 0 deletions viewer-ui/src/hooks/ui/mobileView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// The narrow-screen section is a browser preference: a refresh should return
// to the screen the person was using without adding it to shared repo state.

import { useCallback, useState } from "react";
import type { MobileView } from "../../types";

const STORAGE_KEY = "nightcrow.mobileView";

function parse(raw: string | null): MobileView {
return raw === "diff" || raw === "terminal" || raw === "files" ? raw : "files";
}

function load(): MobileView {
try {
return parse(localStorage.getItem(STORAGE_KEY));
} catch {
return "files";
}
}

export function useMobileView() {
const [mobileView, setView] = useState<MobileView>(load);
const setMobileView = useCallback((view: MobileView) => {
try {
localStorage.setItem(STORAGE_KEY, view);
} catch {
// Storage is optional; navigation remains usable for this page lifetime.
}
setView(view);
}, []);

return { mobileView, setMobileView };
}
Loading