@youtyan/code-viewer 0.2.11 → 0.3.0

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/README.md CHANGED
@@ -14,6 +14,12 @@ Requires Node.js 20 or newer when installed from npm. Development uses
14
14
  pills" that copy `@path#start-end` for AI agents.
15
15
  - Browse commit history per branch and open any commit's changed files and
16
16
  diff, with shareable `/history?ref=<branch>&commit=<sha>` links.
17
+ - Open per-file Blame and History tabs on a file detail page (GitHub-style):
18
+ Blame groups consecutive lines from the same commit with an Older→Newer
19
+ colour bar and lets you jump to the originating commit; History embeds the
20
+ same commit list and diff renderer used by `/history` inside the file's
21
+ tab shell, filtered to that path. Both tabs keep the Repository sidebar
22
+ visible.
17
23
  - Open files directly from the repository or diff view, including large
18
24
  generated files (virtualized source viewer with copy/open-full-view).
19
25
  - Preview Markdown with a table of contents, task lists, Mermaid diagrams
@@ -111,6 +117,15 @@ resolved inside the repository, code blocks are highlighted with Shiki, and
111
117
  Mermaid diagrams are rendered lazily in the browser (click any diagram to
112
118
  open it in a lightbox).
113
119
 
120
+ A file detail page lays out four tabs — **Preview**, **Code**, **Blame**,
121
+ **History** — modelled after the GitHub file view. `Code` is the default and
122
+ `?preview=1` opts in to the Markdown / media preview. `Blame` and `History`
123
+ each have their own canonical URL (`view=blame`, `view=history`), so deep
124
+ links and the browser back/forward stay in sync. The Blame tab reuses the
125
+ source view's row component, so line numbers, drag-selection of `line=`
126
+ ranges, syntax highlighting and the Viewer Settings code font size all match
127
+ the Code tab.
128
+
114
129
  Very large text files use a virtualized source viewer. Only visible rows are
115
130
  rendered, and the page includes controls to copy the full file or reopen it in
116
131
  the full non-virtual view.
@@ -227,7 +242,9 @@ Open Datastores in the global navigation to access:
227
242
  whether each entry came from the browser or the CLI.
228
243
  - **ER diagram** — auto-generated entity-relationship diagram showing
229
244
  foreign-key relationships between tables.
230
- - **Schema view** — table columns, indexes, foreign keys, triggers, and DDL.
245
+ - **Schema view** — table columns (with column comments when the database
246
+ defines them), indexes, foreign keys, triggers, and DDL. Tables themselves
247
+ surface a comment column on the database table list.
231
248
  - **Global search** — full-text search across all tables and text columns of
232
249
  a database.
233
250
  - **Snapshots and diffs** — take point-in-time snapshots of selected tables
@@ -759,6 +759,16 @@ import {
759
759
  statSync
760
760
  } from "node:fs";
761
761
  import { join as join2 } from "node:path";
762
+ function normalizeBlameRef(ref, base) {
763
+ const rawRef = ref || "worktree";
764
+ if (base === "worktree" && rawRef !== "worktree") {
765
+ return { base: "HEAD", ref: rawRef };
766
+ }
767
+ if (base === "HEAD" && rawRef === "worktree") {
768
+ return { base: "HEAD", ref: "HEAD" };
769
+ }
770
+ return { base, ref: rawRef };
771
+ }
762
772
  function run(args, cwd) {
763
773
  return runSync(args, cwd);
764
774
  }
