Stage 2: file panels become a real 7.css ListView

The file list was a CSS grid of <div>s, so 7.css's listview component never
matched a single selector and Tailwind styled the whole thing. It is now the
plain <table> 7.css documents, which is what makes the Aero treatment appear.

- The column header row moved OUT of its own div and INTO <thead>: 7.css makes
  thead position:sticky, so it stays put while the body scrolls, and it has to
  be inside the same table to do that.
- Column widths moved from a computed grid-template-columns to <colgroup>,
  because 7.css sets table-layout: fixed. `gridTemplate()` is deleted.
- Selection is now `tr.highlighted` (7.css's gradient band + #aaddfa border)
  instead of bg-primary/15.
- The sort arrow was the text glyph ↑/↓ from `sortIndicator()`, which is also
  deleted; the sorted column is now `th.highlighted.indicator[.up]` and 7.css
  draws the real clip-path triangle in the header.
- Row height, padding, borders and fonts now come from 7.css, so --row-h and
  the per-cell text/color/font utilities are gone.

Two things 7.css does not cover, added in `skinfix`:
- Dark mode. listview.css hardcodes light literals (#fff, #c0c1cd, #eee and a
  white header gradient) rather than --w7-* vars, so it does NOT follow the
  hand-authored dark palette — the header labels rendered invisible, dark on
  dark. The dark arms restate those rules; light keeps 7.css's own values.
- The keyboard cursor. 7.css models selection only; Win7 drew the focused row
  with a dotted focus rectangle over it, which is what tells "cursor" apart
  from "selected" when they differ. That is `tr.is-cursor`.

Verified visually this time, not just by build log: harness screenshots in both
light and dark show the header gradient, sort triangle, cell separators,
selection band and cursor rectangle matching 7.css's reference markup. Backend
`tsc --noEmit` clean, `bun run build:ui` clean, bundle 489.83 kB (from 490.50).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-16 01:48:20 +02:00
co-authored by Claude
parent fe028a9b01
commit 11c3d419c7
4 changed files with 142 additions and 99 deletions
+21 -11
View File
@@ -91,17 +91,27 @@
</table> </table>
</figure> </figure>
<figure class="cell" style="margin:0"> <figure class="cell" style="margin:0">
<figcaption>ours — div grid (7.css never applies)</figcaption> <figcaption>ours — file-panel.html (selected + cursor row)</figcaption>
<div style="border:1px solid var(--w7-el-bd); background:#fff"> <table>
<div style="display:grid;grid-template-columns:1fr 5.5rem 8.5rem;font-size:11px;opacity:.7;border-bottom:1px solid #d7d7d7"> <colgroup>
<div style="padding:2px 8px">Name</div> <col />
<div style="padding:2px 8px;text-align:right">Size</div> <col style="width: 5.5rem" />
<div style="padding:2px 8px">Modified</div> <col style="width: 8.5rem" />
</div> </colgroup>
<div style="display:grid;grid-template-columns:1fr 5.5rem 8.5rem"><div style="padding:2px 8px">..</div><div style="padding:2px 8px;text-align:right">—</div><div style="padding:2px 8px">—</div></div> <thead>
<div style="display:grid;grid-template-columns:1fr 5.5rem 8.5rem"><div style="padding:2px 8px">Documents</div><div style="padding:2px 8px;text-align:right">—</div><div style="padding:2px 8px">3/29/2007</div></div> <tr>
<div style="display:grid;grid-template-columns:1fr 5.5rem 8.5rem;background:rgba(59,130,246,.15)"><div style="padding:2px 8px">report.pdf</div><div style="padding:2px 8px;text-align:right">27 KB</div><div style="padding:2px 8px">3/23/2007</div></div> <th>Name</th>
</div> <th class="highlighted indicator up" style="text-align:right">Size</th>
<th>Modified</th>
</tr>
</thead>
<tbody>
<tr><td>..</td><td style="text-align:right">—</td><td>—</td></tr>
<tr><td>Documents</td><td style="text-align:right">—</td><td>3/29/2007 12:00 AM</td></tr>
<tr class="highlighted"><td>report.pdf</td><td style="text-align:right">27 KB</td><td>3/23/2007 12:00 AM</td></tr>
<tr class="is-cursor"><td>notes.txt</td><td style="text-align:right">4 KB</td><td>3/13/2007 12:00 AM</td></tr>
</tbody>
</table>
</figure> </figure>
</div> </div>
+90 -79
View File
@@ -191,26 +191,8 @@
</button> </button>
</div> </div>
<!-- column headers (right-click to add/remove columns) --> <!-- Column headers live INSIDE the table now (7.css makes thead sticky), so
<div there is no separate header row here — see the ListView below. -->
(contextmenu)="headerContextMenu($event)"
[style.grid-template-columns]="gridTemplate()"
class="grid border-b border-border text-[11px] font-medium text-muted-foreground"
>
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')">
{{ i18n.t('columns.name') }} {{ sortIndicator('name') }}
</button>
@for (col of visibleColumns(); track col.key) {
<button
class="px-2 py-1 hover:text-foreground"
[class.text-right]="col.key === 'size'"
[class.text-left]="col.key !== 'size'"
(click)="panel().toggleSort(col.key)"
>
{{ i18n.t('columns.' + col.key) }} {{ sortIndicator(col.key) }}
</button>
}
</div>
<!-- type-to-filter bar (appears while a query is active) --> <!-- type-to-filter bar (appears while a query is active) -->
@if (panel().filter(); as query) { @if (panel().filter(); as query) {
@@ -256,67 +238,96 @@
</div> </div>
</div> </div>
} @else { } @else {
@for (entry of panel().sorted(); track entry.name; let i = $index) { <!-- 7.css ListView: a plain <table>. The header is <thead> (7.css makes it
<div position:sticky, which is why it moved in here), the sorted column is
[attr.data-index]="i" .highlighted.indicator[.up], and the selection band is tr.highlighted.
(click)="rowClick($event, i)" Widths come from <colgroup> because 7.css sets table-layout:fixed. -->
(dblclick)="openAt(i)" <table>
(contextmenu)="rowContextMenu($event, i)" <colgroup>
[style.grid-template-columns]="gridTemplate()" <col />
class="grid h-(--row-h) items-center text-(length:--list-font)"
[class.bg-cursor-tint]="i === panel().cursor() && !isSelected(entry)"
[class.bg-primary/15]="isSelected(entry)"
[class.ring-1]="i === panel().cursor() && isActive()"
[class.ring-inset]="i === panel().cursor() && isActive()"
[class.ring-ring]="i === panel().cursor() && isActive()"
>
<div class="flex min-w-0 items-center gap-1.5 px-2">
<ng-icon [name]="iconFor(entry)" size="0.95rem" [class]="iconClass(entry)" />
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
</div>
@for (col of visibleColumns(); track col.key) { @for (col of visibleColumns(); track col.key) {
@switch (col.key) { <col [style.width]="col.width" />
@case ('size') {
<span class="px-2 text-right font-mono text-(length:--list-meta-font) tabular-nums text-muted-foreground">
{{ entry.type === 'dir' ? '—' : formatSize(entry.size) }}
</span>
}
@case ('mtime') {
<span class="px-2 font-mono text-(length:--list-meta-font) tabular-nums text-muted-foreground">
{{ formatDate(entry.mtime) }}
</span>
}
@case ('kind') {
<span class="truncate px-2 text-(length:--list-meta-font) text-muted-foreground">
{{ fileKind(entry) }}
</span>
<!-- fileKind is localized in file-panel.ts via i18n -->
}
@case ('rights') {
<span class="px-2 font-mono text-(length:--list-meta-font) text-muted-foreground">
{{ entry.name === '..' ? '' : formatMode(entry.mode) }}
</span>
}
@case ('owner') {
<span class="truncate px-2 font-mono text-(length:--list-meta-font) text-muted-foreground">
{{ entry.name === '..' ? '' : (entry.owner ?? '—') }}
</span>
}
@case ('group') {
<span class="truncate px-2 font-mono text-(length:--list-meta-font) text-muted-foreground">
{{ entry.name === '..' ? '' : (entry.group ?? '—') }}
</span>
}
}
} }
</div> </colgroup>
} @empty { <thead>
<div class="p-6 text-center text-xs text-muted-foreground">{{ i18n.t('panel.emptyDir') }}</div> <tr (contextmenu)="headerContextMenu($event)">
} <th
@if (panel().filter() && filterMatches() === 0) { (click)="panel().toggleSort('name')"
<div class="p-6 text-center text-xs text-muted-foreground">{{ i18n.t('panel.noMatches') }}</div> [class.highlighted]="panel().sortKey() === 'name'"
} [class.indicator]="panel().sortKey() === 'name'"
[class.up]="panel().sortKey() === 'name' && panel().sortAsc()"
>
{{ i18n.t('columns.name') }}
</th>
@for (col of visibleColumns(); track col.key) {
<th
(click)="panel().toggleSort(col.key)"
[class.highlighted]="panel().sortKey() === col.key"
[class.indicator]="panel().sortKey() === col.key"
[class.up]="panel().sortKey() === col.key && panel().sortAsc()"
[style.text-align]="col.key === 'size' ? 'right' : 'left'"
>
{{ i18n.t('columns.' + col.key) }}
</th>
}
</tr>
</thead>
<tbody>
@for (entry of panel().sorted(); track entry.name; let i = $index) {
<tr
[attr.data-index]="i"
(click)="rowClick($event, i)"
(dblclick)="openAt(i)"
(contextmenu)="rowContextMenu($event, i)"
[class.highlighted]="isSelected(entry)"
[class.is-cursor]="i === panel().cursor() && isActive()"
>
<td>
<span class="flex min-w-0 items-center gap-1.5">
<ng-icon [name]="iconFor(entry)" size="0.95rem" [class]="iconClass(entry)" />
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
</span>
</td>
@for (col of visibleColumns(); track col.key) {
@switch (col.key) {
@case ('size') {
<td style="text-align: right">{{ entry.type === 'dir' ? '—' : formatSize(entry.size) }}</td>
}
@case ('mtime') {
<td>{{ formatDate(entry.mtime) }}</td>
}
@case ('kind') {
<!-- fileKind is localized in file-panel.ts via i18n -->
<td>{{ fileKind(entry) }}</td>
}
@case ('rights') {
<td>{{ entry.name === '..' ? '' : formatMode(entry.mode) }}</td>
}
@case ('owner') {
<td>{{ entry.name === '..' ? '' : (entry.owner ?? '—') }}</td>
}
@case ('group') {
<td>{{ entry.name === '..' ? '' : (entry.group ?? '—') }}</td>
}
}
}
</tr>
} @empty {
<tr>
<td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 24px">
{{ i18n.t('panel.emptyDir') }}
</td>
</tr>
}
@if (panel().filter() && filterMatches() === 0) {
<tr>
<td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 24px">
{{ i18n.t('panel.noMatches') }}
</td>
</tr>
}
</tbody>
</table>
} }
</div> </div>
@if (panel().slowLoad() && !panel().connecting()) { @if (panel().slowLoad() && !panel().connecting()) {
-8
View File
@@ -84,9 +84,6 @@ export class FilePanel {
const visible = this.settings.columns(); const visible = this.settings.columns();
return COLUMN_DEFS.filter((c) => visible.includes(c.key)); return COLUMN_DEFS.filter((c) => visible.includes(c.key));
}); });
readonly gridTemplate = computed(() =>
['minmax(0,1fr)', ...this.visibleColumns().map((c) => c.width)].join(' '),
);
headerContextMenu(event: MouseEvent): void { headerContextMenu(event: MouseEvent): void {
event.preventDefault(); event.preventDefault();
@@ -197,11 +194,6 @@ export class FilePanel {
.filter((e) => e.name !== '..').length; .filter((e) => e.name !== '..').length;
} }
sortIndicator(key: SortKey): string {
if (this.panel().sortKey() !== key) return '';
return this.panel().sortAsc() ? '↑' : '↓';
}
commitPath(): void { commitPath(): void {
if (this.blocked()) return; if (this.blocked()) return;
const value = this.pathDraft().trim(); const value = this.pathDraft().trim();
+31 -1
View File
@@ -598,13 +598,43 @@ body {
neighbouring layer. */ neighbouring layer. */
@layer skinfix { @layer skinfix {
/* Opt back IN to real Win7 push-button metrics, for the dialog and options /* Opt back IN to real Win7 push-button metrics, for the dialog and options
buttons where they are right. Inert under the modern skin. */ buttons where they are right. */
.win7 .w7-btn { .win7 .w7-btn {
min-width: 75px; min-width: 75px;
min-height: 23px; min-height: 23px;
padding: 0 12px; padding: 0 12px;
} }
/* ---- ListView (file panels) ----
7.css's listview.css hardcodes light literals (#fff, #c0c1cd, #eee and the
white header gradient) instead of --w7-* vars, so unlike the rest of the
skin it does NOT follow the hand-authored dark palette — the header labels
came out invisible, dark on dark. These restate the same rules in dark
terms; they are scoped to the dark arms only so light keeps 7.css's own
values untouched. */
.win7[data-theme='dark'] table,
.win7[data-theme='dark'] table > tbody > tr > * {
background-color: var(--w7-el-bg-s-2);
border-color: var(--w7-el-bd-d);
color: var(--w7-el-c);
}
.win7[data-theme='dark'] table > thead > tr > * {
background: linear-gradient(180deg, var(--w7-el-bg) 45%, var(--w7-el-bg-d) 0, var(--w7-el-bg-s-1));
border-color: var(--w7-el-bd-d);
color: var(--w7-el-c);
}
.win7[data-theme='dark'] table > tbody > tr > :not(:last-child) {
border-right-color: var(--w7-el-bd-d);
}
/* The keyboard cursor. 7.css models only selection (tr.highlighted); Win7
drew the focused row with a dotted focus rectangle on top of it, which is
what distinguishes "cursor" from "selected" when they differ. */
.win7 table > tbody > tr.is-cursor > * {
outline: 1px dotted var(--w7-el-c);
outline-offset: -1px;
}
/* Aero context menu. 7.css has no menu component, so this is hand-authored — /* Aero context menu. 7.css has no menu component, so this is hand-authored —
but ONLY out of the existing --w7-* vars, never literal hex, so the but ONLY out of the existing --w7-* vars, never literal hex, so the
hand-written dark Win7 palette above recolours it for free. Values are hand-written dark Win7 palette above recolours it for free. Values are