Add misc UI and ephemeral watching (wip)

This commit is contained in:
Jamie Pine
2025-12-09 06:05:51 -08:00
parent b029004a24
commit 591c7461a4
40 changed files with 3155 additions and 843 deletions

BIN
Cargo.lock generated
View File

Binary file not shown.

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

View File

@@ -0,0 +1,50 @@
{
"fill" : {
"linear-gradient" : [
"display-p3:0.99810,0.99038,1.00000,1.00000",
"display-p3:0.99810,0.99038,1.00000,1.00000"
],
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 1
},
"stop" : {
"x" : 0.5,
"y" : 0.3
}
}
},
"groups" : [
{
"layers" : [
{
"glass" : true,
"image-name" : "Ball.png",
"name" : "Ball",
"position" : {
"scale" : 2,
"translation-in-points" : [
1.7218333746113785,
2.7640092574830533
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

View File

@@ -0,0 +1,27 @@
#!/bin/bash
set -e
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
TAURI_ROOT="$SCRIPT_DIR/.."
ICON_SOURCE="$TAURI_ROOT/Spacedrive.icon"
GEN_DIR="$TAURI_ROOT/src-tauri/gen"
# Create gen directory if it doesn't exist
mkdir -p "$GEN_DIR"
# Compile .icon to Assets.car using actool
echo "Compiling Spacedrive.icon to Assets.car..."
xcrun actool "$ICON_SOURCE" \
--compile "$GEN_DIR" \
--output-format human-readable-text \
--notices --warnings --errors \
--output-partial-info-plist "$GEN_DIR/partial.plist" \
--app-icon Spacedrive \
--include-all-app-icons \
--enable-on-demand-resources NO \
--development-region en \
--target-device mac \
--minimum-deployment-target 11.0 \
--platform macosx
echo "Successfully generated Assets.car and Spacedrive.icns"

2
apps/tauri/src-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Generated icon assets
gen/

View File

@@ -1,8 +1,12 @@
[package]
name = "spacedrive-tauri"
name = "spacedrive"
version = "2.0.0"
edition = "2021"
[[bin]]
name = "Spacedrive"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2.0", features = [] }

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIconFile</key>
<string>Spacedrive</string>
<key>CFBundleIconName</key>
<string>Spacedrive</string>
</dict>
</plist>

View File

@@ -1,3 +1,66 @@
use std::process::Command;
fn main() {
// Compile .icon to Assets.car on macOS
#[cfg(target_os = "macos")]
{
let project_root = std::env::var("CARGO_MANIFEST_DIR")
.expect("CARGO_MANIFEST_DIR not set");
let icon_source = format!("{}/../Spacedrive.icon", project_root);
let gen_dir = format!("{}/gen", project_root);
// Create gen directory
std::fs::create_dir_all(&gen_dir).expect("Failed to create gen directory");
// Check if .icon file exists
if std::path::Path::new(&icon_source).exists() {
println!("cargo:rerun-if-changed={}", icon_source);
// Run actool to compile .icon to Assets.car
let output = Command::new("xcrun")
.args(&[
"actool",
&icon_source,
"--compile",
&gen_dir,
"--output-format",
"human-readable-text",
"--notices",
"--warnings",
"--errors",
"--output-partial-info-plist",
&format!("{}/partial.plist", gen_dir),
"--app-icon",
"Spacedrive",
"--include-all-app-icons",
"--enable-on-demand-resources",
"NO",
"--development-region",
"en",
"--target-device",
"mac",
"--minimum-deployment-target",
"11.0",
"--platform",
"macosx",
])
.output()
.expect("Failed to execute actool");
if !output.status.success() {
eprintln!(
"actool failed: {}",
String::from_utf8_lossy(&output.stderr)
);
} else {
println!(
"Successfully compiled Spacedrive.icon to Assets.car"
);
}
} else {
println!("cargo:warning=Spacedrive.icon not found at {}", icon_source);
}
}
tauri_build::build()
}

View File

Binary file not shown.

View File

@@ -57,6 +57,9 @@
"icons/icon.icns",
"icons/icon.ico"
],
"resources": {
"gen/**/*": "./"
},
"fileAssociations": [
{
"ext": ["memory"],
@@ -73,7 +76,9 @@
},
"macOS": {
"minimumSystemVersion": "10.15",
"signingIdentity": "Apple Development: James Pine (TU2EDB6U4N)"
"signingIdentity": "-",
"infoPlist": "Info.plist",
"frameworks": ["../apps/.deps/Spacedrive.framework"]
},
"windows": {
"webviewInstallMode": {

1424
bun.lock
View File

File diff suppressed because it is too large Load Diff

View File

Binary file not shown.

View File

@@ -22,6 +22,7 @@ pub struct CoreContext {
pub action_manager: Arc<RwLock<Option<Arc<ActionManager>>>>,
pub networking: Arc<RwLock<Option<Arc<NetworkingService>>>>,
pub plugin_manager: Arc<RwLock<Option<Arc<RwLock<crate::infra::extension::PluginManager>>>>>,
pub location_watcher: Arc<RwLock<Option<Arc<crate::service::watcher::LocationWatcher>>>>,
// Ephemeral index cache for unmanaged paths
pub ephemeral_index_cache: Arc<EphemeralIndexCache>,
// Job logging configuration
@@ -49,6 +50,7 @@ impl CoreContext {
action_manager: Arc::new(RwLock::new(None)),
networking: Arc::new(RwLock::new(None)),
plugin_manager: Arc::new(RwLock::new(None)),
location_watcher: Arc::new(RwLock::new(None)),
ephemeral_index_cache: Arc::new(
EphemeralIndexCache::new().expect("Failed to create ephemeral index cache"),
),
@@ -100,6 +102,16 @@ impl CoreContext {
*self.networking.write().await = Some(networking);
}
/// Helper method for services to get the location watcher
pub async fn get_location_watcher(&self) -> Option<Arc<crate::service::watcher::LocationWatcher>> {
self.location_watcher.read().await.clone()
}
/// Method for Core to set location watcher after it's initialized
pub async fn set_location_watcher(&self, watcher: Arc<crate::service::watcher::LocationWatcher>) {
*self.location_watcher.write().await = Some(watcher);
}
/// Helper method to get the action manager
pub async fn get_action_manager(&self) -> Option<Arc<ActionManager>> {
self.action_manager.read().await.clone()

View File

@@ -91,7 +91,7 @@ impl JobManager {
/// Dispatch a job for execution
pub async fn dispatch<J>(&self, job: J) -> JobResult<JobHandle>
where
J: Job + JobHandler,
J: Job + JobHandler + DynJob,
{
self.dispatch_with_priority(job, JobPriority::NORMAL, None)
.await
@@ -165,34 +165,42 @@ impl JobManager {
action_context: Option<ActionContext>,
) -> JobResult<JobHandle> {
let job_id = JobId::new();
info!("Dispatching job {} ({}): {}", job_id, job_name, job_name);
let should_persist = erased_job.should_persist();
// Serialize job state for database
let state = erased_job.serialize_state()?;
info!(
"Dispatching job {} ({}): {} [persist: {}]",
job_id, job_name, job_name, should_persist
);
// Create database record
let job_model = database::jobs::ActiveModel {
id: Set(job_id.to_string()),
name: Set(job_name.to_string()),
state: Set(state),
status: Set(JobStatus::Queued.to_string()),
priority: Set(priority.0),
progress_type: Set(None),
progress_data: Set(None),
parent_job_id: Set(None),
created_at: Set(Utc::now()),
started_at: Set(None),
completed_at: Set(None),
paused_at: Set(None),
error_message: Set(None),
warnings: Set(None),
non_critical_errors: Set(None),
metrics: Set(None),
action_context: Set(None),
action_type: Set(None),
};
// Only persist to database if the job should be persisted
if should_persist {
// Serialize job state for database
let state = erased_job.serialize_state()?;
job_model.insert(self.db.conn()).await?;
// Create database record
let job_model = database::jobs::ActiveModel {
id: Set(job_id.to_string()),
name: Set(job_name.to_string()),
state: Set(state),
status: Set(JobStatus::Queued.to_string()),
priority: Set(priority.0),
progress_type: Set(None),
progress_data: Set(None),
parent_job_id: Set(None),
created_at: Set(Utc::now()),
started_at: Set(None),
completed_at: Set(None),
paused_at: Set(None),
error_message: Set(None),
warnings: Set(None),
non_critical_errors: Set(None),
metrics: Set(None),
action_context: Set(None),
action_type: Set(None),
};
job_model.insert(self.db.conn()).await?;
}
// Create channels
let (status_tx, status_rx) = watch::channel(JobStatus::Queued);
@@ -203,6 +211,7 @@ impl JobManager {
let latest_progress = Arc::new(Mutex::new(None));
// Create progress forwarding task
// For ephemeral jobs, skip database updates and event emission
let broadcast_tx_clone = broadcast_tx.clone();
let latest_progress_clone = latest_progress.clone();
let event_bus = self.context.events.clone();
@@ -217,6 +226,11 @@ impl JobManager {
*latest_progress_clone.lock().await = Some(progress.clone());
let _ = broadcast_tx_clone.send(progress.clone());
// Skip event updates for ephemeral jobs
if !should_persist {
continue;
}
// Throttle JobProgress events to prevent flooding the event bus
let now = std::time::Instant::now();
if now.duration_since(last_emit) < throttle_duration {
@@ -337,54 +351,57 @@ impl JobManager {
let status = *status_rx.borrow();
match status {
JobStatus::Completed => {
// Get the final output from the handle before removing the job
let output = {
let jobs = running_jobs.read().await;
if let Some(job) = jobs.get(&job_id_clone) {
let result = job.handle.output.lock().await.clone();
match result {
Some(Ok(output)) => output,
Some(Err(_)) => JobOutput::Success,
None => JobOutput::Success,
}
} else {
JobOutput::Success
}
};
// Emit completion event with the job's output
event_bus.emit(Event::JobCompleted {
job_id: job_id_clone.to_string(),
job_type: job_type_str.clone(),
output,
});
// Trigger library statistics recalculation after job completion
let library_id_for_stats = library_id_clone;
let context_for_stats = context.clone();
tokio::spawn(async move {
if let Some(library) = context_for_stats
.libraries()
.await
.get_library(library_id_for_stats)
.await
{
if let Err(e) = library.recalculate_statistics().await {
warn!(
library_id = %library_id_for_stats,
job_id = %job_id_clone,
error = %e,
"Failed to trigger library statistics recalculation after job completion"
);
// Only emit events and trigger statistics for persistent jobs
if should_persist {
// Get the final output from the handle before removing the job
let output = {
let jobs = running_jobs.read().await;
if let Some(job) = jobs.get(&job_id_clone) {
let result = job.handle.output.lock().await.clone();
match result {
Some(Ok(output)) => output,
Some(Err(_)) => JobOutput::Success,
None => JobOutput::Success,
}
} else {
debug!(
library_id = %library_id_for_stats,
job_id = %job_id_clone,
"Triggered library statistics recalculation after job completion"
);
JobOutput::Success
}
}
});
};
// Emit completion event with the job's output
event_bus.emit(Event::JobCompleted {
job_id: job_id_clone.to_string(),
job_type: job_type_str.clone(),
output,
});
// Trigger library statistics recalculation after job completion
let library_id_for_stats = library_id_clone;
let context_for_stats = context.clone();
tokio::spawn(async move {
if let Some(library) = context_for_stats
.libraries()
.await
.get_library(library_id_for_stats)
.await
{
if let Err(e) = library.recalculate_statistics().await {
warn!(
library_id = %library_id_for_stats,
job_id = %job_id_clone,
error = %e,
"Failed to trigger library statistics recalculation after job completion"
);
} else {
debug!(
library_id = %library_id_for_stats,
job_id = %job_id_clone,
"Triggered library statistics recalculation after job completion"
);
}
}
});
}
// Remove from running jobs
running_jobs.write().await.remove(&job_id_clone);
@@ -395,23 +412,27 @@ impl JobManager {
break;
}
JobStatus::Failed => {
// Emit failure event
event_bus.emit(Event::JobFailed {
job_id: job_id_clone.to_string(),
job_type: job_type_str.clone(),
error: "Job failed".to_string(),
});
// Only emit events for persistent jobs
if should_persist {
event_bus.emit(Event::JobFailed {
job_id: job_id_clone.to_string(),
job_type: job_type_str.clone(),
error: "Job failed".to_string(),
});
}
// Remove from running jobs
running_jobs.write().await.remove(&job_id_clone);
info!("Job {} failed and removed from running jobs", job_id_clone);
break;
}
JobStatus::Cancelled => {
// Emit cancellation event
event_bus.emit(Event::JobCancelled {
job_id: job_id_clone.to_string(),
job_type: job_type_str.clone(),
});
// Only emit events for persistent jobs
if should_persist {
event_bus.emit(Event::JobCancelled {
job_id: job_id_clone.to_string(),
job_type: job_type_str.clone(),
});
}
// Remove from running jobs
running_jobs.write().await.remove(&job_id_clone);
info!(
@@ -439,54 +460,68 @@ impl JobManager {
action_context: Option<ActionContext>,
) -> JobResult<JobHandle>
where
J: Job + JobHandler,
J: Job + JobHandler + DynJob,
{
let job_id = JobId::new();
let should_persist = job.should_persist();
if let Some(ref ctx) = action_context {
info!(
"Dispatching job {}: {} (from action: {})",
"Dispatching job {}: {} (from action: {}) [persist: {}]",
job_id,
J::NAME,
ctx.action_type
ctx.action_type,
should_persist
);
} else {
info!("Dispatching job {}: {}", job_id, J::NAME);
info!(
"Dispatching job {}: {} [persist: {}]",
job_id,
J::NAME,
should_persist
);
}
// Serialize job state
let state =
rmp_serde::to_vec(&job).map_err(|e| JobError::serialization(format!("{}", e)))?;
// Only persist to database if the job should be persisted
if should_persist {
// Serialize job state
let state =
rmp_serde::to_vec(&job).map_err(|e| JobError::serialization(format!("{}", e)))?;
// Serialize action context if provided
let serialized_action_context = if let Some(ref ctx) = action_context {
Some(rmp_serde::to_vec(ctx).map_err(|e| JobError::serialization(format!("{}", e)))?)
} else {
None
};
// Serialize action context if provided
let serialized_action_context = if let Some(ref ctx) = action_context {
Some(
rmp_serde::to_vec(ctx)
.map_err(|e| JobError::serialization(format!("{}", e)))?,
)
} else {
None
};
// Create database record
let job_model = database::jobs::ActiveModel {
id: Set(job_id.to_string()),
name: Set(J::NAME.to_string()),
state: Set(state),
status: Set(JobStatus::Queued.to_string()),
priority: Set(priority.0),
progress_type: Set(None),
progress_data: Set(None),
parent_job_id: Set(None),
created_at: Set(Utc::now()),
started_at: Set(None),
completed_at: Set(None),
paused_at: Set(None),
error_message: Set(None),
warnings: Set(None),
non_critical_errors: Set(None),
metrics: Set(None),
action_context: Set(serialized_action_context),
action_type: Set(action_context.as_ref().map(|ctx| ctx.action_type.clone())),
};
// Create database record
let job_model = database::jobs::ActiveModel {
id: Set(job_id.to_string()),
name: Set(J::NAME.to_string()),
state: Set(state),
status: Set(JobStatus::Queued.to_string()),
priority: Set(priority.0),
progress_type: Set(None),
progress_data: Set(None),
parent_job_id: Set(None),
created_at: Set(Utc::now()),
started_at: Set(None),
completed_at: Set(None),
paused_at: Set(None),
error_message: Set(None),
warnings: Set(None),
non_critical_errors: Set(None),
metrics: Set(None),
action_context: Set(serialized_action_context),
action_type: Set(action_context.as_ref().map(|ctx| ctx.action_type.clone())),
};
job_model.insert(self.db.conn()).await?;
job_model.insert(self.db.conn()).await?;
}
// Create channels
let (status_tx, status_rx) = watch::channel(JobStatus::Queued);
@@ -497,6 +532,7 @@ impl JobManager {
let latest_progress = Arc::new(Mutex::new(None));
// Create progress forwarding task with batching and throttling
// For ephemeral jobs, skip database updates and event emission
let broadcast_tx_clone = broadcast_tx.clone();
let latest_progress_clone = latest_progress.clone();
let event_bus = self.context.events.clone();
@@ -519,6 +555,11 @@ impl JobManager {
// Ignore errors if no one is listening
let _ = broadcast_tx_clone.send(progress.clone());
// Skip database and event updates for ephemeral jobs
if !should_persist {
continue;
}
// Persist progress to database with throttling
if last_db_update.elapsed() >= DB_UPDATE_INTERVAL {
if let Err(e) = job_db_clone.update_progress(job_id_clone, &progress).await {
@@ -564,12 +605,14 @@ impl JobManager {
}
// Final progress update when channel closes
if let Some(final_progress) = &*latest_progress_clone.lock().await {
if let Err(e) = job_db_clone
.update_progress(job_id_clone, final_progress)
.await
{
debug!("Failed to persist final job progress to database: {}", e);
if should_persist {
if let Some(final_progress) = &*latest_progress_clone.lock().await {
if let Err(e) = job_db_clone
.update_progress(job_id_clone, final_progress)
.await
{
debug!("Failed to persist final job progress to database: {}", e);
}
}
}
});
@@ -661,54 +704,57 @@ impl JobManager {
info!("Job {} status changed to: {:?}", job_id_clone, status);
match status {
JobStatus::Completed => {
// Get the final output from the handle before removing the job
let output = {
let jobs = running_jobs.read().await;
if let Some(job) = jobs.get(&job_id_clone) {
job.handle
.output
.lock()
.await
.clone()
.unwrap_or(Ok(JobOutput::Success))
} else {
Ok(JobOutput::Success)
}
};
// Emit completion event with the job's output
event_bus.emit(Event::JobCompleted {
job_id: job_id_clone.to_string(),
job_type: job_type_str.to_string(),
output: output.unwrap_or(JobOutput::Success),
});
// Trigger library statistics recalculation after job completion
let library_id_for_stats = library_id_clone;
let context_for_stats = context.clone();
tokio::spawn(async move {
if let Some(library) = context_for_stats
.libraries()
.await
.get_library(library_id_for_stats)
.await
{
if let Err(e) = library.recalculate_statistics().await {
warn!(
library_id = %library_id_for_stats,
job_id = %job_id_clone,
error = %e,
"Failed to trigger library statistics recalculation after job completion"
);
// Only emit events and trigger statistics for persistent jobs
if should_persist {
// Get the final output from the handle before removing the job
let output = {
let jobs = running_jobs.read().await;
if let Some(job) = jobs.get(&job_id_clone) {
job.handle
.output
.lock()
.await
.clone()
.unwrap_or(Ok(JobOutput::Success))
} else {
debug!(
library_id = %library_id_for_stats,
job_id = %job_id_clone,
"Triggered library statistics recalculation after job completion"
);
Ok(JobOutput::Success)
}
}
});
};
// Emit completion event with the job's output
event_bus.emit(Event::JobCompleted {
job_id: job_id_clone.to_string(),
job_type: job_type_str.to_string(),
output: output.unwrap_or(JobOutput::Success),
});
// Trigger library statistics recalculation after job completion
let library_id_for_stats = library_id_clone;
let context_for_stats = context.clone();
tokio::spawn(async move {
if let Some(library) = context_for_stats
.libraries()
.await
.get_library(library_id_for_stats)
.await
{
if let Err(e) = library.recalculate_statistics().await {
warn!(
library_id = %library_id_for_stats,
job_id = %job_id_clone,
error = %e,
"Failed to trigger library statistics recalculation after job completion"
);
} else {
debug!(
library_id = %library_id_for_stats,
job_id = %job_id_clone,
"Triggered library statistics recalculation after job completion"
);
}
}
});
}
// Remove from running jobs
running_jobs.write().await.remove(&job_id_clone);
@@ -719,23 +765,27 @@ impl JobManager {
break;
}
JobStatus::Failed => {
// Emit failure event
event_bus.emit(Event::JobFailed {
job_id: job_id_clone.to_string(),
job_type: job_type_str.to_string(),
error: "Job failed".to_string(),
});
// Only emit events for persistent jobs
if should_persist {
event_bus.emit(Event::JobFailed {
job_id: job_id_clone.to_string(),
job_type: job_type_str.to_string(),
error: "Job failed".to_string(),
});
}
// Remove from running jobs
running_jobs.write().await.remove(&job_id_clone);
info!("Job {} failed and removed from running jobs", job_id_clone);
break;
}
JobStatus::Cancelled => {
// Emit cancellation event
event_bus.emit(Event::JobCancelled {
job_id: job_id_clone.to_string(),
job_type: job_type_str.to_string(),
});
// Only emit events for persistent jobs
if should_persist {
event_bus.emit(Event::JobCancelled {
job_id: job_id_clone.to_string(),
job_type: job_type_str.to_string(),
});
}
// Remove from running jobs
running_jobs.write().await.remove(&job_id_clone);
info!(

View File

@@ -132,4 +132,10 @@ pub trait JobDependencies {
pub trait DynJob: Send + Sync {
/// Job name for identification
fn job_name(&self) -> &'static str;
/// Whether this job should be persisted to the database
/// Returns false for ephemeral jobs that run in the background without persistence
fn should_persist(&self) -> bool {
true
}
}

View File

@@ -151,6 +151,10 @@ pub trait ErasedJob: Send + Sync + std::fmt::Debug + 'static {
) -> Box<dyn sd_task_system::Task<crate::infra::job::error::JobError>>;
fn serialize_state(&self) -> Result<Vec<u8>, crate::infra::job::error::JobError>;
fn should_persist(&self) -> bool {
true
}
}
/// Information about a job (for display/querying)

View File

@@ -185,6 +185,11 @@ impl Core {
.set_sidecar_manager(services.sidecar_manager.clone())
.await;
// Set location watcher in context so it can be accessed by jobs (for ephemeral watch registration)
context
.set_location_watcher(services.location_watcher.clone())
.await;
// Auto-load all libraries with context for job manager initialization
info!("Loading existing libraries...");
let mut loaded_libraries: Vec<Arc<crate::library::Library>> =

View File

@@ -209,6 +209,10 @@ impl DynJob for IndexerJob {
fn job_name(&self) -> &'static str {
Self::NAME
}
fn should_persist(&self) -> bool {
!self.config.is_ephemeral()
}
}
impl JobProgress for IndexerProgress {}
@@ -510,10 +514,32 @@ impl JobHandler for IndexerJob {
.ephemeral_cache()
.mark_indexing_complete(local_path);
match &result {
Ok(_) => ctx.log(format!(
"Marked ephemeral indexing complete for: {}",
local_path.display()
)),
Ok(_) => {
ctx.log(format!(
"Marked ephemeral indexing complete for: {}",
local_path.display()
));
// Automatically add filesystem watch for successfully indexed ephemeral paths
// This enables real-time updates when files change in browsed directories
if let Some(watcher) = ctx.library().core_context().get_location_watcher().await {
if let Err(e) = watcher.add_ephemeral_watch(
local_path.to_path_buf(),
self.config.rule_toggles
).await {
ctx.log(format!(
"Warning: Failed to add ephemeral watch for {}: {}",
local_path.display(),
e
));
} else {
ctx.log(format!(
"Added ephemeral watch for: {}",
local_path.display()
));
}
}
}
Err(e) => ctx.log(format!(
"Marked ephemeral indexing complete (job failed: {}) for: {}",
e,

View File

@@ -10,6 +10,6 @@ pub struct ReorderGroupsInput {
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct ReorderItemsInput {
pub group_id: Uuid,
pub group_id: Option<Uuid>,
pub item_ids: Vec<Uuid>,
}

View File

@@ -145,6 +145,8 @@ impl LocationWorkerMetrics {
pub struct WatcherMetrics {
/// Total locations being watched
pub total_locations: AtomicU64,
/// Total ephemeral watches (shallow, non-recursive)
pub total_ephemeral_watches: AtomicU64,
/// Total events received from filesystem
pub total_events_received: AtomicU64,
/// Total workers created
@@ -179,6 +181,11 @@ impl WatcherMetrics {
self.total_locations.store(count as u64, Ordering::Relaxed);
}
/// Update total ephemeral watches count
pub fn update_ephemeral_watches(&self, count: usize) {
self.total_ephemeral_watches.store(count as u64, Ordering::Relaxed);
}
/// Get event processing rate (events per second)
pub fn get_processing_rate(&self) -> f64 {
let received = self.total_events_received.load(Ordering::Relaxed);
@@ -190,8 +197,9 @@ impl WatcherMetrics {
/// Log current metrics
pub fn log_metrics(&self) {
info!(
"Watcher metrics: locations={}, events_received={}, workers_created={}, workers_destroyed={}",
"Watcher metrics: locations={}, ephemeral_watches={}, events_received={}, workers_created={}, workers_destroyed={}",
self.total_locations.load(Ordering::Relaxed),
self.total_ephemeral_watches.load(Ordering::Relaxed),
self.total_events_received.load(Ordering::Relaxed),
self.total_workers_created.load(Ordering::Relaxed),
self.total_workers_destroyed.load(Ordering::Relaxed)

View File

@@ -575,6 +575,8 @@ impl LocationWatcher {
rule_toggles,
},
);
// Update metrics
self.metrics.update_ephemeral_watches(watches.len());
}
// Add to file system watcher with NonRecursive mode
@@ -592,7 +594,10 @@ impl LocationWatcher {
pub async fn remove_ephemeral_watch(&self, path: &Path) -> Result<()> {
let watch = {
let mut watches = self.ephemeral_watches.write().await;
watches.remove(path)
let watch = watches.remove(path);
// Update metrics
self.metrics.update_ephemeral_watches(watches.len());
watch
};
if let Some(watch) = watch {
@@ -720,6 +725,15 @@ impl LocationWatcher {
continue;
};
// Skip locations with IndexMode::None (not persistently indexed)
if location.index_mode == "none" {
debug!(
"Skipping location {} with IndexMode::None (ephemeral browsing only)",
location.uuid
);
continue;
}
// Get the full path using PathResolver with timeout
let path_result = tokio::time::timeout(
std::time::Duration::from_secs(5),
@@ -1183,6 +1197,44 @@ impl LocationWatcher {
path.display()
);
// Query the location to check its index_mode
let libraries = context.libraries().await;
let should_watch = if let Some(library) = libraries.get_library(library_id).await {
let db = library.db().conn();
match crate::infra::db::entities::location::Entity::find()
.filter(crate::infra::db::entities::location::Column::Uuid.eq(location_id))
.one(db)
.await
{
Ok(Some(location_record)) => {
if location_record.index_mode == "none" {
debug!(
"Skipping newly added location {} with IndexMode::None",
location_id
);
false
} else {
true
}
}
Ok(None) => {
warn!("Location {} not found in database", location_id);
false
}
Err(e) => {
error!("Failed to query location {}: {}", location_id, e);
false
}
}
} else {
warn!("Library {} not found for location {}", library_id, location_id);
false
};
if !should_watch {
continue;
}
// Create a temporary LocationWatcher instance for this operation
let temp_watcher = LocationWatcher {
config: config.clone(),

View File

@@ -100,6 +100,10 @@ pub fn derive_job(input: TokenStream) -> TokenStream {
rmp_serde::to_vec(self)
.map_err(|e| crate::infra::job::error::JobError::serialization(format!("{}", e)))
}
fn should_persist(&self) -> bool {
<#name as crate::infra::job::traits::DynJob>::should_persist(self)
}
}
};

View File

@@ -29,7 +29,7 @@
"overrides": {
"@types/node": ">18.18.x",
"react": "19.1.0",
"react-dom": "^19.1.0",
"react-dom": "19.1.0",
"react-router": "=6.20.1",
"react-router-dom": "=6.20.1",
"@remix-run/router": "=1.13.1",

View File

@@ -18,6 +18,7 @@
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@phosphor-icons/react": "^2.1.0",
"@radix-ui/react-dialog": "^1.0.5",

View File

@@ -29,7 +29,10 @@ import {
PREVIEW_LAYER_ID,
} from "./components/QuickPreview";
import { createExplorerRouter } from "./router";
import { useNormalizedQuery, useLibraryMutation } from "./context";
import { useNormalizedQuery, useLibraryMutation, useSpacedriveClient } from "./context";
import { useSidebarStore } from "@sd/ts-client";
import { useSpaces } from "./components/SpacesSidebar/hooks/useSpaces";
import { useQueryClient } from "@tanstack/react-query";
import { usePlatform } from "./platform";
import type { LocationInfo, SdPath } from "@sd/ts-client";
import {
@@ -314,8 +317,15 @@ function DndWrapper({ children }: { children: React.ReactNode }) {
}),
);
const addItem = useLibraryMutation("spaces.add_item");
const reorderItems = useLibraryMutation("spaces.reorder_items");
const reorderGroups = useLibraryMutation("spaces.reorder_groups");
const openFileOperation = useFileOperationDialog();
const [activeItem, setActiveItem] = useState<any>(null);
const client = useSpacedriveClient();
const queryClient = useQueryClient();
const { currentSpaceId } = useSidebarStore();
const { data: spacesData } = useSpaces();
const spaces = spacesData?.spaces;
// Custom collision detection: prefer -top zones over -bottom zones to avoid double lines
const customCollision: CollisionDetection = (args) => {
@@ -342,13 +352,175 @@ function DndWrapper({ children }: { children: React.ReactNode }) {
setActiveItem(null);
if (!over || !active.data.current) return;
if (!over) return;
// Handle sortable reordering (no drag data, just active/over IDs)
if (active.id !== over.id && !active.data.current?.type) {
console.log("[DnD] Sortable reorder:", {
activeId: active.id,
overId: over.id,
});
const libraryId = client.getCurrentLibraryId();
const currentSpace = spaces?.find((s: any) => s.id === currentSpaceId) ?? spaces?.[0];
if (!currentSpace || !libraryId) return;
const queryKey = ['query:spaces.get_layout', libraryId, { space_id: currentSpace.id }];
const layout = queryClient.getQueryData(queryKey) as any;
if (!layout) return;
// Check if we're reordering groups
const groups = layout.groups?.map((g: any) => g.group) || [];
const isGroupReorder = groups.some((g: any) => g.id === active.id);
if (isGroupReorder) {
console.log("[DnD] Reordering groups");
const oldIndex = groups.findIndex((g: any) => g.id === active.id);
const newIndex = groups.findIndex((g: any) => g.id === over.id);
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
// Optimistically update the UI
const newGroups = [...layout.groups];
const [movedGroup] = newGroups.splice(oldIndex, 1);
newGroups.splice(newIndex, 0, movedGroup);
queryClient.setQueryData(queryKey, {
...layout,
groups: newGroups,
});
// Send reorder mutation
try {
await reorderGroups.mutateAsync({
space_id: currentSpace.id,
group_ids: newGroups.map((g: any) => g.group.id),
});
console.log("[DnD] Group reorder successful");
} catch (err) {
console.error("[DnD] Group reorder failed:", err);
// Revert on error
queryClient.setQueryData(queryKey, layout);
}
}
return;
}
// Reordering space items
if (layout?.space_items) {
const items = layout.space_items;
const oldIndex = items.findIndex((item: any) => item.id === active.id);
// Extract item ID from over.id (could be a drop zone ID like "space-item-{id}-top")
let overItemId = String(over.id);
if (overItemId.startsWith('space-item-')) {
// Extract the UUID from "space-item-{uuid}-top/bottom/middle"
const parts = overItemId.split('-');
// Remove "space" and "item" and the last part (top/bottom/middle)
overItemId = parts.slice(2, -1).join('-');
}
const newIndex = items.findIndex((item: any) => item.id === overItemId);
console.log("[DnD] Reorder space items:", {
oldIndex,
newIndex,
activeId: active.id,
extractedOverId: overItemId,
});
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
// Optimistically update the UI
const newItems = [...items];
const [movedItem] = newItems.splice(oldIndex, 1);
newItems.splice(newIndex, 0, movedItem);
queryClient.setQueryData(queryKey, {
...layout,
space_items: newItems,
});
// Send reorder mutation
try {
await reorderItems.mutateAsync({
group_id: null, // Space-level items
item_ids: newItems.map((item: any) => item.id),
});
console.log("[DnD] Space items reorder successful");
} catch (err) {
console.error("[DnD] Space items reorder failed:", err);
// Revert on error
queryClient.setQueryData(queryKey, layout);
}
}
}
return;
}
if (!active.data.current) return;
const dragData = active.data.current;
const dropData = over.data.current;
console.log("[DnD] Drag end:", {
dragType: dragData?.type,
dropAction: dropData?.action,
dropType: dropData?.type,
spaceId: dropData?.spaceId,
groupId: dropData?.groupId,
});
if (!dragData || dragData.type !== "explorer-file") return;
// Add to space (root-level drop zones between groups)
if (dropData?.action === "add-to-space") {
if (!dropData.spaceId) return;
console.log("[DnD] Adding to space root:", {
spaceId: dropData.spaceId,
sdPath: dragData.sdPath,
});
try {
await addItem.mutateAsync({
space_id: dropData.spaceId,
group_id: null,
item_type: { Path: { sd_path: dragData.sdPath } },
});
console.log("[DnD] Successfully added to space root");
} catch (err) {
console.error("[DnD] Failed to add to space:", err);
}
return;
}
// Add to group (empty group drop zone)
if (dropData?.action === "add-to-group") {
if (!dropData.spaceId || !dropData.groupId) return;
console.log("[DnD] Adding to group:", {
spaceId: dropData.spaceId,
groupId: dropData.groupId,
sdPath: dragData.sdPath,
});
try {
await addItem.mutateAsync({
space_id: dropData.spaceId,
group_id: dropData.groupId,
item_type: { Path: { sd_path: dragData.sdPath } },
});
console.log("[DnD] Successfully added to group");
} catch (err) {
console.error("[DnD] Failed to add to group:", err);
}
return;
}
// Insert before/after sidebar items (adds item to space/group)
if (
dropData?.action === "insert-before" ||
@@ -356,15 +528,23 @@ function DndWrapper({ children }: { children: React.ReactNode }) {
) {
if (!dropData.spaceId) return;
console.log("[DnD] Inserting item:", {
action: dropData.action,
spaceId: dropData.spaceId,
groupId: dropData.groupId,
sdPath: dragData.sdPath,
});
try {
await addItem.mutateAsync({
space_id: dropData.spaceId,
group_id: dropData.groupId || null,
item_type: { Path: { sd_path: dragData.sdPath } },
});
console.log("[DnD] Successfully inserted item");
// TODO: Implement proper ordering relative to itemId
} catch (err) {
console.error("Failed to add item:", err);
console.error("[DnD] Failed to add item:", err);
}
return;
}
@@ -391,27 +571,40 @@ function DndWrapper({ children }: { children: React.ReactNode }) {
// Drop on space root area (adds to space)
if (dropData?.type === "space" && dragData.type === "explorer-file") {
console.log("[DnD] Adding to space (type=space):", {
spaceId: dropData.spaceId,
sdPath: dragData.sdPath,
});
try {
await addItem.mutateAsync({
space_id: dropData.spaceId,
group_id: null,
item_type: { Path: { sd_path: dragData.sdPath } },
});
console.log("[DnD] Successfully added to space");
} catch (err) {
console.error("Failed to add item:", err);
console.error("[DnD] Failed to add item:", err);
}
}
// Drop on group area (adds to group)
if (dropData?.type === "group" && dragData.type === "explorer-file") {
console.log("[DnD] Adding to group (type=group):", {
spaceId: dropData.spaceId,
groupId: dropData.groupId,
sdPath: dragData.sdPath,
});
try {
await addItem.mutateAsync({
space_id: dropData.spaceId,
group_id: dropData.groupId,
item_type: { Path: { sd_path: dragData.sdPath } },
});
console.log("[DnD] Successfully added to group");
} catch (err) {
console.error("Failed to add item to group:", err);
console.error("[DnD] Failed to add item to group:", err);
}
}
};
@@ -425,29 +618,44 @@ function DndWrapper({ children }: { children: React.ReactNode }) {
>
{children}
<DragOverlay dropAnimation={null}>
{activeItem?.file && activeItem.gridSize ? (
<div style={{ width: activeItem.gridSize }}>
<div className="flex flex-col items-center gap-2 p-1 rounded-lg relative">
<div className="rounded-lg p-2">
<FileComponent.Thumb
file={activeItem.file}
size={Math.max(
activeItem.gridSize * 0.6,
60,
)}
/>
</div>
<div className="text-sm truncate px-2 py-0.5 rounded-md bg-accent text-white max-w-full">
{activeItem.name}
{activeItem?.file ? (
activeItem.gridSize ? (
// Grid view preview
<div style={{ width: activeItem.gridSize }}>
<div className="flex flex-col items-center gap-2 p-1 rounded-lg relative">
<div className="rounded-lg p-2">
<FileComponent.Thumb
file={activeItem.file}
size={Math.max(
activeItem.gridSize * 0.6,
60,
)}
/>
</div>
<div className="text-sm truncate px-2 py-0.5 rounded-md bg-accent text-white max-w-full">
{activeItem.name}
</div>
{/* Show count badge if dragging multiple files */}
{activeItem.selectedFiles && activeItem.selectedFiles.length > 1 && (
<div className="absolute -top-2 -right-2 size-6 rounded-full bg-accent text-white text-xs font-bold flex items-center justify-center shadow-lg border-2 border-app">
{activeItem.selectedFiles.length}
</div>
)}
</div>
</div>
) : (
// Column/List view preview
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md bg-accent text-white shadow-lg min-w-[200px] max-w-[300px]">
<FileComponent.Thumb file={activeItem.file} size={24} />
<span className="text-sm font-medium truncate">{activeItem.name}</span>
{/* Show count badge if dragging multiple files */}
{activeItem.selectedFiles && activeItem.selectedFiles.length > 1 && (
<div className="absolute -top-2 -right-2 size-6 rounded-full bg-accent text-white text-xs font-bold flex items-center justify-center shadow-lg border-2 border-app">
<div className="ml-auto size-5 rounded-full bg-white text-accent text-xs font-bold flex items-center justify-center">
{activeItem.selectedFiles.length}
</div>
)}
</div>
</div>
)
) : null}
</DragOverlay>
</DndContext>

View File

@@ -46,7 +46,7 @@ export function ColumnItem({
<div ref={setNodeRef} {...listeners} {...attributes}>
<FileComponent
file={file}
selected={selected}
selected={selected && !isDragging}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onContextMenu={onContextMenu}
@@ -54,11 +54,11 @@ export function ColumnItem({
data-file-id={file.id}
className={clsx(
"flex items-center gap-2 px-3 py-1.5 mx-2 rounded-md cursor-default transition-none",
selected
selected && !isDragging
? "bg-accent text-white"
: "text-ink",
focused && !selected && "ring-2 ring-accent/50",
isDragging && "opacity-50"
isDragging && "opacity-40"
)}
>
<div className="[&_*]:!rounded-[3px] flex-shrink-0">

View File

@@ -569,7 +569,7 @@ export const FileCard = memo(
)}
<FileComponent
file={file}
selected={selected}
selected={selected && !dndIsDragging}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onContextMenu={handleContextMenu}
@@ -577,14 +577,14 @@ export const FileCard = memo(
className={clsx(
"flex flex-col items-center gap-2 p-1 rounded-lg transition-all",
focused && !selected && "ring-2 ring-accent/50",
dndIsDragging && "opacity-50",
dndIsDragging && "opacity-40",
isFolder && isDropOver && "bg-accent/10",
)}
>
<div
className={clsx(
"rounded-lg p-2",
selected ? "bg-app-box" : "bg-transparent",
selected && !dndIsDragging ? "bg-app-box" : "bg-transparent",
)}
>
<FileComponent.Thumb file={file} size={thumbSize} />
@@ -593,7 +593,7 @@ export const FileCard = memo(
<div
className={clsx(
"text-sm truncate px-2 py-0.5 rounded-md inline-block max-w-full",
selected ? "bg-accent text-white" : "text-ink",
selected && !dndIsDragging ? "bg-accent text-white" : "text-ink",
)}
>
{file.name}

View File

@@ -1,7 +1,8 @@
import { CaretRight, Desktop, WifiHigh } from "@phosphor-icons/react";
import clsx from "clsx";
import { WifiHigh } from "@phosphor-icons/react";
import { useNavigate } from "react-router-dom";
import { useLibraryQuery, getDeviceIcon } from "../../context";
import { SpaceItem } from "./SpaceItem";
import { GroupHeader } from "./GroupHeader";
interface DevicesGroupProps {
isCollapsed: boolean;
@@ -22,21 +23,7 @@ export function DevicesGroup({ isCollapsed, onToggle }: DevicesGroupProps) {
return (
<div>
{/* Header */}
<button
onClick={onToggle}
className="mb-1 flex w-full cursor-default items-center gap-2 px-1 text-xs font-semibold uppercase tracking-wider text-sidebar-ink-faint hover:text-sidebar-ink"
>
<CaretRight
className={clsx("transition-transform", !isCollapsed && "rotate-90")}
size={10}
weight="bold"
/>
<span>Devices</span>
{devices && devices.length > 0 && (
<span className="ml-auto text-sidebar-ink-faint">{devices.length}</span>
)}
</button>
<GroupHeader label="Devices" isCollapsed={isCollapsed} onToggle={onToggle} />
{/* Items */}
{!isCollapsed && (
@@ -46,47 +33,48 @@ export function DevicesGroup({ isCollapsed, onToggle }: DevicesGroupProps) {
) : !devices || devices.length === 0 ? (
<div className="px-2 py-1 text-xs text-sidebar-ink-faint">No devices</div>
) : (
devices.map((device) => (
<button
key={device.id}
onClick={() => navigate(`/device/${device.id}`)}
className={clsx(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium",
device.is_current
? "bg-sidebar-selected/30 text-sidebar-ink"
: "text-sidebar-inkDull hover:text-sidebar-ink hover:bg-sidebar-box transition-colors"
)}
>
{/* Device Icon */}
<img src={getDeviceIcon(device)} alt="" className="size-4" />
devices.map((device, index) => {
// Create a minimal SpaceItem structure for the device
const deviceItem = {
id: device.id,
item_type: "Overview" as const,
};
{/* Device Name */}
<span className="flex-1 truncate text-left">{device.name}</span>
return (
<SpaceItem
key={device.id}
item={deviceItem as any}
customIcon={getDeviceIcon(device)}
customLabel={device.name}
allowInsertion={false}
isLastItem={index === devices.length - 1}
className="text-sidebar-inkDull"
rightComponent={
<div className="flex items-center gap-1">
{/* Paired indicator (network icon) */}
{device.is_paired && (
<WifiHigh
size={12}
weight="bold"
className="text-accent"
title="Paired via network"
/>
)}
{/* Status Indicators */}
<div className="flex items-center gap-1">
{/* Paired indicator (network icon) */}
{device.is_paired && (
<WifiHigh
size={12}
weight="bold"
className="text-accent"
title="Paired via network"
/>
)}
{/* Offline indicator */}
{!device.is_online && !device.is_connected && (
<span className="text-xs text-ink-faint">Offline</span>
)}
{/* Offline indicator */}
{!device.is_online && !device.is_connected && (
<span className="text-xs text-ink-faint">Offline</span>
)}
{/* Connected indicator for paired devices */}
{device.is_paired && device.is_connected && (
<span className="text-xs text-accent">Connected</span>
)}
</div>
</button>
))
{/* Connected indicator for paired devices */}
{device.is_paired && device.is_connected && (
<span className="text-xs text-accent">Connected</span>
)}
</div>
}
/>
);
})
)}
</div>
)}

View File

@@ -0,0 +1,37 @@
import { CaretRight } from "@phosphor-icons/react";
import clsx from "clsx";
interface GroupHeaderProps {
label: string;
isCollapsed: boolean;
onToggle: () => void;
rightComponent?: React.ReactNode;
sortableAttributes?: any;
sortableListeners?: any;
}
export function GroupHeader({
label,
isCollapsed,
onToggle,
rightComponent,
sortableAttributes,
sortableListeners,
}: GroupHeaderProps) {
return (
<button
onClick={onToggle}
{...(sortableAttributes || {})}
{...(sortableListeners || {})}
className="mb-1 flex w-full cursor-default items-center gap-2 px-1 text-tiny font-semibold tracking-wider opacity-60 text-sidebar-ink-faint hover:text-sidebar-ink"
>
<CaretRight
className={clsx("transition-transform", !isCollapsed && "rotate-90")}
size={10}
weight="bold"
/>
<span>{label}</span>
{rightComponent}
</button>
);
}

View File

@@ -1,8 +1,7 @@
import { CaretRight, Folder } from "@phosphor-icons/react";
import clsx from "clsx";
import { useNavigate } from "react-router-dom";
import { useNormalizedQuery } from "@sd/ts-client";
import { SpaceItem } from "./SpaceItem";
import { GroupHeader } from "./GroupHeader";
interface LocationsGroupProps {
isCollapsed: boolean;
@@ -22,18 +21,7 @@ export function LocationsGroup({ isCollapsed, onToggle }: LocationsGroupProps) {
return (
<div>
{/* Header */}
<button
onClick={onToggle}
className="mb-1 flex w-full cursor-default items-center gap-2 px-1 text-xs font-semibold uppercase tracking-wider text-sidebar-ink-faint hover:text-sidebar-ink"
>
<CaretRight
className={clsx("transition-transform", !isCollapsed && "rotate-90")}
size={10}
weight="bold"
/>
<span>Locations</span>
</button>
<GroupHeader label="Locations" isCollapsed={isCollapsed} onToggle={onToggle} />
{/* Items */}
{!isCollapsed && (

View File

@@ -1,95 +1,132 @@
import { CaretRight } from '@phosphor-icons/react';
import clsx from 'clsx';
import type {
SpaceGroup as SpaceGroupType,
SpaceItem as SpaceItemType,
} from '@sd/ts-client';
import { useSidebarStore } from '@sd/ts-client';
import { SpaceItem } from './SpaceItem';
import { DeviceGroup } from './DeviceGroup';
import { DevicesGroup } from './DevicesGroup';
import { LocationsGroup } from './LocationsGroup';
import { VolumesGroup } from './VolumesGroup';
import { TagsGroup } from './TagsGroup';
SpaceGroup as SpaceGroupType,
SpaceItem as SpaceItemType,
} from "@sd/ts-client";
import { useSidebarStore } from "@sd/ts-client";
import { SpaceItem } from "./SpaceItem";
import { DeviceGroup } from "./DeviceGroup";
import { DevicesGroup } from "./DevicesGroup";
import { LocationsGroup } from "./LocationsGroup";
import { VolumesGroup } from "./VolumesGroup";
import { TagsGroup } from "./TagsGroup";
import { GroupHeader } from "./GroupHeader";
import { useDroppable } from "@dnd-kit/core";
interface SpaceGroupProps {
group: SpaceGroupType;
items: SpaceItemType[];
spaceId?: string;
group: SpaceGroupType;
items: SpaceItemType[];
spaceId?: string;
sortableAttributes?: any;
sortableListeners?: any;
}
export function SpaceGroup({ group, items, spaceId }: SpaceGroupProps) {
const { collapsedGroups, toggleGroup } = useSidebarStore();
// Use backend's is_collapsed value as the source of truth, fallback to local state
const isCollapsed = group.is_collapsed ?? collapsedGroups.has(group.id);
export function SpaceGroup({ group, items, spaceId, sortableAttributes, sortableListeners }: SpaceGroupProps) {
const { collapsedGroups, toggleGroup } = useSidebarStore();
// Use backend's is_collapsed value as the source of truth, fallback to local state
const isCollapsed = group.is_collapsed ?? collapsedGroups.has(group.id);
// System groups (Locations, Volumes, etc.) are dynamic - don't allow insertion/reordering
// Custom/QuickAccess groups allow insertion
const allowInsertion = group.group_type === 'QuickAccess' || group.group_type === 'Custom';
// System groups (Locations, Volumes, etc.) are dynamic - don't allow insertion/reordering
// Custom/QuickAccess groups allow insertion
const allowInsertion =
group.group_type === "QuickAccess" || group.group_type === "Custom";
// Device groups are special - they show device info with children
if (typeof group.group_type === 'object' && 'Device' in group.group_type) {
return (
<DeviceGroup
deviceId={group.group_type.Device.device_id}
items={items}
isCollapsed={isCollapsed}
onToggle={() => toggleGroup(group.id)}
/>
);
}
// Device groups are special - they show device info with children
if (typeof group.group_type === "object" && "Device" in group.group_type) {
return (
<DeviceGroup
deviceId={group.group_type.Device.device_id}
items={items}
isCollapsed={isCollapsed}
onToggle={() => toggleGroup(group.id)}
/>
);
}
// Devices group - fetches all devices (library + paired)
if (group.group_type === 'Devices') {
return <DevicesGroup isCollapsed={isCollapsed} onToggle={() => toggleGroup(group.id)} />;
}
// Devices group - fetches all devices (library + paired)
if (group.group_type === "Devices") {
return (
<DevicesGroup
isCollapsed={isCollapsed}
onToggle={() => toggleGroup(group.id)}
/>
);
}
// Locations group - fetches all locations
if (group.group_type === 'Locations') {
return <LocationsGroup isCollapsed={isCollapsed} onToggle={() => toggleGroup(group.id)} />;
}
// Locations group - fetches all locations
if (group.group_type === "Locations") {
return (
<LocationsGroup
isCollapsed={isCollapsed}
onToggle={() => toggleGroup(group.id)}
/>
);
}
// Volumes group - fetches all volumes
if (group.group_type === 'Volumes') {
return <VolumesGroup isCollapsed={isCollapsed} onToggle={() => toggleGroup(group.id)} />;
}
// Volumes group - fetches all volumes
if (group.group_type === "Volumes") {
return (
<VolumesGroup
isCollapsed={isCollapsed}
onToggle={() => toggleGroup(group.id)}
/>
);
}
// Tags group - fetches all tags
if (group.group_type === 'Tags') {
return <TagsGroup isCollapsed={isCollapsed} onToggle={() => toggleGroup(group.id)} />;
}
// Tags group - fetches all tags
if (group.group_type === "Tags") {
return (
<TagsGroup
isCollapsed={isCollapsed}
onToggle={() => toggleGroup(group.id)}
/>
);
}
// QuickAccess and Custom groups render stored items
return (
<div className="rounded-lg">
{/* Group Header */}
<button
onClick={() => toggleGroup(group.id)}
className="mb-1 flex w-full items-center gap-2 px-1 text-xs font-semibold uppercase tracking-wider text-sidebar-ink-faint hover:text-sidebar-ink"
>
<CaretRight
className={clsx('transition-transform', !isCollapsed && 'rotate-90')}
size={10}
weight="bold"
/>
<span>{group.name}</span>
</button>
// Empty drop zone for groups with no items
const { setNodeRef: setEmptyRef, isOver: isOverEmpty } = useDroppable({
id: `group-${group.id}-empty`,
disabled: !allowInsertion || isCollapsed,
data: {
action: "add-to-group",
groupId: group.id,
spaceId,
},
});
{/* Items */}
{!isCollapsed && (
<div className="space-y-0.5">
{items.map((item, index) => (
<SpaceItem
key={item.id}
item={item}
isLastItem={index === items.length - 1}
allowInsertion={allowInsertion}
spaceId={spaceId}
groupId={group.id}
/>
))}
</div>
)}
</div>
);
// QuickAccess and Custom groups render stored items
return (
<div className="rounded-lg">
<GroupHeader
label={group.name}
isCollapsed={isCollapsed}
onToggle={() => toggleGroup(group.id)}
sortableAttributes={sortableAttributes}
sortableListeners={sortableListeners}
/>
{/* Items */}
{!isCollapsed && (
<div className="space-y-0.5 relative min-h-[20px]">
{items.length > 0 ? (
items.map((item, index) => (
<SpaceItem
key={item.id}
item={item}
isLastItem={index === items.length - 1}
allowInsertion={allowInsertion}
spaceId={spaceId}
groupId={group.id}
/>
))
) : (
<div ref={setEmptyRef} className="absolute inset-0 z-10">
{isOverEmpty && (
<div className="absolute top-1/2 -translate-y-1/2 left-2 right-2 h-[2px] bg-accent rounded-full" />
)}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -23,6 +23,8 @@ import { useContextMenu } from "../../hooks/useContextMenu";
import { usePlatform } from "../../platform";
import { useLibraryMutation } from "../../context";
import { useDroppable } from "@dnd-kit/core";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
interface SpaceItemProps {
item: SpaceItemType;
@@ -38,6 +40,8 @@ interface SpaceItemProps {
volumeData?: { device_slug: string; mount_path: string };
/** Optional custom icon (as image path) to override default icon */
customIcon?: string;
/** Optional custom label to override automatic label detection */
customLabel?: string;
/** Whether this is the last item in the list (for showing bottom insertion line) */
isLastItem?: boolean;
/** Whether this item supports insertion (reordering) - false for system groups */
@@ -46,6 +50,8 @@ interface SpaceItemProps {
spaceId?: string;
/** The group ID this item belongs to (for adding items on insertion) */
groupId?: string | null;
/** Whether this item is sortable (can be reordered) */
sortable?: boolean;
}
function getItemIcon(itemType: ItemType): any {
@@ -96,8 +102,12 @@ function getItemPath(
if (itemType === "Overview") return "/";
if (itemType === "Recents") return "/recents";
if (itemType === "Favorites") return "/favorites";
if (typeof itemType === "object" && "Location" in itemType)
if (typeof itemType === "object" && "Location" in itemType) {
// For proper SpaceItem with Location type, we need the sd_path
// This requires the parent to pass volumeData or similar
// For now, keep the old route - will be replaced when locations use raw format
return `/location/${itemType.Location.location_id}`;
}
if (typeof itemType === "object" && "Volume" in itemType) {
// Navigate to explorer with volume's root path
if (volumeData) {
@@ -114,12 +124,9 @@ function getItemPath(
if (typeof itemType === "object" && "Tag" in itemType)
return `/tag/${itemType.Tag.tag_id}`;
if (typeof itemType === "object" && "Path" in itemType) {
// If it's a directory, navigate to explorer
if (resolvedFile?.kind === "Directory") {
return `/explorer?path=${encodeURIComponent(JSON.stringify(itemType.Path.sd_path))}`;
}
// Regular files don't have a path to navigate to (could open/preview in future)
return null;
// Navigate to explorer with the SD path
// Assume it's explorable (directory or file) - if it's in the sidebar, it should be clickable
return `/explorer?path=${encodeURIComponent(JSON.stringify(itemType.Path.sd_path))}`;
}
return null;
}
@@ -132,16 +139,38 @@ export function SpaceItem({
onClick,
volumeData,
customIcon,
customLabel,
isLastItem = false,
allowInsertion = true,
spaceId,
groupId,
sortable = false,
}: SpaceItemProps) {
const navigate = useNavigate();
const location = useLocation();
const platform = usePlatform();
const deleteItem = useLibraryMutation("spaces.delete_item");
// Sortable hook (for reordering)
const sortableProps = useSortable({
id: item.id,
disabled: !sortable,
});
const {
attributes: sortableAttributes,
listeners: sortableListeners,
setNodeRef: setSortableRef,
transform,
transition,
isDragging: isSortableDragging,
} = sortableProps;
const style = sortable ? {
transform: CSS.Transform.toString(transform),
transition,
} : undefined;
// Check if this is a raw location object (has 'name' and 'sd_path' but no 'item_type')
const isRawLocation =
"name" in item && "sd_path" in item && !item.item_type;
@@ -155,7 +184,9 @@ export function SpaceItem({
// Handle raw location object
iconData = { type: "image", icon: Location };
label = (item as any).name || "Unnamed Location";
path = `/location/${item.id}`;
// Use explorer path with the location's sd_path
const sdPath = (item as any).sd_path;
path = sdPath ? `/explorer?path=${encodeURIComponent(JSON.stringify(sdPath))}` : null;
} else {
// Handle proper SpaceItem
iconData = getItemIcon(item.item_type);
@@ -169,13 +200,38 @@ export function SpaceItem({
iconData = { type: "image", icon: customIcon };
}
// Check if this item is active
// For paths with query params (like volumes), compare full path including search
const isActive = path
? path.includes("?")
? location.pathname + location.search === path
: location.pathname === path
: false;
// Override with custom label if provided
if (customLabel) {
label = customLabel;
}
// Check if this item is active by comparing SD paths
const isActive = (() => {
if (!path) return false;
// For explorer paths with query params, compare the SD path parameter
if (path.startsWith("/explorer?path=")) {
const currentSearchParams = new URLSearchParams(location.search);
const currentPathParam = currentSearchParams.get("path");
const itemPathParam = new URLSearchParams(path.split("?")[1]).get("path");
// Compare the actual SD path objects, not just the encoded strings
if (currentPathParam && itemPathParam) {
try {
const currentSdPath = JSON.parse(decodeURIComponent(currentPathParam));
const itemSdPath = JSON.parse(decodeURIComponent(itemPathParam));
return JSON.stringify(currentSdPath) === JSON.stringify(itemSdPath);
} catch {
// If parsing fails, fall back to string comparison
return currentPathParam === itemPathParam;
}
}
return false;
}
// For non-explorer routes, use simple path matching
return location.pathname === path;
})();
const handleClick = () => {
if (onClick) {
@@ -229,12 +285,10 @@ export function SpaceItem({
icon: Trash,
label: "Remove from Space",
onClick: async () => {
if (confirm(`Remove "${label}" from this space?`)) {
try {
await deleteItem.mutateAsync({ item_id: item.id });
} catch (err) {
console.error("Failed to remove item:", err);
}
try {
await deleteItem.mutateAsync({ item_id: item.id });
} catch (err) {
console.error("Failed to remove item:", err);
}
},
variant: "danger" as const,
@@ -321,14 +375,18 @@ export function SpaceItem({
});
return (
<div className="relative">
<div
ref={setSortableRef}
style={style}
className={clsx("relative", isSortableDragging && "opacity-50 z-50")}
>
{/* Insertion line indicator - only show top (bottom of previous item handles gaps) */}
{isOverTop && (
{isOverTop && !isSortableDragging && (
<div className="absolute -top-[1px] left-2 right-2 h-[2px] bg-accent z-20 rounded-full" />
)}
{/* Ring highlight for drop-into */}
{isOverMiddle && isDropTarget && (
{isOverMiddle && isDropTarget && !isSortableDragging && (
<div className="absolute inset-0 rounded-md ring-2 ring-accent/50 ring-inset pointer-events-none z-10" />
)}
@@ -375,6 +433,7 @@ export function SpaceItem({
<button
onClick={handleClick}
onContextMenu={handleContextMenu}
{...(sortable ? { ...sortableAttributes, ...sortableListeners } : {})}
className={clsx(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors relative cursor-default",
className ||

View File

@@ -1,9 +1,10 @@
import { CaretRight, Tag as TagIcon, Plus } from '@phosphor-icons/react';
import { Tag as TagIcon, Plus } from '@phosphor-icons/react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import clsx from 'clsx';
import { useNormalizedQuery, useLibraryMutation } from '../../context';
import type { Tag } from '@sd/ts-client';
import { GroupHeader } from './GroupHeader';
interface TagsGroupProps {
isCollapsed: boolean;
@@ -119,21 +120,16 @@ export function TagsGroup({ isCollapsed, onToggle }: TagsGroupProps) {
return (
<div>
{/* Header */}
<button
onClick={onToggle}
className="mb-1 flex w-full items-center gap-2 px-1 text-xs font-semibold uppercase tracking-wider text-sidebar-ink-faint hover:text-sidebar-ink"
>
<CaretRight
className={clsx('transition-transform', !isCollapsed && 'rotate-90')}
size={10}
weight="bold"
/>
<span>Tags</span>
{tags.length > 0 && (
<span className="ml-auto text-sidebar-ink-faint">{tags.length}</span>
)}
</button>
<GroupHeader
label="Tags"
isCollapsed={isCollapsed}
onToggle={onToggle}
rightComponent={
tags.length > 0 && (
<span className="ml-auto text-sidebar-ink-faint">{tags.length}</span>
)
}
/>
{/* Items */}
{!isCollapsed && (

View File

@@ -1,8 +1,7 @@
import { CaretRight } from "@phosphor-icons/react";
import clsx from "clsx";
import { useNavigate } from "react-router-dom";
import { useNormalizedQuery, getVolumeIcon } from "@sd/ts-client";
import { SpaceItem } from "./SpaceItem";
import { GroupHeader } from "./GroupHeader";
import type { VolumeItem } from "@sd/ts-client";
interface VolumesGroupProps {
@@ -41,18 +40,7 @@ export function VolumesGroup({
return (
<div>
{/* Group Header */}
<button
onClick={onToggle}
className="mb-1 flex w-full cursor-default items-center gap-2 px-1 text-xs font-semibold uppercase tracking-wider text-sidebar-ink-faint hover:text-sidebar-ink"
>
<CaretRight
className={clsx("transition-transform", !isCollapsed && "rotate-90")}
size={10}
weight="bold"
/>
<span>Volumes</span>
</button>
<GroupHeader label="Volumes" isCollapsed={isCollapsed} onToggle={onToggle} />
{/* Volumes List */}
{!isCollapsed && (

View File

@@ -1,4 +1,8 @@
import { useNormalizedQuery } from '@sd/ts-client';
import { useSpacedriveClient } from '../../../context';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import type { Event } from '@sd/ts-client';
export function useSpaces() {
return useNormalizedQuery({
@@ -9,11 +13,75 @@ export function useSpaces() {
}
export function useSpaceLayout(spaceId: string | null) {
return useNormalizedQuery({
const client = useSpacedriveClient();
const queryClient = useQueryClient();
const libraryId = client.getCurrentLibraryId();
const query = useNormalizedQuery({
wireMethod: 'query:spaces.get_layout',
input: spaceId ? { space_id: spaceId } : null,
resourceType: 'space_layout',
resourceId: spaceId || undefined,
enabled: !!spaceId,
});
// Subscribe to space_item deletions to update the layout
// (space_item sends its own ResourceDeleted events, separate from space_layout)
useEffect(() => {
if (!spaceId || !libraryId) return;
const handleEvent = (event: Event) => {
if (typeof event === 'string') return;
if ('ResourceDeleted' in event) {
const { resource_type, resource_id } = (event as any).ResourceDeleted;
if (resource_type === 'space_item') {
console.log('[useSpaceLayout] Space item deleted, updating layout:', resource_id);
// Remove the item from the layout cache
const queryKey = ['query:spaces.get_layout', libraryId, { space_id: spaceId }];
queryClient.setQueryData(queryKey, (oldData: any) => {
if (!oldData) return oldData;
// Remove from space_items array
const updatedSpaceItems = oldData.space_items?.filter(
(item: any) => item.id !== resource_id
) || [];
// Remove from groups
const updatedGroups = oldData.groups?.map((group: any) => ({
...group,
items: group.items.filter((item: any) => item.id !== resource_id),
})) || [];
return {
...oldData,
space_items: updatedSpaceItems,
groups: updatedGroups,
};
});
}
}
};
let unsubscribe: (() => void) | undefined;
client.subscribeFiltered(
{
resource_type: 'space_item',
library_id: libraryId,
include_descendants: false,
},
handleEvent
).then((unsub) => {
unsubscribe = unsub;
});
return () => {
unsubscribe?.();
};
}, [client, queryClient, spaceId, libraryId]);
return query;
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { GearSix } from "@phosphor-icons/react";
import { useSidebarStore, useLibraryMutation } from "@sd/ts-client";
import type { SpaceGroup as SpaceGroupType, SpaceItem as SpaceItemType } from "@sd/ts-client";
import { useSpaces, useSpaceLayout } from "./hooks/useSpaces";
import { SpaceSwitcher } from "./SpaceSwitcher";
import { SpaceGroup } from "./SpaceGroup";
@@ -13,6 +14,66 @@ import { JobManagerPopover } from "../JobManager/JobManagerPopover";
import { SyncMonitorPopover } from "../SyncMonitor";
import clsx from "clsx";
import { useDroppable } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
// Wrapper that adds a space-level drop zone before each group and makes it sortable
function SpaceGroupWithDropZone({
group,
items,
spaceId,
isFirst,
}: {
group: SpaceGroupType;
items: SpaceItemType[];
spaceId?: string;
isFirst: boolean;
}) {
const { setNodeRef: setDropRef, isOver } = useDroppable({
id: `space-root-before-${group.id}`,
disabled: !spaceId,
data: {
action: 'add-to-space',
spaceId,
groupId: null,
},
});
// Sortable for group reordering
const {
attributes,
listeners,
setNodeRef: setSortableRef,
transform,
transition,
isDragging,
} = useSortable({
id: group.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setSortableRef} style={style} className={clsx("relative", isDragging && "opacity-50 z-50")}>
{/* Drop zone before this group (for adding root-level items) */}
<div ref={setDropRef} className="absolute -top-2.5 left-0 right-0 h-5 z-10">
{isOver && !isDragging && (
<div className="absolute top-1/2 -translate-y-1/2 left-2 right-2 h-[2px] bg-accent rounded-full" />
)}
</div>
<SpaceGroup
group={group}
items={items}
spaceId={spaceId}
sortableAttributes={attributes}
sortableListeners={listeners}
/>
</div>
);
}
interface SpacesSidebarProps {
isPreviewActive?: boolean;
@@ -93,24 +154,43 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
<div className="no-scrollbar mt-3 mask-fade-out flex grow flex-col space-y-5 overflow-x-hidden overflow-y-scroll pb-10">
{/* Space-level items (pinned shortcuts) */}
{layout?.space_items && layout.space_items.length > 0 && (
<div className="space-y-0.5">
{layout.space_items.map((item, index) => (
<SpaceItem
key={item.id}
item={item}
isLastItem={index === layout.space_items.length - 1}
allowInsertion={true}
spaceId={currentSpace?.id}
groupId={null}
/>
))}
</div>
<SortableContext
items={layout.space_items.map(item => item.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-0.5">
{layout.space_items.map((item, index) => (
<SpaceItem
key={item.id}
item={item}
isLastItem={index === layout.space_items.length - 1}
allowInsertion={true}
spaceId={currentSpace?.id}
groupId={null}
sortable={true}
/>
))}
</div>
</SortableContext>
)}
{/* Groups */}
{layout?.groups.map(({ group, items }) => (
<SpaceGroup key={group.id} group={group} items={items} spaceId={currentSpace?.id} />
))}
{/* Groups with space-level drop zones between them */}
{layout?.groups && (
<SortableContext
items={layout.groups.map(({ group }) => group.id)}
strategy={verticalListSortingStrategy}
>
{layout.groups.map(({ group, items }, index) => (
<SpaceGroupWithDropZone
key={group.id}
group={group}
items={items}
spaceId={currentSpace?.id}
isFirst={index === 0}
/>
))}
</SortableContext>
)}
{/* Add Group Button */}
{currentSpace && <AddGroupButton spaceId={currentSpace.id} />}

View File

@@ -450,6 +450,89 @@ export type EntryKind =
*/
"Symlink";
/**
* Status of the unified ephemeral index cache
*/
export type EphemeralCacheStatus = {
/**
* Number of paths that have been indexed
*/
indexed_paths_count: number;
/**
* Number of paths currently being indexed
*/
indexing_in_progress_count: number;
/**
* Unified index statistics (shared arena and string interning)
*/
index_stats: UnifiedIndexStats;
/**
* List of indexed paths (directories whose contents are ready)
*/
indexed_paths: IndexedPathInfo[];
/**
* List of paths currently being indexed
*/
paths_in_progress: string[]; total_indexes?: number | null; indexing_in_progress?: number | null; indexes?: EphemeralIndexInfo[] };
/**
* Input for the ephemeral cache status query
*/
export type EphemeralCacheStatusInput = {
/**
* Optional: only include indexed paths containing this substring
*/
path_filter?: string | null };
/**
* Legacy: Information about a single ephemeral index (for backward compatibility)
*/
export type EphemeralIndexInfo = {
/**
* Root path this index covers
*/
root_path: string;
/**
* Whether indexing is currently in progress
*/
indexing_in_progress: boolean;
/**
* Total entries in the arena
*/
total_entries: number;
/**
* Number of entries indexed by path
*/
path_index_count: number;
/**
* Number of unique interned names
*/
unique_names: number;
/**
* Number of interned strings in cache
*/
interned_strings: number;
/**
* Number of content kinds stored
*/
content_kinds: number;
/**
* Estimated memory usage in bytes
*/
memory_bytes: number;
/**
* Age of the index in seconds
*/
age_seconds: number;
/**
* Seconds since last access
*/
idle_seconds: number;
/**
* Indexer job statistics (files/dirs/bytes counted)
*/
job_stats: JobStats };
/**
* Error event for tracking recent errors
*/
@@ -662,7 +745,7 @@ export type FileByPathQuery = { path: string };
/**
* Internal enum for file conflict resolution strategies
*/
export type FileConflictResolution = "Overwrite" | "AutoModifyName" | "Abort";
export type FileConflictResolution = "Overwrite" | "AutoModifyName" | "Skip" | "Abort";
/**
* Core input structure for file copy operations
@@ -1079,7 +1162,12 @@ include_hidden: boolean;
persistence: IndexPersistence };
/**
* Indexing mode determines the depth of indexing
* How deeply to index files, from metadata-only to full processing.
*
* IndexMode controls the trade-off between indexing speed and feature completeness.
* Shallow mode is fast enough for ephemeral browsing, while Deep mode enables
* duplicate detection, thumbnail generation, and full-text search at the cost of
* significantly longer indexing time.
*/
export type IndexMode =
/**
@@ -1087,20 +1175,27 @@ export type IndexMode =
*/
"None" |
/**
* Just filesystem metadata (fastest)
* Just filesystem metadata
*/
"Shallow" |
/**
* Generate content identities (moderate)
* Generate content identities via sampled BLAKE3 hashing (enables duplicate detection)
*/
"Content" |
/**
* Full indexing with thumbnails and text extraction (slowest)
* Full indexing with thumbnails and text extraction
*/
"Deep";
/**
* Determines whether indexing results are persisted to database or kept in memory
* Whether to write indexing results to the database or keep them in memory.
*
* Ephemeral persistence allows users to browse external drives and network shares
* without adding them as managed locations. The in-memory index survives for the
* session duration and provides the same API surface as persistent entries, enabling
* features like search and navigation to work identically for both modes. If an
* ephemeral path is later promoted to a managed location, UUIDs are preserved to
* maintain continuity for user metadata.
*/
export type IndexPersistence =
/**
@@ -1113,7 +1208,12 @@ export type IndexPersistence =
"Ephemeral";
/**
* Indexing scope determines how much of the directory tree to process
* Whether to index just one directory level or recurse through subdirectories.
*
* Current scope is used for UI navigation where users expand folders on-demand,
* while Recursive scope is used for full location indexing. Current scope with
* persistent storage enables progressive indexing where the UI drives which
* directories get indexed based on user interaction.
*/
export type IndexScope =
/**
@@ -1165,7 +1265,20 @@ path: string;
duration_secs: number };
/**
* Comprehensive metrics for indexing operations
* Information about an indexed path
*/
export type IndexedPathInfo = {
/**
* The directory path that was indexed
*/
path: string;
/**
* Number of direct children in this directory
*/
child_count: number };
/**
* Complete snapshot of indexer performance after job completion.
*/
export type IndexerMetrics = { total_duration: { secs: number; nanos: number }; discovery_duration: { secs: number; nanos: number }; processing_duration: { secs: number; nanos: number }; content_duration: { secs: number; nanos: number }; files_per_second: number; bytes_per_second: number; dirs_per_second: number; db_writes: number; db_reads: number; batch_count: number; avg_batch_size: number; total_errors: number; critical_errors: number; non_critical_errors: number; skipped_paths: number; peak_memory_bytes: number | null; avg_memory_bytes: number | null };
@@ -1175,7 +1288,7 @@ export type IndexerMetrics = { total_duration: { secs: number; nanos: number };
export type IndexerSettings = { no_system_files?: boolean; no_git?: boolean; no_dev_dirs?: boolean; no_hidden?: boolean; gitignore?: boolean; only_images?: boolean };
/**
* Statistics collected during indexing
* Cumulative statistics tracked throughout the indexing process.
*/
export type IndexerStats = { files: number; dirs: number; bytes: number; symlinks: number; skipped: number; errors: number };
@@ -1398,6 +1511,27 @@ export type JobResumeInput = { job_id: string };
export type JobResumeOutput = { job_id: string; success: boolean };
/**
* Statistics from the indexer job
*/
export type JobStats = {
/**
* Number of files indexed
*/
files: number;
/**
* Number of directories indexed
*/
dirs: number;
/**
* Number of symlinks indexed
*/
symlinks: number;
/**
* Total bytes indexed
*/
bytes: number };
/**
* Current status of a job
*/
@@ -2433,7 +2567,7 @@ statistics: LibraryStatistics };
export type ReorderGroupsInput = { space_id: string; group_ids: string[] };
export type ReorderItemsInput = { group_id: string; item_ids: string[] };
export type ReorderItemsInput = { group_id: string | null; item_ids: string[] };
export type ReorderOutput = { success: boolean };
@@ -3150,6 +3284,43 @@ export type TranscribeAudioOutput = {
*/
job_id: string };
/**
* Statistics for the unified ephemeral index
*/
export type UnifiedIndexStats = {
/**
* Total entries in the shared arena
*/
total_entries: number;
/**
* Number of entries indexed by path
*/
path_index_count: number;
/**
* Number of unique interned names (shared across all paths)
*/
unique_names: number;
/**
* Number of interned strings in shared cache
*/
interned_strings: number;
/**
* Number of content kinds stored
*/
content_kinds: number;
/**
* Estimated memory usage in bytes
*/
memory_bytes: number;
/**
* Age of the cache in seconds
*/
age_seconds: number;
/**
* Seconds since last access
*/
idle_seconds: number };
/**
* Input for finding files unique to a location
*/
@@ -3520,197 +3691,199 @@ success: boolean };
// ===== API Type Unions =====
export type CoreAction =
{ type: 'network.stop'; input: NetworkStopInput; output: NetworkStopOutput }
| { type: 'network.device.revoke'; input: DeviceRevokeInput; output: DeviceRevokeOutput }
| { type: 'network.pair.join'; input: PairJoinInput; output: PairJoinOutput }
{ type: 'network.pair.generate'; input: PairGenerateInput; output: PairGenerateOutput }
| { type: 'network.sync_setup'; input: LibrarySyncSetupInput; output: LibrarySyncSetupOutput }
| { type: 'network.pair.generate'; input: PairGenerateInput; output: PairGenerateOutput }
| { type: 'network.device.revoke'; input: DeviceRevokeInput; output: DeviceRevokeOutput }
| { type: 'models.whisper.delete'; input: DeleteWhisperModelInput; output: DeleteWhisperModelOutput }
| { type: 'models.whisper.download'; input: DownloadWhisperModelInput; output: DownloadWhisperModelOutput }
| { type: 'network.spacedrop.send'; input: SpacedropSendInput; output: SpacedropSendOutput }
| { type: 'libraries.create'; input: LibraryCreateInput; output: LibraryCreateOutput }
| { type: 'network.stop'; input: NetworkStopInput; output: NetworkStopOutput }
| { type: 'libraries.open'; input: LibraryOpenInput; output: LibraryOpenOutput }
| { type: 'network.spacedrop.send'; input: SpacedropSendInput; output: SpacedropSendOutput }
| { type: 'network.pair.cancel'; input: PairCancelInput; output: PairCancelOutput }
| { type: 'libraries.delete'; input: LibraryDeleteInput; output: LibraryDeleteOutput }
| { type: 'network.start'; input: NetworkStartInput; output: NetworkStartOutput }
| { type: 'libraries.create'; input: LibraryCreateInput; output: LibraryCreateOutput }
| { type: 'network.pair.join'; input: PairJoinInput; output: PairJoinOutput }
;
export type LibraryAction =
{ type: 'spaces.create'; input: SpaceCreateInput; output: SpaceCreateOutput }
{ type: 'locations.export'; input: LocationExportInput; output: LocationExportOutput }
| { type: 'tags.apply'; input: ApplyTagsInput; output: ApplyTagsOutput }
| { type: 'indexing.verify'; input: IndexVerifyInput; output: IndexVerifyOutput }
| { type: 'media.ocr.extract'; input: ExtractTextInput; output: ExtractTextOutput }
| { type: 'volumes.add_cloud'; input: VolumeAddCloudInput; output: VolumeAddCloudOutput }
| { type: 'jobs.pause'; input: JobPauseInput; output: JobPauseOutput }
| { type: 'media.proxy.generate'; input: GenerateProxyInput; output: GenerateProxyOutput }
| { type: 'libraries.rename'; input: LibraryRenameInput; output: LibraryRenameOutput }
| { type: 'spaces.update'; input: SpaceUpdateInput; output: SpaceUpdateOutput }
| { type: 'tags.create'; input: CreateTagInput; output: CreateTagOutput }
| { type: 'spaces.delete_item'; input: DeleteItemInput; output: DeleteItemOutput }
| { type: 'volumes.remove_cloud'; input: VolumeRemoveCloudInput; output: VolumeRemoveCloudOutput }
| { type: 'locations.triggerJob'; input: LocationTriggerJobInput; output: LocationTriggerJobOutput }
| { type: 'locations.enable_indexing'; input: EnableIndexingInput; output: EnableIndexingOutput }
| { type: 'spaces.reorder_items'; input: ReorderItemsInput; output: ReorderOutput }
| { type: 'spaces.reorder_groups'; input: ReorderGroupsInput; output: ReorderOutput }
| { type: 'spaces.add_item'; input: AddItemInput; output: AddItemOutput }
| { type: 'volumes.track'; input: VolumeTrackInput; output: VolumeTrackOutput }
| { type: 'libraries.export'; input: LibraryExportInput; output: LibraryExportOutput }
| { type: 'locations.remove'; input: LocationRemoveInput; output: LocationRemoveOutput }
| { type: 'jobs.resume'; input: JobResumeInput; output: JobResumeOutput }
| { type: 'jobs.cancel'; input: JobCancelInput; output: JobCancelOutput }
| { type: 'media.speech.transcribe'; input: TranscribeAudioInput; output: TranscribeAudioOutput }
| { type: 'spaces.update_group'; input: UpdateGroupInput; output: UpdateGroupOutput }
| { type: 'spaces.add_group'; input: AddGroupInput; output: AddGroupOutput }
| { type: 'volumes.add_cloud'; input: VolumeAddCloudInput; output: VolumeAddCloudOutput }
| { type: 'files.copy'; input: FileCopyInput; output: JobReceipt }
| { type: 'volumes.refresh'; input: VolumeRefreshInput; output: VolumeRefreshOutput }
| { type: 'locations.import'; input: LocationImportInput; output: LocationImportOutput }
| { type: 'files.delete'; input: FileDeleteInput; output: JobReceipt }
| { type: 'spaces.create'; input: SpaceCreateInput; output: SpaceCreateOutput }
| { type: 'spaces.delete_group'; input: DeleteGroupInput; output: DeleteGroupOutput }
| { type: 'media.thumbnail.regenerate'; input: RegenerateThumbnailInput; output: RegenerateThumbnailOutput }
| { type: 'media.thumbnail'; input: ThumbnailInput; output: JobReceipt }
| { type: 'indexing.verify'; input: IndexVerifyInput; output: IndexVerifyOutput }
| { type: 'libraries.rename'; input: LibraryRenameInput; output: LibraryRenameOutput }
| { type: 'volumes.track'; input: VolumeTrackInput; output: VolumeTrackOutput }
| { type: 'volumes.remove_cloud'; input: VolumeRemoveCloudInput; output: VolumeRemoveCloudOutput }
| { type: 'files.copy'; input: FileCopyInput; output: JobReceipt }
| { type: 'jobs.pause'; input: JobPauseInput; output: JobPauseOutput }
| { type: 'spaces.delete_item'; input: DeleteItemInput; output: DeleteItemOutput }
| { type: 'volumes.speed_test'; input: VolumeSpeedTestInput; output: VolumeSpeedTestOutput }
| { type: 'jobs.resume'; input: JobResumeInput; output: JobResumeOutput }
| { type: 'locations.update'; input: LocationUpdateInput; output: LocationUpdateOutput }
| { type: 'spaces.delete'; input: SpaceDeleteInput; output: SpaceDeleteOutput }
| { type: 'locations.triggerJob'; input: LocationTriggerJobInput; output: LocationTriggerJobOutput }
| { type: 'locations.export'; input: LocationExportInput; output: LocationExportOutput }
| { type: 'tags.create'; input: CreateTagInput; output: CreateTagOutput }
| { type: 'locations.enable_indexing'; input: EnableIndexingInput; output: EnableIndexingOutput }
| { type: 'media.proxy.generate'; input: GenerateProxyInput; output: GenerateProxyOutput }
| { type: 'volumes.refresh'; input: VolumeRefreshInput; output: VolumeRefreshOutput }
| { type: 'spaces.update_group'; input: UpdateGroupInput; output: UpdateGroupOutput }
| { type: 'libraries.export'; input: LibraryExportInput; output: LibraryExportOutput }
| { type: 'volumes.untrack'; input: VolumeUntrackInput; output: VolumeUntrackOutput }
| { type: 'volumes.speed_test'; input: VolumeSpeedTestInput; output: VolumeSpeedTestOutput }
| { type: 'locations.rescan'; input: LocationRescanInput; output: LocationRescanOutput }
| { type: 'locations.add'; input: LocationAddInput; output: LocationAddOutput }
| { type: 'indexing.start'; input: IndexInput; output: JobReceipt }
| { type: 'locations.remove'; input: LocationRemoveInput; output: LocationRemoveOutput }
| { type: 'files.delete'; input: FileDeleteInput; output: JobReceipt }
| { type: 'tags.apply'; input: ApplyTagsInput; output: ApplyTagsOutput }
| { type: 'spaces.add_item'; input: AddItemInput; output: AddItemOutput }
| { type: 'media.ocr.extract'; input: ExtractTextInput; output: ExtractTextOutput }
| { type: 'media.thumbstrip.generate'; input: GenerateThumbstripInput; output: GenerateThumbstripOutput }
| { type: 'spaces.update'; input: SpaceUpdateInput; output: SpaceUpdateOutput }
| { type: 'volumes.untrack'; input: VolumeUntrackInput; output: VolumeUntrackOutput }
| { type: 'locations.import'; input: LocationImportInput; output: LocationImportOutput }
| { type: 'jobs.cancel'; input: JobCancelInput; output: JobCancelOutput }
| { type: 'spaces.delete_group'; input: DeleteGroupInput; output: DeleteGroupOutput }
| { type: 'media.speech.transcribe'; input: TranscribeAudioInput; output: TranscribeAudioOutput }
| { type: 'locations.rescan'; input: LocationRescanInput; output: LocationRescanOutput }
| { type: 'locations.update'; input: LocationUpdateInput; output: LocationUpdateOutput }
;
export type CoreQuery =
{ type: 'network.pair.status'; input: PairStatusQueryInput; output: PairStatusOutput }
| { type: 'network.sync_setup.discover'; input: DiscoverRemoteLibrariesInput; output: DiscoverRemoteLibrariesOutput }
{ type: 'core.ephemeral_status'; input: EphemeralCacheStatusInput; output: EphemeralCacheStatus }
| { type: 'core.events.list'; input: ListEventsInput; output: ListEventsOutput }
| { type: 'network.status'; input: NetworkStatusQueryInput; output: NetworkStatus }
| { type: 'libraries.list'; input: ListLibrariesInput; output: [LibraryInfo] }
| { type: 'network.devices.list'; input: ListPairedDevicesInput; output: ListPairedDevicesOutput }
| { type: 'models.whisper.list'; input: ListWhisperModelsInput; output: ListWhisperModelsOutput }
| { type: 'network.sync_setup.discover'; input: DiscoverRemoteLibrariesInput; output: DiscoverRemoteLibrariesOutput }
| { type: 'core.status'; input: Empty; output: CoreStatus }
| { type: 'libraries.list'; input: ListLibrariesInput; output: [LibraryInfo] }
| { type: 'network.pair.status'; input: PairStatusQueryInput; output: PairStatusOutput }
;
export type LibraryQuery =
{ type: 'files.by_id'; input: FileByIdQuery; output: File }
| { type: 'spaces.get'; input: SpaceGetQueryInput; output: SpaceGetOutput }
| { type: 'libraries.info'; input: LibraryInfoQueryInput; output: LibraryInfoOutput }
| { type: 'jobs.active'; input: ActiveJobsInput; output: ActiveJobsOutput }
| { type: 'locations.list'; input: LocationsListQueryInput; output: LocationsListOutput }
| { type: 'sync.activity'; input: GetSyncActivityInput; output: GetSyncActivityOutput }
| { type: 'tags.search'; input: SearchTagsInput; output: SearchTagsOutput }
| { type: 'jobs.info'; input: JobInfoQueryInput; output: JobInfoOutput }
| { type: 'locations.suggested'; input: SuggestedLocationsQueryInput; output: SuggestedLocationsOutput }
| { type: 'test.ping'; input: PingInput; output: PingOutput }
| { type: 'jobs.list'; input: JobListInput; output: JobListOutput }
| { type: 'spaces.list'; input: SpacesListQueryInput; output: SpacesListOutput }
| { type: 'search.files'; input: FileSearchInput; output: FileSearchOutput }
| { type: 'devices.list'; input: ListLibraryDevicesInput; output: [LibraryDeviceInfo] }
| { type: 'files.media_listing'; input: MediaListingInput; output: MediaListingOutput }
| { type: 'files.directory_listing'; input: DirectoryListingInput; output: DirectoryListingOutput }
| { type: 'sync.metrics'; input: GetSyncMetricsInput; output: GetSyncMetricsOutput }
| { type: 'spaces.get_layout'; input: SpaceLayoutQueryInput; output: SpaceLayout }
| { type: 'volumes.list'; input: VolumeListQueryInput; output: VolumeListOutput }
| { type: 'files.by_path'; input: FileByPathQuery; output: File }
{ type: 'test.ping'; input: PingInput; output: PingOutput }
| { type: 'files.unique_to_location'; input: UniqueToLocationInput; output: UniqueToLocationOutput }
| { type: 'jobs.active'; input: ActiveJobsInput; output: ActiveJobsOutput }
| { type: 'jobs.list'; input: JobListInput; output: JobListOutput }
| { type: 'spaces.get_layout'; input: SpaceLayoutQueryInput; output: SpaceLayout }
| { type: 'spaces.list'; input: SpacesListQueryInput; output: SpacesListOutput }
| { type: 'sync.activity'; input: GetSyncActivityInput; output: GetSyncActivityOutput }
| { type: 'jobs.info'; input: JobInfoQueryInput; output: JobInfoOutput }
| { type: 'files.directory_listing'; input: DirectoryListingInput; output: DirectoryListingOutput }
| { type: 'files.by_path'; input: FileByPathQuery; output: File }
| { type: 'files.media_listing'; input: MediaListingInput; output: MediaListingOutput }
| { type: 'locations.suggested'; input: SuggestedLocationsQueryInput; output: SuggestedLocationsOutput }
| { type: 'spaces.get'; input: SpaceGetQueryInput; output: SpaceGetOutput }
| { type: 'sync.eventLog'; input: GetSyncEventLogInput; output: GetSyncEventLogOutput }
| { type: 'files.by_id'; input: FileByIdQuery; output: File }
| { type: 'sync.metrics'; input: GetSyncMetricsInput; output: GetSyncMetricsOutput }
| { type: 'volumes.list'; input: VolumeListQueryInput; output: VolumeListOutput }
| { type: 'search.files'; input: FileSearchInput; output: FileSearchOutput }
| { type: 'libraries.info'; input: LibraryInfoQueryInput; output: LibraryInfoOutput }
| { type: 'locations.list'; input: LocationsListQueryInput; output: LocationsListOutput }
| { type: 'tags.search'; input: SearchTagsInput; output: SearchTagsOutput }
| { type: 'devices.list'; input: ListLibraryDevicesInput; output: [LibraryDeviceInfo] }
;
// ===== Wire Method Mappings =====
export const WIRE_METHODS = {
coreActions: {
'network.stop': 'action:network.stop.input',
'network.device.revoke': 'action:network.device.revoke.input',
'network.pair.join': 'action:network.pair.join.input',
'network.sync_setup': 'action:network.sync_setup.input',
'network.pair.generate': 'action:network.pair.generate.input',
'network.sync_setup': 'action:network.sync_setup.input',
'network.device.revoke': 'action:network.device.revoke.input',
'models.whisper.delete': 'action:models.whisper.delete.input',
'models.whisper.download': 'action:models.whisper.download.input',
'network.spacedrop.send': 'action:network.spacedrop.send.input',
'libraries.create': 'action:libraries.create.input',
'network.stop': 'action:network.stop.input',
'libraries.open': 'action:libraries.open.input',
'network.spacedrop.send': 'action:network.spacedrop.send.input',
'network.pair.cancel': 'action:network.pair.cancel.input',
'libraries.delete': 'action:libraries.delete.input',
'network.start': 'action:network.start.input',
'libraries.create': 'action:libraries.create.input',
'network.pair.join': 'action:network.pair.join.input',
},
libraryActions: {
'spaces.create': 'action:spaces.create.input',
'locations.export': 'action:locations.export.input',
'tags.apply': 'action:tags.apply.input',
'indexing.verify': 'action:indexing.verify.input',
'media.ocr.extract': 'action:media.ocr.extract.input',
'volumes.add_cloud': 'action:volumes.add_cloud.input',
'jobs.pause': 'action:jobs.pause.input',
'media.proxy.generate': 'action:media.proxy.generate.input',
'libraries.rename': 'action:libraries.rename.input',
'spaces.update': 'action:spaces.update.input',
'tags.create': 'action:tags.create.input',
'spaces.delete_item': 'action:spaces.delete_item.input',
'volumes.remove_cloud': 'action:volumes.remove_cloud.input',
'locations.triggerJob': 'action:locations.triggerJob.input',
'locations.enable_indexing': 'action:locations.enable_indexing.input',
'spaces.reorder_items': 'action:spaces.reorder_items.input',
'spaces.reorder_groups': 'action:spaces.reorder_groups.input',
'spaces.add_item': 'action:spaces.add_item.input',
'volumes.track': 'action:volumes.track.input',
'libraries.export': 'action:libraries.export.input',
'locations.remove': 'action:locations.remove.input',
'jobs.resume': 'action:jobs.resume.input',
'jobs.cancel': 'action:jobs.cancel.input',
'media.speech.transcribe': 'action:media.speech.transcribe.input',
'spaces.update_group': 'action:spaces.update_group.input',
'spaces.add_group': 'action:spaces.add_group.input',
'volumes.add_cloud': 'action:volumes.add_cloud.input',
'files.copy': 'action:files.copy.input',
'volumes.refresh': 'action:volumes.refresh.input',
'locations.import': 'action:locations.import.input',
'files.delete': 'action:files.delete.input',
'spaces.create': 'action:spaces.create.input',
'spaces.delete_group': 'action:spaces.delete_group.input',
'media.thumbnail.regenerate': 'action:media.thumbnail.regenerate.input',
'media.thumbnail': 'action:media.thumbnail.input',
'indexing.verify': 'action:indexing.verify.input',
'libraries.rename': 'action:libraries.rename.input',
'volumes.track': 'action:volumes.track.input',
'volumes.remove_cloud': 'action:volumes.remove_cloud.input',
'files.copy': 'action:files.copy.input',
'jobs.pause': 'action:jobs.pause.input',
'spaces.delete_item': 'action:spaces.delete_item.input',
'volumes.speed_test': 'action:volumes.speed_test.input',
'jobs.resume': 'action:jobs.resume.input',
'locations.update': 'action:locations.update.input',
'spaces.delete': 'action:spaces.delete.input',
'locations.triggerJob': 'action:locations.triggerJob.input',
'locations.export': 'action:locations.export.input',
'tags.create': 'action:tags.create.input',
'locations.enable_indexing': 'action:locations.enable_indexing.input',
'media.proxy.generate': 'action:media.proxy.generate.input',
'volumes.refresh': 'action:volumes.refresh.input',
'spaces.update_group': 'action:spaces.update_group.input',
'libraries.export': 'action:libraries.export.input',
'volumes.untrack': 'action:volumes.untrack.input',
'volumes.speed_test': 'action:volumes.speed_test.input',
'locations.rescan': 'action:locations.rescan.input',
'locations.add': 'action:locations.add.input',
'indexing.start': 'action:indexing.start.input',
'locations.remove': 'action:locations.remove.input',
'files.delete': 'action:files.delete.input',
'tags.apply': 'action:tags.apply.input',
'spaces.add_item': 'action:spaces.add_item.input',
'media.ocr.extract': 'action:media.ocr.extract.input',
'media.thumbstrip.generate': 'action:media.thumbstrip.generate.input',
'spaces.update': 'action:spaces.update.input',
'volumes.untrack': 'action:volumes.untrack.input',
'locations.import': 'action:locations.import.input',
'jobs.cancel': 'action:jobs.cancel.input',
'spaces.delete_group': 'action:spaces.delete_group.input',
'media.speech.transcribe': 'action:media.speech.transcribe.input',
'locations.rescan': 'action:locations.rescan.input',
'locations.update': 'action:locations.update.input',
},
coreQueries: {
'network.pair.status': 'query:network.pair.status',
'network.sync_setup.discover': 'query:network.sync_setup.discover',
'core.ephemeral_status': 'query:core.ephemeral_status',
'core.events.list': 'query:core.events.list',
'network.status': 'query:network.status',
'libraries.list': 'query:libraries.list',
'network.devices.list': 'query:network.devices.list',
'models.whisper.list': 'query:models.whisper.list',
'network.sync_setup.discover': 'query:network.sync_setup.discover',
'core.status': 'query:core.status',
'libraries.list': 'query:libraries.list',
'network.pair.status': 'query:network.pair.status',
},
libraryQueries: {
'files.by_id': 'query:files.by_id',
'spaces.get': 'query:spaces.get',
'libraries.info': 'query:libraries.info',
'jobs.active': 'query:jobs.active',
'locations.list': 'query:locations.list',
'sync.activity': 'query:sync.activity',
'tags.search': 'query:tags.search',
'jobs.info': 'query:jobs.info',
'locations.suggested': 'query:locations.suggested',
'test.ping': 'query:test.ping',
'jobs.list': 'query:jobs.list',
'spaces.list': 'query:spaces.list',
'search.files': 'query:search.files',
'devices.list': 'query:devices.list',
'files.media_listing': 'query:files.media_listing',
'files.directory_listing': 'query:files.directory_listing',
'sync.metrics': 'query:sync.metrics',
'spaces.get_layout': 'query:spaces.get_layout',
'volumes.list': 'query:volumes.list',
'files.by_path': 'query:files.by_path',
'files.unique_to_location': 'query:files.unique_to_location',
'jobs.active': 'query:jobs.active',
'jobs.list': 'query:jobs.list',
'spaces.get_layout': 'query:spaces.get_layout',
'spaces.list': 'query:spaces.list',
'sync.activity': 'query:sync.activity',
'jobs.info': 'query:jobs.info',
'files.directory_listing': 'query:files.directory_listing',
'files.by_path': 'query:files.by_path',
'files.media_listing': 'query:files.media_listing',
'locations.suggested': 'query:locations.suggested',
'spaces.get': 'query:spaces.get',
'sync.eventLog': 'query:sync.eventLog',
'files.by_id': 'query:files.by_id',
'sync.metrics': 'query:sync.metrics',
'volumes.list': 'query:volumes.list',
'search.files': 'query:search.files',
'libraries.info': 'query:libraries.info',
'locations.list': 'query:locations.list',
'tags.search': 'query:tags.search',
'devices.list': 'query:devices.list',
},
} as const;

View File

@@ -142,7 +142,7 @@ export function useNormalizedQuery<I, O>(
// This ensures subscription re-runs when path changes, even if object reference stays same
const pathScopeSerialized = useMemo(
() => JSON.stringify(options.pathScope),
[options.pathScope]
[options.pathScope],
);
// Event subscription
@@ -153,16 +153,14 @@ export function useNormalizedQuery<I, O>(
// Skip subscription for file queries without pathScope (prevent overly broad subscriptions)
// Unless resourceId is provided (single-file queries like FileInspector don't need pathScope)
if (options.resourceType === "file" && !options.pathScope && !options.resourceId) {
if (
options.resourceType === "file" &&
!options.pathScope &&
!options.resourceId
) {
return;
}
console.log('[useNormalizedQuery] Creating subscription', {
resourceType: options.resourceType,
pathScope: options.pathScope,
includeDescendants: options.includeDescendants ?? false,
});
let unsubscribe: (() => void) | undefined;
let isCancelled = false;
@@ -172,20 +170,25 @@ export function useNormalizedQuery<I, O>(
const handleEvent = (event: Event) => {
// Debug: log every batch event to understand what's happening
if (typeof event !== 'string' && 'ResourceChangedBatch' in event) {
const batch = (event as any).ResourceChangedBatch;
console.log('[useNormalizedQuery] Batch event received', {
capturedPath: capturedPathScope,
currentRefPath: optionsRef.current.pathScope,
pathsMatch: JSON.stringify(optionsRef.current.pathScope) === JSON.stringify(capturedPathScope),
resourceCount: batch.resources?.length || 0,
resourceType: batch.resource_type,
});
}
// if (typeof event !== "string" && "ResourceChangedBatch" in event) {
// const batch = (event as any).ResourceChangedBatch;
// console.log("[useNormalizedQuery] Batch event received", {
// capturedPath: capturedPathScope,
// currentRefPath: optionsRef.current.pathScope,
// pathsMatch:
// JSON.stringify(optionsRef.current.pathScope) ===
// JSON.stringify(capturedPathScope),
// resourceCount: batch.resources?.length || 0,
// resourceType: batch.resource_type,
// });
// }
// Guard: only process events if pathScope hasn't changed since subscription
if (JSON.stringify(optionsRef.current.pathScope) !== JSON.stringify(capturedPathScope)) {
console.log('[useNormalizedQuery] Dropping stale event', {
if (
JSON.stringify(optionsRef.current.pathScope) !==
JSON.stringify(capturedPathScope)
) {
console.log("[useNormalizedQuery] Dropping stale event", {
eventPathScope: capturedPathScope,
currentPathScope: optionsRef.current.pathScope,
});
@@ -212,20 +215,22 @@ export function useNormalizedQuery<I, O>(
)
.then((unsub) => {
if (isCancelled) {
console.log('[useNormalizedQuery] Subscription cancelled before creation completed');
// console.log(
// "[useNormalizedQuery] Subscription cancelled before creation completed",
// );
unsub();
} else {
console.log('[useNormalizedQuery] Subscription active', {
pathScope: options.pathScope,
});
// console.log("[useNormalizedQuery] Subscription active", {
// pathScope: options.pathScope,
// });
unsubscribe = unsub;
}
});
return () => {
console.log('[useNormalizedQuery] Cleaning up subscription', {
pathScope: options.pathScope,
});
// console.log("[useNormalizedQuery] Cleaning up subscription", {
// pathScope: options.pathScope,
// });
isCancelled = true;
unsubscribe?.();
};
@@ -272,10 +277,10 @@ export function handleResourceEvent(
if ("ResourceChanged" in event) {
const result = v.safeParse(ResourceChangedSchema, event);
if (!result.success) {
console.warn(
"[useNormalizedQuery] Invalid ResourceChanged event:",
result.issues,
);
// console.warn(
// "[useNormalizedQuery] Invalid ResourceChanged event:",
// result.issues,
// );
return;
}
@@ -289,10 +294,10 @@ export function handleResourceEvent(
else if ("ResourceChangedBatch" in event) {
const result = v.safeParse(ResourceChangedBatchSchema, event);
if (!result.success) {
console.warn(
"[useNormalizedQuery] Invalid ResourceChangedBatch event:",
result.issues,
);
// console.warn(
// "[useNormalizedQuery] Invalid ResourceChangedBatch event:",
// result.issues,
// );
return;
}
@@ -308,10 +313,10 @@ export function handleResourceEvent(
else if ("ResourceDeleted" in event) {
const result = v.safeParse(ResourceDeletedSchema, event);
if (!result.success) {
console.warn(
"[useNormalizedQuery] Invalid ResourceDeleted event:",
result.issues,
);
// console.warn(
// "[useNormalizedQuery] Invalid ResourceDeleted event:",
// result.issues,
// );
return;
}
@@ -381,7 +386,8 @@ export function filterBatchResources(
const physicalFromAlternate = alternatePaths.find((p: any) => p.Physical);
const physicalFromSdPath = resource.sd_path?.Physical;
const physicalPath = physicalFromAlternate?.Physical || physicalFromSdPath;
const physicalPath =
physicalFromAlternate?.Physical || physicalFromSdPath;
if (!physicalPath?.path) {
return false; // No physical path found
@@ -401,15 +407,15 @@ export function filterBatchResources(
return parentDir === normalizedScope;
});
const afterCount = filtered.length;
if (beforeCount !== afterCount) {
console.log('[filterBatchResources] Filtered resources', {
pathScope: options.pathScope,
before: beforeCount,
after: afterCount,
filtered: beforeCount - afterCount,
});
}
// const afterCount = filtered.length;
// if (beforeCount !== afterCount) {
// console.log("[filterBatchResources] Filtered resources", {
// pathScope: options.pathScope,
// before: beforeCount,
// after: afterCount,
// filtered: beforeCount - afterCount,
// });
// }
}
return filtered;
@@ -436,10 +442,10 @@ export function updateSingleResource<O>(
if (options) {
resourcesToUpdate = filterBatchResources(resourcesToUpdate, options);
if (resourcesToUpdate.length === 0) {
console.log('[updateSingleResource] Filtered out resource', {
pathScope: options.pathScope,
resourcePath: resource.sd_path,
});
// console.log("[updateSingleResource] Filtered out resource", {
// pathScope: options.pathScope,
// resourcePath: resource.sd_path,
// });
return; // Resource was filtered out
}
}

View File

@@ -1,189 +1,195 @@
const defaultTheme = require('tailwindcss/defaultTheme');
const defaultTheme = require("tailwindcss/defaultTheme");
function alpha(variableName) {
// some tailwind magic to allow us to specify opacity with CSS variables (eg: bg-app/80)
// https://tailwindcss.com/docs/customizing-colors#using-css-variables
return `hsla(var(${variableName}), <alpha-value>)`;
// some tailwind magic to allow us to specify opacity with CSS variables (eg: bg-app/80)
// https://tailwindcss.com/docs/customizing-colors#using-css-variables
return `hsla(var(${variableName}), <alpha-value>)`;
}
module.exports = function (app, options) {
/**
* @type {import('tailwindcss').Config}
*/
let config = {
content: [
`../../apps/${app}/src/**/*.{ts,tsx,html,stories.tsx}`,
'../../packages/*/src/**/*.{ts,tsx,html,stories.tsx}',
'../../interface/**/*.{ts,tsx,html,stories.tsx}'
],
darkMode: 'class',
theme: {
screens: {
xs: '475px',
sm: '650px',
md: '868px',
lg: '1024px',
xl: '1280px'
},
fontFamily: {
sans: [...defaultTheme.fontFamily.sans],
plex: ['IBM Plex Sans', ...defaultTheme.fontFamily.sans]
},
fontSize: {
'tiny': '.65rem',
'xs': '.75rem',
'sm': '.80rem',
'base': '1rem',
'lg': '1.125rem',
'xl': '1.25rem',
'2xl': '1.5rem',
'3xl': '1.875rem',
'4xl': '2.25rem',
'5xl': '3rem',
'6xl': '4rem',
'7xl': '5rem'
},
extend: {
colors: {
accent: {
DEFAULT: alpha('--color-accent'),
faint: 'hsl(var(--color-accent-faint))',
deep: alpha('--color-accent-deep')
},
ink: {
DEFAULT: alpha('--color-ink'),
dull: alpha('--color-ink-dull'),
faint: alpha('--color-ink-faint')
},
sidebar: {
DEFAULT: alpha('--color-sidebar'),
box: alpha('--color-sidebar-box'),
line: alpha('--color-sidebar-line'),
ink: alpha('--color-sidebar-ink'),
inkFaint: alpha('--color-sidebar-ink-faint'),
inkDull: alpha('--color-sidebar-ink-dull'),
divider: alpha('--color-sidebar-divider'),
button: alpha('--color-sidebar-button'),
selected: alpha('--color-sidebar-selected'),
shade: alpha('--color-sidebar-shade')
},
app: {
DEFAULT: alpha('--color-app'),
box: alpha('--color-app-box'),
darkBox: alpha('--color-app-dark-box'),
darkerBox: alpha('--color-app-darker-box'),
lightBox: alpha('--color-app-light-box'),
overlay: alpha('--color-app-overlay'),
input: alpha('--color-app-input'),
focus: alpha('--color-app-focus'),
line: alpha('--color-app-line'),
divider: alpha('--color-app-divider'),
button: alpha('--color-app-button'),
selected: alpha('--color-app-selected'),
selectedItem: alpha('--color-app-selected-item'),
hover: alpha('--color-app-hover'),
active: alpha('--color-app-active'),
shade: alpha('--color-app-shade'),
frame: alpha('--color-app-frame'),
slider: alpha('--color-app-slider'),
explorerScrollbar: alpha('--color-app-explorer-scrollbar')
},
menu: {
DEFAULT: alpha('--color-menu'),
line: alpha('--color-menu-line'),
hover: alpha('--color-menu-hover'),
selected: alpha('--color-menu-selected'),
shade: alpha('--color-menu-shade'),
ink: alpha('--color-menu-ink'),
faint: alpha('--color-menu-faint')
},
// legacy support
primary: {
DEFAULT: '#2599FF',
50: '#FFFFFF',
100: '#F1F8FF',
200: '#BEE1FF',
300: '#8BC9FF',
400: '#58B1FF',
500: '#2599FF',
600: '#0081F1',
700: '#0065BE',
800: '#004A8B',
900: '#002F58'
},
gray: {
DEFAULT: '#505468',
50: '#F1F1F4',
100: '#E8E9ED',
150: '#E0E1E6',
200: '#D8DAE3',
250: '#D2D4DC',
300: '#C0C2CE',
350: '#A6AABF',
400: '#9196A8',
450: '#71758A',
500: '#303544',
550: '#20222d',
600: '#171720',
650: '#121219',
700: '#121317',
750: '#0D0E11',
800: '#0C0C0F',
850: '#08090D',
900: '#060609',
950: '#030303'
}
},
extend: {
transitionTimingFunction: {
'css': 'ease',
'css-in': 'ease-in',
'css-out': 'ease-out',
'css-in-out': 'ease-in-out',
'in-sine': 'cubic-bezier(0.12, 0, 0.39, 0)',
'out-sine': 'cubic-bezier(0.61, 1, 0.88, 1)',
'in-out-sine': 'cubic-bezier(0.37, 0, 0.63, 1)',
'in-quad': 'cubic-bezier(0.11, 0, 0.5, 0)',
'out-quad': 'cubic-bezier(0.5, 1, 0.89, 1)',
'in-out-quad': 'cubic-bezier(0.45, 0, 0.55, 1)',
'in-cubic': 'cubic-bezier(0.32, 0, 0.67, 0)',
'out-cubic': 'cubic-bezier(0.33, 1, 0.68, 1)',
'in-out-cubic': 'cubic-bezier(0.65, 0, 0.35, 1)',
'in-quart': 'cubic-bezier(0.5, 0, 0.75, 0)',
'out-quart': 'cubic-bezier(0.25, 1, 0.5, 1)',
'in-out-quart': 'cubic-bezier(0.76, 0, 0.24, 1)',
'in-quint': 'cubic-bezier(0.64, 0, 0.78, 0)',
'out-quint': 'cubic-bezier(0.22, 1, 0.36, 1)',
'in-out-quint': 'cubic-bezier(0.83, 0, 0.17, 1)',
'in-expo': 'cubic-bezier(0.7, 0, 0.84, 0)',
'out-expo': 'cubic-bezier(0.16, 1, 0.3, 1)',
'in-out-expo': 'cubic-bezier(0.87, 0, 0.13, 1)',
'in-circ': 'cubic-bezier(0.55, 0, 1, 0.45)',
'out-circ': 'cubic-bezier(0, 0.55, 0.45, 1)',
'in-out-circ': 'cubic-bezier(0.85, 0, 0.15, 1)',
'in-back': 'cubic-bezier(0.36, 0, 0.66, -0.56)',
'out-back': 'cubic-bezier(0.34, 1.56, 0.64, 1)',
'in-out-back': 'cubic-bezier(0.68, -0.6, 0.32, 1.6)'
}
}
}
},
plugins: [
require('@tailwindcss/forms'),
require('tailwindcss-animate'),
require('@headlessui/tailwindcss'),
require('tailwindcss-radix')(),
require('@tailwindcss/typography')
]
};
/**
* @type {import('tailwindcss').Config}
*/
let config = {
content: [
`../../apps/${app}/src/**/*.{ts,tsx,html,stories.tsx}`,
"../../packages/*/src/**/*.{ts,tsx,html,stories.tsx}",
"../../interface/**/*.{ts,tsx,html,stories.tsx}",
],
darkMode: "class",
theme: {
screens: {
xs: "475px",
sm: "650px",
md: "868px",
lg: "1024px",
xl: "1280px",
},
fontFamily: {
sans: [...defaultTheme.fontFamily.sans],
plex: ["IBM Plex Sans", ...defaultTheme.fontFamily.sans],
},
fontSize: {
tiny: ".70rem",
xs: ".75rem",
sm: ".80rem",
base: "1rem",
lg: "1.125rem",
xl: "1.25rem",
"2xl": "1.5rem",
"3xl": "1.875rem",
"4xl": "2.25rem",
"5xl": "3rem",
"6xl": "4rem",
"7xl": "5rem",
},
extend: {
colors: {
accent: {
DEFAULT: alpha("--color-accent"),
faint: "hsl(var(--color-accent-faint))",
deep: alpha("--color-accent-deep"),
},
ink: {
DEFAULT: alpha("--color-ink"),
dull: alpha("--color-ink-dull"),
faint: alpha("--color-ink-faint"),
},
sidebar: {
DEFAULT: alpha("--color-sidebar"),
box: alpha("--color-sidebar-box"),
line: alpha("--color-sidebar-line"),
ink: alpha("--color-sidebar-ink"),
inkFaint: alpha("--color-sidebar-ink-faint"),
inkDull: alpha("--color-sidebar-ink-dull"),
divider: alpha("--color-sidebar-divider"),
button: alpha("--color-sidebar-button"),
selected: alpha("--color-sidebar-selected"),
shade: alpha("--color-sidebar-shade"),
},
app: {
DEFAULT: alpha("--color-app"),
box: alpha("--color-app-box"),
darkBox: alpha("--color-app-dark-box"),
darkerBox: alpha("--color-app-darker-box"),
lightBox: alpha("--color-app-light-box"),
overlay: alpha("--color-app-overlay"),
input: alpha("--color-app-input"),
focus: alpha("--color-app-focus"),
line: alpha("--color-app-line"),
divider: alpha("--color-app-divider"),
button: alpha("--color-app-button"),
selected: alpha("--color-app-selected"),
selectedItem: alpha("--color-app-selected-item"),
hover: alpha("--color-app-hover"),
active: alpha("--color-app-active"),
shade: alpha("--color-app-shade"),
frame: alpha("--color-app-frame"),
slider: alpha("--color-app-slider"),
explorerScrollbar: alpha("--color-app-explorer-scrollbar"),
},
menu: {
DEFAULT: alpha("--color-menu"),
line: alpha("--color-menu-line"),
hover: alpha("--color-menu-hover"),
selected: alpha("--color-menu-selected"),
shade: alpha("--color-menu-shade"),
ink: alpha("--color-menu-ink"),
faint: alpha("--color-menu-faint"),
},
// legacy support
primary: {
DEFAULT: "#2599FF",
50: "#FFFFFF",
100: "#F1F8FF",
200: "#BEE1FF",
300: "#8BC9FF",
400: "#58B1FF",
500: "#2599FF",
600: "#0081F1",
700: "#0065BE",
800: "#004A8B",
900: "#002F58",
},
gray: {
DEFAULT: "#505468",
50: "#F1F1F4",
100: "#E8E9ED",
150: "#E0E1E6",
200: "#D8DAE3",
250: "#D2D4DC",
300: "#C0C2CE",
350: "#A6AABF",
400: "#9196A8",
450: "#71758A",
500: "#303544",
550: "#20222d",
600: "#171720",
650: "#121219",
700: "#121317",
750: "#0D0E11",
800: "#0C0C0F",
850: "#08090D",
900: "#060609",
950: "#030303",
},
},
extend: {
transitionTimingFunction: {
css: "ease",
"css-in": "ease-in",
"css-out": "ease-out",
"css-in-out": "ease-in-out",
"in-sine": "cubic-bezier(0.12, 0, 0.39, 0)",
"out-sine": "cubic-bezier(0.61, 1, 0.88, 1)",
"in-out-sine": "cubic-bezier(0.37, 0, 0.63, 1)",
"in-quad": "cubic-bezier(0.11, 0, 0.5, 0)",
"out-quad": "cubic-bezier(0.5, 1, 0.89, 1)",
"in-out-quad": "cubic-bezier(0.45, 0, 0.55, 1)",
"in-cubic": "cubic-bezier(0.32, 0, 0.67, 0)",
"out-cubic": "cubic-bezier(0.33, 1, 0.68, 1)",
"in-out-cubic": "cubic-bezier(0.65, 0, 0.35, 1)",
"in-quart": "cubic-bezier(0.5, 0, 0.75, 0)",
"out-quart": "cubic-bezier(0.25, 1, 0.5, 1)",
"in-out-quart": "cubic-bezier(0.76, 0, 0.24, 1)",
"in-quint": "cubic-bezier(0.64, 0, 0.78, 0)",
"out-quint": "cubic-bezier(0.22, 1, 0.36, 1)",
"in-out-quint": "cubic-bezier(0.83, 0, 0.17, 1)",
"in-expo": "cubic-bezier(0.7, 0, 0.84, 0)",
"out-expo": "cubic-bezier(0.16, 1, 0.3, 1)",
"in-out-expo": "cubic-bezier(0.87, 0, 0.13, 1)",
"in-circ": "cubic-bezier(0.55, 0, 1, 0.45)",
"out-circ": "cubic-bezier(0, 0.55, 0.45, 1)",
"in-out-circ": "cubic-bezier(0.85, 0, 0.15, 1)",
"in-back": "cubic-bezier(0.36, 0, 0.66, -0.56)",
"out-back": "cubic-bezier(0.34, 1.56, 0.64, 1)",
"in-out-back": "cubic-bezier(0.68, -0.6, 0.32, 1.6)",
},
},
},
},
plugins: [
require("@tailwindcss/forms"),
require("tailwindcss-animate"),
require("@headlessui/tailwindcss"),
require("tailwindcss-radix")(),
require("@tailwindcss/typography"),
],
};
if (app === 'landing') {
console.log('CONFIGURING TAILWIND for Landing');
config.theme.fontFamily.sans = ['var(--font-inter)', ...defaultTheme.fontFamily.sans];
config.theme.fontFamily.plex = ['var(--font-plex-sans)', ...defaultTheme.fontFamily.sans];
}
if (app === "landing") {
console.log("CONFIGURING TAILWIND for Landing");
config.theme.fontFamily.sans = [
"var(--font-inter)",
...defaultTheme.fontFamily.sans,
];
config.theme.fontFamily.plex = [
"var(--font-plex-sans)",
...defaultTheme.fontFamily.sans,
];
}
return config;
return config;
};
// primary: {

View File

@@ -92,7 +92,10 @@ pub fn generate_cargo_config(
let android_ndk_home = std::env::var("ANDROID_NDK")
.or_else(|_| std::env::var("ANDROID_NDK_HOME"))
.expect("Android NDK not found. Set ANDROID_NDK or ANDROID_NDK_HOME");
.unwrap_or_else(|_| {
println!(" ⚠️ Android NDK not found. Android builds will not work.");
String::new()
});
// Build context for mustache
let context = ConfigContext {