Skip to content

feat: redundancy awareness & cross-volume file comparison#3053

Merged
jamiepine merged 17 commits into
mainfrom
spacedrive-redundancy
Apr 19, 2026
Merged

feat: redundancy awareness & cross-volume file comparison#3053
jamiepine merged 17 commits into
mainfrom
spacedrive-redundancy

Conversation

@jamiepine

@jamiepine jamiepine commented Apr 15, 2026

Copy link
Copy Markdown
Member

Summary

  • Add redundancy.summary query endpoint returning per-volume at-risk vs redundant bytes/files and a library-wide replication score (0–1)
  • Extend search.files with 5 new redundancy filters: at_risk, on_volumes, not_on_volumes, min_volume_count, max_volume_count
  • Add /redundancy dashboard, /redundancy/at-risk file list, and /redundancy/compare two-volume comparison UI
  • Add composite index migration on entries(content_id, volume_id) for query performance

Test plan

  • Run migration, verify idx_entries_content_volume exists via PRAGMA index_list('entries')
  • Call redundancy.summary with empty library (all zeros), single volume (100% at-risk), two volumes with overlap (mixed scores)
  • Call search.files with at_risk: true — verify only single-volume files returned
  • Call search.files with on_volumes: [A], not_on_volumes: [B] — verify correct diff
  • Navigate to /redundancy — dashboard loads with score, volume bars, at-risk callout
  • Click through to at-risk files — paginated list sorted by size
  • Compare two volumes — unique/shared toggle shows correct file sets
  • Sidebar shield button navigates to redundancy view

🤖 Generated with Claude Code

Note

Backend implementation adds a new redundancy query module with summary statistics and extends search filters to surface redundancy awareness. A composite database index optimizes the GROUP BY patterns needed for redundancy calculations. Frontend routes for dashboard, at-risk files, and cross-volume comparison are included alongside UI components for displaying replication scores and volume comparisons. All changes integrate with existing content identity and volume tracking systems without architectural modifications.

Written by Tembo for commit 1eea61b. This will update automatically on new commits.

Surface content redundancy data so users can answer "if this drive dies,
what do I lose?" — builds on existing content identity and volume systems.

Backend:
- New `redundancy.summary` library query with per-volume at-risk vs
  redundant byte/file counts and a library-wide replication score
- Extend `SearchFilters` with `at_risk`, `on_volumes`, `not_on_volumes`,
  `min_volume_count`, `max_volume_count` filters
- Add composite index migration on entries(content_id, volume_id)

Frontend:
- `/redundancy` dashboard with replication score, volume bars, at-risk callout
- `/redundancy/at-risk` paginated file list sorted by size
- `/redundancy/compare` two-volume comparison (unique/shared toggle)
- Sidebar ShieldCheck button linking to redundancy view

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Apr 15, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Adds new DB migration/index plus multiple raw-SQL-based search filters and a new query endpoint; mistakes could impact search correctness/perf and migration behavior on large libraries.

Overview
Adds redundancy awareness end-to-end: a new redundancy.summary library query computes per-volume at-risk vs redundant bytes/files and a library-wide replication score, backed by a new composite partial index on entries(content_id, volume_id).

Extends search.files with redundancy filters (at_risk, on_volumes, not_on_volumes, min_volume_count, max_volume_count) and relaxes validation to allow empty queries when these filters are used.

Introduces a new /redundancy UI (dashboard, at-risk file list, and volume-compare view) and a sidebar entry point, and updates the TS client generated types to include the new query and filter enums.

Reviewed by Cursor Bugbot for commit 1eea61b. Configure here.

@coderabbitai

coderabbitai Bot commented Apr 15, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds end-to-end redundancy features: DB migration for a composite entries index, backend redundancy.summary query and search filters, CLI and frontend UI (dashboard, at‑risk, compare) with explorer filtered mode, volume visibility/capacity fixes, build tooling, docs, and related tests/hooks.

Changes

