mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-11-03 05:45:04 -05:00
Docs, register, and interface polish from parallel sessions
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.
This commit is contained in:
1 parent
c32768f2e4
commit
b653676e80
38 files changed
+829
-988
No files matched your search
+22
-8
@@ -144,9 +144,15 @@ The `xtask setup` command:
|
||||
- Builds the release daemon for Tauri bundler validation
|
||||
- Downloads iOS dependencies if iOS targets are installed
|
||||
|
||||
**Media formats.** Video, HEIC, RAW and PDF thumbnails come from ImageIO and
|
||||
QuickLook on macOS, so nothing extra is needed there. On Linux and Windows they
|
||||
need the prebuilt codec bundle, a 91 MB download into `apps/.deps/`:
|
||||
**Media formats.** ImageIO and QuickLook provide HEIC, RAW, PDF, and video
|
||||
previews on macOS. Video thumbnails and thumbstrips also use an FFmpeg installed
|
||||
on the host. Spacedrive discovers normal package-manager locations even when a
|
||||
desktop launch has a restricted `PATH`.
|
||||
|
||||
The default build does not link or bundle FFmpeg. Install it with Homebrew or
|
||||
WinGet through `tools.install`, or install it through your Linux package
|
||||
manager. Use the native dependency bundle only when developing an in-process
|
||||
consumer of the optional `ffmpeg` or `heif` Cargo features:
|
||||
|
||||
```bash
|
||||
cargo run -p xtask -- setup --native-deps
|
||||
@@ -202,10 +208,13 @@ Running `cargo build` from the project root builds all core Rust components:
|
||||
|
||||
**Important: Media Processing Features**
|
||||
|
||||
By default, `cargo build` compiles without optional features to speed up builds and avoid native dependency issues during testing. However, this means media processing features are **disabled by default**:
|
||||
By default, `cargo build` compiles without optional linked features to keep the
|
||||
binary small and avoid native dependency issues during testing:
|
||||
|
||||
- ❌ **Without features:** No video thumbnails, no audio transcription, no HEIF image support
|
||||
- ✅ **With `--features ffmpeg,heif`:** Full media processing capabilities
|
||||
- Video thumbnails and thumbstrips use host FFmpeg when it is installed.
|
||||
- macOS image previews use the system ImageIO and QuickLook frameworks.
|
||||
- Audio transcription and linked FFmpeg consumers still require their Cargo features.
|
||||
- Linux and Windows HEIF decoding still requires the `heif` feature.
|
||||
|
||||
**For development, use:**
|
||||
```bash
|
||||
@@ -217,9 +226,14 @@ cargo daemon # Automatically includes sd-core/ffmpeg,sd-core/heif features
|
||||
cargo cli # Automatically includes sd-core/ffmpeg,sd-core/heif features
|
||||
```
|
||||
|
||||
The Tauri desktop app **always** includes these features by default, so end users get full functionality. This design keeps the test suite fast while giving developers easy access to full features when needed.
|
||||
The Tauri desktop app uses the same default feature set. Installing host FFmpeg
|
||||
adds its media capabilities without rebuilding Spacedrive.
|
||||
|
||||
**Licensing note:** Spacedrive is Apache-2.0 and the default build carries no FFmpeg. The `ffmpeg` feature links FFmpeg itself, which ships under LGPL-2.1+, or GPL-2.0+ when built with `--enable-gpl`. Anyone redistributing a binary built with that feature is responsible for meeting the terms of whichever FFmpeg build they linked.
|
||||
**Licensing note:** Spacedrive is Apache-2.0 and the default build carries no
|
||||
FFmpeg. Host FFmpeg remains separately installed software. The `ffmpeg` feature
|
||||
links FFmpeg itself, which ships under LGPL-2.1+, or GPL-2.0+ when built with
|
||||
`--enable-gpl`. Anyone redistributing a binary built with that feature is
|
||||
responsible for meeting the terms of the FFmpeg build they linked.
|
||||
|
||||
**Note:** The Tauri desktop app is excluded from `cargo build` because it requires the frontend to be built first. See [Desktop Development](#desktop-development-tauri) for Tauri-specific setup.
|
||||
|
||||
|
||||
Generated
+2
@@ -11744,9 +11744,11 @@ dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"futures",
|
||||
"http 1.3.1",
|
||||
"image",
|
||||
"mime_guess",
|
||||
"rust-embed",
|
||||
"sd-core",
|
||||
"sd-pvcache",
|
||||
"sd-sidecar-path",
|
||||
"secstr",
|
||||
"serde",
|
||||
|
||||
+31
-13
@@ -41,16 +41,22 @@ Update this table whenever work starts, lands, becomes blocked, or changes
|
||||
owner. Keep only active and near-term work here. Move completed context to
|
||||
Recent landed.
|
||||
|
||||
| Work | Status | Owner | Next proof or action |
|
||||
| --- | --- | --- | --- |
|
||||
| Mac client operating titan | complete, verified live | done 2026-09-15 | Product-sequence step one is done: the Mac pairs with titan, replicates and streams all nine sources through the WebDAV peer mount (`1d4afd204`), operates it directly with `sd --device titan <any command>` (`996ec6422`), tails its logs with `sd --device titan logs follow`, and watches dispatched work with `sd job remote --watch` (`0f9307b62`). Pairing now triggers the initial peer sync immediately. Every leg verified live in both directions. Next per the sequence: package the proven CLI operations as a Spacedrive skill, then the MCP transport. |
|
||||
| Preserve NAS and pool evidence | pending | James | Save the TrueNAS config, encryption result, pool topology, GUID, and SMART output described by the runbook. |
|
||||
| Track the Expansion drive | pending | James | Run one `sources track` pass over the Expansion drive before its contents merge into `jamie-nas`, then freeze it. |
|
||||
| Back up and export | pending | James | Copy and verify `~/spacedrive/data` off titan (it now contains the freezes), then cleanly export the pool before the drives move. |
|
||||
| Drive catalog H0 | in progress, uncommitted worktree | unconfirmed | Finish the package and archetype contract without crossing the physical-identity firewall. |
|
||||
| Product documentation and Fumadocs conversion | complete, uncommitted worktree | James | The README now states the finished product contract. The docs type check and production build pass. Review and commit the migration, product doctrine, README, and offline build fix without absorbing unrelated drive or generated-schema work. |
|
||||
| Retire Spacebot, Console, and storefront surfaces | blocked on exact private backup | unowned | The company tree contains product and research documents, but no exact copy of the current Spacebot UI, configuration, and design files was found. Back those up, then remove product-specific routes, configuration, UI, build aliases, generated types, and docs. Preserve generic filesystem operations, permissions, remote access, jobs, and logs. |
|
||||
| Reconcile published documentation | complete, uncommitted worktree | current session | The overview, core architecture, storage, networking, sync, CLI, and extension pages now present the October release and source-store model. Fumadocs type-checks, all root-relative doc links resolve, and the production build generates 185 routes. Review and commit with the rest of the docs migration. |
|
||||
| Work | Status | Owner | Next proof or action |
|
||||
| ------------------------------------------------- | -------------------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Mac client operating titan | complete, verified live | done 2026-09-15 | Product-sequence step one is done: the Mac pairs with titan, replicates and streams all nine sources through the WebDAV peer mount (`1d4afd204`), operates it directly with `sd --device titan <any command>` (`996ec6422`), tails its logs with `sd --device titan logs follow`, and watches dispatched work with `sd job remote --watch` (`0f9307b62`). Pairing now triggers the initial peer sync immediately. Every leg verified live in both directions. Next per the sequence: package the proven CLI operations as a Spacedrive skill, then the MCP transport. |
|
||||
| Preserve NAS and pool evidence | pending | James | Save the TrueNAS config, encryption result, pool topology, GUID, and SMART output described by the runbook. |
|
||||
| Track the Expansion drive | pending | James | Run one `sources track` pass over the Expansion drive before its contents merge into `jamie-nas`, then freeze it. |
|
||||
| Back up and export | pending | James | Copy and verify `~/spacedrive/data` off titan (it now contains the freezes), then cleanly export the pool before the drives move. |
|
||||
| Drive catalog H0 | in progress, uncommitted worktree | unconfirmed | Finish the package and archetype contract without crossing the physical-identity firewall. |
|
||||
| Sidebar simplification | first slice built, uncommitted | current session | The active sidebar now has one library scope, flat daily destinations, Places, canonical rows, and an Activity/Settings footer. Places includes computed folders from the current device without persisting them as locations and renders them with Spacedrive's Folder and Home assets. Explorer view preferences are route-scoped again, so Photos and visual collections can default to Media without leaking that mode into folders or persisted tabs. The library menu now renders above sidebar rows while remaining below dialogs. The desktop production build passes. Next: inspect the live library, then refine Home and Storage ownership from real use. |
|
||||
| Explorer path status | built, uncommitted; live proof pending | current session | `paths.context` now resolves APFS aliases and reports source, volume map, watcher, memory, restart-cache, source-store, availability, system Place, and nearest pin state. The PathBar visualizes containment instead of a false processing pipeline: the current folder sits inside its source, watching and persistence appear as outcome labels, and the physical volume sits outside that boundary. Runtime map terminology is hidden in collapsed technical details. Pins now rebuild from the registry's mounted source root, so they follow remounts. Focused Rust tests, the desktop production build, and the Fumadocs type and production builds pass. Restart the daemon and inspect Home, `/Applications`, a pinned folder, and a denied path. |
|
||||
| Virtualized thumbnail remounts | fixed, uncommitted; live proof pending | current session | Hot-tier bake completion epochs now survive cell unmounts, and each thumbnail hides its fallback only after its current DOM image loads. Media rows now keep stable React identity as the virtual window advances, width is measured before paint, content is no longer deliberately invalidated for every render, and live row-count changes no longer snap the camera roll to the bottom. The desktop production build passes. Scroll and trigger selection or inspector rerenders in a large media view to confirm the grid remains stable. |
|
||||
| Quick Preview originals | fixed, uncommitted; live proof pending | current session | The Tauri asset boundary now maps canonical macOS APFS data-volume paths back to the scoped `/Users` and `/Volumes` aliases. Image, video, audio, text, mesh, and local thumbnail-original renderers share the fix. Open a home-directory image and an external-volume file to confirm originals load without a 403. |
|
||||
| Permission-aware automatic startup | in progress, uncommitted | current session | Library loading no longer dispatches a new filesystem discovery pass. The desktop requests one idempotent pass after the connected shell renders; a bare daemon does not initiate discovery, and `sd index start --defaults` gives CLI and server operators the same explicit path. Restored volume snapshots now prevent repeat full walks. Next: model denied coverage and add the macOS permission explanation and Full Disk Access affordance. |
|
||||
| Host media tools | built, uncommitted; live proof pending | current session | The machine-scoped registry discovers host FFmpeg outside GUI PATHs, reports its version and media capabilities, and exposes explicit Homebrew/WinGet installation. Services settings now shows the resolved version, path, capability coverage, missing FFprobe state, refresh, and a confirmed package-manager install action. Default builds use QuickLook or host FFmpeg for hot video thumbnails and generate immutable 5 by 5 thumbstrips on hover. Core, linked-feature, server, Tauri shell, generated-client, and desktop production builds pass; real FFmpeg output is 384×216 and 800×450 in the focused fixtures. Restart the daemon, inspect Services settings, then open a video with no old sidecars and confirm its poster and hover scrub sheet. |
|
||||
| Product documentation and Fumadocs conversion | complete, uncommitted worktree | James | The README now states the finished product contract. The docs type check and production build pass. Review and commit the migration, product doctrine, README, and offline build fix without absorbing unrelated drive or generated-schema work. |
|
||||
| Retire Spacebot, Console, and storefront surfaces | blocked on exact private backup | unowned | The company tree contains product and research documents, but no exact copy of the current Spacebot UI, configuration, and design files was found. Back those up, then remove product-specific routes, configuration, UI, build aliases, generated types, and docs. Preserve generic filesystem operations, permissions, remote access, jobs, and logs. |
|
||||
| Reconcile published documentation | complete, uncommitted worktree | current session | The overview, core architecture, storage, networking, sync, CLI, and extension pages now present the October release and source-store model. Fumadocs type-checks, all root-relative doc links resolve, and the production build generates 185 routes. Review and commit with the rest of the docs migration. |
|
||||
|
||||
## Product sequence after the NAS lock
|
||||
|
||||
@@ -106,9 +112,12 @@ replace the dated observations when its state changes.
|
||||
decisions require confirmed integrity hashes.
|
||||
- The persistent `entry` indexing and read paths have been removed. Do not
|
||||
restore or deepen them.
|
||||
- A location is a pin and retention policy over a source-relative path. It does
|
||||
not own records or trigger a second identity namespace. L0, the pin row, has
|
||||
landed. L1 retention and L2 location-driven watching remain open.
|
||||
- A location is a pin over a source-relative path. It does not own records,
|
||||
trigger indexing, or enable a watcher. A future policy may target its stable
|
||||
ID, but the existence of the pin must not imply that policy.
|
||||
- Places is client vocabulary for navigation destinations computed from known
|
||||
system folders, sources, pins, and extension contributions. It is not a
|
||||
database model, durable identity, operation namespace, or indexing lifecycle.
|
||||
- Cross-source truth still belongs to the future content `catalog.db`. Until it
|
||||
lands, projections such as global redundancy remain incomplete.
|
||||
- `files.duplicates` can find repeated content within the stores it queries. A
|
||||
@@ -149,6 +158,15 @@ These are regression boundaries, not incidental fixes:
|
||||
- Duplicate-space UI uses `indexed_bytes` and `unique_bytes` from the same
|
||||
source-store ledger. Filesystem allocation and ZFS compression cannot be
|
||||
mixed into that ratio.
|
||||
- A hot-thumbnail bake completion must survive virtualized cell unmounts. Hide
|
||||
a fallback icon only after the current image element has loaded its current
|
||||
URL.
|
||||
- Keep Tauri's asset protocol restricted to the current home and mounted
|
||||
volumes. Normalize macOS APFS data-volume aliases before conversion instead
|
||||
of widening the protocol scope.
|
||||
- Resolve source and pin containment on the daemon with path components after
|
||||
volume alias normalization. Never use raw string prefixes, and reject stored
|
||||
relative paths containing roots, parent components, or platform prefixes.
|
||||
|
||||
## Known current limits
|
||||
|
||||
|
||||
@@ -231,8 +231,9 @@ just dev-server # Headless server and web interface
|
||||
just dev-mobile # Mobile client
|
||||
```
|
||||
|
||||
Heavy media dependencies are optional outside macOS. Install the native codec
|
||||
bundle when you need FFmpeg, libheif, or Pdfium:
|
||||
The default build can use FFmpeg installed on the host for video thumbnails and
|
||||
thumbstrips. Install the native codec bundle only when developing code that
|
||||
links the optional FFmpeg, libheif, or Pdfium libraries:
|
||||
|
||||
```bash
|
||||
cargo run -p xtask -- setup --native-deps
|
||||
|
||||
File diff suppressed because one or more lines are too long.
@@ -2023,14 +2023,6 @@
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"cmd": {
|
||||
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
},
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
@@ -2038,6 +2030,14 @@
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cmd": {
|
||||
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -2049,10 +2049,6 @@
|
||||
"sidecar"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
@@ -2061,6 +2057,10 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"sidecar": {
|
||||
"description": "If this command is a sidecar command.",
|
||||
"type": "boolean"
|
||||
@@ -2083,14 +2083,6 @@
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"cmd": {
|
||||
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
},
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
@@ -2098,6 +2090,14 @@
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cmd": {
|
||||
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -2109,10 +2109,6 @@
|
||||
"sidecar"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
@@ -2121,6 +2117,10 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"sidecar": {
|
||||
"description": "If this command is a sidecar command.",
|
||||
"type": "boolean"
|
||||
@@ -6410,6 +6410,34 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"ShellScopeEntryAllowedArg": {
|
||||
"description": "A command argument allowed to be executed by the webview API.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "A non-configurable argument that is passed to the command in the order it was specified.",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "A variable that is set while calling the command from the webview API.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"validator"
|
||||
],
|
||||
"properties": {
|
||||
"raw": {
|
||||
"description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"validator": {
|
||||
"description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"ShellScopeEntryAllowedArgs": {
|
||||
"description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.",
|
||||
"anyOf": [
|
||||
@@ -6425,34 +6453,6 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ShellScopeEntryAllowedArg": {
|
||||
"description": "A command argument allowed to be executed by the webview API.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "A non-configurable argument that is passed to the command in the order it was specified.",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "A variable that is set while calling the command from the webview API.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"validator"
|
||||
],
|
||||
"properties": {
|
||||
"validator": {
|
||||
"description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>",
|
||||
"type": "string"
|
||||
},
|
||||
"raw": {
|
||||
"description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2023,14 +2023,6 @@
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"cmd": {
|
||||
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
},
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
@@ -2038,6 +2030,14 @@
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cmd": {
|
||||
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -2049,10 +2049,6 @@
|
||||
"sidecar"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
@@ -2061,6 +2057,10 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"sidecar": {
|
||||
"description": "If this command is a sidecar command.",
|
||||
"type": "boolean"
|
||||
@@ -2083,14 +2083,6 @@
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"cmd": {
|
||||
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
},
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
@@ -2098,6 +2090,14 @@
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cmd": {
|
||||
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -2109,10 +2109,6 @@
|
||||
"sidecar"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
@@ -2121,6 +2117,10 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"sidecar": {
|
||||
"description": "If this command is a sidecar command.",
|
||||
"type": "boolean"
|
||||
@@ -6410,6 +6410,34 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"ShellScopeEntryAllowedArg": {
|
||||
"description": "A command argument allowed to be executed by the webview API.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "A non-configurable argument that is passed to the command in the order it was specified.",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "A variable that is set while calling the command from the webview API.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"validator"
|
||||
],
|
||||
"properties": {
|
||||
"raw": {
|
||||
"description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"validator": {
|
||||
"description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"ShellScopeEntryAllowedArgs": {
|
||||
"description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.",
|
||||
"anyOf": [
|
||||
@@ -6425,34 +6453,6 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ShellScopeEntryAllowedArg": {
|
||||
"description": "A command argument allowed to be executed by the webview API.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "A non-configurable argument that is passed to the command in the order it was specified.",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "A variable that is set while calling the command from the webview API.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"validator"
|
||||
],
|
||||
"properties": {
|
||||
"validator": {
|
||||
"description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>",
|
||||
"type": "string"
|
||||
},
|
||||
"raw": {
|
||||
"description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-15
@@ -811,25 +811,33 @@ pub async fn files_needing_verification(
|
||||
pool: &sqlx::SqlitePool,
|
||||
batch_size: usize,
|
||||
) -> Result<Vec<PendingVerification>> {
|
||||
let rows: Vec<(Uuid, Option<Uuid>, Option<String>, Option<String>, i64, Option<String>)> =
|
||||
sqlx::query_as(&format!(
|
||||
"SELECT r.uuid, r.parent_uuid, d.path, r.title, f.size, c.sampled_hash \
|
||||
let rows: Vec<(
|
||||
Uuid,
|
||||
Option<Uuid>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
i64,
|
||||
Option<String>,
|
||||
)> = sqlx::query_as(&format!(
|
||||
"SELECT r.uuid, r.parent_uuid, d.path, r.title, f.size, c.sampled_hash \
|
||||
{PENDING_VERIFICATION} ORDER BY r.rowid LIMIT ?"
|
||||
))
|
||||
.bind(batch_size as i64)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
))
|
||||
.bind(batch_size as i64)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|(uuid, parent_uuid, parent_path, title, size, sampled_hash)| {
|
||||
Some(PendingVerification {
|
||||
uuid,
|
||||
external_id: address(parent_uuid, parent_path, title)?,
|
||||
size,
|
||||
sampled_hash,
|
||||
})
|
||||
})
|
||||
.filter_map(
|
||||
|(uuid, parent_uuid, parent_path, title, size, sampled_hash)| {
|
||||
Some(PendingVerification {
|
||||
uuid,
|
||||
external_id: address(parent_uuid, parent_path, title)?,
|
||||
size,
|
||||
sampled_hash,
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
|
||||
|
||||
@@ -52,10 +52,10 @@ pub use content::{uuid_for, ContentId, CONTENT_NAMESPACE};
|
||||
pub use db::{ItemRow, OverlayEvidence, SourceDb, Stamp};
|
||||
pub use error::{Error, Result};
|
||||
pub use file::{
|
||||
content_of, copies_of_content, count_files_needing_content, duplicate_copies,
|
||||
count_files_needing_verification, files_needing_content, files_needing_verification,
|
||||
filesystem_schema, mark_content_unreadable, ContentCopy, FileKind, FileWrite, Ledger,
|
||||
Observation, PendingContent, PendingVerification, Resolution, SubtreeRename, Watermark,
|
||||
content_of, copies_of_content, count_files_needing_content, count_files_needing_verification,
|
||||
duplicate_copies, files_needing_content, files_needing_verification, filesystem_schema,
|
||||
mark_content_unreadable, ContentCopy, FileKind, FileWrite, Ledger, Observation, PendingContent,
|
||||
PendingVerification, Resolution, SubtreeRename, Watermark,
|
||||
};
|
||||
pub use record::{ContentIdentity, Record, RECORD_SCHEMA};
|
||||
pub use schema::{DataTypeSchema, FieldType, ModelDef};
|
||||
|
||||
+16
-10
@@ -917,20 +917,18 @@ async fn ownership_and_link_targets_land_in_the_facet() {
|
||||
.await
|
||||
.expect("apply");
|
||||
|
||||
let (uid, gid, target): (Option<i64>, Option<i64>, Option<String>) = sqlx::query_as(
|
||||
"SELECT uid, gid, link_target FROM facet_file WHERE record_uuid = ?",
|
||||
)
|
||||
.bind(uuid)
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("facet row");
|
||||
let (uid, gid, target): (Option<i64>, Option<i64>, Option<String>) =
|
||||
sqlx::query_as("SELECT uid, gid, link_target FROM facet_file WHERE record_uuid = ?")
|
||||
.bind(uuid)
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("facet row");
|
||||
|
||||
assert_eq!(uid, Some(501));
|
||||
assert_eq!(gid, Some(20));
|
||||
assert_eq!(target.as_deref(), Some("../archive/original.mp4"));
|
||||
}
|
||||
|
||||
|
||||
/// A sweep that saw almost nothing must not be believed. The walk that
|
||||
/// opened it lost its footing (interrupted, resumed, root unmounted); the
|
||||
/// drive did not lose half its files between two scans. Stale rows are
|
||||
@@ -950,7 +948,11 @@ async fn a_sweep_that_saw_almost_nothing_is_refused() {
|
||||
ledger.resolve(&observe("f000.bin", 10, 1_000, Some(0)));
|
||||
ledger.resolve(&observe("f001.bin", 10, 1_000, Some(1)));
|
||||
let gone = ledger.finish_sweep(&[]);
|
||||
assert!(gone.is_empty(), "the blind sweep deleted {} rows", gone.len());
|
||||
assert!(
|
||||
gone.is_empty(),
|
||||
"the blind sweep deleted {} rows",
|
||||
gone.len()
|
||||
);
|
||||
assert_eq!(ledger.len(), 200, "every binding survives the refusal");
|
||||
|
||||
// An honest sweep that saw most of the store still works.
|
||||
@@ -959,5 +961,9 @@ async fn a_sweep_that_saw_almost_nothing_is_refused() {
|
||||
ledger.resolve(&observe(&format!("f{i:03}.bin"), 10, 1_000, Some(i as i64)));
|
||||
}
|
||||
let gone = ledger.finish_sweep(&[]);
|
||||
assert_eq!(gone.len(), 50, "the honest sweep removes what it did not see");
|
||||
assert_eq!(
|
||||
gone.len(),
|
||||
50,
|
||||
"the honest sweep removes what it did not see"
|
||||
);
|
||||
}
|
||||
+36
-4
@@ -1,13 +1,45 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@spacedrive/tokens/theme';
|
||||
@import '@spacedrive/tokens/css/themes/light';
|
||||
@import 'fumadocs-ui/css/neutral.css';
|
||||
@import 'fumadocs-ui/css/preset.css';
|
||||
|
||||
:root {
|
||||
--color-fd-primary: #36a3ff;
|
||||
/* Fumadocs theme variables mapped onto Spacedrive design tokens
|
||||
(@spacedrive/tokens). The token defaults are the dark theme and `.light`
|
||||
swaps the token values, so one mapping covers both modes. */
|
||||
:root,
|
||||
.dark {
|
||||
--color-fd-background: var(--color-app);
|
||||
--color-fd-foreground: var(--color-ink);
|
||||
--color-fd-muted: var(--color-app-box);
|
||||
--color-fd-muted-foreground: var(--color-ink-dull);
|
||||
--color-fd-popover: var(--color-menu);
|
||||
--color-fd-popover-foreground: var(--color-menu-ink);
|
||||
--color-fd-card: var(--color-app-box);
|
||||
--color-fd-card-foreground: var(--color-ink);
|
||||
--color-fd-border: var(--color-app-line);
|
||||
--color-fd-primary: var(--color-accent);
|
||||
--color-fd-primary-foreground: var(--color-white);
|
||||
--color-fd-secondary: var(--color-app-button);
|
||||
--color-fd-secondary-foreground: var(--color-ink);
|
||||
--color-fd-accent: var(--color-app-selected);
|
||||
--color-fd-accent-foreground: var(--color-ink);
|
||||
--color-fd-ring: var(--color-accent);
|
||||
--color-fd-info: var(--color-status-info);
|
||||
--color-fd-warning: var(--color-status-warning);
|
||||
--color-fd-error: var(--color-status-error);
|
||||
--color-fd-success: var(--color-status-success);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-fd-primary: #36a3ff;
|
||||
/* The sidebar sits on the darker sidebar surface, like the app */
|
||||
#nd-sidebar,
|
||||
.dark #nd-sidebar {
|
||||
--color-fd-card: var(--color-sidebar);
|
||||
--color-fd-muted: var(--color-sidebar-box);
|
||||
--color-fd-muted-foreground: var(--color-sidebar-ink-dull);
|
||||
--color-fd-secondary: var(--color-sidebar-button);
|
||||
--color-fd-accent: var(--color-sidebar-selected);
|
||||
--color-fd-border: var(--color-sidebar-line);
|
||||
}
|
||||
|
||||
html {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "sd-docs",
|
||||
"dependencies": {
|
||||
"@spacedrive/tokens": "^0.2.3",
|
||||
"cn": "^0.2.6",
|
||||
"fumadocs-core": "16.15.10",
|
||||
"fumadocs-mdx": "15.4.0",
|
||||
@@ -204,6 +205,8 @@
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
"@spacedrive/tokens": ["@spacedrive/tokens@0.2.3", "", {}, "sha512-AozHOoDjyx08R7hn6mvXAxGNMobro7XjsPML59fAP6e+E/ZRiOSuDCh9s6xgUgsH+iiOBkv52Em9QAR4LT47iQ=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
|
||||
|
||||
@@ -21,7 +21,7 @@ assertions. Libraries connect those stores into one user experience.
|
||||
| Content | Evidence about bytes | Candidate or confirmed content identity |
|
||||
| Edge | Relationship between records | Hierarchy and other typed links |
|
||||
| Overlay | Durable assertion | Tags, notes, policy, review state, and provenance |
|
||||
| Location | Pin inside a source | Name, source-relative path, and attention policy |
|
||||
| Location | Pin inside a source | Name and source-relative path |
|
||||
|
||||
These objects have different lifetimes. Keeping those lifetimes explicit is the
|
||||
main defense against accidental data loss.
|
||||
@@ -153,11 +153,11 @@ store.”
|
||||
## Locations
|
||||
|
||||
A location is a named pin over a source-relative path. It answers where the user
|
||||
wants quick access or special attention. It does not own records, content
|
||||
identity, or another index.
|
||||
wants quick access. It does not own records, content identity, another index,
|
||||
or watcher state.
|
||||
|
||||
Deleting a location removes the pin and its policy. It does not delete source
|
||||
records or file bytes. See [Locations](/core/locations).
|
||||
Deleting a location removes the pin. It does not delete source records, stop
|
||||
source indexing, or change file bytes. See [Locations](/core/locations).
|
||||
|
||||
## Cross-source catalog
|
||||
|
||||
|
||||
@@ -216,8 +216,7 @@ default. Native dependencies use pinned versions and verified hashes.
|
||||
|
||||
The interface makes a large system understandable:
|
||||
|
||||
- The primary sidebar shows the places, sources, and actions a person uses
|
||||
every day.
|
||||
- The primary sidebar shows the places and actions a person uses every day.
|
||||
- Duplicates, screenshots, photos, and extension workflows each have one clear
|
||||
home.
|
||||
- The same canonical component renders the same kind of navigation item.
|
||||
@@ -234,6 +233,29 @@ Performance is part of correctness. Opening a mapped source feels immediate.
|
||||
Scrolling, search, preview, and job updates remain responsive at millions of
|
||||
records. Idle clients and daemons stay quiet.
|
||||
|
||||
### Navigation vocabulary
|
||||
|
||||
The product separates where a person goes from how Spacedrive manages the data:
|
||||
|
||||
- A **source** is a data origin with an ingest and a store. Sources have
|
||||
identity, indexing state, availability, policy, and lifecycle. People manage
|
||||
them in Storage. The CLI and API expose them directly when that precision is
|
||||
useful.
|
||||
- A **location** is an internal pin over a source-relative path. It does not own
|
||||
records, trigger indexing, enable a watcher, or appear as a primary product
|
||||
concept. Future explicit policies may target its stable ID.
|
||||
- A **place** is a navigation destination presented by a client. Desktop,
|
||||
Downloads, a source root, a pinned folder, and an offline indexed drive can
|
||||
all appear under Places even though they do not share one storage type.
|
||||
|
||||
Places are a projection of known system folders, sources, pins, and extension
|
||||
contributions. A place does not have its own database model, durable identity,
|
||||
or operation namespace. Persist the underlying user intent, such as a pin or
|
||||
its ordering, instead of persisting a second representation of the data.
|
||||
|
||||
This gives the interface one plain-language rule: **Places are where you go.
|
||||
Sources are how Spacedrive knows about the data.**
|
||||
|
||||
## The release standard
|
||||
|
||||
The stable release favors a smaller system that works over a wide system held
|
||||
|
||||
@@ -8,7 +8,7 @@ export function baseOptions(): BaseLayoutProps {
|
||||
title: (
|
||||
<>
|
||||
<Image
|
||||
src="/spacedrive-logo.png"
|
||||
src="/spacedrive-icon.webp"
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
|
||||
@@ -106,7 +106,7 @@ the same clients you use locally.
|
||||
| --- | --- |
|
||||
| Library | Source registry, devices, policy, organization, and small shared state |
|
||||
| Source | One origin, ingest path, portable store, and availability state |
|
||||
| Location | A named pin and attention policy inside a source |
|
||||
| Location | A named source-relative pin shown as a Place |
|
||||
| Content identity | Evidence that records contain the same bytes |
|
||||
|
||||
The separation matters. A removable drive can go offline without disappearing
|
||||
|
||||
@@ -35,8 +35,9 @@ sd-cli library switch --library-id <LIBRARY_ID>
|
||||
sd-cli library info
|
||||
~~~
|
||||
|
||||
Creating a library also creates default pins for common user folders. Track a
|
||||
source before expecting those pins to resolve.
|
||||
Clients show common user folders as computed Places when they exist on the
|
||||
current device. Creating a library does not persist a location row for each
|
||||
one.
|
||||
|
||||
## Add storage
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"types:check": "next typegen && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@spacedrive/tokens": "^0.2.3",
|
||||
"cn": "^0.2.6",
|
||||
"fumadocs-core": "16.15.10",
|
||||
"fumadocs-mdx": "15.4.0",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 227 KiB |
@@ -1,37 +1,41 @@
|
||||
import { X, FunnelSimple } from "@phosphor-icons/react";
|
||||
import { CircleButton } from "@spacedrive/primitives";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useJobsContext } from "../hooks/JobsContext";
|
||||
import { JobRow } from "./JobRow";
|
||||
import {FunnelSimple, X} from '@phosphor-icons/react';
|
||||
import {CircleButton} from '@spacedrive/primitives';
|
||||
import {useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useSyncCount} from '../../SyncMonitor/hooks/useSyncCount';
|
||||
import {useJobsContext} from '../hooks/JobsContext';
|
||||
import {JobRow} from './JobRow';
|
||||
|
||||
export function JobsScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { jobs, pause, resume, cancel } = useJobsContext();
|
||||
const {jobs, pause, resume, cancel} = useJobsContext();
|
||||
const {onlinePeerCount, isSyncing} = useSyncCount();
|
||||
const [showOnlyRunning, setShowOnlyRunning] = useState(false);
|
||||
|
||||
// Filter jobs based on toggle
|
||||
const filteredJobs = showOnlyRunning
|
||||
? jobs.filter(
|
||||
(job) => job.status === "running" || job.status === "paused",
|
||||
(job) => job.status === 'running' || job.status === 'paused'
|
||||
)
|
||||
: jobs;
|
||||
|
||||
// Group jobs by status
|
||||
const runningJobs = filteredJobs.filter((j) => j.status === "running");
|
||||
const pausedJobs = filteredJobs.filter((j) => j.status === "paused");
|
||||
const queuedJobs = filteredJobs.filter((j) => j.status === "queued");
|
||||
const completedJobs = filteredJobs.filter((j) => j.status === "completed");
|
||||
const failedJobs = filteredJobs.filter((j) => j.status === "failed");
|
||||
const runningJobs = filteredJobs.filter((j) => j.status === 'running');
|
||||
const pausedJobs = filteredJobs.filter((j) => j.status === 'paused');
|
||||
const queuedJobs = filteredJobs.filter((j) => j.status === 'queued');
|
||||
const completedJobs = filteredJobs.filter((j) => j.status === 'completed');
|
||||
const failedJobs = filteredJobs.filter((j) => j.status === 'failed');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-app">
|
||||
<div className="bg-app flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 backdrop-blur-xl bg-app/80 border-b border-app-line">
|
||||
<div className="bg-app/80 border-app-line sticky top-0 z-10 border-b backdrop-blur-xl">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold text-ink">Jobs</h1>
|
||||
<div className="flex items-center gap-2 text-sm text-ink-dull">
|
||||
<h1 className="text-ink text-2xl font-bold">
|
||||
Activity
|
||||
</h1>
|
||||
<div className="text-ink-dull flex items-center gap-2 text-sm">
|
||||
<span>{jobs.length} total</span>
|
||||
{runningJobs.length > 0 && (
|
||||
<>
|
||||
@@ -39,6 +43,24 @@ export function JobsScreen() {
|
||||
<span>{runningJobs.length} running</span>
|
||||
</>
|
||||
)}
|
||||
{isSyncing && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>syncing</span>
|
||||
</>
|
||||
)}
|
||||
{onlinePeerCount > 0 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{onlinePeerCount}{' '}
|
||||
{onlinePeerCount === 1
|
||||
? 'peer'
|
||||
: 'peers'}{' '}
|
||||
online
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,8 +72,8 @@ export function JobsScreen() {
|
||||
onClick={() => setShowOnlyRunning(!showOnlyRunning)}
|
||||
title={
|
||||
showOnlyRunning
|
||||
? "Show all jobs"
|
||||
: "Show only active jobs"
|
||||
? 'Show all jobs'
|
||||
: 'Show only active jobs'
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -65,19 +87,19 @@ export function JobsScreen() {
|
||||
</div>
|
||||
|
||||
{/* Column headers */}
|
||||
<div className="flex items-center gap-4 px-4 py-2 text-xs font-medium text-ink-dull uppercase tracking-wide bg-app-box/30 border-t border-app-line/30">
|
||||
<div className="flex-shrink-0 w-10" /> {/* Icon spacer */}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-6">
|
||||
<div className="text-ink-dull bg-app-box/30 border-app-line/30 flex items-center gap-4 border-t px-4 py-2 text-xs font-medium uppercase tracking-wide">
|
||||
<div className="w-10 flex-shrink-0" /> {/* Icon spacer */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-6">
|
||||
<div className="flex-1">Name</div>
|
||||
<div className="flex-shrink-0 w-32">Duration</div>
|
||||
<div className="flex-shrink-0 w-24 text-right">
|
||||
<div className="w-32 flex-shrink-0">Duration</div>
|
||||
<div className="w-24 flex-shrink-0 text-right">
|
||||
Time
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-20 text-right">
|
||||
<div className="w-20 flex-shrink-0 text-right">
|
||||
Status
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-6" />{" "}
|
||||
<div className="w-6 flex-shrink-0" />{' '}
|
||||
{/* Action button spacer */}
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,9 +107,9 @@ export function JobsScreen() {
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredJobs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-ink-dull">
|
||||
<p className="text-ink-dull text-sm">
|
||||
No jobs found
|
||||
</p>
|
||||
</div>
|
||||
@@ -196,14 +218,14 @@ interface JobSectionProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function JobSection({ title, count, children }: JobSectionProps) {
|
||||
function JobSection({title, count, children}: JobSectionProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 px-4 py-2 bg-app-box/50 backdrop-blur-sm border-b border-app-line/50">
|
||||
<h2 className="text-xs font-semibold text-ink uppercase tracking-wide">
|
||||
<div className="bg-app-box/50 border-app-line/50 sticky top-0 z-10 flex items-center gap-2 border-b px-4 py-2 backdrop-blur-sm">
|
||||
<h2 className="text-ink text-xs font-semibold uppercase tracking-wide">
|
||||
{title}
|
||||
</h2>
|
||||
<span className="text-xs text-ink-dull">({count})</span>
|
||||
<span className="text-ink-dull text-xs">({count})</span>
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* Shared hook to refetch all tag-related queries after mutations.
|
||||
* Used by FileInspector, useFileContextMenu, TagsGroup, TagSelector.
|
||||
* Used by FileInspector, useFileContextMenu, and TagSelector.
|
||||
*/
|
||||
export function useRefetchTagQueries() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { ArrowLeft, HardDrive, Plugs } from "@phosphor-icons/react";
|
||||
import type { EphemeralSourceInfo, SizeTreeOutput } from "@sd/ts-client";
|
||||
import { useCoreQuery } from "../../contexts/SpacedriveContext";
|
||||
import { formatBytes } from "../overview/DevicePanel";
|
||||
import { Sunburst } from "./Sunburst";
|
||||
import {ArrowLeft, HardDrive, Plugs} from '@phosphor-icons/react';
|
||||
import type {EphemeralSourceInfo, SizeTreeOutput} from '@sd/ts-client';
|
||||
import {useState} from 'react';
|
||||
import {useNavigate, useSearchParams} from 'react-router-dom';
|
||||
import {useCoreQuery} from '../../contexts/SpacedriveContext';
|
||||
import {formatBytes} from '../overview/DevicePanel';
|
||||
import {Sunburst} from './Sunburst';
|
||||
|
||||
/**
|
||||
* The storage analyzer. `/analyzer` renders every indexed source as a small
|
||||
@@ -14,37 +14,48 @@ import { Sunburst } from "./Sunburst";
|
||||
*/
|
||||
export function AnalyzerView() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const path = params.get("path");
|
||||
const path = params.get('path');
|
||||
|
||||
if (path) {
|
||||
return (
|
||||
<AnalyzerDetail
|
||||
path={path}
|
||||
onNavigate={(p) => setParams(p ? { path: p } : {})}
|
||||
onNavigate={(p) => setParams(p ? {path: p} : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <AnalyzerGrid onOpen={(p) => setParams({ path: p })} />;
|
||||
return <AnalyzerGrid onOpen={(p) => setParams({path: p})} />;
|
||||
}
|
||||
|
||||
function AnalyzerGrid({ onOpen }: { onOpen: (path: string) => void }) {
|
||||
const { data: status } = useCoreQuery({
|
||||
type: "core.ephemeral_status",
|
||||
input: {},
|
||||
function AnalyzerGrid({onOpen}: {onOpen: (path: string) => void}) {
|
||||
const navigate = useNavigate();
|
||||
const {data: status} = useCoreQuery({
|
||||
type: 'core.ephemeral_status',
|
||||
input: {}
|
||||
});
|
||||
const sources = (status?.sources ?? []) as EphemeralSourceInfo[];
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-8 pt-[52px]">
|
||||
<h1 className="text-xl font-semibold text-ink">Analyzer</h1>
|
||||
<p className="mt-1 text-sm text-ink-faint">
|
||||
Every indexed drive as a size breakdown — including drives that
|
||||
aren't plugged in.
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/sources')}
|
||||
className="text-ink-dull hover:bg-app-hover hover:text-ink rounded-md px-2 py-1 text-sm"
|
||||
>
|
||||
Storage
|
||||
</button>
|
||||
<span className="text-ink-faint">/</span>
|
||||
<h1 className="text-ink text-xl font-semibold">Usage</h1>
|
||||
</div>
|
||||
<p className="text-ink-faint mt-1 text-sm">
|
||||
See what occupies each indexed source, including sources that
|
||||
are offline.
|
||||
</p>
|
||||
|
||||
{sources.length === 0 && (
|
||||
<div className="mt-16 text-center text-sm text-ink-faint">
|
||||
Nothing indexed yet — index a volume and it appears here.
|
||||
<div className="text-ink-faint mt-16 text-center text-sm">
|
||||
Nothing indexed yet. Track storage and it appears here.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -63,24 +74,23 @@ function AnalyzerGrid({ onOpen }: { onOpen: (path: string) => void }) {
|
||||
|
||||
function SourceCard({
|
||||
source,
|
||||
onOpen,
|
||||
onOpen
|
||||
}: {
|
||||
source: EphemeralSourceInfo;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const { data } = useCoreQuery({
|
||||
type: "files.size_tree",
|
||||
input: { path: source.root, depth: 2, top: 8 },
|
||||
const {data} = useCoreQuery({
|
||||
type: 'files.size_tree',
|
||||
input: {path: source.root, depth: 2, top: 8}
|
||||
});
|
||||
const tree = data as SizeTreeOutput | undefined;
|
||||
const name =
|
||||
source.root.split("/").filter(Boolean).pop() ?? source.root;
|
||||
const name = source.root.split('/').filter(Boolean).pop() ?? source.root;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onOpen}
|
||||
className={`group flex flex-col items-center rounded-lg border border-app-line/60 bg-app-box/40 p-4 text-left transition-colors hover:border-app-line hover:bg-app-box ${
|
||||
source.attached ? "" : "opacity-70"
|
||||
className={`border-app-line/60 bg-app-box/40 hover:border-app-line hover:bg-app-box group flex flex-col items-center rounded-lg border p-4 text-left transition-colors ${
|
||||
source.attached ? '' : 'opacity-70'
|
||||
}`}
|
||||
>
|
||||
<div className="relative">
|
||||
@@ -88,22 +98,24 @@ function SourceCard({
|
||||
<Sunburst root={tree.root} size={170} />
|
||||
) : (
|
||||
<div className="flex size-[170px] items-center justify-center">
|
||||
<HardDrive className="size-10 text-ink-faint/40" />
|
||||
<HardDrive className="text-ink-faint/40 size-10" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex w-full items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-ink">
|
||||
<span className="text-ink min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{name}
|
||||
</span>
|
||||
{!source.attached && (
|
||||
<span title="Not plugged in">
|
||||
<Plugs className="size-3.5 shrink-0 text-ink-faint" />
|
||||
<Plugs className="text-ink-faint size-3.5 shrink-0" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full text-xs text-ink-faint">
|
||||
{source.total_bytes != null ? formatBytes(source.total_bytes) : "—"}
|
||||
<div className="text-ink-faint w-full text-xs">
|
||||
{source.total_bytes != null
|
||||
? formatBytes(source.total_bytes)
|
||||
: '—'}
|
||||
{source.entry_count != null &&
|
||||
` · ${source.entry_count.toLocaleString()} items`}
|
||||
</div>
|
||||
@@ -113,7 +125,7 @@ function SourceCard({
|
||||
|
||||
function AnalyzerDetail({
|
||||
path,
|
||||
onNavigate,
|
||||
onNavigate
|
||||
}: {
|
||||
path: string;
|
||||
onNavigate: (path: string | null) => void;
|
||||
@@ -124,33 +136,33 @@ function AnalyzerDetail({
|
||||
size: number;
|
||||
} | null>(null);
|
||||
|
||||
const { data } = useCoreQuery({
|
||||
type: "files.size_tree",
|
||||
input: { path, depth: 3, top: 16 },
|
||||
const {data} = useCoreQuery({
|
||||
type: 'files.size_tree',
|
||||
input: {path, depth: 3, top: 16}
|
||||
});
|
||||
const tree = data as SizeTreeOutput | undefined;
|
||||
const root = tree?.root;
|
||||
|
||||
const parent = path.split("/").slice(0, -1).join("/");
|
||||
const parent = path.split('/').slice(0, -1).join('/');
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden p-8 pt-[52px]">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onNavigate(null)}
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-sm text-ink-dull hover:bg-app-hover hover:text-ink"
|
||||
className="text-ink-dull hover:bg-app-hover hover:text-ink flex items-center gap-1 rounded-md px-2 py-1 text-sm"
|
||||
>
|
||||
<ArrowLeft className="size-4" /> All drives
|
||||
</button>
|
||||
{parent && (
|
||||
<button
|
||||
onClick={() => onNavigate(parent)}
|
||||
className="rounded-md px-2 py-1 text-sm text-ink-dull hover:bg-app-hover hover:text-ink"
|
||||
className="text-ink-dull hover:bg-app-hover hover:text-ink rounded-md px-2 py-1 text-sm"
|
||||
>
|
||||
Up
|
||||
</button>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-ink-faint">
|
||||
<span className="text-ink-faint min-w-0 flex-1 truncate text-sm">
|
||||
{path}
|
||||
</span>
|
||||
</div>
|
||||
@@ -166,10 +178,10 @@ function AnalyzerDetail({
|
||||
/>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
|
||||
<span className="max-w-[120px] truncate text-sm font-semibold text-ink">
|
||||
<span className="text-ink max-w-[120px] truncate text-sm font-semibold">
|
||||
{hovered?.label ?? root?.name}
|
||||
</span>
|
||||
<span className="text-xs text-ink-faint">
|
||||
<span className="text-ink-faint text-xs">
|
||||
{formatBytes(hovered?.size ?? root?.size ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -187,34 +199,42 @@ function AnalyzerDetail({
|
||||
`/explorer?path=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
Physical: {
|
||||
device_slug: "local",
|
||||
device_slug:
|
||||
'local',
|
||||
path: parent
|
||||
? child.path
|
||||
.split("/")
|
||||
.slice(0, -1)
|
||||
.join("/")
|
||||
: child.path,
|
||||
},
|
||||
}),
|
||||
)}`,
|
||||
.split(
|
||||
'/'
|
||||
)
|
||||
.slice(
|
||||
0,
|
||||
-1
|
||||
)
|
||||
.join(
|
||||
'/'
|
||||
)
|
||||
: child.path
|
||||
}
|
||||
})
|
||||
)}`
|
||||
)
|
||||
}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-app-hover"
|
||||
className="hover:bg-app-hover flex w-full items-center gap-2 rounded-md px-2 py-1 text-left"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-ink-dull">
|
||||
<span className="text-ink-dull min-w-0 flex-1 truncate text-sm">
|
||||
{child.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-ink-faint">
|
||||
<span className="text-ink-faint shrink-0 text-xs tabular-nums">
|
||||
{formatBytes(child.size)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{root.other_size > 0 && (
|
||||
<div className="flex w-full items-center gap-2 px-2 py-1">
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-ink-faint">
|
||||
<span className="text-ink-faint min-w-0 flex-1 truncate text-sm">
|
||||
everything else
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-ink-faint">
|
||||
<span className="text-ink-faint shrink-0 text-xs tabular-nums">
|
||||
{formatBytes(root.other_size)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useExplorer } from "../explorer/context";
|
||||
import { ExplorerView } from "../explorer/ExplorerView";
|
||||
import {useLayoutEffect} from 'react';
|
||||
import {useParams} from 'react-router-dom';
|
||||
import {useExplorer} from '../explorer/context';
|
||||
import {ExplorerView} from '../explorer/ExplorerView';
|
||||
|
||||
/**
|
||||
* Collection view — files identified at index time (screenshots, screen
|
||||
@@ -11,12 +11,11 @@ import { ExplorerView } from "../explorer/ExplorerView";
|
||||
*/
|
||||
// Collections that are visual by nature open in the media grid; mixed-kind
|
||||
// collections (recents) keep the user's current view.
|
||||
const MEDIA_SLUGS = new Set(["screenshots", "screen-recordings"]);
|
||||
const MEDIA_SLUGS = new Set(['screenshots', 'screen-recordings']);
|
||||
|
||||
export function CollectionView() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { enterCollectionMode, exitCollectionMode, viewMode, setViewMode } =
|
||||
useExplorer();
|
||||
const {slug} = useParams<{slug: string}>();
|
||||
const {enterCollectionMode, exitCollectionMode} = useExplorer();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (slug) {
|
||||
@@ -27,16 +26,9 @@ export function CollectionView() {
|
||||
};
|
||||
}, [slug, enterCollectionMode, exitCollectionMode]);
|
||||
|
||||
// Collections open in the media grid; the user can switch views and the
|
||||
// per-surface preference system remembers it from then on.
|
||||
useLayoutEffect(() => {
|
||||
if (slug && MEDIA_SLUGS.has(slug) && viewMode !== "media") {
|
||||
setViewMode("media");
|
||||
}
|
||||
// Intentionally keyed on slug only: run once per collection visit,
|
||||
// not every time the user switches views while here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [slug]);
|
||||
|
||||
return <ExplorerView />;
|
||||
return (
|
||||
<ExplorerView
|
||||
defaultViewMode={slug && MEDIA_SLUGS.has(slug) ? 'media' : 'grid'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,12 +7,19 @@ import {
|
||||
} from '@phosphor-icons/react';
|
||||
import {CircleButton, CircleButtonGroup} from '@spacedrive/primitives';
|
||||
import clsx from 'clsx';
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState
|
||||
} from 'react';
|
||||
import {useLocation} from 'react-router-dom';
|
||||
import {TopBarItem, TopBarPortal} from '../../TopBar';
|
||||
import {ExpandableSearchButton} from './components/ExpandableSearchButton';
|
||||
import {PathBar} from './components/PathBar';
|
||||
import {VirtualPathBar} from './components/VirtualPathBar';
|
||||
import {useExplorer, type ViewMode} from './context';
|
||||
import {getSpaceItemKeyFromRoute, useExplorer, type ViewMode} from './context';
|
||||
import {useVirtualListing} from './hooks/useVirtualListing';
|
||||
import {SearchToolbar} from './SearchToolbar';
|
||||
import {SortMenu, SortMenuPanel} from './SortMenu';
|
||||
@@ -28,7 +35,12 @@ import {SearchView} from './views/SearchView';
|
||||
import {SizeView} from './views/SizeView';
|
||||
import {ViewSettings, ViewSettingsPanel} from './ViewSettings';
|
||||
|
||||
export function ExplorerView() {
|
||||
export function ExplorerView({
|
||||
defaultViewMode = 'grid'
|
||||
}: {
|
||||
defaultViewMode?: ViewMode;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const {
|
||||
sidebarVisible,
|
||||
setSidebarVisible,
|
||||
@@ -55,9 +67,22 @@ export function ExplorerView() {
|
||||
enterSearchMode,
|
||||
exitSearchMode,
|
||||
currentFiles,
|
||||
columnStack
|
||||
columnStack,
|
||||
loadPreferencesForSpaceItem
|
||||
} = useExplorer();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
loadPreferencesForSpaceItem(
|
||||
getSpaceItemKeyFromRoute(location.pathname, location.search),
|
||||
defaultViewMode
|
||||
);
|
||||
}, [
|
||||
defaultViewMode,
|
||||
loadPreferencesForSpaceItem,
|
||||
location.pathname,
|
||||
location.search
|
||||
]);
|
||||
|
||||
const {isVirtualView} = useVirtualListing();
|
||||
const isPreviewActive = !!quickPreviewFileId;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ArrowSquareOut,
|
||||
Books,
|
||||
Browsers,
|
||||
Copy,
|
||||
Crop,
|
||||
Eye,
|
||||
@@ -33,7 +34,8 @@ import {useClipboard} from '../../../hooks/useClipboard';
|
||||
import {useContextMenu} from '../../../hooks/useContextMenu';
|
||||
import {useOpenWith} from '../../../hooks/useOpenWith';
|
||||
import {useRefetchTagQueries} from '../../../hooks/useRefetchTagQueries';
|
||||
import {useExplorer} from '../context';
|
||||
import {useTabManager} from '../../../components/TabManager';
|
||||
import {targetToUrl, useExplorer} from '../context';
|
||||
import {useSelection} from '../SelectionContext';
|
||||
import {useDeleteFiles} from './useDeleteFiles';
|
||||
|
||||
@@ -49,6 +51,7 @@ export function useFileContextMenu({
|
||||
selected
|
||||
}: UseFileContextMenuProps) {
|
||||
const {navigateToPath, currentPath, mode} = useExplorer();
|
||||
const {createTab} = useTabManager();
|
||||
const platform = usePlatform();
|
||||
const refetchTagQueries = useRefetchTagQueries();
|
||||
|
||||
@@ -136,6 +139,18 @@ export function useFileContextMenu({
|
||||
!!file &&
|
||||
(file.kind === 'Directory' || file.kind === 'File')
|
||||
},
|
||||
{
|
||||
icon: Browsers,
|
||||
label: 'Open in New Tab',
|
||||
onClick: () => {
|
||||
if (!file) return;
|
||||
createTab(
|
||||
file.name,
|
||||
targetToUrl({type: 'path', path: file.sd_path})
|
||||
);
|
||||
},
|
||||
condition: () => !!file && file.kind === 'Directory'
|
||||
},
|
||||
{
|
||||
type: 'submenu',
|
||||
icon: ArrowSquareOut,
|
||||
|
||||
@@ -55,74 +55,10 @@ export function MediaView() {
|
||||
// ALL HOOKS MUST BE CALLED BEFORE ANY CONDITIONAL RETURNS
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const initializedListingRef = useRef<string | null>(null);
|
||||
|
||||
// TODO: Preserve scroll position per tab using scrollPosition from context
|
||||
|
||||
// Track when element is ready
|
||||
const [elementReady, setElementReady] = useState(false);
|
||||
|
||||
// Track container width with ResizeObserver AND window resize
|
||||
useEffect(() => {
|
||||
if (!elementReady) return;
|
||||
|
||||
const element = parentRef.current;
|
||||
if (!element) return;
|
||||
|
||||
let rafId: number | null = null;
|
||||
|
||||
const updateWidth = () => {
|
||||
if (rafId) return; // Debounce with requestAnimationFrame
|
||||
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
const newWidth = element.offsetWidth;
|
||||
|
||||
if (newWidth > 0) {
|
||||
setContainerWidth(newWidth);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ResizeObserver for when the element itself resizes
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateWidth();
|
||||
});
|
||||
|
||||
// Window resize listener as fallback
|
||||
const handleWindowResize = () => {
|
||||
updateWidth();
|
||||
};
|
||||
|
||||
resizeObserver.observe(element);
|
||||
window.addEventListener("resize", handleWindowResize);
|
||||
|
||||
// Set initial width immediately
|
||||
const initialWidth = element.offsetWidth;
|
||||
setContainerWidth(initialWidth);
|
||||
|
||||
return () => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener("resize", handleWindowResize);
|
||||
};
|
||||
}, [elementReady]);
|
||||
|
||||
// Track scroll position
|
||||
useEffect(() => {
|
||||
const element = parentRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollOffset(element.scrollTop);
|
||||
};
|
||||
|
||||
element.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => {
|
||||
element.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get files from centralized hook (handles search mode automatically)
|
||||
const { files: explorerFiles } = useExplorerFiles();
|
||||
const isSearchMode = mode.type === "search";
|
||||
@@ -160,6 +96,32 @@ export function MediaView() {
|
||||
// Normal mode: use media_listing query
|
||||
return [...(mediaQuery.data?.files || [])].reverse();
|
||||
}, [usesExplorerFiles, explorerFiles, mediaQuery.data?.files]);
|
||||
const hasFiles = files.length > 0;
|
||||
const listingKey = `${sortBy}:${JSON.stringify(
|
||||
mode.type === "browse" ? currentPath : mode,
|
||||
)}`;
|
||||
|
||||
// Measure before paint. A delayed width update briefly lays out a different
|
||||
// column count, which moves every tile when the real width arrives.
|
||||
useLayoutEffect(() => {
|
||||
const element = parentRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const updateWidth = () => {
|
||||
const newWidth = element.offsetWidth;
|
||||
if (newWidth > 0) {
|
||||
setContainerWidth((width) =>
|
||||
width === newWidth ? width : newWidth,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
const resizeObserver = new ResizeObserver(updateWidth);
|
||||
resizeObserver.observe(element);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [hasFiles]);
|
||||
|
||||
// Update current files in explorer context for quick preview navigation
|
||||
useEffect(() => {
|
||||
@@ -171,13 +133,6 @@ export function MediaView() {
|
||||
restoreSelectionFromFiles(files);
|
||||
}, [files, restoreSelectionFromFiles]);
|
||||
|
||||
// Check if element is ready when files load
|
||||
useEffect(() => {
|
||||
if (parentRef.current && !elementReady) {
|
||||
setElementReady(true);
|
||||
}
|
||||
}, [files, elementReady]);
|
||||
|
||||
// Keyboard navigation for media view
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -273,21 +228,31 @@ export function MediaView() {
|
||||
overscan: overscanCount,
|
||||
});
|
||||
|
||||
// Force remeasure synchronously when layout changes (before paint)
|
||||
// Cached row measurements only become stale when the fixed row size changes.
|
||||
// Data updates must not reset the virtualizer while the user is scrolling.
|
||||
useLayoutEffect(() => {
|
||||
rowVirtualizer.measure();
|
||||
}, [columns, gridSize, rowCount, rowVirtualizer]);
|
||||
}, [actualItemSize, rowVirtualizer]);
|
||||
|
||||
// Scroll to bottom on mount (inverted scroll - show most recent first)
|
||||
useEffect(() => {
|
||||
if (rowCount > 0 && parentRef.current) {
|
||||
rowVirtualizer.scrollToIndex(rowCount - 1, {
|
||||
align: "end",
|
||||
});
|
||||
// The list is oldest-first so the newest media sits at the bottom. Position
|
||||
// a listing once, but never snap back when live data or a resize changes its
|
||||
// row count.
|
||||
useLayoutEffect(() => {
|
||||
if (
|
||||
containerWidth === 0 ||
|
||||
rowCount === 0 ||
|
||||
initializedListingRef.current === listingKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}, [rowCount, rowVirtualizer]);
|
||||
|
||||
rowVirtualizer.scrollToIndex(rowCount - 1, { align: "end" });
|
||||
initializedListingRef.current = listingKey;
|
||||
}, [containerWidth, listingKey, rowCount, rowVirtualizer]);
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const scrollOffset =
|
||||
rowVirtualizer.scrollOffset ?? parentRef.current?.scrollTop ?? 0;
|
||||
|
||||
// Calculate date range for visible items
|
||||
const dateRange = useMemo(() => {
|
||||
@@ -418,10 +383,9 @@ export function MediaView() {
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
willChange: "contents",
|
||||
}}
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
{virtualRows.flatMap((virtualRow) => {
|
||||
// Calculate items in this row
|
||||
const startIndex = virtualRow.index * columns;
|
||||
const endIndex = Math.min(
|
||||
@@ -430,7 +394,8 @@ export function MediaView() {
|
||||
);
|
||||
const rowTop = virtualRow.start;
|
||||
|
||||
// Render items directly without intermediate array
|
||||
// Keep every file at the same React child level. Nested unkeyed
|
||||
// row arrays remount the whole viewport when the first row changes.
|
||||
return Array.from(
|
||||
{ length: endIndex - startIndex },
|
||||
(_, idx) => {
|
||||
@@ -470,4 +435,4 @@ export function MediaView() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useExplorer } from '../../context';
|
||||
import { GridView } from '../GridView';
|
||||
import { ListView } from '../ListView';
|
||||
import { MediaView } from '../MediaView';
|
||||
import { ColumnView } from '../ColumnView';
|
||||
import { SizeView } from '../SizeView';
|
||||
import { KnowledgeView } from '../KnowledgeView';
|
||||
import {useEffect, useLayoutEffect} from 'react';
|
||||
import {useExplorer} from '../../context';
|
||||
import {ColumnView} from '../ColumnView';
|
||||
import {GridView} from '../GridView';
|
||||
import {KnowledgeView} from '../KnowledgeView';
|
||||
import {ListView} from '../ListView';
|
||||
import {MediaView} from '../MediaView';
|
||||
import {SizeView} from '../SizeView';
|
||||
|
||||
/**
|
||||
* RecentsView displays recently indexed files sorted by indexed_at timestamp.
|
||||
@@ -16,7 +16,16 @@ import { KnowledgeView } from '../KnowledgeView';
|
||||
*/
|
||||
export function RecentsView() {
|
||||
const explorer = useExplorer();
|
||||
const { viewMode, enterRecentsMode, exitRecentsMode } = explorer;
|
||||
const {
|
||||
viewMode,
|
||||
enterRecentsMode,
|
||||
exitRecentsMode,
|
||||
loadPreferencesForSpaceItem
|
||||
} = explorer;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
loadPreferencesForSpaceItem('recents');
|
||||
}, [loadPreferencesForSpaceItem]);
|
||||
|
||||
// Enter recents mode on mount, exit on unmount
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {CaretLeft, CaretRight, Lightning} from '@phosphor-icons/react';
|
||||
import ComputeIcon from '@sd/assets/icons/Compute.webp';
|
||||
import {CaretLeft, CaretRight} from '@phosphor-icons/react';
|
||||
import DatabaseIcon from '@sd/assets/icons/Database.webp';
|
||||
import DevicesIcon from '@sd/assets/icons/Devices.webp';
|
||||
import IndexedIcon from '@sd/assets/icons/Indexed.webp';
|
||||
@@ -35,14 +34,6 @@ function formatBytes(bytes: number): {value: string; unit: string} {
|
||||
};
|
||||
}
|
||||
|
||||
function getTOPSRank(tops: number): {label: string} {
|
||||
if (tops >= 100) return {label: 'Extreme'};
|
||||
if (tops >= 70) return {label: 'Very High'};
|
||||
if (tops >= 40) return {label: 'High'};
|
||||
if (tops >= 20) return {label: 'Moderate'};
|
||||
return {label: 'Low'};
|
||||
}
|
||||
|
||||
export function HeroStats({
|
||||
totalStorage,
|
||||
usedStorage,
|
||||
@@ -66,8 +57,6 @@ export function HeroStats({
|
||||
const usedFormatted = formatBytes(usedStorage);
|
||||
const databaseFormatted = formatBytes(databaseSize);
|
||||
const sidecarFormatted = formatBytes(sidecarSize);
|
||||
const topsValue = 70;
|
||||
const topsRank = getTOPSRank(topsValue);
|
||||
|
||||
const updateScrollState = () => {
|
||||
if (!scrollRef.current) return;
|
||||
@@ -186,35 +175,6 @@ export function HeroStats({
|
||||
color="from-green-500 to-emerald-500"
|
||||
/>
|
||||
|
||||
{/* AI Compute Power */}
|
||||
<StatCard
|
||||
icon={
|
||||
<img
|
||||
src={ComputeIcon}
|
||||
alt="Compute"
|
||||
className="size-10 opacity-80"
|
||||
/>
|
||||
}
|
||||
label="AI Compute Power"
|
||||
value={
|
||||
<>
|
||||
{topsValue}{' '}
|
||||
<span className="text-ink-faint text-xl">TOPS</span>
|
||||
</>
|
||||
}
|
||||
subtitle={
|
||||
<span className="flex items-center gap-1">
|
||||
<Lightning
|
||||
size={12}
|
||||
weight="bold"
|
||||
className="text-ink-faint"
|
||||
/>
|
||||
{topsRank.label}
|
||||
</span>
|
||||
}
|
||||
color="from-purple-500 to-pink-500"
|
||||
/>
|
||||
|
||||
{/* Library Size (Database) */}
|
||||
<StatCard
|
||||
icon={
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
import {
|
||||
ArrowsClockwise,
|
||||
CaretDown,
|
||||
CloudArrowUp,
|
||||
DeviceMobile,
|
||||
GearSix,
|
||||
MagnifyingGlass,
|
||||
Plus
|
||||
} from '@phosphor-icons/react';
|
||||
import {useLibraryMutation} from '@sd/ts-client';
|
||||
import {CircleButton, Popover, usePopover} from '@spacedrive/primitives';
|
||||
import clsx from 'clsx';
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {Plus} from '@phosphor-icons/react';
|
||||
import {CircleButton} from '@spacedrive/primitives';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useCreateLibraryDialog} from '../../components/modals/CreateLibraryModal';
|
||||
import {PairingModal} from '../../components/modals/PairingModal';
|
||||
import {useSyncSetupDialog} from '../../components/modals/SyncSetupModal';
|
||||
import {usePlatform} from '../../contexts/PlatformContext';
|
||||
import {useSpacedriveClient} from '../../contexts/SpacedriveContext';
|
||||
import {useLibraries} from '../../hooks/useLibraries';
|
||||
import {TopBarItem, TopBarPortal} from '../../TopBar';
|
||||
import {useAddStorageDialog} from '../explorer/components/AddStorageModal';
|
||||
|
||||
@@ -25,300 +8,40 @@ interface OverviewTopBarProps {
|
||||
libraryName?: string;
|
||||
}
|
||||
|
||||
export function OverviewTopBar({libraryName}: OverviewTopBarProps) {
|
||||
const [isPairingOpen, setIsPairingOpen] = useState(false);
|
||||
/** Home owns the first storage action; library scope lives in the sidebar. */
|
||||
export function OverviewTopBar(_props: OverviewTopBarProps) {
|
||||
const navigate = useNavigate();
|
||||
const client = useSpacedriveClient();
|
||||
const platform = usePlatform();
|
||||
const {data: libraries} = useLibraries();
|
||||
const [currentLibraryId, setCurrentLibraryId] = useState<string | null>(
|
||||
() => client.getCurrentLibraryId() // Initialize from client
|
||||
);
|
||||
const librarySwitcher = usePopover();
|
||||
|
||||
// Listen for library changes from client and update local state
|
||||
useEffect(() => {
|
||||
const handleLibraryChange = (newLibraryId: string) => {
|
||||
setCurrentLibraryId(newLibraryId);
|
||||
};
|
||||
|
||||
client.on('library-changed', handleLibraryChange);
|
||||
return () => {
|
||||
client.off('library-changed', handleLibraryChange);
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
// Auto-select first library on mount if none selected
|
||||
useEffect(() => {
|
||||
if (libraries && libraries.length > 0 && !currentLibraryId) {
|
||||
const firstLib = libraries[0];
|
||||
|
||||
// Set library ID via platform (syncs to all windows on Tauri)
|
||||
if (platform.setCurrentLibraryId) {
|
||||
platform
|
||||
.setCurrentLibraryId(firstLib.id)
|
||||
.catch((err) =>
|
||||
console.error('Failed to set library ID:', err)
|
||||
);
|
||||
} else {
|
||||
// Web fallback - just update client
|
||||
client.setCurrentLibrary(firstLib.id);
|
||||
}
|
||||
}
|
||||
}, [libraries, currentLibraryId, client, platform]);
|
||||
|
||||
const handleLibrarySwitch = (libraryId: string) => {
|
||||
librarySwitcher.setOpen(false);
|
||||
|
||||
// Set library ID via platform (syncs to all windows on Tauri)
|
||||
if (platform.setCurrentLibraryId) {
|
||||
platform
|
||||
.setCurrentLibraryId(libraryId)
|
||||
.catch((err) =>
|
||||
console.error('Failed to set library ID:', err)
|
||||
);
|
||||
} else {
|
||||
// Web fallback - just update client
|
||||
client.setCurrentLibrary(libraryId);
|
||||
}
|
||||
};
|
||||
|
||||
const currentLibrary = libraries?.find(
|
||||
(lib) => lib.id === currentLibraryId
|
||||
);
|
||||
|
||||
const handleAddStorage = () => {
|
||||
useAddStorageDialog((sdPath) => {
|
||||
navigate(`/explorer?path=${encodeURIComponent(JSON.stringify(sdPath))}`);
|
||||
const addStorage = () => {
|
||||
useAddStorageDialog((path) => {
|
||||
navigate(
|
||||
`/explorer?path=${encodeURIComponent(JSON.stringify(path))}`
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSyncSetup = () => {
|
||||
useSyncSetupDialog();
|
||||
};
|
||||
|
||||
// Mutation for refreshing volume statistics
|
||||
const volumeRefreshMutation = useLibraryMutation('volumes.refresh');
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const result = await volumeRefreshMutation.mutateAsync({
|
||||
force: false
|
||||
});
|
||||
console.log(
|
||||
`Volume refresh complete: ${result.volumes_refreshed} refreshed, ${result.volumes_failed} failed`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh volumes:', error);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Memoize TopBarItem children to prevent infinite re-renders
|
||||
const overviewTitleContent = useMemo(
|
||||
() => (
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-ink text-xl font-bold">Overview</h1>
|
||||
<span className="text-ink-dull">•</span>
|
||||
<Popover.Root
|
||||
open={librarySwitcher.open}
|
||||
onOpenChange={librarySwitcher.setOpen}
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
className={clsx(
|
||||
'flex h-8 items-center gap-2 rounded-full px-3 text-xs font-medium',
|
||||
'backdrop-blur-xl transition-all',
|
||||
'border-sidebar-line/30 border',
|
||||
'bg-sidebar-box/20 text-sidebar-inkDull hover:bg-sidebar-box/30 hover:text-sidebar-ink',
|
||||
'active:scale-95',
|
||||
!currentLibrary && 'text-ink-faint'
|
||||
)}
|
||||
>
|
||||
<span className="max-w-[200px] truncate">
|
||||
{currentLibrary?.name ||
|
||||
libraryName ||
|
||||
'Select Library'}
|
||||
</span>
|
||||
<CaretDown size={12} weight="bold" />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content className="min-w-[200px] p-2">
|
||||
<div className="space-y-1">
|
||||
{libraries && libraries.length > 1 && (
|
||||
<>
|
||||
{libraries.map((lib) => (
|
||||
<button
|
||||
key={lib.id}
|
||||
onClick={() =>
|
||||
handleLibrarySwitch(lib.id)
|
||||
}
|
||||
className={clsx(
|
||||
'w-full cursor-pointer rounded-md px-3 py-2 text-left text-sm',
|
||||
lib.id === currentLibraryId
|
||||
? 'bg-accent text-white'
|
||||
: 'text-ink hover:bg-app-selected'
|
||||
)}
|
||||
>
|
||||
{lib.name}
|
||||
</button>
|
||||
))}
|
||||
<div className="border-app-line my-1 border-t" />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
librarySwitcher.setOpen(false);
|
||||
useCreateLibraryDialog();
|
||||
}}
|
||||
className="hover:bg-app-selected text-ink flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
<Plus size={16} weight="bold" />
|
||||
<span>New Library</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => librarySwitcher.setOpen(false)}
|
||||
className="hover:bg-app-selected text-ink flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
<GearSix size={16} weight="bold" />
|
||||
<span>Library Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
),
|
||||
[
|
||||
libraries,
|
||||
currentLibrary,
|
||||
libraryName,
|
||||
currentLibraryId,
|
||||
librarySwitcher,
|
||||
handleLibrarySwitch
|
||||
]
|
||||
);
|
||||
|
||||
const searchButton = useMemo(
|
||||
() => <CircleButton icon={MagnifyingGlass} title="Search" />,
|
||||
[]
|
||||
);
|
||||
|
||||
const pairButton = useMemo(
|
||||
() => (
|
||||
<CircleButton
|
||||
icon={DeviceMobile}
|
||||
title="Pair Device"
|
||||
onClick={() => setIsPairingOpen(true)}
|
||||
>
|
||||
Pair
|
||||
</CircleButton>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const syncButton = useMemo(
|
||||
() => (
|
||||
<CircleButton
|
||||
icon={CloudArrowUp}
|
||||
title="Setup Sync"
|
||||
onClick={handleSyncSetup}
|
||||
>
|
||||
Setup Sync
|
||||
</CircleButton>
|
||||
),
|
||||
[handleSyncSetup]
|
||||
);
|
||||
|
||||
const refreshButton = useMemo(
|
||||
() => (
|
||||
<CircleButton
|
||||
icon={ArrowsClockwise}
|
||||
title="Refresh Statistics"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className={clsx(isRefreshing && 'animate-spin')}
|
||||
>
|
||||
Refresh
|
||||
</CircleButton>
|
||||
),
|
||||
[handleRefresh, isRefreshing]
|
||||
);
|
||||
|
||||
const addStorageButton = useMemo(
|
||||
() => (
|
||||
<CircleButton
|
||||
icon={Plus}
|
||||
className="!bg-accent hover:!bg-accent-deep !text-white"
|
||||
onClick={handleAddStorage}
|
||||
>
|
||||
Add Storage
|
||||
</CircleButton>
|
||||
),
|
||||
[handleAddStorage]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBarPortal
|
||||
left={
|
||||
<>
|
||||
<TopBarItem
|
||||
id="overview-title"
|
||||
label="Overview"
|
||||
priority="high"
|
||||
>
|
||||
{overviewTitleContent}
|
||||
</TopBarItem>
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<>
|
||||
<TopBarItem id="search" label="Search" priority="high">
|
||||
{searchButton}
|
||||
</TopBarItem>
|
||||
<TopBarItem
|
||||
id="pair-device"
|
||||
label="Pair Device"
|
||||
priority="normal"
|
||||
onClick={() => setIsPairingOpen(true)}
|
||||
>
|
||||
{pairButton}
|
||||
</TopBarItem>
|
||||
<TopBarItem
|
||||
id="setup-sync"
|
||||
label="Setup Sync"
|
||||
priority="low"
|
||||
onClick={handleSyncSetup}
|
||||
>
|
||||
{syncButton}
|
||||
</TopBarItem>
|
||||
<TopBarItem
|
||||
id="refresh"
|
||||
label="Refresh Statistics"
|
||||
priority="low"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
{refreshButton}
|
||||
</TopBarItem>
|
||||
<TopBarItem
|
||||
id="add-storage"
|
||||
label="Add Storage"
|
||||
priority="high"
|
||||
onClick={handleAddStorage}
|
||||
>
|
||||
{addStorageButton}
|
||||
</TopBarItem>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<PairingModal
|
||||
isOpen={isPairingOpen}
|
||||
onClose={() => setIsPairingOpen(false)}
|
||||
/>
|
||||
</>
|
||||
<TopBarPortal
|
||||
left={
|
||||
<TopBarItem id="home-title" label="Home" priority="high">
|
||||
<h1 className="text-ink text-xl font-bold">Home</h1>
|
||||
</TopBarItem>
|
||||
}
|
||||
right={
|
||||
<TopBarItem
|
||||
id="add-storage"
|
||||
label="Add Storage"
|
||||
priority="high"
|
||||
>
|
||||
<CircleButton
|
||||
icon={Plus}
|
||||
className="!bg-accent hover:!bg-accent-deep !text-white"
|
||||
onClick={addStorage}
|
||||
>
|
||||
Add Storage
|
||||
</CircleButton>
|
||||
</TopBarItem>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,13 +7,13 @@
|
||||
* context menus, drag-drop, sort, etc.).
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, ShieldWarning } from "@phosphor-icons/react";
|
||||
import { CircleButton } from "@spacedrive/primitives";
|
||||
import type { SearchFilters } from "@sd/ts-client";
|
||||
import { TopBarPortal, TopBarItem } from "../../TopBar";
|
||||
import { ExplorerView, useExplorer } from "../explorer";
|
||||
import {ArrowLeft, ShieldWarning} from '@phosphor-icons/react';
|
||||
import type {SearchFilters} from '@sd/ts-client';
|
||||
import {CircleButton} from '@spacedrive/primitives';
|
||||
import {useEffect, useMemo, useRef} from 'react';
|
||||
import {useNavigate, useSearchParams} from 'react-router-dom';
|
||||
import {TopBarItem, TopBarPortal} from '../../TopBar';
|
||||
import {ExplorerView, useExplorer} from '../explorer';
|
||||
|
||||
const EMPTY_FILTERS: SearchFilters = {
|
||||
file_types: null,
|
||||
@@ -28,27 +28,27 @@ const EMPTY_FILTERS: SearchFilters = {
|
||||
on_volumes: null,
|
||||
not_on_volumes: null,
|
||||
min_volume_count: null,
|
||||
max_volume_count: null,
|
||||
max_volume_count: null
|
||||
};
|
||||
|
||||
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 volumeFilter = searchParams.get('volume');
|
||||
const atRiskParam = searchParams.get('at_risk');
|
||||
const isAtRisk = atRiskParam !== 'false'; // default true
|
||||
|
||||
const { enterFilteredMode, exitFilteredMode, setSortBy } = useExplorer();
|
||||
const {enterFilteredMode, exitFilteredMode, setSortBy} = useExplorer();
|
||||
|
||||
const label = isAtRisk ? "At-Risk Files" : "Redundant Files";
|
||||
const label = isAtRisk ? 'At-Risk Files' : 'Redundant Files';
|
||||
|
||||
const filters = useMemo<SearchFilters>(
|
||||
() => ({
|
||||
...EMPTY_FILTERS,
|
||||
at_risk: isAtRisk,
|
||||
on_volumes: volumeFilter ? [volumeFilter] : null,
|
||||
on_volumes: volumeFilter ? [volumeFilter] : null
|
||||
}),
|
||||
[isAtRisk, volumeFilter],
|
||||
[isAtRisk, volumeFilter]
|
||||
);
|
||||
|
||||
// Keep filtered mode in sync with current params
|
||||
@@ -63,7 +63,7 @@ export function AtRiskFiles() {
|
||||
useEffect(() => {
|
||||
if (didSetSort.current) return;
|
||||
didSetSort.current = true;
|
||||
setSortBy("size");
|
||||
setSortBy('size');
|
||||
}, [setSortBy]);
|
||||
|
||||
// Exit filtered mode only when the route unmounts
|
||||
@@ -76,18 +76,18 @@ export function AtRiskFiles() {
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleButton
|
||||
icon={ArrowLeft}
|
||||
title="Back to Redundancy"
|
||||
onClick={() => navigate("/redundancy")}
|
||||
title="Back to Protection"
|
||||
onClick={() => navigate('/redundancy')}
|
||||
/>
|
||||
<ShieldWarning
|
||||
size={18}
|
||||
weight="bold"
|
||||
className="text-status-warning"
|
||||
/>
|
||||
<span className="text-sm font-semibold text-ink">{label}</span>
|
||||
<span className="text-ink text-sm font-semibold">{label}</span>
|
||||
</div>
|
||||
),
|
||||
[navigate, label],
|
||||
[navigate, label]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,16 +7,16 @@
|
||||
* results so users keep selection, QuickPreview, context menus, drag-drop, etc.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, ArrowsLeftRight, ShieldCheck } from "@phosphor-icons/react";
|
||||
import { CircleButton } from "@spacedrive/primitives";
|
||||
import type { SearchFilters } from "@sd/ts-client";
|
||||
import { TopBarPortal, TopBarItem } from "../../TopBar";
|
||||
import { useLibraryQuery } from "../../contexts/SpacedriveContext";
|
||||
import { ExplorerView, useExplorer } from "../explorer";
|
||||
import {ArrowLeft, ArrowsLeftRight, ShieldCheck} from '@phosphor-icons/react';
|
||||
import type {SearchFilters} from '@sd/ts-client';
|
||||
import {CircleButton} from '@spacedrive/primitives';
|
||||
import {useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useLibraryQuery} from '../../contexts/SpacedriveContext';
|
||||
import {TopBarItem, TopBarPortal} from '../../TopBar';
|
||||
import {ExplorerView, useExplorer} from '../explorer';
|
||||
|
||||
type CompareMode = "unique_a" | "shared" | "unique_b";
|
||||
type CompareMode = 'unique_a' | 'shared' | 'unique_b';
|
||||
|
||||
const EMPTY_FILTERS: SearchFilters = {
|
||||
file_types: null,
|
||||
@@ -31,13 +31,13 @@ const EMPTY_FILTERS: SearchFilters = {
|
||||
on_volumes: null,
|
||||
not_on_volumes: null,
|
||||
min_volume_count: null,
|
||||
max_volume_count: null,
|
||||
max_volume_count: null
|
||||
};
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
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]}`;
|
||||
}
|
||||
@@ -46,58 +46,58 @@ export function CompareVolumes() {
|
||||
const navigate = useNavigate();
|
||||
const [volumeA, setVolumeA] = useState<string | null>(null);
|
||||
const [volumeB, setVolumeB] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<CompareMode>("unique_a");
|
||||
const [mode, setMode] = useState<CompareMode>('unique_a');
|
||||
|
||||
const { enterFilteredMode, exitFilteredMode, setSortBy } = useExplorer();
|
||||
const {enterFilteredMode, exitFilteredMode, setSortBy} = useExplorer();
|
||||
|
||||
const { data: summaryData } = useLibraryQuery({
|
||||
type: "redundancy.summary",
|
||||
input: {},
|
||||
const {data: summaryData} = useLibraryQuery({
|
||||
type: 'redundancy.summary',
|
||||
input: {}
|
||||
});
|
||||
|
||||
const volumes = summaryData?.volumes ?? [];
|
||||
|
||||
const volumeAName =
|
||||
volumes.find((v) => v.volume_uuid === volumeA)?.display_name ??
|
||||
"Volume A";
|
||||
'Volume A';
|
||||
const volumeBName =
|
||||
volumes.find((v) => v.volume_uuid === volumeB)?.display_name ??
|
||||
"Volume B";
|
||||
'Volume B';
|
||||
|
||||
const hasBoth = !!volumeA && !!volumeB;
|
||||
|
||||
const filters = useMemo<SearchFilters | null>(() => {
|
||||
if (!hasBoth) return null;
|
||||
switch (mode) {
|
||||
case "unique_a":
|
||||
case 'unique_a':
|
||||
return {
|
||||
...EMPTY_FILTERS,
|
||||
on_volumes: [volumeA!],
|
||||
not_on_volumes: [volumeB!],
|
||||
not_on_volumes: [volumeB!]
|
||||
};
|
||||
case "unique_b":
|
||||
case 'unique_b':
|
||||
return {
|
||||
...EMPTY_FILTERS,
|
||||
on_volumes: [volumeB!],
|
||||
not_on_volumes: [volumeA!],
|
||||
not_on_volumes: [volumeA!]
|
||||
};
|
||||
case "shared":
|
||||
case 'shared':
|
||||
return {
|
||||
...EMPTY_FILTERS,
|
||||
on_volumes: [volumeA!, volumeB!],
|
||||
min_volume_count: 2,
|
||||
min_volume_count: 2
|
||||
};
|
||||
}
|
||||
}, [hasBoth, mode, volumeA, volumeB]);
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (!hasBoth) return "Compare Volumes";
|
||||
if (!hasBoth) return 'Compare Volumes';
|
||||
switch (mode) {
|
||||
case "unique_a":
|
||||
case 'unique_a':
|
||||
return `Unique to ${volumeAName}`;
|
||||
case "unique_b":
|
||||
case 'unique_b':
|
||||
return `Unique to ${volumeBName}`;
|
||||
case "shared":
|
||||
case 'shared':
|
||||
return `Shared between ${volumeAName} & ${volumeBName}`;
|
||||
}
|
||||
}, [hasBoth, mode, volumeAName, volumeBName]);
|
||||
@@ -118,7 +118,7 @@ export function CompareVolumes() {
|
||||
useEffect(() => {
|
||||
if (didSetSort.current) return;
|
||||
didSetSort.current = true;
|
||||
setSortBy("size");
|
||||
setSortBy('size');
|
||||
}, [setSortBy]);
|
||||
|
||||
// Exit filtered mode when leaving the route
|
||||
@@ -131,14 +131,14 @@ export function CompareVolumes() {
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleButton
|
||||
icon={ArrowLeft}
|
||||
title="Back to Redundancy"
|
||||
onClick={() => navigate("/redundancy")}
|
||||
title="Back to Protection"
|
||||
onClick={() => navigate('/redundancy')}
|
||||
/>
|
||||
<ArrowsLeftRight size={18} weight="bold" className="text-ink" />
|
||||
<span className="text-sm font-semibold text-ink">{label}</span>
|
||||
<span className="text-ink text-sm font-semibold">{label}</span>
|
||||
</div>
|
||||
),
|
||||
[navigate, label],
|
||||
[navigate, label]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -157,12 +157,12 @@ export function CompareVolumes() {
|
||||
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Picker + mode toggle */}
|
||||
<div className="border-b border-app-line bg-app-box/30 p-3 space-y-2">
|
||||
<div className="border-app-line bg-app-box/30 space-y-2 border-b p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={volumeA ?? ""}
|
||||
value={volumeA ?? ''}
|
||||
onChange={(e) => setVolumeA(e.target.value || null)}
|
||||
className="flex-1 rounded-lg border border-app-line bg-app-box/50 px-3 py-1.5 text-sm text-ink"
|
||||
className="border-app-line bg-app-box/50 text-ink flex-1 rounded-lg border px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">Select Volume A</option>
|
||||
{volumes.map((v) => (
|
||||
@@ -178,12 +178,12 @@ export function CompareVolumes() {
|
||||
</select>
|
||||
<ArrowsLeftRight
|
||||
size={16}
|
||||
className="flex-shrink-0 text-ink-dull"
|
||||
className="text-ink-dull flex-shrink-0"
|
||||
/>
|
||||
<select
|
||||
value={volumeB ?? ""}
|
||||
value={volumeB ?? ''}
|
||||
onChange={(e) => setVolumeB(e.target.value || null)}
|
||||
className="flex-1 rounded-lg border border-app-line bg-app-box/50 px-3 py-1.5 text-sm text-ink"
|
||||
className="border-app-line bg-app-box/50 text-ink flex-1 rounded-lg border px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">Select Volume B</option>
|
||||
{volumes.map((v) => (
|
||||
@@ -200,20 +200,20 @@ export function CompareVolumes() {
|
||||
</div>
|
||||
|
||||
{hasBoth && (
|
||||
<div className="flex gap-1 rounded-lg border border-app-line bg-app-box/50 p-1">
|
||||
<div className="border-app-line bg-app-box/50 flex gap-1 rounded-lg border p-1">
|
||||
<ModeButton
|
||||
active={mode === "unique_a"}
|
||||
onClick={() => setMode("unique_a")}
|
||||
active={mode === 'unique_a'}
|
||||
onClick={() => setMode('unique_a')}
|
||||
label={`Unique to ${volumeAName}`}
|
||||
/>
|
||||
<ModeButton
|
||||
active={mode === "shared"}
|
||||
onClick={() => setMode("shared")}
|
||||
active={mode === 'shared'}
|
||||
onClick={() => setMode('shared')}
|
||||
label="Shared"
|
||||
/>
|
||||
<ModeButton
|
||||
active={mode === "unique_b"}
|
||||
onClick={() => setMode("unique_b")}
|
||||
active={mode === 'unique_b'}
|
||||
onClick={() => setMode('unique_b')}
|
||||
label={`Unique to ${volumeBName}`}
|
||||
/>
|
||||
</div>
|
||||
@@ -223,7 +223,7 @@ export function CompareVolumes() {
|
||||
{/* Results */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{!hasBoth ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-ink-dull">
|
||||
<div className="text-ink-dull flex flex-col items-center justify-center gap-2 py-16">
|
||||
<ShieldCheck size={48} weight="thin" />
|
||||
<span className="text-sm">
|
||||
Select two volumes to compare their contents
|
||||
@@ -241,7 +241,7 @@ export function CompareVolumes() {
|
||||
function ModeButton({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
label
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
@@ -251,7 +251,7 @@ function ModeButton({
|
||||
<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"
|
||||
active ? 'bg-accent text-white' : 'text-ink-dull hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {motion} from 'framer-motion';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
|
||||
interface RedundancyVolumeBarProps {
|
||||
volumeUuid: string;
|
||||
@@ -13,9 +13,9 @@ interface RedundancyVolumeBarProps {
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
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]}`;
|
||||
}
|
||||
@@ -28,48 +28,47 @@ export function RedundancyVolumeBar({
|
||||
atRiskFileCount,
|
||||
redundantBytes,
|
||||
redundantFileCount,
|
||||
totalFileCount,
|
||||
totalFileCount
|
||||
}: RedundancyVolumeBarProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const redundantPercent =
|
||||
totalBytes > 0 ? (redundantBytes / totalBytes) * 100 : 0;
|
||||
const atRiskPercent =
|
||||
totalBytes > 0 ? (atRiskBytes / totalBytes) * 100 : 0;
|
||||
const atRiskPercent = totalBytes > 0 ? (atRiskBytes / totalBytes) * 100 : 0;
|
||||
// Remaining is unindexed content (no content_id)
|
||||
const unindexedPercent = Math.max(
|
||||
0,
|
||||
100 - redundantPercent - atRiskPercent,
|
||||
100 - redundantPercent - atRiskPercent
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-app-line bg-app-box/50 p-3">
|
||||
<div className="border-app-line bg-app-box/50 rounded-lg border p-3">
|
||||
{/* Header */}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="truncate text-sm font-medium text-ink">
|
||||
{displayName || "Unknown Volume"}
|
||||
<span className="text-ink truncate text-sm font-medium">
|
||||
{displayName || 'Unknown Volume'}
|
||||
</span>
|
||||
<span className="text-xs text-ink-dull">
|
||||
<span className="text-ink-dull text-xs">
|
||||
{totalFileCount.toLocaleString()} files
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bar */}
|
||||
<div className="flex h-3 w-full overflow-hidden rounded-full bg-app-box">
|
||||
<div className="bg-app-box flex h-3 w-full overflow-hidden rounded-full">
|
||||
{/* Redundant segment (safe) */}
|
||||
{redundantPercent > 0 && (
|
||||
<motion.div
|
||||
className="h-full cursor-pointer bg-status-success/70 hover:bg-status-success/90"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${redundantPercent}%` }}
|
||||
className="bg-status-success/70 hover:bg-status-success/90 h-full cursor-pointer"
|
||||
initial={{width: 0}}
|
||||
animate={{width: `${redundantPercent}%`}}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: [0.25, 1, 0.5, 1],
|
||||
ease: [0.25, 1, 0.5, 1]
|
||||
}}
|
||||
title={`Redundant: ${formatBytes(redundantBytes)} (${redundantFileCount} files) — safely backed up on other volumes`}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/redundancy/at-risk?volume=${volumeUuid}&at_risk=false`,
|
||||
`/redundancy/at-risk?volume=${volumeUuid}&at_risk=false`
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -77,18 +76,18 @@ export function RedundancyVolumeBar({
|
||||
{/* At-risk segment */}
|
||||
{atRiskPercent > 0 && (
|
||||
<motion.div
|
||||
className="h-full cursor-pointer bg-status-warning/70 hover:bg-status-warning/90"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${atRiskPercent}%` }}
|
||||
className="bg-status-warning/70 hover:bg-status-warning/90 h-full cursor-pointer"
|
||||
initial={{width: 0}}
|
||||
animate={{width: `${atRiskPercent}%`}}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: [0.25, 1, 0.5, 1],
|
||||
delay: 0.1,
|
||||
delay: 0.1
|
||||
}}
|
||||
title={`At risk: ${formatBytes(atRiskBytes)} (${atRiskFileCount} files) — only copy, not backed up`}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/redundancy/at-risk?volume=${volumeUuid}&at_risk=true`,
|
||||
`/redundancy/at-risk?volume=${volumeUuid}&at_risk=true`
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -96,31 +95,27 @@ export function RedundancyVolumeBar({
|
||||
{/* Unindexed segment */}
|
||||
{unindexedPercent > 0 && (
|
||||
<motion.div
|
||||
className="h-full bg-app-line/50"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${unindexedPercent}%` }}
|
||||
className="bg-app-line/50 h-full"
|
||||
initial={{width: 0}}
|
||||
animate={{width: `${unindexedPercent}%`}}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: [0.25, 1, 0.5, 1],
|
||||
delay: 0.2,
|
||||
delay: 0.2
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-2 flex gap-4 text-xs text-ink-dull">
|
||||
<div className="text-ink-dull mt-2 flex gap-4 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="size-2 rounded-full bg-status-success/70" />
|
||||
<span>
|
||||
Redundant ({formatBytes(redundantBytes)})
|
||||
</span>
|
||||
<div className="bg-status-success/70 size-2 rounded-full" />
|
||||
<span>Redundant ({formatBytes(redundantBytes)})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="size-2 rounded-full bg-status-warning/70" />
|
||||
<span>
|
||||
At Risk ({formatBytes(atRiskBytes)})
|
||||
</span>
|
||||
<div className="bg-status-warning/70 size-2 rounded-full" />
|
||||
<span>At Risk ({formatBytes(atRiskBytes)})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
/**
|
||||
* Redundancy Dashboard
|
||||
* Protection dashboard
|
||||
*
|
||||
* Shows library-wide replication score, per-volume redundancy bars,
|
||||
* and an at-risk data callout.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ShieldCheck, Warning, ArrowRight } from "@phosphor-icons/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { TopBarPortal, TopBarItem } from "../../TopBar";
|
||||
import { useLibraryQuery } from "../../contexts/SpacedriveContext";
|
||||
import { RedundancyVolumeBar } from "./components/RedundancyVolumeBar";
|
||||
import {ArrowRight, ShieldCheck, Warning} from '@phosphor-icons/react';
|
||||
import {motion} from 'framer-motion';
|
||||
import {useMemo} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useLibraryQuery} from '../../contexts/SpacedriveContext';
|
||||
import {TopBarItem, TopBarPortal} from '../../TopBar';
|
||||
import {RedundancyVolumeBar} from './components/RedundancyVolumeBar';
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
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]}`;
|
||||
}
|
||||
@@ -24,9 +24,9 @@ function formatBytes(bytes: number): string {
|
||||
export function RedundancyDashboard() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, isLoading } = useLibraryQuery({
|
||||
type: "redundancy.summary",
|
||||
input: {},
|
||||
const {data, isLoading} = useLibraryQuery({
|
||||
type: 'redundancy.summary',
|
||||
input: {}
|
||||
});
|
||||
|
||||
const scorePercent = useMemo(() => {
|
||||
@@ -36,19 +36,19 @@ export function RedundancyDashboard() {
|
||||
|
||||
const scoreColor =
|
||||
scorePercent >= 75
|
||||
? "text-status-success"
|
||||
? 'text-status-success'
|
||||
: scorePercent >= 40
|
||||
? "text-status-warning"
|
||||
: "text-status-error";
|
||||
? 'text-status-warning'
|
||||
: 'text-status-error';
|
||||
|
||||
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>
|
||||
<h1 className="text-ink text-xl font-bold">Protection</h1>
|
||||
</div>
|
||||
),
|
||||
[],
|
||||
[]
|
||||
);
|
||||
|
||||
if (isLoading || !data) {
|
||||
@@ -58,24 +58,24 @@ export function RedundancyDashboard() {
|
||||
left={
|
||||
<TopBarItem
|
||||
id="redundancy-title"
|
||||
label="Redundancy"
|
||||
label="Protection"
|
||||
priority="high"
|
||||
>
|
||||
{topBarTitle}
|
||||
</TopBarItem>
|
||||
}
|
||||
/>
|
||||
<div className="flex h-full items-center justify-center text-ink-dull">
|
||||
Loading redundancy data...
|
||||
<div className="text-ink-dull flex h-full items-center justify-center">
|
||||
Loading protection data...
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const { library_totals, volumes } = data;
|
||||
const {library_totals, volumes} = data;
|
||||
const totalAtRiskFiles = volumes.reduce(
|
||||
(sum, v) => sum + v.at_risk_file_count,
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -84,7 +84,7 @@ export function RedundancyDashboard() {
|
||||
left={
|
||||
<TopBarItem
|
||||
id="redundancy-title"
|
||||
label="Redundancy"
|
||||
label="Protection"
|
||||
priority="high"
|
||||
>
|
||||
{topBarTitle}
|
||||
@@ -93,47 +93,47 @@ export function RedundancyDashboard() {
|
||||
/>
|
||||
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-auto p-4 space-y-4">
|
||||
<div className="flex-1 space-y-4 overflow-auto p-4">
|
||||
{/* Replication Score + At-Risk Summary */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{/* Replication Score Card */}
|
||||
<div className="flex items-center gap-4 rounded-lg border border-app-line bg-app-box/50 p-4">
|
||||
<div className="border-app-line bg-app-box/50 flex items-center gap-4 rounded-lg border p-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<motion.span
|
||||
className={`text-4xl font-bold ${scoreColor}`}
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
initial={{opacity: 0, scale: 0.5}}
|
||||
animate={{opacity: 1, scale: 1}}
|
||||
transition={{duration: 0.4}}
|
||||
>
|
||||
{scorePercent}%
|
||||
</motion.span>
|
||||
<span className="text-xs text-ink-dull">
|
||||
<span className="text-ink-dull text-xs">
|
||||
Replication Score
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1 text-xs text-ink-dull">
|
||||
<div className="text-ink-dull flex-1 space-y-1 text-xs">
|
||||
<div>
|
||||
<span className="text-ink">
|
||||
{formatBytes(
|
||||
library_totals.total_redundant_bytes,
|
||||
library_totals.total_redundant_bytes
|
||||
)}
|
||||
</span>{" "}
|
||||
</span>{' '}
|
||||
safely replicated
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-ink">
|
||||
{formatBytes(
|
||||
library_totals.total_at_risk_bytes,
|
||||
library_totals.total_at_risk_bytes
|
||||
)}
|
||||
</span>{" "}
|
||||
</span>{' '}
|
||||
at risk (single copy)
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-ink">
|
||||
{formatBytes(
|
||||
library_totals.total_unique_content_bytes,
|
||||
library_totals.total_unique_content_bytes
|
||||
)}
|
||||
</span>{" "}
|
||||
</span>{' '}
|
||||
unique content total
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,43 +141,43 @@ export function RedundancyDashboard() {
|
||||
|
||||
{/* At-Risk Callout */}
|
||||
<button
|
||||
onClick={() => navigate("/redundancy/at-risk")}
|
||||
className="flex items-center gap-4 rounded-lg border border-status-warning/30 bg-status-warning/5 p-4 text-left transition-colors hover:bg-status-warning/10"
|
||||
onClick={() => navigate('/redundancy/at-risk')}
|
||||
className="border-status-warning/30 bg-status-warning/5 hover:bg-status-warning/10 flex items-center gap-4 rounded-lg border p-4 text-left transition-colors"
|
||||
>
|
||||
<Warning
|
||||
size={32}
|
||||
weight="fill"
|
||||
className="flex-shrink-0 text-status-warning"
|
||||
className="text-status-warning flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-ink">
|
||||
<div className="text-ink text-sm font-medium">
|
||||
{totalAtRiskFiles.toLocaleString()} files at
|
||||
risk
|
||||
</div>
|
||||
<div className="text-xs text-ink-dull">
|
||||
<div className="text-ink-dull text-xs">
|
||||
{formatBytes(
|
||||
library_totals.total_at_risk_bytes,
|
||||
)}{" "}
|
||||
library_totals.total_at_risk_bytes
|
||||
)}{' '}
|
||||
of data exists on only one volume
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight
|
||||
size={16}
|
||||
className="flex-shrink-0 text-ink-dull"
|
||||
className="text-ink-dull flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Per-Volume Redundancy Bars */}
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-medium text-ink-dull">
|
||||
<h2 className="text-ink-dull mb-2 text-sm font-medium">
|
||||
Per-Volume Breakdown
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{volumes.length === 0 ? (
|
||||
<div className="rounded-lg border border-app-line bg-app-box/50 p-6 text-center text-sm text-ink-dull">
|
||||
No volumes with indexed content found.
|
||||
Index a volume to see redundancy data.
|
||||
<div className="border-app-line bg-app-box/50 text-ink-dull rounded-lg border p-6 text-center text-sm">
|
||||
No volumes with indexed content found. Track
|
||||
storage to see its protection state.
|
||||
</div>
|
||||
) : (
|
||||
volumes.map((vol) => (
|
||||
@@ -187,9 +187,7 @@ export function RedundancyDashboard() {
|
||||
displayName={vol.display_name}
|
||||
totalBytes={vol.total_bytes}
|
||||
atRiskBytes={vol.at_risk_bytes}
|
||||
atRiskFileCount={
|
||||
vol.at_risk_file_count
|
||||
}
|
||||
atRiskFileCount={vol.at_risk_file_count}
|
||||
redundantBytes={vol.redundant_bytes}
|
||||
redundantFileCount={
|
||||
vol.redundant_file_count
|
||||
@@ -204,14 +202,14 @@ export function RedundancyDashboard() {
|
||||
{/* Quick Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate("/redundancy/compare")}
|
||||
className="rounded-lg border border-app-line bg-app-box/50 px-4 py-2 text-sm text-ink transition-colors hover:bg-app-hover"
|
||||
onClick={() => navigate('/redundancy/compare')}
|
||||
className="border-app-line bg-app-box/50 text-ink hover:bg-app-hover rounded-lg border px-4 py-2 text-sm transition-colors"
|
||||
>
|
||||
Compare Volumes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate("/redundancy/at-risk")}
|
||||
className="rounded-lg border border-app-line bg-app-box/50 px-4 py-2 text-sm text-ink transition-colors hover:bg-app-hover"
|
||||
onClick={() => navigate('/redundancy/at-risk')}
|
||||
className="border-app-line bg-app-box/50 text-ink hover:bg-app-hover rounded-lg border px-4 py-2 text-sm transition-colors"
|
||||
>
|
||||
View At-Risk Files
|
||||
</button>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useExplorer } from "../explorer/context";
|
||||
import { ExplorerView } from "../explorer/ExplorerView";
|
||||
import {useLayoutEffect} from 'react';
|
||||
import {useExplorer} from '../explorer/context';
|
||||
import {ExplorerView} from '../explorer/ExplorerView';
|
||||
|
||||
/**
|
||||
* Media sources (photos) render through the standard explorer: source mode
|
||||
* projects the source's records as Files via sources.media_listing, and the
|
||||
* real MediaView — date headers, selection, quick preview — does the rest.
|
||||
* Media sources render through the standard explorer. Source mode projects
|
||||
* records through sources.media_listing, with Media as the route default and
|
||||
* any later view choice saved only for this source.
|
||||
*/
|
||||
export function SourceMediaView({ sourceId }: { sourceId: string }) {
|
||||
const { enterSourceMode, exitSourceMode, viewMode, setViewMode } =
|
||||
useExplorer();
|
||||
export function SourceMediaView({sourceId}: {sourceId: string}) {
|
||||
const {enterSourceMode, exitSourceMode} = useExplorer();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
enterSourceMode(sourceId);
|
||||
@@ -18,13 +17,5 @@ export function SourceMediaView({ sourceId }: { sourceId: string }) {
|
||||
};
|
||||
}, [sourceId, enterSourceMode, exitSourceMode]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (viewMode !== "media") {
|
||||
setViewMode("media");
|
||||
}
|
||||
// Once per source visit; the user's later view switches stick.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sourceId]);
|
||||
|
||||
return <ExplorerView />;
|
||||
return <ExplorerView defaultViewMode="media" />;
|
||||
}
|
||||
@@ -1,41 +1,46 @@
|
||||
import { Plus, ArrowLeft } from "@phosphor-icons/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {ArrowLeft, ChartPieSlice, Plus} from '@phosphor-icons/react';
|
||||
import type {Device} from '@sd/ts-client';
|
||||
import {CircleButton, SearchBar} from '@spacedrive/primitives';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {DriveSourceCard} from '../../components/Sources/DriveSourceCard';
|
||||
import {SourceCard} from '../../components/Sources/SourceCard';
|
||||
import {useTabManager} from '../../components/TabManager/useTabManager';
|
||||
import {
|
||||
useCoreQuery,
|
||||
useLibraryQuery,
|
||||
useNormalizedQuery,
|
||||
} from "../../contexts/SpacedriveContext";
|
||||
import { useTabManager } from "../../components/TabManager/useTabManager";
|
||||
import { SourceCard } from "../../components/Sources/SourceCard";
|
||||
import { DriveSourceCard } from "../../components/Sources/DriveSourceCard";
|
||||
import { TopBarPortal, TopBarItem } from "../../TopBar";
|
||||
import { CircleButton } from "@spacedrive/primitives";
|
||||
import { SearchBar } from "@spacedrive/primitives";
|
||||
useNormalizedQuery
|
||||
} from '../../contexts/SpacedriveContext';
|
||||
import {TopBarItem, TopBarPortal} from '../../TopBar';
|
||||
|
||||
export function SourcesHome() {
|
||||
const navigate = useNavigate();
|
||||
const { createTab } = useTabManager();
|
||||
const { data: sourcesRaw, isLoading, error } = useLibraryQuery({
|
||||
type: "sources.list",
|
||||
input: { data_type: null },
|
||||
const {createTab} = useTabManager();
|
||||
const {
|
||||
data: sourcesRaw,
|
||||
isLoading,
|
||||
error
|
||||
} = useLibraryQuery({
|
||||
type: 'sources.list',
|
||||
input: {data_type: null}
|
||||
});
|
||||
const sources = sourcesRaw as any[] | undefined;
|
||||
const sources = sourcesRaw?.filter(
|
||||
(source) => source.data_type !== 'filesystem'
|
||||
);
|
||||
|
||||
// Filesystem sources — drives are sources too; the registries converge
|
||||
// storage-side, the surface converges here.
|
||||
const { data: ephemeralStatus } = useCoreQuery({
|
||||
type: "core.ephemeral_status",
|
||||
input: {},
|
||||
const {data: ephemeralStatus} = useCoreQuery({
|
||||
type: 'core.ephemeral_status',
|
||||
input: {}
|
||||
});
|
||||
const driveSources = ephemeralStatus?.sources ?? [];
|
||||
const { data: devicesData } = useNormalizedQuery({
|
||||
query: "devices.list",
|
||||
input: { include_offline: true, include_details: false },
|
||||
resourceType: "device",
|
||||
const {data: devicesData} = useNormalizedQuery({
|
||||
query: 'devices.list',
|
||||
input: {include_offline: true, include_details: false},
|
||||
resourceType: 'device'
|
||||
});
|
||||
const deviceSlug = ((devicesData as any[]) ?? []).find(
|
||||
(d) => d.is_current,
|
||||
)?.slug;
|
||||
const devices: Device[] = devicesData ?? [];
|
||||
const deviceSlug = devices.find((device) => device.is_current)?.slug;
|
||||
const totalCount = (sources?.length ?? 0) + driveSources.length;
|
||||
|
||||
return (
|
||||
@@ -51,7 +56,7 @@ export function SourcesHome() {
|
||||
</TopBarItem>
|
||||
<TopBarItem id="title" label="Title" priority="high">
|
||||
<h1 className="text-ink text-xl font-bold">
|
||||
Sources
|
||||
Storage
|
||||
</h1>
|
||||
</TopBarItem>
|
||||
</>
|
||||
@@ -59,7 +64,7 @@ export function SourcesHome() {
|
||||
right={
|
||||
<>
|
||||
<TopBarItem id="search" label="Search" priority="high">
|
||||
<SearchBar
|
||||
<SearchBar
|
||||
placeholder="Search sources..."
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
@@ -67,10 +72,27 @@ export function SourcesHome() {
|
||||
className="w-64"
|
||||
/>
|
||||
</TopBarItem>
|
||||
<TopBarItem id="add-source" label="Add Source" priority="high">
|
||||
<TopBarItem
|
||||
id="storage-usage"
|
||||
label="Usage"
|
||||
priority="normal"
|
||||
>
|
||||
<CircleButton
|
||||
icon={ChartPieSlice}
|
||||
onClick={() => navigate('/analyzer')}
|
||||
title="Storage usage"
|
||||
/>
|
||||
</TopBarItem>
|
||||
<TopBarItem
|
||||
id="add-source"
|
||||
label="Add Source"
|
||||
priority="high"
|
||||
>
|
||||
<CircleButton
|
||||
icon={Plus}
|
||||
onClick={() => createTab("Adapters", "/sources/adapters")}
|
||||
onClick={() =>
|
||||
createTab('Adapters', '/sources/adapters')
|
||||
}
|
||||
title="Add Source"
|
||||
/>
|
||||
</TopBarItem>
|
||||
@@ -78,50 +100,52 @@ export function SourcesHome() {
|
||||
}
|
||||
/>
|
||||
<div className="p-6">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-ink-faint text-sm">Loading...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-ink-faint text-sm">Loading...</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-400/20 p-4">
|
||||
<p className="text-sm text-red-400">
|
||||
Failed to load sources: {String(error)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="border-red-400/20 rounded-lg border p-4">
|
||||
<p className="text-sm text-red-400">
|
||||
Failed to load sources: {String(error)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{sources && totalCount === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-ink-dull text-sm">No storage yet</p>
|
||||
<p className="text-ink-faint mt-1 text-xs">
|
||||
Track a folder, drive, server, or provider to get
|
||||
started
|
||||
</p>
|
||||
<button
|
||||
onClick={() =>
|
||||
createTab('Adapters', '/sources/adapters')
|
||||
}
|
||||
className="bg-accent hover:bg-accent-deep mt-4 rounded-lg px-3.5 py-1.5 text-sm font-medium text-white transition-colors"
|
||||
>
|
||||
Add Source
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sources && totalCount === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-ink-dull text-sm">No sources yet</p>
|
||||
<p className="text-ink-faint mt-1 text-xs">
|
||||
Add a data source to get started
|
||||
</p>
|
||||
<button
|
||||
onClick={() => createTab("Adapters", "/sources/adapters")}
|
||||
className="bg-accent hover:bg-accent-deep mt-4 rounded-lg px-3.5 py-1.5 text-sm font-medium text-white transition-colors"
|
||||
>
|
||||
Add Source
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalCount > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{driveSources.map((source) => (
|
||||
<DriveSourceCard
|
||||
key={source.id}
|
||||
source={source}
|
||||
deviceSlug={deviceSlug}
|
||||
/>
|
||||
))}
|
||||
{(sources ?? []).map((source) => (
|
||||
<SourceCard key={source.id} source={source} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{totalCount > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{driveSources.map((source) => (
|
||||
<DriveSourceCard
|
||||
key={source.id}
|
||||
source={source}
|
||||
deviceSlug={deviceSlug}
|
||||
/>
|
||||
))}
|
||||
{(sources ?? []).map((source) => (
|
||||
<SourceCard key={source.id} source={source} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,17 +9,15 @@ import type {
|
||||
} from "../generated/types";
|
||||
import { useNormalizedQuery } from "./useNormalizedQuery";
|
||||
|
||||
export type SearchScopeUI = "folder" | "location" | "library";
|
||||
export type SearchScopeUI = "folder" | "library";
|
||||
|
||||
export interface UseSearchFilesOptions {
|
||||
/** Search query string (minimum 2 characters to search) */
|
||||
query: string;
|
||||
/** Search scope: "folder" (current path), "location", or "library" */
|
||||
/** Search scope: "folder" (current path) or "library" */
|
||||
scope: SearchScopeUI;
|
||||
/** Current path (required for "folder" scope) */
|
||||
currentPath?: SdPath | null;
|
||||
/** Location ID (required for "location" scope) */
|
||||
locationId?: string | null;
|
||||
/** Sort field: "Relevance", "Name", "Size", "ModifiedAt", "CreatedAt" */
|
||||
sortBy?: "Relevance" | "Name" | "Size" | "ModifiedAt" | "CreatedAt";
|
||||
/** Sort direction */
|
||||
@@ -76,7 +74,6 @@ export function useSearchFiles(
|
||||
query,
|
||||
scope,
|
||||
currentPath,
|
||||
locationId,
|
||||
sortBy = "Relevance",
|
||||
sortDirection = "Desc",
|
||||
mode = "Normal",
|
||||
@@ -90,11 +87,8 @@ export function useSearchFiles(
|
||||
if (scope === "folder" && currentPath) {
|
||||
return { Path: { path: currentPath } };
|
||||
}
|
||||
if (scope === "location" && locationId) {
|
||||
return { Location: { location_id: locationId } };
|
||||
}
|
||||
return "Library";
|
||||
}, [scope, currentPath, locationId]);
|
||||
}, [scope, currentPath]);
|
||||
|
||||
// Build search input
|
||||
const searchInput: FileSearchInput = useMemo(
|
||||
|
||||
Reference in new issue
Block a user