@youtyan/code-viewer 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youtyan/code-viewer",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Local browser-based git diff viewer",
5
5
  "type": "module",
6
6
  "bin": {
package/web/app.js CHANGED
@@ -109,6 +109,21 @@
109
109
  };
110
110
  }
111
111
 
112
+ // web-src/catch-up.ts
113
+ function shouldCatchUpDiff(route) {
114
+ return route.screen !== "repo" && !(route.screen === "file" && route.view === "blob");
115
+ }
116
+ function createCatchUpGate(now, minIntervalMs) {
117
+ let lastForceAt = 0;
118
+ return function shouldRun() {
119
+ const current = now();
120
+ if (current - lastForceAt < minIntervalMs)
121
+ return false;
122
+ lastForceAt = current;
123
+ return true;
124
+ };
125
+ }
126
+
112
127
  // web-src/routes.ts
113
128
  function assertNever(value) {
114
129
  throw new Error("unhandled route: " + JSON.stringify(value));
@@ -2686,7 +2701,7 @@
2686
2701
  syncHeaderMenu();
2687
2702
  }).catch(() => setStatus("error"));
2688
2703
  }
2689
- function load() {
2704
+ function load(options = {}) {
2690
2705
  if (STATE.route.screen === "repo")
2691
2706
  return loadRepo();
2692
2707
  setStatus("refreshing");
@@ -2697,6 +2712,8 @@
2697
2712
  params.set("from", STATE.from);
2698
2713
  if (STATE.to)
2699
2714
  params.set("to", STATE.to);
2715
+ if (options.force)
2716
+ params.set("nocache", "1");
2700
2717
  const url = "/diff.json" + (params.toString() ? "?" + params.toString() : "");
2701
2718
  return trackLoad(fetch(url).then((r) => r.json())).then((data) => {
2702
2719
  renderShell(data);
@@ -3033,24 +3050,30 @@
3033
3050
  }, 350);
3034
3051
  }
3035
3052
  const es = new EventSource("/events");
3053
+ const catchUpGate = createCatchUpGate(() => Date.now(), 1000);
3054
+ let openedOnce = false;
3036
3055
  es.addEventListener("update", () => scheduleSseLoad());
3037
3056
  es.addEventListener("reload", () => location.reload());
3038
3057
  es.addEventListener("error", () => setStatus("error"));
3039
- es.addEventListener("open", () => setStatus("live"));
3040
- let assetVersion = null;
3041
- function pollAssetVersion() {
3042
- fetch("/_asset_version").then((r) => r.ok ? r.json() : null).then((data) => {
3043
- if (!data || !data.version)
3044
- return;
3045
- if (assetVersion == null) {
3046
- assetVersion = data.version;
3047
- return;
3048
- }
3049
- if (data.version !== assetVersion)
3050
- location.reload();
3051
- }).catch(() => {});
3058
+ es.addEventListener("open", () => {
3059
+ setStatus("live");
3060
+ if (!openedOnce) {
3061
+ openedOnce = true;
3062
+ return;
3063
+ }
3064
+ catchUpDiff();
3065
+ });
3066
+ function catchUpDiff() {
3067
+ if (!shouldCatchUpDiff(STATE.route))
3068
+ return;
3069
+ if (!catchUpGate())
3070
+ return;
3071
+ load({ force: true });
3052
3072
  }
3053
- pollAssetVersion();
3054
- setInterval(pollAssetVersion, 1500);
3073
+ document.addEventListener("visibilitychange", () => {
3074
+ if (!document.hidden)
3075
+ catchUpDiff();
3076
+ });
3077
+ window.addEventListener("focus", catchUpDiff);
3055
3078
  })();
3056
3079
  })();
