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

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
+62 -12
View File
@@ -373,7 +373,32 @@ fn pack_snapshot(rows: Vec<(i64, Option<u32>, Option<u32>, Option<u32>, i64, i64
type SnapRow = (i64, Option<u32>, Option<u32>, Option<u32>, i64, i64);
const SNAP_COLS: &str = "f.id, f.width, f.height, f.orientation, f.kind, f.thumb_state";
const SNAP_ORDER: &str = "ORDER BY COALESCE(f.taken_at, f.mtime_ms) DESC, f.id DESC";
/// 스냅샷 공통 옵션 — 종류 필터 + 정렬. 프론트 툴바에서 전달.
#[derive(Default)]
pub struct SnapOpts {
pub sort_key: Option<String>, // taken(기본)|name|size|mtime
pub sort_desc: Option<bool>, // 기본 내림차순
pub kind: Option<i64>, // 0=사진, 1=영상, 그 외/None=전체
}
/// WHERE 꼬리(종류 필터) + ORDER BY 절을 만든다.
/// sort_key/kind는 고정 집합에서만 해석하므로 SQL 주입 안전(kind는 i64 리터럴).
fn snap_tail(o: &SnapOpts) -> String {
let kind_clause = match o.kind {
Some(0) => " AND f.kind = 0",
Some(1) => " AND f.kind = 1",
_ => "",
};
let dir = if o.sort_desc.unwrap_or(true) { "DESC" } else { "ASC" };
let order = match o.sort_key.as_deref() {
Some("name") => format!("ORDER BY f.name COLLATE NOCASE {dir}, f.id {dir}"),
Some("size") => format!("ORDER BY f.size {dir}, f.id {dir}"),
Some("mtime") => format!("ORDER BY f.mtime_ms {dir}, f.id {dir}"),
_ => format!("ORDER BY COALESCE(f.taken_at, f.mtime_ms) {dir}, f.id {dir}"),
};
format!("{kind_clause} {order}")
}
fn map_snap_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<SnapRow> {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?))
@@ -385,7 +410,11 @@ pub fn folder_snapshot(
source_id: Option<i64>,
folder_id: Option<i64>,
recursive: bool,
sort_key: Option<String>,
sort_desc: Option<bool>,
kind: Option<i64>,
) -> CmdResult<Response> {
let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind });
let rows: Vec<SnapRow> = state
.db
.with_read(|conn| {
@@ -393,7 +422,7 @@ pub fn folder_snapshot(
(Some(fid), false) => {
let sql = format!(
"SELECT {SNAP_COLS} FROM files f
WHERE f.folder_id = ?1 AND f.deleted_at IS NULL {SNAP_ORDER}"
WHERE f.folder_id = ?1 AND f.deleted_at IS NULL {tail}"
);
let mut stmt = conn.prepare_cached(&sql)?;
let rows = stmt.query_map([fid], |r| map_snap_row(r))?;
@@ -411,7 +440,7 @@ pub fn folder_snapshot(
"SELECT {SNAP_COLS} FROM files f
JOIN folders fo ON fo.id = f.folder_id
WHERE fo.source_id = ?1 AND (fo.path = ?2 OR fo.path LIKE ?3)
AND f.deleted_at IS NULL {SNAP_ORDER}"
AND f.deleted_at IS NULL {tail}"
);
let mut stmt = conn.prepare_cached(&sql)?;
let rows =
@@ -422,7 +451,7 @@ pub fn folder_snapshot(
Some(sid) => {
let sql = format!(
"SELECT {SNAP_COLS} FROM files f
WHERE f.source_id = ?1 AND f.deleted_at IS NULL {SNAP_ORDER}"
WHERE f.source_id = ?1 AND f.deleted_at IS NULL {tail}"
);
let mut stmt = conn.prepare_cached(&sql)?;
let rows = stmt.query_map([sid], |r| map_snap_row(r))?;
@@ -431,7 +460,7 @@ pub fn folder_snapshot(
None => {
let sql = format!(
"SELECT {SNAP_COLS} FROM files f
WHERE f.deleted_at IS NULL {SNAP_ORDER}"
WHERE f.deleted_at IS NULL {tail}"
);
let mut stmt = conn.prepare_cached(&sql)?;
let rows = stmt.query_map([], |r| map_snap_row(r))?;
@@ -445,11 +474,18 @@ pub fn folder_snapshot(
}
#[tauri::command]
pub fn search_snapshot(state: tauri::State<'_, AppState>, query: String) -> CmdResult<Response> {
pub fn search_snapshot(
state: tauri::State<'_, AppState>,
query: String,
sort_key: Option<String>,
sort_desc: Option<bool>,
kind: Option<i64>,
) -> CmdResult<Response> {
let q = query.trim().to_string();
if q.is_empty() {
return Ok(Response::new(Vec::new()));
}
let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind });
let use_fts = state.db.fts_enabled && q.chars().count() >= 3;
let rows: Vec<SnapRow> = state
.db
@@ -458,7 +494,7 @@ pub fn search_snapshot(state: tauri::State<'_, AppState>, query: String) -> CmdR
let sql = format!(
"SELECT {SNAP_COLS} FROM files_fts ft
JOIN files f ON f.id = ft.rowid
WHERE files_fts MATCH ?1 AND f.deleted_at IS NULL {SNAP_ORDER}"
WHERE files_fts MATCH ?1 AND f.deleted_at IS NULL {tail}"
);
let mut stmt = conn.prepare_cached(&sql)?;
// MATCH 구문 특수문자 무력화를 위해 따옴표로 감싼다
@@ -468,7 +504,7 @@ pub fn search_snapshot(state: tauri::State<'_, AppState>, query: String) -> CmdR
} else {
let sql = format!(
"SELECT {SNAP_COLS} FROM files f
WHERE f.name LIKE '%' || ?1 || '%' ESCAPE '\\' AND f.deleted_at IS NULL {SNAP_ORDER}"
WHERE f.name LIKE '%' || ?1 || '%' ESCAPE '\\' AND f.deleted_at IS NULL {tail}"
);
let mut stmt = conn.prepare_cached(&sql)?;
let escaped = q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
@@ -619,14 +655,21 @@ pub fn export_tags(state: tauri::State<'_, AppState>, path: Option<String>) -> C
}
#[tauri::command]
pub fn tag_snapshot(state: tauri::State<'_, AppState>, tag_id: i64) -> CmdResult<Response> {
pub fn tag_snapshot(
state: tauri::State<'_, AppState>,
tag_id: i64,
sort_key: Option<String>,
sort_desc: Option<bool>,
kind: Option<i64>,
) -> CmdResult<Response> {
let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind });
let rows: Vec<SnapRow> = state
.db
.with_read(|conn| {
let sql = format!(
"SELECT {SNAP_COLS} FROM files f
JOIN file_tags ft ON ft.file_id = f.id
WHERE ft.tag_id = ?1 AND f.deleted_at IS NULL {SNAP_ORDER}"
WHERE ft.tag_id = ?1 AND f.deleted_at IS NULL {tail}"
);
let mut stmt = conn.prepare_cached(&sql)?;
let rows = stmt.query_map([tag_id], |r| map_snap_row(r))?;
@@ -797,7 +840,14 @@ pub fn delete_smart_folder(state: tauri::State<'_, AppState>, id: i64) -> CmdRes
}
#[tauri::command]
pub fn smart_snapshot(state: tauri::State<'_, AppState>, id: i64) -> CmdResult<Response> {
pub fn smart_snapshot(
state: tauri::State<'_, AppState>,
id: i64,
sort_key: Option<String>,
sort_desc: Option<bool>,
kind: Option<i64>,
) -> CmdResult<Response> {
let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind });
let query: SmartQuery = state
.db
.with_read(|conn| {
@@ -834,7 +884,7 @@ pub fn smart_snapshot(state: tauri::State<'_, AppState>, id: i64) -> CmdResult<R
let rows: Vec<SnapRow> = state
.db
.with_read(|conn| {
let sql = format!("SELECT {SNAP_COLS} FROM files f WHERE {where_sql} {SNAP_ORDER}");
let sql = format!("SELECT {SNAP_COLS} FROM files f WHERE {where_sql} {tail}");
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([], |r| map_snap_row(r))?;
rows.collect()