Files
miniSCP/ui/src/app/skins/shadlike/dialogs/overwrite-dialog.ts
T
jgamboaandClaude 9f2a076e08 Scale stage 1: everything the UI sizes with is now rem
Groundwork for the continuous interface scale (a root font-size lever): every
size the UI derives from must be root-relative, or it freezes while the rest
grows. Gate for this commit: at the default 16px root the app renders
pixel-identical to before — every conversion is exact (11px = 0.6875rem …).

- The 8 panel-chrome tokens in styles.css: px -> rem, and the data-text
  compact/large tiers are DELETED — a continuous scale replaces discrete
  tiers. Same for --w7-fs (12px -> 0.75rem) and the win7 --radius (5px ->
  0.3125rem, still landing rounded-md exactly on --w7-el-bdr's 3px).
  --radius-sm/md subtrahends: px -> rem, or corners would sharpen as the UI
  grew.
- All 93 text-[11px]/text-[10px] arbitraries -> rem equivalents, plus the two
  inline padding:24px empty-state cells in the 7like file panel.
- 7like.css gains a scale bridge in skinfix: 7.css is 370 px values with zero
  rem, and --w7-font reaches only 15 rules' TYPOGRAPHY — under a root lever
  win7 text would grow inside boxes frozen at 23px/14px/15px. The bridge
  re-expresses 7.css's size vars and its hardcoded box metrics (buttons via
  .w7-btn only, inputs, table rows/headers, title-bar controls, menu items,
  tabs, the 10pt check glyph) in rem at identical scale-1 pixels. 1px
  hairlines and focus rects stay px deliberately — crisp at any scale, and
  the tr border exists precisely to stop selection shifting rows.
- Two skinfix traps found while writing it, one by reasoning and one by
  pixels: (a) NO blanket `.win7 button` restatement — skinfix outranks the
  skin-layer neutralizer that strips 7.css's 75x23 metrics off the ~84 icon
  buttons, so a blanket rule would re-inflate them all; (b) restating a base
  rule in skinfix also outranks 7.css's own VARIANTS of it by layer — the
  title-bar rule silently shrank the close button from 48px to 29px until the
  harness pixel diff caught it, and the active tab's padding-bottom needed
  the same treatment. Both variants are restated alongside their base rules.
- TS px constants that mirror rem CSS are the latent-bug half of this work:
  both context menus' flip/clamp math converts through a new @core/metrics
  remPx() helper reading the LIVE root font-size (the shadlike menu's
  MENU_W=224 diverged from its w-56 box the moment scale != 1; the 7like
  menu's [style.width.px] pinned the box at 200px while text grew — it now
  binds width in rem, so math and box cannot disagree). The editors'
  cachedCharWidth is now keyed on the computed font string, self-invalidating
  under any font change, and both || px fallbacks derive from the element's
  font size instead of assuming a 16px root.

Verified: tsc + build clean; modern and win7 live captures and the harness
all diff to zero against pre-change baselines in their opaque regions (the
harness to exactly zero end to end; modern's only delta is the native GTK
titlebar focus state; win7's is the translucent Aero corner over the
desktop).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 18:02:41 +02:00

150 lines
5.3 KiB
TypeScript

import {
Component,
ElementRef,
afterNextRender,
computed,
inject,
input,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service';
import { FocusService } from '@core/focus.service';
@Component({
selector: 'app-overwrite-dialog',
imports: [NgIcon],
template: `
<div
class="fixed inset-0 z-40 grid place-items-center bg-black/40"
(keydown.escape)="pick('cancel')"
>
<div
class="w-[26rem] rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
>
<div class="flex items-start justify-between gap-2">
<h2 class="min-w-0 text-sm font-semibold">
{{ isDir() ? i18n.t('dialogs.mergeFolder') : i18n.t('dialogs.replace') }}
<span class="font-mono">"{{ request().srcEntry.name }}"</span>?
</h2>
@if (request().total > 1) {
<span
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[0.625rem] font-medium text-muted-foreground"
>
{{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }}
</span>
}
</div>
<p class="mt-1 text-xs text-muted-foreground">
@if (isDir()) {
{{ i18n.t('dialogs.overwriteDirBody') }}
} @else {
{{ i18n.t('dialogs.overwriteFileBody') }}
}
</p>
<!-- comparison -->
<div class="mt-3 grid grid-cols-2 gap-2">
@for (side of comparison(); track side.label) {
<div class="rounded-md border border-border p-2.5">
<div class="flex items-center justify-between">
<span class="text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
{{ i18n.t('dialogs.' + side.label) }}
</span>
@if (side.newer) {
<span class="rounded-full bg-success/15 px-1.5 text-[0.625rem] font-medium text-success">
{{ i18n.t('dialogs.newer') }}
</span>
}
</div>
<div class="mt-1.5 flex items-center gap-1.5 text-xs">
<ng-icon
[name]="isDir() ? 'mynaFolder' : 'mynaFile'"
size="0.9rem"
class="shrink-0 text-muted-foreground"
/>
<span class="font-mono tabular-nums">
{{ isDir() ? '—' : i18n.size(side.entry.size) }}
</span>
</div>
<div class="mt-1 font-mono text-[0.6875rem] tabular-nums text-muted-foreground">
{{ i18n.date(side.entry.mtime) }}
</div>
</div>
}
</div>
<!-- actions -->
<div class="mt-4 flex items-center gap-2">
<button
(click)="pick('cancel')"
class="rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ i18n.t('common.cancel') }}
</button>
<div class="flex-1"></div>
@if (request().total > 1) {
<button
(click)="pick('skipAll')"
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ i18n.t('dialogs.skipAll') }}
</button>
<button
(click)="pick('overwriteAll')"
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ isDir() ? i18n.t('dialogs.mergeAll') : i18n.t('dialogs.replaceAll') }}
</button>
<div class="h-4 w-px bg-border"></div>
}
<button
(click)="pick('skip')"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('dialogs.skip') }}
</button>
<button
#primaryBtn
(click)="pick('overwrite')"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{{ isDir() ? i18n.t('dialogs.merge') : i18n.t('dialogs.replace') }}
</button>
</div>
</div>
</div>
`,
})
export class OverwriteDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<OverwriteRequest>();
private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService);
private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn');
constructor() {
afterNextRender(() => this.primaryBtn().nativeElement.focus());
}
readonly isDir = computed(() => this.request().srcEntry.type === 'dir');
readonly comparison = computed(() => {
const { srcEntry, dstEntry } = this.request();
return [
{ label: 'source', entry: srcEntry, newer: srcEntry.mtime > dstEntry.mtime },
{ label: 'destination', entry: dstEntry, newer: dstEntry.mtime > srcEntry.mtime },
];
});
pick(choice: OverwriteChoice): void {
const req = this.request();
this.dialogs.overwrite.set(null);
this.focus.bump();
req.resolve(choice);
}
}