* Draw raw photos from their embedded JPEG preview
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Rebake raw tiles under their own recipe and document the raw module
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Serve Quick Preview originals to the web client
The web platform has no asset protocol, so useOriginalUrl returned no URL
and Quick Preview showed the 384 px tile for images and nothing for video.
sd-server now proxies the mounts share under /dav behind its own auth,
passing range headers both ways, and the web platform rewrites
files.stream_url URLs to that same-origin path. Local files on a platform
without disk access stream from the share like remote ones.
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Record sd-server's reqwest dependency in the lockfile
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Sandbox proxied originals and reach a reused daemon's share
The proxy served untrusted files on the application's origin. A sandbox
CSP and nosniff keep a document opened from /dav from running scripts
there. A daemon sd-server reused rather than embedded binds the share in
its own process, so the proxy falls back to the share's preferred port.
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Ask a reused daemon where its mounts share listens
A daemon sd-server reused may have bound any port. The proxy reads the
share's base URL from mounts.status over the daemon socket instead of
assuming the preferred port.
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Capture time and place in listings and search, read from the image facet on both backends
search.files sorts by CapturedAt and filters by a CapturedAt range and
has_location; files.directory_listing sorts by datetaken; the media
listing's datetaken reads the facet. The store joins facet_image into
every entry read, guarded by the row's content hash, and the arena path
reads the facet from the store ahead of its sort and filter, so both
backends hand the same File to one predicate and one order (R8 row 19,
new row 29). File.image_media_data is filled from the facet on every
path; the inspector shows the capture time and the coordinates; Date
Taken is in every explorer view's sort menu.
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* search.media judges the capture filters on its rows and leaves replicas out under them; a directory listing reads facets ahead of the sort only for a capture order
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* search.media judges a replica's rows under a capture filter as search.files does instead of dropping the partition
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Add to Library step 4: keep an offline copy of an on-source catalog as a library replica
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Say where a detached source's catalog is: offline copy, or on the drive
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Retire only a reader on the offline copy when publishing; drop the unused apply_setting
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Fill the new field in the CLI's source row test
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Fill offline_copy in sources.get; show the catalog hint on the card whether or not the drive has a badge
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Locked volumes L5: volume state in the app, CLI and peer volume facts
RemoteVolumeInfo carries VolumeState and a device publishes its tracked
unmounted and locked volumes, so a peer lists the owner's dataset as
locked instead of a replica that stopped updating. RemoteSourceInfo
carries each source's volume_state and sources.get reads the live index.
VolumeMountChanged carries the state and fires on any state change,
including a key unloaded from an already unmounted dataset. The CLI
event printer names a lock; sd sources list has a State column and
sd volumes list a State line.
The sidebar, Storage cards, source detail and Overview show Locked,
Unmounted or Offline and say the index stays browsable. The Add to
Library modal refuses a locked or unmounted drive with the reason.
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Refuse an add only for this device's away drives; printer falls back to is_mounted without a state
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Say why RemoteVolumeInfo.state stays last
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Hold the Add to Library refusal until the device list resolves
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Add to Library modal: one setup modal for folders and drives over sources.track
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Add to Library modal: one setup modal for folders and drives over sources.track
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Add to Library modal: pass the volume spelling from suggested folders, save only the user's changes as defaults, rank the containing volume by the matching mount
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Merge main; wasm_extension_test uses TrackSourceInput.overrides (field renamed in #3121, broken by #3124)
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Add to Library defaults: library settings with per-add overrides
Every entry point used to carry its own notion of what a source starts as:
volumes.track defaulted external drives to unfiltered, folder tracking to
filtered, and the Explorer sent unfiltered: false by hand. The library now
holds the defaults under settings.adding (store placement in library, an
offline copy kept, filtered capture, content identified), config.library.get
reports them and config.library.update changes them for later adds only.
sources.track and volumes.track take the same AddOverrides on top; an absent
field is the default and an override never writes back. The effective
settings are the source's SourceConfig, persisted with the registration and
returned in the output together with the saved name and the store path.
A name, settings or volume row that fails to persist now fails the add
instead of reporting a tracked source. A source added without content
identification gets its walk and nothing after. volumes.track keeps the
archival behavior by passing an explicit unfiltered override for an external
drive unless the caller set one.
The CLI gains --on-source, --in-library, --keep-offline-copy and
--no-identify on sd source track. Generated TypeScript types regenerated.
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Resolve store placement per library in the source layer
Placement is intent recorded in each source's config column; the serving
device resolves it. VolumeIndex::store_dir is the one resolver: in library
under <data>/sources/<id>, on source under <root>/.spacedrive/sources/<id>,
following the source's current root so an on-source store tracks its volume
across remounts and resolves to nothing while the drive is away. Writer
opens, read-only opens, freezes, the status surface and sources.list all go
through it, and SourceStore::open takes the resolved stores directory rather
than the data-directory layout. On source is refused for network and cloud
volumes, whose WAL database cannot live on the share.
The volume index also gains register_source_with, which registers under an
identity a store on disk already carries, and forget_source, which drops a
registration and retires its writer and reader handles without touching the
directory.
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Adding a path tracks its volume
ensure_volume_in_db wrote the row but skipped the sync publication and the
VolumeTracked event that track_volume emits, and the add logged its failure
and carried on. It now syncs and emits on insert, the add fails when the row
cannot be written, and the volume index maps the drive at the same moment so
the mount point check and the detached flag apply to the new source. A second
folder on the same drive finds the row already there.
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Portable source identity and managed-directory exclusions
Every store now carries source.json beside data.db: a versioned descriptor
with the source id, the library that wrote it, its volume anchor and path
within the volume, and its settings. Adding a scope first looks for a store
whose descriptor binds to this library, this volume and this path, and
registers under that identity, so a removed folder re-added or a drive
plugged in with its catalog on it reopens the catalog instead of minting a
fresh source over an empty one. A descriptor from another library or another
drive is ignored and a new source starts beside it.
The own-data-directory check becomes crate::config::is_managed: the data
directory under every spelling, any .spacedrive directory and what is under
it, and the volume identity marker. It is a path-component check rather than
a registry lookup, so it holds for an unfiltered source whose rules are off,
for an enclosing source that was never told about the inner store, for a
store on a drive nobody here tracks, and across remounts and aliases. The
walk, the watcher and the writer all use it.
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Removing a source keeps its catalog unless asked to delete it
sources.delete used to delete the store directory through the adapter engine
before unregistering, which would have turned removal of an on-source store
into destruction of the portable catalog, assertions included. It now
retires the source's handles, drops the registration and leaves the store
where it is; delete_catalog is an explicit second choice. The volume row is
never touched: removing the last folder on a drive does not forget the
hardware. sd source untrack wraps the action.
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Add to Library acceptance suite and status
One Core over temporary directories tracks a folder with defaults and one
with overrides, proves one volume row for both, finds the on-source store
excluded from its own and an enclosing source, removes a source and sees
its catalog and record identities survive the re-add, and sees deletion
start over. The suite joins the acceptance group of xtask test-core;
docs/core/acceptance/add-to-library.md maps the plan's cases to tests and
names the rows the core half cannot prove. PROJECT_STATUS and the plan's
status line record what landed and what the modal thread builds next.
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Review fixes: placement-aware reads and backup, re-track keeps settings, adoption across placements
The library's per-source read queries (sources.list_items, list_records,
media_listing) opened the store through the engine's in-library layout, so
an on-source catalog was unreachable from the explorer; they now resolve a
registered filesystem source through the volume index and fall through to
the engine only for adapter sources. libraries.backup reads each store from
where placement put it. A re-track carries the previous value of every
setting the add did not name instead of the library's current defaults,
and never changes placement. portable_identity searches both placements so
a drive arriving with its catalog is adopted whatever placement the add
asked for, and the adopted catalog keeps the placement it was found under.
SourceConfig::default agrees with its serde defaults.
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Restore names an on-source catalog it cannot put back
The archive now holds a store backed up from its drive, but a restore lays
every store in the in-library layout, where an on-source registration does
not read. The manifest records each source's placement; a restore leaves an
on-source catalog where it landed, keeps it out of the restored count, and
names it in on_source_catalogs with its path, so the caller knows what was
not put back. Moving it onto the drive is relocation.
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* catalog_reused means an identity was kept, not that a file exists
The acceptance run on CI saw a fresh add report its catalog reused: the
content identity job of an enclosing source opened the new source's store
between registration and the file probe. The flag now answers from the
registration (it existed, or a descriptor-bound catalog was adopted), which
is what it claims.
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Regenerate TypeScript types for the catalog_reused doc
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* Volume tracking test follows the one-library-per-path rule
volumes.track now surfaces a source setup failure instead of logging it,
so tracking a volume another open library holds is refused. The
multi-library test asserts the refusal, then the handover once the first
library untracks the drive and removes its source.
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
* ci: install rustfmt and clippy components on every runner
Self-hosted runners have rustc preinstalled, so setup-rust skipped the
dtolnay/rust-toolchain step that added the components and the Rust
Formatting job died with 'cargo-fmt is not installed'. Add the components
to rust-toolchain.toml and run rustup component add unconditionally.
* setup: install FFmpeg development libraries on Linux and macOS
crates/ffmpeg is a default workspace member that links libav* through
ffmpeg-sys-next, so a fresh machine fails the first cargo build with
'libavutil.pc not found'. Install the libav dev packages and pkg-config
from scripts/setup.sh for every supported package manager, warn from
xtask setup when pkg-config cannot find them, and document the requirement
in the README build section.
* test: call the trash directly in delete_strategy_test
bcc7337 gave LocalDeleteStrategy::move_to_trash a JobContext parameter so
it can record where the item went; the integration test still passed one
argument and stopped every cargo test target from compiling. The two trash
cases now call ops::files::trash::trash with the same arguments the
strategy forwards, which is what they were asserting on.
* interface: clear the 30 Tauri typecheck errors
Remove the 23 unused imports and locals, apply the rename rules' serde
defaults (regex and whole_name false, start and step 1) where the modal
renders optional fields, drop the size prop ToggleGroup never accepted,
and type SourceCard on ts-client's SourceInfo so a filesystem source with
no adapter renders its data type instead. Register the /redundancy/duplicates
route the dashboard already navigates to, which is what the DuplicatesView
import was for.
* clippy: make cargo clippy --workspace -D warnings pass on Linux
sd-store names its sqlx row tuples with type aliases. sd-ffmpeg converts
with try_from and f32::from where the cast was lossless, takes raw
borrows for FFI out-pointers, resamples from an integer position, and
allows the pointer-alignment and s32-to-f32 precision casts with the
reason. sd-mobile-core sets DaemonRequest.device (None, local execution)
on the two envelopes it builds. file-opening-macos and file-opening-windows
pull their platform bindings only on that target and gate their sources,
matching sd-desktop-macos. The Tauri build script drops externalBin from
the config when no daemon sidecar exists, so a check or clippy run that
never built the daemon still passes, and the desktop binary's macOS-only
commands return on other hosts without unreachable code. Workspace
rust-version moves to 1.95, the floor gpui already imposes, so the MSRV
lint stops flagging APIs the pinned 1.97.1 toolchain uses.
* docs: make the stale doctests compile
sd-media-metadata examples still imported from the old image module and
asserted a plus code that belonged to the other example's coordinates (the
value is verified against the reference Open Location Code encoder);
MediaLocation has no Display, so the parse example compares coordinates.
The sd-task-system example unwraps dispatch and matches the (TaskId,
TaskOutput) pair Done now carries. The spacedrive-sdk sketch names types
that do not exist yet and the job-derive expansion only resolves inside
sd-core, so both are marked ignore with the reason beside them.
* build: pin bun to 1.3.4 everywhere
package.json said bun@1.3.0 while CI installed 1.3.4, and any newer bun
rewrites bun.lockb on install, so the lockfile churned on every machine.
Pin packageManager to the CI version, document it, and install with
--frozen-lockfile in the TypeScript job so a drifting lockfile fails
instead of being silently rewritten.
* status: register the CI green on sources work and the libav requirement
* docs: make the sd-core doctests compile too
Five sd-core examples referenced the crate by its old name, used an arrow
glyph in a rust fence, or called methods without the trait in scope; the
copy strategy sketch needs a running JobContext so it is marked ignore.
* ci: stop shrinking the runner's root disk before installing a toolchain
easimon/maximize-build-space moves the root filesystem's free space into an
LVM volume mounted at the workspace and leaves root with the reserve, which
on the Blacksmith runner came out at 100 MB after the swap file. rustup
installs the pinned 1.97.1 toolchain under /home/runner/.rustup on root, so
both Rust jobs died with 'No space left on device'. The runner has 82 GB
free on root, which is plenty for a clippy target directory, so the step
goes.
* ci: run clippy on the Ubuntu 24.04 Blacksmith runner; let sd-server check without bun
The Blacksmith migration wrote the runner label into the matrix's target
field and left runs-on at GitHub's ubuntu-22.04, whose FFmpeg 4.4 predates
the libav 6 API sd-ffmpeg uses (ch_layout, av_dict_iterate), so the job
could never compile sd-ffmpeg. It now runs on blacksmith-4vcpu-ubuntu-2404
with FFmpeg 6.1, the same as a developer machine on 24.04, and the target
field holds a target triple again.
sd-server's build script skipped the web build when bun was absent and
RustEmbed then failed on the missing apps/web/dist. A debug build now gets
an empty dist with a warning so check and clippy pass; a release build
without bun and without a prebuilt dist still refuses, so a server never
ships without its UI by accident.
* build: let the desktop crate compile without a built frontend
tauri::generate_context! panics when frontendDist is missing, which is how
every check or clippy run looks before bun run build. The build script now
creates an empty apps/tauri/dist for debug builds with a warning; a release
build without one still refuses.
* status: record the CI result for the sources green-up
Spacebot ships separately. This drops its UI, the voice overlay and the
Alt+Space shortcut that opened it, the spacebot app config and window, the
@spacebot/api-client and @spacedrive/ai wiring, and the Spacebot and
Console design docs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Quick Preview reads every original through useOriginalUrl: the asset protocol for a file on this device, and files.stream_url for one on another, so a remote video plays and seeks without a download. It replaces five copies of the URL code across the renderers and the mesh viewer. Open and double-click hand another device's file to the opener at the path files.local_path returns. Open With, Show in Finder and Share, in the menu and in the inspector, take only files on this device, since another device's path names a different file here or none. The CSP allows media from loopback.
DeleteModal replaces the browser confirm for ⌘⌫ and ⌥⌘⌫ and lists the removed rows; ⌘D duplicates after validation. RenameModal for several files, UndoModal from the job list, ⌘Z and the trash view, a remove-extras switch in the merge dialog, RearrangeModal (organize, flatten), ArchiveModal (compress, extract), AttributesModal, make link from the context menu, and /trash in the sidebar.
Every job that changes the filesystem records effects (created, moved, trashed with where it went, replaced with where the previous bytes went, removed, attributes before and after) in a job_journal table through ctx.record. The trash reports locations: NSFileManager on macOS, the trash crate's listing elsewhere, and a .spacedrive/trash/<job>/ directory on volumes with none. Overwrites in copy, merge and extract stash the previous file there. files.undo previews the reverse as an FsPlan, refuses what changed since, and its job writes its own journal.
Also: files.rename on preflight with per-filesystem name rules and case probing, files.rename_batch with ordered rules and a job that never overwrites; remove_extras on files.merge for a mirror; files.organize and files.flatten as in-place renames; files.archive and files.extract for zip and tar.zst, planned from the archive's directory; files.set_attributes and files.link; files.trash_list and files.trash_empty; jobs.journal and a journal summary on jobs.list. The copy job now honors the skip policy and merges a folder onto an existing folder instead of overwriting inside it, and the copy preview plans a keep-both copy at its numbered name.
30 tests over the fixture. Docs, the plan's status table and PROJECT_STATUS updated.
Protection gets a Find Duplicates screen: groups from files.duplicates with a size filter, a keeper radio per group, and one dialog that removes the other copies. The dialog renders delete's preflight (keepers as skips, copies as deletes), gates confirm on error findings, and can browse the result in the explorer's preview mode. Context menu: remove duplicates inside a folder, remove other copies of a file, and delete what the folder on the clipboard already holds.
The operation modal validates and previews the input it dispatches, gates
confirm on an error finding, and offers Merge when a folder is copied
onto a folder of its name. A folder's context menu shows "Merge into"
while the clipboard holds only folders, an Option-drop of folders opens
the merge dialog directly, and grid drop targets validate during hover.
Browse the result puts the explorer in preview mode: every listing passes
the plan handle and rows render ghosted, badged, dimmed or struck.
files.delete takes a comparison as its targets alongside a path list, so
the client sends five fields and the daemon derives the set from the
index as the job runs. The job drains the matcher the compare query
pages, checkpoints its cursor after each batch, and for `both` reads
whichever side lacks an integrity hash before removing a file, writing
what it learned back to the store. Pairs whose bytes differ, or whose
copy in B is gone by then, stay and are reported. The CLI gains
`sd file delete A --against B --show both` behind a y/N prompt, plus the
plain paths form.
Compare matched on content uuids, which re-derive from the integrity
hash once a copy is verified, so a copy verified on one side stopped
matching its unread twin. It now matches on the sampled hash every store
keys a content by, with integrity hashes deciding where both sides have
them; FsEntry carries both hashes for that.
Photos listed only a folder's direct children and never saw the explorer's
search, so searching from a folder of folders showed no photos.
search.media pages images and videos straight from source SQLite. Files come
out in the order the directory_path and sibling indexes already keep, so a
page reads its own rows and resumes from a (source, directory, name) cursor.
Extension, kind and hidden narrow in SQL; name, size, date and tags are
judged per row the way the store backend of search.files judges them. A
folder reaches its innermost source and any sources nested beneath it.
The navigation focus now carries the running search, and Photos lists it in
place of the folder. Folders list everything beneath them. Pages grow the
grid as they land, and tile identities name their drive's cache, so a
library-wide search across drives renders.
The filter bar opens when the search is focused, before there's a query.
A click on it pins it open; a click anywhere else closes an empty search,
and Escape or the bar's close button ends a pinned one. Clearing the query
keeps a filter-only search running.
The daemon accepts an empty query when an extension, kind, size, date or
tag filter narrows it, and matches every entry in scope, walking only the
folder's subtree for a scoped search. The Kind pill offers common kinds
before there are results to count.
Tags get their own section after Places, one row per tag with its color,
each opening that tag's files. The section only renders once the library
has tags, and it reads the same tags.search list tag mode does, so a tag
edit refreshes both.
The tag view now includes files under child tags, which files.by_tag
documents as the client default, and stops sending min_confidence, which
the input no longer has. The default tag color moves into one tagColor
helper instead of nine copies.
Paired devices answer a new DeviceFacts request with their hardware and
mounted volumes, under the ids their sources anchor to. The peer fetches
them on every sync beside the device summary and keeps them in the replica
manifest with its own receipt time, so an offline device still describes
itself as it last did.
volumes.list lists those volumes under their owner, mounted only while it's
connected, and devices.list fills in a paired device's hardware. The
overview folds replicas into their owner's volume bars the way local
sources fold into theirs. Another device's volumes show when they were last
seen and hide the local volume actions on desktop and mobile.
Volume classifications travel as names, so one a peer's build doesn't know
reads as unknown instead of failing the response.
FD4 routed local sources onto this device's card as rows built for
replicas, so a tracked home folder looked like a fourth drive next to the
volume it lives on. A local source on a listed volume is now part of that
volume's bar, and rows are left for replicas and for sources on a volume
the card doesn't list. Adapter sources stay off device cards.
The bar's files badge sums the sources handed to it instead of querying
the index status and taking the first match, so a volume holding two
sources counts both.
The error variants, the permissions flag, the sync fixtures, and a long
tail of comments still named locations and entries after both left the
product. LocationNotFound goes from the query, action, and CLI errors;
can_manage_locations becomes can_manage_sources; ChangeConfig's
location_root becomes root; and the sync tests key their watermarks,
checkpoints, dependency parsing, and messages on models that still sync.
The transaction manager's stubbed pre-HLC methods and the bulk operation
types only they used are removed, and the spaces invalidation arm stops
matching a location resource nothing emits.
File transfers and deletes describe their allow-list as the source roots
it is. The CLI no longer points a new cloud volume at sd location add. In
the clients, mobile loses its location search scope and empty Locations
section, an unused overview card mock is deleted, and empty states and
copy say folder, place, or source instead of location.
The word marked a contrast with the persistent entries index, and that
index is gone. ops/indexing/ephemeral folds into ops/indexing, and the names
now say what each piece is, keeping the in-memory arena apart from the
durable registry and stores:
EphemeralIndexCache, ephemeral_cache() -> VolumeIndex, volume_index()
VolumeIndex (one drive's slot) -> Partition
EphemeralIndex -> Arena
EphemeralEventHandler -> FsEventHandler
watch_ephemeral / unwatch_ephemeral -> watch_root / unwatch_root
IndexerJobConfig::ephemeral_browse -> IndexerJobConfig::new
set_ephemeral_index, from_ephemeral -> set_arena, from_arena
ops/search/ephemeral_search -> arena_search
core.ephemeral_status / ephemeral_reset -> core.index_status / index_reset
The CLI's index subcommands become status and reset. Status output loses
its always-empty legacy fields, EntryRef loses an id that was always zero,
the indexer output loses a field nothing read, and module docs that
described browsing outside managed locations now describe the volume
index. On-disk snapshots keep the ephemeral.snapshot name: renaming the
file would orphan every snapshot and re-walk every drive. The job system's
ephemeral jobs, Spacedrop's ephemeral shares, and UI state that is never
persisted keep the word, since it means what it says there.
Search reports one index, so IndexType and the index_type field are gone,
along with the Locations filter kind, the locations filter and facet, the
constructors that only existed to stamp Persistent, and the Entry-based
enhanced output. Tags and hidden files are advertised filters with no
caveat, since both backends apply them.
Fifteen Event variants that nothing emits are removed: the Entry events,
FsRawChange, the location-keyed Indexing events, and the legacy location,
thumbnail, file-operation, and files-modified events, with their helper
types. events.list now derives its names from the enum instead of a list
that had drifted from it. The CLI printer, the Tauri and ts-client default
subscriptions, and the client-side filter types follow, and an uncompiled
stale copy of type_extraction.rs goes with them.
FD4 of the final entries drop. One migration drops 29 retired tables inside
its own transaction, children before parents: with foreign keys on, dropping
a parent makes SQLite parse every child's constraints, and the first attempt
failed on sidecar, whose other parent entries was already gone. That attempt
also showed Migrator::up leaves SQLite migrations unwrapped, which is why
the migration opens its own transaction. Every library now converges on 14
tables. Proven on a copy of the live library, on a library the pre-drop
build left half-migrated, on a brand-new library, and on the live Mac
library after a backup; every dropped table held zero rows there apart from
the seeded kind lookup.
Locations leave with the schema: ops, domain type, entity, CLI domain, and
every consumer in the web and mobile clients. PathBar pins become
space-level Path items listed under Pinned, the sidebar's Places come from
a new paths.system_folders query, Add Storage tracks sources and validates
through sources.validate_path, device views list sources, and library
statistics count sources as source_count. Content kind statistics read the
source stores instead of a lookup table.
A TagFilter resolves once per query: include tags intersect to the paths
carrying all of them, exclude tags union, both absolute in store-root
spelling, checked at collection on the scoped path, the library-wide
fan-out, and the store fallback alike. Replica partitions drop out while a
tag filter is active, because their assertion state lives with their owner
and passing their hits through unfiltered would be a wrong answer rather
than a degraded one.
Ephemeral results now advertise the Tags filter, and the refinement bar
gains a tag pill driven by tags.search with a color dot per tag.
Proven live: a seven-hit query narrowed to exactly the tagged file on
include, six hits with it absent on exclude, and a 1396-hit replica-heavy
query returned zero under the filter.
tags.create stages a definition in library.db until first application adopts
it into a store; apply resolves targets through the arena with the stores as
the floor, so a cold daemon and a detached source both answer. Every claim is
stamped by a real HLC seeded from DeviceManager; the library-id-as-device
placeholder is gone. File targets carry the content key when the store knows
it, so tagging a copy reaches the bytes without the client choosing.
Writes against a remote-owned source author their rows durably in an
assertion outbox and deliver to the owner over remote ops when it answers:
sources.assertions.merge commits before replying, the reply is the ack, and
redelivery is absorbed by the assertion primary key. Pending claims overlay
replica listings immediately.
Reads decorate from the stores everywhere: directory listings on both the
arena and store paths, files.by_id, files.by_path, replica listings, and
files.by_tag, which no longer needs a loaded arena. File::announce decorates
its payload too, which is what makes tagging reactive: the event used to
carry empty tags and the client faithfully rendered that.
Deleted: TagManager, TagClosureService, TagConflictResolver,
TagUsageAnalyzer, TaggingFacade, UserMetadataManager, ops/metadata, the
validation module, and the semantic-tag field zoo. Tag is uuid, path, name,
color, icon. The XOR-era UI gates (the needs-indexing toast and the
inspector's isEphemeral message) are gone; the one honest refusal left is a
path outside every tracked source. The CLI gains tag unapply and delete. No
data migration: the legacy tables were verified empty, and their entities
wait for FD3 and FD4.
ToggleGroup gained size variants on the SelectPill scale in spaceui
(cd42fd2 there); the search bar's scope control uses sm so the whole
refinement row sits on one h-7 baseline. Requires @spacedrive/
primitives 0.2.4; the workspace is bun-linked to the local package
until that publishes.
The refinement bar is now one quiet row of @spacedrive/primitives: a
ToggleGroup for scope, SelectPill popovers for kind, size, modified
date, and extension, a Switch for hidden files, the true result count,
and a subtle Clear button. Pills carry their own state in their label
and tint accent while filtering, so the separate Filters toggle, the
badge, and the second panel row are gone; an active filter is legible
without opening anything. The kind popover keeps facet counts and
stays open for multi-select. Colors move from the sidebar palette to
the app palette the bar actually sits in, and the popover composition
follows the SelectPill/OptionList idiom the composer established.
Phase 3 of docs/plans/2026-09-15-search-refinement.md. The Filters
button opens a panel whose every control maps onto a wire filter the
daemon answers: kind, extension, size presets, modified-date presets,
and a hidden-files toggle. Active filters badge the button, and the
toolbar shows the true result count from total_found.
Facets are now folded over each partition's full filtered match set
before narrowing, so a facet count describes the whole match set
rather than one page, and they gain a content-kind map keyed by the
wire spelling so the panel can echo a facet key straight back as a
content_types value. The search functions return one SearchPage
carrying results, total, and facets.
Filter state is stored in wire shape through the previously dead
searchFilters context state; the parallel camelCase SearchFilters
interface that nothing ever read is gone, and useExplorerFiles sends
the live filters instead of a hardcoded all-null literal. Verified
live: an uppercase extension filter matches lowercase files with the
true filtered total.
From a parallel session. Inset content is the default: navigation sits
on the window background and the explorer renders as an inset sheet.
Settings > Appearance > Layout retains the floating-panels mode. Both
modes share mounted content and stable preview hosts through a shell
geometry context, the layout preference propagates to other windows on
the same origin, and the sidebar footer returns to circular Activity
and Settings controls with the activity count and busy indicator.
Preview and Size view follow the sheet bounds, with Size thumbnails in
container coordinates.
From a parallel session. thumbs.generate accepts a file or an indexed
folder with missing, stale, and force modes, runs in resumable batches,
and versions video tiles against host FFmpeg availability so a tile
regenerated with better tooling replaces its predecessor in place. File
and folder context menus and the new sd thumbs CLI domain call the same
operation. paths.context now reports whether the exact path has a
committed source record, and the path bar surfaces it with a refresh
affordance. Coverage and resume semantics are documented in
docs/core/thumbnail-generation.mdx.
The middle scope button said Location, had no wire representation, and
fell through to Library, so two of the three buttons did the same
thing. Locations are pins now and pins do not define search domains; a
source does.
The button reads the containing source from paths.context, shows the
source's name, and disables itself when nothing contains the current
path. Picking it searches the source's whole subtree through the
existing Path scope with the daemon-normalized source root and the
current device's slug, so it works identically on replicas browsed
through their owning device. Source scope with an unresolved source
sends no query rather than silently widening to the library.
The search input's debounce returned a cleanup closure from an onChange
handler that nothing ever invoked, so typing a six-letter word fired
five separate searches 300ms apart, and each dispatch re-entered search
mode with the default scope, silently reverting a Library or Location
selection to This Folder on the next keystroke.
An effect owns the timer now: each keystroke cancels the previous one,
one search fires per settled query, and entering search mode carries
the scope already picked. Dropping below the minimum length exits
search deterministically, including the backspace to one character
that previously left stale two-character results on screen.
The minimum query length lives in one exported constant instead of
three synchronized literals, and Cmd+F finally does something: the
already-defined focusSearchBar keybind expands and focuses the search
bar through a handle on ExpandableSearchButton.
Per-tab search state and search as a navigation target remain with the
search refinement plan's Phase 2; they touch the shell layout another
session is actively changing.
sources.list has carried peer replicas since 3ac1f0af7, but the Storage
page filtered the response to adapter sources and rendered filesystem
sources from the local ephemeral registry only, so synced sources fell
between the two queries. Replicas now render with their own card in the
same language as local drives: the owning device as the subtitle, a
replica badge, item count and size, and the sync age. Opening one routes
the explorer through the owning device's slug, which serves the listing
from the replicated index, same as the sidebar rows. devices.list gains
show_paired so the slug lookup can see paired devices at all.
README and CONTRIBUTING updates, the working register brought current,
locations and data-model pages reconciled, redundancy and overview
polish, media view tweaks, regenerated schemas, and the new Spacedrive
icon asset.
Library loading no longer dispatches a filesystem discovery pass. The
desktop requests one idempotent pass after the connected shell
renders, a bare daemon initiates nothing, and sd index start
--defaults gives CLI and server operators the same explicit path.
Restored volume snapshots prevent repeat full walks.
Hot-tier bake completion epochs survive virtualized cell unmounts, and
a thumbnail hides its fallback only after the current image element
loads its current URL. The Tauri asset boundary maps canonical macOS
APFS data-volume paths back to the scoped /Users and /Volumes aliases
so originals load without a 403, with tests over the alias mapping.
A machine-scoped registry discovers host FFmpeg and companion tools,
surfaced in Services settings. The thumbnail service uses them for
video posters and on-demand scrub sheets: thumbstrips bake per request
into the volume hot tier and serve over the local HTTP servers with
immutable content-versioned URLs, wired to the timeline scrubber.
The path bar reads a typed path-context query backed by the daemon's
source, volume map, watcher, persistence, and pin state, replacing the
location/index-mode indicator. Containment resolves on the daemon with
path components after volume alias normalization; a pin no longer
implies indexing or watching, and the docs say so.
One library scope, flat daily destinations, Places computed from
system folders without persisting them as locations, canonical rows,
and an Activity/Settings footer. Topology groups retire; their pins
survive seed convergence. Replica sources render under their owning
device and route into the explorer with that device's slug.
Peer device cards only appeared when a connect or disconnect event
pushed the device into the normalized cache, which the presence
flapping supplied constantly. With presence stable, a fresh page never
learned the peer existed. Both views now pass show_paired so paired
network devices come back with the device list itself, and the sidebar
gets the owning device's slug for routing replica sources.
Every closed connection fired ConnectionLost for its device, but sync
and remote ops open a connection per request and drop it, so paired
devices cycled offline/online on every 30-second peer refresh. A loss
now prunes closed connections and marks the device disconnected only
when none remain — the durable job-activity stream keeps presence
honest. devices.list also treats the registry's Connected state as
connected when iroh's path watcher momentarily reads None.
Direct paths on Tailscale's CGNAT range (100.64/10, fd7a:115c:a1e0::)
classify as a new Tailscale connection method with its own badge,
instead of reading as generic internet. Verified live from titan: 20
consecutive samples online, alternating LocalNetwork/Tailscale as iroh
picks between the LAN and tailnet routes.
Three layers of wrong in the overview numbers, now fixed:
The daemon shelled out to bare zfs/zpool, which a non-login launch
(TrueNAS, systemd) does not have on PATH, so the pool-root capacity
correction never ran and a 68 TB pool reported 5.6 TB with 0 B used.
Binaries now resolve against /sbin and /usr/sbin. Statistics also
overlay live volume-manager capacity over the persisted rows, which
froze at track time, and a re-sighted volume refreshes its row's
capacity. The duplicated volume-capacity implementation now delegates
to one static body.
The Files Indexed card added source counts to library statistics that
were already derived from the same source rows, roughly doubling every
number. It reads the statistics alone now.
Each device answers a byterange DeviceSummary with its own statistics,
peers cache it on every sync, and libraries.info adds the cached
summaries at read time. Every device reports the same fleet totals,
computed once by the machine that owns the data.
sources.list now appends paired devices' sources from the peer-mount
replica set, attributed with the owning device's id and label and
marked status "replica". The overview device card renders them, so a
paired NAS shows its sources instead of "No volumes".
directory_listing serves paths on another device straight from that
device's replicated index: same conversion as the local cached path
(extracted into shared helpers), no indexer dispatch, since indexing
happens on the owner. Verified from titan against the Mac's replica.
sync_device now holds a per-device lock; the refresh loop clears the
debounce before running, so a reconnect in that window was loading the
same snapshots twice in parallel.
README rewritten for the 2.0 release framing. AGENTS.md points at
product-direction.mdx for the product boundary and makes
PROJECT_STATUS.md the project-wide working register (the entries
teardown table stays authoritative for that track). CLAUDE.md and
PROJECT_STATUS.md are now tracked. Spacebot VISION marked retired.
justfile gains dev-docs/build-docs and splits dev-desktop-watch out of
dev-desktop. Tauri gen schemas regenerated.
ZFS compression makes apparent file sizes exceed allocated blocks, so
comparing the store's unique bytes against statfs usage clamped a
compressed dataset's duplicate segment to nothing. The volume now
carries indexed_bytes beside unique_bytes, both from the source rows,
and the bar draws the duplicate fraction of that pair scaled onto the
used segment: both sides of the ratio come from one ledger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The overview bar's duplicate segment was Math.floor(used * 0.7): the
domain Volume had no unique_bytes field, nothing ever wrote the column,
and the two calculators queried entry tables that no longer exist.
The store now counts it where it is knowable: each distinct content
once, plus files with no identity yet, which are unique until proven
otherwise. The figure rides the existing rollup onto the source row,
volumes.list resolves it per volume (whole-volume source wins, subtree
sources sum), and the refresh path reads the same rows. A volume
nothing has indexed reports None and the bar shows plain usage, never
an estimate. Also adds the missing directory_count, content_count and
unique_bytes columns to the source upsert's update list, which only
ever updated counts on first insert.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A location was fifteen fields rooted at an entry. It is six now: a name, the
source holding it, a path relative to that source's root, and whether someone
put it there. It owns nothing, so unpinning is a DELETE of one row and the
volume index does not notice.
Everything dropped lives somewhere that already had it, and mostly had it
better. scan_state and last_scan_at are the source's. total_size and file_count
come off the arena's rollups, which are current rather than as of the last scan
and can say a subtree was summarised where a stored integer cannot. index_mode
and job_policies configured an indexer and a job pipeline that are both gone.
entry_id is the ownership this phase exists to remove.
The path is relative so a pin survives its drive mounting somewhere else, which
is the whole reason a source carries a root and a location does not. Sources
nest, so the innermost one containing the path wins.
IndexMode goes with it. It was set on every IndexerJobConfig, threaded through
the walk, and never read: the phase that consulted it was deleted with the
persistent indexer, and content identity is its own job now. Four CLI flags and
two modals were choosing between values nothing looked at.
No rows carry over. Every one was anchored to an entry_id in a table this
series removes. The five folders a person recognises are written at library
creation instead, which is why there is no longer anything to add: Home,
Desktop, Documents, Downloads and Pictures are simply there.
Three more things fell out. File::from_entry_uuids and its siblings became
File::at_path, for_record and announce, which is the pattern five call sites
had each written by hand. SdPath::Content resolves through copies_of_content
rather than joining entries to locations to devices, so it finds a copy on any
drive rather than only where a location happened to be. And the peer file
protocols allow source roots instead of location paths, which reaches at least
as far and costs no query.
One thing removed rather than ported: user_metadata_tag checked that a
file-scoped tag came from the device owning the entry, via the location holding
it. A record belongs to whichever machine walked it and carries no device
column, so that check wants re-deriving when sync returns on top of sources
rather than being rewritten to always pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>