Compare commits

...

7 Commits

Author SHA1 Message Date
LyAhn a40e5c2771 feat: watchdog, EXIF capture date, and timeline view
Merges feat/watchdog-exif-timeline into main.

- Adaptive filesystem watchdog: switches between inotify/ReadDirectoryChanges
  and polling with zero CPU when idle; watcher events update sidebar counts live
- EXIF taken_at: extracted at index time and stored as a sortable column;
  new taken_asc/taken_desc sort orders; migration is safe on existing DBs
- Timeline view: virtualised month-grouped grid (react-virtual v3), reuses
  ImageTile/ContextMenu, Calendar icon in Sidebar, defaults to taken_asc sort
- Bug fixes: startup migration panic on existing DBs without taken_at column,
  "Unknown Date" grouping due to null created_at fallback, watcher not updating
  folder image counts, cols overflow in Timeline grid
2026-06-08 22:54:33 +01:00
LyAhn a2804d8c1b fix(timeline): CodeRabbit review corrections
- Fix cols overflow: use (containerWidth - GAP) formula so the grid
  never exceeds the container width
- Explicitly pin "unknown" date keys to the end of the sort rather than
  relying on alphabetical accident ("u" > digits)
- Guard buildLabel against non-YYYY-MM keys with finite/range checks
  and an isNaN fallback to "Unknown Date"
- Clear similarSourceFolderId, similarFolderId, and similarCrop when
  switching to the timeline view so all similar-state is consistently
  reset
2026-06-08 22:26:28 +01:00
LyAhn 0ab156d2d9 fix: startup crash, watcher count, timeline date fallback
db.rs:
- Move idx_images_taken_at creation to after ensure_column so it never
  runs against a schema where taken_at doesn't exist yet; this was causing
  a startup panic on any DB that predates Phase 1
- COALESCE(taken_at, created_at) → COALESCE(taken_at, modified_at) since
  created_at is never populated (modified_at is always set)
- Remove dead is_tagging_job_cancelled function (superseded by
  is_tagging_job_processing) and unused thumbnail_path from ImagePathRecord

indexer.rs:
- Watcher create path now calls update_folder_count after commit_batch and
  emits folder-counts-changed so the sidebar count stays in sync; the
  notification is only emitted when the DB write actually succeeds

store.ts / Timeline.tsx:
- compareImages taken_asc/taken_desc: fall back to modified_at not
  created_at (created_at is always null)
- Timeline groupImages: same fallback fix so images group by month
  correctly for libraries not yet re-indexed for EXIF
- subscribeToProgress: listen for folder-counts-changed and call
  loadFolders() to keep the sidebar image count live
2026-06-08 22:09:50 +01:00
LyAhn 9ace1f6778 chore: remove plan handoff document 2026-06-08 20:06:17 +01:00
LyAhn ec6be96c6a feat(timeline): add virtualised month-grouped timeline view
- New Timeline view groups all media by YYYY-MM using taken_at ?? created_at
- @tanstack/react-virtual (group-level virtualiser) keeps scroll smooth on
  large libraries; estimateSize is exact so no measureElement needed
- ResizeObserver tracks container width; virtualizer.measure() is called on
  cols change to prevent stale position cache after window resize
- setView("timeline") in store auto-sets sort:"taken_asc", resets images,
  and triggers a fresh load
- Calendar nav item added to Sidebar between Explore and Duplicates
- ContextMenu and ImageTile exported from Gallery for reuse in Timeline
2026-06-08 18:19:01 +01:00
LyAhn ae9e806e61 feat(watcher): adaptive filesystem watchdog with zero CPU when idle
Monitors all registered folders using OS-native events (ReadDirectoryChangesW
on Windows) so new, modified, and deleted files are reflected in the gallery
automatically without manual reindexing.

Key design points:
- Adaptive blocking: recv() when no events pending (zero CPU), switches to
  recv_timeout(earliest_deadline) only when debounce timers are running — wakes
  exactly when the soonest event is ready, no busy-polling
- 500 ms per-path debounce coalesces rapid OS event bursts into one action
- Change detection preserved: build_record skips upsert if file_size + mtime
  unchanged, preventing spurious thumbnail/embedding re-queues and avoiding
  clobbering of existing metadata like thumbnail_path
- Access events (reads) filtered out; only Create/Modify/Remove trigger work
- Deletion path: emits watcher-deleted event with image IDs; frontend removes
  those images from state and clears selectedImage if it was deleted

WatcherHandle stored in app state; add_folder / remove_folder / update_folder_path
commands keep the watched path set in sync with the DB.
2026-06-08 06:50:01 +01:00
LyAhn 9ee5b08c93 feat(exif): extract capture date and surface as sortable taken_at field
Adds EXIF date extraction during indexing so photos can be sorted by when
they were actually taken, not just when the file was modified on disk.

- Add `taken_at` (nullable TEXT, ISO 8601) column to the images table via
  ensure_column migration and a new idx_images_taken_at index
- Populate taken_at in build_record via extract_exif_date, which tries
  DateTimeOriginal → DateTimeDigitized → DateTime tags and rejects all-zero
  sentinel dates written by uninitialised cameras
- upsert_image uses taken_at = excluded.taken_at so a re-indexed file gets
  fresh EXIF; stale dates from replaced files are not preserved
- Add taken_asc / taken_desc sort using COALESCE(taken_at, created_at) so
  images without EXIF gracefully fall back to their creation date
