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>
user_metadata already had the right shape: entry_uuid or content_identity_uuid,
exactly one set. Nothing needed redesigning. The uuids just start meaning the
record the volume index minted and the content uuid the source store derives
from the bytes, and the joins that went through the entry table go through the
arena instead.
Tags stay in library.db, which is right rather than a compromise. A tag is a
claim by a person, so it syncs and travels with the library. That is the
opposite of a sidecar, which is a property of the bytes and belongs beside them.
TagTargets loses a variant it cannot have. Entry(Vec<i32>) addressed rows by
database id and there are no rows and no ids; the client never sent it. What is
left is Content and File, which is the real distinction: tag the bytes and reach
every copy on every drive, or tag this one. The client already chose content
when the file had been identified and fell back otherwise, so the policy was
already right and only the names were wrong.
ApplyToTargets was a second copy of the same enum with the same three variants.
tags.create takes TagTargets now, and both inputs share one validate.
Three helpers landed on the cache because four call sites wanted them:
content_of, copies_of_content and path_of_record. alternate_instances and
files.by_id had hand-rolled versions of the first and last; they use these now
and lost 100 lines between them.
Content-scoped changes emit events for every copy of the bytes, not just the
one that was named. Tagging a photo on one drive updates the row showing it on
another.
Two dead things fell out: find_entries_by_semantic_tags and find_files_by_tags,
both returning Vec<i32> entry ids, neither called.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deleting locations.triggerJob cut the last live dispatcher for every media
job. What was left was six jobs, six processors, six registered actions and
an 853-line SidecarManager, all keyed on entry rows nothing writes, reachable
from nothing. The UI buttons that fired them were the only thing keeping the
registrations honest, and they were firing into a pipeline that had no input.
The hot tier is unaffected. ThumbService over sources/<id>/thumbs.pvcache is
the live thumbnail path, it is source-scoped already, and it shares no code
with any of this.
Two things survive because they outlive their writers. SidecarKind, variant,
format and status move to domain::sidecar: they are the vocabulary a derived
artifact is addressed by, they are what SdPath::Sidecar parses, and
content_sidecar will be keyed on the same three. And File.sidecars keeps its
shape, empty until something fills it, so the subtitle and thumbstrip lookups
in the client keep compiling.
The EXIF and ffmpeg extraction went with the rest. It read into
image_media_data and video_media_data, which are entity rows on their way
out, and its field mapping is a straight match for the facets declared in
df965b851. When the facet writer gets built it wants rewriting against those
columns rather than resurrecting; git show df965b851~1:core/src/ops/media/
metadata_extractor.rs has the EXIF handling worth copying.
sd-server's sidecar route now builds its path through sd-sidecar-path, the
same way the tauri one already did. Both still serve from <library>/sidecars,
both are equally empty, and L5 re-roots them together.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing produces a row in `entry` after this. The walk fills the volume
index and its source store, which is what the arena has been doing since
the storage map landed; the persistent half of the indexer was a second
writer to a second store that no source ever read.
Gone: IndexPersistence, and with it the branch in every phase. The state
machine is Discovery then Processing, since Aggregation existed to bubble
directory sizes the arena now maintains incrementally, and
ContentIdentification is ContentIdentityJob's job against the source store.
DatabaseAdapter, PersistentEventHandler and the location watching that fed
them go too, so ArenaWriter is the only change handler and the walk and the
watcher differ only in Notify.
DatabaseStorage was 1230 lines of entry writes wrapped around three things
the arena actually uses. Those are ops::indexing::metadata now: EntryMetadata,
extract_metadata and get_inode, plus is_hidden_path. delete_subtree moves onto
the entry entity, which is what it operates on and what it dies with.
locations.rescan, locations.enable_indexing and locations.triggerJob dispatched
the deleted indexer or its media jobs, so the ops and their UI go. Reindex and
Run Now are gone from the inspector; the job policy toggles stay, since they
outlive the ops that read them. Adding a location no longer dispatches a walk,
which is the point: the drive is already mapped.
The entry tables and their readers are still here. That is the next commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Track this folder as a source" is our vocabulary, not a person's, and it
cannot answer what they get for doing it. Everything on the drive is already
searchable before anyone chooses anything. What the library adds is the part a
walk cannot rebuild: tags and notes that stay put, files still listed when the
drive is unplugged, sync to another device.
So the gesture is Add to Library, on a directory, in the explorer context menu.
Photos, Music and Lightroom all landed on the same word because people already
have it, and Spacedrive has libraries too.
Home goes in on the first library open, because it is the answer almost
everyone would give if asked which folder that should be, and asking is worse
than doing it. Once only: any registered source means the person has made their
own arrangements, so one removed on purpose does not come back next launch.
Anchoring needed fixing first. volume_for_path is a prefix test, and
/Users/me is not under /System/Volumes/Data, so tracking home would have
dropped the anchor and started a second map of files the drive already holds.
locate_path returns the volume and the path as that volume spells it, deciding
it by inode rather than by a platform table, and track_and_index takes the
volume's spelling over the caller's.
Tracking a volume did not index it. There were three flows and none of
them was one gesture: the context menu offered Track and Index as
separate items, and AddStorageModal tracked the volume and then created a
Location, which is the model being deleted.
volumes.track now calls the same track_and_index that sources.track does.
The medium and the index stay separate underneath, since a drive can be
tracked with nothing on it indexed and a folder can be indexed on a
volume nobody tracks; what converges is the click.
The shared body matters more than the saving. Both entry points forget
the same two steps otherwise, seeding from the snapshot and clearing what
the new pass will not revisit, which is exactly what sources.track was
missing when it was written separately.
An external mount indexes unfiltered, since a drive being archived wants
completeness rather than rules that hide files on purpose. An unmounted
drive tracks without walking and gets its map when it returns.
Index Volume becomes Re-index Volume and only shows for tracked drives,
since the first index is no longer something to ask for.
A filesystem source used to appear as a side effect: browsing a directory
dispatched a walk and the walk registered whatever partition it landed
in. sources.track is that intent made explicit. Whole drive and
folder-within-a-drive are the same call with a different path, since the
volume resolves from the path and the source anchors to it.
Indexing starts immediately rather than waiting to be asked, and the
action returns as soon as the row exists, because registration is instant
and a walk over a drive is not. The job then reports against a source
that already exists.
sources.list serves both kinds now, so the sidebar stopped needing a
group per registry: filesystem rows render as walked roots with their
attachment state, adapter rows as before. The union is transitional,
reading filesystem rows from the sources table and adapter rows from
registry.db, and folding the second into the first is the rest of P3.
Content ids stay out of the walk on purpose. The walk is fast and worth
keeping fast, and records_needing_content_identity is already the query a
separate pass would drive. Nothing generates them yet, which the plan now
says plainly rather than implying the sampled tier is wired.
volumes and sources.json described the same drive twice, joined by a
fingerprint string that was a foreign key in neither direction and
updated at different moments, so their counts drifted with nothing to
notice. The line that holds is medium against index: a volume is a device
fact, true whether or not anything is indexed, and a source is a
Spacedrive fact.
So sources get a row in library.db with a nullable volume_uuid. Nullable
because the cardinality is many sources to optionally one volume: nested
roots are distinct sources, and adapter, cloud and fingerprint-less
network sources have no volume at all. One table serves both kinds,
forked by data_type, matching _schema.data_type_id in the source's own
store.
Anchoring replaces fingerprint matching outright. A volume-backed source
stores its root relative to the volume, so a remount re-derives the
absolute path instead of being a case anything handles: the volume keeps
its identity, the source keeps its path within it. A whole drive has an
empty relative root. Roots that fall outside the mount point they claim
stay unanchored rather than binding to a volume that cannot produce them.
SourceRegistry is now pure and does no I/O, so the arena's read path
never waits on a database to answer which source owns a path. The cache
holds the memory half and the open library holds the durable one. That
seam is awkward on purpose and visible in the code: the cache is
machine-scoped, a registration is library metadata, and until sources are
reachable from wherever they are mutated the cache follows the open
library.
sources.json is deleted rather than migrated, per the reindex-from-zero
cutover. Index facts still on volumes move when their writers do.
The adapter minted a parallel record universe over bytes a filesystem source
will index — the shape file-backed-sources.md exists to forbid, and the reason
photos showed 0-byte sizes and UUID names in the UI. The Photos app rewrite
already went the other way: it follows an explorer window and renders
thumbnails the daemon bakes from filesystem records.
What was worth keeping is the reading: the ZASSET projection, the album and
person joins resolved by shape across Core Data ordinals, the derivative
fallback chain, the APFS-clone snapshot. That is now crates/photos, a
standalone read-only library reader that mints nothing. Its API speaks
ordinary units — unix milliseconds instead of Core Data seconds, a MediaKind
instead of ZKIND — so every Apple-specific decode stays behind the boundary.
It is the harvest path for file-backed enrichment when P2 lands.
Falling out with it: AdapterKind (apple-photos was the only Native one, and
core never projected the field to the frontend), register_native_adapters,
the rusqlite/libc/dirs dependencies and the Sqlite error variant, and the
Import -> Photos entry that pointed at the adapter.
Photos no longer runs its own demo bake; it follows a Spacedrive explorer
window via a new navigation-focus resource and renders that window's
folder as a media grid, sourced from thumbnails baked by a new daemon-owned
thumbs service. Supporting changes: pvcache moves to a v2 format that
stores each tile's true aspect (envelope + frame) instead of forcing a
square crop, peer mounts now refresh on a generation check instead of only
at reconnect, and the archive crate's `spine` module is renamed to `record`
throughout.
Absolute paths from one developer's machine sat in an example, a shell script,
recorded benchmark metadata, task docs and design docs. The example and the
script were broken for anyone else as a result: the example now resolves against
CARGO_MANIFEST_DIR and the script derives the repo root from git. The duplicate
copy of that script under core/scripts is gone.
Also drops a pointer to a repository that is not public.
Sidebar lists indexed drives including detached ones, volume rows and overview
cards carry persisted entry counts, and the explorer gains a list view over
daemon ops with event-driven refresh.
- Add SHARE-000..011 task specs and docs/design/shares.md defining
the sd.app public sharing architecture and wire protocols
- Rework INDEX-010 to scope ephemeral UUID reconciliation per-library
instead of a single global map, since core can have multiple
libraries loaded at once
- Add sidecar serving and configurable bind host to apps/server, and
wire the web platform's daemon status to the server's own origin
- Add quick preview button to the file inspector, with a null-safe
useOptionalExplorer for use outside the explorer context