@@ -0,0 +1,13 @@
1
+ // Short enough that a browser reload self-heals stale git data, while still
2
+ // coalescing bursts from one render pass.
3
+ export const CACHE_TTL_MS = 1500;
4
+
5
+ export type TimedCacheEntry<T> = T & { storedAt: number };
6
+
7
+ export function cacheFresh<T>(
8
+ cached: TimedCacheEntry<T> | undefined,
9
+ now = Date.now(),
10
+ ttlMs = CACHE_TTL_MS,
11
+ ): cached is TimedCacheEntry<T> {
12
+ return !!cached && now - cached.storedAt <= ttlMs;
13
+ }
@@ -0,0 +1,37 @@
1
+ import { basename } from 'node:path';
2
+
3
+ export type WatchFn = (
4
+ path: string,
5
+ options: { persistent?: boolean },
6
+ listener: (eventType: string, filename: string | Buffer | null) => void,
7
+ ) => unknown;
8
+
9
+ type DevAssetReloadOptions = {
10
+ enabled: boolean;
11
+ webRoot: string;
12
+ watchedFiles: readonly string[];
13
+ watch: WatchFn;
14
+ sendReload: () => void;
15
+ setTimeoutFn?: typeof setTimeout;
16
+ clearTimeoutFn?: typeof clearTimeout;
17
+ debounceMs?: number;
18
+ };
19
+
20
+ export function startDevAssetReload(options: DevAssetReloadOptions): boolean {
21
+ if (!options.enabled) return false;
22
+ const watched = new Set(options.watchedFiles);
23
+ const setTimer = options.setTimeoutFn || setTimeout;
24
+ const clearTimer = options.clearTimeoutFn || clearTimeout;
25
+ const debounceMs = options.debounceMs ?? 150;
26
+ let timer: ReturnType<typeof setTimeout> | null = null;
27
+
28
+ options.watch(options.webRoot, { persistent: false }, (_event, filename) => {
29
+ if (!filename || !watched.has(basename(filename.toString()))) return;
30
+ if (timer) clearTimer(timer);
31
+ timer = setTimer(() => {
32
+ timer = null;
33
+ options.sendReload();
34
+ }, debounceMs);
35
+ });
36
+ return true;
37
+ }
@@ -56,7 +56,12 @@ function startServer() {
56
56
  firstStart = false;
57
57
  server = Bun.spawn([
58
58
  'bun', 'run', 'web-src/server/preview.ts', ...args,
59
- ], { cwd: ROOT, stdout: 'inherit', stderr: 'inherit' }) as ChildProcess;
59
+ ], {
60
+ cwd: ROOT,
61
+ stdout: 'inherit',
62
+ stderr: 'inherit',
63
+ env: { ...process.env, CODE_VIEWER_DEV: '1' },
64
+ }) as ChildProcess;
60
65
  }
61
66
 
