Skip to content

Commit cc7bb31

Browse files
committed
Polish error pane: richer copy, debug preview, visual fixes
- Enrich all 47 error messages with context, inline term explanations, and diagnostic CLI commands (`df -h`, `lsof`, `ls -la`, `ping`, `umount -f`, etc.) - Add FUSE mount detection via `statfs` (`macfuse`/`osxfuse`/`pcloudfs`) with provider-specific suggestions - Add `preview_friendly_error` Tauri command (`#[cfg(debug_assertions)]`) that returns real `FriendlyError` from Rust - Add "Error pane preview" section to debug window: all 47 error states with provider dropdown and L/R pane trigger buttons - Wire debug → main window via `debug-inject-error`/`debug-reset-error` events through `DualPaneExplorer.injectError`/`resetError` - Fix reset bug: `friendlyError` was not cleared in `loadDirectory`, so injected errors persisted through re-navigation - Title always uses accent color; Lucide `TriangleAlert`/`CircleAlert` icons in warning/error color signal severity - Fix text selection: add `-webkit-user-select: text` to override global WebKit rule, themed `::selection` with 20% accent tint - Remove stale `/settings/...` link post-processing from `renderErrorMarkdown` (no messages use it)
1 parent eec50ff commit cc7bb31

11 files changed

Lines changed: 833 additions & 152 deletions

File tree

apps/desktop/src-tauri/src/commands/file_system.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -939,6 +939,63 @@ pub fn inject_listing_error(volume_id: String, error_code: i32) -> Result<(), St
939939
Ok(())
940940
}
941941