Cohort / File(s) Summary
DB Migration
core/src/infra/db/migration/m20260414_000001_add_redundancy_indexes.rs, core/src/infra/db/migration/mod.rs
New SeaORM migration adds composite index idx_entries_content_volume (content_id, volume_id) excluding NULLs; migration registered in migrator list.
Redundancy backend & query
core/src/ops/mod.rs, core/src/ops/redundancy/..., core/src/ops/redundancy/summary/{input.rs,output.rs,query.rs,mod.rs}
New redundancy ops module and redundancy.summary query: DTOs, query implementation computing per-volume at‑risk/redundant bytes/files and library totals, optional volume scoping, and query registration.
Search filters & integration
core/src/ops/search/{filters.rs,input.rs,mod.rs,query.rs}
Added redundancy search filters (at_risk, on_volumes, not_on_volumes, min_volume_count, max_volume_count), UUID→SQLite BLOB helper, updated SearchFilters/FilterKind, and integrated filters into fast search.
Explorer filtered mode & files plumbing
packages/interface/src/routes/explorer/{context.tsx,useExplorerFiles.ts,ExplorerView.tsx}
Introduced "filtered" explorer mode, enterFilteredMode/exitFilteredMode, new FileSource "filtered", filtered query plumbing/priority, and render guard adjusted for filtered views.
Frontend redundancy UI
packages/interface/src/router.tsx, packages/interface/src/routes/redundancy/*, packages/interface/src/routes/redundancy/components/RedundancyVolumeBar.tsx, packages/interface/src/components/SpacesSidebar/*, packages/interface/src/components/SpacesSidebar/hooks/spaceItemUtils.ts
Added /redundancy routes (dashboard, at‑risk, compare), route components and volume bar, sidebar palette item, icon/label/path mappings, and related UI wiring.
CLI commands & args
apps/cli/src/domains/redundancy/{mod.rs,args.rs}, apps/cli/src/domains/mod.rs, apps/cli/src/main.rs, apps/cli/src/domains/search/args.rs
New redundancy CLI subcommand with summary, at-risk, compare subcommands and clap args; hooked into main CLI; FileSearchArgs→FileSearchInput mapping updated for new filters.
Volume visibility & capacity fixes
core/src/volume/utils.rs, core/src/volume/fs/zfs.rs, core/src/ops/volumes/list/query.rs, core/src/library/{manager.rs,mod.rs}
Centralized should_hide_by_mount_path (Linux-enabled, unit test), ZFS pool-root capacity correction, volume-list uses shared helper, default space items include Redundancy, and capacity computation filters out hidden volumes.
Build & tooling
apps/server/build.rs, apps/server/Dockerfile, build-server.sh
Added server build.rs to optionally build/embed web UI; Dockerfile sets SD_SKIP_WEB_BUILD=1; added native release build script.
Context menu & UI plumbing
packages/interface/src/contexts/WebContextMenuContext.tsx, packages/interface/src/hooks/useContextMenu.ts, packages/interface/src/ShellLayout.tsx
Web context menu provider/controller added; useContextMenu prefers web controller with Tauri fallback; ShellLayout wrapped with provider.
Docs & tests
docs/core/filesystems.mdx, core/src/volume/utils.rs (tests)
New filesystem integration docs and Linux unit test for mount-path hiding.
Misc / Integration touches
core/src/domain/space.rs, core/src/library/manager.rs, core/src/library/mod.rs, packages/interface/src/components/SpacesSidebar/*, packages/interface/src/routes/explorer/hooks/useExplorerFiles.ts, apps/cli/src/domains/volume/mod.rs
Added ItemType::Redundancy, default space item, UI palette/sidebar plumbing, explorer filtered-source handling, CLI volume output formatting changes, and cross-module wiring.

Sequence Diagram

sequenceDiagram
    actor User
    participant Frontend as Frontend Client
    participant API as Backend Query Handler
    participant DB as Database

    User->>Frontend: open /redundancy (dashboard)
    Frontend->>API: request "redundancy.summary" (input: volume_uuids?)
    API->>DB: query per-volume at-risk (content on exactly 1 visible volume)
    DB-->>API: at-risk rows
    API->>DB: query per-volume redundant (content on >=2 volumes)
    DB-->>API: redundant rows
    API->>DB: query per-volume totals (file counts & bytes)
    DB-->>API: totals rows
    API->>DB: query library unique content bytes (deduped identities)
    DB-->>API: library totals
    API->>API: merge rows, compute replication_score, assemble output
    API-->>Frontend: RedundancySummaryOutput
    Frontend->>Frontend: render dashboard, bars, navigation (at-risk/compare)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

"I nibble bytes and count each hop,
From pools and paths to the database crop.
A coat of shields, a dashboard bright,
I hop through volumes, day and night.
— Yours, the Redundancy Rabbit 🐇"

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The PR description provides a comprehensive summary of changes, includes a detailed test plan with checkboxes, and explains the architectural approach. However, it does not reference a corresponding issue via 'Closes #(issue)' as required by the description template. Add a 'Closes #(issue)' line to link this PR to its corresponding GitHub issue, as specified in the description template.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'feat: redundancy awareness & cross-volume file comparison' accurately and concisely captures the primary feature additions: new redundancy summary/filtering capabilities and cross-volume comparison functionality.
Docstring Coverage ✅ Passed Docstring coverage is 93.90% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch spacedrive-redundancy

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.


/// Filter to files whose content is present on the specified volumes
pub fn on_volumes(mut self, on_volumes: &Option<Vec<Uuid>>) -> Self {
if let Some(uuids) = on_volumes {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on_volumes currently matches content present on any of the provided volumes. The compare view’s “Shared” mode (and the docstring) reads more like “present on all selected volumes”. If that’s the intent, you can enforce it with a GROUP BY + HAVING COUNT(DISTINCT ...) = uuids.len()

Suggested change
if let Some(uuids) = on_volumes {
pub fn on_volumes(mut self, on_volumes: &Option<Vec<Uuid>>) -> Self {
if let Some(uuids) = on_volumes {
if !uuids.is_empty() {
let uuid_list = uuids
.iter()
.map(|u| format!("'{}'", u))
.collect::<Vec<_>>()
.join(",");
let required_count = uuids.len();
self.condition = self.condition.add(Expr::cust(format!(
"entries.content_id IN (\\
SELECT e2.content_id FROM entries e2 \\
INNER JOIN volumes v ON e2.volume_id = v.id \\
WHERE e2.content_id IS NOT NULL \\
AND v.uuid IN ({}) \\
GROUP BY e2.content_id \\
HAVING COUNT(DISTINCT v.uuid) = {}\\
)",
uuid_list,
required_count
)));
}
}
self
}

if self.query.trim().is_empty() && !is_recents_query {
// Allow empty queries when redundancy filters are active (browsing at-risk files)
let has_redundancy_filters = self.filters.at_risk.is_some()
|| self.filters.on_volumes.is_some()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor edge case: has_redundancy_filters treats Some(vec![]) as active, so an empty query becomes valid even though the filter builder ignores empty lists. Probably want to treat empty vectors as inactive.

Suggested change
|| self.filters.on_volumes.is_some()
let has_redundancy_filters = self.filters.at_risk.is_some()
|| self
.filters
.on_volumes
.as_ref()
.map_or(false, |v| !v.is_empty())
|| self
.filters
.not_on_volumes
.as_ref()
.map_or(false, |v| !v.is_empty())
|| self.filters.min_volume_count.is_some()
|| self.filters.max_volume_count.is_some();

case "unique_b":
return { ...NULL_FILTERS, on_volumes: [volumeB], not_on_volumes: [volumeA] };
case "shared":
return { ...NULL_FILTERS, on_volumes: [volumeA, volumeB], min_volume_count: 2 };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

“Shared” mode currently uses on_volumes: [A, B] + min_volume_count: 2. If on_volumes is “any-of” (as implemented today), this can include files on A + C (not B). Might be worth either making on_volumes an “all-of” filter for multi-select, or introducing a dedicated shared-between-volumes filter.


// Helper to build volume ID WHERE clause
let volume_where = match &volume_id_filter {
Some(ids) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When volume_uuids is provided, the outer queries are scoped but the inner COUNT(DISTINCT volume_id) subqueries aren’t, so “at risk”/“redundant” is computed globally rather than within the selected set. Also, summing ci.total_size per entry will multiply bytes when the same content_id appears multiple times on a volume. Might be worth clarifying the intended semantics (per-entry vs per-content) and, if needed, deduping (volume_id, content_id) before summing.

/// Display name of the volume
pub display_name: Option<String>,
/// Total bytes of file content on this volume (deduplicated within volume)
pub total_bytes: i64,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: total_bytes is documented as “deduplicated within volume”, but totals_sql sums entries.size per entry. If per-entry is intended, updating the comment avoids confusion.

Suggested change
pub total_bytes: i64,
/// Total bytes of file entries on this volume

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 5 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1eea61b. Configure here.

let total_file_count = total.map_or(0, |r| r.total_file_count as u32);

lib_at_risk_bytes += at_risk_bytes;
lib_redundant_bytes += redundant_bytes;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Library redundant bytes double-counted across volumes

High Severity

lib_redundant_bytes is computed by summing per-volume redundant bytes, but redundant content by definition exists on 2+ volumes. This means the same content's size is counted once per volume it appears on, inflating total_redundant_bytes and the replication_score. For example, a 100 MB file on 3 volumes contributes 300 MB instead of 100 MB. The doc on LibraryRedundancyTotals.total_redundant_bytes says "content bytes that exist on two or more volumes" (implying deduplicated), but the value is a per-volume sum. The replication_score is similarly overstated.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1eea61b. Configure here.

case "unique_b":
return { ...NULL_FILTERS, on_volumes: [volumeB], not_on_volumes: [volumeA] };
case "shared":
return { ...NULL_FILTERS, on_volumes: [volumeA, volumeB], min_volume_count: 2 };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared comparison mode uses OR instead of AND

High Severity

The "shared" comparison mode builds on_volumes: [volumeA, volumeB], min_volume_count: 2. The on_volumes filter uses SQL IN which matches content on volume A or B. Combined with min_volume_count: 2 (a global check across all volumes), this incorrectly includes files that exist on A and some unrelated volume C but not B. The intent is to find files shared between A and B specifically, but the filter combination doesn't express that constraint.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1eea61b. Configure here.

format!("AND e.volume_id IN ({})", id_list)
}
None => String::new(),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty volume ID list generates invalid SQL

Medium Severity

When volume_uuids contains UUIDs that don't match any existing volumes, the resolved volume_id_filter becomes Some(vec![]). The volume_where clause then generates AND e.volume_id IN () — an empty IN list is a SQL syntax error in SQLite, causing all three subsequent queries to fail at runtime.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1eea61b. Configure here.

const sizes = ["B", "KB", "MB", "GB", "TB", "PB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated formatBytes utility across new files

Low Severity

An identical formatBytes function is defined in four new files (plus RedundancyVolumeBar.tsx). An exported formatBytes already exists at packages/interface/src/routes/explorer/utils.ts and is used by other components. The new copies are redundant and risk diverging in behavior.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1eea61b. Configure here.

.file_types(&self.input.filters.file_types)
.date_range(&self.input.filters.date_range)
.size_range(&self.input.filters.size_range);
.size_range(&self.input.filters.size_range)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundancy filters missing from total count query

Medium Severity

The apply_filters method used by get_total_count does not include the five new redundancy filters (at_risk, on_volumes, not_on_volumes, min_volume_count, max_volume_count), even though FilterBuilder in execute_fast_search_no_fts does. This means total_found in the response ignores redundancy filters, returning an inflated count. The at-risk files view and compare view rely on total_found for pagination, so they'll display incorrect page counts and allow navigating to empty pages.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1eea61b. Configure here.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (7)
packages/interface/src/router.tsx (1)

4-6: Lazy-load the new redundancy route components.

These components are eagerly imported on Lines 4–6, which pulls redundancy pages into the initial bundle. Prefer React.lazy + Suspense at route boundaries.

♻️ Suggested refactor
+import { lazy, Suspense } from "react";
 import { createBrowserRouter, Navigate, Outlet } from "react-router-dom";
 import { Overview } from "./routes/overview";
 import { ExplorerView } from "./routes/explorer";
-import { RedundancyDashboard } from "./routes/redundancy";
-import { AtRiskFiles } from "./routes/redundancy/at-risk";
-import { CompareVolumes } from "./routes/redundancy/compare";
+
+const RedundancyDashboard = lazy(() =>
+	import("./routes/redundancy").then((m) => ({ default: m.RedundancyDashboard }))
+);
+const AtRiskFiles = lazy(() =>
+	import("./routes/redundancy/at-risk").then((m) => ({ default: m.AtRiskFiles }))
+);
+const CompareVolumes = lazy(() =>
+	import("./routes/redundancy/compare").then((m) => ({ default: m.CompareVolumes }))
+);
 			{
 				path: "redundancy",
 				children: [
 					{
 						index: true,
-						element: <RedundancyDashboard />,
+						element: (
+							<Suspense fallback={null}>
+								<RedundancyDashboard />
+							</Suspense>
+						),
 					},
 					{
 						path: "at-risk",
-						element: <AtRiskFiles />,
+						element: (
+							<Suspense fallback={null}>
+								<AtRiskFiles />
+							</Suspense>
+						),
 					},
 					{
 						path: "compare",
-						element: <CompareVolumes />,
+						element: (
+							<Suspense fallback={null}>
+								<CompareVolumes />
+							</Suspense>
+						),
 					},
 				],
 			},

As per coding guidelines: "Lazy load route components using React.lazy and Suspense for code splitting".

Also applies to: 84-100

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/interface/src/router.tsx` around lines 4 - 6, The three redundancy
route components (RedundancyDashboard, AtRiskFiles, CompareVolumes) are eagerly
imported; replace those static imports in router.tsx with React.lazy(...) and
wrap the route rendering boundaries in a React.Suspense fallback to enable
code-splitting; specifically, change the import sites for RedundancyDashboard,
AtRiskFiles and CompareVolumes to lazy-loaded declarations and ensure the Routes
(where those components are used, also noted around lines 84-100) are wrapped
with a Suspense element (or each route uses a Suspense boundary) so the
components load asynchronously with a fallback UI.
packages/interface/src/routes/redundancy/compare.tsx (2)

268-289: Consider using clsx for conditional class application.

Per coding guidelines, prefer clsx utility for conditional className instead of manual template literals.

♻️ Refactor using clsx
+import clsx from "clsx";

 function ModeButton({
 	active,
 	onClick,
 	label,
 }: {
 	active: boolean;
 	onClick: () => void;
 	label: string;
 }) {
 	return (
 		<button
 			onClick={onClick}
-			className={`flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
-				active
-					? "bg-accent text-white"
-					: "text-ink-dull hover:text-ink"
-			}`}
+			className={clsx(
+				"flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
+				active ? "bg-accent text-white" : "text-ink-dull hover:text-ink"
+			)}
 		>
 			{label}
 		</button>
 	);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/interface/src/routes/redundancy/compare.tsx` around lines 268 - 289,
The ModeButton component uses a template literal to conditionally set className;
replace it with clsx by importing clsx and calling it in the className prop
(keep static classes like "flex-1 rounded-md px-3 py-1.5 text-xs font-medium
transition-colors" and apply the conditional object or array form for active ?
"bg-accent text-white" : "text-ink-dull hover:text-ink"). Update the ModeButton
function signature to use className={clsx(...)} and ensure clsx is added to the
module imports.

69-83: Consider adding pagination or a "View All" link for large result sets.

The query is limited to 50 results without pagination controls. For volumes with many unique or shared files, users won't see the complete picture. Consider either adding pagination (like at-risk.tsx) or linking to the at-risk view with appropriate filters.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/interface/src/routes/redundancy/compare.tsx` around lines 69 - 83,
The search call using useLibraryQuery currently limits results to 50
(pagination: { limit: 50, offset: 0 }) and is gated by filters, which hides
large result sets; update the component to either implement pagination state and
controls (e.g., page/offset and limit state passed into the pagination object of
useLibraryQuery, showing next/prev or page numbers and reflecting
searchLoading/searchData) or add a "View All" / "Open At-Risk" link that
navigates to the at-risk view with the current filters applied; reference
useLibraryQuery, searchData/searchLoading, filters, NULL_FILTERS and the
pagination object when making the changes so the query updates as users paginate
or follow the link.
packages/interface/src/routes/redundancy/at-risk.tsx (1)

15-22: Consider extracting formatBytes to a shared utility.

This helper is duplicated in at-risk.tsx, compare.tsx, index.tsx, and RedundancyVolumeBar.tsx. Extracting it to a shared utilities file would reduce duplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/interface/src/routes/redundancy/at-risk.tsx` around lines 15 - 22,
The function formatBytes is duplicated across at-risk.tsx, compare.tsx,
index.tsx, and RedundancyVolumeBar.tsx; extract it into a shared utility (e.g.,
create a new helper like formatBytes in a utils/bytes.ts or similar), export it,
and replace the local implementations by importing and using the shared
formatBytes function in each file (ensure the exported function signature
remains formatBytes(bytes: number): string and update imports in the components
that currently define it).
core/src/ops/search/input.rs (1)

206-213: Consider validating min_volume_countmax_volume_count when both are provided.

The validation logic correctly allows empty queries when redundancy filters are active, consistent with the execute_fast_search_no_fts implementation. However, similar to the date_range and size_range validations below, you may want to ensure min_volume_count doesn't exceed max_volume_count when both are specified.

♻️ Optional validation addition
 		if let (Some(min), Some(max)) = (size_range.min, size_range.max) {
 			if min > max {
 				return Err("Size range min must be less than max".to_string());
 			}
 		}
 	}

+	// Validate volume count range if both are provided
+	if let (Some(min), Some(max)) = (self.filters.min_volume_count, self.filters.max_volume_count) {
+		if min > max {
+			return Err("Volume count min must be less than or equal to max".to_string());
+		}
+	}
+
 	Ok(())
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/ops/search/input.rs` around lines 206 - 213, Add a validation that
ensures self.filters.min_volume_count ≤ self.filters.max_volume_count when both
are Some: locate the same validation area that checks date_range/size_range
(near has_redundancy_filters / the empty-query check) and, if both
self.filters.min_volume_count and self.filters.max_volume_count are present,
return/emit the same kind of validation error used elsewhere (or set the same
invalid flag) when min > max so the volume-count range is consistent with the
other range checks.
core/src/ops/redundancy/summary/query.rs (2)

254-259: Potential truncation when casting i64 to u32.

The file_count and total_file_count values from SQL (i64) are cast to u32 using as. While exceeding ~4 billion files is unlikely, the as cast silently truncates/wraps on overflow, which could produce incorrect results without warning.

Consider using try_into() for defensive error handling or switching the output struct fields to i64/u64.

♻️ Suggested defensive cast
-			let at_risk_file_count = at_risk.map_or(0, |r| r.file_count as u32);
+			let at_risk_file_count = at_risk.map_or(0, |r| r.file_count.try_into().unwrap_or(u32::MAX));
-			let redundant_file_count = redundant.map_or(0, |r| r.file_count as u32);
+			let redundant_file_count = redundant.map_or(0, |r| r.file_count.try_into().unwrap_or(u32::MAX));
-			let total_file_count = total.map_or(0, |r| r.total_file_count as u32);
+			let total_file_count = total.map_or(0, |r| r.total_file_count.try_into().unwrap_or(u32::MAX));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/ops/redundancy/summary/query.rs` around lines 254 - 259, The current
casts like in at_risk_file_count, redundant_file_count, and total_file_count use
`as` to convert SQL i64 counts to u32 which silently truncates on overflow;
update the conversion to perform a fallible conversion (e.g. use
try_into()/TryFrom) and propagate or handle errors (return a Result or
clamp/validate) or alternatively change the output fields to a wider
signed/unsigned type (i64/u64) so no lossy cast is needed—locate the assignments
for at_risk_file_count, redundant_file_count, and total_file_count in query.rs
and implement the chosen defensive conversion/field type change consistently
across the summary struct and callers.

139-143: Hardcoded DbBackend::Sqlite limits database portability.

All raw SQL statements use DbBackend::Sqlite. If Spacedrive intends to remain SQLite-only, this is fine. Otherwise, consider parameterizing the backend or adding a comment noting this constraint.

Also applies to: 168-172, 189-194, 206-211

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/ops/redundancy/summary/query.rs` around lines 139 - 143, The SQL
statement construction hardcodes DbBackend::Sqlite (seen in
Statement::from_string(DbBackend::Sqlite, at_risk_sql)) which breaks
portability; replace the hardcoded backend with the actual database backend used
at runtime (e.g., obtain the backend from the active connection or a passed
context) and pass that to Statement::from_string for all occurrences (see
VolumeRedundancyRow::find_by_statement usages and the at_risk_sql/other SQL
variables at lines referenced). If the project is intentionally SQLite-only,
instead add a clear comment next to Statement::from_string(DbBackend::Sqlite,
...) stating that SQLite is required and why; otherwise modify the calling code
to supply db.backend() or an injected Backend enum so Statement::from_string
uses the correct backend for each query.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@core/src/ops/redundancy/summary/query.rs`:
- Around line 196-211: The library_total_sql query (used to compute
total_unique_content_bytes via LibraryTotalRow::find_by_statement) is missing
the volume filter used elsewhere (volume_where), causing totals to be
library-wide while total_at_risk_bytes and total_redundant_bytes are filtered;
update the library_total_sql subquery on entries (the SELECT DISTINCT
e.content_id FROM entries e WHERE ...) to include the same volume_where
condition (or the equivalent e.volume_id IN (...) predicate) so
total_unique_content_bytes respects the requested volume_uuids, or alternatively
add a clear comment next to library_total_sql explaining the intentional
difference if the library-wide behavior is desired.

In `@core/src/ops/search/query.rs`:
- Around line 927-932: The redundancy filters (size_range, at_risk, on_volumes,
not_on_volumes, min_volume_count, max_volume_count) are only applied in
execute_fast_search_no_fts causing divergence; update execute_fast_search and
get_total_count to apply the same predicates. Best fix: extract the repeated
chain into a helper (e.g., apply_redundancy_filters) that accepts the query
builder and &self.input.filters and call it from execute_fast_search,
execute_fast_search_no_fts, and get_total_count so both FTS and non-FTS paths
and total-count calculations share identical filter logic.

In `@packages/interface/src/routes/redundancy/at-risk.tsx`:
- Around line 26-34: In AtRiskFiles, reset the pagination offset whenever the
URL filter parameters change: add a useEffect in the AtRiskFiles component that
calls setOffset(0) when either volumeFilter or isAtRisk changes so the component
does not retain a stale offset; ensure the effect’s dependency array includes
volumeFilter and isAtRisk (referencing the existing useState offset/setOffset
and the volumeFilter/isAtRisk variables) and avoid adding unrelated
dependencies.

---

Nitpick comments:
In `@core/src/ops/redundancy/summary/query.rs`:
- Around line 254-259: The current casts like in at_risk_file_count,
redundant_file_count, and total_file_count use `as` to convert SQL i64 counts to
u32 which silently truncates on overflow; update the conversion to perform a
fallible conversion (e.g. use try_into()/TryFrom) and propagate or handle errors
(return a Result or clamp/validate) or alternatively change the output fields to
a wider signed/unsigned type (i64/u64) so no lossy cast is needed—locate the
assignments for at_risk_file_count, redundant_file_count, and total_file_count
in query.rs and implement the chosen defensive conversion/field type change
consistently across the summary struct and callers.
- Around line 139-143: The SQL statement construction hardcodes
DbBackend::Sqlite (seen in Statement::from_string(DbBackend::Sqlite,
at_risk_sql)) which breaks portability; replace the hardcoded backend with the
actual database backend used at runtime (e.g., obtain the backend from the
active connection or a passed context) and pass that to Statement::from_string
for all occurrences (see VolumeRedundancyRow::find_by_statement usages and the
at_risk_sql/other SQL variables at lines referenced). If the project is
intentionally SQLite-only, instead add a clear comment next to
Statement::from_string(DbBackend::Sqlite, ...) stating that SQLite is required
and why; otherwise modify the calling code to supply db.backend() or an injected
Backend enum so Statement::from_string uses the correct backend for each query.

In `@core/src/ops/search/input.rs`:
- Around line 206-213: Add a validation that ensures
self.filters.min_volume_count ≤ self.filters.max_volume_count when both are
Some: locate the same validation area that checks date_range/size_range (near
has_redundancy_filters / the empty-query check) and, if both
self.filters.min_volume_count and self.filters.max_volume_count are present,
return/emit the same kind of validation error used elsewhere (or set the same
invalid flag) when min > max so the volume-count range is consistent with the
other range checks.

In `@packages/interface/src/router.tsx`:
- Around line 4-6: The three redundancy route components (RedundancyDashboard,
AtRiskFiles, CompareVolumes) are eagerly imported; replace those static imports
in router.tsx with React.lazy(...) and wrap the route rendering boundaries in a
React.Suspense fallback to enable code-splitting; specifically, change the
import sites for RedundancyDashboard, AtRiskFiles and CompareVolumes to
lazy-loaded declarations and ensure the Routes (where those components are used,
also noted around lines 84-100) are wrapped with a Suspense element (or each
route uses a Suspense boundary) so the components load asynchronously with a
fallback UI.

In `@packages/interface/src/routes/redundancy/at-risk.tsx`:
- Around line 15-22: The function formatBytes is duplicated across at-risk.tsx,
compare.tsx, index.tsx, and RedundancyVolumeBar.tsx; extract it into a shared
utility (e.g., create a new helper like formatBytes in a utils/bytes.ts or
similar), export it, and replace the local implementations by importing and
using the shared formatBytes function in each file (ensure the exported function
signature remains formatBytes(bytes: number): string and update imports in the
components that currently define it).

In `@packages/interface/src/routes/redundancy/compare.tsx`:
- Around line 268-289: The ModeButton component uses a template literal to
conditionally set className; replace it with clsx by importing clsx and calling
it in the className prop (keep static classes like "flex-1 rounded-md px-3
py-1.5 text-xs font-medium transition-colors" and apply the conditional object
or array form for active ? "bg-accent text-white" : "text-ink-dull
hover:text-ink"). Update the ModeButton function signature to use
className={clsx(...)} and ensure clsx is added to the module imports.
- Around line 69-83: The search call using useLibraryQuery currently limits
results to 50 (pagination: { limit: 50, offset: 0 }) and is gated by filters,
which hides large result sets; update the component to either implement
pagination state and controls (e.g., page/offset and limit state passed into the
pagination object of useLibraryQuery, showing next/prev or page numbers and
reflecting searchLoading/searchData) or add a "View All" / "Open At-Risk" link
that navigates to the at-risk view with the current filters applied; reference
useLibraryQuery, searchData/searchLoading, filters, NULL_FILTERS and the
pagination object when making the changes so the query updates as users paginate
or follow the link.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 138d65f9-5ed4-401c-8c2b-388c88f973c1

📥 Commits

Reviewing files that changed from the base of the PR and between f68606a and 1eea61b.

⛔ Files ignored due to path filters (1)
  • packages/ts-client/src/generated/types.ts is excluded by !**/generated/**, !**/generated/**
📒 Files selected for processing (18)
  • core/src/infra/db/migration/m20260414_000001_add_redundancy_indexes.rs
  • core/src/infra/db/migration/mod.rs
  • core/src/ops/mod.rs
  • core/src/ops/redundancy/mod.rs
  • core/src/ops/redundancy/summary/input.rs
  • core/src/ops/redundancy/summary/mod.rs
  • core/src/ops/redundancy/summary/output.rs
  • core/src/ops/redundancy/summary/query.rs
  • core/src/ops/search/filters.rs
  • core/src/ops/search/input.rs
  • core/src/ops/search/mod.rs
  • core/src/ops/search/query.rs
  • packages/interface/src/components/SpacesSidebar/index.tsx
  • packages/interface/src/router.tsx
  • packages/interface/src/routes/redundancy/at-risk.tsx
  • packages/interface/src/routes/redundancy/compare.tsx
  • packages/interface/src/routes/redundancy/components/RedundancyVolumeBar.tsx
  • packages/interface/src/routes/redundancy/index.tsx

Comment on lines +196 to +211
// Query 4: Library-wide unique content total (deduplicated)
let library_total_sql = r#"
SELECT COALESCE(SUM(ci.total_size), 0) as total_bytes
FROM content_identities ci
WHERE ci.id IN (
SELECT DISTINCT e.content_id FROM entries e
WHERE e.content_id IS NOT NULL AND e.volume_id IS NOT NULL
)
"#;

let library_total = LibraryTotalRow::find_by_statement(Statement::from_string(
DbBackend::Sqlite,
library_total_sql.to_string(),
))
.one(db)
.await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent volume filtering for library totals.

The library_total_sql query does not apply volume_where, so total_unique_content_bytes is always library-wide. However, total_at_risk_bytes and total_redundant_bytes (computed from volume-filtered queries at lines 261-262) respect the volume_uuids filter.

This creates an inconsistency: when a user requests a scoped summary for specific volumes, they get:

  • total_unique_content_bytes: entire library (unfiltered)
  • total_at_risk_bytes / total_redundant_bytes: filtered to selected volumes

If this is intentional, consider adding a comment clarifying the design choice. Otherwise, apply the volume filter to align all totals.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/ops/redundancy/summary/query.rs` around lines 196 - 211, The
library_total_sql query (used to compute total_unique_content_bytes via
LibraryTotalRow::find_by_statement) is missing the volume filter used elsewhere
(volume_where), causing totals to be library-wide while total_at_risk_bytes and
total_redundant_bytes are filtered; update the library_total_sql subquery on
entries (the SELECT DISTINCT e.content_id FROM entries e WHERE ...) to include
the same volume_where condition (or the equivalent e.volume_id IN (...)
predicate) so total_unique_content_bytes respects the requested volume_uuids, or
alternatively add a clear comment next to library_total_sql explaining the
intentional difference if the library-wide behavior is desired.

Comment on lines +927 to +932
.size_range(&self.input.filters.size_range)
.at_risk(&self.input.filters.at_risk)
.on_volumes(&self.input.filters.on_volumes)
.not_on_volumes(&self.input.filters.not_on_volumes)
.min_volume_count(&self.input.filters.min_volume_count)
.max_volume_count(&self.input.filters.max_volume_count);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Redundancy filters are only applied in the empty-query path.

On Lines 927–932, the new predicates are added only in execute_fast_search_no_fts. execute_fast_search (non-empty FTS flow) and get_total_count still don’t apply these redundancy filters, so search.files behavior and pagination totals diverge by query mode.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/ops/search/query.rs` around lines 927 - 932, The redundancy filters
(size_range, at_risk, on_volumes, not_on_volumes, min_volume_count,
max_volume_count) are only applied in execute_fast_search_no_fts causing
divergence; update execute_fast_search and get_total_count to apply the same
predicates. Best fix: extract the repeated chain into a helper (e.g.,
apply_redundancy_filters) that accepts the query builder and &self.input.filters
and call it from execute_fast_search, execute_fast_search_no_fts, and
get_total_count so both FTS and non-FTS paths and total-count calculations share
identical filter logic.

Comment on lines +26 to +34
export function AtRiskFiles() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const volumeFilter = searchParams.get("volume");
const atRiskParam = searchParams.get("at_risk");
const isAtRisk = atRiskParam !== "false"; // default true

const [offset, setOffset] = useState(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Reset offset when URL parameters change.

When volumeFilter or isAtRisk changes (via URL search params), the offset state retains its previous value. This can cause the user to see an empty page or stale pagination if they were on page 3, then changed filters resulting in fewer total results.

🐛 Proposed fix using useEffect for reset
 import { useState, useMemo } from "react";
+import { useState, useMemo, useEffect } from "react";
 import { useSearchParams, useNavigate } from "react-router-dom";
 	const [offset, setOffset] = useState(0);

+	// Reset pagination when filters change
+	useEffect(() => {
+		setOffset(0);
+	}, [volumeFilter, isAtRisk]);
+
 	const { data, isLoading } = useLibraryQuery({
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function AtRiskFiles() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const volumeFilter = searchParams.get("volume");
const atRiskParam = searchParams.get("at_risk");
const isAtRisk = atRiskParam !== "false"; // default true
const [offset, setOffset] = useState(0);
export function AtRiskFiles() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const volumeFilter = searchParams.get("volume");
const atRiskParam = searchParams.get("at_risk");
const isAtRisk = atRiskParam !== "false"; // default true
// Derive offset from URL or default to 0
const offset = parseInt(searchParams.get("offset") ?? "0", 10);
const { data, isLoading } = useLibraryQuery({
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/interface/src/routes/redundancy/at-risk.tsx` around lines 26 - 34,
In AtRiskFiles, reset the pagination offset whenever the URL filter parameters
change: add a useEffect in the AtRiskFiles component that calls setOffset(0)
when either volumeFilter or isAtRisk changes so the component does not retain a
stale offset; ensure the effect’s dependency array includes volumeFilter and
isAtRisk (referencing the existing useState offset/setOffset and the
volumeFilter/isAtRisk variables) and avoid adding unrelated dependencies.

jamiepine and others added 7 commits April 15, 2026 00:12
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
# Conflicts:
#	core/src/volume/fs/zfs.rs
- ZFS: override total_capacity for pool-root volumes using zfs list
  used+available. df under-reports pool-root Size because it only
  counts the root dataset's own used bytes plus avail — on a 60 TB
  raidz2 pool this shows as ~15 TB instead of ~62 TB. The pool root's
  own used property includes descendants, so used+available is the
  real usable capacity.

- Library stats: drop volumes where is_user_visible=false AND
  re-apply should_hide_by_mount_path retroactively so stale DB rows
  (detected before the Linux visibility filters existed) don't
  inflate reported capacity.

- Extract should_hide_by_mount_path into volume/utils as a shared
  helper used by both the list query and the stats calculation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Makes it possible to verify library-level capacity aggregation from
the CLI — previously the list only showed mount, fingerprint, and
tracked/mounted state, which meant debugging the ZFS pool capacity
issue required querying the library DB directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New docs/core/filesystems.mdx covering per-filesystem capabilities
(CoW, pool-awareness, visibility filtering, capacity correction),
platform detection strategies, the FilesystemHandler trait, Linux/
macOS/ZFS visibility rules, the ZFS pool-root capacity problem and
fix, copy strategy selection, and known limitations.

Registered under File Management in both mint.json and docs.json.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- core/src/ops/search: wire redundancy filters (at_risk, on_volumes,
  not_on_volumes, min/max volume_count) through the search query;
  fix UUID-to-SQLite BLOB literal so volume UUID comparisons actually
  match (volumes.uuid is stored as a 16-byte BLOB, quoted-string
  comparison silently returned zero rows).

- apps/cli: new redundancy subcommand + populate the new
  SearchFilters fields from search args.

- packages/interface: redundancy at-risk and compare pages reworked
  to consume the new filter surface; explorer context/hook updates
  to support redundancy-scoped views.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
packages/interface/src/routes/redundancy/compare.tsx (1)

84-89: ⚠️ Potential issue | 🟠 Major

shared filter is still overly broad (duplicate of earlier reviewer comment).

With on_volumes: [A, B] treated as "any-of" plus min_volume_count: 2, a file on A + C (not B) still qualifies as "shared between A & B". Consider either switching on_volumes to all-of semantics for multi-select or adding a dedicated intersection filter (e.g., reuse not_on_volumes exclusion logic, or add a shared_between filter server-side).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/interface/src/routes/redundancy/compare.tsx` around lines 84 - 89,
The "shared" branch currently builds filters using on_volumes + min_volume_count
which yields false positives (case "shared", EMPTY_FILTERS, on_volumes,
min_volume_count); change the client payload to express an intersection rather
than an any-of: replace on_volumes + min_volume_count with a dedicated
intersection field (e.g., on_volumes_all or shared_between) set to [volumeA!,
volumeB!], and update the server/filter logic to treat that field as "all-of"
(or alternatively add a min_count scoped to on_volumes like
min_volume_count_on_volumes: 2) so only files present on both A and B are
returned.
🧹 Nitpick comments (7)
apps/cli/src/domains/search/args.rs (1)

165-228: Optional: use ..Default::default() to future-proof struct init.

SearchFilters derives Default (per core/src/ops/search/input.rs), so explicitly writing at_risk: None, on_volumes: None, … for every non-CLI-backed field risks breaking every time a new filter field is added to the struct. Consider spreading ..Default::default() to cover those fields instead.

♻️ Proposed refactor
 			include_hidden: Some(args.include_hidden),
 			include_archived: Some(args.include_archived),
-			at_risk: None,
-			on_volumes: None,
-			not_on_volumes: None,
-			min_volume_count: None,
-			max_volume_count: None,
+			..Default::default()
 		};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/domains/search/args.rs` around lines 165 - 228, The
SearchFilters struct is being fully enumerated and manually sets many fields to
None, which will break when new fields are added; instead construct
SearchFilters using the fields you need (file_types, tags, date_range,
size_range, locations, content_types, include_hidden, include_archived) and use
..Default::default() to fill the rest so future additions are covered; update
the initializer around SearchFilters (the block that builds tags via TagFilter,
date_range via DateRangeFilter, size_range via SizeRangeFilter, and
content_types mapping from ContentTypeArg to ContentKind) to only set the
required fields and append ..Default::default() at the end.
core/src/library/mod.rs (1)

1259-1272: Duplicate visibility predicate across instance and static capacity paths.

The same "user-visible + should_hide_by_mount_path" filter closure is now repeated in both calculate_volume_capacity and calculate_volume_capacity_static. Future tweaks to the predicate (e.g., a new is_system_volume_type check) will need to be mirrored in two places or the two paths will silently drift. Consider extracting a small helper like fn is_capacity_eligible(vol: &volume::Model) -> bool so both capacity calculators call the same predicate.

♻️ Proposed refactor sketch
fn is_capacity_eligible(vol: &crate::infra::db::entities::volume::Model) -> bool {
    let volume_type = vol.volume_type.as_deref().unwrap_or("Unknown");
    matches!(volume_type, "Primary" | "UserData" | "External" | "Secondary")
        && vol.is_user_visible.unwrap_or(true)
        && !vol
            .mount_point
            .as_deref()
            .map(std::path::Path::new)
            .map(crate::volume::utils::should_hide_by_mount_path)
            .unwrap_or(false)
}

Then both call sites become volumes.into_iter().filter(Self::is_capacity_eligible).collect().

Also applies to: 1686-1699

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/library/mod.rs` around lines 1259 - 1272, Extract the duplicated
visibility predicate into a single helper function (e.g., fn
is_capacity_eligible(vol: &crate::infra::db::entities::volume::Model) -> bool)
and have both calculate_volume_capacity and calculate_volume_capacity_static
call that helper instead of inlining the closure; implement the helper to
combine the existing checks (volume_type inclusion if needed,
vol.is_user_visible.unwrap_or(true), and the mount_point ->
should_hide_by_mount_path check) and replace the .filter(|vol| { ... }) closures
in both calculate_volume_capacity and calculate_volume_capacity_static with
.filter(Self::is_capacity_eligible) (or the appropriate path to the helper) so
future predicate changes are made in one place.
apps/cli/src/domains/volume/mod.rs (1)

110-126: Duplicated format_bytes helper — consider extracting to a shared util.

apps/cli/src/main.rs (lines 8–23) already defines a nearly identical format_bytes function (differs only in decimal precision and inclusion of "PB"). Consider moving a single implementation into apps/cli/src/util/ (e.g., util/format.rs) and consuming it from both call sites to avoid drift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/domains/volume/mod.rs` around lines 110 - 126, The project
contains duplicated format_bytes implementations; extract a single shared helper
and update both callers to use it: create a new util module (e.g., util::format
or util::format_bytes) that exports a format_bytes function (preserving the
desired unit list UNITS and formatting behavior), replace the local format_bytes
in apps/cli/src/domains/volume/mod.rs and the other copy in apps/cli/src/main.rs
to call the shared util, and update imports/uses accordingly so both call sites
use the same implementation to prevent drift.
packages/interface/src/routes/redundancy/index.tsx (2)

54-93: Duplicated TopBarPortal block between loading and loaded branches.

The same portal/title markup is rendered in both branches. Hoist it above the isLoading guard so only the body differs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/interface/src/routes/redundancy/index.tsx` around lines 54 - 93,
Hoist the duplicated TopBarPortal/TopBarItem JSX out of the isLoading guard:
move the TopBarPortal block that uses topBarTitle so it renders unconditionally
above the existing isLoading || !data check, then simplify the conditional
branches (the loading branch and the data-rendering branch in the redundancy
component) to only return their respective body content (loading spinner/message
or the rendered data) without repeating TopBarPortal; update references to
isLoading, data, TopBarPortal, TopBarItem, and topBarTitle accordingly.

32-52: Unnecessary useMemo calls.

scorePercent is a trivial Math.round(... * 100) and topBarTitle has no dependencies; neither warrants memoization. Per guidelines, reserve useMemo for genuinely expensive computations.

As per coding guidelines: "Use useMemo only when actually needed for expensive computations, not for simple values like string concatenation".

♻️ Proposed cleanup
-	const scorePercent = useMemo(() => {
-		if (!data) return 0;
-		return Math.round(data.library_totals.replication_score * 100);
-	}, [data]);
+	const scorePercent = data
+		? Math.round(data.library_totals.replication_score * 100)
+		: 0;
@@
-	const topBarTitle = useMemo(
-		() => (
-			<div className="flex items-center gap-2">
-				<ShieldCheck size={20} weight="bold" className="text-ink" />
-				<h1 className="text-xl font-bold text-ink">Redundancy</h1>
-			</div>
-		),
-		[],
-	);
+	const topBarTitle = (
+		<div className="flex items-center gap-2">
+			<ShieldCheck size={20} weight="bold" className="text-ink" />
+			<h1 className="text-xl font-bold text-ink">Redundancy</h1>
+		</div>
+	);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/interface/src/routes/redundancy/index.tsx` around lines 32 - 52,
Remove unnecessary useMemo wrappers: the scorePercent computation and
topBarTitle JSX are trivial and do not need memoization. Replace const
scorePercent = useMemo(..., [data]) with a plain computed value using data
(e.g., const scorePercent = !data ? 0 :
Math.round(data.library_totals.replication_score * 100)), and replace const
topBarTitle = useMemo(() => (...), []) with a plain constant JSX assignment
(e.g., const topBarTitle = <div.../>). Keep the existing variable names
scorePercent and topBarTitle and remove the useMemo import if no longer used.
packages/interface/src/routes/redundancy/compare.tsx (2)

106-113: Prefer event-handler driven filtered-mode sync over an effect.

Per guidelines, effects shouldn't be used to update parent component state; call enterFilteredMode/exitFilteredMode directly from the setVolumeA/setVolumeB/setMode handlers (you already derive filters synchronously). The unmount cleanup at Line 125-127 is the only effect you actually need here.

As per coding guidelines: "Never use Effects to update parent component state - call callback in event handler instead for same render pass".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/interface/src/routes/redundancy/compare.tsx` around lines 106 - 113,
The useEffect that calls enterFilteredMode/exitFilteredMode based on filters
should be removed and those callbacks invoked directly from the event handlers
that change filter inputs; update setVolumeA, setVolumeB and setMode handlers to
call enterFilteredMode(filters, label) when filters exist and exitFilteredMode()
when filters are cleared (ensuring you pass the same `filters`/`label` values
you already derive), and keep only the existing unmount cleanup effect (the
cleanup that runs on unmount) in the component.

37-43: formatBytes is duplicated across redundancy files.

Same helper appears in compare.tsx, index.tsx, and components/RedundancyVolumeBar.tsx. Extract into a shared util (e.g., packages/interface/src/routes/redundancy/utils.ts or @sd/ui if broadly useful) to keep units/formatting consistent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/interface/src/routes/redundancy/compare.tsx` around lines 37 - 43,
formatBytes is duplicated; extract the function into a single shared utility and
import it where needed. Create a new util module (e.g., redundancy/utils or a
shared UI utils) that exports formatBytes, move the logic from the local
formatBytes implementations into that exported function, update callers in
compare.tsx, index.tsx, and components/RedundancyVolumeBar.tsx to import and use
the shared formatBytes, and remove the duplicate local definitions so formatting
logic remains consistent across components.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/cli/src/domains/redundancy/mod.rs`:
- Around line 99-105: CompareMode::Shared currently builds a single on_volumes
predicate with min_volume_count=2 which uses OR semantics and can match files
present on A and any other volume (not necessarily B); change the logic for
CompareMode::Shared to require AND semantics by generating two separate
on_volumes predicates (one for args.volume_a and one for args.volume_b) or by
constructing a query that enforces both volumes (e.g., two EXISTS/joins or an
INTERSECT on file UUIDs) instead of relying on min_volume_count; update the code
paths that set on_volumes and min_volume_count for CompareMode::Shared so the
SQL ensures presence on both volumes explicitly rather than OR + count.

In `@core/src/volume/fs/zfs.rs`:
- Around line 434-449: The pool-root override can set volume.total_capacity to 0
when parse_zfs_size fails; compute pool_total =
dataset_info.used_bytes.saturating_add(dataset_info.available_bytes) and only
perform the debug/log and assignments to volume.total_capacity and
volume.available_space when pool_total > 0 (i.e., the sum of
dataset_info.used_bytes and dataset_info.available_bytes is non-zero) in the
same block that currently handles dataset_info.name == dataset_info.pool_name;
this prevents replacing the df-derived capacity with a zero value (see symbols:
dataset_info, pool_total, volume.total_capacity, volume.available_space,
used_bytes, available_bytes, and the parsing site in
find_dataset_for_path/parse_zfs_size).

In `@docs/core/filesystems.mdx`:
- Around line 187-195: Update the descriptive count to match the enumerated
steps in the comment for calculate_volume_capacity (and _static) in
core::library::mod: replace "three passes" with "five steps" (or alternatively
reword to "the following passes" if you prefer not to enumerate) so the prose
and the numbered list are consistent; ensure the symbols
calculate_volume_capacity and _static are unchanged and only the surrounding
documentation text is edited.

In `@packages/interface/src/routes/redundancy/compare.tsx`:
- Around line 253-255: The JSX className string uses the raw Tailwind color
"text-white" when active; replace that with a semantic color token per the
semantic-color rule (for example use "text-ink" or your project's
accent-foreground semantic token) in the ternary branch that reads active ?
"bg-accent text-white" so the active state becomes active ? "bg-accent
<semantic-token>" (locate the className template in compare.tsx where the
variable active is used).

---

Duplicate comments:
In `@packages/interface/src/routes/redundancy/compare.tsx`:
- Around line 84-89: The "shared" branch currently builds filters using
on_volumes + min_volume_count which yields false positives (case "shared",
EMPTY_FILTERS, on_volumes, min_volume_count); change the client payload to
express an intersection rather than an any-of: replace on_volumes +
min_volume_count with a dedicated intersection field (e.g., on_volumes_all or
shared_between) set to [volumeA!, volumeB!], and update the server/filter logic
to treat that field as "all-of" (or alternatively add a min_count scoped to
on_volumes like min_volume_count_on_volumes: 2) so only files present on both A
and B are returned.

---

Nitpick comments:
In `@apps/cli/src/domains/search/args.rs`:
- Around line 165-228: The SearchFilters struct is being fully enumerated and
manually sets many fields to None, which will break when new fields are added;
instead construct SearchFilters using the fields you need (file_types, tags,
date_range, size_range, locations, content_types, include_hidden,
include_archived) and use ..Default::default() to fill the rest so future
additions are covered; update the initializer around SearchFilters (the block
that builds tags via TagFilter, date_range via DateRangeFilter, size_range via
SizeRangeFilter, and content_types mapping from ContentTypeArg to ContentKind)
to only set the required fields and append ..Default::default() at the end.

In `@apps/cli/src/domains/volume/mod.rs`:
- Around line 110-126: The project contains duplicated format_bytes
implementations; extract a single shared helper and update both callers to use
it: create a new util module (e.g., util::format or util::format_bytes) that
exports a format_bytes function (preserving the desired unit list UNITS and
formatting behavior), replace the local format_bytes in
apps/cli/src/domains/volume/mod.rs and the other copy in apps/cli/src/main.rs to
call the shared util, and update imports/uses accordingly so both call sites use
the same implementation to prevent drift.

In `@core/src/library/mod.rs`:
- Around line 1259-1272: Extract the duplicated visibility predicate into a
single helper function (e.g., fn is_capacity_eligible(vol:
&crate::infra::db::entities::volume::Model) -> bool) and have both
calculate_volume_capacity and calculate_volume_capacity_static call that helper
instead of inlining the closure; implement the helper to combine the existing
checks (volume_type inclusion if needed, vol.is_user_visible.unwrap_or(true),
and the mount_point -> should_hide_by_mount_path check) and replace the
.filter(|vol| { ... }) closures in both calculate_volume_capacity and
calculate_volume_capacity_static with .filter(Self::is_capacity_eligible) (or
the appropriate path to the helper) so future predicate changes are made in one
place.

In `@packages/interface/src/routes/redundancy/compare.tsx`:
- Around line 106-113: The useEffect that calls
enterFilteredMode/exitFilteredMode based on filters should be removed and those
callbacks invoked directly from the event handlers that change filter inputs;
update setVolumeA, setVolumeB and setMode handlers to call
enterFilteredMode(filters, label) when filters exist and exitFilteredMode() when
filters are cleared (ensuring you pass the same `filters`/`label` values you
already derive), and keep only the existing unmount cleanup effect (the cleanup
that runs on unmount) in the component.
- Around line 37-43: formatBytes is duplicated; extract the function into a
single shared utility and import it where needed. Create a new util module
(e.g., redundancy/utils or a shared UI utils) that exports formatBytes, move the
logic from the local formatBytes implementations into that exported function,
update callers in compare.tsx, index.tsx, and components/RedundancyVolumeBar.tsx
to import and use the shared formatBytes, and remove the duplicate local
definitions so formatting logic remains consistent across components.

In `@packages/interface/src/routes/redundancy/index.tsx`:
- Around line 54-93: Hoist the duplicated TopBarPortal/TopBarItem JSX out of the
isLoading guard: move the TopBarPortal block that uses topBarTitle so it renders
unconditionally above the existing isLoading || !data check, then simplify the
conditional branches (the loading branch and the data-rendering branch in the
redundancy component) to only return their respective body content (loading
spinner/message or the rendered data) without repeating TopBarPortal; update
references to isLoading, data, TopBarPortal, TopBarItem, and topBarTitle
accordingly.
- Around line 32-52: Remove unnecessary useMemo wrappers: the scorePercent
computation and topBarTitle JSX are trivial and do not need memoization. Replace
const scorePercent = useMemo(..., [data]) with a plain computed value using data
(e.g., const scorePercent = !data ? 0 :
Math.round(data.library_totals.replication_score * 100)), and replace const
topBarTitle = useMemo(() => (...), []) with a plain constant JSX assignment
(e.g., const topBarTitle = <div.../>). Keep the existing variable names
scorePercent and topBarTitle and remove the useMemo import if no longer used.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 8e3be43c-3553-4be0-ac90-bb5d31d3dfef

📥 Commits

Reviewing files that changed from the base of the PR and between 1eea61b and e05db09.

⛔ Files ignored due to path filters (3)
  • docs/docs.json is excluded by !**/*.json
  • docs/mint.json is excluded by !**/*.json
  • packages/ts-client/src/generated/types.ts is excluded by !**/generated/**, !**/generated/**
📒 Files selected for processing (25)
  • apps/cli/src/domains/mod.rs
  • apps/cli/src/domains/redundancy/args.rs
  • apps/cli/src/domains/redundancy/mod.rs
  • apps/cli/src/domains/search/args.rs
  • apps/cli/src/domains/volume/mod.rs
  • apps/cli/src/main.rs
  • core/src/domain/space.rs
  • core/src/library/manager.rs
  • core/src/library/mod.rs
  • core/src/ops/search/filters.rs
  • core/src/ops/search/query.rs
  • core/src/ops/volumes/list/query.rs
  • core/src/volume/fs/zfs.rs
  • core/src/volume/utils.rs
  • docs/core/filesystems.mdx
  • packages/interface/src/components/SpacesSidebar/SpaceCustomizationPanel.tsx
  • packages/interface/src/components/SpacesSidebar/hooks/spaceItemUtils.ts
  • packages/interface/src/components/SpacesSidebar/index.tsx
  • packages/interface/src/routes/explorer/ExplorerView.tsx
  • packages/interface/src/routes/explorer/context.tsx
  • packages/interface/src/routes/explorer/hooks/useExplorerFiles.ts
  • packages/interface/src/routes/redundancy/at-risk.tsx
  • packages/interface/src/routes/redundancy/compare.tsx
  • packages/interface/src/routes/redundancy/components/RedundancyVolumeBar.tsx
  • packages/interface/src/routes/redundancy/index.tsx
✅ Files skipped from review due to trivial changes (3)
  • apps/cli/src/domains/mod.rs
  • packages/interface/src/components/SpacesSidebar/index.tsx
  • packages/interface/src/routes/redundancy/at-risk.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/interface/src/routes/redundancy/components/RedundancyVolumeBar.tsx
  • core/src/ops/search/filters.rs

Comment on lines +99 to +105
CompareMode::Shared => (
Some(vec![args.volume_a, args.volume_b]),
None,
Some(2u32),
"shared",
),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm on_volumes uses IN (...) (OR semantics) and there's no AND-of-volumes filter.
rg -nP -C3 'fn on_volumes|fn not_on_volumes|fn min_volume_count' --type=rust
rg -nP -C2 'v\.uuid IN' --type=rust

Repository: spacedriveapp/spacedrive

Length of output: 2202


🏁 Script executed:

# Get full on_volumes implementation to see condition chaining
sed -n '135,156p' core/src/ops/search/filters.rs

Repository: spacedriveapp/spacedrive

Length of output: 640


🏁 Script executed:

# Check for any AND-based volume filters or on_all_volumes
rg 'on_all_volumes|all_volumes|AND.*uuid|AND.*volume' -C2 --type=rust core/src/ops/search/

Repository: spacedriveapp/spacedrive

Length of output: 1849


🏁 Script executed:

# View the full redundancy mod.rs to see all compare modes and filter combinations
sed -n '85,115p' apps/cli/src/domains/redundancy/mod.rs

Repository: spacedriveapp/spacedrive

Length of output: 794


🏁 Script executed:

# Check how SearchFilters applies these filters and verify AND chaining
rg -nP 'impl.*SearchFilters|fn apply_filters|apply.*on_volumes.*min_volume' -A5 --type=rust core/src/ops/search/

Repository: spacedriveapp/spacedrive

Length of output: 560


🏁 Script executed:

# Look for tests of the compare/shared mode functionality
rg -nP 'test.*shared|test.*compare|SharedContent' -B2 -A8 --type=rust

Repository: spacedriveapp/spacedrive

Length of output: 5070


🏁 Script executed:

# Check if there's a struct or method that combines on_volumes
fd -name "*.rs" -type f | xargs grep -l "SearchFilters" | head -5 | xargs -I {} sh -c "echo '===== {} =====' && head -100 {}"

Repository: spacedriveapp/spacedrive

Length of output: 298


🏁 Script executed:

# Get the full apply_filters implementation to see how filters chain
sed -n '583,650p' core/src/ops/search/query.rs

Repository: spacedriveapp/spacedrive

Length of output: 2578


🏁 Script executed:

# Check if there are any comments in redundancy/mod.rs explaining Shared mode logic
sed -n '75,120p' apps/cli/src/domains/redundancy/mod.rs | head -50

Repository: spacedriveapp/spacedrive

Length of output: 1111


Shared mode can return files that are not actually on both volumes.

on_volumes uses OR semantics at the SQL layer (v.uuid IN (...) means "present on any of these volumes"), so on_volumes=[A, B] with min_volume_count=2 will match a file that exists on {A, C} (on A ✓, on 2 volumes ✓) and report it as shared, even though it does not exist on B. To correctly identify content present on both A and B, you need AND semantics—e.g., two separate on_volumes predicates—rather than OR + volume count. As written, the CLI will over-report the shared set whenever a third volume exists.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/domains/redundancy/mod.rs` around lines 99 - 105,
CompareMode::Shared currently builds a single on_volumes predicate with
min_volume_count=2 which uses OR semantics and can match files present on A and
any other volume (not necessarily B); change the logic for CompareMode::Shared
to require AND semantics by generating two separate on_volumes predicates (one
for args.volume_a and one for args.volume_b) or by constructing a query that
enforces both volumes (e.g., two EXISTS/joins or an INTERSECT on file UUIDs)
instead of relying on min_volume_count; update the code paths that set
on_volumes and min_volume_count for CompareMode::Shared so the SQL ensures
presence on both volumes explicitly rather than OR + count.

Comment thread core/src/volume/fs/zfs.rs
Comment on lines +434 to +449
if dataset_info.name == dataset_info.pool_name {
let pool_total = dataset_info
.used_bytes
.saturating_add(dataset_info.available_bytes);
debug!(
"ZFS pool root '{}' at {}: overriding total_capacity {} → {} (used={}, avail={})",
dataset_info.pool_name,
mount_point,
volume.total_capacity,
pool_total,
dataset_info.used_bytes,
dataset_info.available_bytes,
);
volume.total_capacity = pool_total;
volume.available_space = dataset_info.available_bytes;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against zero pool totals from malformed zfs list parsing.

parse_zfs_size silently falls back to 0 on parse failure (via unwrap_or(0) in find_dataset_for_path). If that happens for a pool-root dataset, this branch will overwrite the df-derived total_capacity with 0, which is strictly worse than the original value. Consider only applying the override when pool_total > 0 (or at least non-zero used_bytes + available_bytes).

🛡️ Proposed fix
 			if dataset_info.name == dataset_info.pool_name {
 				let pool_total = dataset_info
 					.used_bytes
 					.saturating_add(dataset_info.available_bytes);
-				debug!(
-					"ZFS pool root '{}' at {}: overriding total_capacity {} → {} (used={}, avail={})",
-					dataset_info.pool_name,
-					mount_point,
-					volume.total_capacity,
-					pool_total,
-					dataset_info.used_bytes,
-					dataset_info.available_bytes,
-				);
-				volume.total_capacity = pool_total;
-				volume.available_space = dataset_info.available_bytes;
+				if pool_total > 0 {
+					debug!(
+						"ZFS pool root '{}' at {}: overriding total_capacity {} → {} (used={}, avail={})",
+						dataset_info.pool_name,
+						mount_point,
+						volume.total_capacity,
+						pool_total,
+						dataset_info.used_bytes,
+						dataset_info.available_bytes,
+					);
+					volume.total_capacity = pool_total;
+					volume.available_space = dataset_info.available_bytes;
+				}
 			}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if dataset_info.name == dataset_info.pool_name {
let pool_total = dataset_info
.used_bytes
.saturating_add(dataset_info.available_bytes);
debug!(
"ZFS pool root '{}' at {}: overriding total_capacity {} → {} (used={}, avail={})",
dataset_info.pool_name,
mount_point,
volume.total_capacity,
pool_total,
dataset_info.used_bytes,
dataset_info.available_bytes,
);
volume.total_capacity = pool_total;
volume.available_space = dataset_info.available_bytes;
}
if dataset_info.name == dataset_info.pool_name {
let pool_total = dataset_info
.used_bytes
.saturating_add(dataset_info.available_bytes);
if pool_total > 0 {
debug!(
"ZFS pool root '{}' at {}: overriding total_capacity {} → {} (used={}, avail={})",
dataset_info.pool_name,
mount_point,
volume.total_capacity,
pool_total,
dataset_info.used_bytes,
dataset_info.available_bytes,
);
volume.total_capacity = pool_total;
volume.available_space = dataset_info.available_bytes;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/volume/fs/zfs.rs` around lines 434 - 449, The pool-root override can
set volume.total_capacity to 0 when parse_zfs_size fails; compute pool_total =
dataset_info.used_bytes.saturating_add(dataset_info.available_bytes) and only
perform the debug/log and assignments to volume.total_capacity and
volume.available_space when pool_total > 0 (i.e., the sum of
dataset_info.used_bytes and dataset_info.available_bytes is non-zero) in the
same block that currently handles dataset_info.name == dataset_info.pool_name;
this prevents replacing the df-derived capacity with a zero value (see symbols:
dataset_info, pool_total, volume.total_capacity, volume.available_space,
used_bytes, available_bytes, and the parsing site in
find_dataset_for_path/parse_zfs_size).

Comment thread docs/core/filesystems.mdx
Comment on lines +187 to +195
`calculate_volume_capacity` (and `_static`) in `core/src/library/mod.rs` aggregates per-volume capacity with three passes:

1. Filter by `volume_type` (`Primary`, `UserData`, `External`, `Secondary`).
2. Filter by visibility (`is_user_visible = true` *and* `!should_hide_by_mount_path(mount)`).
3. Deduplicate by fingerprint.
4. Sort by mount-path length (shortest first).
5. For each volume: skip if it's a subpath of an already-counted volume on the same device; otherwise add its capacity to the running totals.

Subpath dedup handles the common ZFS case: when `/mnt/pool` is tracked along with `/mnt/pool/footage` and `/mnt/pool/cctv`, only `/mnt/pool` gets counted (once).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

"three passes" doesn't match the numbered list.

The prose says three passes but the list enumerates five steps. Either update the count (to "five steps") or group/collapse the list.

✏️ Proposed fix
-`calculate_volume_capacity` (and `_static`) in `core/src/library/mod.rs` aggregates per-volume capacity with three passes:
+`calculate_volume_capacity` (and `_static`) in `core/src/library/mod.rs` aggregates per-volume capacity in the following steps:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
`calculate_volume_capacity` (and `_static`) in `core/src/library/mod.rs` aggregates per-volume capacity with three passes:
1. Filter by `volume_type` (`Primary`, `UserData`, `External`, `Secondary`).
2. Filter by visibility (`is_user_visible = true` *and* `!should_hide_by_mount_path(mount)`).
3. Deduplicate by fingerprint.
4. Sort by mount-path length (shortest first).
5. For each volume: skip if it's a subpath of an already-counted volume on the same device; otherwise add its capacity to the running totals.
Subpath dedup handles the common ZFS case: when `/mnt/pool` is tracked along with `/mnt/pool/footage` and `/mnt/pool/cctv`, only `/mnt/pool` gets counted (once).
`calculate_volume_capacity` (and `_static`) in `core/src/library/mod.rs` aggregates per-volume capacity in the following steps:
1. Filter by `volume_type` (`Primary`, `UserData`, `External`, `Secondary`).
2. Filter by visibility (`is_user_visible = true` *and* `!should_hide_by_mount_path(mount)`).
3. Deduplicate by fingerprint.
4. Sort by mount-path length (shortest first).
5. For each volume: skip if it's a subpath of an already-counted volume on the same device; otherwise add its capacity to the running totals.
Subpath dedup handles the common ZFS case: when `/mnt/pool` is tracked along with `/mnt/pool/footage` and `/mnt/pool/cctv`, only `/mnt/pool` gets counted (once).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/core/filesystems.mdx` around lines 187 - 195, Update the descriptive
count to match the enumerated steps in the comment for calculate_volume_capacity
(and _static) in core::library::mod: replace "three passes" with "five steps"
(or alternatively reword to "the following passes" if you prefer not to
enumerate) so the prose and the numbered list are consistent; ensure the symbols
calculate_volume_capacity and _static are unchanged and only the surrounding
documentation text is edited.

Comment on lines +253 to +255
className={`flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
active ? "bg-accent text-white" : "text-ink-dull hover:text-ink"
}`}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use a semantic color instead of text-white.

Per the semantic-color rule, avoid raw Tailwind palette classes. Swap text-white for a semantic token (e.g., text-ink or an accent-paired ink variant).

As per coding guidelines: "Use semantic Tailwind color classes only - never use arbitrary gray values... use bg-app or bg-sidebar instead".

🎨 Proposed fix
-				active ? "bg-accent text-white" : "text-ink-dull hover:text-ink"
+				active ? "bg-accent text-ink" : "text-ink-dull hover:text-ink"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
className={`flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
active ? "bg-accent text-white" : "text-ink-dull hover:text-ink"
}`}
className={`flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
active ? "bg-accent text-ink" : "text-ink-dull hover:text-ink"
}`}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/interface/src/routes/redundancy/compare.tsx` around lines 253 - 255,
The JSX className string uses the raw Tailwind color "text-white" when active;
replace that with a semantic color token per the semantic-color rule (for
example use "text-ink" or your project's accent-foreground semantic token) in
the ternary branch that reads active ? "bg-accent text-white" so the active
state becomes active ? "bg-accent <semantic-token>" (locate the className
template in compare.tsx where the variable active is used).

jamiepine and others added 4 commits April 16, 2026 21:21
- New WebContextMenuProvider + Radix DropdownMenu-based renderer anchored
  at cursor via a 1x1 virtual trigger. Handles separators, submenus,
  disabled, and the danger variant via text-status-error.
- useContextMenu now routes web clicks through the provider instead of
  parking data in unused local state, and trims leading/trailing/adjacent
  separators so condition-filtered menus don't render orphaned lines.
- Drop app-frame corner rounding on the web build.
- Add shrink-0 to the sidebar space switcher so the scrollable sibling
  can't compress it vertically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…a dir

- build.rs runs `bun run build` in apps/web so `just dev-server` always
  embeds the latest UI. rerun-if-changed covers apps/web/src,
  packages/interface/src, and packages/ts-client/src so Rust-only edits
  skip the rebuild. Skips gracefully when bun isn't on PATH or
  SD_SKIP_WEB_BUILD is set; Dockerfile sets the latter since dist is
  pre-built and bun isn't in the Rust stage.
- Graceful shutdown was hanging because the browser holds the /events
  SSE stream open forever and axum waits for all connections to drain.
  After the first signal, arm a background force-exit on second Ctrl+C
  or 5s timeout so the process can't stick.
- Debug builds were starting from a fresh tempfile::tempdir() on every
  run (the TempDir handle dropped at end of the closure, deleting the
  dir we just took a path to). Default to ~/.spacedrive in debug so data
  persists and `just dev-server` shares a data dir with the Tauri app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Uses zig cc as C/C++ compiler on TrueNAS Scale where /usr is
read-only and no system gcc exists. Dev tools live at
/mnt/pool/dev-tools/ (zig, cmake, make, extracted deb headers).

Builds sd-server + sd-cli in ~4 min on a 12-core NAS. AI feature
disabled (whisper.cpp C11 atomics incompatible with zig clang-18).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (6)
packages/interface/src/contexts/WebContextMenuContext.tsx (2)

33-41: show() adds a frame of latency on the cold path.

The setState(null)requestAnimationFrame(…setState(new)) dance is only needed to force Radix to re-anchor when a menu is already open. On the common cold-open path (state is already null), this still delays the menu by one animation frame and can feel laggy on right-click. Consider branching: open immediately when state === null, and only null-then-rAF when replacing an existing menu.

♻️ Proposed fix
 	const show = useCallback(
 		(items: ContextMenuItem[], x: number, y: number) => {
-			// If a menu is already open, close it first so Radix re-anchors
-			// at the new cursor position on the next tick.
-			setState(null);
-			requestAnimationFrame(() => setState({ items, x, y }));
+			setState((prev) => {
+				if (prev === null) return { items, x, y };
+				// A menu is already open; close it so Radix re-anchors at
+				// the new cursor position on the next frame.
+				requestAnimationFrame(() => setState({ items, x, y }));
+				return null;
+			});
 		},
 		[],
 	);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/interface/src/contexts/WebContextMenuContext.tsx` around lines 33 -
41, The show() callback adds an unnecessary animation-frame delay on cold opens;
change its logic in WebContextMenuContext's show function to check the current
state and only perform the null-then-requestAnimationFrame dance when replacing
an open menu—i.e., if state === null call setState({ items, x, y }) immediately,
otherwise call setState(null) and requestAnimationFrame(() => setState({ items,
x, y })); keep the same parameters and use the existing setState and state
identifiers.

67-79: Prefer Tailwind for the static trigger styles; keep inline only for dynamic coords.

Per the project guideline against inline style tags, the fixed positioning, 1×1 box, and pointer-events: none on the trigger can all move to utility classes; only left/top truly need to be inline because they're dynamic.

♻️ Proposed fix
-			<DropdownMenu.Trigger asChild>
-				<span
-					aria-hidden
-					style={{
-						position: "fixed",
-						left: state?.x ?? 0,
-						top: state?.y ?? 0,
-						width: 1,
-						height: 1,
-						pointerEvents: "none",
-					}}
-				/>
-			</DropdownMenu.Trigger>
+			<DropdownMenu.Trigger asChild>
+				<span
+					aria-hidden
+					className="pointer-events-none fixed size-px"
+					style={{ left: state?.x ?? 0, top: state?.y ?? 0 }}
+				/>
+			</DropdownMenu.Trigger>

As per coding guidelines: "Never use <style>, <style jsx>, or inline style tags - always use Tailwind utility classes instead".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/interface/src/contexts/WebContextMenuContext.tsx` around lines 67 -
79, The span used as the DropdownMenu.Trigger should move all static CSS into
Tailwind classes and keep only the dynamic left/top inline: replace the inline
style block on the span with a className like "fixed w-px h-px
pointer-events-none" (or "w-[1px] h-[1px]" if you prefer) and leave style={{
left: state?.x ?? 0, top: state?.y ?? 0 }} to set coordinates; keep aria-hidden
and the surrounding DropdownMenu.Trigger unchanged so only the static
positioning/size/pointer-events are migrated to Tailwind.
packages/interface/src/hooks/useContextMenu.ts (1)

59-76: collapseSeparators logic looks solid.

Leading/trailing trimming and run collapsing are handled correctly, and submenu recursion matches what WebContextMenu.renderItems expects. One small note: if a submenu item's submenu collapses to [] after filtering, renderItems will fall through to rendering it as a normal Item (its length > 0 check), which will make it look clickable even though it was meant to be a parent. Probably fine for now, but worth dropping such items if you see it in practice.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/interface/src/hooks/useContextMenu.ts` around lines 59 - 76,
collapseSeparators can return an empty submenu which later causes
WebContextMenu.renderItems to render the parent as a clickable Item; update
collapseSeparators so that when handling an item with item.submenu you compute
const collapsed = collapseSeparators(item.submenu) and only push { ...item,
submenu: collapsed } if collapsed.length > 0, otherwise skip that parent item
entirely; reference collapseSeparators and WebContextMenu.renderItems to locate
the behavior.
build-server.sh (2)

6-6: Prefer set -euo pipefail.

set -e alone won't catch unset variables ($SR typos) or failures inside pipelines. For a build script that exports a lot of environment variables and then shells out to Cargo, set -euo pipefail is the safer default.

♻️ Proposed fix
-set -e
+set -euo pipefail
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@build-server.sh` at line 6, Replace the current bash safety setting "set -e"
in build-server.sh with the stronger "set -euo pipefail" to fail on unset
variables and pipeline errors; update the top-level shell invocation or the
block where "set -e" appears (search for the literal set -e) to use set -euo
pipefail so the script aborts on unset vars (-u) and pipeline failures (-o
pipefail).

1-24: Is this environment-specific script intended to land in the repo?

Every path in this script is hardcoded to a single host (/mnt/pool/dev-tools, /mnt/pool/spacedrive, /mnt/pool/dev-tools/sysroot, -j10). It's clearly useful on one specific TrueNAS box, but as committed it won't work for any other contributor and will rot silently when those paths change.

A few options worth considering:

  • Move it under scripts/ (or similar) and parameterize the paths with env vars (SPACEDRIVE_SYSROOT, SPACEDRIVE_SRC) with sensible defaults, so the script is reusable.
  • Keep it out of the repo and place it in dotfiles/ops tooling on the host.
  • At minimum, rename to something scoped (build-server-truenas.sh) and document the assumption in the header.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@build-server.sh` around lines 1 - 24, The script build-server.sh contains
host-specific hardcoded paths and flags (e.g. SR=/mnt/pool/dev-tools/sysroot,
PATH entries, CC/CXX/AR exports, OPENSSL_* dirs, cd /mnt/pool/spacedrive and the
-j10 in the cargo build invocation) that will break for other contributors; make
the script reusable by parameterizing these values via environment variables
with sensible defaults (e.g. SPACEDRIVE_SYSROOT, SPACEDRIVE_DEVTOOLS,
SPACEDRIVE_SRC, BUILD_JOBS) and update uses of SR, PATH, CC/CXX/AR,
OPENSSL_INCLUDE_DIR/OPENSSL_LIB_DIR, and the cargo build -j invocation to
reference those variables; additionally either move the file to scripts/ or
rename it to reflect its TrueNAS scope (e.g. build-server-truenas.sh) and add a
header comment documenting the assumptions if you keep it in the repo.
apps/server/build.rs (1)

9-12: Consider validating apps/web/dist when the web build is skipped.

Both the SD_SKIP_WEB_BUILD branch and the "bun not found" branch exit without checking that apps/web/dist/ (or at least dist/index.html) actually exists. If it doesn't, rust_embed's #[folder = "../web/dist/"] on WebAssets will still compile — it just yields an empty asset set — and the server will happily start and return the "Spacedrive web UI is not bundled in this build" 404 from serve_web for every request.

A quick repo_root.join("apps/web/dist/index.html").exists() guard emitting cargo:warning= (or panicking) would turn that silent misconfiguration into a build-time signal — especially valuable for the Docker stage where forgetting the COPY apps/web/dist ./apps/web/dist step would otherwise only be caught at runtime.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/build.rs` around lines 9 - 12, When skipping the web build
(env::var_os("SD_SKIP_WEB_BUILD").is_some()) or when the "bun not found" branch
returns early, add a filesystem check using
repo_root.join("apps/web/dist/index.html").exists() (or at least
repo_root.join("apps/web/dist").exists()) and emit a clear build-time signal
(println!("cargo:warning=...") or panic!) if the file/dir is missing so
rust_embed's WebAssets (#[folder = "../web/dist/"]) doesn't silently compile
with empty assets and serve_web later returns the 404; update both the
SD_SKIP_WEB_BUILD branch and the bun-not-found early-exit path to perform this
validation and log/panic accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/server/build.rs`:
- Around line 16-19: The bun availability check currently only treats process
spawn errors as missing; change the check that calls
Command::new("bun").arg("--version").output() so it handles both Err and
non-successful ExitStatus: capture the Result from output(), and if it is Err OR
the returned output.status.success() is false, log the same cargo:warning and
return (so a non-zero exit or broken bun falls back to apps/web/dist). Update
the code around Command::new("bun").arg("--version").output() to perform this
combined check and include the output or status in the warning if you want more
context.

In `@apps/server/src/main.rs`:
- Around line 547-560: The forced-exit spawned task currently calls
std::process::exit(0) for both the second Ctrl+C and the 5s timeout, which
should instead signal abnormal termination; update the tokio::spawn block (the
code using tokio::spawn, signal::ctrl_c, tokio::time::sleep and
Duration::from_secs) so that the branches that represent abnormal termination
(the second signal branch and the timeout branch) call std::process::exit with a
non-zero status (e.g. exit(1)), leaving normal graceful shutdown behavior
unchanged.
- Around line 315-319: The closure that creates the fallback temp dir drops the
TempDir guard (temp) and returns only a PathBuf, leaving base_data_dir pointing
at a deleted directory; fix by converting the TempDir into a persistent path
before returning so the guard isn't dropped — e.g., inside the unwrap_or_else
closure call TempDir::into_path() (or TempDir::keep()/persist() if your tempfile
version uses that) on temp and return that resulting PathBuf; update the closure
that binds temp (the TempDir) so it is consumed into a persistent path and
handle any errors from persist/keep accordingly so base_data_dir remains valid
for subsequent file operations.

In `@build-server.sh`:
- Around line 2-4: The header comment incorrectly claims "Uses zig cc as C
compiler" while the script exports CC and CXX as /mnt/pool/dev-tools/cc and
/mnt/pool/dev-tools/c++; update the header to accurately describe the actual
compiler setup: either state that CC and CXX point to local wrapper scripts that
invoke zig cc (if they are wrappers) or remove/replace the zig mention and say
the script uses the compilers at CC=/mnt/pool/dev-tools/cc and
CXX=/mnt/pool/dev-tools/c++; reference the exported variables CC and CXX in the
revised comment to make the relationship clear.

In `@packages/interface/src/hooks/useContextMenu.ts`:
- Line 147: Remove the unnecessary any cast when reading __SPACEDRIVE__ in
useContextMenu.ts: replace the line that assigns nativeShow from (window as
any).__SPACEDRIVE__?.showContextMenu with a plain
window.__SPACEDRIVE__?.showContextMenu (the Window interface in env.d.ts already
declares __SPACEDRIVE__), so nativeShow uses the typed window without using any.

---

Nitpick comments:
In `@apps/server/build.rs`:
- Around line 9-12: When skipping the web build
(env::var_os("SD_SKIP_WEB_BUILD").is_some()) or when the "bun not found" branch
returns early, add a filesystem check using
repo_root.join("apps/web/dist/index.html").exists() (or at least
repo_root.join("apps/web/dist").exists()) and emit a clear build-time signal
(println!("cargo:warning=...") or panic!) if the file/dir is missing so
rust_embed's WebAssets (#[folder = "../web/dist/"]) doesn't silently compile
with empty assets and serve_web later returns the 404; update both the
SD_SKIP_WEB_BUILD branch and the bun-not-found early-exit path to perform this
validation and log/panic accordingly.

In `@build-server.sh`:
- Line 6: Replace the current bash safety setting "set -e" in build-server.sh
with the stronger "set -euo pipefail" to fail on unset variables and pipeline
errors; update the top-level shell invocation or the block where "set -e"
appears (search for the literal set -e) to use set -euo pipefail so the script
aborts on unset vars (-u) and pipeline failures (-o pipefail).
- Around line 1-24: The script build-server.sh contains host-specific hardcoded
paths and flags (e.g. SR=/mnt/pool/dev-tools/sysroot, PATH entries, CC/CXX/AR
exports, OPENSSL_* dirs, cd /mnt/pool/spacedrive and the -j10 in the cargo build
invocation) that will break for other contributors; make the script reusable by
parameterizing these values via environment variables with sensible defaults
(e.g. SPACEDRIVE_SYSROOT, SPACEDRIVE_DEVTOOLS, SPACEDRIVE_SRC, BUILD_JOBS) and
update uses of SR, PATH, CC/CXX/AR, OPENSSL_INCLUDE_DIR/OPENSSL_LIB_DIR, and the
cargo build -j invocation to reference those variables; additionally either move
the file to scripts/ or rename it to reflect its TrueNAS scope (e.g.
build-server-truenas.sh) and add a header comment documenting the assumptions if
you keep it in the repo.

In `@packages/interface/src/contexts/WebContextMenuContext.tsx`:
- Around line 33-41: The show() callback adds an unnecessary animation-frame
delay on cold opens; change its logic in WebContextMenuContext's show function
to check the current state and only perform the null-then-requestAnimationFrame
dance when replacing an open menu—i.e., if state === null call setState({ items,
x, y }) immediately, otherwise call setState(null) and requestAnimationFrame(()
=> setState({ items, x, y })); keep the same parameters and use the existing
setState and state identifiers.
- Around line 67-79: The span used as the DropdownMenu.Trigger should move all
static CSS into Tailwind classes and keep only the dynamic left/top inline:
replace the inline style block on the span with a className like "fixed w-px
h-px pointer-events-none" (or "w-[1px] h-[1px]" if you prefer) and leave
style={{ left: state?.x ?? 0, top: state?.y ?? 0 }} to set coordinates; keep
aria-hidden and the surrounding DropdownMenu.Trigger unchanged so only the
static positioning/size/pointer-events are migrated to Tailwind.

In `@packages/interface/src/hooks/useContextMenu.ts`:
- Around line 59-76: collapseSeparators can return an empty submenu which later
causes WebContextMenu.renderItems to render the parent as a clickable Item;
update collapseSeparators so that when handling an item with item.submenu you
compute const collapsed = collapseSeparators(item.submenu) and only push {
...item, submenu: collapsed } if collapsed.length > 0, otherwise skip that
parent item entirely; reference collapseSeparators and
WebContextMenu.renderItems to locate the behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f8ab7e56-fadc-46aa-bfea-0e30f522fad6

📥 Commits

Reviewing files that changed from the base of the PR and between e05db09 and dad50ec.

⛔ Files ignored due to path filters (2)
  • Cargo.lock is excluded by !**/*.lock, !**/*.lock
  • apps/server/Cargo.toml is excluded by !**/*.toml