62
67
  async function restartServer() {
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
3
+ import { existsSync, readFileSync, realpathSync, statSync, watch } from 'node:fs';
4
4
  import { basename, extname, join, normalize, relative } from 'node:path';
5
5
  import { APP_ENTRY_PATHS, SPA_PATHS } from '../routes';
6
6
  import type { DiffMeta, FileDiffResponse, FileMeta, FileRangeResponse, RepoTreeResponse } from '../types';
7
+ import { cacheFresh, type TimedCacheEntry } from './cache';
8
+ import { startDevAssetReload } from './dev-assets';
7
9
  import * as git from './git';
8
10
  import { isSameWorktreeRange } from './range';
9
11
 
@@ -24,8 +26,8 @@ let listenPort = 0;
24
26
 
25
27
  const enc = new TextEncoder();
26
28
  const sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
27
- const fileCache = new Map<string, string>();
28
- const metaCache = new Map<string, { body: string; sig: string }>();
29
+ const fileCache = new Map<string, TimedCacheEntry<{ diffText: string }>>();
30
+ const metaCache = new Map<string, TimedCacheEntry<{ body: string; sig: string }>>();
29
31
 
30
32
  function parseCli() {
31
33
  const rest: string[] = [];
@@ -242,14 +244,14 @@ function handleDiffJson(url: URL) {
242
244
  fileCache.clear();
243
245
  }
244
246
  const body = JSON.stringify(payload);
245
- metaCache.set(key, { body, sig });
247
+ metaCache.set(key, { body, sig, storedAt: Date.now() });
246
248
  return new Response(body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
247
249
  }
248
250
  const cached = metaCache.get(key);
249
- if (cached) return new Response(cached.body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
251
+ if (cacheFresh(cached)) return new Response(cached.body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
250
252
  const payload = computePayload(extras, range);
251
253
  const body = JSON.stringify(payload);
252
- metaCache.set(key, { body, sig: JSON.stringify({ ...payload, generation: undefined }) });
254
+ metaCache.set(key, { body, sig: JSON.stringify({ ...payload, generation: undefined }), storedAt: Date.now() });
253
255
  return new Response(body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
254
256
  }
255
257
 
@@ -343,9 +345,12 @@ function handleFileDiff(url: URL) {
343
345
  const cacheKey = isUntracked
344
346
  ? `u\0${path}\0${extras.join('\0')}`
345
347
  : `t\0${path}\0${oldPath || ''}\0${[...extras, ...args].join('\0')}`;
346
- let diffText = fileCache.get(cacheKey);
348
+ const cached = fileCache.get(cacheKey);
349
+ let diffText: string;
347
350
  let errText = '';
348
- if (!diffText) {
351
+ if (cacheFresh(cached)) {
352
+ diffText = cached.diffText;
353
+ } else {
349
354
  if (isUntracked) {
350
355
  diffText = git.untrackedFileDiff(extras, path, cwd).stdout || '';
351
356
  } else {
@@ -353,7 +358,7 @@ function handleFileDiff(url: URL) {
353
358
  diffText = res.stdout || '';
354
359
  if (res.code !== 0) errText = res.stderr;
355
360
  }
356
- fileCache.set(cacheKey, diffText);
361
+ fileCache.set(cacheKey, { diffText, storedAt: Date.now() });
357
362
  }
358
363
  const mode = url.searchParams.get('mode') || 'full';
359
364
  const truncated = mode === 'preview'
@@ -454,10 +459,6 @@ const server = Bun.serve({
454
459
  if (url.pathname === '/file_range') return handleFileRange(url);
455
460
  if (url.pathname === '/_file') return handleRawFile(url);
456
461
  if (url.pathname === '/_refs') return json(git.refs(cwd));
457
- if (url.pathname === '/_asset_version') {
458
- const version = Math.max(...WATCHED_ASSET_FILES.map((name) => statSync(join(WEB_ROOT, name)).mtimeMs));
459
- return json({ version });
460
- }
461
462
  if (url.pathname === '/refresh' && req.method === 'POST') {
462
463
  generation++;
463
464
  fileCache.clear();
@@ -498,5 +499,13 @@ const server = Bun.serve({
498
499
  },
499
500
  });
500
501
 
502
+ startDevAssetReload({
503
+ enabled: process.env.CODE_VIEWER_DEV === '1',
504
+ webRoot: WEB_ROOT,
505
+ watchedFiles: WATCHED_ASSET_FILES,
506
+ watch,
507
+ sendReload: () => sendSse('reload'),
508
+ });
509
+
501
510
  console.log(`GDP_LISTEN_URL=http://127.0.0.1:${server.port}/`);
502
511
  console.log(`git-diff-preview serving ${cwd}`);
@@ -17,6 +17,7 @@ declare const Bun: {
17
17
 
18
18
  declare const process: {
19
19
  argv: string[];
20
+ env: Record<string, string | undefined>;
20
21
  cwd(): string;
21
22
  platform: 'darwin' | 'win32' | string;
22
23
  on(event: 'SIGINT' | 'SIGTERM', listener: () => void): void;
@@ -34,6 +35,11 @@ declare module 'node:fs' {
34
35
  export function readFileSync(path: string, encoding: BufferEncoding): string;
35
36
  export function realpathSync(path: string): string;
36
37
  export function statSync(path: string): { mtimeMs: number };
38
+ export function watch(
39
+ path: string,
40
+ options: { persistent?: boolean },
41
+ listener: (eventType: string, filename: string | Buffer | null) => void,
42
+ ): unknown;
37
43
  }
38
44
 
39
45
  declare module 'node:path' {
package/web-src/types.ts CHANGED
@@ -86,10 +86,6 @@ export type RefResponse = {
86
86
  current?: string;
87
87
  };
88
88
 
89
- export type AssetVersionResponse = {
90
- version?: number;
91
- };
92
-
93
89
  declare global {
94
90
  interface Window {
95
91
  Diff2HtmlUI: any;