@youtyan/code-viewer 0.13.1 → 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.1",
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
@@ -12478,6 +12478,7 @@ ${frontmatter.yaml}
12478
12478
  exportAction: "Export",
12479
12479
  foreignKeyHint: "Foreign key — click to view related rows",
12480
12480
  relatedEmpty: "No matching row in the referenced table",
12481
+ relatedListResize: "Resize the related-reference list",
12481
12482
  filteredEmptyTitle: (count) => `No rows match ${count} active filter${count === 1 ? "" : "s"}`,
12482
12483
  filteredEmptyHint: "The table was loaded, but the current search or column filters hide every row.",
12483
12484
  filteredEmptyAction: "Clear filters",
@@ -12835,6 +12836,7 @@ ${frontmatter.yaml}
12835
12836
  exportAction: "エクスポート",
12836
12837
  foreignKeyHint: "外部キー: クリックして関連データを表示",
12837
12838
  relatedEmpty: "参照先に該当する行がありません",
12839
+ relatedListResize: "関連参照リストの幅を変える",
12838
12840
  filteredEmptyTitle: (count) => `フィルタ ${count} 件に一致する行がありません`,
12839
12841
  filteredEmptyHint: "表は読み込めていますが、現在の検索/列フィルタですべての行が隠れています。",
12840
12842
  filteredEmptyAction: "フィルタ解除",
@@ -20087,17 +20089,29 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20087
20089
 
20088
20090
  // web-src/views/database/table-grid.ts
20089
20091
  var ROW_HEIGHT = 28;
20092
+ var ROWNUM_WIDTH = 50;
20090
20093
  var OVERSCAN = 20;
20091
20094
  var PAGE_SIZE = 200;
20092
20095
  var MAX_PAGE_CACHE_PAGES = 32;
20093
20096
  var FILTER_DEBOUNCE_MS = 300;
20094
20097
  var DEFAULT_COL_WIDTH = 180;
20095
20098
  var CELL_PREVIEW_MAX_CHARS = 4e3;
20099
+ var DETAIL_JSON_HIGHLIGHT_MAX_CHARS = 1e5;
20096
20100
  var RELATED_PANEL_DEFAULT_HEIGHT = 320;
20097
20101
  var RELATED_PANEL_MIN_HEIGHT = 60;
20098
20102
  var DETAIL_PANEL_DEFAULT_HEIGHT = 200;
20099
20103
  var DETAIL_PANEL_MIN_HEIGHT = 40;
20100
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
+ };
20101
20115
  function createTableGrid(callbacks, options = {}) {
20102
20116
  const embedded = options.embedded === true;
20103
20117
  const text3 = () => callbacks.getText?.() ?? dbText("en");
@@ -20222,6 +20236,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20222
20236
  filterRowWrap.appendChild(filterRow);
20223
20237
  const viewport = document.createElement("div");
20224
20238
  viewport.className = "db-grid-viewport";
20239
+ viewport.tabIndex = 0;
20225
20240
  const spacer = document.createElement("div");
20226
20241
  spacer.className = "db-grid-spacer";
20227
20242
  const body = document.createElement("div");
@@ -20325,6 +20340,156 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20325
20340
  function clearActiveCell() {
20326
20341
  setActiveCell(-1, -1);
20327
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
+ }
20328
20493
  function clearDetailContent() {
20329
20494
  for (const child of Array.from(detailPanel.children)) {
20330
20495
  if (child !== detailResize) child.remove();
@@ -20399,6 +20564,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20399
20564
  let editingCellCol = -1;
20400
20565
  let relatedPanel = null;
20401
20566
  let relatedListEl = null;
20567
+ let relatedListResizeEl = null;
20402
20568
  let relatedGridHost = null;
20403
20569
  let relatedEmptyEl = null;
20404
20570
  let relatedCrumbEl = null;
@@ -20412,6 +20578,20 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20412
20578
  RELATED_PANEL_MIN_HEIGHT,
20413
20579
  Math.min(panelMaxHeight(), savedRelatedHeight)
20414
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
+ }
20415
20595
  if (!embedded) {
20416
20596
  relatedPanel = document.createElement("div");
20417
20597
  relatedPanel.className = "db-related-panel";
@@ -20591,6 +20771,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20591
20771
  function resetSelectionAndDetail() {
20592
20772
  selectedRowIndex = -1;
20593
20773
  selectedRowElement = null;
20774
+ clearActiveCell();
20594
20775
  detailPanel.hidden = true;
20595
20776
  clearDetailContent();
20596
20777
  }
@@ -20797,6 +20978,24 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20797
20978
  bodyRow.className = "db-related-body";
20798
20979
  relatedListEl = document.createElement("div");
20799
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
+ });
20800
20999
  relatedGridHost = document.createElement("div");
20801
21000
  relatedGridHost.className = "db-related-grid-host";
20802
21001
  embeddedGrid = createTableGrid(
@@ -20817,7 +21016,9 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20817
21016
  getBaseEq: () => relatedEq,
20818
21017
  getText: callbacks.getText,
20819
21018
  // 埋め込みグリッドの FK クリックで 1 段潜る。
20820
- 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 })
20821
21022
  },