- Surface as "Taken: newest / oldest" in the Toolbar sort dropdown
- Shift map_image_row column indices after the new taken_at (index 10)
2026-06-08 00:26:36 +01:00
12 changed files with 810 additions and 53 deletions
+107 -1
View File
@@ -1750,6 +1750,15 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "futf" name = "futf"
version = "0.1.5" version = "0.1.5"
@@ -2932,6 +2941,26 @@ dependencies = [
"cfb", "cfb",
] ]
[[package]]
name = "inotify"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"
@@ -3113,6 +3142,15 @@ dependencies = [
"serde_json", "serde_json",
] ]
[[package]]
name = "kamadak-exif"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077"
dependencies = [
"mutate_once",
]
[[package]] [[package]]
name = "keyboard-types" name = "keyboard-types"
version = "0.7.0" version = "0.7.0"
@@ -3124,6 +3162,26 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "kqueue"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
"bitflags 2.11.0",
"libc",
]
[[package]] [[package]]
name = "kuchikiki" name = "kuchikiki"
version = "0.8.8-speedreader" version = "0.8.8-speedreader"
@@ -3429,6 +3487,18 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.0" version = "1.2.0"
@@ -3510,6 +3580,12 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "mutate_once"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
[[package]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.18" version = "0.2.18"
@@ -3606,6 +3682,25 @@ dependencies = [
"minimal-lexical", "minimal-lexical",
] ]
[[package]]
name = "notify"
version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
dependencies = [
"bitflags 2.11.0",
"crossbeam-channel",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio 0.8.11",
"walkdir",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "notify-rust" name = "notify-rust"
version = "4.17.0" version = "4.17.0"
@@ -4290,8 +4385,10 @@ dependencies = [
"hf-hub", "hf-hub",
"hnsw_rs", "hnsw_rs",
"image", "image",
"kamadak-exif",
"log", "log",
"memmap2", "memmap2",
"notify",
"ort", "ort",
"r2d2", "r2d2",
"r2d2_sqlite", "r2d2_sqlite",
@@ -6376,7 +6473,7 @@ checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
"mio", "mio 1.2.0",
"parking_lot", "parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "signal-hook-registry",
@@ -7503,6 +7600,15 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
+2
View File
@@ -50,6 +50,8 @@ ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "n
ureq = { version = "3.3.0", default-features = false, features = ["native-tls"] } ureq = { version = "3.3.0", default-features = false, features = ["native-tls"] }
zip = { version = "4.6.1", default-features = false, features = ["deflate"] } zip = { version = "4.6.1", default-features = false, features = ["deflate"] }
csv = "1" csv = "1"
kamadak-exif = "0.5"
notify = "6"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
# ── Dev-mode performance ──────────────────────────────────────────────────── # ── Dev-mode performance ────────────────────────────────────────────────────
+24 -5
View File
@@ -5,7 +5,7 @@ use crate::captioner::{
use crate::db::{self, DbPool, ExploreTagEntry, Folder, FolderJobProgress, ImageRecord, ImageTag}; use crate::db::{self, DbPool, ExploreTagEntry, Folder, FolderJobProgress, ImageRecord, ImageTag};
use crate::embedder; use crate::embedder;
use crate::hnsw_index; use crate::hnsw_index;
use crate::indexer; use crate::indexer::{self, WatcherHandle};
use crate::tagger::{self, TaggerAcceleration, TaggerModelStatus, TaggerRuntimeProbe}; use crate::tagger::{self, TaggerAcceleration, TaggerModelStatus, TaggerRuntimeProbe};
use crate::vector; use crate::vector;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -202,6 +202,7 @@ pub struct GetImagesByIdsParams {
pub async fn add_folder( pub async fn add_folder(
app: AppHandle, app: AppHandle,
db: State<'_, DbState>, db: State<'_, DbState>,
watcher: State<'_, WatcherHandle>,
path: String, path: String,
) -> Result<Folder, String> { ) -> Result<Folder, String> {
let folder_path = PathBuf::from(&path); let folder_path = PathBuf::from(&path);
@@ -230,6 +231,7 @@ pub async fn add_folder(
.find(|f| f.id == folder_id) .find(|f| f.id == folder_id)
.ok_or("Folder not found after insert")?; .ok_or("Folder not found after insert")?;
watcher.add_folder(folder_path.clone(), folder_id);
indexer::index_folder(app, db.inner().clone(), folder_id, folder_path); indexer::index_folder(app, db.inner().clone(), folder_id, folder_path);
Ok(folder) Ok(folder)
@@ -250,9 +252,23 @@ pub async fn get_background_job_progress(
} }
#[tauri::command] #[tauri::command]
pub async fn remove_folder(db: State<'_, DbState>, folder_id: i64) -> Result<(), String> { pub async fn remove_folder(
db: State<'_, DbState>,
watcher: State<'_, WatcherHandle>,
folder_id: i64,
) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?; let conn = db.get().map_err(|e| e.to_string())?;
db::delete_folder(&conn, folder_id).map_err(|e| e.to_string()) // Capture the path before deletion so we can unregister the watcher.
let folder_path = db::get_folders(&conn)
.map_err(|e| e.to_string())?
.into_iter()
.find(|f| f.id == folder_id)
.map(|f| PathBuf::from(f.path));
db::delete_folder(&conn, folder_id).map_err(|e| e.to_string())?;
if let Some(path) = folder_path {
watcher.remove_folder(&path);
}
Ok(())
} }
#[tauri::command] #[tauri::command]
@@ -352,6 +368,7 @@ pub async fn rename_folder(
pub async fn update_folder_path( pub async fn update_folder_path(
app: AppHandle, app: AppHandle,
db: State<'_, DbState>, db: State<'_, DbState>,
watcher: State<'_, WatcherHandle>,
folder_id: i64, folder_id: i64,
new_path: String, new_path: String,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -363,7 +380,7 @@ pub async fn update_folder_path(
.file_name() .file_name()
.map(|n| n.to_string_lossy().to_string()) .map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| new_path.clone()); .unwrap_or_else(|| new_path.clone());
{ let old_path = {
let conn = db.get().map_err(|e| e.to_string())?; let conn = db.get().map_err(|e| e.to_string())?;
// Fetch the old path before updating so image paths can be rewritten. // Fetch the old path before updating so image paths can be rewritten.
let old_path = db::get_folders(&conn) let old_path = db::get_folders(&conn)
@@ -374,7 +391,9 @@ pub async fn update_folder_path(
.ok_or("Folder not found")?; .ok_or("Folder not found")?;
db::update_folder_path(&conn, folder_id, &old_path, &new_path, &new_name) db::update_folder_path(&conn, folder_id, &old_path, &new_path, &new_name)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
} old_path
};
watcher.update_folder(&PathBuf::from(old_path), new_path_buf.clone(), folder_id);
indexer::index_folder(app, db.inner().clone(), folder_id, new_path_buf); indexer::index_folder(app, db.inner().clone(), folder_id, new_path_buf);
Ok(()) Ok(())
} }
+78 -41
View File
@@ -45,6 +45,7 @@ pub struct ImageRecord {
pub file_size: i64, pub file_size: i64,
pub created_at: Option<String>, pub created_at: Option<String>,
pub modified_at: Option<String>, pub modified_at: Option<String>,
pub taken_at: Option<String>,
pub mime_type: String, pub mime_type: String,
pub media_kind: String, pub media_kind: String,
pub duration_ms: Option<i64>, pub duration_ms: Option<i64>,
@@ -313,6 +314,13 @@ pub fn migrate(conn: &Connection) -> Result<()> {
ensure_column(conn, "images", "ai_tagger_model", "TEXT")?; ensure_column(conn, "images", "ai_tagger_model", "TEXT")?;
ensure_column(conn, "images", "ai_tagged_at", "TEXT")?; ensure_column(conn, "images", "ai_tagged_at", "TEXT")?;
ensure_column(conn, "images", "ai_tagger_error", "TEXT")?; ensure_column(conn, "images", "ai_tagger_error", "TEXT")?;
ensure_column(conn, "images", "taken_at", "TEXT")?;
// Index must be created after ensure_column adds the column; it cannot live
// in the execute_batch above because that batch runs before the column exists
// on databases that predate Phase 1.
conn.execute_batch(
"CREATE INDEX IF NOT EXISTS idx_images_taken_at ON images(taken_at);",
)?;
ensure_column(conn, "folders", "scan_error", "TEXT")?; ensure_column(conn, "folders", "scan_error", "TEXT")?;
vector::migrate(conn)?; vector::migrate(conn)?;
@@ -334,8 +342,8 @@ pub fn insert_folder(conn: &Connection, path: &str, name: &str) -> Result<i64> {
pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result<i64> { pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result<i64> {
let id = conn.query_row( let id = conn.query_row(
"INSERT INTO images (folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type, media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error, generated_caption, caption_model, caption_updated_at, caption_error, ai_rating, ai_tagger_model, ai_tagged_at, ai_tagger_error) "INSERT INTO images (folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type, media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error, generated_caption, caption_model, caption_updated_at, caption_error, ai_rating, ai_tagger_model, ai_tagged_at, ai_tagger_error)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31)
ON CONFLICT(path) DO UPDATE SET ON CONFLICT(path) DO UPDATE SET
folder_id = excluded.folder_id, folder_id = excluded.folder_id,
filename = excluded.filename, filename = excluded.filename,
@@ -345,6 +353,7 @@ pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result<i64> {
file_size = excluded.file_size, file_size = excluded.file_size,
created_at = excluded.created_at, created_at = excluded.created_at,
modified_at = excluded.modified_at, modified_at = excluded.modified_at,
taken_at = excluded.taken_at,
mime_type = excluded.mime_type, mime_type = excluded.mime_type,
media_kind = excluded.media_kind, media_kind = excluded.media_kind,
duration_ms = excluded.duration_ms, duration_ms = excluded.duration_ms,
@@ -375,6 +384,7 @@ pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result<i64> {
img.file_size, img.file_size,
img.created_at, img.created_at,
img.modified_at, img.modified_at,
img.taken_at,
img.mime_type, img.mime_type,
img.media_kind, img.media_kind,
img.duration_ms, img.duration_ms,
@@ -1334,7 +1344,7 @@ pub fn update_image_details(
)?; )?;
conn.query_row( conn.query_row(
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type, "SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type,
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error, media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error,
generated_caption, caption_model, caption_updated_at, caption_error, generated_caption, caption_model, caption_updated_at, caption_error,
@@ -1347,9 +1357,47 @@ pub fn update_image_details(
.map_err(Into::into) .map_err(Into::into)
} }
/// Look up the lightweight indexed-media entry for a single path.
/// Used by the filesystem watcher to run change-detection before upserting.
pub fn get_indexed_entry_by_path(conn: &Connection, path: &str) -> Result<Option<IndexedMediaEntry>> {
let result = conn.query_row(
"SELECT id, path, modified_at, file_size, media_kind FROM images WHERE path = ?1",
[path],
|row| {
Ok(IndexedMediaEntry {
id: row.get(0)?,
path: row.get(1)?,
modified_at: row.get(2)?,
file_size: row.get(3)?,
media_kind: row.get(4)?,
})
},
);
match result {
Ok(entry) => Ok(Some(entry)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
/// Look up just the image id for a path. Used by the filesystem watcher
/// to find the DB row to delete when a file is removed from disk.
pub fn get_image_id_by_path(conn: &Connection, path: &str) -> Result<Option<i64>> {
let result = conn.query_row(
"SELECT id FROM images WHERE path = ?1",
[path],
|row| row.get(0),
);
match result {
Ok(id) => Ok(Some(id)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn get_image_by_id(conn: &Connection, image_id: i64) -> Result<ImageRecord> { pub fn get_image_by_id(conn: &Connection, image_id: i64) -> Result<ImageRecord> {
conn.query_row( conn.query_row(
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type, "SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type,
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error, media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error,
generated_caption, caption_model, caption_updated_at, caption_error, generated_caption, caption_model, caption_updated_at, caption_error,
@@ -1457,6 +1505,8 @@ pub fn get_images(
"rating_desc" => "rating DESC, modified_at DESC NULLS LAST", "rating_desc" => "rating DESC, modified_at DESC NULLS LAST",
"duration_asc" => "duration_ms ASC NULLS LAST", "duration_asc" => "duration_ms ASC NULLS LAST",
"duration_desc" => "duration_ms DESC NULLS LAST", "duration_desc" => "duration_ms DESC NULLS LAST",
"taken_asc" => "COALESCE(taken_at, modified_at) ASC NULLS LAST",
"taken_desc" => "COALESCE(taken_at, modified_at) DESC NULLS LAST",
_ => "modified_at DESC NULLS LAST", _ => "modified_at DESC NULLS LAST",
}; };
@@ -1464,7 +1514,7 @@ pub fn get_images(
let favorites_flag = i64::from(favorites_only); let favorites_flag = i64::from(favorites_only);
let embedding_failed_flag = i64::from(embedding_failed_only); let embedding_failed_flag = i64::from(embedding_failed_only);
let sql = format!( let sql = format!(
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type, "SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type,
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error, media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error,
generated_caption, caption_model, caption_updated_at, caption_error, generated_caption, caption_model, caption_updated_at, caption_error,
@@ -1626,7 +1676,6 @@ pub fn search_tags_autocomplete(
pub struct ImagePathRecord { pub struct ImagePathRecord {
pub id: i64, pub id: i64,
pub path: String, pub path: String,
pub thumbnail_path: Option<String>,
} }
pub fn get_all_image_paths( pub fn get_all_image_paths(
@@ -1634,14 +1683,13 @@ pub fn get_all_image_paths(
folder_id: Option<i64>, folder_id: Option<i64>,
) -> Result<Vec<ImagePathRecord>> { ) -> Result<Vec<ImagePathRecord>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, path, thumbnail_path FROM images WHERE (?1 IS NULL OR folder_id = ?1) ORDER BY id", "SELECT id, path FROM images WHERE (?1 IS NULL OR folder_id = ?1) ORDER BY id",
)?; )?;
let rows = stmt let rows = stmt
.query_map(params![folder_id], |row| { .query_map(params![folder_id], |row| {
Ok(ImagePathRecord { Ok(ImagePathRecord {
id: row.get(0)?, id: row.get(0)?,
path: row.get(1)?, path: row.get(1)?,
thumbnail_path: row.get(2)?,
}) })
})? })?
.collect::<rusqlite::Result<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
@@ -2012,18 +2060,6 @@ pub fn requeue_processing_tagging_jobs_for_folder(conn: &Connection, folder_id:
Ok(()) Ok(())
} }
/// Returns `true` when the job row for `image_id` currently has status = 'cancelled'.
/// Used by the worker to discard inference results for jobs that were cancelled
/// while inference was running.
pub fn is_tagging_job_cancelled(conn: &Connection, image_id: i64) -> Result<bool> {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM tagging_jobs WHERE image_id = ?1 AND status = 'cancelled'",
[image_id],
|row| row.get(0),
)?;
Ok(count > 0)
}
/// Returns `true` when the job row for `image_id` is still `processing`. /// Returns `true` when the job row for `image_id` is still `processing`.
/// A `false` result means the row was reset to `pending` (pause) or `cancelled` /// A `false` result means the row was reset to `pending` (pause) or `cancelled`
/// while inference was running — either way the result must be discarded. /// while inference was running — either way the result must be discarded.
@@ -2128,27 +2164,28 @@ fn map_image_row(row: &Row<'_>) -> rusqlite::Result<ImageRecord> {
file_size: row.get(7)?, file_size: row.get(7)?,
created_at: row.get(8)?, created_at: row.get(8)?,
modified_at: row.get(9)?, modified_at: row.get(9)?,
mime_type: row.get(10)?, taken_at: row.get(10)?,
media_kind: row.get(11)?, mime_type: row.get(11)?,
duration_ms: row.get(12)?, media_kind: row.get(12)?,
video_codec: row.get(13)?, duration_ms: row.get(13)?,
audio_codec: row.get(14)?, video_codec: row.get(14)?,
metadata_updated_at: row.get(15)?, audio_codec: row.get(15)?,
metadata_error: row.get(16)?, metadata_updated_at: row.get(16)?,
favorite: row.get::<_, i64>(17)? != 0, metadata_error: row.get(17)?,
rating: row.get(18)?, favorite: row.get::<_, i64>(18)? != 0,
embedding_status: row.get(19)?, rating: row.get(19)?,
embedding_model: row.get(20)?, embedding_status: row.get(20)?,
embedding_updated_at: row.get(21)?, embedding_model: row.get(21)?,
embedding_error: row.get(22)?, embedding_updated_at: row.get(22)?,
generated_caption: row.get(23)?, embedding_error: row.get(23)?,
caption_model: row.get(24)?, generated_caption: row.get(24)?,
caption_updated_at: row.get(25)?, caption_model: row.get(25)?,
caption_error: row.get(26)?, caption_updated_at: row.get(26)?,
ai_rating: row.get(27)?, caption_error: row.get(27)?,
ai_tagger_model: row.get(28)?, ai_rating: row.get(28)?,
ai_tagged_at: row.get(29)?, ai_tagger_model: row.get(29)?,
ai_tagger_error: row.get(30)?, ai_tagged_at: row.get(30)?,
ai_tagger_error: row.get(31)?,
}) })
} }
+274 -1
View File
@@ -9,9 +9,10 @@ use crate::vector;
use anyhow::Result; use anyhow::Result;
use rayon::prelude::*; use rayon::prelude::*;
use serde::Serialize; use serde::Serialize;
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use walkdir::WalkDir; use walkdir::WalkDir;
@@ -425,6 +426,49 @@ fn do_index(app: AppHandle, pool: &DbPool, folder_id: i64, folder_path: PathBuf)
Ok(()) Ok(())
} }
/// Extract the capture date from EXIF metadata, returned as an ISO 8601 string
/// (`"YYYY-MM-DDTHH:MM:SS"`). Tries `DateTimeOriginal` first, then
/// `DateTimeDigitized`, then `DateTime`. Returns `None` for video files or
/// images with no readable EXIF date.
fn extract_exif_date(path: &Path) -> Option<String> {
use exif::{In, Tag, Value};
use std::io::BufReader;
let file = std::fs::File::open(path).ok()?;
let mut reader = BufReader::new(file);
let exif = exif::Reader::new().read_from_container(&mut reader).ok()?;
for tag in [Tag::DateTimeOriginal, Tag::DateTimeDigitized, Tag::DateTime] {
if let Some(field) = exif.get_field(tag, In::PRIMARY) {
if let Value::Ascii(ref parts) = field.value {
if let Some(bytes) = parts.first() {
// EXIF datetime format: "YYYY:MM:DD HH:MM:SS" (19 bytes)
if bytes.len() >= 19 {
let s = String::from_utf8_lossy(bytes);
// Reject all-zero sentinel dates written by some cameras
// for uninitialised EXIF fields ("0000:00:00 00:00:00").
let year: u32 = s[0..4].parse().unwrap_or(0);
if year == 0 {
continue;
}
let iso = format!(
"{}-{}-{}T{}:{}:{}",
&s[0..4],
&s[5..7],
&s[8..10],
&s[11..13],
&s[14..16],
&s[17..19]
);
return Some(iso);
}
}
}
}
}
None
}
fn build_record( fn build_record(
path: &Path, path: &Path,
folder_id: i64, folder_id: i64,
@@ -465,6 +509,7 @@ fn build_record(
file_size, file_size,
created_at: None, created_at: None,
modified_at, modified_at,
taken_at: extract_exif_date(path),
mime_type: mime_for_ext(ext).to_string(), mime_type: mime_for_ext(ext).to_string(),
media_kind: media_kind.clone(), media_kind: media_kind.clone(),
duration_ms: None, duration_ms: None,
@@ -1238,3 +1283,231 @@ fn mime_for_ext(ext: &str) -> &'static str {
_ => "image/jpeg", _ => "image/jpeg",
} }
} }
// ── Filesystem watcher ────────────────────────────────────────────────────────
/// How long to wait after the last event for a path before processing it.
/// Absorbs bursts of OS events that accompany a single logical file write.
const WATCHER_DEBOUNCE: Duration = Duration::from_millis(500);
struct WatcherInner {
watcher: Mutex<RecommendedWatcher>,
/// Maps each watched folder root → its folder_id in the DB.
folder_map: Arc<Mutex<HashMap<PathBuf, i64>>>,
}
/// Shared handle that lets command handlers register and deregister watched
/// directories without touching the debounce thread directly.
#[derive(Clone)]
pub struct WatcherHandle {
inner: Arc<WatcherInner>,
}
impl WatcherHandle {
pub fn add_folder(&self, path: PathBuf, folder_id: i64) {
{
let mut w = self.inner.watcher.lock().unwrap();
if path.is_dir() {
if let Err(e) = w.watch(&path, RecursiveMode::Recursive) {
eprintln!("Watcher: failed to watch {:?}: {}", path, e);
}
}
}
self.inner.folder_map.lock().unwrap().insert(path, folder_id);
}
pub fn remove_folder(&self, path: &Path) {
{
let mut w = self.inner.watcher.lock().unwrap();
let _ = w.unwatch(path);
}
self.inner.folder_map.lock().unwrap().remove(path);
}
pub fn update_folder(&self, old_path: &Path, new_path: PathBuf, folder_id: i64) {
{
let mut w = self.inner.watcher.lock().unwrap();
let _ = w.unwatch(old_path);
if new_path.is_dir() {
if let Err(e) = w.watch(&new_path, RecursiveMode::Recursive) {
eprintln!("Watcher: failed to watch {:?}: {}", new_path, e);
}
}
}
let mut map = self.inner.folder_map.lock().unwrap();
map.remove(old_path);
map.insert(new_path, folder_id);
}
}
/// Start the filesystem watcher. Watches all folders currently in the DB and
/// returns a handle that command handlers can use to add/remove watched paths.
///
/// The debounce loop uses adaptive blocking:
/// - `recv()` when no events are pending — zero CPU
/// - `recv_timeout(earliest_deadline)` when events are pending — wakes exactly
/// when the soonest debounce window expires, no busy-polling
pub fn start_watcher(app: AppHandle, pool: DbPool) -> WatcherHandle {
let (tx, rx) = std::sync::mpsc::channel::<notify::Result<notify::Event>>();
let raw_watcher = notify::recommended_watcher(move |result| {
let _ = tx.send(result);
})
.expect("Failed to create filesystem watcher");
// Seed the folder map from the DB so existing folders are watched on startup.
let folder_map: Arc<Mutex<HashMap<PathBuf, i64>>> = Arc::new(Mutex::new(HashMap::new()));
{
let conn = pool.get().expect("Watcher: failed to get DB connection for init");
let folders = db::get_folders(&conn).unwrap_or_default();
let mut map = folder_map.lock().unwrap();
for f in folders {
map.insert(PathBuf::from(f.path), f.id);
}
}
// Register each known folder with the OS watcher.
let raw_watcher = {
let mut w = raw_watcher;
let map = folder_map.lock().unwrap();
for path in map.keys() {
if path.is_dir() {
if let Err(e) = w.watch(path, RecursiveMode::Recursive) {
eprintln!("Watcher: failed to watch {:?}: {}", path, e);
}
}
}
w
};
let handle = WatcherHandle {
inner: Arc::new(WatcherInner {
watcher: Mutex::new(raw_watcher),
folder_map: Arc::clone(&folder_map),
}),
};
// Spawn the debounce loop on its own thread.
let folder_map_thread = Arc::clone(&folder_map);
std::thread::spawn(move || {
// path → deadline: the earliest instant at which this path should be processed.
let mut pending: HashMap<PathBuf, Instant> = HashMap::new();
loop {
// Adaptive blocking: block forever when idle, wake at the earliest
// deadline when events are queued.
let received = if pending.is_empty() {
match rx.recv() {
Ok(e) => Some(e),
Err(_) => break, // sender dropped — app is shutting down
}
} else {
let earliest = pending.values().copied().min().unwrap(); // safe: non-empty
let timeout = earliest.saturating_duration_since(Instant::now());
match rx.recv_timeout(timeout) {
Ok(e) => Some(e),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => None,
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
};
let now = Instant::now();
// Absorb incoming event — coalesces rapid writes into one deadline.
if let Some(Ok(event)) = received {
use notify::EventKind;
// Skip pure access events (reads); they never change file content.
if !matches!(event.kind, EventKind::Access(_)) {
for path in event.paths {
if is_supported_media(&path) {
pending.insert(path, now + WATCHER_DEBOUNCE);
}
}
}
}
// Process all paths whose debounce window has expired.
let ready: Vec<PathBuf> = pending
.iter()
.filter(|(_, &deadline)| deadline <= now)
.map(|(p, _)| p.clone())
.collect();
for path in ready {
pending.remove(&path);
process_watcher_path(&app, &pool, &folder_map_thread, &path);
}
}
});
handle
}
/// Decide what to do with a path whose debounce window just expired.
/// If the file exists → upsert (with change-detection to avoid clobbering
/// metadata like thumbnail_path). If the file is gone → delete from DB.
fn process_watcher_path(
app: &AppHandle,
pool: &DbPool,
folder_map: &Arc<Mutex<HashMap<PathBuf, i64>>>,
path: &Path,
) {
// Determine which registered folder owns this file.
let folder_id = {
let map = folder_map.lock().unwrap();
map.iter()
.find(|(folder_path, _)| path.starts_with(folder_path.as_path()))
.map(|(_, &id)| id)
};
let Some(folder_id) = folder_id else { return };
let conn = match pool.get() {
Ok(c) => c,
Err(e) => {
eprintln!("Watcher: DB pool error: {}", e);
return;
}
};
if path.exists() {
// File still on disk — upsert if content changed.
let path_str = path.to_string_lossy();
let existing = db::get_indexed_entry_by_path(&conn, &path_str).unwrap_or(None);
let Some(record) = build_record(path, folder_id, existing.as_ref()) else {
return; // file unchanged (same size + mtime) — nothing to do
};
drop(conn); // commit_batch acquires its own connection from the pool
match commit_batch(pool, &[record]) {
Ok(committed) if !committed.is_empty() => {
// Always emit the images — they are committed to the DB.
emit_images(app, &IndexedImagesBatch { folder_id, images: committed });
emit_folder_job_progress(app, pool, &[folder_id], false);
// Update the sidebar count only if we successfully write the new
// count; skip the frontend notification on pool/DB failure to
// avoid showing a stale number.
if let Ok(count_conn) = pool.get() {
if db::update_folder_count(&count_conn, folder_id).is_ok() {
let _ = app.emit("folder-counts-changed", ());
}
}
}
Ok(_) => {}
Err(e) => eprintln!("Watcher: commit error for {:?}: {}", path, e),
}
} else {
// File removed from disk — clean up DB row.
let path_str = path.to_string_lossy();
match db::get_image_id_by_path(&conn, &path_str) {
Ok(Some(image_id)) => {
if db::delete_images_by_ids(&conn, &[image_id]).is_ok() {
db::update_folder_count(&conn, folder_id).ok();
let _ = app.emit("watcher-deleted", vec![image_id]);
let _ = app.emit("folder-counts-changed", ());
}
}
Ok(None) => {} // never indexed or already removed
Err(e) => eprintln!("Watcher: lookup error for {:?}: {}", path, e),
}
}
}
+3
View File
@@ -74,8 +74,11 @@ pub fn run() {
// indexer::start_caption_worker(app.handle().clone(), pool.clone(), app_dir.clone()); // indexer::start_caption_worker(app.handle().clone(), pool.clone(), app_dir.clone());
indexer::start_tagging_worker(app.handle().clone(), pool.clone(), app_dir.clone()); indexer::start_tagging_worker(app.handle().clone(), pool.clone(), app_dir.clone());
let watcher_handle = indexer::start_watcher(app.handle().clone(), pool.clone());
app.manage(pool); app.manage(pool);
app.manage(media_tools); app.manage(media_tools);
app.manage(watcher_handle);
Ok(()) Ok(())
}) })
+8 -1
View File
@@ -7,6 +7,7 @@ import { Gallery } from "./components/Gallery";
import { Lightbox } from "./components/Lightbox"; import { Lightbox } from "./components/Lightbox";
import { TagCloud } from "./components/TagCloud"; import { TagCloud } from "./components/TagCloud";
import { DuplicateFinder } from "./components/DuplicateFinder"; import { DuplicateFinder } from "./components/DuplicateFinder";
import { Timeline } from "./components/Timeline";
import { TitleBar } from "./components/TitleBar"; import { TitleBar } from "./components/TitleBar";
import { SettingsModal } from "./components/SettingsModal"; import { SettingsModal } from "./components/SettingsModal";
import { initializeNotifications } from "./notifications"; import { initializeNotifications } from "./notifications";
@@ -46,7 +47,13 @@ export default function App() {
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
<Sidebar /> <Sidebar />
<main className="flex-1 flex flex-col min-w-0"> <main className="flex-1 flex flex-col min-w-0">
{activeView === "explore" ? ( {activeView === "timeline" ? (
<>
<Toolbar />
<BackgroundTasks />
<Timeline />
</>
) : activeView === "explore" ? (
<> <>
<BackgroundTasks /> <BackgroundTasks />
<TagCloud /> <TagCloud />
+2 -2
View File
@@ -16,7 +16,7 @@ function formatDuration(durationMs: number | null): string | null {
return `${minutes}:${seconds.toString().padStart(2, "0")}`; return `${minutes}:${seconds.toString().padStart(2, "0")}`;
} }
function ContextMenu({ export function ContextMenu({
x, x,
y, y,
image, image,
@@ -104,7 +104,7 @@ function ContextMenu({
); );
} }
function ImageTile({ export function ImageTile({
image, image,
onClick, onClick,
onContextMenu, onContextMenu,
+17
View File
@@ -329,6 +329,23 @@ export function Sidebar() {
</span> </span>
</div> </div>
<div
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
activeView === "timeline"
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={() => setView("timeline")}
>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className={`text-[13px] font-medium ${activeView === "timeline" ? "text-white" : ""}`}>
Timeline
</span>
</div>
<div <div
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${ className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
activeView === "duplicates" activeView === "duplicates"
+255
View File
@@ -0,0 +1,255 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store";
import { ContextMenu, ImageTile } from "./Gallery";
const GAP = 6;
const HEADER_HEIGHT = 52;
interface TimelineGroup {
key: string;
label: string;
images: ImageRecord[];
}
function buildLabel(key: string): string {
if (key === "unknown") return "Unknown Date";
const [yearStr, monthStr] = key.split("-");
const year = Number(yearStr);
const month = Number(monthStr);
if (!isFinite(year) || !isFinite(month) || month < 1 || month > 12) return "Unknown Date";
const date = new Date(year, month - 1);
if (isNaN(date.getTime())) return "Unknown Date";
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
}
function groupImages(images: ImageRecord[]): TimelineGroup[] {
const map = new Map<string, ImageRecord[]>();
for (const img of images) {
const ds = img.taken_at ?? img.modified_at;
const key = ds ? ds.substring(0, 7) : "unknown";
let bucket = map.get(key);
if (bucket === undefined) {
bucket = [];
map.set(key, bucket);
}
bucket.push(img);
}
return Array.from(map.entries())
.sort(([a], [b]) => {
if (a === "unknown") return 1;
if (b === "unknown") return -1;
return a < b ? -1 : a > b ? 1 : 0;
})
.map(([key, imgs]) => ({ key, label: buildLabel(key), images: imgs }));
}
export function Timeline() {
const images = useGalleryStore((s) => s.images);
const loadMoreImages = useGalleryStore((s) => s.loadMoreImages);
const openImage = useGalleryStore((s) => s.openImage);
const totalImages = useGalleryStore((s) => s.totalImages);
const loadingImages = useGalleryStore((s) => s.loadingImages);
const imageLoadError = useGalleryStore((s) => s.imageLoadError);
const zoomPreset = useGalleryStore((s) => s.zoomPreset);
const parentRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
image: ImageRecord;
} | null>(null);
// Measure container width before first paint to avoid a single-column flash.
useLayoutEffect(() => {
const el = parentRef.current;
if (!el) return;
setContainerWidth(el.clientWidth);
const ro = new ResizeObserver((entries) => {
setContainerWidth(entries[0].contentRect.width);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const tileSize = tileSizeForZoom(zoomPreset);
const cols = useMemo(
() => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))),
[containerWidth, tileSize],
);
const groups = useMemo(() => groupImages(images), [images]);
// estimateSize must be exact so virtualizer positions groups correctly.
// Each group height = header + rowCount * (tileSize + GAP) where the last row's
// GAP acts as spacing between this group and the next header.
const estimateSize = useCallback(
(index: number): number => {
const group = groups[index];
if (!group) return HEADER_HEIGHT;
const rowCount = Math.ceil(group.images.length / cols);
return HEADER_HEIGHT + rowCount * (tileSize + GAP);
},
[groups, cols, tileSize],
);
const virtualizer = useVirtualizer({
count: groups.length,
getScrollElement: () => parentRef.current,
estimateSize,
overscan: 2,
});
// Re-measure all items when cols changes so virtualizer positions stay accurate
// after a window resize (react-virtual v3 doesn't invalidate cached sizes on its own).
useEffect(() => {
virtualizer.measure();
}, [cols, virtualizer]);
const handleScroll = useCallback(() => {
const el = parentRef.current;
if (!el) return;
if (el.scrollTop < 24) return;
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600;
if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages();
}
}, [images.length, loadMoreImages, loadingImages, totalImages]);
useEffect(() => {
const el = parentRef.current;
if (!el) return;
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
useEffect(() => {
const close = (e: PointerEvent) => {
if ((e.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return;
setContextMenu(null);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setContextMenu(null);
};
window.addEventListener("pointerdown", close);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("pointerdown", close);
window.removeEventListener("keydown", onKey);
};
}, []);
return (
<div
ref={parentRef}
className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]"
>
{images.length === 0 && loadingImages ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72">
<div className="h-5 w-5 mx-auto rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
<p className="mt-4 text-sm text-white/40 font-medium">Loading timeline</p>
<p className="text-xs text-white/20 mt-1">Fetching results</p>
</div>
</div>
) : images.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
<svg
className="h-12 w-12 mx-auto text-white/10 mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={0.75}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p className="text-sm text-white/30 font-medium">
{imageLoadError ? "Could not load timeline" : "No media found"}
</p>
<p className="text-xs text-white/15 mt-1">
{imageLoadError ?? "Add a folder to see your timeline"}
</p>
</div>
</div>
) : (
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const group = groups[virtualItem.index];
if (!group) return null;
return (
<div
key={virtualItem.key}
style={{
position: "absolute",
top: virtualItem.start,
width: "100%",
height: virtualItem.size,
}}
>
{/* Group header */}
<div
className="flex items-center gap-3 px-4"
style={{ height: HEADER_HEIGHT }}
>
<span className="text-sm font-semibold text-white/80 shrink-0">
{group.label}
</span>
<span className="text-xs text-white/25 shrink-0 tabular-nums">
{group.images.length}
</span>
<div className="flex-1 h-px bg-white/[0.06]" />
</div>
{/* Image grid paddingBottom:GAP gives the gap below the last row,
matching the row-to-row gap and making estimateSize exact. */}
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
gap: GAP,
paddingLeft: GAP,
paddingRight: GAP,
paddingBottom: GAP,
}}
>
{group.images.map((image) => (
<ImageTile
key={image.id}
image={image}
onClick={() => openImage(image)}
onContextMenu={(event) => {
event.preventDefault();
setContextMenu({ x: event.clientX, y: event.clientY, image });
}}
/>
))}
</div>
</div>
);
})}
</div>
)}
{images.length > 0 && loadingImages ? (
<div className="flex justify-center py-8">
<div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
</div>
) : null}
{contextMenu ? (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
image={contextMenu.image}
onClose={() => setContextMenu(null)}
/>
) : null}
</div>
);
}
+2
View File
@@ -5,6 +5,8 @@ import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "date_desc", label: "Newest first" }, { value: "date_desc", label: "Newest first" },
{ value: "date_asc", label: "Oldest first" }, { value: "date_asc", label: "Oldest first" },
{ value: "taken_desc", label: "Taken: newest" },
{ value: "taken_asc", label: "Taken: oldest" },
{ value: "name_asc", label: "Name AZ" }, { value: "name_asc", label: "Name AZ" },
{ value: "name_desc", label: "Name ZA" }, { value: "name_desc", label: "Name ZA" },
{ value: "rating_desc", label: "Highest rated" }, { value: "rating_desc", label: "Highest rated" },
+38 -2
View File
@@ -37,6 +37,7 @@ export interface ImageRecord {
file_size: number; file_size: number;
created_at: string | null; created_at: string | null;
modified_at: string | null; modified_at: string | null;
taken_at: string | null;
mime_type: string; mime_type: string;
media_kind: MediaKind; media_kind: MediaKind;
duration_ms: number | null; duration_ms: number | null;
@@ -121,7 +122,7 @@ export interface ThumbnailBatch {
images: ImageRecord[]; images: ImageRecord[];
} }
export type ActiveView = "gallery" | "explore" | "duplicates"; export type ActiveView = "gallery" | "explore" | "duplicates" | "timeline";
export interface TagCloudEntry { export interface TagCloudEntry {
count: number; count: number;
@@ -214,7 +215,9 @@ export type SortOrder =
| "rating_desc" | "rating_desc"
| "rating_asc" | "rating_asc"
| "duration_desc" | "duration_desc"
| "duration_asc"; | "duration_asc"
| "taken_desc"
| "taken_asc";
interface GalleryState { interface GalleryState {
folders: Folder[]; folders: Folder[];
@@ -494,6 +497,10 @@ function compareImages(a: ImageRecord, b: ImageRecord, sort: SortOrder): number
return compareNullableNumber(a.duration_ms, b.duration_ms); return compareNullableNumber(a.duration_ms, b.duration_ms);
case "duration_desc": case "duration_desc":
return compareNullableNumber(b.duration_ms, a.duration_ms); return compareNullableNumber(b.duration_ms, a.duration_ms);
case "taken_asc":
return compareNullableDate(a.taken_at ?? a.modified_at, b.taken_at ?? b.modified_at);
case "taken_desc":
return compareNullableDate(b.taken_at ?? b.modified_at, a.taken_at ?? a.modified_at);
default: default:
return compareNullableDate(b.modified_at, a.modified_at); return compareNullableDate(b.modified_at, a.modified_at);
} }
@@ -898,6 +905,11 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
closeImage: () => set({ selectedImage: null }), closeImage: () => set({ selectedImage: null }),
setView: (activeView) => { setView: (activeView) => {
if (activeView === "timeline") {
set({ activeView, sort: "taken_asc", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarSourceFolderId: null, similarFolderId: null, similarHasMore: false, similarCrop: null, imageLoadError: null });
void get().loadImages(true);
return;
}
if (activeView === "duplicates") { if (activeView === "duplicates") {
const { selectedFolderId, duplicateScanFolderId } = get(); const { selectedFolderId, duplicateScanFolderId } = get();
if (duplicateScanFolderId !== selectedFolderId) { if (duplicateScanFolderId !== selectedFolderId) {
@@ -1740,6 +1752,28 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}); });
}); });
const unlistenWatcherDeleted = await listen<number[]>("watcher-deleted", (event) => {
const deletedIds = new Set(event.payload);
set((state) => {
const removed = state.images.filter((img) => deletedIds.has(img.id)).length;
const images = state.images.filter((img) => !deletedIds.has(img.id));
const selectedImage =
state.selectedImage && deletedIds.has(state.selectedImage.id)
? null
: state.selectedImage;
return {
images,
totalImages: Math.max(0, state.totalImages - removed),
loadedCount: Math.max(0, state.loadedCount - removed),
selectedImage,
};
});
});
const unlistenFolderCounts = await listen("folder-counts-changed", () => {
void get().loadFolders();
});
return () => { return () => {
unlistenProgress(); unlistenProgress();
unlistenMediaJobs(); unlistenMediaJobs();
@@ -1747,6 +1781,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
unlistenTaggerModelProgress(); unlistenTaggerModelProgress();
unlistenImages(); unlistenImages();
unlistenThumbnails(); unlistenThumbnails();
unlistenWatcherDeleted();
unlistenFolderCounts();
}; };
}, },
})); }));