Update TODO list and enhance job logging configuration

- Added critical tasks for remote file access and updater functionality to the TODO list.
- Updated job logging configuration to include an option for logging ephemeral jobs, improving logging flexibility.
- Enhanced the job manager to conditionally log job events based on persistence settings, ensuring better tracking of job statuses.
- Refactored the inspector component to remove unnecessary console logs, streamlining the codebase.
- Improved the selection context to eliminate redundant logging during file synchronization, enhancing performance.
This commit is contained in:
Jamie Pine
2025-12-24 08:04:57 -08:00
parent 9f5c7c5880
commit b555156fa7
13 changed files with 288 additions and 102 deletions

17
TODO
View File

@@ -23,11 +23,14 @@
Journey to v2.0.0-pre.1:
✔ Get all desktop release CI working @done(25-12-16 23:51)
☐ Ephemeral sidecars @critical
☐ Remote file access on demand @critical
☐ Open files with default app (cross platform) @critical
☐ Sometimes quick preview reporting file not found @today (happens in the second column of column view)
☐ Ensure updater and changelog are working @today
☐ Drop external items INTO spacedrive explorer @today
☐ Connection info on device panel (lan/relay)
✔ Grid view render bug, shows as column for split second on first render of results @done(25-12-22 07:49)
☐ Ensure updater and changelog are working
☐ Drop external items INTO spacedrive explorer
☐ Job sound: make copy sound a varient, not play also
✔ Job sound: make copy sound a varient, not play also @done(25-12-24 07:24)
☐ Run now button in Location Inspector doesn't work well @today
✔ Sidebar active based on Explorer path @today @done(25-12-20 07:59)
☐ Delete location UX improvement
@@ -36,10 +39,9 @@ Journey to v2.0.0-pre.1:
# It has an issue where it conflicts with the tab focus navigation and is out of order often
☐ Device owned data count mismatch reconciliation
☐ Synced directories seem to be missing relationships to form tree (sometimes)
☐ Drag selection area + command to add to selection
☐ Drag selection area + command to add to selection @today
✔ Back/forward button navigation not working @critical @done(25-12-22 05:43)
# Investigate sync integrity
☐ Remote file access on demand @critical
✔ Refetch all queries when window focus @today @done(25-12-20 09:55)
✔ Fix job reactivity @today @done(25-12-21 06:14)
# CLI reactivity works fine, but interface job manager often doesn't show up for jobs, generating thubmnails ALWAYS does, but speech, splat and indexing don't show in the UI
@@ -60,9 +62,10 @@ Journey to v2.0.0-pre.1:
☐ Make default home folders SpaceItems
☐ Choose custom device name on mobile setup flow
# Decide how this works in the context of sync, likely organize by device
New Space Item resource event not working
New Space Item resource event not working @done(25-12-24 07:25)
✔ Can't drag and drop onto space items to copy paste @done(25-12-16 23:51)
☐ Sometimes device shows as online even if its not
☐ Stale detection background discovery reindex @critical
# Looks like cache state issue, even though the devices due
☐ Eject volume button
☐ Index mode not showing in location inspector
@@ -82,10 +85,8 @@ Journey to v2.0.0-pre.1:
☐ History tab of the File Inspector is dummy data
☐ Improve copy/paste/move flow to actually validate before showing modal
☐ Fix three device sync
☐ Add box selections
☐ Refactor the CLI
☐ Improve inspector design
☐ Stale detection background discovery reindex
# See INDEX-009 for stale detection
☐ RAW support
☐ Sign Windows build

View File

