편의 기능(정렬·종류필터·전체선택/단축키·정보 오버레이) + 스냅샷 범위 안전화

Snapshot 접근자 범위 안전화(핵심 버그):
- id/width/height/kind/hasThumb/thumbFailed가 count 밖 i에서 DataView RangeError를
  던져 Solid 반응성 그래프 전체가 무너지던 문제 수정. 스냅샷이 줄어들 때(필터/검색/
  폴더 전환) For가 초과 셀을 제거하기 전에 셀 접근자가 옛 인덱스로 재실행되며 발생.
  범위 밖이면 0/false 반환. (증상: 필터/검색이 한 번은 되고 이후 먹통)

기능:
- 정렬: 촬영일/이름/크기/수정일 × 오름·내림 (툴바 드롭다운+방향 토글).
  backend snap_tail로 ORDER BY 구성, 4개 스냅샷 커맨드에 sort/kind 파라미터 추가.
- 종류 필터: 전체/사진/영상 툴바 토글 (backend kind 필터).
- 전체 선택(Ctrl+A)/선택 반전(Ctrl+Shift+A), ? 키 단축키 도움말 오버레이.
- 썸네일 정보 오버레이(ⓘ 토글): 가시 셀에 파일명·크기 지연 로드 표시.

검증(사진120+영상25): 종류필터 120/25/145/120 반복 정상, 정렬 순서 변경, Ctrl+A
145개 선택, ? 오버레이, ⓘ로 파일명 표시 확인.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
강 한
2026-07-20 00:34:52 +09:00
co-authored by Claude Opus 4.8
parent ac3b527e8e
commit 67d2ddc59e
8 changed files with 367 additions and 36 deletions
+2
View File
@@ -8,6 +8,7 @@ import { ContextMenu } from "./features/fileops/ContextMenu";
import { MoveDialog } from "./features/fileops/MoveDialog";
import { DedupePanel } from "./features/dedupe/DedupePanel";
import { RemoteDialog } from "./features/sources/RemoteDialog";
import { ShortcutsOverlay } from "./shell/ShortcutsOverlay";
import { onLibraryChanged, onThumbsReady, type UnlistenFn } from "./ipc/commands";
import { bumpThumbVersions, loadAppInfo, refreshPendingCounts, refreshSnapshot } from "./state/store";
@@ -45,6 +46,7 @@ const App: Component = () => {
<MoveDialog />
<DedupePanel />
<RemoteDialog />
<ShortcutsOverlay />
</div>
);
};
+66
View File
@@ -10,6 +10,7 @@ import {
Show,
type Component,
} from "solid-js";
import { createStore, produce } from "solid-js/store";
import { computeLayout, type LayoutResult } from "./layout";
import {
anchorIndex,
@@ -17,9 +18,11 @@ import {
gridRowHeight,
info,
openLightbox,
openShortcuts,
selected,
selectSingle,
setSelectionFromIds,
showInfo,
toggleSelect,
selectRange,
snapshot,
@@ -38,6 +41,12 @@ import {
const GAP = 6;
const OVERSCAN_PX = 800;
function fmtSize(bytes: number): string {
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)}MB`;
if (bytes >= 1 << 10) return `${(bytes / (1 << 10)).toFixed(0)}KB`;
return `${bytes}B`;
}
/** rowTops에서 y 이상이 처음 나오는 행 인덱스 (이진탐색) */
function findRow(rowTops: Float32Array, y: number): number {
let lo = 0;
@@ -156,6 +165,35 @@ export const JustifiedGrid: Component = () => {
});
onCleanup(() => clearTimeout(prioTimer));
// 정보 오버레이용 파일명/크기 지연 로드 (showInfo 켜졌을 때 가시 범위만)
const [detailsCache, setDetailsCache] = createStore<Record<number, { name: string; size: number }>>({});
let detTimer: ReturnType<typeof setTimeout> | undefined;
createEffect(() => {
if (!showInfo()) return;
const [first, last] = visibleRange();
const snap = snapshot();
if (!snap || last < first) return;
clearTimeout(detTimer);
detTimer = setTimeout(() => {
const need: number[] = [];
for (let i = first; i <= last && need.length < 512; i++) {
const id = snap.id(i);
if (detailsCache[id] === undefined) need.push(id);
}
if (need.length === 0) return;
void fileDetails(need)
.then((ds) =>
setDetailsCache(
produce((s) => {
for (const d of ds) s[d.id] = { name: d.name, size: d.size };
}),
),
)
.catch(() => {});
}, 150);
});
onCleanup(() => clearTimeout(detTimer));
const indices = createMemo(() => {
const [first, last] = visibleRange();
const out: number[] = [];
@@ -206,6 +244,25 @@ export const JustifiedGrid: Component = () => {
void performUndo();
return;
}
// Ctrl+A 전체 선택 / Ctrl+Shift+A 선택 반전
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
e.preventDefault();
const all: number[] = [];
for (let i = 0; i < snap.count; i++) all.push(snap.id(i));
if (e.shiftKey) {
const cur = selected();
setSelectionFromIds(all.filter((id) => !cur.has(id)));
} else {
setSelectionFromIds(all);
}
return;
}
// ? 단축키 도움말
if (e.key === "?") {
e.preventDefault();
openShortcuts();
return;
}
switch (e.key) {
case "Enter":
@@ -456,6 +513,15 @@ export const JustifiedGrid: Component = () => {
</span>
</Show>
{/* 파일명·크기 오버레이 (툴바 ⓘ 토글) */}
<Show when={showInfo()}>
<div class="pointer-events-none absolute inset-x-0 bottom-0 bg-black/55 px-1 py-0.5 leading-tight">
<div class="truncate text-[10px] text-white/90">{detailsCache[id()]?.name ?? ""}</div>
<Show when={detailsCache[id()]}>
<div class="text-[9px] text-white/60">{fmtSize(detailsCache[id()]!.size)}</div>
</Show>
</div>
</Show>
</div>
);
}}
+32
View File
@@ -28,6 +28,38 @@ export const ko = {
empty: "표시할 항목이 없습니다",
items: (n: number) => `${n.toLocaleString("ko-KR")}개 항목`,
selected: (n: number) => `${n.toLocaleString("ko-KR")}개 선택됨`,
infoToggle: "썸네일에 파일명·크기 표시",
},
filter: {
all: "전체",
image: "사진",
video: "영상",
},
sort: {
taken: "촬영일",
name: "이름",
size: "크기",
mtime: "수정일",
asc: "오름차순",
desc: "내림차순",
},
shortcuts: {
title: "키보드 단축키",
close: "닫기 (Esc / ?)",
items: [
["Space / K", "재생·일시정지 (영상)"],
["← →", "이전·다음 항목 (그리드) / 5초 이동 (영상)"],
["↑ ↓", "위·아래 줄 이동 (그리드) / 볼륨 (영상)"],
["Enter / Space", "선택 항목 크게 보기"],
["Ctrl+A", "전체 선택"],
["Ctrl+Shift+A", "선택 반전"],
["Esc", "선택 해제 / 닫기"],
["Delete", "휴지통으로"],
["Ctrl+Z", "실행 취소"],
["F2", "이름 바꾸기"],
["Ctrl+휠", "썸네일 크기"],
["?", "이 도움말"],
] as [string, string][],
},
details: {
title: "상세 정보",
+36 -17
View File
@@ -88,24 +88,30 @@ export class Snapshot {
this.view = new DataView(buf);
this.count = Math.floor(buf.byteLength / ITEM_BYTES);
}
// 모든 접근자는 범위 밖 i에 안전해야 한다. 스냅샷이 줄어들 때(필터/검색/폴더 전환)
// For가 초과 셀을 제거하기 전에 셀의 반응형 접근자가 옛 인덱스로 재실행될 수 있는데,
// DataView가 범위를 벗어나면 RangeError를 던져 Solid 반응성 그래프 전체가 무너진다.
private ok(i: number): boolean {
return i >= 0 && i < this.count;
}
id(i: number): number {
return this.view.getUint32(i * ITEM_BYTES, true);
return this.ok(i) ? this.view.getUint32(i * ITEM_BYTES, true) : 0;
}
width(i: number): number {
return this.view.getUint16(i * ITEM_BYTES + 4, true);
return this.ok(i) ? this.view.getUint16(i * ITEM_BYTES + 4, true) : 0;
}
height(i: number): number {
return this.view.getUint16(i * ITEM_BYTES + 6, true);
return this.ok(i) ? this.view.getUint16(i * ITEM_BYTES + 6, true) : 0;
}
kind(i: number): number {
return this.view.getUint8(i * ITEM_BYTES + 8);
return this.ok(i) ? this.view.getUint8(i * ITEM_BYTES + 8) : 0;
}
hasThumb(i: number): boolean {
return (this.view.getUint8(i * ITEM_BYTES + 9) & 1) !== 0;
return this.ok(i) && (this.view.getUint8(i * ITEM_BYTES + 9) & 1) !== 0;
}
/** 썸네일 생성을 시도했으나 실패함(state 2) — 대기와 구분해 스켈레톤을 멈추기 위함 */
thumbFailed(i: number): boolean {
return (this.view.getUint8(i * ITEM_BYTES + 9) & 2) !== 0;
return this.ok(i) && (this.view.getUint8(i * ITEM_BYTES + 9) & 2) !== 0;
}
/** 레이아웃 워커 입력용 종횡비 배열 (미지 치수는 1:1) */
aspectRatios(): Float32Array {
@@ -119,21 +125,34 @@ export class Snapshot {
}
}
export async function folderSnapshot(args: {
sourceId?: number;
folderId?: number;
recursive?: boolean;
}): Promise<Snapshot> {
/** 정렬 키: taken(촬영/기본) | name | size | mtime */
export type SortKey = "taken" | "name" | "size" | "mtime";
/** 스냅샷 공통 옵션 — 정렬/종류 필터 */
export interface SnapOpts {
sortKey?: SortKey;
sortDesc?: boolean;
kind?: number | null; // 0=사진, 1=영상, null=전체
}
const snapArgs = (o?: SnapOpts) => ({
sortKey: o?.sortKey ?? null,
sortDesc: o?.sortDesc ?? null,
kind: o?.kind ?? null,
});
export async function folderSnapshot(
args: { sourceId?: number; folderId?: number; recursive?: boolean } & SnapOpts,
): Promise<Snapshot> {
const buf = await invoke<ArrayBuffer>("folder_snapshot", {
sourceId: args.sourceId ?? null,
folderId: args.folderId ?? null,
recursive: args.recursive ?? true,
...snapArgs(args),
});
return new Snapshot(buf);
}
export async function searchSnapshot(query: string): Promise<Snapshot> {
const buf = await invoke<ArrayBuffer>("search_snapshot", { query });
export async function searchSnapshot(query: string, opts?: SnapOpts): Promise<Snapshot> {
const buf = await invoke<ArrayBuffer>("search_snapshot", { query, ...snapArgs(opts) });
return new Snapshot(buf);
}
@@ -165,8 +184,8 @@ export const fileTagsOf = (fileId: number) => invoke<Tag[]>("file_tags_of", { fi
export const exportTags = (path?: string) =>
invoke<string>("export_tags", { path: path ?? null });
export async function tagSnapshot(tagId: number): Promise<Snapshot> {
return new Snapshot(await invoke<ArrayBuffer>("tag_snapshot", { tagId }));
export async function tagSnapshot(tagId: number, opts?: SnapOpts): Promise<Snapshot> {
return new Snapshot(await invoke<ArrayBuffer>("tag_snapshot", { tagId, ...snapArgs(opts) }));
}
// ── 파일 작업 ───────────────────────────────────────────
@@ -227,8 +246,8 @@ export const createSmartFolder = (name: string, query: SmartQuery) =>
export const deleteSmartFolder = (id: number) =>
invoke<void>("delete_smart_folder", { id });
export async function smartSnapshot(id: number): Promise<Snapshot> {
return new Snapshot(await invoke<ArrayBuffer>("smart_snapshot", { id }));
export async function smartSnapshot(id: number, opts?: SnapOpts): Promise<Snapshot> {
return new Snapshot(await invoke<ArrayBuffer>("smart_snapshot", { id, ...snapArgs(opts) }));
}
// ── 원격 소스 ───────────────────────────────────────────
+62 -1
View File
@@ -1,4 +1,4 @@
import { createResource, createSignal, onCleanup, Show, type Component } from "solid-js";
import { createResource, createSignal, For, onCleanup, Show, type Component } from "solid-js";
import { open } from "@tauri-apps/plugin-dialog";
import { ko } from "../i18n/ko";
import { JustifiedGrid } from "../features/grid/JustifiedGrid";
@@ -6,13 +6,22 @@ import {
GRID_ROW_MAX,
GRID_ROW_MIN,
gridRowHeight,
kindFilter,
refreshSidebar,
setGridRowHeight,
setKindFilter,
setSort,
setView,
showInfo,
sidebarVersion,
snapshot,
sortDesc,
sortKey,
toggleShowInfo,
updateScanProgress,
view,
type KindFilter,
type SortKey,
} from "../state/store";
import { addLocalSource, listSources, startScan } from "../ipc/commands";
import { openRemoteDialog } from "../features/sources/RemoteDialog";
@@ -61,6 +70,58 @@ export const ContentArea: Component = () => {
<span class="text-[11px] text-[var(--text-muted)]">
{snapshot() ? ko.grid.items(snapshot()!.count) : ""}
</span>
{/* 종류 필터: 전체 / 사진 / 영상 */}
<div class="flex overflow-hidden rounded border border-[var(--border)] text-[11px]">
<For each={[["all", ko.filter.all], ["image", ko.filter.image], ["video", ko.filter.video]] as [KindFilter, string][]}>
{([k, label]) => (
<button
class="px-2 py-0.5"
classList={{
"bg-[var(--bg-active)] text-[var(--text-primary)]": kindFilter() === k,
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": kindFilter() !== k,
}}
onClick={() => setKindFilter(k)}
>
{label}
</button>
)}
</For>
</div>
{/* 정렬: 키 선택 + 방향 토글 */}
<div class="flex items-center gap-1 text-[11px] text-[var(--text-secondary)]">
<select
class="rounded border border-[var(--border)] bg-[var(--bg-panel)] px-1.5 py-0.5 outline-none"
value={sortKey()}
onChange={(e) => setSort(e.currentTarget.value as SortKey, sortDesc())}
>
<For each={[["taken", ko.sort.taken], ["name", ko.sort.name], ["size", ko.sort.size], ["mtime", ko.sort.mtime]] as [SortKey, string][]}>
{([k, label]) => <option value={k}>{label}</option>}
</For>
</select>
<button
class="rounded border border-[var(--border)] px-1.5 py-0.5 hover:bg-[var(--bg-hover)]"
onClick={() => setSort(sortKey(), !sortDesc())}
title={sortDesc() ? ko.sort.desc : ko.sort.asc}
>
{sortDesc() ? "↓" : "↑"}
</button>
</div>
{/* 썸네일 정보 오버레이 토글 */}
<button
class="rounded border border-[var(--border)] px-1.5 py-0.5 text-[11px]"
classList={{
"bg-[var(--bg-active)] text-[var(--text-primary)]": showInfo(),
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": !showInfo(),
}}
onClick={toggleShowInfo}
title={ko.grid.infoToggle}
>
</button>
{/* 그리드 줌 슬라이더 */}
<div class="ml-auto flex items-center gap-1.5 text-[var(--text-muted)]" title="썸네일 크기 (Ctrl+휠)">
<span class="text-xs">🔍</span>
+58
View File
@@ -0,0 +1,58 @@
// 키보드 단축키 도움말 오버레이 (? 키로 열기).
import { For, onCleanup, onMount, Show, type Component } from "solid-js";
import { ko } from "../i18n/ko";
import { closeShortcuts, shortcutsOpen } from "../state/store";
export const ShortcutsOverlay: Component = () => {
onMount(() => {
const onKey = (e: KeyboardEvent) => {
if (!shortcutsOpen()) return;
if (e.key === "Escape" || e.key === "?") {
e.preventDefault();
e.stopPropagation();
closeShortcuts();
}
};
window.addEventListener("keydown", onKey, { capture: true });
onCleanup(() => window.removeEventListener("keydown", onKey, { capture: true }));
});
return (
<Show when={shortcutsOpen()}>
<div
class="fixed inset-0 z-50 grid place-items-center bg-black/50"
onClick={closeShortcuts}
>
<div
class="max-h-[80vh] w-[min(30rem,92vw)] overflow-y-auto rounded-lg border border-[var(--border)] bg-[var(--bg-panel)] p-5 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div class="mb-3 flex items-center justify-between">
<span class="text-sm font-semibold text-[var(--text-primary)]">
{ko.shortcuts.title}
</span>
<button
class="rounded px-2 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
onClick={closeShortcuts}
title={ko.shortcuts.close}
>
</button>
</div>
<div class="flex flex-col gap-1.5">
<For each={ko.shortcuts.items}>
{([keys, desc]) => (
<div class="flex items-center justify-between gap-4 text-xs">
<kbd class="shrink-0 rounded border border-[var(--border)] bg-[var(--bg-app)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--text-secondary)]">
{keys}
</kbd>
<span class="text-right text-[var(--text-secondary)]">{desc}</span>
</div>
)}
</For>
</div>
</div>
</div>
</Show>
);
};
+49 -6
View File
@@ -15,6 +15,8 @@ import {
type DedupeProgress,
type PendingCounts,
type ScanProgress,
type SnapOpts,
type SortKey,
} from "../ipc/commands";
// ── 앱 정보 (미디어 서버 포트/토큰 포함) ─────────────────
@@ -43,6 +45,36 @@ const [snapshot, setSnapshot] = createSignal<Snapshot | undefined>();
const [loading, setLoading] = createSignal(false);
export { view, snapshot, loading };
// ── 정렬 / 종류 필터 (툴바) ──────────────────────────────
const [sortKey, setSortKeySig] = createSignal<SortKey>("taken");
const [sortDesc, setSortDescSig] = createSignal(true);
export type KindFilter = "all" | "image" | "video";
const [kindFilter, setKindFilterSig] = createSignal<KindFilter>("all");
export { sortKey, sortDesc, kindFilter };
export type { SortKey };
/** 정렬 변경 → 재조회 */
export function setSort(key: SortKey, desc: boolean): void {
setSortKeySig(key);
setSortDescSig(desc);
void refreshSnapshot();
}
/** 종류 필터 변경(전체/사진/영상) → 재조회 */
export function setKindFilter(k: KindFilter): void {
setKindFilterSig(k);
void refreshSnapshot();
}
// 그리드 썸네일에 파일명·크기 오버레이 표시 (localStorage 유지)
const INFO_KEY = "archive.grid.infoOverlay";
const [showInfo, setShowInfoRaw] = createSignal(localStorage.getItem(INFO_KEY) === "1");
export { showInfo };
export function toggleShowInfo(): void {
const n = !showInfo();
setShowInfoRaw(n);
localStorage.setItem(INFO_KEY, n ? "1" : "0");
}
let viewSeq = 0;
export async function setView(v: View): Promise<void> {
@@ -55,26 +87,31 @@ export async function refreshSnapshot(): Promise<void> {
const v = view();
const seq = ++viewSeq;
setLoading(true);
const opts: SnapOpts = {
sortKey: sortKey(),
sortDesc: sortDesc(),
kind: kindFilter() === "image" ? 0 : kindFilter() === "video" ? 1 : null,
};
try {
let snap: Snapshot;
switch (v.type) {
case "all":
snap = await folderSnapshot({});
snap = await folderSnapshot({ ...opts });
break;
case "source":
snap = await folderSnapshot({ sourceId: v.sourceId });
snap = await folderSnapshot({ sourceId: v.sourceId, ...opts });
break;
case "folder":
snap = await folderSnapshot({ folderId: v.folderId, recursive: v.recursive });
snap = await folderSnapshot({ folderId: v.folderId, recursive: v.recursive, ...opts });
break;
case "search":
snap = await searchSnapshot(v.query);
snap = await searchSnapshot(v.query, opts);
break;
case "tag":
snap = await tagSnapshot(v.tagId);
snap = await tagSnapshot(v.tagId, opts);
break;
case "smart":
snap = await smartSnapshot(v.smartId);
snap = await smartSnapshot(v.smartId, opts);
break;
}
if (seq === viewSeq) setSnapshot(snap);
@@ -193,6 +230,12 @@ const [repeatMode, setRepeatMode] = createSignal(false);
export { repeatMode };
export const toggleRepeat = () => setRepeatMode((r) => !r);
// ── 단축키 도움말 오버레이 ───────────────────────────────
const [shortcutsOpen, setShortcutsOpen] = createSignal(false);
export { shortcutsOpen };
export const openShortcuts = () => setShortcutsOpen(true);
export const closeShortcuts = () => setShortcutsOpen(false);
// ── 중복 검토 패널 ───────────────────────────────────────
const [dedupeOpen, setDedupeOpen] = createSignal(false);
export { dedupeOpen };