942+
/// Debug-only command that generates a real `FriendlyError` for the debug error pane preview.
943+
///
944+
/// Accepts either an errno code (for `IoError` variants) or a `VolumeError` variant name.
945+
/// Optionally enriches with provider-specific suggestions when `provider_path` is set.
946+
#[cfg(debug_assertions)]
947+
#[tauri::command]
948+
pub fn preview_friendly_error(
949+
error_code: Option<i32>,
950+
variant: Option<String>,
951+
provider_path: Option<String>,
952+
) -> Result<crate::file_system::volume::friendly_error::FriendlyError, String> {
953+
use crate::file_system::volume::VolumeError;
954+
use crate::file_system::volume::friendly_error::{enrich_with_provider, friendly_error_from_volume_error};
955+
use std::path::Path;
956+
957+
let path_str = provider_path
958+
.clone()
959+
.unwrap_or_else(|| "/Users/demo/Documents/test".to_string());
960+
let path = Path::new(&path_str);
961+
962+
let volume_error = if let Some(code) = error_code {
963+
VolumeError::IoError {
964+
message: format!("os error {}", code),
965+
raw_os_error: Some(code),
966+
}
967+
} else if let Some(ref name) = variant {
968+
match name.as_str() {
969+
"NotFound" => VolumeError::NotFound(path_str.clone()),
970+
"PermissionDenied" => VolumeError::PermissionDenied(path_str.clone()),
971+
"AlreadyExists" => VolumeError::AlreadyExists(path_str.clone()),
972+
"NotSupported" => VolumeError::NotSupported,
973+
"DeviceDisconnected" => VolumeError::DeviceDisconnected("device went away".into()),
974+
"ReadOnly" => VolumeError::ReadOnly(path_str.clone()),
975+
"StorageFull" => VolumeError::StorageFull {
976+
message: "not enough space".into(),
977+
},
978+
"ConnectionTimeout" => VolumeError::ConnectionTimeout("timed out".into()),
979+
"Cancelled" => VolumeError::Cancelled("cancelled by user".into()),
980+
"IoError (no errno)" => VolumeError::IoError {
981+
message: "unknown I/O problem".into(),
982+
raw_os_error: None,
983+
},
984+
_ => return Err(format!("Unknown VolumeError variant: {}", name)),
985+
}
986+
} else {
987+
return Err("Provide either error_code or variant".into());
988+
};
989+
990+
let mut friendly = friendly_error_from_volume_error(&volume_error, path);
991+
992+
if provider_path.is_some() {
993+
enrich_with_provider(&mut friendly, path);
994+
}
995+
996+
Ok(friendly)
997+
}
998+
942999
/// Expands tilde (~) to the user's home directory.
9431000
pub(crate) fn expand_tilde(path: &str) -> String {
9441001
if (path.starts_with("~/") || path == "~")

apps/desktop/src-tauri/src/file_system/volume/friendly_error.rs

Lines changed: 341 additions & 124 deletions
Large diffs are not rendered by default.

apps/desktop/src-tauri/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,9 @@ pub fn run() {
10011001
commands::e2e::get_e2e_start_path,
10021002
#[cfg(feature = "playwright-e2e")]
10031003
commands::file_system::inject_listing_error,
1004+
// Debug-only: preview real FriendlyError for debug error pane
1005+
#[cfg(debug_assertions)]
1006+
commands::file_system::preview_friendly_error,
10041007
// Clipboard file operations
10051008
commands::clipboard::copy_files_to_clipboard,
10061009
commands::clipboard::cut_files_to_clipboard,

apps/desktop/src/lib/file-explorer/CLAUDE.md

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,8 @@ Core explorer UI components:
149149
## Error display
150150

151151
When a directory listing fails, the user sees a full-pane `ErrorPane` instead of the file list. This replaces the old
152-
raw "I/O error: Operation timed out (os error 60)" text and the separate `PermissionDeniedPane` with a unified,
153-
warm, and actionable error experience.
152+
raw "I/O error: Operation timed out (os error 60)" text and the separate `PermissionDeniedPane` with a unified, warm,
153+
and actionable error experience.
154154

155155
### How it works
156156

@@ -161,10 +161,11 @@ warm, and actionable error experience.
161161

162162
### `ErrorPane.svelte`
163163

164-
Receives a `FriendlyError` struct from Rust (all content is pre-baked on the backend, the frontend doesn't do any
165-
error classification or OS-specific logic):
164+
Receives a `FriendlyError` struct from Rust (all content is pre-baked on the backend, the frontend doesn't do any error
165+
classification or OS-specific logic):
166166

167-
- **Title**: large text, color varies by category (warning for transient, error for serious, default for needs-action)
167+
- **Title**: large text, always in accent color. Lucide icon signals severity: ⚠ `TriangleAlert` in warning color for
168+
transient, ⊘ `CircleAlert` in error color for serious, no icon for needs-action
168169
- **Folder path**: shown in secondary text so the user knows exactly which folder is affected
169170
- **Explanation**: rendered as markdown via `snarkdown` — plain-language description of what happened
170171
- **Suggestion**: rendered as markdown — actionable steps, often provider-specific (for example, "Open **MacDroid** and
@@ -185,11 +186,20 @@ wording, add a new error state, or add a new provider: edit the Rust file. See `
185186
The `ErrorPane` component should rarely need changes unless you're adding new UI elements (like illustrations, new
186187
button types, or new sections). The content flexibility comes from markdown rendering, not component code.
187188

189+
### Debug preview
190+
191+
The debug window has an "Error pane preview" section that can trigger any error state on either pane. The flow is
192+
cross-window: debug page calls `preview_friendly_error` (Tauri command, `#[cfg(debug_assertions)]` only) to get a real
193+
`FriendlyError` from Rust, then emits `debug-inject-error` via `emitTo('main', ...)`. The main window's `+page.svelte`
194+
listens for this event and calls `explorerRef.injectError(pane, friendly)`, which delegates to
195+
`FilePane.injectError(friendly)` setting the `friendlyError` state directly. Reset works via `debug-reset-error` which
196+
re-navigates the pane (clearing `friendlyError` in `loadDirectory`).
197+
188198
## Key decisions
189199

190-
**Decision**: Scoped CSS for file explorer list components, Tailwind elsewhere. **Why**: File lists render 50k+ items.
191-
Scoped CSS produces smaller DOM (no repetitive utility classes on each file entry), enabling faster rendering and lower
192-
memory. Guideline: if a component renders >100 repeated items, prefer scoped CSS.
200+
**Decision**: Scoped CSS for file explorer list components (and throughout the app — Tailwind was removed due to 15s dev
201+
startup from JIT scanning). **Why**: File lists render 50k+ items. Scoped CSS produces smaller DOM (no repetitive
202+
utility classes on each file entry), enabling faster rendering and lower memory.
193203

194204
**Decision**: Icon registry pattern — `iconId` refs in file entries, separate `get_icons()` call, frontend caches.
195205
**Why**: 50k JPEG files would otherwise transmit 50k identical icon blobs (~100-200MB). Instead, file entries carry only

apps/desktop/src/lib/file-explorer/pane/DualPaneExplorer.svelte

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
NetworkHost,
4141
ConflictResolution,
4242
WriteOperationError,
43+
FriendlyError,
4344
} from '../types'
4445
import { defaultSortOrders } from '../types'
4546
import { ensureFontMetricsLoaded } from '$lib/font-metrics'
@@ -2083,6 +2084,21 @@
20832084
paneRef?.refreshView()
20842085
}
20852086
2087+
/** Debug only: inject a FriendlyError into the specified pane. */
2088+
export function injectError(pane: 'left' | 'right', friendly: FriendlyError) {
2089+
getPaneRef(pane)?.injectError(friendly)
2090+
}
2091+
2092+
/** Debug only: reset a pane's error state by re-navigating to its current path. */
2093+
export function resetError(pane: 'left' | 'right' | 'both') {
2094+
if (pane === 'both' || pane === 'left') {
2095+
getPaneRef('left')?.navigateToPath(getPanePath('left'))
2096+
}
2097+
if (pane === 'both' || pane === 'right') {
2098+
getPaneRef('right')?.navigateToPath(getPanePath('right'))
2099+
}
2100+
}
2101+
20862102
/** Refresh network hosts in the focused pane (used by ⌘R shortcut). */
20872103
export function refreshNetworkHosts() {
20882104
const paneRef = getPaneRef(focusedPane)

apps/desktop/src/lib/file-explorer/pane/ErrorPane.svelte

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { isMacOS } from '$lib/shortcuts/key-capture'
66
import Button from '$lib/ui/Button.svelte'
77
import { renderErrorMarkdown } from './error-pane-utils'
8+
import { TriangleAlert, CircleAlert } from '@lucide/svelte'
89
910
interface Props {
1011
friendly: FriendlyError
@@ -43,14 +44,6 @@
4344
return `${String(hours)}h ago`
4445
}
4546
46-
const titleColorClass = $derived(
47-
friendly.category === 'transient'
48-
? 'title-warning'
49-
: friendly.category === 'serious'
50-
? 'title-error'
51-
: 'title-default',
52-
)
53-
5447
const isPermissionDenied = $derived(
5548
friendly.category === 'needs_action' && friendly.title.toLowerCase().includes('no permission'),
5649
)
@@ -71,7 +64,18 @@
7164

7265
<div class="error-pane" role="alert" aria-live="assertive">
7366
<div class="content">
74-
<h2 class={titleColorClass}>{friendly.title}</h2>
67+
<h2 class="title">
68+
{#if friendly.category === 'serious'}
69+
<span class="title-icon icon-error">
70+
<CircleAlert size={20} strokeWidth={2} />
71+
</span>
72+
{:else if friendly.category === 'transient'}
73+
<span class="title-icon icon-warning">
74+
<TriangleAlert size={20} strokeWidth={2} />
75+
</span>
76+
{/if}
77+
{friendly.title}
78+
</h2>
7579
<p class="folder-path">{folderPath}</p>
7680

7781
<div class="explanation">
@@ -118,6 +122,13 @@
118122
height: 100%;
119123
padding: var(--spacing-xl);
120124
line-height: 1.5;
125+
user-select: text;
126+
-webkit-user-select: text;
127+
}
128+
129+
.error-pane ::selection {
130+
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
131+
color: inherit;
121132
}
122133
123134
.content {
@@ -128,18 +139,23 @@
128139
font-size: var(--font-size-xl);
129140
font-weight: 600;
130141
margin: 0 0 var(--spacing-sm) 0;
142+
color: var(--color-accent-text);
143+
display: flex;
144+
align-items: center;
145+
gap: var(--spacing-sm);
131146
}
132147
133-
.title-warning {
134-
color: var(--color-warning);
148+
.title-icon {
149+
display: flex;
150+
flex-shrink: 0;
135151
}
136152
137-
.title-error {
138-
color: var(--color-error);
153+
.icon-warning {
154+
color: var(--color-warning);
139155
}
140156
141-
.title-default {
142-
color: var(--color-text-primary);
157+
.icon-error {
158+
color: var(--color-error);
143159
}
144160
145161
.folder-path {

apps/desktop/src/lib/file-explorer/pane/FilePane.svelte

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,7 @@
772772
loadingCount = undefined
773773
finalizingCount = undefined
774774
error = null
775+
friendlyError = null
775776
syncStatusMap = {}
776777
clearTimeout(syncRetryTimer)
777778
syncRetryTimer = undefined
@@ -1263,6 +1264,13 @@
12631264
}
12641265
}
12651266
1267+
/** Debug only: inject a FriendlyError into this pane to preview the error state. */
1268+
export function injectError(friendly: FriendlyError) {
1269+
error = null
1270+
friendlyError = friendly
1271+
loading = false
1272+
}
1273+
12661274
// When includeHidden changes, cancel rename and refetch total count
12671275
$effect(() => {
12681276
if (listingId && !loading) {

apps/desktop/src/lib/file-explorer/pane/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { FileEntry, NetworkHost } from '../types'
1+
import type { FileEntry, FriendlyError, NetworkHost } from '../types'
22

33
/** State snapshot for swapping panes without backend calls. */
44
export interface SwapState {
@@ -60,6 +60,9 @@ export interface FilePaneAPI {
6060

6161
handleKeyDown(e: KeyboardEvent): void
6262
handleKeyUp(e: KeyboardEvent): void
63+
64+
/** Debug only: inject a FriendlyError into this pane's error state. */
65+
injectError(friendly: FriendlyError): void
6366
}
6467

6568
/** Typed interface for BriefList/FullList exported methods used by FilePane. */

apps/desktop/src/routes/(main)/+page.svelte

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
} from '$lib/licensing/licensing-store.svelte'
4646
import { updateLicenseCommandName } from '$lib/commands/command-registry'
4747
import type { ViewMode } from '$lib/app-status-store'
48+
import type { FriendlyError } from '$lib/file-explorer/types'
4849
4950
// Interface for DualPaneExplorer's exported methods
5051
interface ExplorerAPI {
@@ -92,6 +93,8 @@
9293
scrollTo: (pane: 'left' | 'right', index: number) => void
9394
refreshPane: () => void
9495
refreshNetworkHosts: () => void
96+
injectError: (pane: 'left' | 'right', friendly: FriendlyError) => void
97+
resetError: (pane: 'left' | 'right' | 'both') => void
9598
newTab: () => boolean
9699
closeActiveTab: () => 'closed' | 'last-tab'
97100
closeActiveTabWithConfirmation: () => Promise<'closed' | 'last-tab' | 'cancelled'>
@@ -336,6 +339,18 @@
336339
void focusMainWindow()
337340
}
338341
})
342+
343+
// Debug error injection (dev mode only)
344+
if (import.meta.env.DEV) {
345+
await listenTauri('debug-inject-error', (event) => {
346+
const { pane, friendly } = event.payload as { pane: 'left' | 'right'; friendly: FriendlyError }
347+
explorerRef?.injectError(pane, friendly)
348+
})
349+
await listenTauri('debug-reset-error', (event) => {
350+
const { pane } = event.payload as { pane: 'left' | 'right' | 'both' }
351+
explorerRef?.resetError(pane)
352+
})
353+
}
339354
}
340355
341356
/** Set up MCP-related event listeners */

0 commit comments

Comments
 (0)