20822
21023
  { embedded: true }
20823
21024
  );
@@ -20827,7 +21028,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20827
21028
  relatedEmptyEl.textContent = text3().grid.relatedEmpty;
20828
21029
  relatedEmptyEl.hidden = true;
20829
21030
  relatedGridHost.appendChild(relatedEmptyEl);
20830
- bodyRow.append(relatedListEl, relatedGridHost);
21031
+ bodyRow.append(relatedListEl, listResize, relatedGridHost);
20831
21032
  relatedPanel.append(resizer, header, bodyRow);
20832
21033
  }
20833
21034
  function startRelatedResize(e2) {
@@ -20945,7 +21146,11 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20945
21146
  if (i2 === level.selectedIndex) item.classList.add("active");
20946
21147
  const name = document.createElement("span");
20947
21148
  name.className = "db-related-list-name";
20948
- 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);
20949
21154
  if (target.fk.inferred) {
20950
21155
  const badge = document.createElement("span");
20951
21156
  badge.className = "db-related-list-inferred-badge";
@@ -20955,7 +21160,9 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20955
21160
  }
20956
21161
  const via = document.createElement("span");
20957
21162
  via.className = "db-related-list-via";
20958
- 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;
20959
21166
  item.append(name, via);
20960
21167
  item.addEventListener("click", () => selectRelatedTarget(i2));
20961
21168
  relatedListEl?.appendChild(item);
@@ -20992,9 +21199,42 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
20992
21199
  grid.showError(err instanceof Error ? err.message : String(err));
20993
21200
  }
20994
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
+ }
20995
21233
  function showCellDetail(colIndex, value) {
20996
21234
  const colName = columnNames[colIndex];
20997
21235
  const colType = columns[colIndex]?.type || "";
21236
+ detailJsonPre = null;
21237
+ detailJsonText = "";
20998
21238
  hideRelatedPanel();
20999
21239
  detailPanel.hidden = false;
21000
21240
  clearDetailContent();
@@ -21046,7 +21286,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
21046
21286
  const parsed = JSON.parse(str);
21047
21287
  const pre = document.createElement("pre");
21048
21288
  pre.className = "db-grid-detail-json";
21049
- pre.textContent = JSON.stringify(parsed, null, 2);
21289
+ showJsonDetail(pre, JSON.stringify(parsed, null, 2));
21050
21290
  content.appendChild(pre);
21051
21291
  } catch {
21052
21292
  content.textContent = str;
@@ -21111,6 +21351,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
21111
21351
  }
21112
21352
  renderFilterRow();
21113
21353
  syncContentWidth();
21354
+ syncHorizontalScroll();
21114
21355
  }
21115
21356
  function startResize(colIndex, startEvent) {
21116
21357
  cleanupResize();
@@ -21191,9 +21432,27 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
21191
21432
  invalidateData();
21192
21433
  }, FILTER_DEBOUNCE_MS);