@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for Spacedrive",
"windows": ["main"],
"windows": ["main", "inspector-*", "quick-preview-*", "settings-*", "job-manager"],
"permissions": [
"core:default",
"core:event:allow-listen",

View File

@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default permissions for Spacedrive","local":true,"windows":["main"],"permissions":["core:default","core:event:allow-listen","core:event:allow-emit","core:window:allow-create","core:window:allow-close","core:window:allow-get-all-windows","core:window:allow-start-dragging","core:webview:allow-create-webview-window","core:path:default","dialog:allow-open","dialog:allow-save","shell:allow-open","fs:allow-home-read-recursive","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text"]}}
{"default":{"identifier":"default","description":"Default permissions for Spacedrive","local":true,"windows":["main","inspector-*","quick-preview-*","settings-*","job-manager"],"permissions":["core:default","core:event:allow-listen","core:event:allow-emit","core:window:allow-create","core:window:allow-close","core:window:allow-get-all-windows","core:window:allow-start-dragging","core:webview:allow-create-webview-window","core:path:default","dialog:allow-open","dialog:allow-save","shell:allow-open","fs:allow-home-read-recursive","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text"]}}

View File

@@ -159,9 +159,19 @@ impl SpacedriveWindow {
(320.0, 400.0),
true,
true, // always on top
false,
true, // transparent for macOS styling
)?;
// Apply macOS titlebar styling
#[cfg(target_os = "macos")]
{
if let Ok(ns_window) = window.ns_window() {
unsafe {
sd_desktop_macos::set_titlebar_style(&ns_window, false);
}
}
}
// Listen for window close to notify main window
let app_handle = app.clone();
window.on_window_event(move |event| {
@@ -372,7 +382,7 @@ fn create_window(
always_on_top: bool,
transparent: bool,
) -> Result<WebviewWindow, String> {
let window = WebviewWindowBuilder::new(app, label, WebviewUrl::App(url.into()))
let mut builder = WebviewWindowBuilder::new(app, label, WebviewUrl::App(url.into()))
.title(title)
.inner_size(size.0, size.1)
.min_inner_size(min_size.0, min_size.1)
@@ -380,12 +390,21 @@ fn create_window(
.decorations(decorations)
.transparent(transparent)
.always_on_top(always_on_top)
.center()
.center();
// Enable DevTools in dev mode
#[cfg(debug_assertions)]
{
builder = builder.devtools(true);
}
let window = builder
.build()
.map_err(|e| format!("Failed to create window: {}", e))?;
window.show().ok();
window.set_focus().ok();
Ok(window)
}

View File

@@ -47,6 +47,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
log_directory: "job_logs".to_string(),
max_file_size: 10 * 1024 * 1024, // 10MB
include_debug: true, // Include debug logs for full detail
log_ephemeral_jobs: false,
};
config.save()?;

View File

@@ -33,6 +33,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
log_directory: "job_logs".to_string(),
max_file_size: 10 * 1024 * 1024,
include_debug: true,
log_ephemeral_jobs: false,
};
config.save()?;

View File

@@ -85,6 +85,10 @@ pub struct JobLoggingConfig {
/// Whether to include debug logs
pub include_debug: bool,
/// Whether to create log files for ephemeral (non-persistent) jobs
#[serde(default)]
pub log_ephemeral_jobs: bool,
}
impl Default for JobLoggingConfig {
@@ -94,6 +98,7 @@ impl Default for JobLoggingConfig {
log_directory: "job_logs".to_string(),
max_file_size: 10 * 1024 * 1024, // 10MB default
include_debug: false,
log_ephemeral_jobs: false,
}
}
}

View File

@@ -306,6 +306,20 @@ impl JobManager {
// Create persistence completion channel
let (persistence_complete_tx, persistence_complete_rx) = tokio::sync::oneshot::channel();
// Only enable job file logging for persistent jobs (or if explicitly configured)
let job_logging_config = if should_persist
|| self
.context
.job_logging_config
.as_ref()
.map(|c| c.log_ephemeral_jobs)
.unwrap_or(false)
{
self.context.job_logging_config.clone()
} else {
None
};
// Create executor using the erased job
let executor = erased_job.create_executor(
job_id,
@@ -321,7 +335,7 @@ impl JobManager {
handle.output.clone(),
networking,
volume_manager,
self.context.job_logging_config.clone(),
job_logging_config,
Some(library.job_logs_dir()),
Some(persistence_complete_tx),
);
@@ -367,18 +381,16 @@ impl JobManager {
info!("Job {} status changed to: {:?}", job_id_clone, status);
match status {
JobStatus::Running => {
// Only emit events for persistent jobs
if should_persist {
event_bus.emit(Event::JobStarted {
job_id: job_id_clone.to_string(),
job_type: job_type_str.to_string(),
device_id,
});
info!("Emitted JobStarted event for job {}", job_id_clone);
}
// Emit event for all jobs
event_bus.emit(Event::JobStarted {
job_id: job_id_clone.to_string(),
job_type: job_type_str.to_string(),
device_id,
});
info!("Emitted JobStarted event for job {}", job_id_clone);
}
JobStatus::Completed => {
// Only emit events and trigger statistics for persistent jobs
// Emit completion event for all jobs
if should_persist {
// Get the final output from the handle before removing the job
let output = {
@@ -440,7 +452,7 @@ impl JobManager {
break;
}
JobStatus::Failed => {
// Only emit events for persistent jobs
// Emit event for all jobs
if should_persist {
event_bus.emit(Event::JobFailed {
job_id: job_id_clone.to_string(),
@@ -455,7 +467,7 @@ impl JobManager {
break;
}
JobStatus::Cancelled => {
// Only emit events for persistent jobs
// Emit event for all jobs
if should_persist {
event_bus.emit(Event::JobCancelled {
job_id: job_id_clone.to_string(),
@@ -684,6 +696,20 @@ impl JobManager {
// Create persistence completion channel
let (persistence_complete_tx, persistence_complete_rx) = tokio::sync::oneshot::channel();
// Only enable job file logging for persistent jobs (or if explicitly configured)
let job_logging_config = if should_persist
|| self
.context
.job_logging_config
.as_ref()
.map(|c| c.log_ephemeral_jobs)
.unwrap_or(false)
{
self.context.job_logging_config.clone()
} else {
None
};
// Create executor
let executor = JobExecutor::new(
job,
@@ -700,7 +726,7 @@ impl JobManager {
handle.output.clone(),
networking,
volume_manager,
self.context.job_logging_config.clone(),
job_logging_config,
Some(library.job_logs_dir()),
Some(persistence_complete_tx),
);
@@ -745,18 +771,16 @@ impl JobManager {
info!("Job {} status changed to: {:?}", job_id_clone, status);
match status {
JobStatus::Running => {
// Only emit events for persistent jobs
if should_persist {
event_bus.emit(Event::JobStarted {
job_id: job_id_clone.to_string(),
job_type: job_type_str.to_string(),
device_id,
});
info!("Emitted JobStarted event for job {}", job_id_clone);
}
// Emit event for all jobs
event_bus.emit(Event::JobStarted {
job_id: job_id_clone.to_string(),
job_type: job_type_str.to_string(),
device_id,
});
info!("Emitted JobStarted event for job {}", job_id_clone);
}
JobStatus::Completed => {
// Only emit events and trigger statistics for persistent jobs
// Emit completion event for all jobs
if should_persist {
// Get the final output from the handle before removing the job
let output = {
@@ -818,7 +842,7 @@ impl JobManager {
break;
}
JobStatus::Failed => {
// Only emit events for persistent jobs
// Emit event for all jobs
if should_persist {
event_bus.emit(Event::JobFailed {
job_id: job_id_clone.to_string(),
@@ -833,7 +857,7 @@ impl JobManager {
break;
}
JobStatus::Cancelled => {
// Only emit events for persistent jobs
// Emit event for all jobs
if should_persist {
event_bus.emit(Event::JobCancelled {
job_id: job_id_clone.to_string(),

View File

@@ -136,7 +136,7 @@ impl PersistentEventHandler {
"Handler is running, creating worker for location {}",
location_id
);
self.ensure_worker(meta).await?;
self.ensure_worker(meta.clone()).await?;
} else {
debug!(
"Handler not running yet, worker will be created on start for location {}",
@@ -407,8 +407,16 @@ impl PersistentEventHandler {
context: Arc<CoreContext>,
config: PersistentHandlerConfig,
) -> Result<()> {
use sd_fs_watcher::FsEventKind;
use std::collections::HashMap;
info!("Location worker started for {}", meta.id);
// Buffer for pending removes - maps inode to (path, timestamp, is_directory)
// These are Remove events that might be part of a rename operation.
let mut pending_removes: HashMap<u64, (PathBuf, Instant, Option<bool>)> = HashMap::new();
const RENAME_TIMEOUT: Duration = Duration::from_millis(1000);
while let Some(first_event) = rx.recv().await {
// Start batching window
let mut batch = vec![first_event];
@@ -432,12 +440,51 @@ impl PersistentEventHandler {
meta.id
);
// Evict expired pending removes
let now = Instant::now();
let expired: Vec<u64> = pending_removes
.iter()
.filter(|(_, (_, ts, _))| now.duration_since(*ts) > RENAME_TIMEOUT)
.map(|(inode, _)| *inode)
.collect();
// Process expired removes as actual deletions
let mut expired_events = Vec::new();
for inode in expired {
if let Some((path, _, is_dir)) = pending_removes.remove(&inode) {
debug!(
"Evicting expired pending remove: {} (inode {})",
path.display(),
inode
);
expired_events.push(FsEvent::remove(path));
}
}
// Detect renames by matching Remove+Create pairs using database inodes.
// On macOS, renames arrive as separate Remove and Create events across batches.
let batch = Self::detect_renames_from_db(
&context,
meta.library_id,
batch,
&mut pending_removes,
)
.await;
// Combine expired removes with processed batch
let mut final_batch = expired_events;
final_batch.extend(batch);
if final_batch.is_empty() {
continue;
}
// Pass FsEvent batch directly to responder
if let Err(e) = responder::apply_batch(
&context,
meta.library_id,
meta.id,
batch,
final_batch,
meta.rule_toggles,
&meta.root_path,
None, // volume_backend - TODO: resolve from context
@@ -451,6 +498,143 @@ impl PersistentEventHandler {
info!("Location worker stopped for {}", meta.id);
Ok(())
}
/// Detect renames by matching Remove+Create pairs using database inodes.
///
/// Remove events with inodes are buffered in `pending_removes`. Create events
/// check against this buffer to detect renames. This handles the case where
/// Remove and Create arrive in separate batches (common on macOS FSEvents).
async fn detect_renames_from_db(
context: &Arc<CoreContext>,
library_id: Uuid,
events: Vec<FsEvent>,
pending_removes: &mut std::collections::HashMap<u64, (PathBuf, Instant, Option<bool>)>,
) -> Vec<FsEvent> {
use sd_fs_watcher::FsEventKind;
let Some(library) = context.get_library(library_id).await else {
return events;
};
let db = library.db().conn();
let mut result: Vec<FsEvent> = Vec::new();
for event in events {
match &event.kind {
FsEventKind::Remove => {
// Query database for inode
let inode = Self::get_inode_from_db(db, &event.path).await;
if let Some(inode) = inode {
debug!(
"Buffering Remove event: {} with inode {} for potential rename",
event.path.display(),
inode
);
// Buffer for potential rename detection
pending_removes
.insert(inode, (event.path, Instant::now(), event.is_directory));
} else {
// No inode in DB, emit as regular Remove
result.push(event);
}
}
FsEventKind::Create => {
// Get inode from filesystem
let inode = Self::get_inode_from_fs(&event.path).await;
if let Some(inode) = inode {
// Check if this matches a pending remove
if let Some((old_path, _, is_dir)) = pending_removes.remove(&inode) {
// Found a match - emit Rename
info!(
"Detected rename via database inode {}: {} -> {}",
inode,
old_path.display(),
event.path.display()
);
let rename_event = if let Some(is_dir) = is_dir.or(event.is_directory) {
FsEvent::rename_with_dir_flag(old_path, event.path, is_dir)
} else {
FsEvent::rename(old_path, event.path)
};
result.push(rename_event);
continue;
}
}
// No matching pending remove, emit as regular Create
result.push(event);
}
_ => {
result.push(event);
}
}
}
result
}
/// Get the inode for a path from the database.
async fn get_inode_from_db(
db: &sea_orm::DatabaseConnection,
path: &std::path::Path,
) -> Option<u64> {
use crate::infra::db::entities::{directory_paths, entry};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
// First try as directory (lookup via directory_paths)
let path_str = path.to_string_lossy().to_string();
if let Ok(Some(dir_record)) = directory_paths::Entity::find()
.filter(directory_paths::Column::Path.eq(&path_str))
.one(db)
.await
{
if let Ok(Some(entry_record)) =
entry::Entity::find_by_id(dir_record.entry_id).one(db).await
{
if let Some(inode) = entry_record.inode {
return Some(inode as u64);
}
}
}
// Try as file (lookup via parent directory + name)
let parent = path.parent()?;
let name = path.file_stem()?.to_str()?;
let ext = path.extension().and_then(|e| e.to_str());
let parent_str = parent.to_string_lossy().to_string();
let parent_dir = directory_paths::Entity::find()
.filter(directory_paths::Column::Path.eq(&parent_str))
.one(db)
.await
.ok()??;
let mut query = entry::Entity::find()
.filter(entry::Column::ParentId.eq(parent_dir.entry_id))
.filter(entry::Column::Name.eq(name));
if let Some(e) = ext {
query = query.filter(entry::Column::Extension.eq(e.to_lowercase()));
} else {
query = query.filter(entry::Column::Extension.is_null());
}
let entry_record = query.one(db).await.ok()??;
entry_record.inode.map(|i| i as u64)
}
/// Get the inode for a path from the filesystem.
async fn get_inode_from_fs(path: &std::path::Path) -> Option<u64> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
tokio::fs::metadata(path).await.ok().map(|m| m.ino())
}
#[cfg(not(unix))]
{
None
}
}
}
#[cfg(test)]

View File

@@ -58,7 +58,7 @@ impl LibraryAction for IndexVerifyAction {
);
// Step 1: Scan filesystem to get current state
let fs_entries = self.run_ephemeral_index(&library, &context, &path).await?;
let fs_entries = self.run_ephemeral_index(&library, &path).await?;
// Step 2: Query database for existing entries in this path
let db_entries = self.query_database_entries(&library, &path).await?;
@@ -95,7 +95,6 @@ impl IndexVerifyAction {
async fn run_ephemeral_index(
&self,
library: &Arc<crate::library::Library>,
context: &Arc<CoreContext>,
path: &Path,
) -> Result<HashMap<PathBuf, crate::ops::indexing::database_storage::EntryMetadata>, ActionError>
{
@@ -109,9 +108,6 @@ impl IndexVerifyAction {
ActionError::from(std::io::Error::new(std::io::ErrorKind::Other, e))
})?));
// Subscribe to job events before dispatching
let mut event_subscriber = context.events.subscribe();
// Create indexer job config for ephemeral scanning
let config = IndexerJobConfig {
location_id: None, // Ephemeral - no location
@@ -133,55 +129,22 @@ impl IndexVerifyAction {
ActionError::Internal(format!("Failed to dispatch indexer job: {}", e))
})?;
let job_id = job_handle.id().to_string();
let job_id = job_handle.id();
tracing::debug!(
"Waiting for ephemeral indexer job {} to complete...",
job_id
);
// Listen for job completion events
loop {
match event_subscriber.recv().await {
Ok(event) => match event {
crate::infra::event::Event::JobCompleted {
job_id: completed_id,
..
} if completed_id == job_id => {
tracing::debug!("Ephemeral indexer job {} completed", job_id);
break;
}
crate::infra::event::Event::JobFailed {
job_id: failed_id,
error,
..
} if failed_id == job_id => {
return Err(ActionError::Internal(format!(
"Ephemeral indexer job failed: {}",
error
)));
}
crate::infra::event::Event::JobCancelled {
job_id: cancelled_id,
..
} if cancelled_id == job_id => {
return Err(ActionError::Internal(
"Ephemeral indexer job was cancelled".to_string(),
));
}
_ => {
// Not our job event, keep listening
}
},
Err(e) => {
return Err(ActionError::Internal(format!(
"Failed to receive job event: {}",
e
)));
}
}
}
// Wait for the job to complete using the handle's built-in wait mechanism
job_handle
.wait()
.await
.map_err(|e| ActionError::Internal(format!("Ephemeral indexer job failed: {}", e)))?;
tracing::debug!("Ephemeral indexer job completed, extracting results");
tracing::debug!(
"Ephemeral indexer job {} completed, extracting results",
job_id
);
// Extract the results from our shared ephemeral index
let entries = {

View File

@@ -211,6 +211,7 @@ impl TestConfigBuilder {
log_directory: "job_logs".to_string(),
max_file_size: 10 * 1024 * 1024, // 10MB
include_debug: false,
log_ephemeral_jobs: false,
},
services: ServiceConfig {
networking_enabled: self.networking_enabled,

View File

@@ -152,14 +152,12 @@ export function PopoutInspector() {
platform.onSelectedFilesChanged((fileIds) => {
if (mounted) {
console.log("[PopoutInspector] Received selection change:", fileIds);
setSelectedFileIds(fileIds);
}
}).then((unlistenFn) => {
if (mounted) {
unlisten = unlistenFn;
} else {
// Component unmounted before listener was set up, clean up immediately
unlistenFn();
}
}).catch((err) => {
@@ -175,11 +173,6 @@ export function PopoutInspector() {
// Fetch the first selected file
const firstFileId = selectedFileIds[0] || null;
console.log("[PopoutInspector] Current state:", {
selectedFileIds,
firstFileId,
});
const { data: file, isLoading } = useLibraryQuery(
{
type: "files.by_id",
@@ -190,11 +183,6 @@ export function PopoutInspector() {
}
);
console.log("[PopoutInspector] Query result:", {
file: file?.id,
isLoading,
});
// Compute inspector variant
const variant: InspectorVariant = file
? { type: "file", file }

View File

@@ -30,7 +30,6 @@ export function SelectionProvider({ children }: { children: ReactNode }) {
const fileIds = selectedFiles.map((f) => f.id);
if (platform.setSelectedFileIds) {
console.log("[SelectionContext] Syncing selected files to platform:", fileIds);
platform.setSelectedFileIds(fileIds).catch((err) => {
console.error("Failed to sync selected files to platform:", err);
});