@@ -1063,6 +1073,13 @@ function commitHistory(cwd, options) {
1063
1073
  const skip = Math.max(0, Math.floor(options.skip) || 0);
1064
1074
  const limit = Math.max(1, Math.min(Math.floor(options.limit) || 1, MAX_HISTORY_LIMIT));
1065
1075
  const { filterArgs, pathspec, shaTerm } = historyQueryArgs(options.query || "");
1076
+ const pathFilter = (options.path || "").trim();
1077
+ const pathArgs = [];
1078
+ if (pathFilter && !pathFilter.includes("\x00") && !pathFilter.startsWith("-")) {
1079
+ if (!pathFilter.endsWith("/"))
1080
+ pathArgs.push("--follow");
1081
+ pathArgs.push("--", pathFilter);
1082
+ }
1066
1083
  const res = run([
1067
1084
  "git",
1068
1085
  "log",
@@ -1072,7 +1089,8 @@ function commitHistory(cwd, options) {
1072
1089
  `--format=${HISTORY_FORMAT}`,
1073
1090
  ...filterArgs,
1074
1091
  verified.stdout.trim(),
1075
- ...pathspec
1092
+ ...pathspec,
1093
+ ...pathArgs
1076
1094
  ], cwd);
1077
1095
  if (res.code !== 0)
1078
1096
  return { commits: [], hasMore: false, error: "git log failed" };
@@ -1173,6 +1191,130 @@ function numstatZ(args, cwd) {
1173
1191
  function isToolInternalPath(path) {
1174
1192
  return path.split(/[\\/]+/).some((part) => part.toLowerCase() === ".code-viewer");
1175
1193
  }
1194
+ function syntheticUncommittedBlameFromWorktree(cwd, path) {
1195
+ const filePath = join2(cwd, path);
1196
+ try {
1197
+ const stat = statSync(filePath);
1198
+ if (!stat.isFile())
1199
+ return { lines: [], commits: {}, error: "not a file" };
1200
+ const text = readFileSync(filePath, "utf8");
1201
+ const normalized = text.replace(/\r\n/g, `
1202
+ `).replace(/\r/g, `
1203
+ `);
1204
+ const lineCount = normalized.length ? normalized.endsWith(`
1205
+ `) ? normalized.length - 1 === 0 ? 1 : normalized.split(`
1206
+ `).length - 1 : normalized.split(`
1207
+ `).length : 1;
1208
+ const lines = [];
1209
+ for (let i = 1;i <= lineCount; i++) {
1210
+ lines.push({ lineNo: i, sha: BLAME_ZERO_SHA, isUncommitted: true });
1211
+ }
1212
+ return {
1213
+ lines,
1214
+ commits: {
1215
+ [BLAME_ZERO_SHA]: {
1216
+ sha: BLAME_ZERO_SHA,
1217
+ author: "Not Committed Yet",
1218
+ authorMail: "",
1219
+ authorTime: 0,
1220
+ summary: "Working tree",
1221
+ isUncommitted: true
1222
+ }
1223
+ },
1224
+ isUntracked: true,
1225
+ isSynthetic: true
1226
+ };
1227
+ } catch (err) {
1228
+ if (typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT") {
1229
+ return { lines: [], commits: {}, error: "file not found" };
1230
+ }
1231
+ return { lines: [], commits: {}, error: "file not readable" };
1232
+ }
1233
+ }
1234
+ function blame(cwd, options) {
1235
+ const path = options.path;
1236
+ if (!path || path.includes("\x00") || path.startsWith("-")) {
1237
+ return { lines: [], commits: {}, error: "invalid path" };
1238
+ }
1239
+ const normalized = normalizeBlameRef(options.ref, options.base);
1240
+ const args = ["git", "blame", "--porcelain"];
1241
+ if (normalized.base === "HEAD") {
1242
+ if (normalized.ref.startsWith("-") || normalized.ref.includes("\x00"))
1243
+ return { lines: [], commits: {}, error: "invalid ref" };
1244
+ args.push(normalized.ref);
1245
+ }
1246
+ args.push("--", path);
1247
+ const res = run(args, cwd);
1248
+ if (res.code !== 0) {
1249
+ if (normalized.base === "worktree") {
1250
+ return syntheticUncommittedBlameFromWorktree(cwd, path);
1251
+ }
1252
+ return {
1253
+ lines: [],
1254
+ commits: {},
1255
+ error: res.stderr.trim() || "blame failed"
1256
+ };
1257
+ }
1258
+ const lines = [];
1259
+ const commits = {};
1260
+ const rawLines = res.stdout.split(`
1261
+ `);
1262
+ let i = 0;
1263
+ while (i < rawLines.length) {
1264
+ const headerLine = rawLines[i];
1265
+ if (!headerLine) {
1266
+ i++;
1267
+ continue;
1268
+ }
1269
+ const headerMatch = /^([0-9a-f]{40}) (\d+) (\d+)(?: (\d+))?$/.exec(headerLine);
1270
+ if (!headerMatch) {
1271
+ i++;
1272
+ continue;
1273
+ }
1274
+ const sha = headerMatch[1];
1275
+ const finalLine = Number(headerMatch[3]);
1276
+ i++;
1277
+ let commit = commits[sha];
1278
+ if (!commit) {
1279
+ commit = {
1280
+ sha,
1281
+ author: "",
1282
+ authorMail: "",
1283
+ authorTime: 0,
1284
+ summary: "",
1285
+ isUncommitted: sha === BLAME_ZERO_SHA
1286
+ };
1287
+ commits[sha] = commit;
1288
+ }
1289
+ while (i < rawLines.length && !rawLines[i].startsWith("\t")) {
1290
+ const metaLine = rawLines[i++];
1291
+ if (!metaLine)
1292
+ continue;
1293
+ const sp = metaLine.indexOf(" ");
1294
+ const key = sp >= 0 ? metaLine.slice(0, sp) : metaLine;
1295
+ const val = sp >= 0 ? metaLine.slice(sp + 1) : "";
1296
+ if (key === "author" && !commit.author)
1297
+ commit.author = val;
1298
+ else if (key === "author-mail" && !commit.authorMail)
1299
+ commit.authorMail = val.replace(/^</, "").replace(/>$/, "");
1300
+ else if (key === "author-time" && !commit.authorTime)
1301
+ commit.authorTime = Number(val) || 0;
1302
+ else if (key === "summary" && !commit.summary)
1303
+ commit.summary = val;
1304
+ }
1305
+ if (i < rawLines.length && rawLines[i].startsWith("\t"))
1306
+ i++;
1307
+ if (Number.isFinite(finalLine) && finalLine > 0) {
1308
+ lines.push({
1309
+ lineNo: finalLine,
1310
+ sha,
1311
+ isUncommitted: sha === BLAME_ZERO_SHA
1312
+ });
1313
+ }
1314
+ }
1315
+ lines.sort((a, b) => a.lineNo - b.lineNo);
1316
+ return { lines, commits };
1317
+ }
1176
1318
  function untracked(cwd, path = "") {
1177
1319
  const args = ["git", "ls-files", "--others", "--exclude-standard"];
1178
1320
  if (path)
@@ -1509,7 +1651,7 @@ function truncateToNHunks(diffText, n, maxLines = Number.POSITIVE_INFINITY) {
1509
1651
  lineTruncated
1510
1652
  };
1511
1653
  }
1512
- var WORKTREE_RECURSIVE_DEPTH_LIMIT = 32, WORKTREE_RECURSIVE_ENTRY_LIMIT = 50000, DEFAULT_REF_COMMIT_LIMIT = 100, MAX_REF_COMMIT_LIMIT = 500, COMMIT_FORMAT = "%H%x00%s%x00%an%x00%aI", DEFAULT_WORKTREE_OMIT_DIR_NAMES, HISTORY_FORMAT = "%H%x00%s%x00%an%x00%aI%x00%P%x00%b", MAX_HISTORY_LIMIT = 200;
1654
+ var BLAME_ZERO_SHA = "0000000000000000000000000000000000000000", WORKTREE_RECURSIVE_DEPTH_LIMIT = 32, WORKTREE_RECURSIVE_ENTRY_LIMIT = 50000, DEFAULT_REF_COMMIT_LIMIT = 100, MAX_REF_COMMIT_LIMIT = 500, COMMIT_FORMAT = "%H%x00%s%x00%an%x00%aI", DEFAULT_WORKTREE_OMIT_DIR_NAMES, HISTORY_FORMAT = "%H%x00%s%x00%an%x00%aI%x00%P%x00%b", MAX_HISTORY_LIMIT = 200;
1513
1655
  var init_git = __esm(() => {
1514
1656
  init_runtime();
1515
1657
  DEFAULT_WORKTREE_OMIT_DIR_NAMES = [
@@ -4016,12 +4158,41 @@ function sanitizeDbUiPrefs(raw) {
4016
4158
  }
4017
4159
  return Object.keys(out).length > 0 ? out : undefined;
4018
4160
  }
4161
+ function sanitizeDbUiExpandedTables(raw) {
4162
+ if (!isRecord(raw))
4163
+ return;
4164
+ const out = {};
4165
+ let scopeCount = 0;
4166
+ for (const [scopeRaw, tablesRaw] of Object.entries(raw)) {
4167
+ if (scopeCount >= MAX_DB_UI_EXPANDED_SCOPES)
4168
+ break;
4169
+ const scope = safeObjectKey(scopeRaw);
4170
+ if (!scope)
4171
+ continue;
4172
+ const tables = normalizeStringList(tablesRaw, {
4173
+ maxItems: MAX_DB_UI_TABLES,
4174
+ maxLen: MAX_KEY_LEN,
4175
+ sort: true
4176
+ });
4177
+ if (!tables || tables.length === 0)
4178
+ continue;
4179
+ out[scope] = tables;
4180
+ scopeCount++;
4181
+ }
4182
+ return Object.keys(out).length > 0 ? out : undefined;
4183
+ }
4019
4184
  function sanitizeDbUiState(raw) {
4020
4185
  if (!isRecord(raw))
4021
4186
  return emptyDbUiState();
4022
4187
  const prefs = sanitizeDbUiPrefs(raw.prefs);
4188
+ const expandedTables = sanitizeDbUiExpandedTables(raw.expandedTables);
4023
4189
  if (!isRecord(raw.columnWidths)) {
4024
- return prefs ? { ...emptyDbUiState(), prefs } : emptyDbUiState();
4190
+ const out2 = emptyDbUiState();
4191
+ if (expandedTables)
4192
+ out2.expandedTables = expandedTables;
4193
+ if (prefs)
4194
+ out2.prefs = prefs;
4195
+ return out2;
4025
4196
  }
4026
4197
  const columnWidths = {};
4027
4198
  let dbCount = 0;
@@ -4062,6 +4233,8 @@ function sanitizeDbUiState(raw) {
4062
4233
  dbCount++;
4063
4234
  }
4064
4235
  const out = { version: 1, columnWidths };
4236
+ if (expandedTables)
4237
+ out.expandedTables = expandedTables;
4065
4238
  if (prefs)
4066
4239
  out.prefs = prefs;
4067
4240
  return out;
@@ -4079,12 +4252,30 @@ function mergeDbUiPrefs(current, patch) {
4079
4252
  }
4080
4253
  return Object.keys(next).length > 0 ? next : undefined;
4081
4254
  }
4255
+ function mergeDbUiExpandedTables(current, patch) {
4256
+ if (!isRecord(patch))
4257
+ return current;
4258
+ const next = { ...current ?? {} };
4259
+ for (const [scope, tablesRaw] of Object.entries(patch)) {
4260
+ if (tablesRaw === null) {
4261
+ delete next[scope];
4262
+ continue;
4263
+ }
4264
+ next[scope] = tablesRaw;
4265
+ }
4266
+ return sanitizeDbUiExpandedTables(next);
4267
+ }
4082
4268
  function mergeDbUiState(current, patch) {
4083
4269
  if (!isRecord(patch))
4084
4270
  return current;
4085
4271
  const mergedPrefs = "prefs" in patch ? mergeDbUiPrefs(current.prefs, patch.prefs) : current.prefs;
4272
+ const mergedExpandedTables = "expandedTables" in patch ? mergeDbUiExpandedTables(current.expandedTables, patch.expandedTables) : current.expandedTables;
4086
4273
  if (!isRecord(patch.columnWidths)) {
4087
4274
  const merged = { ...current, version: 1 };
4275
+ if (mergedExpandedTables)
4276
+ merged.expandedTables = mergedExpandedTables;
4277
+ else
4278
+ delete merged.expandedTables;
4088
4279
  if (mergedPrefs)
4089
4280
  merged.prefs = mergedPrefs;
4090
4281
  else
@@ -4124,6 +4315,7 @@ function mergeDbUiState(current, patch) {
4124
4315
  ...current,
4125
4316
  ...patch,
4126
4317
  columnWidths,
4318
+ expandedTables: mergedExpandedTables,
4127
4319
  prefs: mergedPrefs,
4128
4320
  version: 1
4129
4321
  });
@@ -4155,7 +4347,7 @@ async function patchDbUiState(root, patch) {
4155
4347
  return { state: next, result: next };
4156
4348
  });
4157
4349
  }
4158
- var CODE_VIEWER_DIR2 = ".code-viewer", SETTINGS_FILE_NAME = "settings.json", VIEW_STATE_FILE_NAME = "view-state.json", DB_UI_FILE_NAME = "db-ui.json", MAX_SETTINGS_BYTES = 200000, MAX_VIEW_STATE_BYTES = 1e6, MAX_DB_UI_BYTES = 1e6, MAX_REF_LEN = 1024, MAX_KEY_LEN = 2048, MAX_VIEW_ITEMS = 20000, MAX_DB_UI_DBS = 200, MAX_DB_UI_TABLES = 500, MAX_DB_UI_COLUMNS = 1000, DB_UI_BOOL_PREF_KEYS, settingsStore, viewStateStore, dbUiStore;
4350
+ var CODE_VIEWER_DIR2 = ".code-viewer", SETTINGS_FILE_NAME = "settings.json", VIEW_STATE_FILE_NAME = "view-state.json", DB_UI_FILE_NAME = "db-ui.json", MAX_SETTINGS_BYTES = 200000, MAX_VIEW_STATE_BYTES = 1e6, MAX_DB_UI_BYTES = 1e6, MAX_REF_LEN = 1024, MAX_KEY_LEN = 2048, MAX_VIEW_ITEMS = 20000, MAX_DB_UI_DBS = 200, MAX_DB_UI_TABLES = 500, MAX_DB_UI_COLUMNS = 1000, MAX_DB_UI_EXPANDED_SCOPES = 500, DB_UI_BOOL_PREF_KEYS, settingsStore, viewStateStore, dbUiStore;
4159
4351
  var init_state_store = __esm(() => {
4160
4352
  init_json_store();
4161
4353
  DB_UI_BOOL_PREF_KEYS = ["s3TooltipEnabled", "inferFkRails"];
@@ -5268,9 +5460,12 @@ function createDockerAdapter(config) {
5268
5460
  const tableLiteral = table.replace(/'/g, "''");
5269
5461
  if (config.kind === "postgresql") {
5270
5462
  const schemaLiteral = postgresSchemaLiteral();
5271
- return `SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, CASE WHEN pk.column_name IS NULL THEN 'NO' ELSE 'YES' END FROM information_schema.columns c LEFT JOIN (SELECT kcu.column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema AND tc.table_name = kcu.table_name WHERE tc.table_schema = ${schemaLiteral} AND tc.table_name = '${tableLiteral}' AND tc.constraint_type = 'PRIMARY KEY') pk ON pk.column_name = c.column_name WHERE c.table_schema = ${schemaLiteral} AND c.table_name = '${tableLiteral}' ORDER BY c.ordinal_position`;
5463
+ return `SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, CASE WHEN pk.column_name IS NULL THEN 'NO' ELSE 'YES' END, COALESCE(d.description, '') FROM information_schema.columns c JOIN pg_namespace n ON n.nspname = c.table_schema JOIN pg_class cls ON cls.relnamespace = n.oid AND cls.relname = c.table_name LEFT JOIN pg_attribute a ON a.attrelid = cls.oid AND a.attname = c.column_name AND a.attnum > 0 AND NOT a.attisdropped LEFT JOIN pg_description d ON d.objoid = cls.oid AND d.objsubid = a.attnum LEFT JOIN (SELECT kcu.column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema AND tc.table_name = kcu.table_name WHERE tc.table_schema = ${schemaLiteral} AND tc.table_name = '${tableLiteral}' AND tc.constraint_type = 'PRIMARY KEY') pk ON pk.column_name = c.column_name WHERE c.table_schema = ${schemaLiteral} AND c.table_name = '${tableLiteral}' ORDER BY c.ordinal_position`;
5272
5464
  }
5273
- return `SELECT column_name, column_type, is_nullable, column_default, column_key FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = '${tableLiteral}' ORDER BY ordinal_position`;
5465
+ return `SELECT column_name, column_type, is_nullable, column_default, column_key, column_comment FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = '${tableLiteral}' ORDER BY ordinal_position`;
5466
+ }
5467
+ function columnCommentFromInfoValue(value) {
5468
+ return value ? value : null;
5274
5469
  }
5275
5470
  function columnsFromInfoRows(rows) {
5276
5471
  if (config.kind === "postgresql") {
@@ -5279,7 +5474,8 @@ function createDockerAdapter(config) {
5279
5474
  type: row[1],
5280
5475
  nullable: row[2] === "YES",
5281
5476
  primaryKey: row[4] === "YES",
5282
- defaultValue: row[3] === "" ? null : row[3]
5477
+ defaultValue: row[3] === "" ? null : row[3],
5478
+ comment: columnCommentFromInfoValue(row[5])
5283
5479
  }));
5284
5480
  }
5285
5481
  return rows.map((row) => ({
@@ -5287,7 +5483,8 @@ function createDockerAdapter(config) {
5287
5483
  type: row[1],
5288
5484
  nullable: row[2] === "YES",
5289
5485
  primaryKey: row[4] === "PRI",
5290
- defaultValue: row[3] === "NULL" ? null : row[3]
5486
+ defaultValue: row[3] === "NULL" ? null : row[3],
5487
+ comment: columnCommentFromInfoValue(row[5])
5291
5488
  }));
5292
5489
  }
5293
5490
  async function fetchColumnsAsyncUncached(table, signal) {
@@ -5386,10 +5583,10 @@ function createDockerAdapter(config) {
5386
5583
  let sql;
5387
5584
  if (config.kind === "postgresql") {
5388
5585
  const inList = uncached.map((t) => `'${t.replace(/'/g, "''")}'`).join(",");
5389
- sql = `SELECT table_name, column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_schema = ${postgresSchemaLiteral()} AND table_name IN (${inList}) ORDER BY table_name, ordinal_position`;
5586
+ sql = `SELECT c.table_name, c.column_name, c.data_type, c.is_nullable, c.column_default, COALESCE(d.description, '') FROM information_schema.columns c JOIN pg_namespace n ON n.nspname = c.table_schema JOIN pg_class cls ON cls.relnamespace = n.oid AND cls.relname = c.table_name LEFT JOIN pg_attribute a ON a.attrelid = cls.oid AND a.attname = c.column_name AND a.attnum > 0 AND NOT a.attisdropped LEFT JOIN pg_description d ON d.objoid = cls.oid AND d.objsubid = a.attnum WHERE c.table_schema = ${postgresSchemaLiteral()} AND c.table_name IN (${inList}) ORDER BY c.table_name, c.ordinal_position`;
5390
5587
  } else {
5391
5588
  const inList = uncached.map((t) => `'${t.replace(/'/g, "''")}'`).join(",");
5392
- sql = `SELECT table_name, column_name, column_type, is_nullable, column_default, column_key FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name IN (${inList}) ORDER BY table_name, ordinal_position`;
5589
+ sql = `SELECT table_name, column_name, column_type, is_nullable, column_default, column_key, column_comment FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name IN (${inList}) ORDER BY table_name, ordinal_position`;
5393
5590
  }
5394
5591
  try {
5395
5592
  const queryResult = await execAsync(sql, signal);
@@ -5425,7 +5622,8 @@ function createDockerAdapter(config) {
5425
5622
  type: row[2],
5426
5623
  nullable: row[3] === "YES",
5427
5624
  primaryKey: pkCols.has(row[1]),
5428
- defaultValue: row[4] === "" ? null : row[4]
5625
+ defaultValue: row[4] === "" ? null : row[4],
5626
+ comment: columnCommentFromInfoValue(row[5])
5429
5627
  }));
5430
5628
  } else {
5431
5629
  cols = rows.map((row) => ({
@@ -5433,7 +5631,8 @@ function createDockerAdapter(config) {
5433
5631
  type: row[2],
5434
5632
  nullable: row[3] === "YES",
5435
5633
  primaryKey: row[5] === "PRI",
5436
- defaultValue: row[4] === "NULL" ? null : row[4]
5634
+ defaultValue: row[4] === "NULL" ? null : row[4],
5635
+ comment: columnCommentFromInfoValue(row[6])
5437
5636
  }));
5438
5637
  }
5439
5638
  columnCache.set(tbl, cols);
@@ -5526,12 +5725,7 @@ function createDockerAdapter(config) {
5526
5725
  async getFilteredTablePageWithMeta(table, options, signal) {
5527
5726
  const id = tableIdentifier(table);
5528
5727
  const columnsPromise = tableMetaCache.getColumns(table, () => fetchColumnsAsyncUncached(table, signal));
5529
- let columns;
5530
- try {
5531
- columns = await columnsPromise;
5532
- } catch (err) {
5533
- throw err;
5534
- }
5728
+ const columns = await columnsPromise;
5535
5729
  const columnNames = columns.map((column) => column.name);
5536
5730
  const order = buildOrderClause(filterOrderByColumns(options.orderBy, columnNames), config.kind);
5537
5731
  const where = buildFilterWhere(filterGroupedColumns(options.grouped, columnNames), config.kind, filterExactColumns(options.exact, columnNames)).where;
@@ -11607,7 +11801,7 @@ function fileToMeta(file, range, extraQs) {
11607
11801
  untracked: file.untracked || false
11608
11802
  };
11609
11803
  }
11610
- function computePayload(extras, range) {
11804
+ function computePayload(extras, range, pathFilter = "") {
11611
11805
  if (isSameWorktreeRange(range)) {
11612
11806
  return {
11613
11807
  files: [],
@@ -11623,8 +11817,9 @@ function computePayload(extras, range) {
11623
11817
  const files = fileMeta(fullArgs, cwd, false);
11624
11818
  if (includeUntracked(range, refs2))
11625
11819
  files.push(...untrackedMeta(cwd));
11626
- files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
11627
- files.forEach((file, i) => {
11820
+ const filteredFiles = pathFilter ? files.filter((file) => file.path === pathFilter || file.old_path === pathFilter) : files;
11821
+ filteredFiles.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
11822
+ filteredFiles.forEach((file, i) => {
11628
11823
  file.order = i + 1;
11629
11824
  });
11630
11825
  const extraQs = {};
@@ -11634,7 +11829,7 @@ function computePayload(extras, range) {
11634
11829
  if (e === "--ignore-blank-lines")
11635
11830
  extraQs.ignore_blank = "1";
11636
11831
  }
11637
- const meta = files.map((file) => fileToMeta(file, range, extraQs));
11832
+ const meta = filteredFiles.map((file) => fileToMeta(file, range, extraQs));
11638
11833
  const totals = meta.reduce((acc, file) => {
11639
11834
  acc.additions += file.additions || 0;
11640
11835
  acc.deletions += file.deletions || 0;
@@ -11661,9 +11856,12 @@ function handleDiffJson(url) {
11661
11856
  from: url.searchParams.get("from") || "",
11662
11857
  to: url.searchParams.get("to") || ""
11663
11858
  };
11664
- const key = `${range.from}|${range.to}|${url.searchParams.get("ignore_ws") || ""}|${url.searchParams.get("ignore_blank") || ""}`;
11859
+ const path = url.searchParams.get("path") || "";
11860
+ if (path && !safePath(path))
11861
+ return text("invalid path", 400);
11862
+ const key = `${range.from}|${range.to}|${url.searchParams.get("ignore_ws") || ""}|${url.searchParams.get("ignore_blank") || ""}|${path}`;
11665
11863
  if (url.searchParams.get("nocache") === "1") {
11666
- const payload2 = computePayload(extras, range);
11864
+ const payload2 = computePayload(extras, range, path);
11667
11865
  const sig = JSON.stringify({ ...payload2, generation: undefined });
11668
11866
  const cached2 = metaCache.get(key);
11669
11867
  if (!cached2 || cached2.sig !== sig) {
@@ -11689,7 +11887,7 @@ function handleDiffJson(url) {
11689
11887
  "Cache-Control": "no-store"
11690
11888
  }
11691
11889
  });
11692
- const payload = computePayload(extras, range);
11890
+ const payload = computePayload(extras, range, path);
11693
11891
  const body = JSON.stringify(payload);
11694
11892
  setTimedCacheEntry(metaCache, key, {
11695
11893
  body,
@@ -12106,15 +12304,109 @@ function handleLog(url) {
12106
12304
  const ref = url.searchParams.get("ref") || "HEAD";
12107
12305
  const skip = Number(url.searchParams.get("skip") || "0");
12108
12306
  const limit = Number(url.searchParams.get("limit") || "50");
12307
+ const path = url.searchParams.get("path") || "";
12308
+ if (path && !safePath(path))
12309
+ return text("invalid path", 400);
12109
12310
  const result = commitHistory(cwd, {
12110
12311
  ref,
12111
12312
  skip: Number.isFinite(skip) ? skip : 0,
12112
12313
  limit: Number.isFinite(limit) ? limit : 50,
12113
- query: url.searchParams.get("q") || ""
12314
+ query: url.searchParams.get("q") || "",
12315
+ ...path ? { path } : {}
12114
12316
  });
12115
12317
  if (result.error)
12116
12318
  return text(result.error, 400);
12117
- return json2({ commits: result.commits, hasMore: result.hasMore });
12319
+ const wantsWorktreeHead = path && skip === 0 && (ref === "worktree" || url.searchParams.get("worktree") === "1");
12320
+ let commits = result.commits;
12321
+ let hasWorktree = false;
12322
+ if (wantsWorktreeHead) {
12323
+ const status = runSync([
12324
+ "git",
12325
+ "-c",
12326
+ "core.quotepath=false",
12327
+ "status",
12328
+ "--porcelain=v1",
12329
+ "-z",
12330
+ "--untracked-files=normal",
12331
+ "--",
12332
+ path
12333
+ ], cwd);
12334
+ if (status.code === 0 && status.stdout.length > 0) {
12335
+ const parts = status.stdout.split("\x00").filter(Boolean);
12336
+ if (parts.length > 0) {
12337
+ hasWorktree = true;
12338
+ commits = [
12339
+ {
12340
+ sha: "worktree",
12341
+ subject: "未コミット変更 (Working tree)",
12342
+ author: "",
12343
+ when: "",
12344
+ parents: [],
12345
+ body: ""
12346
+ },
12347
+ ...commits
12348
+ ];
12349
+ }
12350
+ }
12351
+ }
12352
+ return json2({
12353
+ commits,
12354
+ hasMore: result.hasMore,
12355
+ generation,
12356
+ ...hasWorktree ? { hasWorktree: true } : {}
12357
+ });
12358
+ }
12359
+ function blamePathKey(p) {
12360
+ try {
12361
+ const st = statSync3(join13(cwd, p));
12362
+ return `${st.mtimeMs}:${st.size}`;
12363
+ } catch {
12364
+ return "missing";
12365
+ }
12366
+ }
12367
+ function rememberBlame(key, value) {
12368
+ if (blameCache.has(key))
12369
+ blameCache.delete(key);
12370
+ blameCache.set(key, value);
12371
+ while (blameCache.size > BLAME_CACHE_MAX) {
12372
+ const oldest = blameCache.keys().next();
12373
+ if (oldest.done)
12374
+ break;
12375
+ blameCache.delete(oldest.value);
12376
+ }
12377
+ }
12378
+ function handleFileBlame(url) {
12379
+ const path = url.searchParams.get("path") || "";
12380
+ if (!safePath(path))
12381
+ return text("invalid path", 400);
12382
+ const ref = url.searchParams.get("ref") || "worktree";
12383
+ if (!ref || ref.startsWith("-") || ref.includes("\x00"))
12384
+ return text("invalid ref", 400);
12385
+ const rawBase = url.searchParams.get("base");
12386
+ const requestedBase = rawBase === "HEAD" ? "HEAD" : rawBase === "worktree" ? "worktree" : ref === "worktree" ? "worktree" : "HEAD";
12387
+ const normalized = normalizeBlameRef(ref, requestedBase);
12388
+ const { base } = normalized;
12389
+ let cacheKey;
12390
+ if (base === "worktree") {
12391
+ cacheKey = `worktree|${path}|${blamePathKey(path)}`;
12392
+ } else {
12393
+ const resolved = runSync(["git", "rev-parse", "--verify", `${normalized.ref}^{commit}`], cwd);
12394
+ if (resolved.code !== 0)
12395
+ return text("unknown ref", 400);
12396
+ cacheKey = `HEAD|${path}|${resolved.stdout.trim()}`;
12397
+ }
12398
+ const cached = blameCache.get(cacheKey);
12399
+ if (cached) {
12400
+ if (blameCache.has(cacheKey)) {
12401
+ blameCache.delete(cacheKey);
12402
+ blameCache.set(cacheKey, cached);
12403
+ }
12404
+ return json2({ ...cached, base, ref, generation });
12405
+ }
12406
+ const result = blame(cwd, { path, ref: normalized.ref, base });
12407
+ if (!result.error)
12408
+ rememberBlame(cacheKey, result);
12409
+ return json2({ ...result, base, ref, generation });
12118
12410
  }
12119
12411
  function handleFileDiff(url) {
12120
12412
  const path = url.searchParams.get("path") || "";
@@ -13123,7 +13415,7 @@ function restartWorktreeWatch() {
13123
13415
  }
13124
13416
  worktreeWatch = startScopedWorktreeWatch();
13125
13417
  }
13126
- var WEB_ROOT, VERSION, DEFAULT_ARGS, PREVIEW_HUNKS_DEFAULT = 3, PREVIEW_LINES_DEFAULT = 1200, WATCHED_ASSET_FILES, SIZE_SMALL = 2000, SIZE_MEDIUM = 8000, SIZE_LARGE = 20000, LINE_INDEX_MIN_START = 1e4, LINE_INDEX_MAX_FILE_BYTES, BLOB_LINE_CACHE_MAX_BYTES, MAX_UPLOAD_FILE_BYTES, MAX_UPLOAD_TOTAL_BYTES, MAX_UPLOAD_BODY_BYTES, MAX_UPLOAD_FILES = 50, SAFE_UPLOAD_EXTENSIONS, generation = 1, cwd, cliArgs, listenPort = 0, openAfterStart = false, scopeOmitDirNames, scopeOmitDirCliOverride = null, scopeExcludeNames, scopeWatchLimit, uploadEnabled = true, rgAvailableCache = null, enc, sseClients, sseKeepalives, fileCache, metaCache, fileListCache, lineIndexCache, blobLineIndexCache, blobBytesCache, blobLineCacheBytes = 0, isCodeViewerInternalPath, watchLimitReached = null, server, worktreeWatch = null, shuttingDown = false;
13418
+ var WEB_ROOT, VERSION, DEFAULT_ARGS, PREVIEW_HUNKS_DEFAULT = 3, PREVIEW_LINES_DEFAULT = 1200, WATCHED_ASSET_FILES, SIZE_SMALL = 2000, SIZE_MEDIUM = 8000, SIZE_LARGE = 20000, LINE_INDEX_MIN_START = 1e4, LINE_INDEX_MAX_FILE_BYTES, BLOB_LINE_CACHE_MAX_BYTES, MAX_UPLOAD_FILE_BYTES, MAX_UPLOAD_TOTAL_BYTES, MAX_UPLOAD_BODY_BYTES, MAX_UPLOAD_FILES = 50, SAFE_UPLOAD_EXTENSIONS, generation = 1, cwd, cliArgs, listenPort = 0, openAfterStart = false, scopeOmitDirNames, scopeOmitDirCliOverride = null, scopeExcludeNames, scopeWatchLimit, uploadEnabled = true, rgAvailableCache = null, enc, sseClients, sseKeepalives, fileCache, blameCache, BLAME_CACHE_MAX = 64, metaCache, fileListCache, lineIndexCache, blobLineIndexCache, blobBytesCache, blobLineCacheBytes = 0, isCodeViewerInternalPath, watchLimitReached = null, server, worktreeWatch = null, shuttingDown = false;
13127
13419
  var init_preview = __esm(async () => {
13128
13420
  init_routes();
13129
13421
  init_annotations();
@@ -13191,6 +13483,7 @@ var init_preview = __esm(async () => {
13191
13483
  sseClients = new Set;
13192
13484
  sseKeepalives = new Map;
13193
13485
  fileCache = new Map;
13486
+ blameCache = new Map;
13194
13487
  metaCache = new Map;
13195
13488
  fileListCache = new Map;
13196
13489
  lineIndexCache = new Map;
@@ -13223,6 +13516,8 @@ var init_preview = __esm(async () => {
13223
13516
  return handleRefCommits(url);
13224
13517
  if (url.pathname === "/_log")
13225
13518
  return handleLog(url);
13519
+ if (url.pathname === "/_file_blame")
13520
+ return handleFileBlame(url);
13226
13521
  if (url.pathname === "/file_diff")
13227
13522
  return handleFileDiff(url);
13228
13523
  if (url.pathname === "/file_range")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youtyan/code-viewer",
3
- "version": "0.2.11",
3
+ "version": "0.3.0",
4
4
  "description": "Local browser-based code and git diff viewer",
5
5
  "type": "module",
6
6
  "bin": {