📒 Files selected for processing (9)
  • apps/server/Dockerfile
  • apps/server/build.rs
  • apps/server/src/main.rs
  • build-server.sh
  • core/src/library/manager.rs
  • packages/interface/src/ShellLayout.tsx
  • packages/interface/src/components/SpacesSidebar/SpaceSwitcher.tsx
  • packages/interface/src/contexts/WebContextMenuContext.tsx
  • packages/interface/src/hooks/useContextMenu.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/interface/src/components/SpacesSidebar/SpaceSwitcher.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • core/src/library/manager.rs

Comment thread apps/server/build.rs
Comment on lines +16 to +19
if Command::new("bun").arg("--version").output().is_err() {
println!("cargo:warning=bun not found on PATH — using existing apps/web/dist");
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

bun detection only catches spawn failures.

Command::new("bun").arg("--version").output().is_err() returns Err only if the OS failed to spawn the process (typically "not found on PATH"). If a bun executable is present but broken (shim that exits non-zero, wrong arch, permission denied after spawn, etc.), output() is Ok with an unsuccessful ExitStatus, the check passes, and the script will go on to call bun run build and panic later instead of falling back to the prebuilt dist. Consider also inspecting status.success() before treating bun as usable.

♻️ Proposed fix
-	if Command::new("bun").arg("--version").output().is_err() {
-		println!("cargo:warning=bun not found on PATH — using existing apps/web/dist");
-		return;
-	}
+	let bun_ok = Command::new("bun")
+		.arg("--version")
+		.output()
+		.map(|o| o.status.success())
+		.unwrap_or(false);
+	if !bun_ok {
+		println!("cargo:warning=bun not found or not runnable on PATH — using existing apps/web/dist");
+		return;
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if Command::new("bun").arg("--version").output().is_err() {
println!("cargo:warning=bun not found on PATH — using existing apps/web/dist");
return;
}
let bun_ok = Command::new("bun")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !bun_ok {
println!("cargo:warning=bun not found or not runnable on PATH — using existing apps/web/dist");
return;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/build.rs` around lines 16 - 19, The bun availability check
currently only treats process spawn errors as missing; change the check that
calls Command::new("bun").arg("--version").output() so it handles both Err and
non-successful ExitStatus: capture the Result from output(), and if it is Err OR
the returned output.status.success() is false, log the same cargo:warning and
return (so a non-zero exit or broken bun falls back to apps/web/dist). Update
the code around Command::new("bun").arg("--version").output() to perform this
combined check and include the output or status in the warning if you want more
context.

Comment thread apps/server/src/main.rs
Comment on lines 315 to 319
.unwrap_or_else(|_| {
warn!("Could not resolve home directory; falling back to tempdir");
let temp = tempfile::tempdir().expect("Failed to create temp dir");
temp.path().to_path_buf()
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

tempfile crate TempDir keep vs into_path API current method name

💡 Result:

In the current version of the tempfile crate (3.27.0 as of 2026-03-11), the TempDir struct provides methods to control automatic deletion of the temporary directory. - TempDir::keep(self) -> PathBuf: Persists the temporary directory to disk, consuming the TempDir and returning its PathBuf. The directory is no longer automatically deleted. - TempDir::into_path(self) -> PathBuf: Deprecated alias for TempDir::keep. Marked as deprecated in recent docs, use keep instead. - TempDir::disable_cleanup(&mut self, disable_cleanup: bool): Disables automatic cleanup in-place without consuming the TempDir. Primarily for testing/debugging; prefer keep to turn it into a non-temporary directory. Example usage: use tempfile::TempDir; use std::fs; let dir = TempDir::new?; // Use dir... let path = dir.keep; // Directory persists at path // or dir.into_path; // Deprecated, same effect keep vs into_path: keep is the current recommended method name; into_path is deprecated but still functional as an alias. Official documentation: https://docs.rs/tempfile/latest/tempfile/struct.TempDir.html (latest version confirmed via crates.io and docs.rs redirects).

Citations:


🏁 Script executed:

find . -name "main.rs" -path "*/apps/server/src/*" | head -5

Repository: spacedriveapp/spacedrive

Length of output: 93


🏁 Script executed:

# Find the main.rs file in apps/server
fd -t f "main.rs" | grep "apps/server/src"

Repository: spacedriveapp/spacedrive

Length of output: 91


🏁 Script executed:

sed -n '310,325p' apps/server/src/main.rs

Repository: spacedriveapp/spacedrive

Length of output: 574


🏁 Script executed:

sed -n '300,330p' apps/server/src/main.rs

Repository: spacedriveapp/spacedrive

Length of output: 1151


🏁 Script executed:

sed -n '280,340p' apps/server/src/main.rs

Repository: spacedriveapp/spacedrive

Length of output: 1956


🏁 Script executed:

# Verify what happens with base_data_dir after it's assigned
sed -n '325,360p' apps/server/src/main.rs

Repository: spacedriveapp/spacedrive

Length of output: 1286


Dev tempdir fallback is destroyed immediately.

tempfile::tempdir() returns a TempDir guard whose Drop impl deletes the directory. Binding it to temp and returning only temp.path().to_path_buf() drops the guard at the end of the closure, leaving base_data_dir holding a PathBuf to a directory that has already been removed. Any subsequent file operations (e.g., base_data_dir.join("instances")) will fail or produce undefined behavior.

Use TempDir::keep() to detach the directory from the guard and persist it:

Proposed fix
 				.unwrap_or_else(|_| {
 					warn!("Could not resolve home directory; falling back to tempdir");
-					let temp = tempfile::tempdir().expect("Failed to create temp dir");
-					temp.path().to_path_buf()
+					tempfile::tempdir()
+						.expect("Failed to create temp dir")
+						.keep()
 				})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.unwrap_or_else(|_| {
warn!("Could not resolve home directory; falling back to tempdir");
let temp = tempfile::tempdir().expect("Failed to create temp dir");
temp.path().to_path_buf()
})
.unwrap_or_else(|_| {
warn!("Could not resolve home directory; falling back to tempdir");
tempfile::tempdir()
.expect("Failed to create temp dir")
.keep()
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/main.rs` around lines 315 - 319, The closure that creates the
fallback temp dir drops the TempDir guard (temp) and returns only a PathBuf,
leaving base_data_dir pointing at a deleted directory; fix by converting the
TempDir into a persistent path before returning so the guard isn't dropped —
e.g., inside the unwrap_or_else closure call TempDir::into_path() (or
TempDir::keep()/persist() if your tempfile version uses that) on temp and return
that resulting PathBuf; update the closure that binds temp (the TempDir) so it
is consumed into a persistent path and handle any errors from persist/keep
accordingly so base_data_dir remains valid for subsequent file operations.

Comment thread apps/server/src/main.rs
Comment on lines +547 to +560

// Force-exit fallback: if graceful shutdown doesn't finish (e.g. a browser
// is holding an SSE connection open), exit on the next Ctrl+C or after 5s.
tokio::spawn(async {
tokio::select! {
_ = signal::ctrl_c() => {
warn!("Second signal received, forcing exit");
}
_ = tokio::time::sleep(Duration::from_secs(5)) => {
warn!("Graceful shutdown timed out after 5s, forcing exit");
}
}
std::process::exit(0);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Force-exit should signal a non-zero exit code on timeout.

A forced exit caused by a 5-second timeout (or a second signal after a first one was already handled) is an abnormal termination — downstream supervisors (systemd, Docker, Kubernetes, CI) typically rely on a non-zero exit status to distinguish "clean graceful shutdown" from "we had to kill ourselves because a connection was stuck". Exiting with 0 in both branches hides that signal and can mask bugs where graceful shutdown is routinely exceeding the budget.

Also worth noting: once axum's graceful shutdown completes normally, main returns and the runtime is dropped, which cancels this spawned task — so the behavior only fires when graceful shutdown is actually stuck, which is exactly the case you want to flag as non-zero.

♻️ Proposed fix
 	tokio::spawn(async {
 		tokio::select! {
 			_ = signal::ctrl_c() => {
 				warn!("Second signal received, forcing exit");
 			}
 			_ = tokio::time::sleep(Duration::from_secs(5)) => {
 				warn!("Graceful shutdown timed out after 5s, forcing exit");
 			}
 		}
-		std::process::exit(0);
+		std::process::exit(1);
 	});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/main.rs` around lines 547 - 560, The forced-exit spawned task
currently calls std::process::exit(0) for both the second Ctrl+C and the 5s
timeout, which should instead signal abnormal termination; update the
tokio::spawn block (the code using tokio::spawn, signal::ctrl_c,
tokio::time::sleep and Duration::from_secs) so that the branches that represent
abnormal termination (the second signal branch and the timeout branch) call
std::process::exit with a non-zero status (e.g. exit(1)), leaving normal
graceful shutdown behavior unchanged.

Comment thread build-server.sh
Comment on lines +2 to +4
# Build sd-server + sd-cli natively on TrueNAS Scale
# Uses zig cc as C compiler (since gcc/clang not installed)
# Dev tools at /mnt/pool/dev-tools/

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Header comment disagrees with the exported compiler.

The header says "Uses zig cc as C compiler", but CC=/mnt/pool/dev-tools/cc and CXX=/mnt/pool/dev-tools/c++ point at plain binaries, not zig cc. If those are wrapper scripts that shell out to zig cc, say so; otherwise update the comment so future readers don't chase a zig toolchain that isn't actually in use.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@build-server.sh` around lines 2 - 4, The header comment incorrectly claims
"Uses zig cc as C compiler" while the script exports CC and CXX as
/mnt/pool/dev-tools/cc and /mnt/pool/dev-tools/c++; update the header to
accurately describe the actual compiler setup: either state that CC and CXX
point to local wrapper scripts that invoke zig cc (if they are wrappers) or
remove/replace the zig mention and say the script uses the compilers at
CC=/mnt/pool/dev-tools/cc and CXX=/mnt/pool/dev-tools/c++; reference the
exported variables CC and CXX in the revised comment to make the relationship
clear.

if (isTauri) {
// Native mode: Use Tauri's native menu API
console.log('[useContextMenu] Using Tauri native menu');
const nativeShow = (window as any).__SPACEDRIVE__?.showContextMenu;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm __SPACEDRIVE__ is declared on the Window interface so the cast is unnecessary.
fd -t f 'env.d.ts' | xargs rg -n -C3 '__SPACEDRIVE__|showContextMenu'
rg -nP '\binterface\s+Window\b' -C5

Repository: spacedriveapp/spacedrive

Length of output: 1002


🏁 Script executed:

cat -n packages/interface/src/hooks/useContextMenu.ts | sed -n '140,155p'

Repository: spacedriveapp/spacedrive

Length of output: 717


Remove unnecessary any cast on window.

Line 147 uses (window as any) to access __SPACEDRIVE__, but this type is already declared on the Window interface via apps/tauri/src/env.d.ts. The cast violates the project rule against using any types.

Fix
-			const nativeShow = (window as any).__SPACEDRIVE__?.showContextMenu;
+			const nativeShow = window.__SPACEDRIVE__?.showContextMenu;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const nativeShow = (window as any).__SPACEDRIVE__?.showContextMenu;
const nativeShow = window.__SPACEDRIVE__?.showContextMenu;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/interface/src/hooks/useContextMenu.ts` at line 147, Remove the
unnecessary any cast when reading __SPACEDRIVE__ in useContextMenu.ts: replace
the line that assigns nativeShow from (window as
any).__SPACEDRIVE__?.showContextMenu with a plain
window.__SPACEDRIVE__?.showContextMenu (the Window interface in env.d.ts already
declares __SPACEDRIVE__), so nativeShow uses the typed window without using any.

On first load (fresh library, all stats zero), libraries.info used
to calculate statistics synchronously before responding. On large
libraries during active indexing this hangs indefinitely — the
closure-table walk in calculate_file_statistics loads every
descendant ID into a Vec then issues a WHERE IN(...) with millions
of entries, which SQLite can't finish while the indexer is writing.

Now always return cached (possibly zero) stats and let the
background recalculate_statistics task fill them in. The UI
refreshes via the ResourceChanged event when the calculation
completes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
core/src/ops/libraries/info/query.rs (1)

59-64: ⚠️ Potential issue | 🟡 Minor

is_stale conflates "never calculated" with "genuinely empty library."

A library that's legitimately empty (no files, no locations, no tags) will match is_stale on every libraries.info call, causing a background recalculate_statistics to be spawned every time the query runs — even though the last computation was accurate. Consider using an explicit sentinel (e.g. Option<Statistics> in config, or a last_calculated_at timestamp) so you can distinguish "never populated" from "zero". Not a correctness bug, but it defeats some of the caching benefit this PR is introducing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/ops/libraries/info/query.rs` around lines 59 - 64, The current check
around cached_stats (config.statistics) using is_stale conflates a legitimately
empty library with "never calculated" because it only tests zero counts; change
config.statistics to carry an explicit sentinel (e.g. Option<Statistics> or add
a last_calculated_at/timestamp) and update the libraries.info logic that
currently computes is_stale to instead check that sentinel (e.g.
statistics.is_none() or timestamp is missing/too old) before spawning
recalculate_statistics; update references to cached_stats, is_stale,
config.statistics and the recalculate_statistics call so only genuinely
uncomputed or expired stats trigger background recomputation.
🧹 Nitpick comments (3)
core/src/ops/libraries/info/query.rs (3)

11-11: Nit: use tracing; is redundant.

tracing is already in scope as an extern crate; the bare use tracing; statement doesn't add anything since you call the macros via fully-qualified paths (tracing::info!, etc.). Either drop this line or switch to use tracing::{debug, info, warn}; and call the macros unqualified for brevity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/ops/libraries/info/query.rs` at line 11, The `use tracing;` import
is redundant because macros are already invoked via fully-qualified paths (e.g.,
tracing::info!); remove the bare `use tracing;` line or replace it with a
selective import like `use tracing::{debug, info, warn};` and then call `info!`,
`debug!`, `warn!` unqualified in functions such as those that currently call
`tracing::info!`, ensuring imports match the macros actually used (update any
macro calls if you switch to unqualified names).

65-109: Deduplicate the recalculate_statistics trigger across both branches.

Both the stale and non-stale branches call library.recalculate_statistics().await with near-identical warn-on-error plumbing and both return cached_stats. The only real divergence is the initial log message. You can collapse this to a single trigger after the if is_stale log, which also makes the intent clearer: always return what's cached, always kick off a background recalc.

♻️ Proposed simplification
-		let statistics = if is_stale {
-			// First load or completely empty — return zeros immediately and
-			// calculate in the background.  The synchronous path used to
-			// block here, but on large libraries (e.g. NAS with millions of
-			// files being indexed) the closure-table walk in
-			// calculate_file_statistics can take minutes, locking up the RPC
-			// endpoint and making the UI unresponsive.  The background task
-			// emits a ResourceChanged event when done so the UI refreshes.
-			tracing::info!(
-				library_id = %library_id,
-				library_name = %config.name,
-				"Cached statistics are empty, returning zeros and calculating in background"
-			);
-
-			if let Err(e) = library.recalculate_statistics().await {
-				tracing::warn!(
-					library_id = %library_id,
-					library_name = %config.name,
-					error = %e,
-					"Failed to trigger background statistics calculation"
-				);
-			}
-
-			cached_stats
-		} else {
-			// Return cached statistics immediately (non-blocking)
-			tracing::debug!(
-				library_id = %library_id,
-				library_name = %config.name,
-				"Returning cached statistics and triggering background recalculation"
-			);
-
-			// Trigger background recalculation (non-blocking)
-			// This will emit a ResourceChanged event when complete
-			if let Err(e) = library.recalculate_statistics().await {
-				tracing::warn!(
-					library_id = %library_id,
-					library_name = %config.name,
-					error = %e,
-					"Failed to trigger background statistics recalculation"
-				);
-			}
-
-			cached_stats
-		};
+		// Large libraries (e.g. NAS with millions of indexed files) make the closure-table
+		// walk in calculate_file_statistics take minutes, so we never block the RPC here —
+		// the background task emits a ResourceChanged event to refresh the UI.
+		if is_stale {
+			tracing::info!(
+				library_id = %library_id,
+				library_name = %config.name,
+				"Cached statistics are empty, returning zeros and calculating in background"
+			);
+		} else {
+			tracing::debug!(
+				library_id = %library_id,
+				library_name = %config.name,
+				"Returning cached statistics and triggering background recalculation"
+			);
+		}
+
+		if let Err(e) = library.recalculate_statistics().await {
+			tracing::warn!(
+				library_id = %library_id,
+				library_name = %config.name,
+				error = %e,
+				"Failed to trigger background statistics recalculation"
+			);
+		}
+
+		let statistics = cached_stats;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/ops/libraries/info/query.rs` around lines 65 - 109, The code
duplicates the library.recalculate_statistics().await call in both the if
is_stale and else branches; refactor by keeping the distinct tracing::info (for
stale) and tracing::debug (for non-stale) messages inside the if/else but move
the library.recalculate_statistics().await call (with its warn-on-error
handling) once after the if/else so it always runs and cached_stats is returned;
ensure you reference the same cached_stats return value and preserve the error
logging (the warn() block) around library.recalculate_statistics().

66-72: Nit: multi-sentence rationale comment.

This block is 6 sentences. Per guidelines, inline comments should be kept to one sentence when possible. The NAS/closure-table rationale is valuable, but can be tightened.

As per coding guidelines: "Inline comments should explain WHY decisions were made, not WHAT the code does, and should be kept to one sentence when possible."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/ops/libraries/info/query.rs` around lines 66 - 72, Tighten the
multi-sentence rationale comment above the calculate_file_statistics call to a
single concise sentence that explains the why: e.g., note that we return zeros
and spawn a background task to avoid blocking the RPC/UI on large libraries
(closure-table walks can take minutes) and mention that the background task
emits a ResourceChanged event when finished; update the comment block that
currently spans multiple sentences to this single-line rationale referencing
calculate_file_statistics and ResourceChanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@core/src/ops/libraries/info/query.rs`:
- Around line 59-64: The current check around cached_stats (config.statistics)
using is_stale conflates a legitimately empty library with "never calculated"
because it only tests zero counts; change config.statistics to carry an explicit
sentinel (e.g. Option<Statistics> or add a last_calculated_at/timestamp) and
update the libraries.info logic that currently computes is_stale to instead
check that sentinel (e.g. statistics.is_none() or timestamp is missing/too old)
before spawning recalculate_statistics; update references to cached_stats,
is_stale, config.statistics and the recalculate_statistics call so only
genuinely uncomputed or expired stats trigger background recomputation.

---

Nitpick comments:
In `@core/src/ops/libraries/info/query.rs`:
- Line 11: The `use tracing;` import is redundant because macros are already
invoked via fully-qualified paths (e.g., tracing::info!); remove the bare `use
tracing;` line or replace it with a selective import like `use tracing::{debug,
info, warn};` and then call `info!`, `debug!`, `warn!` unqualified in functions
such as those that currently call `tracing::info!`, ensuring imports match the
macros actually used (update any macro calls if you switch to unqualified
names).
- Around line 65-109: The code duplicates the
library.recalculate_statistics().await call in both the if is_stale and else
branches; refactor by keeping the distinct tracing::info (for stale) and
tracing::debug (for non-stale) messages inside the if/else but move the
library.recalculate_statistics().await call (with its warn-on-error handling)
once after the if/else so it always runs and cached_stats is returned; ensure
you reference the same cached_stats return value and preserve the error logging
(the warn() block) around library.recalculate_statistics().
- Around line 66-72: Tighten the multi-sentence rationale comment above the
calculate_file_statistics call to a single concise sentence that explains the
why: e.g., note that we return zeros and spawn a background task to avoid
blocking the RPC/UI on large libraries (closure-table walks can take minutes)
and mention that the background task emits a ResourceChanged event when
finished; update the comment block that currently spans multiple sentences to
this single-line rationale referencing calculate_file_statistics and
ResourceChanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: afbf3ce1-24ed-4d49-bdec-1d8663e4433f

📥 Commits

Reviewing files that changed from the base of the PR and between dad50ec and 8fde30a.

📒 Files selected for processing (1)
  • core/src/ops/libraries/info/query.rs

Core::new() registers default protocol handlers after starting networking,
but swallows any failure (error is only logged). If the initial registration
fails — e.g. on a host where start_networking hasn't fully set up the event
loop command sender by the time register_default_protocol_handlers runs —
the registry is left empty. A subsequent call to Core::init_networking()
would see `services.networking().is_some()` and skip re-registration,
permanently leaving protocols unregistered for the life of the process.

sd-server calls init_networking() right after Core::new(), so it's the
client most exposed to this. Symptom: pairing over the web UI returns
"Pairing protocol not registered" while the same library works fine
from Tauri and mobile.

Fix: init_networking now queries the registry directly for the pairing
handler and re-registers the default set if it's missing, independent of
whether networking is already initialized.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@core/src/lib.rs`:
- Around line 523-544: Replace the hardcoded "pairing" literal used when probing
the registry with the canonical name provider to avoid duplication and drift:
call PairingProtocolHandler::protocol_name() (or a shared constant) wherever
"pairing" is used in this file (the probe using
networking_service.protocol_registry().read().await.get_handler("pairing").is_some()
and the other occurrence noted in the review) so the registry lookup and any
future checks use the same authoritative symbol; additionally consider making
register_handler idempotent or explicitly check each default handler in
register_default_protocols to tolerate partial registrations (see
register_default_protocols and register_handler for the recovery path).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 7d09210e-68d7-45b5-80ac-291e9d3c63bd

📥 Commits

Reviewing files that changed from the base of the PR and between 8fde30a and 452fbf4.

📒 Files selected for processing (1)
  • core/src/lib.rs

Comment thread core/src/lib.rs
Comment on lines +523 to 544
// Core::new() attempts to register default protocols after starting
// networking, but swallows errors (only logs them). That means
// `already_initialized` does NOT imply protocols are registered —
// e.g. if the event loop's command sender wasn't ready when Core::new()
// tried to build the pairing handler, registration silently failed.
// Check the registry directly and re-register if missing.
let pairing_registered = networking_service
.protocol_registry()
.read()
.await
.get_handler("pairing")
.is_some();

if !already_initialized || !pairing_registered {
if already_initialized && !pairing_registered {
logger
.warn("Networking was initialized but protocol handlers are missing; re-registering")
.await;
} else {
logger.info("Registering protocol handlers...").await;
}
self.register_default_protocols(&networking_service).await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 1) Confirm register_default_protocol_handlers is still all-or-nothing
#    (no fallible step between successive register_handler calls).
rg -nP -C2 '\bregister_handler\s*\(' core/src

# 2) Find all occurrences of the "pairing" string literal used as a protocol name
rg -nP '"pairing"' core/src

# 3) Locate PairingProtocolHandler::protocol_name to confirm the canonical name
ast-grep --pattern $'impl $_ for PairingProtocolHandler {
  $$$
  fn protocol_name($$$) $$$ {
    $$$
  }
  $$$
}'

Repository: spacedriveapp/spacedrive

Length of output: 44054


Replace hardcoded "pairing" strings with a shared constant or PairingProtocolHandler::protocol_name() for robustness.

The all-or-nothing invariant currently holds: if command_sender acquisition fails at line 654–657, no handlers register at all, so probing for "pairing" correctly indicates whether all defaults are missing. However, this coupling is implicit and fragile. A future change that inserts a fallible operation between successive register_handler calls (lines 750–754) would silently break the assumption and cause a partial-registration state, leaving the registry inconsistent.

Two improvements:

  1. Replace the magic string "pairing" at lines 533 and 782 with PairingProtocolHandler::protocol_name() (defined in core/src/service/network/protocol/pairing/mod.rs:2138) or a shared module constant. This keeps the protocol name in sync with its canonical definition and reduces duplication across the codebase.

  2. Optionally, make register_handler skip-if-already-registered semantics for this recovery path, or check each default handler name explicitly, to harden against future partial-registration scenarios.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/lib.rs` around lines 523 - 544, Replace the hardcoded "pairing"
literal used when probing the registry with the canonical name provider to avoid
duplication and drift: call PairingProtocolHandler::protocol_name() (or a shared
constant) wherever "pairing" is used in this file (the probe using
networking_service.protocol_registry().read().await.get_handler("pairing").is_some()
and the other occurrence noted in the review) so the registry lookup and any
future checks use the same authoritative symbol; additionally consider making
register_handler idempotent or explicitly check each default handler in
register_default_protocols to tolerate partial registrations (see
register_default_protocols and register_handler for the recovery path).

jamiepine and others added 3 commits April 17, 2026 18:39
Iroh's endpoint.bind() fails wholesale if any configured discovery service
fails to initialize. MdnsDiscovery requires binding UDP :5353, which on
most Linux systems (including TrueNAS) is already owned by avahi-daemon.
Result: endpoint creation errors out with "Service 'mdns' error", the
event loop never starts, command_sender stays None, and protocol
registration fails — so sd-server has no working networking at all.

Make mDNS best-effort: on any error whose message mentions "mdns",
retry endpoint creation with only pkarr + DNS discovery. Local-network
auto-discovery is lost but remote pairing via node ID (which uses n0's
DNS infrastructure, not mDNS) continues to work normally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dual-path discovery in start_pairing_as_joiner_with_code used
tokio::select! to race mDNS and relay. select! resolves on the first
branch to complete — including errors — so a host that can't bind
mDNS (e.g. a Linux box where avahi already owns UDP :5353) would fail
pairing wholesale: mDNS discovery errors out in <1ms with
"Failed to create mDNS discovery: Service 'mdns' error", that Err
wins the race, and relay discovery gets cancelled before it can even
begin.

Switch to futures::select_ok so we only return the error if EVERY
discovery path has failed. mDNS failing immediately now leaves relay
running to completion, which is the common case for remote pairing
into a NAS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolve conflicts:
- migration/mod.rs: keep redundancy + tags + sync cursor migrations in
  chronological order (m20260125 → m20260414 → m20260417).
- ExplorerView.tsx: render unless mode is plain "browse" without a path,
  covering both filtered (redundancy) and tag/recents modes.
- context.tsx: combine ExplorerMode/Action/dispatcher additions for both
  filtered and tag modes side by side.
- useExplorerFiles.ts: support both filtered and tag query paths with
  priority filtered > tag > recents > search > virtual > directory.
- types.ts: keep Redundancy summary types alongside upstream changes.
@jamiepine jamiepine merged commit 60369e9 into main Apr 19, 2026
2 of 5 checks passed
@jamiepine jamiepine deleted the spacedrive-redundancy branch April 19, 2026 01:56
@coderabbitai coderabbitai Bot mentioned this pull request Apr 19, 2026
13 tasks
HiderWild pushed a commit to HiderWild/spacedrive-multimedia-manager that referenced this pull request Jun 8, 2026
…app#3053)

* feat: add redundancy awareness & cross-volume file comparison

Surface content redundancy data so users can answer "if this drive dies,
what do I lose?" — builds on existing content identity and volume systems.

Backend:
- New `redundancy.summary` library query with per-volume at-risk vs
  redundant byte/file counts and a library-wide replication score
- Extend `SearchFilters` with `at_risk`, `on_volumes`, `not_on_volumes`,
  `min_volume_count`, `max_volume_count` filters
- Add composite index migration on entries(content_id, volume_id)

Frontend:
- `/redundancy` dashboard with replication score, volume bars, at-risk callout
- `/redundancy/at-risk` paginated file list sorted by size
- `/redundancy/compare` two-volume comparison (unique/shared toggle)
- Sidebar ShieldCheck button linking to redundancy view

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* redundancy UI improvements + ZFS volume detection fix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix ZFS pool capacity reporting and stats filtering

- ZFS: override total_capacity for pool-root volumes using zfs list
  used+available. df under-reports pool-root Size because it only
  counts the root dataset's own used bytes plus avail — on a 60 TB
  raidz2 pool this shows as ~15 TB instead of ~62 TB. The pool root's
  own used property includes descendants, so used+available is the
  real usable capacity.

- Library stats: drop volumes where is_user_visible=false AND
  re-apply should_hide_by_mount_path retroactively so stale DB rows
  (detected before the Linux visibility filters existed) don't
  inflate reported capacity.

- Extract should_hide_by_mount_path into volume/utils as a shared
  helper used by both the list query and the stats calculation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* show capacity and visibility in sd volume list

Makes it possible to verify library-level capacity aggregation from
the CLI — previously the list only showed mount, fingerprint, and
tracked/mounted state, which meant debugging the ZFS pool capacity
issue required querying the library DB directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* document filesystem support matrix and detection

New docs/core/filesystems.mdx covering per-filesystem capabilities
(CoW, pool-awareness, visibility filtering, capacity correction),
platform detection strategies, the FilesystemHandler trait, Linux/
macOS/ZFS visibility rules, the ZFS pool-root capacity problem and
fix, copy strategy selection, and known limitations.

Registered under File Management in both mint.json and docs.json.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* WIP: redundancy filter wiring across search, CLI, and UI

- core/src/ops/search: wire redundancy filters (at_risk, on_volumes,
  not_on_volumes, min/max volume_count) through the search query;
  fix UUID-to-SQLite BLOB literal so volume UUID comparisons actually
  match (volumes.uuid is stored as a 16-byte BLOB, quoted-string
  comparison silently returned zero rows).

- apps/cli: new redundancy subcommand + populate the new
  SearchFilters fields from search args.

- packages/interface: redundancy at-risk and compare pages reworked
  to consume the new filter surface; explorer context/hook updates
  to support redundancy-scoped views.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* add web context menu renderer and UI polish

- New WebContextMenuProvider + Radix DropdownMenu-based renderer anchored
  at cursor via a 1x1 virtual trigger. Handles separators, submenus,
  disabled, and the danger variant via text-status-error.
- useContextMenu now routes web clicks through the provider instead of
  parking data in unused local state, and trims leading/trailing/adjacent
  separators so condition-filtered menus don't render orphaned lines.
- Drop app-frame corner rounding on the web build.
- Add shrink-0 to the sidebar space switcher so the scrollable sibling
  can't compress it vertically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* sd-server dev workflow: auto web build, shutdown watchdog, stable data dir

- build.rs runs `bun run build` in apps/web so `just dev-server` always
  embeds the latest UI. rerun-if-changed covers apps/web/src,
  packages/interface/src, and packages/ts-client/src so Rust-only edits
  skip the rebuild. Skips gracefully when bun isn't on PATH or
  SD_SKIP_WEB_BUILD is set; Dockerfile sets the latter since dist is
  pre-built and bun isn't in the Rust stage.
- Graceful shutdown was hanging because the browser holds the /events
  SSE stream open forever and axum waits for all connections to drain.
  After the first signal, arm a background force-exit on second Ctrl+C
  or 5s timeout so the process can't stick.
- Debug builds were starting from a fresh tempfile::tempdir() on every
  run (the TempDir handle dropped at end of the closure, deleting the
  dir we just took a path to). Default to ~/.spacedrive in debug so data
  persists and `just dev-server` shares a data dir with the Tauri app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* add Sources space item alongside Redundancy in default library layout

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* add TrueNAS native build script

Uses zig cc as C/C++ compiler on TrueNAS Scale where /usr is
read-only and no system gcc exists. Dev tools live at
/mnt/pool/dev-tools/ (zig, cmake, make, extracted deb headers).

Builds sd-server + sd-cli in ~4 min on a 12-core NAS. AI feature
disabled (whisper.cpp C11 atomics incompatible with zig clang-18).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* never block RPC on synchronous statistics calculation

On first load (fresh library, all stats zero), libraries.info used
to calculate statistics synchronously before responding. On large
libraries during active indexing this hangs indefinitely — the
closure-table walk in calculate_file_statistics loads every
descendant ID into a Vec then issues a WHERE IN(...) with millions
of entries, which SQLite can't finish while the indexer is writing.

Now always return cached (possibly zero) stats and let the
background recalculate_statistics task fill them in. The UI
refreshes via the ResourceChanged event when the calculation
completes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* self-heal protocol handler registration on re-init

Core::new() registers default protocol handlers after starting networking,
but swallows any failure (error is only logged). If the initial registration
fails — e.g. on a host where start_networking hasn't fully set up the event
loop command sender by the time register_default_protocol_handlers runs —
the registry is left empty. A subsequent call to Core::init_networking()
would see `services.networking().is_some()` and skip re-registration,
permanently leaving protocols unregistered for the life of the process.

sd-server calls init_networking() right after Core::new(), so it's the
client most exposed to this. Symptom: pairing over the web UI returns
"Pairing protocol not registered" while the same library works fine
from Tauri and mobile.

Fix: init_networking now queries the registry directly for the pairing
handler and re-registers the default set if it's missing, independent of
whether networking is already initialized.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fall back to pkarr+DNS discovery when mDNS port is unavailable

Iroh's endpoint.bind() fails wholesale if any configured discovery service
fails to initialize. MdnsDiscovery requires binding UDP :5353, which on
most Linux systems (including TrueNAS) is already owned by avahi-daemon.
Result: endpoint creation errors out with "Service 'mdns' error", the
event loop never starts, command_sender stays None, and protocol
registration fails — so sd-server has no working networking at all.

Make mDNS best-effort: on any error whose message mentions "mdns",
retry endpoint creation with only pkarr + DNS discovery. Local-network
auto-discovery is lost but remote pairing via node ID (which uses n0's
DNS infrastructure, not mDNS) continues to work normally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* succeed pairing if either mDNS or relay discovery wins

The dual-path discovery in start_pairing_as_joiner_with_code used
tokio::select! to race mDNS and relay. select! resolves on the first
branch to complete — including errors — so a host that can't bind
mDNS (e.g. a Linux box where avahi already owns UDP :5353) would fail
pairing wholesale: mDNS discovery errors out in <1ms with
"Failed to create mDNS discovery: Service 'mdns' error", that Err
wins the race, and relay discovery gets cancelled before it can even
begin.

Switch to futures::select_ok so we only return the error if EVERY
discovery path has failed. mDNS failing immediately now leaves relay
running to completion, which is the common case for remote pairing
into a NAS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant