@youtyan/code-viewer 0.13.0 → 0.14.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
@@ -454,9 +454,17 @@ Open Datastores in the global navigation to access:
454
454
  row-count changes. Double-click a cell to edit inline when Edit mode is on;
455
455
  the whole pending edit batch commits atomically.
456
456
  - **Detail footer and related panel** — click any cell to open a resizable
457
- detail footer; foreign-key cells open a related panel showing the
457
+ detail footer, where JSON values are pretty-printed and syntax-highlighted;
458
+ foreign-key cells open a related panel showing the
458
459
  referenced or referencing rows, with multi-step drill-down breadcrumbs.
459
- Cells that match the focused row are highlighted in both panels.
460
+ Cells that match the focused row are highlighted in both panels. Once the
461
+ grid has focus, arrow keys move the active cell from data cell to data cell
462
+ and the detail footer follows it, `Enter` follows a foreign key (arrow keys
463
+ alone never fire a related-table query), `Escape` closes whichever panel is
464
+ open, and `Tab` / `Shift+Tab` move between the main grid and the related
465
+ grid. The related panel's reference list keeps each entry on one line with
466
+ the full table name and condition in a tooltip, and its width can be dragged
467
+ and is remembered.
460
468
  - **Footer dock with Query History & Session log** — JetBrains-style bottom
461
469
  dock that hosts two tabs: the per-database **Query History** (master/detail
462
470
  list of saved queries, SSE-synced across tabs) and a **Session log** that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youtyan/code-viewer",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Local browser-based code and git diff viewer",
5
5
  "type": "module",
6
6
  "bin": {
package/web/app.js CHANGED
@@ -875,6 +875,17 @@ Details: ${JSON.stringify(output)}` : "";
875
875
  };
876
876
  }
877
877
 
878
+ // web-src/core/file-refresh.ts
879
+ function rawFileInfoSignature(info) {
880
+ if (info.missing) return "missing";
881
+ if (info.size == null && !info.updated_at && !info.commit_updated_at)
882
+ return null;
883
+ return `${info.size ?? ""}|${info.updated_at ?? ""}|${info.commit_updated_at ?? ""}`;
884
+ }
885
+ function fileSignatureUnchanged(stored, key, sig) {
886
+ return stored !== null && stored.key === key && stored.sig === sig;
887
+ }
888
+
878
889
  // web-src/core/focus-scope.ts
879
890
  function isEditableKeyTarget(target) {
880
891
  if (!target) return false;
@@ -12467,6 +12478,7 @@ ${frontmatter.yaml}
12467
12478
  exportAction: "Export",
12468
12479
  foreignKeyHint: "Foreign key — click to view related rows",
12469
12480
  relatedEmpty: "No matching row in the referenced table",
12481
+ relatedListResize: "Resize the related-reference list",
12470
12482
  filteredEmptyTitle: (count) => `No rows match ${count} active filter${count === 1 ? "" : "s"}`,
12471
12483
  filteredEmptyHint: "The table was loaded, but the current search or column filters hide every row.",
12472
12484
  filteredEmptyAction: "Clear filters",
@@ -12824,6 +12836,7 @@ ${frontmatter.yaml}
12824
12836
  exportAction: "エクスポート",
12825
12837
  foreignKeyHint: "外部キー: クリックして関連データを表示",
12826
12838
  relatedEmpty: "参照先に該当する行がありません",
12839
+ relatedListResize: "関連参照リストの幅を変える",
12827
12840
  filteredEmptyTitle: (count) => `フィルタ ${count} 件に一致する行がありません`,
12828
12841
  filteredEmptyHint: "表は読み込めていますが、現在の検索/列フィルタですべての行が隠れています。",
12829
12842
  filteredEmptyAction: "フィルタ解除",
@@ -20076,17 +20089,29 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20076
20089
 
20077
20090
  // web-src/views/database/table-grid.ts
20078
20091
  var ROW_HEIGHT = 28;
20092
+ var ROWNUM_WIDTH = 50;
20079
20093
  var OVERSCAN = 20;
20080
20094
  var PAGE_SIZE = 200;
20081
20095
  var MAX_PAGE_CACHE_PAGES = 32;
20082
20096
  var FILTER_DEBOUNCE_MS = 300;
20083
20097
  var DEFAULT_COL_WIDTH = 180;
20084
20098
  var CELL_PREVIEW_MAX_CHARS = 4e3;
20099
+ var DETAIL_JSON_HIGHLIGHT_MAX_CHARS = 1e5;
20085
20100
  var RELATED_PANEL_DEFAULT_HEIGHT = 320;
20086
20101
  var RELATED_PANEL_MIN_HEIGHT = 60;
20087
20102
  var DETAIL_PANEL_DEFAULT_HEIGHT = 200;
20088
20103
  var DETAIL_PANEL_MIN_HEIGHT = 40;
20089
20104
  var PANEL_MAX_RESERVE = 20;
20105
+ var RELATED_LIST_DEFAULT_WIDTH = 200;
20106
+ var RELATED_LIST_MIN_WIDTH = 120;
20107
+ var RELATED_LIST_MAX_WIDTH = 480;
20108
+ var RELATED_LIST_WIDTH_KEY = "code-viewer:db-related-list-width";
20109
+ var ARROW_STEP = {
20110
+ ArrowUp: { row: -1, col: 0 },
20111
+ ArrowDown: { row: 1, col: 0 },
20112
+ ArrowLeft: { row: 0, col: -1 },
20113
+ ArrowRight: { row: 0, col: 1 }
20114
+ };
20090
20115
  function createTableGrid(callbacks, options = {}) {
20091
20116
  const embedded = options.embedded === true;
20092
20117
  const text3 = () => callbacks.getText?.() ?? dbText("en");
@@ -20211,6 +20236,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20211
20236
  filterRowWrap.appendChild(filterRow);
20212
20237
  const viewport = document.createElement("div");
20213
20238
  viewport.className = "db-grid-viewport";
20239
+ viewport.tabIndex = 0;
20214
20240
  const spacer = document.createElement("div");
20215
20241
  spacer.className = "db-grid-spacer";
20216
20242
  const body = document.createElement("div");
@@ -20314,6 +20340,156 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20314
20340
  function clearActiveCell() {
20315
20341
  setActiveCell(-1, -1);
20316
20342
  }
20343
+ function syncHorizontalScroll() {
20344
+ headerWrap.scrollLeft = viewport.scrollLeft;
20345
+ filterRowWrap.scrollLeft = viewport.scrollLeft;
20346
+ }
20347
+ function focusCell(rowIndex, row, colIndex) {
20348
+ setSelectedRow(rowIndex, row);
20349
+ setActiveCell(rowIndex, colIndex);
20350
+ viewport.focus({ preventScroll: true });
20351
+ }
20352
+ function moveActiveCell(rowIndex, colIndex) {
20353
+ const rendered = body.children[rowIndex - renderStartRow];
20354
+ focusCell(rowIndex, rendered ?? null, colIndex);
20355
+ scrollCellIntoView(rowIndex, colIndex);
20356
+ showDetailForActiveCell();
20357
+ }
20358
+ function scrollCellIntoView(rowIndex, colIndex) {
20359
+ const viewHeight = viewport.clientHeight;
20360
+ if (viewHeight > 0) {
20361
+ const top = rowIndex * ROW_HEIGHT;
20362
+ const bottom = top + ROW_HEIGHT;
20363
+ if (top < viewport.scrollTop) viewport.scrollTop = top;
20364
+ else if (bottom > viewport.scrollTop + viewHeight) {
20365
+ viewport.scrollTop = bottom - viewHeight;
20366
+ }
20367
+ }
20368
+ const viewWidth = viewport.clientWidth;
20369
+ if (viewWidth <= 0) return;
20370
+ let left = ROWNUM_WIDTH;
20371
+ for (let c2 = 0; c2 < colIndex; c2++) left += getColWidth(columnNames[c2]);
20372
+ const right = left + getColWidth(columnNames[colIndex]);
20373
+ if (left < viewport.scrollLeft) {
20374
+ viewport.scrollLeft = colIndex === 0 ? 0 : left;
20375
+ } else if (right > viewport.scrollLeft + viewWidth) {
20376
+ viewport.scrollLeft = right - viewWidth;
20377
+ }
20378
+ }
20379
+ function cachedRow(rowIndex) {
20380
+ const pageStart = Math.floor(rowIndex / PAGE_SIZE) * PAGE_SIZE;
20381
+ return getCachedPage(pageStart)?.[rowIndex - pageStart];
20382
+ }
20383
+ function cachedCellValue(rowIndex, colIndex) {
20384
+ const row = cachedRow(rowIndex);
20385
+ return row ? row[colIndex] : void 0;
20386
+ }
20387
+ function showDetailForActiveCell() {
20388
+ const rowIndex = activeCellRowIndex;
20389
+ const colIndex = activeCellColIndex;
20390
+ if (rowIndex < 0 || colIndex < 0) return;
20391
+ const value = cachedCellValue(rowIndex, colIndex);
20392
+ if (value !== void 0) {
20393
+ showCellDetail(colIndex, value);
20394
+ return;
20395
+ }
20396
+ const pageStart = Math.floor(rowIndex / PAGE_SIZE) * PAGE_SIZE;
20397
+ void ensurePage(pageStart).then(() => {
20398
+ if (activeCellRowIndex !== rowIndex || activeCellColIndex !== colIndex) {
20399
+ return;
20400
+ }
20401
+ const loaded = cachedCellValue(rowIndex, colIndex);
20402
+ if (loaded !== void 0) showCellDetail(colIndex, loaded);
20403
+ });
20404
+ }
20405
+ function moveGridFocus(back) {
20406
+ if (back) {
20407
+ if (!embedded || !callbacks.onFocusParentGrid) return false;
20408
+ callbacks.onFocusParentGrid();
20409
+ return true;
20410
+ }
20411
+ if (embedded || !relatedPanel || relatedPanel.hidden || !embeddedGrid) {
20412
+ return false;
20413
+ }
20414
+ embeddedGrid.focusGrid();
20415
+ return true;
20416
+ }
20417
+ function closeOpenPanel() {
20418
+ if (relatedPanel && !relatedPanel.hidden) {
20419
+ hideRelatedPanel();
20420
+ clearActiveCell();
20421
+ return true;
20422
+ }
20423
+ if (!detailPanel.hidden) {
20424
+ detailPanel.hidden = true;
20425
+ clearDetailContent();
20426
+ clearActiveCell();
20427
+ return true;
20428
+ }
20429
+ return false;
20430
+ }
20431
+ function activateActiveCell() {
20432
+ const rowIndex = activeCellRowIndex;
20433
+ const colIndex = activeCellColIndex;
20434
+ if (rowIndex < 0 || colIndex < 0) return false;
20435
+ const rowValues = cachedRow(rowIndex);
20436
+ if (!rowValues) return false;
20437
+ const colName = columnNames[colIndex];
20438
+ const fkClickable = fkColumns.has(colName) && (!embedded || !!callbacks.onForeignKeyCellClick);
20439
+ if (!fkClickable) {
20440
+ showDetailForActiveCell();
20441
+ return true;
20442
+ }
20443
+ if (embedded) {
20444
+ callbacks.onForeignKeyCellClick?.(
20445
+ currentTable,
20446
+ columnNames,
20447
+ rowValues,
20448
+ colName
20449
+ );
20450
+ } else {
20451
+ openRelatedForRow(currentTable, columnNames, rowValues, colName);
20452
+ }
20453
+ return true;
20454
+ }
20455
+ function onViewportKeydown(e2) {
20456
+ if (isImeComposing(e2)) return;
20457
+ if (isEditableKeyTarget(e2.target)) return;
20458
+ if (e2.key === "Tab" && !e2.ctrlKey && !e2.metaKey && !e2.altKey) {
20459
+ if (moveGridFocus(e2.shiftKey)) e2.preventDefault();
20460
+ return;
20461
+ }
20462
+ if (e2.ctrlKey || e2.metaKey || e2.altKey || e2.shiftKey) return;
20463
+ if (e2.key === "Escape") {
20464
+ if (closeOpenPanel()) e2.preventDefault();
20465
+ return;
20466
+ }
20467
+ if (e2.key === "Enter") {
20468
+ if (activateActiveCell()) e2.preventDefault();
20469
+ return;
20470
+ }
20471
+ const step = ARROW_STEP[e2.key];
20472
+ if (!step) return;
20473
+ const lastRow = totalRows - 1;
20474
+ const lastCol = columnNames.length - 1;
20475
+ if (lastRow < 0 || lastCol < 0) return;
20476
+ e2.preventDefault();
20477
+ if (activeCellRowIndex < 0 || activeCellColIndex < 0) {
20478
+ moveActiveCell(Math.min(Math.max(selectedRowIndex, 0), lastRow), 0);
20479
+ return;
20480
+ }
20481
+ const nextRow = Math.min(
20482
+ Math.max(activeCellRowIndex + step.row, 0),
20483
+ lastRow
20484
+ );
20485
+ const nextCol = Math.min(
20486
+ Math.max(activeCellColIndex + step.col, 0),
20487
+ lastCol
20488
+ );
20489
+ if (nextRow === activeCellRowIndex && nextCol === activeCellColIndex)
20490
+ return;
20491
+ moveActiveCell(nextRow, nextCol);
20492
+ }
20317
20493
  function clearDetailContent() {
20318
20494
  for (const child of Array.from(detailPanel.children)) {
20319
20495
  if (child !== detailResize) child.remove();
@@ -20388,6 +20564,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20388
20564
  let editingCellCol = -1;
20389
20565
  let relatedPanel = null;
20390
20566
  let relatedListEl = null;
20567
+ let relatedListResizeEl = null;
20391
20568
  let relatedGridHost = null;
20392
20569
  let relatedEmptyEl = null;
20393
20570
  let relatedCrumbEl = null;
@@ -20401,6 +20578,20 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20401
20578
  RELATED_PANEL_MIN_HEIGHT,
20402
20579
  Math.min(panelMaxHeight(), savedRelatedHeight)
20403
20580
  ) : RELATED_PANEL_DEFAULT_HEIGHT;
20581
+ let relatedListWidth = clampRelatedListWidth(
20582
+ readStoredSize(RELATED_LIST_WIDTH_KEY, RELATED_LIST_DEFAULT_WIDTH)
20583
+ );
20584
+ let relatedListResizeDetach = null;
20585
+ function clampRelatedListWidth(width) {
20586
+ return Math.max(
20587
+ RELATED_LIST_MIN_WIDTH,
20588
+ Math.min(RELATED_LIST_MAX_WIDTH, Math.round(width))
20589
+ );
20590
+ }
20591
+ function applyRelatedListWidth(width) {
20592
+ relatedListWidth = clampRelatedListWidth(width);
20593
+ el2.style.setProperty("--db-related-list-w", `${relatedListWidth}px`);
20594
+ }
20404
20595
  if (!embedded) {
20405
20596
  relatedPanel = document.createElement("div");
20406
20597
  relatedPanel.className = "db-related-panel";
@@ -20580,6 +20771,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20580
20771
  function resetSelectionAndDetail() {
20581
20772
  selectedRowIndex = -1;
20582
20773
  selectedRowElement = null;
20774
+ clearActiveCell();
20583
20775
  detailPanel.hidden = true;
20584
20776
  clearDetailContent();
20585
20777
  }
@@ -20786,6 +20978,24 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20786
20978
  bodyRow.className = "db-related-body";
20787
20979
  relatedListEl = document.createElement("div");
20788
20980
  relatedListEl.className = "db-related-list";
20981
+ const listResize = document.createElement("div");
20982
+ listResize.className = "db-related-list-resize";
20983
+ listResize.tabIndex = 0;
20984
+ listResize.setAttribute("role", "separator");
20985
+ listResize.setAttribute("aria-orientation", "vertical");
20986
+ listResize.setAttribute("aria-label", text3().grid.relatedListResize);
20987
+ relatedListResizeEl = listResize;
20988
+ applyRelatedListWidth(relatedListWidth);
20989
+ relatedListResizeDetach = attachDragResizer({
20990
+ handle: listResize,
20991
+ getSize: () => relatedListWidth,
20992
+ applySize: applyRelatedListWidth,
20993
+ direction: 1,
20994
+ axis: "x",
20995
+ onEnd: () => writeStoredSize(RELATED_LIST_WIDTH_KEY, relatedListWidth),
20996
+ activeClassTarget: relatedPanel,
20997
+ activeClassName: "db-related-list-resizing"
20998
+ });
20789
20999
  relatedGridHost = document.createElement("div");
20790
21000
  relatedGridHost.className = "db-related-grid-host";
20791
21001
  embeddedGrid = createTableGrid(
@@ -20806,7 +21016,9 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20806
21016
  getBaseEq: () => relatedEq,
20807
21017
  getText: callbacks.getText,
20808
21018
  // 埋め込みグリッドの FK クリックで 1 段潜る。
20809
- onForeignKeyCellClick: (sourceTable, colNames, rowData, clicked) => drillIntoRelated(sourceTable, colNames, rowData, clicked)
21019
+ onForeignKeyCellClick: (sourceTable, colNames, rowData, clicked) => drillIntoRelated(sourceTable, colNames, rowData, clicked),
21020
+ // 埋め込み側で Shift+Tab を押したらメイングリッドへ戻す。
21021
+ onFocusParentGrid: () => viewport.focus({ preventScroll: true })
20810
21022
  },
20811
21023
  { embedded: true }
20812
21024
  );
@@ -20816,7 +21028,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20816
21028
  relatedEmptyEl.textContent = text3().grid.relatedEmpty;
20817
21029
  relatedEmptyEl.hidden = true;
20818
21030
  relatedGridHost.appendChild(relatedEmptyEl);
20819
- bodyRow.append(relatedListEl, relatedGridHost);
21031
+ bodyRow.append(relatedListEl, listResize, relatedGridHost);
20820
21032
  relatedPanel.append(resizer, header, bodyRow);
20821
21033
  }
20822
21034
  function startRelatedResize(e2) {
@@ -20934,7 +21146,11 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20934
21146
  if (i2 === level.selectedIndex) item.classList.add("active");
20935
21147
  const name = document.createElement("span");
20936
21148
  name.className = "db-related-list-name";
20937
- name.textContent = relatedDrillTable(target);
21149
+ const tableName = document.createElement("span");
21150
+ tableName.className = "db-related-list-table";
21151
+ tableName.textContent = relatedDrillTable(target);
21152
+ tableName.title = relatedDrillTable(target);
21153
+ name.appendChild(tableName);
20938
21154
  if (target.fk.inferred) {
20939
21155
  const badge = document.createElement("span");
20940
21156
  badge.className = "db-related-list-inferred-badge";
@@ -20944,7 +21160,9 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20944
21160
  }
20945
21161
  const via = document.createElement("span");
20946
21162
  via.className = "db-related-list-via";
20947
- via.textContent = target.direction === "outgoing" ? `${target.fk.fromColumn} = ${target.value}` : `${target.fk.fromTable}.${target.fk.fromColumn} = ${target.value}`;
21163
+ const condition = target.direction === "outgoing" ? `${target.fk.fromColumn} = ${target.value}` : `${target.fk.fromTable}.${target.fk.fromColumn} = ${target.value}`;
21164
+ via.textContent = condition;
21165
+ via.title = condition;
20948
21166
  item.append(name, via);
20949
21167
  item.addEventListener("click", () => selectRelatedTarget(i2));
20950
21168
  relatedListEl?.appendChild(item);
@@ -20981,9 +21199,42 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20981
21199
  grid.showError(err instanceof Error ? err.message : String(err));
20982
21200
  }
20983
21201
  }
21202
+ let jsonHighlighter = null;
21203
+ let jsonHighlighterRequested = false;
21204
+ let detailJsonPre = null;
21205
+ let detailJsonText = "";
21206
+ function paintJsonHighlight(pre, json) {
21207
+ if (json.length > DETAIL_JSON_HIGHLIGHT_MAX_CHARS) return false;
21208
+ const inner = highlightToInnerHtml(json, "json", jsonHighlighter);
21209
+ if (!inner) return false;
21210
+ pre.innerHTML = inner;
21211
+ return true;
21212
+ }
21213
+ function showJsonDetail(pre, json) {
21214
+ detailJsonPre = pre;
21215
+ detailJsonText = json;
21216
+ if (paintJsonHighlight(pre, json)) return;
21217
+ pre.textContent = json;
21218
+ if (json.length > DETAIL_JSON_HIGHLIGHT_MAX_CHARS) return;
21219
+ ensureJsonHighlighter();
21220
+ }
21221
+ function ensureJsonHighlighter() {
21222
+ if (jsonHighlighterRequested) return;
21223
+ jsonHighlighterRequested = true;
21224
+ void loadShikiHighlighter({
21225
+ themes: ["github-light", "github-dark"],
21226
+ langs: ["json"]
21227
+ }).then((highlighter) => {
21228
+ jsonHighlighter = highlighter;
21229
+ if (!highlighter || !detailJsonPre?.isConnected) return;
21230
+ paintJsonHighlight(detailJsonPre, detailJsonText);
21231
+ });
21232
+ }
20984
21233
  function showCellDetail(colIndex, value) {
20985
21234
  const colName = columnNames[colIndex];
20986
21235
  const colType = columns[colIndex]?.type || "";
21236
+ detailJsonPre = null;
21237
+ detailJsonText = "";
20987
21238
  hideRelatedPanel();
20988
21239
  detailPanel.hidden = false;
20989
21240
  clearDetailContent();
@@ -21035,7 +21286,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
21035
21286
  const parsed = JSON.parse(str);
21036
21287
  const pre = document.createElement("pre");
21037
21288
  pre.className = "db-grid-detail-json";
21038
- pre.textContent = JSON.stringify(parsed, null, 2);
21289
+ showJsonDetail(pre, JSON.stringify(parsed, null, 2));
21039
21290
  content.appendChild(pre);
21040
21291
  } catch {
21041
21292
  content.textContent = str;
@@ -21100,6 +21351,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
21100
21351
  }
21101
21352
  renderFilterRow();
21102
21353
  syncContentWidth();
21354
+ syncHorizontalScroll();
21103
21355
  }
21104
21356
  function startResize(colIndex, startEvent) {
21105
21357
  cleanupResize();
@@ -21180,9 +21432,27 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
21180
21432
  invalidateData();
21181
21433
  }, FILTER_DEBOUNCE_MS);
21182
21434
  }
21435
+ let scrollbarGutterPx = -1;
21436
+ function syncScrollbarGutter() {
21437
+ const outer = viewport.offsetWidth;
21438
+ const inner = viewport.clientWidth;
21439
+ if (!(outer > 0) || !(inner > 0)) return;
21440
+ const gutter = outer - inner;
21441
+ if (gutter === scrollbarGutterPx) return;
21442
+ scrollbarGutterPx = gutter;
21443
+ el2.style.setProperty("--db-grid-scrollbar-w", `${gutter}px`);
21444
+ }
21445
+ function measureContentWidth() {
21446
+ let total = 0;
21447
+ for (const cell of headerRow.children) {
21448
+ total += cell.getBoundingClientRect().width;
21449
+ }
21450
+ return total;
21451
+ }
21183
21452
  function syncContentWidth() {
21184
21453
  requestAnimationFrame(() => {
21185
- const w = headerRow.scrollWidth;
21454
+ syncScrollbarGutter();
21455
+ const w = measureContentWidth();
21186
21456
  if (w > 0) {
21187
21457
  spacer.style.minWidth = `${w}px`;
21188
21458
  body.style.minWidth = `${w}px`;
@@ -21382,8 +21652,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
21382
21652
  const fkClickable = fkColumns.has(cellColName) && (!embedded || !!callbacks.onForeignKeyCellClick);
21383
21653
  const isEditingThisCell = !ro && i2 === editingCellRow && c2 === editingCellCol;
21384
21654
  const activate = () => {
21385
- setSelectedRow(rowIndex, row);
21386
- setActiveCell(rowIndex, cellColIndex);
21655
+ focusCell(rowIndex, row, cellColIndex);
21387
21656
  if (fkClickable) {
21388
21657
  if (embedded) {
21389
21658
  callbacks.onForeignKeyCellClick?.(
@@ -21475,8 +21744,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
21475
21744
  }
21476
21745
  cell.addEventListener("click", (e2) => {
21477
21746
  e2.stopPropagation();
21478
- setSelectedRow(rowIndex, row);
21479
- setActiveCell(rowIndex, cellColIndex);
21747
+ focusCell(rowIndex, row, cellColIndex);
21480
21748
  if (fkClickable) {
21481
21749
  if (embedded) {
21482
21750
  callbacks.onForeignKeyCellClick?.(
@@ -21586,6 +21854,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
21586
21854
  );
21587
21855
  }
21588
21856
  syncFilteredEmptyState();
21857
+ syncHorizontalScroll();
21589
21858
  if (focusRestore) {
21590
21859
  const next = body.querySelector(
21591
21860
  `.db-grid-cell-input[data-edit-row="${focusRestore.row}"][data-edit-col="${focusRestore.col}"]`
@@ -21751,11 +22020,22 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
21751
22020
  void refreshCurrentTable();
21752
22021
  });
21753
22022
  const onViewportScroll = () => {
21754
- headerWrap.scrollLeft = viewport.scrollLeft;
21755
- filterRowWrap.scrollLeft = viewport.scrollLeft;
22023
+ syncHorizontalScroll();
21756
22024
  renderViewport();
21757
22025
  };
22026
+ const onWrapScroll = (wrap) => () => {
22027
+ if (wrap.scrollLeft === viewport.scrollLeft) return;
22028
+ viewport.scrollLeft = wrap.scrollLeft;
22029
+ syncHorizontalScroll();
22030
+ };
22031
+ const onHeaderWrapScroll = onWrapScroll(headerWrap);
22032
+ const onFilterWrapScroll = onWrapScroll(filterRowWrap);
21758
22033
  viewport.addEventListener("scroll", onViewportScroll, { passive: true });
22034
+ viewport.addEventListener("keydown", onViewportKeydown);
22035
+ headerWrap.addEventListener("scroll", onHeaderWrapScroll, { passive: true });
22036
+ filterRowWrap.addEventListener("scroll", onFilterWrapScroll, {
22037
+ passive: true
22038
+ });
21759
22039
  const onCompositionStart = () => {
21760
22040
  isComposing = true;
21761
22041
  };
@@ -21769,7 +22049,12 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
21769
22049
  clear();
21770
22050
  embeddedGrid?.destroy();
21771
22051
  embeddedGrid = null;
22052
+ relatedListResizeDetach?.();
22053
+ relatedListResizeDetach = null;
21772
22054
  viewport.removeEventListener("scroll", onViewportScroll);
22055
+ viewport.removeEventListener("keydown", onViewportKeydown);
22056
+ headerWrap.removeEventListener("scroll", onHeaderWrapScroll);
22057
+ filterRowWrap.removeEventListener("scroll", onFilterWrapScroll);
21773
22058
  body.removeEventListener("compositionstart", onCompositionStart);
21774
22059
  body.removeEventListener("compositionend", onCompositionEnd);
21775
22060
  detailResizeCleanup?.();
@@ -21793,6 +22078,10 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
21793
22078
  updateStatus();
21794
22079
  }
21795
22080
  renderRelatedCrumbs();
22081
+ renderRelatedList();
22082
+ if (relatedListResizeEl) {
22083
+ relatedListResizeEl.setAttribute("aria-label", t2.grid.relatedListResize);
22084
+ }
21796
22085
  embeddedGrid?.localize();
21797
22086
  }
21798
22087
  function rebuildFkColumnsForCurrentTable() {
@@ -22042,6 +22331,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
22042
22331
  }
22043
22332
  return {
22044
22333
  el: el2,
22334
+ focusGrid: () => viewport.focus({ preventScroll: true }),
22045
22335
  load,
22046
22336
  refresh: refreshCurrentTable,
22047
22337
  showError,
@@ -25714,6 +26004,18 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
25714
26004
  (f2) => `${fileKey2(f2)}\0${f2.path}\0${f2.old_path || ""}\0${f2.status || "M"}`
25715
26005
  ).join("\n");
25716
26006
  }
26007
+ function stripLoadUrlGeneration(url) {
26008
+ const queryStart = url.indexOf("?");
26009
+ if (queryStart === -1) return url;
26010
+ const params = new URLSearchParams(url.slice(queryStart + 1));
26011
+ if (!params.has("generation")) return url;
26012
+ params.delete("generation");
26013
+ const s2 = params.toString();
26014
+ return s2 ? `${url.slice(0, queryStart)}?${s2}` : url.slice(0, queryStart);
26015
+ }
26016
+ function diffResponseSignature(data) {
26017
+ return JSON.stringify({ ...data, generation: void 0 });
26018
+ }
25717
26019
  function computeCardSignature(f2) {
25718
26020
  return [
25719
26021
  fileKey2(f2),
@@ -25725,12 +26027,18 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
25725
26027
  f2.size_class || "small",
25726
26028
  f2.force_layout || "",
25727
26029
  f2.highlight ? 1 : 0,
25728
- f2.load_url,
25729
- f2.preview_url || "",
26030
+ stripLoadUrlGeneration(f2.load_url),
26031
+ f2.preview_url ? stripLoadUrlGeneration(f2.preview_url) : "",
25730
26032
  f2.estimated_height_px || 0,
25731
26033
  f2.untracked ? 1 : 0
25732
26034
  ].join("\0");
25733
26035
  }
26036
+ function cardNeedsLoad(card) {
26037
+ if (card.classList.contains("loading")) return false;
26038
+ if (card.dataset.staleLoading === "1") return false;
26039
+ if (card.classList.contains("loaded")) return card.dataset.stale === "1";
26040
+ return true;
26041
+ }
25734
26042
  function ensureLazyObserver() {
25735
26043
  if (!lazyObserver) {
25736
26044
  lazyObserver = new IntersectionObserver(
@@ -25738,8 +26046,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
25738
26046
  for (const entry of entries) {
25739
26047
  if (!entry.isIntersecting) continue;
25740
26048
  const card = entry.target;
25741
- if (card.classList.contains("loaded") || card.classList.contains("loading"))
25742
- continue;
26049
+ if (!cardNeedsLoad(card)) continue;
25743
26050
  const f2 = card._file || STATE.files.find((x) => x.path === card.dataset.path);
25744
26051
  if (f2) enqueueLoad(f2, card, 0);
25745
26052
  }
@@ -25761,15 +26068,27 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
25761
26068
  return false;
25762
26069
  if (!changedPathsCoverPath(changedPaths, file.path)) return false;
25763
26070
  card.dataset.reqId = String(++CLIENT_REQ_SEQ);
26071
+ delete card.dataset.stale;
26072
+ delete card.dataset.staleLoading;
25764
26073
  card.style.minHeight = `${measuredHeight ?? card.offsetHeight}px`;
25765
26074
  card.classList.remove("loaded", "loading", "error");
25766
26075
  card.classList.add("pending");
25767
26076
  card._diffData = null;
26077
+ card._loadedSig = null;
26078
+ card._loadedSigUrl = null;
25768
26079
  const indicator = card.querySelector(".loading-indicator");
25769
26080
  if (indicator) indicator.hidden = false;
25770
26081
  activatePendingCard(card, file);
25771
26082
  return true;
25772
26083
  }
26084
+ function revalidateCardSilently(card, file) {
26085
+ if (!card.classList.contains("loaded")) return false;
26086
+ card.dataset.reqId = String(++CLIENT_REQ_SEQ);
26087
+ card.dataset.stale = "1";
26088
+ card._file = file;
26089
+ activatePendingCard(card, file);
26090
+ return true;
26091
+ }
25773
26092
  function updateSidebarStats(files) {
25774
26093
  for (const f2 of files) {
25775
26094
  const li = document.querySelector(
@@ -25800,8 +26119,12 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
25800
26119
  delete card.dataset.manualRendered;
25801
26120
  delete card.dataset.manualLoad;
25802
26121
  delete card.dataset.manualMode;
26122
+ delete card.dataset.stale;
26123
+ delete card.dataset.staleLoading;
25803
26124
  card.style.minHeight = `${file.estimated_height_px || 80}px`;
25804
26125
  card._diffData = null;
26126
+ card._loadedSig = null;
26127
+ card._loadedSigUrl = null;
25805
26128
  card._file = file;
25806
26129
  }
25807
26130
  function renderShell(meta, changedPaths) {
@@ -25877,23 +26200,37 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
25877
26200
  activatePendingCard(card, f2);
25878
26201
  invalidatedCards++;
25879
26202
  sidebarNeedsStatsUpdate = true;
25880
- } else {
26203
+ } else if (sigChanged) {
25881
26204
  const stats = card.querySelector(".gdp-shell-header .stats");
25882
26205
  if (stats) {
25883
26206
  stats.innerHTML = '<span class="a">+' + (f2.additions || 0) + '</span><span class="d">−' + (f2.deletions || 0) + "</span>";
25884
26207
  }
25885
26208
  card._file = f2;
25886
- const didInvalidate = sigChanged ? invalidateLoadedCard(card, f2, null, measuredHeights.get(key)) : invalidateLoadedCard(
26209
+ const didInvalidate = invalidateLoadedCard(
25887
26210
  card,
25888
26211
  f2,
25889
- changedPaths,
26212
+ null,
25890
26213
  measuredHeights.get(key)
25891
26214
  );
25892
26215
  if (didInvalidate) invalidatedCards++;
25893
- else if ((sigChanged || pathHint) && card.classList.contains("pending")) {
26216
+ else if (card.classList.contains("pending")) {
25894
26217
  activatePendingCard(card, f2);
25895
26218
  }
25896
- if (sigChanged) sidebarNeedsStatsUpdate = true;
26219
+ sidebarNeedsStatsUpdate = true;
26220
+ } else {
26221
+ card._file = f2;
26222
+ if (!revalidateCardSilently(card, f2)) {
26223
+ const didInvalidate = invalidateLoadedCard(
26224
+ card,
26225
+ f2,
26226
+ changedPaths,
26227
+ measuredHeights.get(key)
26228
+ );
26229
+ if (didInvalidate) invalidatedCards++;
26230
+ else if (card.classList.contains("pending")) {
26231
+ activatePendingCard(card, f2);
26232
+ }
26233
+ }
25897
26234
  }
25898
26235
  }
25899
26236
  if (sidebarNeedsStatsUpdate && canUpdateSidebar)
@@ -25988,8 +26325,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
25988
26325
  entries.forEach((entry) => {
25989
26326
  if (!entry.isIntersecting) return;
25990
26327
  const card = entry.target;
25991
- if (card.classList.contains("loaded") || card.classList.contains("loading"))
25992
- return;
26328
+ if (!cardNeedsLoad(card)) return;
25993
26329
  const f2 = card._file || STATE.files.find((x) => x.path === card.dataset.path);
25994
26330
  if (!f2) return;
25995
26331
  enqueueLoad(f2, card, 0);
@@ -26030,8 +26366,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
26030
26366
  while (ACTIVE_LOADS < MAX_PARALLEL && LOAD_QUEUE.length) {
26031
26367
  const item = LOAD_QUEUE.shift();
26032
26368
  if (item.epoch !== LOAD_EPOCH) continue;
26033
- if (item.card.classList.contains("loaded") || item.card.classList.contains("loading"))
26034
- continue;
26369
+ if (!cardNeedsLoad(item.card)) continue;
26035
26370
  ACTIVE_LOADS++;
26036
26371
  loadFile(item.file, item.card).finally(() => {
26037
26372
  if (item.epoch === LOAD_EPOCH) {
@@ -26126,20 +26461,29 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
26126
26461
  });
26127
26462
  }
26128
26463
  function loadFile(file, card, urlOverride, options) {
26129
- card.classList.remove("pending");
26130
- card.classList.add("loading");
26131
- if (lazyObserver) lazyObserver.unobserve(card);
26464
+ const silent = card.classList.contains("loaded") && card.dataset.stale === "1";
26132
26465
  const indicator = card.querySelector(".loading-indicator");
26133
- if (indicator) indicator.hidden = false;
26466
+ if (silent) {
26467
+ card.dataset.staleLoading = "1";
26468
+ } else {
26469
+ card.classList.remove("pending");
26470
+ card.classList.add("loading");
26471
+ if (indicator) indicator.hidden = false;
26472
+ }
26473
+ if (lazyObserver) lazyObserver.unobserve(card);
26134
26474
  const url = urlOverride || (card.dataset.manualMode === "full" ? file.load_url : file.preview_url || file.load_url);
26135
26475
  const myGen = getServerGeneration();
26136
26476
  const myReq = ++CLIENT_REQ_SEQ;
26137
26477
  card.dataset.reqId = String(myReq);
26138
26478
  const retryStale = () => {
26139
26479
  if (String(myReq) !== card.dataset.reqId) return;
26140
- card.classList.remove("loading");
26141
- card.classList.add("pending");
26142
- if (indicator) indicator.hidden = true;
26480
+ if (silent) {
26481
+ delete card.dataset.staleLoading;
26482
+ } else {
26483
+ card.classList.remove("loading");
26484
+ card.classList.add("pending");
26485
+ if (indicator) indicator.hidden = true;
26486
+ }
26143
26487
  const fresh = card._file || STATE.files.find((x) => x.path === card.dataset.path);
26144
26488
  if (fresh && card.isConnected) enqueueLoad(fresh, card, 0);
26145
26489
  };
@@ -26158,11 +26502,32 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
26158
26502
  retryStale();
26159
26503
  return;
26160
26504
  }
26505
+ const strippedUrl = stripLoadUrlGeneration(url);
26506
+ if (silent) {
26507
+ const sig = diffResponseSignature(data);
26508
+ if (card._loadedSigUrl === strippedUrl && card._loadedSig === sig) {
26509
+ delete card.dataset.staleLoading;
26510
+ delete card.dataset.stale;
26511
+ return;
26512
+ }
26513
+ }
26161
26514
  if (!options?.immediate) await nextIdle();
26162
26515
  if (String(myReq) !== card.dataset.reqId) return;
26516
+ if (silent) {
26517
+ delete card.dataset.staleLoading;
26518
+ delete card.dataset.stale;
26519
+ }
26520
+ card._loadedSig = diffResponseSignature(data);
26521
+ card._loadedSigUrl = strippedUrl;
26163
26522
  renderFile(file, data, card);
26164
26523
  }).catch((error2) => {
26165
26524
  if (String(myReq) !== card.dataset.reqId) return;
26525
+ if (silent) {
26526
+ delete card.dataset.staleLoading;
26527
+ delete card.dataset.stale;
26528
+ console.error("[code-viewer] silent diff revalidation failed", error2);
26529
+ return;
26530
+ }
26166
26531
  console.error("[code-viewer] failed to load diff", error2);
26167
26532
  card.classList.remove("loading");
26168
26533
  card.classList.add("error");
@@ -29256,7 +29621,7 @@ code-viewer annotate add-db --db app.db --tab query \\
29256
29621
  ],
29257
29622
  [
29258
29623
  "Detail footer & related panel",
29259
- "Click any cell to open a resizable detail footer. Foreign-key cells open a related-rows panel with multi-step drill-down breadcrumbs, supporting both outgoing (FK → PK) and incoming (PK ← FK) navigation."
29624
+ "Click any cell to open a resizable detail footer; JSON values are pretty-printed and syntax-highlighted there. Once the grid has focus, arrow keys move the active cell from data cell to data cell and the footer follows the value under it, scrolling only as far as needed; Enter follows a foreign key (arrow keys alone never fire a related-table query), Escape closes whichever panel is open, and Tab / Shift+Tab move between the main grid and the related grid. Foreign-key cells open a related-rows panel with multi-step drill-down breadcrumbs, supporting both outgoing (FK → PK) and incoming (PK ← FK) navigation. Its reference list keeps each entry on one line with the full table name and condition in a tooltip, and the list width can be dragged and is remembered."
29260
29625
  ],
29261
29626
  [
29262
29627
  "Schema tab",
@@ -30023,7 +30388,7 @@ code-viewer annotate add-db --db app.db --tab query \\
30023
30388
  ],
30024
30389
  [
30025
30390
  "詳細フッタ・関連パネル",
30026
- "セルをクリックするとリサイズ可能な詳細フッタが開きます。外部キー値からは関連行パネルが開き、ブレッドクラム付きで多段ドリルダウン可能。outgoing (FK→PK) と incoming (PK←FK) の両方向に対応。"
30391
+ "セルをクリックするとリサイズ可能な詳細フッタが開きます。JSON 値は整形してシンタックスハイライト付きで表示されます。グリッドにフォーカスがある間は矢印キーでデータセル間を移動でき、詳細フッタもその値に追従します(スクロールは見える位置まで必要なぶんだけ)。Enter で外部キーを辿り(矢印キーだけでは関連テーブルへのクエリは飛びません)、Escape で開いているパネルを閉じ、Tab / Shift+Tab でメイングリッドと関連グリッドを行き来できます。外部キー値からは関連行パネルが開き、ブレッドクラム付きで多段ドリルダウン可能。outgoing (FK→PK) と incoming (PK←FK) の両方向に対応。左の参照リストは各項目を1行に保ち、テーブル名と条件の全文は tooltip で確認できます。リスト幅はドラッグで変更でき、次回も保持されます。"
30027
30392
  ],
30028
30393
  [
30029
30394
  "Schema タブ",
@@ -36086,6 +36451,7 @@ code-viewer annotate add-db --db app.db --tab query \\
36086
36451
  async function loadRawFileInfo(target) {
36087
36452
  try {
36088
36453
  const res = await fetch(buildRawFileUrl(target), { method: "HEAD" });
36454
+ if (res.status === 404) return { missing: true };
36089
36455
  if (!res.ok) return {};
36090
36456
  const rawSize = res.headers.get("content-length");
36091
36457
  const size = rawSize == null ? NaN : Number(rawSize);
@@ -47326,12 +47692,53 @@ ${error2.stack}` : ""}`
47326
47692
  );
47327
47693
  }
47328
47694
  }
47695
+ let fileRouteSignature = null;
47696
+ let fileRouteSignatureSeed = null;
47697
+ let fileRouteSignatureCheck = null;
47698
+ function fileRouteSignatureKey(route) {
47699
+ return `${route.view || "blob"}\0${route.path}\0${route.ref || "worktree"}`;
47700
+ }
47701
+ async function readFileRouteSignature(route) {
47702
+ const info = await REPO_VIEW.loadRawFileInfo({
47703
+ path: route.path,
47704
+ ref: route.ref || "worktree"
47705
+ });
47706
+ return rawFileInfoSignature(info);
47707
+ }
47708
+ function seedFileRouteSignature(route) {
47709
+ const key = fileRouteSignatureKey(route);
47710
+ const seed = trackLoad(readFileRouteSignature(route)).then((sig) => {
47711
+ if (fileRouteSignatureSeed === seed) fileRouteSignatureSeed = null;
47712
+ if (sig === null) return;
47713
+ fileRouteSignature = { key, sig };
47714
+ });
47715
+ fileRouteSignatureSeed = seed;
47716
+ }
47717
+ function refreshFileRouteIfChanged(route) {
47718
+ if (fileRouteSignatureCheck) return;
47719
+ const key = fileRouteSignatureKey(route);
47720
+ const pendingSeed = fileRouteSignatureSeed ?? Promise.resolve();
47721
+ fileRouteSignatureCheck = pendingSeed.then(() => trackLoad(readFileRouteSignature(route))).then((sig) => {
47722
+ fileRouteSignatureCheck = null;
47723
+ if (sig === null) return;
47724
+ const routeNow = STATE.route;
47725
+ if (routeNow.screen !== "file" || !isBlobOrBlameFileRoute(routeNow) || fileRouteSignatureKey(routeNow) !== key)
47726
+ return;
47727
+ if (fileSignatureUnchanged(fileRouteSignature, key, sig)) return;
47728
+ fileRouteSignature = { key, sig };
47729
+ dispatchFileRoute(routeNow, { refresh: true });
47730
+ });
47731
+ fileRouteSignatureCheck.catch(() => {
47732
+ fileRouteSignatureCheck = null;
47733
+ });
47734
+ }
47329
47735
  function dispatchFileRoute(route, options = {}) {
47330
47736
  if (route.view === "blob") {
47331
47737
  setStatus("live");
47332
47738
  removeFileHistoryShell2();
47333
47739
  BLAME_VIEW.removeBlamePage();
47334
47740
  applySourceRouteToShell(options);
47741
+ seedFileRouteSignature(route);
47335
47742
  return true;
47336
47743
  }
47337
47744
  if (route.view === "blame") {
@@ -47339,6 +47746,7 @@ ${error2.stack}` : ""}`
47339
47746
  cancelActiveSourceLoad("navigation");
47340
47747
  removeFileHistoryShell2();
47341
47748
  void BLAME_VIEW.renderBlamePage({ path: route.path, ref: route.ref });
47749
+ seedFileRouteSignature(route);
47342
47750
  return true;
47343
47751
  }
47344
47752
  if (route.view === "history") {
@@ -49510,7 +49918,7 @@ ${error2.stack}` : ""}`
49510
49918
  if (isBlobOrBlameFileRoute(route)) {
49511
49919
  const viewingPath = route.path;
49512
49920
  if (viewingPath && !changedPathsCoverPath(paths, viewingPath)) return;
49513
- dispatchFileRoute(route, { refresh: true });
49921
+ refreshFileRouteIfChanged(route);
49514
49922
  return;
49515
49923
  }
49516
49924
  if (route.screen === "repo") {
package/web/style.css CHANGED
@@ -10464,6 +10464,14 @@ body.journal-task-editor-resizing * {
10464
10464
  .db-grid-header-wrap {
10465
10465
  flex-shrink: 0;
10466
10466
  overflow: hidden;
10467
+ /* 本文 (.db-grid-viewport) の縦スクロールバーが食う幅を、右端に透明な
10468
+ border として空ける。空けないと「見える幅」が本文だけ狭くなり、本文が
10469
+ 右へ余分にスクロールできてしまう。ヘッダは scrollLeft を同期しても
10470
+ そこまで追随できず、右端で列がスクロールバー幅ぶんずれる。
10471
+ scrollbar-gutter は overflow:hidden だと見た目の幅しか縮まず、
10472
+ スクロール範囲は縮まないので使えない (実測済み)。
10473
+ 幅は table-grid.ts が実測して --db-grid-scrollbar-w に入れる。 */
10474
+ border-right: var(--db-grid-scrollbar-w, 0px) solid transparent;
10467
10475
  border-bottom: 2px solid var(--border);
10468
10476
  background: var(--bg-soft);
10469
10477
  }
@@ -10508,6 +10516,20 @@ body.journal-task-editor-resizing * {
10508
10516
  flex: 1;
10509
10517
  overflow: auto;
10510
10518
  position: relative;
10519
+ /* 行が少なくて縦スクロールバーが出ないときも幅を変えない。ここを可変に
10520
+ すると、ヘッダ / フィルタ行に渡す --db-grid-scrollbar-w が行数やフィルタで
10521
+ 変わり、列の位置が動く。 */
10522
+ scrollbar-gutter: stable;
10523
+ }
10524
+ /* 矢印キーでセルを移動するため viewport 自体がフォーカスを受け取る
10525
+ (tabIndex=0)。セルをクリックするたびに枠が出ると常時ノイズになるので、
10526
+ キーボードで到達したときだけリングを出す。親の .db-grid が
10527
+ overflow:hidden なので外側に描くと切れる → inset で内側に描く。 */
10528
+ .db-grid-viewport:focus {
10529
+ outline: none;
10530
+ }
10531
+ .db-grid-viewport:focus-visible {
10532
+ box-shadow: inset var(--db-focus-ring);
10511
10533
  }
10512
10534
 
10513
10535
  .db-grid-spacer {
@@ -11097,6 +11119,8 @@ html[data-theme="dark"] .db-schema-table tr.pk-row {
11097
11119
  .db-grid-filter-row-wrap {
11098
11120
  flex-shrink: 0;
11099
11121
  overflow: hidden;
11122
+ /* ヘッダと同じ理由でスクロールバー幅を空ける (.db-grid-header-wrap 参照)。 */
11123
+ border-right: var(--db-grid-scrollbar-w, 0px) solid transparent;
11100
11124
  border-bottom: 1px solid var(--border);
11101
11125
  background: var(--bg-soft);
11102
11126
  }
@@ -11276,6 +11300,23 @@ html[data-theme="dark"] .db-schema-table tr.pk-row {
11276
11300
  white-space: pre-wrap;
11277
11301
  word-break: break-word;
11278
11302
  }
11303
+ /* shiki は `<span style="--shiki-light:#xxx;--shiki-dark:#yyy">` の inline
11304
+ CSS 変数で token 色を出すので、light / dark でそれぞれ参照する。
11305
+ 明示テーマ (html[data-theme]) を OS 設定より後に置いて優先させる。 */
11306
+ .db-grid-detail-json span[style] {
11307
+ color: var(--shiki-light);
11308
+ }
11309
+ @media (prefers-color-scheme: dark) {
11310
+ .db-grid-detail-json span[style] {
11311
+ color: var(--shiki-dark);
11312
+ }
11313
+ }
11314
+ html[data-theme="light"] .db-grid-detail-json span[style] {
11315
+ color: var(--shiki-light);
11316
+ }
11317
+ html[data-theme="dark"] .db-grid-detail-json span[style] {
11318
+ color: var(--shiki-dark);
11319
+ }
11279
11320
 
11280
11321
  /* ---------- Foreign-key related data ---------- */
11281
11322
  .db-grid-header-fk-icon {
@@ -11373,13 +11414,32 @@ html[data-theme="dark"] .db-schema-table tr.pk-row {
11373
11414
  }
11374
11415
 
11375
11416
  .db-related-list {
11376
- width: 200px;
11417
+ /* 既定 / 下限 / 上限は table-grid.ts が持つ。ここは JS 実行前の初回描画用の
11418
+ fallback だけ (ui-layout: CSS と TS に同じ数値を書かない)。 */
11419
+ width: var(--db-related-list-w, 200px);
11377
11420
  flex-shrink: 0;
11378
11421
  overflow: auto;
11379
- border-right: 1px solid var(--border);
11380
11422
  padding: 4px;
11381
11423
  }
11382
11424
 
11425
+ /* リストとグリッドの間の掴みしろ。境界線の役目も兼ねるので、リスト側の
11426
+ border-right はこちらへ移してある (二重線にしない)。 */
11427
+ .db-related-list-resize {
11428
+ flex: 0 0 auto;
11429
+ width: 5px;
11430
+ cursor: col-resize;
11431
+ background: var(--border);
11432
+ background-clip: content-box;
11433
+ border-left: 2px solid transparent;
11434
+ border-right: 2px solid transparent;
11435
+ }
11436
+ .db-related-list-resize:hover,
11437
+ .db-related-list-resize:focus-visible,
11438
+ .db-related-panel.db-related-list-resizing .db-related-list-resize {
11439
+ background: var(--accent);
11440
+ outline: none;
11441
+ }
11442
+
11383
11443
  .db-related-list-item {
11384
11444
  display: flex;
11385
11445
  flex-direction: column;
@@ -11410,6 +11470,17 @@ html[data-theme="dark"] .db-schema-table tr.pk-row {
11410
11470
  display: inline-flex;
11411
11471
  align-items: baseline;
11412
11472
  gap: 6px;
11473
+ /* 項目の幅を超えたら省略記号にする。min-width:0 が無いと flex 子は
11474
+ 内容幅より縮まず、はみ出したまま折り返す。 */
11475
+ max-width: 100%;
11476
+ min-width: 0;
11477
+ }
11478
+
11479
+ .db-related-list-table {
11480
+ min-width: 0;
11481
+ overflow: hidden;
11482
+ text-overflow: ellipsis;
11483
+ white-space: nowrap;
11413
11484
  }
11414
11485
  /* 方向アイコンを name の前に出す: outgoing は → (自分→他テーブル)、
11415
11486
  incoming は ← (他テーブル→自分の PK)。テキスト 1 文字なのでフォントに
@@ -11430,7 +11501,12 @@ html[data-theme="dark"] .db-schema-table tr.pk-row {
11430
11501
  .db-related-list-via {
11431
11502
  font-size: 11px;
11432
11503
  color: var(--fg-muted);
11433
- word-break: break-all;
11504
+ /* break-all だと "sample_association_table.sa / mple_column_id = 12" の
11505
+ ように語の途中で折り返って読めなくなる。1 行で省略し、全文は title で出す。 */
11506
+ max-width: 100%;
11507
+ overflow: hidden;
11508
+ text-overflow: ellipsis;
11509
+ white-space: nowrap;
11434
11510
  }
11435
11511
 
11436
11512
  .db-related-grid-host {