21193
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
+ }
21194
21452
  function syncContentWidth() {
21195
21453
  requestAnimationFrame(() => {
21196
- const w = headerRow.scrollWidth;
21454
+ syncScrollbarGutter();
21455
+ const w = measureContentWidth();
21197
21456
  if (w > 0) {
21198
21457
  spacer.style.minWidth = `${w}px`;
21199
21458
  body.style.minWidth = `${w}px`;
@@ -21393,8 +21652,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
21393
21652
  const fkClickable = fkColumns.has(cellColName) && (!embedded || !!callbacks.onForeignKeyCellClick);
21394
21653
  const isEditingThisCell = !ro && i2 === editingCellRow && c2 === editingCellCol;
21395
21654
  const activate = () => {
21396
- setSelectedRow(rowIndex, row);
21397
- setActiveCell(rowIndex, cellColIndex);
21655
+ focusCell(rowIndex, row, cellColIndex);
21398
21656
  if (fkClickable) {
21399
21657
  if (embedded) {
21400
21658
  callbacks.onForeignKeyCellClick?.(
@@ -21486,8 +21744,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
21486
21744
  }
21487
21745
  cell.addEventListener("click", (e2) => {
21488
21746
  e2.stopPropagation();
21489
- setSelectedRow(rowIndex, row);
21490
- setActiveCell(rowIndex, cellColIndex);
21747
+ focusCell(rowIndex, row, cellColIndex);
21491
21748
  if (fkClickable) {
21492
21749
  if (embedded) {
21493
21750
  callbacks.onForeignKeyCellClick?.(
@@ -21597,6 +21854,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
21597
21854
  );
21598
21855
  }
21599
21856
  syncFilteredEmptyState();
21857
+ syncHorizontalScroll();
21600
21858
  if (focusRestore) {
21601
21859
  const next = body.querySelector(
21602
21860
  `.db-grid-cell-input[data-edit-row="${focusRestore.row}"][data-edit-col="${focusRestore.col}"]`
@@ -21762,11 +22020,22 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
21762
22020
  void refreshCurrentTable();
21763
22021
  });
21764
22022
  const onViewportScroll = () => {
21765
- headerWrap.scrollLeft = viewport.scrollLeft;
21766
- filterRowWrap.scrollLeft = viewport.scrollLeft;
22023
+ syncHorizontalScroll();
21767
22024
  renderViewport();
21768
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);
21769
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
+ });
21770
22039
  const onCompositionStart = () => {
21771
22040
  isComposing = true;
21772
22041
  };
@@ -21780,7 +22049,12 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
21780
22049
  clear();
21781
22050
  embeddedGrid?.destroy();
21782
22051
  embeddedGrid = null;
22052
+ relatedListResizeDetach?.();
22053
+ relatedListResizeDetach = null;
21783
22054
  viewport.removeEventListener("scroll", onViewportScroll);
22055
+ viewport.removeEventListener("keydown", onViewportKeydown);
22056
+ headerWrap.removeEventListener("scroll", onHeaderWrapScroll);
22057
+ filterRowWrap.removeEventListener("scroll", onFilterWrapScroll);
21784
22058
  body.removeEventListener("compositionstart", onCompositionStart);
21785
22059
  body.removeEventListener("compositionend", onCompositionEnd);
21786
22060
  detailResizeCleanup?.();
@@ -21804,6 +22078,10 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
21804
22078
  updateStatus();
21805
22079
  }
21806
22080
  renderRelatedCrumbs();
22081
+ renderRelatedList();
22082
+ if (relatedListResizeEl) {
22083
+ relatedListResizeEl.setAttribute("aria-label", t2.grid.relatedListResize);
22084
+ }
21807
22085
  embeddedGrid?.localize();
21808
22086
  }
21809
22087
  function rebuildFkColumnsForCurrentTable() {
@@ -22053,6 +22331,7 @@ ${entry.message}` : entry.detail ?? entry.message ?? "";
22053
22331
  }
22054
22332
  return {
22055
22333
  el: el2,
22334
+ focusGrid: () => viewport.focus({ preventScroll: true }),
22056
22335
  load,
22057
22336
  refresh: refreshCurrentTable,
22058
22337
  showError,
@@ -29342,7 +29621,7 @@ code-viewer annotate add-db --db app.db --tab query \\
29342
29621
  ],
29343
29622
  [
29344
29623
  "Detail footer & related panel",
29345
- "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."
29346
29625
  ],
29347
29626
  [
29348
29627
  "Schema tab",
@@ -30109,7 +30388,7 @@ code-viewer annotate add-db --db app.db --tab query \\
30109
30388
  ],
30110
30389
  [
30111
30390
  "詳細フッタ・関連パネル",
30112
- "セルをクリックするとリサイズ可能な詳細フッタが開きます。外部キー値からは関連行パネルが開き、ブレッドクラム付きで多段ドリルダウン可能。outgoing (FK→PK) と incoming (PK←FK) の両方向に対応。"
30391
+ "セルをクリックするとリサイズ可能な詳細フッタが開きます。JSON 値は整形してシンタックスハイライト付きで表示されます。グリッドにフォーカスがある間は矢印キーでデータセル間を移動でき、詳細フッタもその値に追従します(スクロールは見える位置まで必要なぶんだけ)。Enter で外部キーを辿り(矢印キーだけでは関連テーブルへのクエリは飛びません)、Escape で開いているパネルを閉じ、Tab / Shift+Tab でメイングリッドと関連グリッドを行き来できます。外部キー値からは関連行パネルが開き、ブレッドクラム付きで多段ドリルダウン可能。outgoing (FK→PK) と incoming (PK←FK) の両方向に対応。左の参照リストは各項目を1行に保ち、テーブル名と条件の全文は tooltip で確認できます。リスト幅はドラッグで変更でき、次回も保持されます。"
30113
30392
  ],
30114
30393
  [
30115
30394
  "Schema タブ",
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 {