From 3c53a92dbf36db31954749a197f58180c44a2d83 Mon Sep 17 00:00:00 2001 From: Helge Klein Date: Thu, 10 Sep 2026 00:14:14 +0200 Subject: [PATCH 1/7] Dialog operation context implemented; feedback system planned --- TODO.md | 12 - .../DIALOG_OPERATION_CONTEXT_UX_PLAN.md | 1115 +++++++++++++++++ .../FORM_AND_DIALOG_FEEDBACK_POLICY_PLAN.md | 527 ++++++++ .../components/Admin/DialogReadOnlyField.tsx | 7 +- .../FileBrowser/ArchiveExtractDialog.tsx | 87 +- .../ArchiveMemberErrorResolver.tsx | 29 +- .../FileBrowser/ArchiveOperationProgress.tsx | 42 +- .../FileBrowser/BreadcrumbsNavigation.tsx | 21 +- .../FileBrowser/ConfirmDeleteDialog.tsx | 37 +- .../components/FileBrowser/CopyMoveDialog.tsx | 100 +- .../FileBrowser/CreateItemDialog.tsx | 16 +- .../FileBrowser/DialogIdentifierDisplay.tsx | 87 ++ .../FileBrowser/DialogOperationContext.tsx | 33 + .../FileBrowser/NameInputDialog.tsx | 49 +- .../FileBrowser/OverwriteConflictDialog.tsx | 82 +- .../components/FileBrowser/RenameDialog.tsx | 10 +- .../__tests__/ArchiveExtractDialog.test.tsx | 65 +- .../__tests__/ConfirmDeleteDialog.test.tsx | 33 +- .../__tests__/CopyMoveDialog.test.tsx | 95 +- .../__tests__/CreateItemDialog.test.tsx | 10 +- .../__tests__/DialogOperationContext.test.tsx | 94 ++ .../__tests__/NameInputDialog.test.tsx | 17 + .../OverwriteConflictDialog.test.tsx | 37 +- .../__tests__/RenameDialog.test.tsx | 5 +- .../FileBrowser/copyMoveDialogStrings.ts | 10 +- .../Settings/SettingsFormLayout.tsx | 105 +- frontend/src/i18n/resources.ts | 43 +- frontend/src/pages/FileBrowser.tsx | 33 +- .../FileBrowser/archiveExtractionExecution.ts | 14 +- .../FileBrowser/contentProviders.test.ts | 2 + .../src/pages/FileBrowser/contentProviders.ts | 1 + .../FileBrowser-interactions.test.tsx | 20 +- frontend/src/utils/pathDisplay.test.ts | 36 +- frontend/src/utils/pathDisplay.ts | 81 +- .../docs-reports/docs-structure-report.html | 2 +- .../settings-form-dialog-pattern/index.md | 165 +++ .../settings-form-dialog-pattern/inherit.md | 0 37 files changed, 2716 insertions(+), 406 deletions(-) create mode 100644 documentation_planning/DIALOG_OPERATION_CONTEXT_UX_PLAN.md create mode 100644 documentation_planning/FORM_AND_DIALOG_FEEDBACK_POLICY_PLAN.md create mode 100644 frontend/src/components/FileBrowser/DialogIdentifierDisplay.tsx create mode 100644 frontend/src/components/FileBrowser/DialogOperationContext.tsx create mode 100644 frontend/src/components/FileBrowser/__tests__/DialogOperationContext.test.tsx create mode 100644 website/content/docs/1.0/developer-guide/frontend-architecture/settings-form-dialog-pattern/index.md delete mode 100644 website/content/docs/1.0/developer-guide/frontend-architecture/settings-form-dialog-pattern/inherit.md diff --git a/TODO.md b/TODO.md index 972e9e38..250512eb 100644 --- a/TODO.md +++ b/TODO.md @@ -2,18 +2,6 @@ ## Bugs -- Image viewer: while swiping through images in a directory (not an archive), rendering the last image (in either direction) times out. - -## Settings - -### Styling - -- Apply the new dialog styling to dialogs we missed previously: - - "Choose viewer" -- Apply the new dialog styling to the settings pages, too, e.g.: - - 2-column design on desktop, 1-column on smaller devices - - background colors, etc. - ## Misc. commands - Add a command to open the current file list location in a new browser tab (which keyboard shortcut to assign? Ctrl+(Shift)+Tab is needed by the browser) diff --git a/documentation_planning/DIALOG_OPERATION_CONTEXT_UX_PLAN.md b/documentation_planning/DIALOG_OPERATION_CONTEXT_UX_PLAN.md new file mode 100644 index 00000000..fd4d93ae --- /dev/null +++ b/documentation_planning/DIALOG_OPERATION_CONTEXT_UX_PLAN.md @@ -0,0 +1,1115 @@ +# Dialog Operation Context UX Plan + +## Purpose + +Define a shared, accessible presentation for the description and source/target context in file-operation dialogs. The pattern must work for copy, move, rename, create, delete, archive creation and extraction, conflict resolution, and archive-member failures. + +The primary user-facing rule is simple: every filename or path appears on its own visual line, is limited to one line, and is shortened without losing the most useful identifying information. The sole exception is the current-file transfer-progress row, where a filename may share one dedicated line with its byte figures and is shortened only when needed to preserve those figures. The design must remain correct when translated text requires a different sentence order, and when item names or paths contain right-to-left text. + +## Implementation Status (completed 2026-09-09) + +Legend: ✅ verified complete; 🟡 implemented but incomplete or missing acceptance coverage; ⬜ not started. + +- ✅ Grapheme-aware filename/path abbreviation, including shared breadcrumb truncation, with focused unit coverage. +- ✅ `DialogOperationContext`, `DialogIdentifierDisplay`, fixed feedback slots, and `ArchiveOperationProgress` status-only modes. +- ✅ Dialog migrations, archive destination context, cancellation-state focus, and responsive/bidi acceptance coverage. +- ✅ Outcome-only translation migration, including obsolete inline prompt cleanup and an alternate-word-order locale fixture. +- ✅ Versioned dialog style-guide update and required documentation workflow. + +## Problem Statement + +Several dialogs currently place dynamic filenames and paths inside a translated prose sentence. `CopyMoveDialog`, `RenameDialog`, `CreateItemDialog`, the archive-create `NameInputDialog` workflow in `FileBrowser.tsx`, and `ArchiveExtractDialog` use `Trans` markup with inline identifier components. This permits localized word order, but it cannot guarantee a separate line for every identifier or give long values a shared truncation behavior. + +Other dialogs have the same presentation problem in a different form: + +- `ConfirmDeleteDialog` uses a read-only field for selected names, but does not use a common operation-context model. +- `OverwriteConflictDialog` places path labels and values in a desktop metadata grid, putting them on the same visual row. +- `ArchiveMemberErrorResolver` uses a responsive two-column definition list and allows horizontal scrolling for long paths. +- `InlineItemName` styles identifiers but does not shorten them. +- `DialogReadOnlyField` uses CSS ellipsis for input-like values, which only preserves the beginning of a long filename and does not share an identifier-specific shortening strategy. + +The result is inconsistent scanning, poor behavior for long values, and excessive dependence on sentence construction for information that is structurally source/target metadata. + +## Goals + +- Make source, target, archive, and item identifiers easy to scan in every operation dialog. +- Guarantee that a displayed filename or path has its own visual line, with no prose or another identifier beside it, except for the compact current-file transfer-progress row and its byte figures. +- Keep every identifier to one visual line without horizontal scrolling or a widening dialog. +- Preserve the useful parts of a shortened filename or path. +- Keep the complete original value available to assistive technology and through a hover affordance. +- Keep an enabled cancellation action available while a copy, move, archive creation, or archive extraction operation is pending, using its existing cancellation handle. +- Let translators localize outcome prose independently of the order or position of dynamic identifiers. +- Isolate unknown-direction filenames and paths so they do not reorder surrounding localized text. +- Reuse the pattern across all relevant dialogs without creating a schema-driven dialog framework. + +## Non-Goals + +- Change operation semantics or add cancellation plumbing for rename, create-item, delete, or conflict-decision requests. +- Add archive-creation member-progress reporting to storage or backend contracts. +- Change the values submitted to APIs, editable fields, or clipboard operations. +- Replace the existing `ResponsiveFormDialog` shell, focus behavior, or action layout. +- Add visual cards, chips, or a new global dialog theme. +- Solve arbitrary file-content or filename spoofing beyond correctly isolating presentation. Handling visible bidi-control character warnings is a separate security decision. + +## Design Principles + +### Separate prose from identifiers + +The dialog description explains the pending outcome but contains no dynamic filename or path. For example: + +- Copy single item: "Copy this item to the destination directory." +- Move multiple items: "Move the 3 selected items to the destination directory." +- Rename: "Choose a new name for this item." +- Create: "Create the new directory in the selected location." +- Create archive: "Create an archive from the 3 selected items." + +The body then shows the dynamic values in labelled fields. This avoids requiring an i18n message to accommodate an inline filename or target path at a particular grammatical position. + +### Keep currently cancellable operations cancellable + +Copy, move, archive creation, and archive extraction already expose an `AbortSignal` or execution `cancel()` handle. While one of those operations is pending, expose its established enabled cancellation action. Do not change an existing operation-specific cancellation label solely for this migration. Cancellation is a request to stop further work; it does not promise rollback of work already completed. + +The owner must connect the action to the existing controller and prevent duplicate requests while cancellation is being sent. On a clean cancellation, close the dialog without a completion summary, just as for a successful operation. Keep or reopen a terminal dialog only when cancellation produces an actionable error, partial output, or unknown outcome that the user needs to understand. + +Do not add cancellation controls to pending rename, create-item, or delete requests in this work. Their current APIs do not provide a cancel handle, so their pending primary and dismissal actions retain their current disabled behavior. Preserve the conflict dialog's existing parent-operation behavior: copy/move interprets its `null` decision as cancellation of the active transfer loop, and archive extraction supplies its active extraction cancellation callback. If the component is ever used without a parent cancellation callback, `Cancel` declines only that decision. + +### Apply focus consistently + +The dialog owner must set initial focus after the dialog transition, and move focus deliberately when the dialog changes state: + +- An editable form focuses its primary input. Rename keeps its established selection behavior: select the basename of a file and all text for a directory. +- A non-destructive confirmation without an input focuses its primary action. +- A destructive confirmation focuses `Cancel`. +- A conflict dialog focuses its owner-supplied safe resolution; if that resolution is `Rename`, focus the target-name input instead. An unavailable-resolution error receives focus. +- A cancellable pending copy, move, archive-create, or archive-extract operation focuses its enabled cancellation action. Do not leave focus on a control that became disabled. +- A terminal actionable outcome focuses `Close`. +- A validation or recoverable API error returns focus to the invalid or retryable input. An archive member error focuses `Retry`. + +### Use a vertical operation context + +Every identifier field uses a vertical definition-list row: + +1. A translated `dt` label, such as "Source item" or "Destination directory". +2. A full-width `dd` containing one identifier display. + +The label is always above the value at every viewport width. Do not use a desktop label/value grid for these fields. Group related fields under a concise translated heading only where that distinction is meaningful. + +`OverwriteConflictDialog` is the deliberate exception: its constrained migration retains the existing compact label/value metadata grids under `Existing item` and `Incoming item`. Each `Path` label and its filename-inclusive `DialogIdentifierDisplay` value remain on the same row; do not apply the general vertical-context reorganization to that dialog. + +### Preserve information according to identifier type + +Shortening should be semantic, not a generic end ellipsis: + +| Identifier type | Shortening rule | Example | +| --- | --- | --- | +| Filename | Preserve the beginning, a meaningful tail, and the final extension. Use a middle ellipsis. | `2026-08-28...Firefox.png` | +| Directory or file path | Preserve the provider/root prefix and final basename. Collapse ancestor segments before shortening the basename. | `Demo:/.../Test dir/report.png` | +| Root or provider-only path | Preserve the provider/root first; shorten the remaining content only if required. | `Demo:/...` | +| Multi-selection | Do not concatenate all values into description prose. Use a count in the outcome sentence. For a delete preview, show at most six one-line names followed by a localized remaining-count summary. | + +Treat a leading-dot filename such as `.gitignore` as having no extension. For a multi-extension filename such as `archive.tar.gz`, preserve the final extension by default; do not infer semantics from arbitrary dot-separated suffixes. + +## Shared Component Design + +### `DialogOperationContext` + +Create `frontend/src/components/FileBrowser/DialogOperationContext.tsx` as the shared, layout-only component for read-only operation metadata. + +Suggested public contract: + +```tsx +type DialogOperationIdentifierKind = "fileName" | "path"; + +interface DialogOperationContextEntry { + label: string; + value: string; + kind: DialogOperationIdentifierKind; + testId?: string; +} + +interface DialogOperationContextProps { + entries: readonly DialogOperationContextEntry[]; + ariaLabel?: string; +} +``` + +The component must: + +- Render a `section` containing a `dl`. +- Render each label as a `dt` above its corresponding `dd`. +- Set `minWidth: 0` on the flex/grid ancestors and the `dd` so overflow behavior is reliable. +- Use a single column at every breakpoint. +- Be unframed. It may use the existing subdued dialog surface only when the owning dialog already uses `SettingsFormSurface` or `DialogReadOnlyField` for its operation context. +- Accept entries already formatted for display. Connection/path formatting remains the responsibility of the owning dialog through `formatConnectionPath` or an existing equivalent. + +Keep operation-specific state, translated labels, and source/target selection in each owning dialog. `DialogOperationContext` only owns semantic layout and identifier display. + +### `DialogIdentifierDisplay` + +Create `frontend/src/components/FileBrowser/DialogIdentifierDisplay.tsx` as the presentation primitive used by `DialogOperationContext`. It replaces display-only uses of `InlineItemName` in operation contexts; retain `InlineItemName` for genuinely inline prose elsewhere until there are no callers. + +Suggested public contract: + +```tsx +interface DialogIdentifierDisplayProps { + value: string; + kind: "fileName" | "path"; + testId?: string; +} +``` + +The component must: + +- Render as a block-level `` element inside ``, or an equivalent structure that preserves code styling while applying directional isolation to the value. +- Keep a single visual line with `overflow: hidden`, `textOverflow: ellipsis`, `whiteSpace: nowrap`, and `maxWidth: "100%"` as the final browser-level overflow safeguard. +- Render a pre-shortened display string, but expose the complete `value` through `aria-label` and a MUI `Tooltip` on hover only when the displayed string differs from `value`. Set `disableFocusListener` and `disableTouchListener`; do not give the display a `tabIndex`. +- Use the complete unmodified value in the tooltip and accessible name. Never insert truncation markers into API values, input state, test fixtures that represent stored values, or clipboard values. +- Preserve the existing dialog identifier colors, border, monospace family, and compact padding from `InlineItemName`. +- Avoid a text button inside the display. A later copy-to-clipboard feature, if needed, must use a dedicated icon button with an accessible label and must not reduce the available value width unexpectedly. + +Do not use `title` as the only full-value mechanism. Native titles do not provide a dependable touch or keyboard experience. Retain `title` only if it is useful as a low-cost browser fallback in addition to the accessible label and tooltip. + +### Fixed input feedback slots + +Extend `frontend/src/components/Settings/SettingsFormLayout.tsx` with shared feedback styles or primitives for one- and two-field operation dialogs. Do not let each dialog define its own error-message height, wrapping, or overflow behavior. + +Use exactly one of these stable slots for each condition: + +1. **Field feedback slot:** reserve one helper-text line directly below every input that can produce local validation feedback. Supply a placeholder when idle, then replace it with the field error or warning. Use the same `formHelperText` slot styling for `CopyMoveDialog`, `NameInputDialog`, `ArchiveExtractDialog`, and `OverwriteConflictDialog`. +2. **Form notice slot:** reserve one alert-height line below the form surface for a non-field error or warning, such as a failed request, an unavailable destination, or partial completion. Keep this notice mounted with `visibility: hidden` while idle; do not conditionally insert it after the form has rendered. + +Both slots must use the same geometry: one line of the configured typography, no wrapping, `overflow: hidden`, and `textOverflow: ellipsis`. Field feedback remains associated with its input through `aria-describedby`. A shortened feedback message must retain its complete text in the accessible name or description and a MUI tooltip available on hover. Authors must write concise, actionable summaries that fit the line under normal dialog widths; log or expose diagnostic detail separately rather than expanding the slot to a second line. + +Do not duplicate a field-owned validation error in the form notice slot. The fixed-height form notice is only for a condition the user cannot remedy in a specific input. Both the field slot and form notice must remain in the layout while a pending state disables the input, so changing feedback or state never shifts the action row. + +### Archive operation progress + +Evolve the existing `ArchiveOperationProgress` into a status-only archive surface with two explicit presentation modes, using only data already returned by existing archive APIs: + +```ts +type ArchiveOperationProgressProps = + | { operation: "create" } + | { operation: "extract"; processedMembers?: number }; +``` + +Archive creation receives no member counter and renders only the localized `Creating archive...` label with an indeterminate bar. Archive extraction has two localized, truthful states: + +```text +Preparing archive... +[==============================] + +Processed 12 archive items... +[==============================] +``` + +Use `Preparing archive...` until extraction has processed an archive member. Once the count advances, use `Processed {{processed}} archive items...`, retaining an indeterminate bar. This neutral wording is accurate because `members_processed` includes successful, skipped, and failed member outcomes. Do not present a determinate bar, percentage, or `of {{total}}` wording: the current extraction progress payload has no total-member counter, and this work must not change archive API, backend, storage-coordinator, or generic `ContentOperationExecution` contracts. + +For extraction, retain the existing `members_processed` aggregate counter from each status/result payload in the frontend-only `ArchiveExtractionSummary` mapper and pass it as `processedMembers`. It includes successful, skipped, and failed member outcomes exactly once, unlike `filesExtracted + directoriesCreated`, which is a display-statistic combination rather than a progress invariant. Do not infer a total from selected top-level sources or directory entries. + +Do not render bytes or the current item name in either archive status mode. The transport payload may retain byte and file-statistic fields for diagnostics or other UI, but `ArchiveOperationProgress` must not consume them. The static archive/source context already identifies the operation input, and any additional current filename must use its own labelled one-line `DialogIdentifierDisplay`. + +### Overwrite conflict resolution: constrained migration + +`OverwriteConflictDialog` is the shared, multi-purpose resolution surface for copy, move, and archive extraction. Its migration is intentionally limited: + +**Will change:** + +1. Move `Target name` from the top of the dialog to below the resolution choices. Render its editable input and fixed helper-text line only after the user selects `Rename`. The resulting layout shift is accepted because the selected resolution changes the form. The initial suggested name, validation rules, selection behavior, and submitted `targetName` remain unchanged. +2. Keep the existing `Existing item` and `Incoming item` metadata grids, but expand each `Path` value to include its filename. Render the resulting full file path with `DialogIdentifierDisplay` and semantic path shortening, rather than the local `PathValue` end ellipsis. Modified time and size remain in their current compact rows. +3. Replace the conditionally mounted owner-error alert with the shared, one-line form-notice slot in its existing pre-metadata position. Keep the slot reserved while idle, retain the existing error focus behavior, and do not add a notice for field-owned Rename validation. + +**Will not change:** + +- The dialog remains the single shared resolution component for copy, move, and archive extraction. +- The owner continues to provide the allowed actions, safe default, all-remaining policy, progress context, source/target paths, errors, and operation callbacks. +- No resolution is added, removed, renamed, reordered, or inferred. `Skip`, `Overwrite`, `Overwrite only older`, and `Rename` retain their current semantics and availability rules. +- The existing all-remaining checkbox behavior, conflict count/progress, direction cue, control ordering, action labels, Enter handling, `aria-busy`, error focus, and Rename-input focus transfer remain intact, except that the approved form-notice slot stays mounted while idle. +- Archive extraction continues to use the same decision mapping, member paths, and resume/continuation behavior. +- No conflict-resolution business rule or decision payload changes as part of this UI migration. + +**Current layout:** + +```text +Resolve conflict +An item already exists at the destination. + +Target name +[annual-report.pdf ] + +Existing item + Path [Demo:/Archive/2026] + Modified 2026-09-01 10:30 + Size 2.4 MB + + [upward direction cue] + +Incoming item + Path [Demo:/Reports] + Modified 2026-09-02 08:15 + Size 2.5 MB + +Resolution + (x) Skip ( ) Overwrite ( ) Overwrite only older ( ) Rename + + Cancel operation Continue +``` + +**New layout:** + +```text +Resolve conflict +An item already exists at the destination. + +Existing item + Path [Demo:/Archive/2026/annual-report.pdf] + Modified 2026-09-01 10:30 + Size 2.4 MB + + [upward direction cue] + +Incoming item + Path [Demo:/Reports/annual-report.pdf] + Modified 2026-09-02 08:15 + Size 2.5 MB + +Resolution + (x) Skip ( ) Overwrite ( ) Overwrite only older ( ) Rename + +[Target name: visible only for Rename] +[input: annual-report (copy).pdf ] +[error: Name is already in use. ] + + Cancel operation Continue +``` + +The final three lines in the new wireframe denote Rename-only controls, not controls that are visible together. When the resolution is not `Rename`, they are absent. When it is `Rename`, the input and its fixed feedback line are inserted below the resolution choices and the input receives focus. The resulting layout shift is intentional. No other visual restructuring is part of this migration. + +**Owner-error state:** + +```text +Resolve conflict +An item already exists at the destination. + +[Error: The conflict could not be resolved.] + +Existing item + Path [Demo:/Archive/2026/annual-report.pdf] + Modified 2026-09-01 10:30 + Size 2.4 MB + + [upward direction cue] + +Incoming item + Path [Demo:/Reports/annual-report.pdf] + Modified 2026-09-02 08:15 + Size 2.5 MB + +Resolution + (x) Skip ( ) Overwrite ( ) Overwrite only older ( ) Rename + + Cancel operation Continue +Focus: Error notice +``` + +The form-notice slot occupies this same pre-metadata position while idle but is visually hidden. An owner error reveals it and receives focus without moving the metadata, resolution controls, or actions. + +### Shared shortening utilities + +Extend the existing `frontend/src/utils/pathDisplay.ts`; it is the single source of truth for all filename and path abbreviation. Do not create `identifierDisplay.ts` or another component-local shortening helper. + +Its public API must cover the existing path use case and the new filename use case: + +```ts +function abbreviateFileName(value: string, availableWidth: number, measureText: (value: string) => number): string; +function abbreviatePath(value: string, availableWidth: number, measureText: (value: string) => number): string; +``` + +Requirements: + +- Use grapheme clusters rather than UTF-16 code units when splitting text. Use `Intl.Segmenter` when available and a safe fallback when it is unavailable. +- Use a binary search to find the largest fitting prefix/suffix combination after reserving the ellipsis and filename extension. +- Keep and improve `abbreviatePath`, including support for `/` and `\\` separators and provider-prefixed roots. +- When a path does not fit, collapse complete ancestor segments first. Only shorten the basename when the root plus the collapsed form still does not fit. +- When even the minimal meaningful form does not fit, return the least misleading abbreviated form and let CSS overflow provide the final clipping safeguard. +- Preserve a stable result for the same value and measured width; do not visibly oscillate while a dialog is open. +- Move `BreadcrumbsNavigation`'s `truncateSegmentName` logic to an exported companion utility in `pathDisplay.ts`, or make it delegate to one. Its breadcrumb-specific character budget may remain at the call site, but it must not maintain a second filename-shortening implementation. + +`DialogIdentifierDisplay` must obtain available width from its own rendered element using `ResizeObserver`. Measure text using a hidden element with the display's resolved font styles, or a canvas configured with its computed font. Recalculate after font readiness when `document.fonts` is available. This component owns measurement only; it must call `pathDisplay.ts` for abbreviation. Do not use a fixed character count for dialog values, because proportional fonts, localization, browser zoom, and user font settings make it inaccurate. + +Server-side rendering is not currently required by the frontend, but the initial render must safely show the full value until browser measurement is available. + +## Localization and Bidirectional Text + +### Translation keys + +Replace dynamic-identifier sentences with two classes of translations: + +1. Outcome-only descriptions, with pluralization where a count appears. +2. Stable field labels for source, target, archive, existing item, incoming item, and selected items. + +Example key structure: + +```json +{ + "fileBrowser": { + "operationContext": { + "sourceItem": "Source item", + "destinationDirectory": "Destination directory", + "existingItem": "Existing item", + "incomingItem": "Incoming item" + }, + "copyMove": { + "copySingleDescription": "Copy this item to the destination directory.", + "copyMultipleDescription_one": "Copy this item to the destination directory.", + "copyMultipleDescription_other": "Copy the {{count}} selected items to the destination directory." + }, + "archive": { + "createDescription_one": "Create an archive from this selected item.", + "createDescription_other": "Create an archive from the {{count}} selected items." + } + } +} +``` + +No outcome key should use markup placeholders for filenames, paths, directories, or connections. This permits language-specific word order without forcing dynamic values into a translated sentence. + +### Migration inventory + +Replace the following current dynamic-value `Trans` usages during the named dialog migration. Retire a legacy key only after every locale has the replacement key. + +| Owner | Current dynamic prompt | Replacement outcome key | Context labels | +| --- | --- | --- | --- | +| `CopyMoveDialog` | Single-item copy/move prompt with source name and destination | `copySingleDescription` / `moveSingleDescription` | `Source item`, `Destination directory` | +| `CopyMoveDialog` | Multi-item copy/move prompt with destination | pluralized `copyMultipleDescription` / `moveMultipleDescription` | `Destination directory` | +| `RenameDialog` through `NameInputDialog` | Rename prompt with current name | `renameDescription` | Current editable `New name` field remains authoritative | +| `CreateItemDialog` through `NameInputDialog` | Create prompt with target directory | `createItemDescription` | `Destination directory` | +| Archive-create `NameInputDialog` workflow | Archive-create prompt with selected names or directory | pluralized `archive.createDescription` | `Source item` when singular, `Destination directory` | +| `ArchiveExtractDialog` | Archive or member extraction prompt with archive/member value | `archive.extractDescription`, `archive.extractMemberDescription`, and pluralized `archive.extractMembersDescription` | `Archive` or `Archive member`, `Destination directory` | +| `ConfirmDeleteDialog` | Delete prompt with selected name(s) | `deleteSingleDescription` / pluralized `deleteMultipleDescription` | `Item to delete`, or bounded selected-item preview | +| `OverwriteConflictDialog` | Existing/incoming conflict prompt values | `conflictDescription` | Existing compact `Existing item` / `Incoming item` groups | +| `ArchiveMemberErrorResolver` | Member failure prompt values | `archive.memberErrorDescription` | `Archive member`, `Target path` | + +The exact English wording may change during implementation, but the migration must preserve the existing namespace ownership and use plain interpolation only for counts, not identifiers. + +### Bidi handling + +Filenames and paths are data with unknown text direction. Render them as separate, directionally isolated elements using ``; do not add Unicode directional control characters to the stored or submitted value. + +The display must continue to work when: + +- The application is in an RTL locale and the value is predominantly Latin text. +- The application is in an LTR locale and the value is predominantly Arabic or Hebrew text. +- A path combines slash or backslash separators, digits, Latin text, and RTL directory names. + +Do not force all identifiers to `dir="ltr"`, because filenames may legitimately be RTL. Do not rely on the surrounding description paragraph for directionality. + +## Proposed Layout Examples + +The examples below are inspection sketches for the accepted layout. They show visual order, not implementation markup. Every indented value under a label is one `DialogIdentifierDisplay`: full width, one visual line, intelligently abbreviated when needed, with the complete value available through its accessible name and tooltip. + +`[input: value]` is an editable text input. `[value]` is a read-only identifier display. `[error: message]` and `[warning: message]` are the fixed, single-line feedback slots. `Focus:` is a wireframe annotation for initial focus on opening or after a state transition; it is not visible UI text. Alerts and progress bars appear in the order shown. Identifier displays align with their labels. Actions are a compact, right-aligned group, matching the existing dialog shell. The compact examples use short placeholder values for legibility; the one-line identifier rule still applies to long or mixed-direction values. + +### Copy and Move + +#### Single-item copy + +```text +Copy +Copy this item to the destination directory. + +Source item +[report-final.pdf] +Destination directory +[Demo:/Archive/2026] + +New name +[input: report-final.pdf ] + + Cancel Copy +Focus: New name +``` + +#### Single-item move + +```text +Move +Move this item to the destination directory. + +Source item +[report-final.pdf] +Destination directory +[Demo:/Archive/2026] + +New name +[input: report-final.pdf ] + + Cancel Move +Focus: New name +``` + +#### Single-item copy to its current directory + +```text +Copy +Copy this item to the destination directory. + +Source item +[report-final.pdf] +Destination directory +[Demo:/Reports] + +New name +[input: report-final (copy).pdf ] + + Cancel Copy +Focus: New name +``` + +The suggested copy name makes this valid immediately. The context still shows the original source item and destination independently. + +#### Single-item move with unchanged name in its current directory + +```text +Move +Move this item to the destination directory. + +Source item +[report-final.pdf] +Destination directory +[Demo:/Reports] + +New name +[input: report-final.pdf ] +[error: A different name is required in this directory.] + + Cancel Move (disabled) +Focus: Cancel +``` + +#### Batch copy or move + +```text +Copy +Copy the 3 selected items to the destination directory. + +Destination directory +[Demo:/Archive/2026] + + Cancel Copy +Focus: Copy +``` + +`Move` substitutes its title, outcome, and primary action, which receives initial focus. The selected items are not listed in the description or context by default. + +#### Batch operation with the same destination + +```text +Copy +Copy the 3 selected items to the destination directory. + +Destination directory +[Demo:/Reports] + +The destination directory is the same as the source directory. + + Cancel Copy (disabled) +Focus: Cancel +``` + +#### Processing and transfer progress + +```text +Copy +Copy the 3 selected items to the destination directory. + +Destination directory +[Demo:/Archive/2026] + +Copying 2 of 3 +[====================----------] + +[report-final.pdf] 24.0 MB / 60.0 MB +[============------------------] + + Cancel Copying (disabled) +Focus: Cancel +``` + +For a single-item operation, retain its source, destination, and disabled `New name` field above the progress. Keep the current filename and its byte figures on one compact flex row. Reserve the byte figures as a non-wrapping trailing value, give the filename the remaining width, and abbreviate that filename with the shared `pathDisplay.ts` utility only when the complete row would otherwise overflow. The complete filename remains available through its accessible name and tooltip. When the total bytes are unknown, retain the same row with only bytes transferred and use an indeterminate byte-progress bar. + +Copy/move already supplies selected-item progress and current-file byte progress. The regular-file counter has a fixed selected-item total. Recursive directory transfers report completed and discovered entries, so their denominator may grow; label that state as a discovered-item count rather than promising a final total. Byte totals remain optional in the cross-provider transfer contract because a provider may not expose file size; making them mandatory would require provider-wide metadata guarantees and preflight directory traversal, so it is not a low-effort change. + +#### Actionable feedback and terminal state + +```text +Copy +Copy this item to the destination directory. + +[Error: The copy could not be completed.] +[Warning: The destination was created but the source was retained.] + +Source item +[report-final.pdf] +Destination directory +[Demo:/Archive/2026] + +New name +[input: report-final.pdf ] + + Close +Focus: Close +``` + +Show a terminal state only when an error, warning, partial-output, or unknown-outcome notice requires user attention. Retain the contextual information, hide the primary action, and provide only `Close`. A successful copy/move and a clean cancellation close the dialog without a terminal summary. + +### Rename and Create Item + +#### Rename file + +```text +Rename file +Choose a new name for this item. + +New name +[input: annual-report.pdf ] + + Cancel Rename +Focus: New name +``` + +#### Rename directory + +```text +Rename directory +Choose a new name for this item. + +New name +[input: Financial reports ] + + Cancel Rename +Focus: New name +``` + +The file and directory variants differ only in title and initial text-selection range. + +#### Rename validation, failure, and submitting + +```text +Rename file +Choose a new name for this item. + +New name +[input: annual-report.pdf ] +[error: Choose a different name.] + + Cancel Rename (disabled) +Focus: New name +``` + +```text +Rename file +Choose a new name for this item. + +New name +[input: annual-report-final.pdf ] + +[Error: A file with this name already exists.] + + Cancel Rename +Focus: New name +``` + +```text +Rename file +Choose a new name for this item. + +New name +[input: annual-report-final.pdf ] (disabled) + + Cancel Renaming... (disabled) +Focus: Cancel +``` + +The prefilled `New name` input is the sole presentation of the current name; do not repeat it in read-only context. Validation uses its reserved field-feedback line. A server error uses the separate reserved form-notice line and refocuses the input; neither condition shifts the dialog layout. + +#### Create file + +```text +New file +Create the new file in the destination directory. + +Destination directory +[Demo:/Projects/2026] + +Name +[input: ] + + Cancel Create +Focus: Name +``` + +#### Create directory + +```text +New directory +Create the new directory in the destination directory. + +Destination directory +[Demo:/Projects/2026] + +Name +[input: ] + + Cancel Create +Focus: Name +``` + +#### Creating and creation failure + +```text +New file +Create the new file in the destination directory. + +Destination directory +[Demo:/Projects/2026] + +Name +[input: notes.txt ] (disabled) + + Cancel Creating... (disabled) +Focus: Cancel +``` + +```text +New file +Create the new file in the destination directory. + +Destination directory +[Demo:/Projects/2026] + +Name +[input: notes.txt ] + +[Error: A file with this name already exists.] + + Cancel Create +Focus: Name +``` + +The file and directory variants share this layout. Validation uses the reserved field-feedback line below `Name`; an API failure uses the separate reserved form-notice line. + +### Archive Creation + +Archive creation is composed in `FileBrowser.tsx` with `NameInputDialog`; it is not a separate `ArchiveCreateDialog` component. + +#### One selected source + +```text +Create ZIP Archive +Create an archive from the selected item. + +Source item +[annual-report.pdf] +Destination directory +[Demo:/Archives] + +Archive name +[input: archive.zip ] + + Cancel Create +Focus: Archive name +``` + +#### Multiple selected sources + +```text +Create ZIP Archive +Create an archive from the 3 selected items. + +Destination directory +[Demo:/Archives] + +Archive name +[input: archive.zip ] + + Cancel Create +Focus: Archive name +``` + +Only a single selection gets a `Source item` context row. Multiple selections use the localized count without listing selected names. + +#### Creating + +```text +Create ZIP Archive +Create an archive from the 3 selected items. + +Destination directory +[Demo:/Archives] + +Creating archive... +[==================--------------] + + Cancel +Focus: Cancel +``` + +During creation, replace the editable archive-name form with `ArchiveOperationProgress` in its `operation="create"` mode, retaining the static destination context. Show `Creating archive...`; do not invent member counts, a total, or a determinate percentage until archive creation has a separately designed backend progress capability. + +#### Creation failure or partial output + +```text +Create ZIP Archive +Create an archive from the 3 selected items. + +Destination directory +[Demo:/Archives] + +Archive name +[input: archive.zip ] + +[Error: Archive creation failed.] + + Cancel Create +Focus: Archive name +``` + +For partial output, use the factual partial-output error in the same position. The error returns the user to the editable archive-name form. + +### Archive Extraction + +#### Whole archive with editable destination name + +```text +Extract Archive +Extract this archive to the destination directory. + +Archive +[project-backup.zip] +Destination directory +[Demo:/Restored] + +Destination name +[input: project-backup ] + + Cancel Extract +Focus: Destination name +``` + +#### Archive scope with a fixed destination + +```text +Extract Archive +Extract this archive member to the destination directory. + +Archive member +[reports/annual-report.pdf] +Destination directory +[Demo:/Restored] + + Cancel Extract +Focus: Extract +``` + +There is no editable destination-name field when the provider fixes the extraction destination. + +This fixed-destination layout applies to a whole archive or one or more selected archive members when extracting to the other pane. Substitute the localized scope description, such as `Extract the 3 selected archive members to the destination directory.`, without adding a destination-name input. + +#### Multiple archive members with a new sibling directory + +```text +Extract Archive +Extract the 3 selected archive members to the destination directory. + +Destination directory +[Demo:/Restored] + +Destination name +[input: project-backup ] + + Cancel Extract +Focus: Destination name +``` + +The selected-member count is localized. Individual member paths are not placed in prose. `Destination name` is the editable new sibling-directory name and is initialized from the archive basename, not the selected members. It appears only in single-pane extraction, regardless of whether the scope is the whole archive or one or more selected members. In dual-pane extraction, the other pane supplies a fixed destination and the input is omitted. + +#### Extracting + +```text +Extract Archive +Extract this archive to the destination directory. + +Archive +[project-backup.zip] +Destination directory +[Demo:/Restored] + +Processed 12 archive items... +[==============================] + + Cancel +Focus: Cancel +``` + +#### Archive member error + +```text +Error Extracting Member +Choose how to continue extracting the archive. + +[Error or warning: Unable to write the member.] + +Archive member +[reports/annual-report.pdf] +Target path +[Demo:/Restored/reports/annual-report.pdf] + +Partial output may be present. (warning only) + + Cancel Ignore Retry +Focus: Retry +``` + +The warning and error variants differ by alert severity and whether the partial-output note appears. Both use the same vertically stacked operation context. + +#### Extraction conflict + +```text +Resolve conflict +An item already exists at the destination. + +Existing item + Path [Demo:/Restored/reports/annual-report.pdf] + Modified 2026-09-01 10:30 + Size 2.4 MB + + [upward direction cue] + +Incoming item + Path [project-backup.zip/reports/annual-report.pdf] + Modified 2026-08-30 09:12 + Size 2.3 MB + +Resolution: ( ) Skip ( ) Overwrite ( ) Rename + + Cancel operation Continue +Focus: Skip +``` + +This delegates to the shared overwrite-resolution layout in the constrained-migration section above. Archive extraction supplies the action set and source/target path labels. + +### Delete Confirmation + +#### Single file or directory + +```text +Delete file +Delete this file permanently. + +Item to delete +[annual-report.pdf] + + Cancel Delete +Focus: Cancel +``` + +For a directory, substitute `Delete directory`, its destructive outcome description, and the same `Item to delete` row. `Cancel` retains initial focus for both variants. + +#### Multiple items + +```text +Delete multiple items +Delete the 3 selected items permanently. + +Items to delete +[annual-report.pdf] +[notes.txt] +[Financial reports] + + Cancel Delete +Focus: Cancel +``` + +Show at most six one-line identifier displays. When additional items are selected, replace the remaining rows with a localized summary such as `and 47 more items`; do not add an inner scroll container or render the full list. Each shown item has its own complete accessible value. Do not use one newline-delimited textarea. + +## Dialog Migration Plan + +### Phase 1: Reference implementation + +Migrate `CopyMoveDialog` first. + +- Replace the single-item `` description with an outcome-only translated description. +- For a single item, render `Source item` with the original filename and `Destination directory` with `destinationLabel` in `DialogOperationContext`. +- Keep the existing editable `New name` field for a single item and the read-only destination control for a batch only until the shared context replaces it without losing selection/copy behavior. +- For a batch, use the count in the description and show `Destination directory` in the context. Do not list every selected name by default. +- Preserve existing same-directory validation, suggested copy name, focus selection, and Enter-key behavior. Wire the pending action to its existing abort controller so `Cancel` remains available while work is in progress. + +This migration validates the primary use case shown by the current browser issue: a long source filename and a long destination must never share a prose line. + +### Phase 2: Single-name workflows + +Migrate `RenameDialog` and `CreateItemDialog` through `NameInputDialog` support. + +- Extend `NameInputDialog` with an optional `operationContext` slot rendered below the description and above the state-specific body. Retain the context while a submitting-content view replaces the editable form. +- `RenameDialog`: description states the action. The prefilled `New name` input is the sole presentation of the current name; preserve its existing selection behavior. +- `CreateItemDialog`: description states the action; context shows `Destination directory` and the formatted target directory. +- Keep the editable field as the authoritative full value. The display-only context must not alter name selection, validation, or API errors. +- Adopt the shared fixed input-feedback slots. Validation uses the reserved helper-text line; API failures and warnings use the reserved form-notice line, with no wrapping or state-driven layout shift. +- Retain the current pending behavior for rename and create-item requests. Their APIs do not expose cancellation handles. + +### Phase 3: Archive workflows + +Migrate the archive-create `NameInputDialog` workflow in `FileBrowser.tsx`, `ArchiveExtractDialog`, and `ArchiveMemberErrorResolver`. + +- Archive creation: use `createDescription` pluralization with the selected-item count. Show `Destination directory` and the editable archive filename in their established controls; when the dialog needs to identify a single selected source, show it as `Source item` in the context. Do not concatenate selected source names into prose. During pending creation, show only the indeterminate `Creating archive...` status. Preserve archive filename validation and its existing cancellation handle. +- `ArchiveExtractDialog`: show `Archive` or `Archive member` in the context for a single source; use a count-only description for multiple selected archive members; show the extraction destination as its own context value whenever it is known before confirmation. Make `Destination name` depend only on the destination mode: single-pane extraction creates a sibling directory initialized from the archive basename, while dual-pane extraction has a fixed other-pane destination and omits the input for every scope. Map the existing extraction payload's `members_processed` aggregate to the frontend summary and render preparation and processed-only indeterminate states using neutral `Processed {{processed}} archive items...` wording. +- `ArchiveMemberErrorResolver`: replace its two-column definition list and horizontal-scroll containers with vertically stacked `Archive member` and `Target path` context entries. +- Keep error and partial-output notices before or after the context according to their existing state, without embedding values inside error prose. +- Apply the shared fixed input-feedback slots to archive-name and destination-name validation, plus archive API notices, so a new message never pushes controls or actions. +- Ensure archive creation and extraction cancellation reaches their existing active execution handles. Preserve the conflict dialog's parent-operation cancellation behavior. Close silently after a clean cancellation; retain the dialog only for an actionable error, partial-output, or unknown outcome. + +### Phase 4: Destructive and conflict workflows + +Migrate `ConfirmDeleteDialog` and `OverwriteConflictDialog`. + +- `ConfirmDeleteDialog`: a single item uses `Item to delete` as an identifier context. For multiple items, show at most six single-line filename rows with identifier shortening, followed by a localized summary of the undisplayed count. Do not use a scrollable full list or one newline-delimited textarea. +- `OverwriteConflictDialog`: retain the target-name field, source/existing-target group headings, metadata grids, direction cue, resolution controls, and actions. Move the target-name field below the resolution choices and render it only for Rename, accepting that state change's layout shift. Include each item's filename in its one-line, semantically shortened `Path` value. Keep modified time and size in their current compact rows. Replace the local `PathValue` display with `DialogIdentifierDisplay` after migration. +- Apply the constrained-migration checklist above. Do not alter owner-supplied resolutions, safe defaults, batch policy, decision payloads, or archive-extraction action mapping. +- Preserve the current safe default focus on Cancel for delete and Skip or the owner-supplied safe choice for conflicts. +- Retain the current disabled pending controls for delete. While a conflict dialog is waiting for a choice, its enabled `Cancel operation` delegates to the active parent copy, move, or extraction operation. Once `Continue` has submitted a decision and the owner is persisting it, disable the conflict controls to prevent duplicate decisions; do not add a separate cancellation request for that short submission state. +- Apply the shared fixed form-notice slot to same-directory, delete, and conflict errors or warnings that do not belong to a specific input. + +### Phase 5: Cleanup + +- Migrate or retire remaining display-only `InlineItemName` operation-context call sites. +- Remove obsolete `Trans` markup keys only after all locale resources have been migrated. +- Complete the `pathDisplay.ts` consolidation: migrate `BreadcrumbsNavigation` and every new dialog call site to its exported utilities, then remove their local shortening implementations. +- Do not remove `DialogReadOnlyField`; retain it for editable/read-only form controls where a native input is the appropriate interaction, rather than presentation-only context. + +## Documentation Changes + +The version-specific style-guide update is planned for `website/content/docs/1.0/developer-guide/frontend-architecture/settings-form-dialog-pattern/index.md` after the reference implementation is accepted. Keep the published guide aligned with implemented components and behavior. + +Add an "Operation Context" section that requires: + +- Outcome-only descriptions for operation dialogs. +- A vertical labelled context for dynamic names and paths. +- One visual line per identifier, semantic type-aware abbreviation, full-value accessibility, and bidi isolation. +- `DialogOperationContext` for shared display structure, with operation-specific translations and state kept in the owning dialog. +- No source/target interpolation in translated prose. + +Revise the existing guidance that says to name a single source item in the description so it instead directs authors to the labelled context. Retain the existing guidance for `ResponsiveFormDialog`, form surfaces, focus, validation, safe defaults, and mobile drawers. + +Follow the repository documentation update workflow when editing the website document, including its documentation style guide and derived-artifact refresh requirements. + +## Accessibility Requirements + +- Continue using `ResponsiveFormDialog` for title labelling, focus trapping, Escape behavior, and focus restoration. +- Keep `aria-describedby` associated with the short outcome description, not a potentially long structured source/target listing. Users of assistive technology can then navigate the semantic context without an excessive announcement when the dialog opens. +- Use semantic `dl`, `dt`, and `dd` structure for labelled operation metadata. +- Give every shortened identifier an accessible name containing its complete value. The visual shortened text remains visible rather than being hidden from assistive technology. +- Show the full value in a hover-only tooltip only when it has been shortened. Ensure the tooltip does not become the sole accessible representation. +- Preserve text selection in read-only values where it already exists. Do not make display-only code values keyboard-focusable solely for selection. +- Do not use color alone to communicate source versus target; labels and group headings must establish meaning. +- Verify 200% browser zoom and a 320 CSS-pixel viewport without horizontal scrolling caused by a context value. + +## Test Plan + +### Utility tests + +Add unit tests for filename and path shortening: + +- Short inputs are unchanged. +- A long filename retains an ellipsis and final extension. +- Dotfiles are not treated as extension-bearing filenames. +- A Unicode grapheme cluster is never split. +- Long paths preserve the provider/root and basename while collapsing ancestors. +- Unix, Windows-style, and provider-prefixed paths are covered. +- Widths too small for the root or basename return a stable safe fallback. +- Existing `abbreviatePath` expectations remain valid or are deliberately updated with documented improved results. + +### Component tests + +For `DialogIdentifierDisplay` and `DialogOperationContext`: + +- Labels and values render as `dt` followed by a full-width `dd`, never a side-by-side grid. +- The full value is exposed through the accessible name when shortened. +- Tooltip content is present on hover only for shortened output; focus and touch do not open it. +- The original value is used in the DOM input/props and no abbreviated value leaks into callbacks. +- `bdi` with `dir="auto"` is rendered around the identifier value. +- Resize behavior reruns shortening when a test-controlled `ResizeObserver` width changes. +- Field feedback and form notices reserve their one-line height while idle, visible, and pending; long messages do not wrap or shift adjacent controls or dialog actions. +- A shortened feedback message retains its complete accessible text and tooltip; a field-owned error is not duplicated in the form notice. +- The conflict form-notice slot keeps its pre-metadata height while idle, reveals an owner error without moving later content, and receives focus when shown. +- Archive extraction progress uses two real-data states: preparation before a member is processed, then a `members_processed` count with an indeterminate bar. Its frontend mapper preserves the existing aggregate counter and never derives a false total from selected top-level sources. +- Archive progress never renders byte figures, including when a backend provides them. + +### Dialog regression tests + +Extend tests for every migrated dialog: + +- Short values retain the existing operational behavior and translated outcome text. +- Very long source names and destination paths render in separate rows. +- Single-item copy/move retains its editable destination filename and submit behavior. +- Multi-item copy/move uses count pluralization and does not render every name in its description. +- Archive creation uses the selected-item count and the singular/plural outcome description without rendering selected names in prose. +- Archive creation shows an indeterminate localized `Creating archive...` state without a count or percentage. Archive extraction shows localized preparation, then neutral `Processed {{processed}} archive items...` wording based on the existing `members_processed` count, always with an indeterminate bar. +- Archive extraction remains count-only when transport data includes processed or total bytes. +- Archive extraction renders `Destination name` only for the single-pane sibling-directory mode, initializes it from the archive basename, and omits it for every fixed-target scope. +- Rename, create, delete, archive creation and extraction, conflict resolution, and archive-member failure show the expected labels and full accessibility values. +- Existing focus, validation, loading, cancel, error, and terminal-state tests remain green. +- Validation errors, warnings, and non-field API notices use the shared fixed feedback slots and do not create a second line or shift the dialog layout at narrow and desktop widths. +- Pending copy, move, archive creation, and archive extraction retain an enabled cancellation route. Assert that invoking it reaches the existing abort or job-cancellation controller and prevents duplicate requests. Assert that conflict `Cancel` delegates to the active parent operation. Clean cancellation closes without a terminal dialog; actionable cancellation errors, partial output, and unknown outcomes retain their context and notice. Assert that rename, create-item, and delete pending states preserve their existing disabled controls. + +### Localization and browser checks + +- Add translation fixtures for an RTL locale and a locale whose sentence order differs from English. Assert that dynamic values remain in field rows and are not coupled to prose order. +- Use mixed Latin/RTL filenames and paths containing separators and digits. +- Add Playwright coverage at 320 px, 768 px, and desktop widths for the long-name copy/move scenario. +- Check light and dark themes, 200% zoom, hover tooltip behavior, and a short mobile viewport where drawer actions remain reachable. +- Manually verify the approved frontend browser connection at `http://localhost:3000/browse/smb/demo` after implementation. + +## Delivery Order + +1. ✅ Extend `pathDisplay.ts` with pure, grapheme-safe filename and path abbreviation utilities; migrate the breadcrumb helper and add unit tests. +2. ✅ Build and test `DialogIdentifierDisplay`, including measurement, tooltip, full-value accessibility, and bidi isolation. +3. ✅ Build and test `DialogOperationContext` with vertical semantic layout. +4. ✅ Migrate and browser-test `CopyMoveDialog` as the reference implementation. +5. ✅ Migrate and test `ArchiveOperationProgress` using only existing frontend data: `operation="create"` for indeterminate creation status, and the already-returned extraction `members_processed` aggregate for indeterminate count-only extraction status. +6. ✅ Migrate delete and overwrite conflict workflows, including their bounded multi-item preview and existing metadata grids. +7. ✅ Update the dialog style guide to document the accepted, implemented reference pattern. +8. ✅ Remove obsolete inline-prompt markup and duplicated path display components after every caller is migrated. +9. ✅ Run focused frontend tests during each migration, then the full frontend test suite, TypeScript check, lint, and responsive browser checks. + +## Risks and Mitigations + +| Risk | Mitigation | +| --- | --- | +| Measurement differs after custom fonts load or the dialog resizes. | Recalculate with `ResizeObserver` and after `document.fonts.ready`; retain CSS overflow as a final safeguard. | +| Generated display text splits emoji or combining characters. | Segment by grapheme cluster, never UTF-16 index. | +| Bidi values reorder punctuation or surrounding translated content. | Isolate each value with ``; do not concatenate it with prose. | +| Screen readers announce a large amount of path data on opening. | Limit `aria-describedby` to the short outcome description and retain structured context in normal navigation order. | +| A validation or API message shifts controls and actions, or becomes a second line. | Use the shared fixed one-line field-feedback and form-notice slots; truncate only visually while retaining the full accessible message and tooltip. | +| An archive progress bar implies a total that is not known, especially for selected directories. | Keep both archive modes indeterminate. For extraction, use preparation before any member is processed, then the existing `members_processed` counter without an inferred total. | +| Some archive backends provide byte totals while others do not. | Keep archive progress count-only across all backends; retain byte fields outside the dialog UI. | +| An archive-member selection implies a new output name when the target is fixed. | Drive `Destination name` solely from the single-pane sibling-directory mode; initialize it from the archive basename and omit it in dual-pane mode. | +| A migration regresses editable-field focus or submission. | Keep context display-only and run the existing dialog test suite before migrating another dialog. | +| The shared component becomes an operation-specific abstraction. | Limit its API to labels, values, and identifier type. Keep business state and translations in owners. | + +## Acceptance Criteria + +The work is complete when all of the following are true: + +- Every migrated operation dialog presents each filename and path as a labelled, full-width, one-line identifier display. +- No filename or path shares a prose line with action text or another identifier. +- Long filenames preserve a recognizable prefix and final extension; long paths preserve root/provider and basename before shortening the basename. +- The complete raw value is available to assistive technology and through hover without changing the underlying operation value. +- Outcome descriptions can be translated without embedding dynamic identifiers in sentence markup. +- Mixed LTR/RTL identifier content is isolated from surrounding localized UI text. +- Existing dialog keyboard, focus, validation, pending, cancel, and responsive behaviors remain correct. +- Pending copy, move, archive creation, and archive extraction can be cancelled through an enabled dialog action connected to their existing request or job handle; other pending dialog types retain their current behavior until their APIs support cancellation. +- Archive creation remains indeterminate without invented counts. Archive extraction uses preparation before any member is processed, then shows neutral `Processed {{processed}} archive items...` wording based on the existing `members_processed` count with an indeterminate bar; neither operation infers a total or displays a determinate percentage. +- Archive extraction never displays processed or total byte figures. +- Archive extraction shows `Destination name` only for single-pane sibling-directory extraction, never because the user selected multiple archive members. +- Successful operations and clean cancellations close without a terminal dialog; terminal dialogs appear only for actionable errors, warnings, partial output, or unknown outcomes. +- The frontend test suite, type check, lint, and required browser checks pass. diff --git a/documentation_planning/FORM_AND_DIALOG_FEEDBACK_POLICY_PLAN.md b/documentation_planning/FORM_AND_DIALOG_FEEDBACK_POLICY_PLAN.md new file mode 100644 index 00000000..2c64b648 --- /dev/null +++ b/documentation_planning/FORM_AND_DIALOG_FEEDBACK_POLICY_PLAN.md @@ -0,0 +1,527 @@ +# Form And Dialog Feedback Policy Plan + +## Status + +Draft proposal. This is a temporary implementation plan and must be deleted after the work is complete. The versioned developer guide is the durable source of truth for the accepted policy. + +## Purpose + +Define one accessible, predictable feedback, persistence, and visual-consistency policy for all Sambee forms, dialogs, and settings surfaces. It covers validation errors, field warnings, immediate setting persistence, feedback caused by submitting an action, responsive field layout, and shared overlay/form surfaces. + +The policy accepts that a newly displayed message occupies space and can move content downward. It prioritizes understandable feedback, normal Material UI behavior, and proximity to the user's task over preserving every pixel of layout geometry. + +## Decision Summary + +1. Do not reserve blank space for potential helper text or alerts. +2. Use the existing responsive field-layout system for field-owned messages. +3. Show field messages only while active, in normal document flow, with no forced one-line clipping. +4. Render dialog-level messages only while active. Never mount an empty or visually hidden `Alert`. +5. Place a dialog-level message near the top only when it explains an already-known condition of the dialog's initial context. Place a result of a user action near the bottom, immediately above the actions that initiated it. +6. Do not duplicate a field-owned error in a form-level message. +7. Use Material UI defaults unless a small explicit exception is needed for warning severity, accessibility role, or the existing two-column desktop form layout. +8. Use one shared overlay and form-surface token contract for responsive dialogs, the desktop settings dialog, and the mobile settings Drawer. Keep their distinct navigation and interaction models. +9. Ordinary settings persist a valid committed value immediately. Save buttons are reserved for neither routine settings nor hidden API batching; buttons remain only for explicit commands and workflows. +10. Every independently editable server or user setting has its own persisted key and single-target update contract. Do not retain JSON policy blobs for File Search or SMB settings. The backend atomically validates and persists every correlated settings aggregate, so independent client requests cannot commit an invalid combined state. +11. Every ordinary settings `PUT` returns a typed `{ field, value }` result for precisely the committed field; complete settings state remains a `GET` concern. +12. One central field-scoped current-user settings store owns all current-user reads, writes, synchronization, cross-tab invalidation, and subscriptions. It is bound to authenticated identity changes, rejects stale completions, and permits only one in-flight write per field while distinct fields remain concurrent. Components hold their own uncommitted drafts and never call its endpoint, broadcast synchronization, or persist server-backed current-user settings to local storage directly. +13. SMB policy fields persist immediately and use the existing post-commit runtime refresh lifecycle: refresh the in-process settings cache, retire pooled SMB contexts, stop directory caches, and restart active monitors. New connections and restarted directory services use the current policy; active pooled operations finish before their contexts retire. +14. Backend Pydantic models and concise handwritten frontend discriminated unions define the settings contract. Endpoint and client tests keep the matching `{ field, value }` request/result pairs aligned. + +## Problem + +The existing operation-dialog implementation reserves hidden helper-text and alert space to avoid reflow. In `CopyMoveDialog`, a hidden alert is located before the operation context. Together with normal description and stack margins, this creates a conspicuous empty region between the description and `Source item` in the normal no-message state. + +This approach has three problems: + +- It makes the common, successful state look sparse and disconnected. +- It changes the semantics of a live alert into a permanently mounted but hidden element. +- It constrains important error text to a one-line ellipsis and hover-only full value. + +The existing settings and dialog surfaces also share form controls but do not consistently install or consume the same surface tokens across desktop dialogs, the desktop settings container, and the mobile settings Drawer. This makes the intended background hierarchy depend on the host rather than the user task. + +Material UI documents helper text as part of a field's normal flow and notes that it changes a text field's height. Its recommended alignment workaround (an empty helper-text value) is appropriate only when adjacent fields must remain aligned. Material UI also states that alerts should not take keyboard focus, and that dynamically rendered alerts are announced while alerts already present on initial load are not. + +The design decision is therefore to accept bounded downward reflow when feedback appears. The invariant is that feedback remains close to the control or action it concerns, not that unrelated content never moves. + +## Scope + +### Included + +- All editable forms and dialogs, including settings forms and file-operation dialogs. +- Inline validation, field-level warnings, submit/API errors, and operation-result warnings. +- Both responsive settings layouts: one-column and desktop two-column. +- Desktop dialogs and mobile Drawer dialogs rendered by the existing responsive dialog shell. +- Desktop settings navigation dialog, mobile settings Drawer, `SettingsPage` frame and action bar, every settings category, and the Browser Viewer Picker and Theme Selector persistence workflows. +- Settings API models, endpoints, service methods, a central field-scoped current-user settings store, per-field persistence migration, and tests needed to support independent field-granular writes. +- Shared feedback primitives, their call sites, tests, and the versioned dialog style guide. + +### Direct Implementation Scope + +The policy applies to every included form and dialog whenever it is created or changed. This plan's direct implementation work is the shared foundations and named surfaces in **Direct Feedback And Persistence Migrations**; each has an identified feedback, persistence, or visual-host change. The policy does not require an additional repository-wide discovery or refactor before the named work is complete. + +### Excluded + +- A new global notification/toast system. +- Changing validation rules, business workflows, or cancellation behavior beyond removing routine settings and Theme Selector draft/Save behavior and relocating the Authentication activation command. +- Turning informational help copy into warnings or errors. +- Arbitrarily truncating actionable feedback to preserve dialog dimensions. +- Replacing the settings navigation dialog or mobile settings Drawer with `ResponsiveDialogShell`; their navigation, resizing, and category-selection behavior remain settings-specific. + +## Feedback Taxonomy + +Every message must have one owner and one placement. Authors must classify it before choosing a component. + +| Category | Owner | Examples | Blocking | Placement | +| --- | --- | --- | --- | --- | +| Field error | One editable control | Invalid filename; required value; malformed port | Yes | Associated with that control | +| Field warning | One editable control | A valid but discouraged value | Usually no | Associated with that control | +| Field persistence status | One ordinary settings control | Saving; Saved | No | Small temporary status adjacent to that control | +| Contextual form notice | Dialog/form state known before submit | Batch destination is the current directory; an operation has partial state to explain before action | Depends on message | Top notice region | +| Action-result form notice | A submitted action with no field-owned remedy | API failure; completed copy with source retained; unavailable server-side condition | Depends on message | Bottom notice region | + +Do not use a form-level notice for a condition that a user can remedy in one field. Do not show the same condition in both places. When a server rejects a value and identifies its field, map it to that field; otherwise treat it as an action-result notice. + +Classify non-field failures by lifecycle as well as ownership. A load, retry, or external-state failure exists before the user submits the current form, so it is a contextual notice in the top region. A save, reset, delete, or operation outcome caused by an action is an action-result notice in the bottom region. Preserve an error's existing Retry control with its top contextual notice when retrying is the available remedy. + +Routine settings writes are field-owned persistence, not form submission. A failed ordinary setting write is a field error, and routine success does not use an alert, snackbar, or page-level notice. Action-result notices remain for dialogs and explicit commands such as Authentication activation. + +## Field Feedback Policy + +### All Single-Column Forms + +This includes one- and two-field operation dialogs at every viewport width, and all forms below `md`. + +- Use normal Material UI `TextField` or composed `FormControl` helper text directly below the associated control. +- Render helper text only when help, a warning, or an error is active. Do not supply an idle nonbreaking space or set a fixed helper-text height. +- For an error, use the standard `error` prop and `helperText`. Preserve Material UI's normal `aria-describedby` relationship. +- For a warning, use helper text with a visible `Warning:` prefix and warning color. This small exception is necessary because `TextField` models errors directly but has no built-in warning-feedback state. +- Allow feedback to wrap at narrow widths. It must remain readable without hover, touch, or a tooltip. +- When validation is performed after submit, move focus to the first invalid control. Do not also move focus to a form-level alert. +- For immediate-persistence settings, retain an invalid or failed value locally and show its active field error. Clear that error when the user next edits the control or a later write succeeds; do not move focus for an asynchronous persistence failure. + +### Multi-Field Forms At `md` And Above + +Continue using the established two-column settings layout. + +- Keep the external field label and normal description in the left column. +- When a field message is active, replace that field's normal description with the error or warning in the left column. +- Continue to set `error` on an invalid Material UI control so its native error affordance remains visible. +- Associate the control with the replacement message through the existing label/description ID relationship. +- Do not add duplicate helper text below the control at this breakpoint. +- Let the row grow to fit the active message. A longer label-column message moving later rows downward is expected and correct. + +The field-layout helpers may need a severity-aware message input rather than their current error-only presentation. Keep label, description, and validation ownership in the concrete form; the shared helper only owns responsive placement and presentation. + +### Field Message Content + +- State the problem and the correction, for example: `Choose a different name; this name already exists.` +- Use warning only for a value that remains valid. An error blocks submission. +- Do not rely on color, an icon, or a tooltip as the sole indication of severity or content. +- Do not use a live region for feedback that changes on every keystroke. Validate dialog fields after submit unless early validation has a demonstrated user benefit. Settings text and numeric fields validate locally before their committed write. + +### Immediate Setting Persistence + +Every ordinary settings control follows one model: a valid committed value persists immediately. Users never need to infer whether a given settings page requires a separate Save action. + +- Checkboxes, switches, radios, and selects write when their value changes. +- Text and numeric fields write on blur or Enter after local validation succeeds. Do not send a request for incomplete or invalid in-progress text. +- Requests remain independent across controls. Do not queue, serialize, debounce into batches, or coordinate writes between controls; the API applies each field-granular update as it arrives. +- A control permits at most one in-flight write. Disable its control while it is pending and do not queue a later value for it. When the same setting field is rendered by multiple controls, disable every representation of that field while its shared request is pending; unrelated fields remain available and may persist independently. +- Guard every commit handler with a field-local in-flight ref as well as the rendered `disabled` state, so blur and Enter cannot submit the same control twice before React applies its state update. +- Do not replace a field from a whole-settings or stale response. On success of that control's current in-flight request, apply only the server-returned canonical value for the field it wrote, then update its persistence state. +- Remove routine settings drafts, dirty-state comparisons, Save buttons, and successful-save alerts or snackbars. + +Use existing Material UI primitives for the compact field status rather than a new notification system: + +- While a write is pending, render a small `CircularProgress` adjacent to the field: inline with the desktop `FormFieldLabel` description, or as an `InputAdornment`/label-adjacent element in a single-column control. +- After a successful write, replace it briefly with `CheckCircleOutline`, then remove it. Define one named shared success-display duration constant rather than scattering timer values. The check is supplementary confirmation, not a persistent message or a layout reservation. +- Give an active saving status a localized accessible name with `role="status"`. Do not use `role="alert"` for routine saving or successful-save state. +- Render neither spinner nor check while idle. Do not add an empty adornment, helper line, or fixed-size status container. +- A write failure replaces the temporary status with the field error described above. It remains readable until another edit or successful write resolves it. +- Clear a field's pending and success-display timers when that field is edited, fails, unmounts, or begins another write. A completion callback must affect status only when it belongs to the current field-local write. + +The frontend and API use one explicit single-target payload shape rather than reusing a whole-settings object for a one-field change. Each ordinary `PUT` request and result contains exactly one matching `{ field, value }` pair: + +```ts +type SingleSettingUpdate> = { + [Key in Field]: { field: Key; value: Values[Key] }; +}[Field]; + +type SystemSettingsUpdate = SingleSettingUpdate; +type CurrentUserSettingsUpdate = SingleSettingUpdate; +type SettingUpdateResult> = SingleSettingUpdate; +``` + +Every endpoint declares a Pydantic discriminated union for its permitted field/value pairs, with `extra="forbid"`; the frontend maintains the matching concise handwritten TypeScript union beside the API client. Reject empty, multi-target, unknown, mismatched, and legacy reset payloads at request validation. Do not accept endpoint-specific property-shaped alternatives or derive `field` from an arbitrary submitted property. The response echoes the canonical committed pair. A client applies a response only when its field matches the control's pending request. `GET` routes remain complete page-state reads. Settings APIs expose no reset command: remove legacy reset fields, handlers, buttons, source-gated reset affordances, and reset-specific translations. + +Custom themes and viewer associations remain intentional compound values: each entire collection/map is one non-independent user-setting control, has one `UserSettingKey`, and is submitted and returned as one target. Do not split viewer associations into independently persisted entries or merge an update response into a stale cached map. Validate and canonicalize the complete compound value server-side, persist its one row, and replace only that compound value from the matching canonical response. + +An initiating workflow outside a settings-page field still owns the same field-local lifecycle. The Browser Viewer Picker owns the `viewer_associations` compound control while `Always use` is selected; it must keep the dialog open while that one write is pending, disable the viewer list and `Always use` checkbox together as that compound control, render saving state on its confirming action, and render a failed write immediately above its actions. Cancellation remains available. It may open the viewer only after persistence succeeds. If product behavior permits opening without persistence after a failure, provide that as a distinct explicit action that leaves the failed association unsaved; do not silently proceed after a failed remembered-selection write. + +Theme selection and custom-theme collection edits are distinct current-user controls. A theme selection writes only `theme_id`; a custom-theme add, edit, or removal writes only the complete `custom_themes` compound value. Define one version-controlled, packaged shared built-in-theme ID manifest consumed by the backend validator. The frontend retains its full theme-definition registry and a parity test compares its IDs with the manifest. A `theme_id` is valid only when it appears in that manifest or in the effective custom-theme collection. Custom-theme IDs must be unique and cannot collide with a built-in ID. Do not delete the active custom theme as a two-field transition: require a successful canonical response for a different theme before enabling removal. + +Use one short SQLite immediate write transaction for each appearance mutation: acquire the write transaction before reading `theme_id` and `custom_themes`, apply the requested target to the effective aggregate, validate it, write only that target row, then commit. Roll back on every exception. This is required for every `theme_id` or `custom_themes` write, including stale tabs and direct API calls. Do not add a settings lock manager, lock table, keyed mutex, or client-side coordination. + +Do not persist server-backed current-user settings to local storage. Theme preview is in-memory state owned by its initiating control only; on a failed write or discarded local candidate, it reverts to the store's last confirmed canonical field value. The initial rendered theme may use the application default while the store loads the authenticated settings, then updates from the complete authoritative `GET` result. + +The File Search and SMB policy JSON blobs must be retired as part of this change. Add per-field `SystemSettingKey` values for File Search retention limit, result limit, excluded categories, and excluded extensions; and for SMB authentication mode, encryption mode, and connection timeout. Retain the existing independent SMB read-chunk-size key and the existing independent Network keys. Scalar integers and enum values use their canonical string representations in `SystemSetting.value`. Collection values use a shared JSON-array codec, with a sorted canonical encoding, strict array-of-string decoding, and the existing File Search normalization before persistence. This per-field collection encoding is not a policy blob: one row encodes only its own collection value. + +Add one versioned backend database migration that runs after the `systemsetting` table exists. In one database transaction, it must parse each legacy `file_search.policy` and `smb.policy` JSON row with the existing Pydantic models, write its values into missing per-field keys without overwriting a pre-existing per-field value, and delete the legacy JSON row only after all per-field writes succeed. Each materialized row inherits the legacy row's `updated_at` and `updated_by_user_id`; an existing per-field row retains its own value and metadata. Invalid legacy JSON must fail migration with an actionable error and roll back every row created by that migration, leaving both legacy rows untouched. New read, runtime-policy, and update paths must use only per-field keys after the migration; do not retain lazy read fallback or dual-write compatibility behavior. + +Invalid request shape is rejected by strict request validation; a well-shaped request with an invalid value or effective configuration receives an actionable field error. For File Search, use one short SQLite immediate write transaction: acquire the write transaction before reading its effective settings, apply the candidate, validate the complete `FileSearchSettings`, trim history when required, write only the target row, then commit. Roll back on every exception. This prevents invalid combined configuration without a database lock subsystem or client serialization. Every endpoint returns the matching canonical field result rather than a whole settings object. Remove source metadata used only to expose reset behavior, including SMB policy source. + +Replace `userSettingsSync` with one `userSettingsStore` module. It is the only frontend module permitted to call `GET /auth/me/settings` or `PUT /auth/me/settings`; do not expose a general `patchCurrentUserSettings` helper to components. The store owns the complete canonical snapshot, field selectors, and a `useCurrentUserSetting(field)` hook that exposes `confirmedValue`, shared `pending` and `error` state, `commit(value)`, and `clearError()`. Components retain uncommitted text, selected candidates, and transient previews locally; they clear a field error when the candidate changes. + +Extend `authSession` with one identity-change subscription that supplies the user ID and a monotonically increasing epoch. Advance it when the authenticated identity changes or clears, including a later login as the same user; do not advance it for a same-user token refresh. The store clears its snapshot, pending state, errors, and queued refresh when the epoch changes, then loads once for a new authenticated identity. Each `GET` and `PUT` captures that epoch and ignores a completion from an earlier session. + +The store keeps one snapshot version and one in-flight request token per field. Beginning a commit increments the snapshot version, marks that field pending, and sends its `{ field, value }` request immediately. A second same-field commit is rejected without sending or queuing another request; distinct fields remain concurrent. On success, replace only the canonical returned field, clear its error, notify subscribers, and publish one BroadcastChannel `invalidate` message. On failure, clear pending, retain the field error, and request a normal refresh; the control continues showing its local candidate and offers retry. The store never returns an ambiguous `null` success/failure result. + +Keep one coalesced complete-settings `GET`. It captures the epoch and snapshot version; apply its response only when both still match. Otherwise discard it and run one queued refresh after the active read settles. Focus, visibility, and BroadcastChannel invalidation use that same refresh function. BroadcastChannel carries only `invalidate`, never setting values. Remove `USER_SETTINGS_CHANGED_EVENT` and its complete-settings payload. This protects a newer local write from an older read while controls retain their own drafts. + +`ThemeContext`, `LocalePreferencesProvider`, Browser Viewer Picker, Theme Selector, and every File Browser preference hook consume only their owned field selector/hook. ThemeContext derives the active visual theme from store data and supports transient preview, but owns no persistence request, settings event listener, or local-storage write. The initiating UI control owns preview rollback, pending, and failure presentation. Enforce this ownership with targeted searches and tests: no component outside `userSettingsStore` imports `api.updateCurrentUserSettings`, calls the current-user settings endpoint, uses `patchCurrentUserSettings`, listens for `USER_SETTINGS_CHANGED_EVENT`, or writes a storage key for a server-backed current-user setting. + +Persist required side effects with a defined order. A File Search retention-limit decrease writes the one field and trims recent-file history inside its aggregate transaction before it commits. An SMB authentication-mode, encryption-mode, or timeout update commits its one persisted field, refreshes the in-process system-settings cache, retires all SMB connection contexts, stops directory caches, and restarts active directory monitors using the existing runtime-refresh implementation. Context retirement immediately removes idle contexts and marks active pooled contexts to retire after their final lease, while restarted directory services establish sessions with the current cache-backed policy. A read-chunk-size update does not trigger this policy-refresh lifecycle. The update returns the normal canonical `{ field, value }` result only after the existing lifecycle completes; retain the established operational error handling for runtime-refresh failure. This model relies on the supported single-backend-worker deployment; changing that deployment requires an explicit cross-worker cache-invalidation design before enabling additional workers. + +Explicit commands are not ordinary setting persistence. The settings action bar retains only page-level commands such as Add connection and Add user. Authentication mode activation remains an explicit command because it changes active sign-in behavior and can end the current session; locate it directly below the Authentication mode selector and its description, with its progress and action-result feedback adjacent to that control. + +## Form-Level Notice Policy + +### Top Contextual Notice Region + +Use the top region only for an active message that is already true when the form/dialog is presented, or changes because external context changes rather than because the user submitted the form. + +Place it after the outcome description and before the operation context or first control. Render nothing while no message is active. + +For a settings page, use this region only for load, retry, or external-state failure. Place it after the settings page header and before the first form control in the page's scrollable content. + +Examples: + +- A multi-item copy/move whose destination is the current directory. +- A known limitation or partial state that changes the meaning of a pending action. + +This region may move the context and controls down when it first becomes relevant. That is acceptable. Do not reserve its height while idle. + +### Bottom Action-Result Notice Region + +Use the bottom region for a message caused by pressing a dialog/form action where no individual field owns the correction. + +Place it after the form body and immediately before submit/cancel actions: + +- On desktop, render it at the end of `DialogContent`, directly before `DialogActions`. +- On a mobile Drawer, render it at the end of the scrollable content, directly before the sticky action area. +- On a non-dialog explicit-command form, render it at the end of the form body, immediately before its action row. + +Examples: + +- The server rejected the requested copy after validation passed. +- A move completed but the source could not be removed. +- An archive action failed without identifying an editable field as the remedy. + +The action area may move downward on desktop and the scrollable content may grow on mobile. Actions must remain reachable. Do not place result feedback before passive source/destination context simply to avoid this reflow. Routine setting writes use field-local persistence status and errors, not this region. + +### Stacking And Coexistence + +- Render one visible notice for one active condition. +- When independent conditions genuinely coexist, stack only the active notices with normal theme spacing. +- Do not reserve one hidden alert-height row for each possible condition. +- Do not combine unrelated messages into one broad alert solely to preserve space. +- A dialog may use both top and bottom regions only when the messages have distinct owners, such as an opening contextual warning and a later API failure. This should be uncommon. + +### Severity And Accessibility + +- Use `` for an urgent, action-blocking form-level error. Its Material UI default `role="alert"` announces dynamically inserted content. +- Use `severity="warning"` with `role="status"` for a non-urgent warning. Do not assertively interrupt a user for every advisory message. +- Do not focus an `Alert`. Keep focus on the submit action that produced an action-result notice, or move it to the first invalid field for field validation. +- Do not render an empty alert with `visibility: hidden`, `aria-hidden`, or placeholder text. A real alert exists only while there is a real message. +- Keep messages actionable and concise, but allow wrapping. Do not clip, ellipsize, or make full error content available only through a pointer-hover tooltip. + +## Target Component Architecture + +The current locations reflect historical ownership, not reusable responsibilities. The following is the desired ownership structure, but delivery of feedback and persistence behavior may use the existing modules. Move or rename a cohesive primitive only after its behavior migration is complete and its direct tests pass: + +```text + theme/ + palette.ts # shared overlay and form-surface token contract + components/ + Dialog/ + ResponsiveDialogShell.tsx # responsive desktop Dialog/mobile Drawer shell + DialogNotice.tsx # visible-only contextual and action-result notices + DialogReadOnlyField.tsx # labelled selectable value shown in a dialog + Form/ + FormLayout.tsx # reusable form surface, group, row, field label, and control styles + Settings/ + SettingsFormLayout.tsx # settings-only sections composed with SettingsGroup + SettingsDialog.tsx # settings product container + SettingsPage.tsx # settings content frame and contextual-notice host + SettingsActionBar.tsx # page-level command row only +``` + +The refactor is intentionally responsibility-based: + +- `ResponsiveFormDialog` becomes `ResponsiveDialogShell` and moves out of `Admin`, because it is an application-wide responsive overlay shell rather than an admin form component. +- `DialogFormNotice` and `DialogFormNoticeRegion` become `DialogNotice` and `DialogNoticeRegion` in `components/Dialog`. They are dialog-level message primitives and must not be owned by Settings. +- Reusable `SettingsFormSurface`, `SettingsFormGroup`, `SettingsFormRow`, `SettingsFormFieldLabel`, and generic control styles become `FormSurface`, `FormGroup`, `FormRow`, `FormFieldLabel`, and corresponding `form*` style exports in `components/Form/FormLayout.tsx`. +- `SettingsFormSection` remains settings-specific because it composes `SettingsGroup` and its settings section-heading conventions. It remains in a reduced `components/Settings/SettingsFormLayout.tsx` or moves to an equivalently named settings-only module. +- `DialogReadOnlyField` moves from `Admin` to `Dialog` without changing its interaction semantics. +- `DialogOperationContext` and `DialogIdentifierDisplay` remain in `FileBrowser`: they express file-operation identifiers, not generic form or dialog behavior. +- Generalize `getDialogSurfaceTokens`, `DIALOG_SURFACE_CSS_VARIABLE`, and `DIALOG_FORM_SURFACE_CSS_VARIABLE` into `getOverlaySurfaceTokens`, `OVERLAY_SURFACE_CSS_VARIABLE`, and `FORM_SURFACE_CSS_VARIABLE`. This describes their shared use by dialogs and settings overlays without assigning their ownership to either feature area. +- `ResponsiveDialogShell`, `SettingsDialog`, and `MobileSettingsDrawer` install the same overlay/form tokens on their Paper surfaces. `SettingsPage`, `SettingsActionBar`, and generic form controls consume them, with a theme-background fallback for an unhosted render. +- `SettingsDialog` and `MobileSettingsDrawer` remain settings-specific hosts. They must not be rebuilt on `ResponsiveDialogShell`, because desktop resizing/sidebar navigation and mobile category navigation are distinct product behavior. + +Do not introduce compatibility re-exports or duplicate implementations for a completed cleanup. A source-to-target import rewrite is a mechanical follow-up to each move, not a prerequisite for behavior changes and not a single all-or-nothing refactor. + +Complete this refactor alongside the direct behavior migrations that establish each primitive's reuse. It does not require unrelated forms, dialogs, or settings pages to migrate before a completed workflow group can be delivered. + +## Shared Component Design + +### Form Layout Helpers + +Retain the established responsive behavior through the current row and field-label primitives. Revise the field-label contract so a field can express an optional message with severity rather than treating only errors as special. A later cleanup may rename or relocate those primitives to the target Form ownership path. + +Define and use one shared `FieldFeedback` contract in `components/Form`: + +```ts +type FieldFeedback = + | { + message: ReactNode; + severity: "error" | "warning"; + } + | null; +``` + +`FormFieldLabel` receives this optional feedback together with the existing field description and description ID. The concrete form remains responsible for validation and chooses whether a valid warning blocks submission. The shared layout owns only the responsive presentation: error color and standard control error state remain separate concerns; warning feedback uses the existing warning color and a localized visible `Warning:` prefix. In both layouts, the control's `aria-describedby` must reference the active message ID, or the normal description ID when no message is active. + +The helpers must support: + +- no active message: normal description appears; +- error: normal description is replaced by an associated error at desktop widths and helper text at single-column widths; +- warning: normal description is replaced by an associated warning at desktop widths and helper text at single-column widths. + +Do not create a separate visual system for operation dialogs. One- and two-field operation dialogs continue to use standard MUI labels and helper text in a single column. + +### Dialog Notice Components + +Implement `DialogNotice` as a small shared visible-only `Alert` wrapper. It accepts message, severity, test ID, and an optional accessibility role. It returns `null` when no message is supplied. + +The component must not: + +- set `minHeight` for an idle state; +- set `visibility: hidden`; +- render placeholder content; +- force single-line overflow or text ellipsis; +- wrap content in a hover-only tooltip; +- set `tabIndex` or move focus. + +`DialogNoticeRegion` is a visible-only stack for callers that genuinely have concurrent notices. It returns `null` when every message is absent. + +### Responsive Dialog Shell + +Implement optional named `contextualNotice` and `actionNotice` slots in the existing responsive dialog shell, so desktop and mobile placements cannot drift. A later cleanup may rename it to `ResponsiveDialogShell`. + +The shell should render: + +```text +Title +Description +[contextual notice, only while active] +Operation context and form body +[action-result notice, only while active] +Actions +``` + +On mobile, the action-result notice must be in the scrollable content directly before the sticky actions, never underneath or behind them. + +Non-dialog forms should use the same top/bottom order in their own form body and action row. The policy is universal even where `ResponsiveDialogShell` is not the host component. + +### Settings Page Frame + +Implement `SettingsPage` as the non-dialog host for top contextual notices. It accepts an optional `contextualNotice` slot and renders: + +```text +Settings page header +[contextual notice, only while active] +Settings form or page body +[page-level command action bar, only when commands exist] +``` + +The contextual notice is inside existing scrollable content and precedes the body. The frame renders no notice element or placeholder while idle. It does not own an action-result slot for routine setting persistence: ordinary setting writes use field-local saving, saved, and error states. Explicit commands own feedback next to their command. + +### Shared Overlay And Form Surfaces + +Install the shared overlay and form-surface CSS variables on every overlay Paper host. Use the overlay value for the settings navigation/sidebar, settings page, and dialog paper; use the form value for `FormSurface` and outlined field label backgrounds. Preserve the current theme fallback when a component is rendered outside an overlay host. + +For `ResponsiveDialogShell`, compose the token-setting base Paper styles before the caller-provided `paperSx` rather than replacing it. Caller styles continue to own geometry and specialized behavior such as the resizable Keyboard Shortcuts dialog; they may override a surface value only through an explicit, intentional `paperSx` declaration. Apply the same composition rule in the desktop Dialog and mobile Drawer paths. + +Apply this token contract on both desktop and mobile before making page-level visual adjustments. This is a visual unification, not a change to spacing, keyboard behavior, settings navigation, resizing, or responsive breakpoints. + +### Settings Responsive Layout + +Use `FormRow` and `FormFieldLabel` at `md` and above only for genuine multi-field settings editors. Keep their two equal columns, external labels/descriptions, and normal row dividers. Below `md`, keep a single-column layout with full-width controls and active helper text below the associated control. + +Do not force the two-column form layout onto category navigation, searchable lists, viewer selection, or a compact one-control preference. Those surfaces retain their task-appropriate single-column/list presentation while inheriting the shared overlay and form colors. + +## Direct Feedback And Persistence Migrations + +The following surfaces have an identified feedback, persistence, or visual-host change. Surfaces without one are not implementation work for this plan; they retain their current task-specific behavior. + +### Shared Foundations And Hosts + +| Surface | Location | Processing required | +| --- | --- | --- | +| Responsive dialog and Drawer shell | `components/Admin/ResponsiveFormDialog.tsx` | Direct behavior migration: add named visible-only contextual and action-result notice slots with identical desktop/mobile order. Relocate and rename it to `components/Dialog/ResponsiveDialogShell.tsx` only as a separate mechanical cleanup. | +| Dialog notices, read-only field, and reusable form layout | `components/Settings/SettingsFormLayout.tsx`, `components/Admin/DialogReadOnlyField.tsx` | Direct behavior migration: remove idle reservations and add severity-aware field feedback in the existing helpers. Extract or relocate cohesive dialog/form primitives only after the behavior tests pass. | +| Overlay/form surface tokens | `theme/palette.ts`, `components/Settings/settingsSurface.ts` | Direct visual migration: apply one overlay/form token contract to every host while preserving theme fallbacks. Rename dialog-specific token APIs only as a separate mechanical cleanup. | +| Settings page and action frame | `components/Settings/SettingsPage.tsx`, `components/Settings/SettingsActionBar.tsx`, `components/Settings/SettingsSectionHeader.tsx` | Direct migration: add a visible-only contextual notice slot; render the action bar only for page-level commands; consume the shared surface hierarchy. | +| Settings update APIs and persistence | `shared/built_in_theme_ids.json`, `frontend/src/theme/themes.ts`, `frontend/src/services/authSession.ts`, `backend/app/core/system_setting_definitions.py`, `backend/app/db/migrations.py`, `backend/app/models/system_settings.py`, `backend/app/api/system_settings.py`, `backend/app/services/system_settings.py`, `backend/app/models/user_settings.py`, `backend/app/api/auth.py`, `backend/app/services/user_settings.py`, `frontend/src/services/api.ts`, `frontend/src/services/userSettingsStore.ts`, `frontend/src/types/` | Direct migration: add a packaged shared built-in-theme ID manifest for backend validation and registry parity testing; replace `file_search.policy` and `smb.policy` JSON blobs with independent per-field keys through one versioned transactional migration; use strict `{ field, value }` requests and canonical single-field responses for File Search, Network, SMB, System, and current-user settings. Replace `userSettingsSync` with the sole field-scoped frontend owner of current-user API access, identity-aware snapshot refresh, cross-tab invalidation, and subscriptions. | +| Settings dialog container | `components/Settings/SettingsDialog.tsx` | Direct visual migration: retain resize/sidebar behavior while installing shared overlay/form tokens and preserving the desktop two-column form breakpoint. | +| Mobile settings Drawer | `components/Mobile/MobileSettingsDrawer.tsx` | Direct visual migration: retain category navigation while installing shared overlay/form tokens and ensuring scrollable notices/actions remain readable and reachable. | + +### Direct Feedback And Visual Migrations + +These concrete surfaces currently own feedback, host a feedback-owning workflow, or need a policy-driven visual/layout update. This is the direct implementation checklist; it does not require discovery or migration of additional surfaces before these entries are complete. + +| Surface | Location | Current feedback | Required result | +| --- | --- | --- | --- | +| Copy / Move | `components/FileBrowser/CopyMoveDialog.tsx` | Hidden notice region before operation context; fixed filename feedback | Same-directory batch condition is a visible-only top contextual notice. API errors/warnings are visible-only action-result notices. Filename validation uses normal helper text. | +| Shared name-entry dialog | `components/FileBrowser/NameInputDialog.tsx` | Fixed helper feedback and hidden API notice | Render active field feedback normally and API feedback only above actions. | +| Rename workflow | `components/FileBrowser/RenameDialog.tsx` | Uses `NameInputDialog` with rename-specific validation | Adapt and test through the shared dialog; no duplicate rename error notice. | +| Create file/directory workflow | `components/FileBrowser/CreateItemDialog.tsx` | Uses `NameInputDialog` | Adapt and test through the shared dialog, retaining destination context and normal name feedback. | +| Archive extraction | `components/FileBrowser/ArchiveExtractDialog.tsx` | Fixed destination-name feedback and hidden API notice before content | Use normal destination-name helper feedback and a visible-only action-result notice above actions. While member resolution is active, it exclusively owns the member-error action-result notice because it also owns Retry/Ignore/Cancel actions. | +| Archive member error resolution | `components/FileBrowser/ArchiveMemberErrorResolver.tsx` | Active error/warning alert with Retry/Ignore actions | Remove its direct alert. Render only error-specific operation context and resolution controls; the parent supplies the one visible, fully readable action-result notice. | +| Overwrite conflict resolution | `components/FileBrowser/OverwriteConflictDialog.tsx` | Hidden owner-error notice before metadata, alert focus, fixed Rename feedback | Move owner feedback to visible-only action-result placement; remove alert focus; retain field-owned Rename feedback at its control. | +| Connection editor | `components/Admin/ConnectionDialog.tsx` | Multi-field validation, descriptions, and server errors | Apply the existing one-/two-column policy consistently and ensure no placeholder helper space is introduced. | +| Change password | `pages/AccountSettings.tsx` | Password-confirmation feedback | Remove idle placeholder helper text and use normal active field feedback. | +| OIDC configuration editor | `pages/AuthenticationSettings.tsx` | Multi-field provider and role-mapping validation | Apply the desktop label-column and mobile helper-text rules to every validation path. | +| OIDC mapping review dialog | `pages/AuthenticationSettings.tsx` | Mapping-selection and validation feedback | Apply the same responsive field and action-result notice policy. | +| User editor | `pages/UserManagementSettings.tsx` | Multi-field validation, including duplicate usernames | Apply the desktop label-column and mobile helper-text rules without duplicate notices. | +| Reset password | `pages/UserManagementSettings.tsx` | Password validation | Use visible-only field feedback; verify action-result errors have the correct owner and placement. | +| OIDC account-mapping editor | `pages/UserManagementSettings.tsx` | Expected-username/account-selection validation | Apply the existing responsive field rules and visible-only action-result notices where applicable. | +| Appearance settings | `pages/PreferencesSettings.tsx`, `theme/ThemeContext.tsx`, `services/userSettingsStore.ts` | Draft/preview state, page Save action, and fire-and-forget custom-theme persistence | Persist theme and locale choices immediately through the field-scoped store; remove page-wide draft/Save behavior while retaining an initiating control's local preview. The initiating theme/custom-theme controls own their one-target commit, pending/error/retry state, and preview rollback; ThemeContext owns no settings persistence or local storage. | +| Theme Selector | `components/ThemeSelector.tsx`, `theme/ThemeContext.tsx`, `services/userSettingsStore.ts` | Preview draft, Save action, and immediate dialog close after a fire-and-forget theme write | Persist the selected theme immediately through the one `theme_id` store hook, disable its selectable list while pending, and associate saving/saved/error state with that list. Do not close automatically for pending or failed writes; clearing the local candidate after a failure restores the last confirmed preview. | +| File Browser settings | `pages/FileBrowserSettings.tsx` | Draft preference state and page Save action | Persist each preference immediately; remove draft/saved comparison and the Save action; retain explicit clear-history dialogs. | +| File Browser runtime preferences | `pages/FileBrowser/preferences.ts`, `services/userSettingsStore.ts` | Fire-and-forget current-user writes, whole-settings event listeners, and optimistic local-storage updates outside the Settings page | Direct migration: route every current-user preference through its field-scoped store hook and selector. Keep the initiating control as the status/error owner, disable it while pending, and remove local-storage persistence for server-backed values. | +| Text Editor settings | `pages/TextEditorSettings.tsx` | Draft numeric input and page Save action | Persist a locally valid value on blur or Enter; remove saved-value comparison and the Save action. | +| File Search settings | `pages/FileSearchSettings.tsx` | Full-policy Save action and field validation | Persist each valid setting through a single-target API request and apply its canonical response only to that field; show local saving/saved/error state and remove the Save action. | +| Network settings | `pages/NetworkSettings.tsx` | Full-form Save action and field help | Persist each valid field through a single-target API request, apply canonical URL/CIDR output only to that field, show local saving/saved/error state, and remove the Save action. | +| SMB settings | `pages/SmbSettings.tsx` | Full-form Save action plus load/retry and field validation | Persist each valid policy or resource setting through a single-target API request; retain top load/retry contextual notice and field-local persistence feedback; remove the Save action. | +| System settings | `pages/AdvancedSettings.tsx` | Full-page Save action, legacy reset handler, and dynamic validation | Retain composed `FormControl` accessibility; persist each valid setting with a single-target partial request, apply only its canonical response, use field-local status/error feedback, remove the Save action and legacy reset UI/handler. | +| Browser Viewer Picker | `components/FileBrowser/BrowserViewerPicker.tsx`, `pages/FileBrowser/useFileBrowserPane.ts`, `services/userSettingsStore.ts` | Responsive dialog shell, read-only file field, single-column selection list, and a remembered viewer-association write that currently outlives the dialog | Use the shared dialog feedback behavior and overlay/form tokens. Retain its list-based single-column picker layout; do not add a two-column settings form. When `Always use` is selected, use the one compound `viewer_associations` store hook; keep the picker open, disable the viewer list and checkbox while pending, show pending state on Confirm, and show failure immediately above the actions. Open the viewer only after success, or after a separately explicit open-without-saving choice. | + +### Scope Guard + +Do not add feedback primitives, field layouts, or visual rewrites to confirmation dialogs, viewer dialogs, menus, navigation Drawers, searchable lists, or read-only settings pages without an identified feedback or persistence defect. List, picker, and read-only settings tasks retain their existing task-appropriate layout. + +### Workflow Owners Requiring Integration Verification + +These are not independent feedback presentations, but they construct or launch an in-scope dialog and must be checked so errors retain the correct owner and placement. + +| Owner | Location | Verification | +| --- | --- | --- | +| File browser orchestration | `pages/FileBrowser.tsx` | Archive-create and transfer workflow errors reach the shared dialog's correct contextual or action-result slot. | +| Connection settings launcher | Settings connection-management surface | Connection errors continue to reach `ConnectionDialog` without a duplicate page-level notice. | + +The policy deliberately excludes transient snackbars, menus, popovers, code-editor find/replace controls, and inline search/filter controls. They are not dialogs or submitted forms and retain their existing component-specific feedback behavior. + +## Documentation Revisions + +Before deleting this plan, revise the versioned developer guide: + +- `website/content/docs/1.0/developer-guide/frontend-architecture/settings-form-dialog-pattern/index.md` must describe visible-only field feedback and contextual/action-result form notices. + +The published guide must not claim that notices remain mounted while idle, that feedback is one line, that tooltips supply the only complete error text, or that routine settings require a Save action. Document the feedback taxonomy, immediate-persistence commit events, no-client-serialization rule, field-granular `{ field, value }` contract, saving/saved/failure accessibility behavior, one-column/two-column field rules, top/bottom form-notice placement, and accessibility requirements. It must define `userSettingsStore` as the sole frontend API boundary, one in-flight write per field, identity-change hydration, stale request rejection, invalidation-only cross-tab refresh, and the prohibition on server-backed local storage. Document the built-in-theme ID manifest, transaction-based aggregate validation, and SMB's existing complete post-commit runtime refresh lifecycle. Keep implementation mechanisms such as snapshot versions and refresh coalescing in code and tests, not the durable guide. Update `DIALOG_OPERATION_CONTEXT_UX_PLAN.md` only as needed to remove a contradictory completed-policy claim; it is not a second durable policy source. + +## Implementation Plan + +### Phase 0: Persistence And Store Prerequisites + +1. Add the File Search and SMB per-field `SystemSettingKey` values and one versioned migration. In one transaction, materialize valid legacy JSON rows into missing per-field rows, retain existing row values and metadata, preserve inherited legacy metadata for inserted rows, and delete a legacy row only after all replacement rows are durable. Invalid legacy JSON fails with an actionable error and rolls back every migration write. New reads and updates use only the new keys. +2. Change File Search, Network, SMB, System, and current-user endpoints and frontend clients to strict discriminated `{ field, value }` request/result unions. Remove whole-settings update responses, reset inputs, reset UI, source-gated reset state, and reset-only response metadata. Preserve explicit empty, false-like, and null values when valid for their documented field. +3. Add the built-in-theme ID manifest for backend validation and a frontend-registry parity test. Implement the File Search and per-user appearance aggregate mutations as short SQLite immediate write transactions: read the effective aggregate, apply one target, validate, perform the required row write and side effect, then commit or roll back. +4. Replace `userSettingsSync` with `userSettingsStore`. Add an auth identity epoch that changes on logout or identity transition but not same-user token refresh. Implement a canonical snapshot, one snapshot version, one coalesced refresh, and one pending request token per field. The hook exposes only `confirmedValue`, `pending`, `error`, `commit`, and `clearError`; components own local drafts and previews. Delete direct current-user API calls, complete-settings window events, and server-backed current-user local-storage writes outside the store. +5. Retain the existing post-commit SMB runtime refresh for policy fields: refresh `SystemSettingsStore`, retire pooled contexts, stop directory caches, and restart active monitors. Read-chunk updates do not perform that refresh. + +### Phase 1: Establish Visible-Only Behavior + +1. Add a small shared message type containing `message` and `severity` in the current shared layout module. +2. Remove fixed-height, placeholder, ellipsis, and tooltip behavior from the current field-feedback helper; use native MUI props where clearer. +3. Make the shared dialog notice return `null` when idle and use normal MUI `Alert` rendering while active. It may be extracted and renamed to `DialogNotice` after its behavior tests pass. +4. Add named top/bottom notice slots to the current responsive dialog shell with matching desktop and mobile placement. Rename or relocate the shell only as a later mechanical cleanup. +5. Add a named top contextual-notice slot to `SettingsPage`. Render it in scrollable content before the page body only while active; do not add a general bottom action-result slot for routine setting writes. + +### Phase 2: Adapt Field Layouts And Settings Visual Frame + +1. Extend the existing field-label helper to present error or warning text and compact field persistence status in the desktop label column while preserving the control's accessible description relationship. +2. Provide one small field-local persistence-state helper for system settings and `useCurrentUserSetting` for current-user settings. Each provides pending/error state, duplicate-commit exclusion, cleanup, and a named success-display timer. The store keeps same-field pending state shared; controls keep drafts and preview state. Neither mechanism queues, serializes, batches, or coordinates distinct field writes. +3. Update single-column controls to render only active normal MUI helper text plus the compact saving/saved status at the field label or input adornment. Disable every representation of a pending setting field while leaving unrelated controls enabled. +4. Keep existing settings forms on their established two-column/one-column breakpoint behavior. +5. Verify each affected field remains correctly labelled and described in both modes. +6. Apply the shared overlay/form tokens to the responsive dialog shell, `SettingsDialog`, and `MobileSettingsDrawer`, then ensure `SettingsPage`, `SettingsActionBar`, form surfaces, and outlined controls consume the same hierarchy. +7. Make `SettingsActionBar` command-only: remove routine Save actions and render no empty bar. Preserve list, picker, read-only, navigation, and resize behavior rather than applying form geometry indiscriminately. + +### Phase 3: Migrate Feedback And Overlay Consumers + +1. Migrate the file-operation workflows: `CopyMoveDialog` first, then `NameInputDialog` and its Rename/Create callers, `ArchiveExtractDialog`, `ArchiveMemberErrorResolver`, and `OverwriteConflictDialog`. Remove each idle reservation and use the shared named slots where the policy assigns ownership to the dialog. For archive member resolution, derive one `actionNotice` in `ArchiveExtractDialog` from the active member-resolution state, clear it when that state clears, and remove the resolver's direct alert so the message appears exactly once above Retry/Ignore/Cancel. +2. Migrate `BrowserViewerPicker` to the shared dialog feedback behavior and verify its desktop/mobile surface hierarchy without changing its single-column list layout. Replace its direct preference write with the `viewer_associations` store hook. When its remembered-selection compound control persists, keep the dialog open, disable the viewer list and `Always use` checkbox as that one compound control, show pending state on the confirming action, and place an actionable failure immediately above the actions; do not open the viewer until persistence succeeds or the user chooses a separately explicit open-without-saving action. Keep cancellation available. +3. Migrate the connection editor in `ConnectionDialog`, preserving its established responsive field layout and mapping server feedback to its owning field or action-result notice. +4. Migrate the Account settings Change Password form, including its confirmation-field feedback and submit-result feedback. +5. Migrate the Authentication settings OIDC configuration editor and mapping-review dialog, including every validation and action-result path. Move non-OIDC Activation below the Authentication mode selector and description; retain it as an explicit local command with adjacent progress and action-result feedback. +6. Migrate the User Management user editor, Reset Password workflow, and OIDC account-mapping editor, including every validation and action-result path. Retain only Add user in the settings action bar. +7. Migrate Appearance, Theme Selector, File Browser settings and runtime preferences, and Text Editor settings to immediate persistence through `userSettingsStore`. Remove page-wide drafts, dirty-state comparison, Save actions, save-success messages, direct current-user API calls, current-user settings events, and server-backed current-user local-storage persistence while preserving existing explicit commands. Each initiating control receives its own success/failure result from its store hook; compound custom-theme and viewer-association editors persist their complete compound value as their one control. On failed Theme Selector persistence, clearing the control's local candidate restores its last confirmed preview. Make ThemeContext a store consumer and visual-theme provider only. Migrate `LocalePreferencesProvider` and every File Browser preference hook to field selectors/hooks. Require a successfully persisted replacement theme before allowing removal of the active custom theme, while the backend appearance transaction rejects a stale or direct removal attempt that would orphan the active custom theme. +8. Migrate File Search, Network, SMB, and System settings to immediate field-granular persistence. Keep local validation and composed-control behavior; send independent valid single-target writes; apply a matching request's canonical field response without replacing unrelated local fields; show field-local saving/saved/error states; retain top contextual load/retry notices; remove routine Save actions and all legacy reset UI, handlers, and translations. SMB policy fields use the same ordinary field-persistence states as every other setting while their existing post-commit runtime refresh completes. +9. Run focused tests after each workflow group. A group is independently deliverable when its named behavior and tests are complete. Remove its now-unused fixed-slot exports and call sites after that group, without waiting for unrelated groups. + +### Phase 4: Documentation And Validation + +1. Update the versioned developer guide with the accepted policy and remove only contradictory completed-policy claims from the prior operation-context plan. +2. Refresh website derived documentation artifacts through the required docs workflow. +3. Run focused backend and frontend tests after each dialog, settings-page, API-model, and overlay-host migration. +4. Run the full backend and frontend test suites, TypeScript check, lint, and responsive browser checks after the final migration. +5. Delete `FORM_AND_DIALOG_FEEDBACK_POLICY_PLAN.md` after the developer guide, implementation, and validation are complete. + +## Test Plan + +### Shared Feedback And Hosts + +- The responsive dialog shell preserves its current responsive, focus, Escape, and action behavior while rendering no idle field-feedback or notice space. +- Active field errors and warnings are associated with their control; desktop multi-field forms replace the left-column description and mobile forms render helper text below the control. +- Active notices wrap, have the required alert/status role, and never receive programmatic focus. Idle notices render no `Alert` element. +- `SettingsPage` renders a load/retry notice before its body only while active. Overlay/form tokens apply to the responsive shell, desktop settings, and mobile settings without changing resize, navigation, or specialized caller Paper styles. + +### Persistence And API + +- The migration splits valid legacy File Search and SMB blobs into per-field rows, preserves existing-row precedence and inserted-row metadata, deletes a legacy row only after durable replacement rows, and rolls back fully on invalid JSON. +- Each settings endpoint accepts exactly one documented `{ field, value }` pair and returns its canonical pair. Tests reject empty, multi-target, unknown, mismatched, retired policy, and reset payloads while accepting documented empty, false-like, or null values. +- A field update preserves unrelated persisted settings and reflects canonical URL, CIDR, and extension normalization only in the matching control. +- File Search and appearance mutations use an immediate SQLite write transaction. Tests cover valid aggregate updates, rejected invalid aggregates, retention trimming in the same transaction, and request orderings that must not leave an invalid effective configuration. +- Theme validation accepts built-in manifest IDs and effective custom themes, rejects duplicate custom IDs and built-in collisions, and prevents removing the active custom theme. The manifest and frontend theme registry have matching IDs. +- Custom themes and viewer associations remain complete compound field values; no stale client-side map merge occurs. +- SMB policy changes retain the existing cache refresh, context retirement, cache stop, monitor restart, and normal canonical response. Read-chunk updates do not trigger that lifecycle. + +### Current-User Store And Controls + +- Targeted searches prove that `userSettingsStore` is the only frontend caller of current-user settings APIs and that `userSettingsSync`, its window event, direct patch helper, and server-backed local-storage writes have no remaining consumer. +- The store loads once for a new authenticated identity, retains its snapshot across a same-user token refresh, clears on logout or identity change, and ignores `GET` or `PUT` completions from an earlier epoch. +- A `GET` that began before a commit cannot overwrite the commit result: its snapshot-version mismatch discards it and schedules at most one follow-up refresh. Refreshes coalesce across focus, visibility, and BroadcastChannel invalidation. +- A successful commit replaces only its canonical field and sends one invalidation. A failed commit exposes a concrete field error, retains the initiating control's local candidate, and requests a normal refresh. Invalidations contain no setting value. +- Duplicate same-field commits send no second request; distinct field commits remain concurrent. Every representation of a pending field is disabled while unrelated fields remain usable. +- Selects and toggles commit on change; text and numeric inputs commit only a locally valid value on blur or Enter. Pending, saved, and failure feedback is field-local, accessible, and has no idle reservation. + +### Direct Workflows And Regression + +- Each listed direct dialog migration has a focused test for its contextual notice, action-result notice, or field feedback. Archive member resolution renders exactly one parent-owned notice above its controls. +- The Browser Viewer Picker remains a single-column list, stays open for a remembered-viewer write, and opens a viewer only after persistence succeeds or an explicit unsaved path is chosen. +- Theme Selector, File Browser preferences, locale, and text-editor preferences use immediate persistence through the store. Failed theme persistence restores the initiating control's last confirmed preview when its local candidate is discarded. +- Routine settings pages have no Save button, dirty-state comparison, success alert, reset control, or reset-only response metadata. The settings action bar contains only explicit page commands. +- Existing validation focus, cancellation, i18n, identifier formatting, bidi isolation, and conflict-resolution behavior remain unchanged. Feedback is readable at 200% zoom and a 320 CSS-pixel viewport. +- Run focused backend and frontend tests throughout, then the full frontend suite, TypeScript check, lint, backend validation, and approved browser checks at `http://localhost:3000/browse/smb/demo`. + +## Acceptance Criteria + +- Forms and dialogs render feedback only while active; messages are readable, associated with their owner, and placed near the relevant field or action. +- Ordinary settings persist valid committed values immediately with independent field writes, local saving/saved/error feedback, and no routine Save or reset workflow. +- All ordinary update APIs use strict canonical `{ field, value }` request/result pairs. File Search and SMB policy blobs are migrated to per-field keys without compatibility reads or dual writes. +- File Search and user appearance invariants are enforced by short immediate SQLite transactions; the frontend does not serialize independent writes. +- `userSettingsStore` is the sole current-user API owner. It protects identity changes and stale complete reads with an epoch and snapshot version, rejects duplicate same-field writes, invalidates other tabs without sending values, and never persists server-backed settings locally. +- SMB policy writes preserve the existing complete post-commit runtime refresh lifecycle before returning. +- The responsive dialog shell, desktop settings, and mobile settings share the intended surface hierarchy while retaining their distinct behavior. Component relocations or renames are optional mechanical cleanup after behavior delivery. +- The developer guide records the durable policy, this temporary plan is deleted after completion, and focused tests, full validation, and browser checks pass. diff --git a/frontend/src/components/Admin/DialogReadOnlyField.tsx b/frontend/src/components/Admin/DialogReadOnlyField.tsx index e3c65b7c..d72efa6b 100644 --- a/frontend/src/components/Admin/DialogReadOnlyField.tsx +++ b/frontend/src/components/Admin/DialogReadOnlyField.tsx @@ -1,8 +1,8 @@ import type { SxProps, Theme } from "@mui/material"; import { TextField } from "@mui/material"; -import type { ChangeEventHandler, FocusEventHandler, Ref } from "react"; +import type { ChangeEventHandler, FocusEventHandler, ReactNode, Ref } from "react"; import { DIALOG_FORM_SURFACE_CSS_VARIABLE, getModeAdjustedSurfaceColor } from "../../theme/palette"; -import { settingsFormOutlinedControlSx } from "../Settings/SettingsFormLayout"; +import { dialogFormHelperTextSx, settingsFormOutlinedControlSx } from "../Settings/SettingsFormLayout"; interface DialogReadOnlyFieldProps { id?: string; @@ -14,7 +14,7 @@ interface DialogReadOnlyFieldProps { onChange?: ChangeEventHandler; inputRef?: Ref; error?: boolean; - helperText?: string; + helperText?: ReactNode; autoFocus?: boolean; onFocus?: FocusEventHandler; size?: "small" | "medium"; @@ -71,6 +71,7 @@ export function DialogReadOnlyField({ "aria-describedby": ariaDescribedBy, "aria-readonly": !editable, }, + formHelperText: { sx: dialogFormHelperTextSx }, }} sx={[ settingsFormOutlinedControlSx, diff --git a/frontend/src/components/FileBrowser/ArchiveExtractDialog.tsx b/frontend/src/components/FileBrowser/ArchiveExtractDialog.tsx index a2a4c427..c815eac2 100644 --- a/frontend/src/components/FileBrowser/ArchiveExtractDialog.tsx +++ b/frontend/src/components/FileBrowser/ArchiveExtractDialog.tsx @@ -1,18 +1,25 @@ -import { Alert, Box, Button, TextField, Typography } from "@mui/material"; +import { Box, Button, TextField, Typography } from "@mui/material"; import { useEffect, useRef, useState } from "react"; -import { Trans, useTranslation } from "react-i18next"; +import { useTranslation } from "react-i18next"; import type { ArchiveExtractionConflict, ArchiveExtractionConflictAction, ArchiveExtractionSummary, } from "../../pages/FileBrowser/contentProviders"; import { type ConflictInfo, FileType } from "../../types"; -import { DialogReadOnlyField } from "../Admin/DialogReadOnlyField"; import { ResponsiveFormDialog } from "../Admin/ResponsiveFormDialog"; -import { SettingsFormGroup, SettingsFormRow, SettingsFormSurface, settingsFormOutlinedControlSx } from "../Settings/SettingsFormLayout"; +import { + DialogFieldFeedback, + DialogFormNotice, + dialogFormHelperTextSx, + SettingsFormGroup, + SettingsFormRow, + SettingsFormSurface, + settingsFormOutlinedControlSx, +} from "../Settings/SettingsFormLayout"; import { ArchiveMemberErrorResolver } from "./ArchiveMemberErrorResolver"; import { ArchiveOperationProgress } from "./ArchiveOperationProgress"; -import { InlineItemName } from "./InlineItemName"; +import { DialogOperationContext } from "./DialogOperationContext"; import { type ConflictDecision, type ConflictResolution, OverwriteResolutionDialog } from "./OverwriteConflictDialog"; export type ArchiveExtractionScope = { kind: "archive" } | { kind: "members"; memberPaths: string[] }; @@ -59,12 +66,6 @@ function getParentPath(path: string): string { return separatorIndex < 0 ? "" : path.slice(0, separatorIndex); } -function getMemberDisplayName(memberPath: string): string { - const normalizedPath = memberPath.replace(/\/+$/, ""); - const separatorIndex = normalizedPath.lastIndexOf("/"); - return normalizedPath.slice(separatorIndex + 1) || memberPath; -} - function joinDisplayPath(prefix: string | undefined, path: string): string { if (!prefix) return path; return `${prefix.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`; @@ -153,6 +154,7 @@ export function ArchiveExtractDialog({ const { t } = useTranslation(); const inputRef = useRef(null); const extractButtonRef = useRef(null); + const cancelExtractionButtonRef = useRef(null); const retryMemberButtonRef = useRef(null); const [destinationPath, setDestinationPath] = useState(initialDestinationName); const [validationError, setValidationError] = useState(null); @@ -182,7 +184,7 @@ export function ArchiveExtractDialog({ ? t("fileBrowser.archive.validationDestinationEmpty") : validationError === "unsafe" ? t("fileBrowser.archive.validationDestinationUnsafe") - : " "; + : null; const currentConflict = conflicts?.[0] ?? null; const awaitingConflictDecision = currentConflict !== null && onConflictDecision !== undefined; const selectedMemberPaths = extractionScope.kind === "members" ? extractionScope.memberPaths : []; @@ -198,23 +200,13 @@ export function ArchiveExtractDialog({ }; const extractionDescription = ( - {memberError ? ( - t("fileBrowser.archive.memberErrorPrompt") - ) : extractionScope.kind === "archive" ? ( - }} - /> - ) : isSingleMemberExtraction ? ( - }} - /> - ) : ( - - )} + {memberError + ? t("fileBrowser.archive.memberErrorPrompt") + : extractionScope.kind === "archive" + ? t("fileBrowser.archive.extractDescriptionArchive") + : isSingleMemberExtraction + ? t("fileBrowser.archive.extractDescriptionMember") + : t("fileBrowser.archive.extractDescriptionMembers", { count: selectedMemberPaths.length })} ); @@ -224,6 +216,12 @@ export function ArchiveExtractDialog({ return () => cancelAnimationFrame(frameId); }, [isCancelling, isSubmittingConflictDecision, memberError]); + useEffect(() => { + if (!open || !isExtracting || awaitingConflictDecision || memberError || !onCancelExtraction) return; + const frameId = requestAnimationFrame(() => cancelExtractionButtonRef.current?.focus()); + return () => cancelAnimationFrame(frameId); + }, [awaitingConflictDecision, isExtracting, memberError, onCancelExtraction, open]); + if (awaitingConflictDecision && currentConflict && onConflictDecision) { const conflictResolutions = toConflictResolutions(allowedConflictActions); const handleConflictResolve = (decision: ConflictDecision) => { @@ -289,7 +287,7 @@ export function ArchiveExtractDialog({ ) : isExtracting ? ( onCancelExtraction ? ( - ) : null @@ -304,16 +302,22 @@ export function ArchiveExtractDialog({ } > - {isExtracting && !awaitingConflictDecision && !memberError ? ( - ) : null} - {error && !awaitingConflictDecision ? {error} : null} + {isExtracting && !awaitingConflictDecision && !memberError ? ( + + ) : null} + {!awaitingConflictDecision && !memberError ? : null} {memberError ? ( ) : null} @@ -324,7 +328,7 @@ export function ArchiveExtractDialog({ { setDestinationPath(event.target.value); @@ -337,14 +341,13 @@ export function ArchiveExtractDialog({ } }} error={validationError !== null} - helperText={validationMessage} + helperText={} + slotProps={{ formHelperText: { sx: dialogFormHelperTextSx } }} sx={settingsFormOutlinedControlSx} /> - ) : !isExtracting && !awaitingConflictDecision ? ( - ) : null} diff --git a/frontend/src/components/FileBrowser/ArchiveMemberErrorResolver.tsx b/frontend/src/components/FileBrowser/ArchiveMemberErrorResolver.tsx index 30b021ab..2e5bd5c4 100644 --- a/frontend/src/components/FileBrowser/ArchiveMemberErrorResolver.tsx +++ b/frontend/src/components/FileBrowser/ArchiveMemberErrorResolver.tsx @@ -1,6 +1,6 @@ import { Alert, Box, Typography } from "@mui/material"; import { useTranslation } from "react-i18next"; -import { InlineItemName } from "./InlineItemName"; +import { DialogOperationContext } from "./DialogOperationContext"; interface ArchiveMemberErrorResolverProps { error: { memberPath: string; targetPath: string; message: string; partialOutput: boolean }; @@ -14,27 +14,12 @@ export function ArchiveMemberErrorResolver({ error }: ArchiveMemberErrorResolver {error.message} - - - {t("fileBrowser.archive.collisionArchiveMemberLabel")} - - - - {error.memberPath} - - - - {t("fileBrowser.archive.memberErrorTargetLabel")} - - - - {error.targetPath} - - - + {error.partialOutput ? ( {t("fileBrowser.archive.memberErrorPartialOutputNote")} diff --git a/frontend/src/components/FileBrowser/ArchiveOperationProgress.tsx b/frontend/src/components/FileBrowser/ArchiveOperationProgress.tsx index c4e3bbf9..8ec862c7 100644 --- a/frontend/src/components/FileBrowser/ArchiveOperationProgress.tsx +++ b/frontend/src/components/FileBrowser/ArchiveOperationProgress.tsx @@ -1,45 +1,23 @@ import { Box, LinearProgress, Typography } from "@mui/material"; import { useTranslation } from "react-i18next"; -interface ArchiveOperationProgressProps { - currentItem: string; - completedMembers?: number; - totalMembers?: number; - processedBytes?: number; - totalBytes?: number; -} +type ArchiveOperationProgressProps = { operation: "create" } | { operation: "extract"; processedMembers?: number }; -export function ArchiveOperationProgress({ - currentItem, - completedMembers, - totalMembers, - processedBytes, - totalBytes, -}: ArchiveOperationProgressProps) { +export function ArchiveOperationProgress(props: ArchiveOperationProgressProps) { const { t } = useTranslation(); - const progressValue = - totalBytes && processedBytes !== undefined - ? Math.min((processedBytes / totalBytes) * 100, 100) - : totalMembers && completedMembers !== undefined - ? Math.min((completedMembers / totalMembers) * 100, 100) - : undefined; + const progressMessage = + props.operation === "create" + ? t("fileBrowser.archive.progressCreating") + : props.processedMembers && props.processedMembers > 0 + ? t("fileBrowser.archive.progressProcessedMembers", { processed: props.processedMembers }) + : t("fileBrowser.archive.progressPreparing"); return ( - - {totalMembers && completedMembers !== undefined ? ( - - {t("fileBrowser.archive.progressMembers", { completed: completedMembers, total: totalMembers })} - - ) : null} - {totalBytes && processedBytes !== undefined ? ( - - {t("fileBrowser.archive.progressBytes", { processed: processedBytes, total: totalBytes })} - - ) : null} - {t("fileBrowser.archive.progressSourceArchive")}: {currentItem} + {progressMessage} + ); } diff --git a/frontend/src/components/FileBrowser/BreadcrumbsNavigation.tsx b/frontend/src/components/FileBrowser/BreadcrumbsNavigation.tsx index 27d02119..24675717 100644 --- a/frontend/src/components/FileBrowser/BreadcrumbsNavigation.tsx +++ b/frontend/src/components/FileBrowser/BreadcrumbsNavigation.tsx @@ -6,6 +6,7 @@ import { Breadcrumbs, Link, Typography } from "@mui/material"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { createEscapeHandler } from "../../utils/keyboardUtils"; +import { truncateTextByGrapheme } from "../../utils/pathDisplay"; // ─── Constants ─────────────────────────────────────────────────────────────── @@ -52,20 +53,6 @@ interface DisplaySegment { // ─── Utility Functions ─────────────────────────────────────────────────────── -// -// truncateSegmentName -// -/** - * Truncate a segment name with a trailing ellipsis when it exceeds maxChars. - * Preserves the beginning of the name because path segments typically carry - * the most meaning at the start (dates, prefixes, project names). - */ -function truncateSegmentName(name: string, maxChars: number): string { - if (name.length <= maxChars) return name; - if (maxChars <= 1) return "…"; - return `${name.slice(0, maxChars - 1)}…`; -} - // // estimateSegmentsWidth // @@ -206,7 +193,7 @@ function tryFitWithTruncation(segments: DisplaySegment[], maxWidth: number): Dis let budgetIdx = 0; for (const seg of result) { if (seg.type === "segment") { - seg.label = truncateSegmentName(seg.fullLabel, budgets[budgetIdx] ?? MIN_SEGMENT_CHARS); + seg.label = truncateTextByGrapheme(seg.fullLabel, budgets[budgetIdx] ?? MIN_SEGMENT_CHARS); budgetIdx++; } } @@ -244,7 +231,7 @@ function calculateBreadcrumbSegments(pathParts: string[], containerWidth: number return [ { type: "segment", - label: truncateSegmentName(last, MIN_SEGMENT_CHARS), + label: truncateTextByGrapheme(last, MIN_SEGMENT_CHARS), fullLabel: last, pathIndex: pathParts.length - 1, }, @@ -275,7 +262,7 @@ function calculateBreadcrumbSegments(pathParts: string[], containerWidth: number // Phase 4: Just the last segment, truncated to fit const last = pathParts[n - 1] ?? ""; const maxChars = Math.max(MIN_SEGMENT_CHARS, Math.floor(maxWidth / CHAR_WIDTH_PX)); - return [{ type: "segment", label: truncateSegmentName(last, maxChars), fullLabel: last, pathIndex: n - 1 }]; + return [{ type: "segment", label: truncateTextByGrapheme(last, maxChars), fullLabel: last, pathIndex: n - 1 }]; } // ─── Component ─────────────────────────────────────────────────────────────── diff --git a/frontend/src/components/FileBrowser/ConfirmDeleteDialog.tsx b/frontend/src/components/FileBrowser/ConfirmDeleteDialog.tsx index 7825db46..d1bbe036 100644 --- a/frontend/src/components/FileBrowser/ConfirmDeleteDialog.tsx +++ b/frontend/src/components/FileBrowser/ConfirmDeleteDialog.tsx @@ -11,14 +11,16 @@ * accidentally trigger the destructive action. */ -import { Button, CircularProgress } from "@mui/material"; +import { Box, Button, CircularProgress, Typography } from "@mui/material"; import type React from "react"; import { useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { type FileEntry, FileType } from "../../types"; import { dialogEnterKeyHandler } from "../../utils/keyboardUtils"; -import { DialogReadOnlyField } from "../Admin/DialogReadOnlyField"; import { ResponsiveFormDialog } from "../Admin/ResponsiveFormDialog"; import { CONFIRM_DELETE_STRINGS } from "./confirmDeleteDialogStrings"; +import { DialogIdentifierDisplay } from "./DialogIdentifierDisplay"; +import { DialogOperationContext } from "./DialogOperationContext"; // ============================================================================ // Props @@ -47,6 +49,7 @@ const DELETE_LIST_MAX_VISIBLE_ROWS = 6; // ConfirmDeleteDialog // const ConfirmDeleteDialog: React.FC = ({ open, items, isDeleting, onClose, onConfirm }) => { + const { t } = useTranslation(); const isSingleItem = items.length === 1; const item = items[0]; const isDirectory = item?.type === FileType.DIRECTORY; @@ -70,7 +73,8 @@ const ConfirmDeleteDialog: React.FC = ({ open, items, ? CONFIRM_DELETE_STRINGS.CONFIRM_DIRECTORY : CONFIRM_DELETE_STRINGS.CONFIRM_FILE : CONFIRM_DELETE_STRINGS.CONFIRM_MULTI(items.length); - const itemNames = items.map((currentItem) => currentItem.name).join("\n"); + const visibleItems = items.slice(0, DELETE_LIST_MAX_VISIBLE_ROWS); + const remainingItemCount = items.length - visibleItems.length; /** ENTER activates the focused button; no default fallback (Cancel has focus). */ const handleKeyDown = useMemo(() => dialogEnterKeyHandler(), []); @@ -101,14 +105,25 @@ const ConfirmDeleteDialog: React.FC = ({ open, items, } > - + {isSingleItem && item ? ( + + ) : ( + + + {CONFIRM_DELETE_STRINGS.ARIA_LABEL_ITEMS} + + {visibleItems.map((visibleItem) => ( + + ))} + {remainingItemCount > 0 ? ( + {t("fileBrowser.confirmDelete.moreItems", { count: remainingItemCount })} + ) : null} + + )} ); }; diff --git a/frontend/src/components/FileBrowser/CopyMoveDialog.tsx b/frontend/src/components/FileBrowser/CopyMoveDialog.tsx index decc2634..cfab57c9 100644 --- a/frontend/src/components/FileBrowser/CopyMoveDialog.tsx +++ b/frontend/src/components/FileBrowser/CopyMoveDialog.tsx @@ -17,18 +17,26 @@ * progress. Both panes refresh via WebSocket after completion. */ -import { Alert, Box, Button, CircularProgress, LinearProgress, TextField, Typography } from "@mui/material"; +import { Box, Button, CircularProgress, LinearProgress, TextField, Typography } from "@mui/material"; import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Trans } from "react-i18next"; +import { useTranslation } from "react-i18next"; import type { FileEntry } from "../../types"; import { dialogEnterKeyHandler } from "../../utils/keyboardUtils"; -import { DialogReadOnlyField } from "../Admin/DialogReadOnlyField"; import { ResponsiveFormDialog } from "../Admin/ResponsiveFormDialog"; -import { SettingsFormGroup, SettingsFormRow, SettingsFormSurface, settingsFormOutlinedControlSx } from "../Settings/SettingsFormLayout"; +import { + DialogFieldFeedback, + DialogFormNoticeRegion, + dialogFormHelperTextSx, + SettingsFormGroup, + SettingsFormRow, + SettingsFormSurface, + settingsFormOutlinedControlSx, +} from "../Settings/SettingsFormLayout"; import { COPY_MOVE_STRINGS as S } from "./copyMoveDialogStrings"; +import { DialogIdentifierDisplay } from "./DialogIdentifierDisplay"; +import { DialogOperationContext } from "./DialogOperationContext"; import { FILENAME_FIELD_PROPS, FILENAME_INPUT_PROPS, FILENAME_INPUT_SX } from "./filenameFieldProps"; -import { InlineItemName } from "./InlineItemName"; import { validateItemName } from "./nameDialogStrings"; // ============================================================================ @@ -108,6 +116,7 @@ const CopyMoveDialog: React.FC = ({ warning, isTerminal = false, }) => { + const { t } = useTranslation(); // Editable file name — only used for single-item operations const isSingleItem = files.length === 1; const originalFileName = isSingleItem ? (files[0]!.name ?? "") : ""; @@ -116,6 +125,7 @@ const CopyMoveDialog: React.FC = ({ const [destFileName, setDestFileName] = useState(initialFileName); const inputRef = useRef(null); const confirmButtonRef = useRef(null); + const cancelButtonRef = useRef(null); const closeButtonRef = useRef(null); // Reset state when the dialog opens and restore focus after a nested workflow finishes. @@ -138,21 +148,20 @@ const CopyMoveDialog: React.FC = ({ } }, [open, initialFileName, isSingleItem, isTerminal]); + useEffect(() => { + if (!open || !isProcessing || isTerminal) return; + const frame = requestAnimationFrame(() => cancelButtonRef.current?.focus()); + return () => cancelAnimationFrame(frame); + }, [isProcessing, isTerminal, open]); + const title = isCopy ? S.TITLE_COPY : S.TITLE_MOVE; - const prompt = isCopy ? S.PROMPT_COPY_MULTI(files.length) : S.PROMPT_MOVE_MULTI(files.length); + const batchDescription = isCopy ? S.DESCRIPTION_COPY_MULTI(files.length) : S.DESCRIPTION_MOVE_MULTI(files.length); const description = isSingleItem ? ( - , - destination: , - }} - /> + {t(isCopy ? "fileBrowser.copyMove.descriptionCopySingle" : "fileBrowser.copyMove.descriptionMoveSingle")} ) : ( - prompt + batchDescription ); const confirmLabel = isProcessing ? (isCopy ? S.BUTTON_COPYING : S.BUTTON_MOVING) : isCopy ? S.BUTTON_COPY : S.BUTTON_MOVE; @@ -180,8 +189,22 @@ const CopyMoveDialog: React.FC = ({ const formContent = ( - {error ? {error} : null} - {warning ? {warning} : null} + + {isSingleItem ? ( @@ -194,35 +217,15 @@ const CopyMoveDialog: React.FC = ({ onChange={(event) => setDestFileName(event.target.value)} disabled={isProcessing} error={Boolean(fileNameError)} - helperText={fileNameError ?? " "} + helperText={} {...FILENAME_FIELD_PROPS} - slotProps={{ htmlInput: FILENAME_INPUT_PROPS }} + slotProps={{ htmlInput: FILENAME_INPUT_PROPS, formHelperText: { sx: dialogFormHelperTextSx } }} sx={[settingsFormOutlinedControlSx, FILENAME_INPUT_SX]} /> - ) : ( - - - - - - - - )} - - {!isSingleItem ? ( - - {S.ERROR_SAME_DIRECTORY} - ) : null} - {isProcessing && progress ? ( @@ -234,12 +237,16 @@ const CopyMoveDialog: React.FC = ({ {isProcessing && transferProgress ? ( - - {transferProgress.itemName}:{" "} - {transferProgress.totalBytes != null && transferProgress.totalBytes > 0 - ? `${formatBytes(transferProgress.bytesTransferred)} / ${formatBytes(transferProgress.totalBytes)}` - : formatBytes(transferProgress.bytesTransferred)} - + + + + + + {transferProgress.totalBytes != null && transferProgress.totalBytes > 0 + ? `${formatBytes(transferProgress.bytesTransferred)} / ${formatBytes(transferProgress.totalBytes)}` + : formatBytes(transferProgress.bytesTransferred)} + + {transferProgress.totalBytes != null && transferProgress.totalBytes > 0 ? ( = ({ const actions = ( <> - {!isTerminal ? ( @@ -277,6 +284,7 @@ const CopyMoveDialog: React.FC = ({ open={open} onClose={onCancel} disableClose={isProcessing} + onEscape={isProcessing ? onCancel : undefined} onKeyDown={handleKeyDown} title={title} description={description} diff --git a/frontend/src/components/FileBrowser/CreateItemDialog.tsx b/frontend/src/components/FileBrowser/CreateItemDialog.tsx index 1d1087b7..23e284ce 100644 --- a/frontend/src/components/FileBrowser/CreateItemDialog.tsx +++ b/frontend/src/components/FileBrowser/CreateItemDialog.tsx @@ -15,11 +15,11 @@ import { Typography } from "@mui/material"; import type React from "react"; -import { Trans } from "react-i18next"; +import { useTranslation } from "react-i18next"; import { FileType } from "../../types"; import { CREATE_ITEM_DIALOG_STRINGS } from "./createItemDialogStrings"; +import { DialogOperationContext } from "./DialogOperationContext"; import { formatConnectionPath } from "./formatConnectionPath"; -import { InlineItemName } from "./InlineItemName"; import NameInputDialog from "./NameInputDialog"; // ============================================================================ @@ -59,16 +59,13 @@ const CreateItemDialog: React.FC = ({ onConfirm, apiError, }) => { + const { t } = useTranslation(); const isDirectory = itemType === FileType.DIRECTORY; const title = isDirectory ? CREATE_ITEM_DIALOG_STRINGS.TITLE_DIRECTORY : CREATE_ITEM_DIALOG_STRINGS.TITLE_FILE; const targetDirectory = formatConnectionPath(targetConnectionName, targetPath); const description = ( - }} - /> + {t(isDirectory ? "fileBrowser.createItem.descriptionDirectory" : "fileBrowser.createItem.descriptionFile")} ); @@ -81,6 +78,11 @@ const CreateItemDialog: React.FC = ({ initialValue="" submitLabel={CREATE_ITEM_DIALOG_STRINGS.BUTTON_CREATE} submittingLabel={CREATE_ITEM_DIALOG_STRINGS.BUTTON_CREATING} + operationContext={ + + } isSubmitting={isCreating} onClose={onClose} onConfirm={onConfirm} diff --git a/frontend/src/components/FileBrowser/DialogIdentifierDisplay.tsx b/frontend/src/components/FileBrowser/DialogIdentifierDisplay.tsx new file mode 100644 index 00000000..8e18180d --- /dev/null +++ b/frontend/src/components/FileBrowser/DialogIdentifierDisplay.tsx @@ -0,0 +1,87 @@ +import { alpha, Box, Tooltip } from "@mui/material"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { DIALOG_FORM_SURFACE_CSS_VARIABLE, getModeAdjustedSurfaceColor } from "../../theme/palette"; +import { abbreviateFileName, abbreviatePath } from "../../utils/pathDisplay"; + +export type DialogOperationIdentifierKind = "fileName" | "path"; + +interface DialogIdentifierDisplayProps { + value: string; + kind: DialogOperationIdentifierKind; + testId?: string; +} + +function measureText(element: HTMLElement, value: string): number { + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (!context) return value.length; + const style = getComputedStyle(element); + context.font = `${style.fontStyle} ${style.fontVariant} ${style.fontWeight} ${style.fontSize}/${style.lineHeight} ${style.fontFamily}`; + return context.measureText(value).width; +} + +export function DialogIdentifierDisplay({ value, kind, testId }: DialogIdentifierDisplayProps) { + const valueRef = useRef(null); + const [availableWidth, setAvailableWidth] = useState(null); + + useLayoutEffect(() => { + const element = valueRef.current; + if (!element) return; + const updateWidth = () => setAvailableWidth(element.getBoundingClientRect().width); + updateWidth(); + const observer = new ResizeObserver(updateWidth); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!document.fonts) return; + void document.fonts.ready.then(() => { + const element = valueRef.current; + if (element) setAvailableWidth(element.getBoundingClientRect().width); + }); + }, []); + + const displayValue = + availableWidth === null || availableWidth <= 0 + ? value + : kind === "fileName" + ? abbreviateFileName(value, availableWidth, (text) => measureText(valueRef.current!, text)) + : abbreviatePath(value, availableWidth, (text) => measureText(valueRef.current!, text)); + const isShortened = displayValue !== value; + const identifier = ( + + + `var(${DIALOG_FORM_SURFACE_CSS_VARIABLE}, ${getModeAdjustedSurfaceColor(theme.palette.background.default, theme.palette.mode)})`, + border: (theme) => `1px solid ${alpha(theme.palette.text.primary, 0.2)}`, + borderRadius: 0.5, + color: "text.primary", + display: "block", + fontFamily: "monospace", + fontSize: "0.875em", + maxWidth: "100%", + minWidth: 0, + overflow: "hidden", + px: 0.5, + py: 0.125, + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }} + > + {displayValue} + + + ); + + return isShortened ? ( + + {identifier} + + ) : ( + identifier + ); +} diff --git a/frontend/src/components/FileBrowser/DialogOperationContext.tsx b/frontend/src/components/FileBrowser/DialogOperationContext.tsx new file mode 100644 index 00000000..31e55e86 --- /dev/null +++ b/frontend/src/components/FileBrowser/DialogOperationContext.tsx @@ -0,0 +1,33 @@ +import { Box, Typography } from "@mui/material"; +import { DialogIdentifierDisplay, type DialogOperationIdentifierKind } from "./DialogIdentifierDisplay"; + +interface DialogOperationContextEntry { + label: string; + value: string; + kind: DialogOperationIdentifierKind; + testId?: string; +} + +interface DialogOperationContextProps { + entries: readonly DialogOperationContextEntry[]; + ariaLabel?: string; +} + +export function DialogOperationContext({ entries, ariaLabel }: DialogOperationContextProps) { + return ( + + + {entries.map((entry) => ( + + + {entry.label} + + + + + + ))} + + + ); +} diff --git a/frontend/src/components/FileBrowser/NameInputDialog.tsx b/frontend/src/components/FileBrowser/NameInputDialog.tsx index 2a103ee2..b5c4a340 100644 --- a/frontend/src/components/FileBrowser/NameInputDialog.tsx +++ b/frontend/src/components/FileBrowser/NameInputDialog.tsx @@ -17,11 +17,19 @@ * - Auto-focus on open and re-focus on new API errors */ -import { Alert, Box, Button, CircularProgress, TextField } from "@mui/material"; +import { Box, Button, CircularProgress, TextField } from "@mui/material"; import type React from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { ResponsiveFormDialog } from "../Admin/ResponsiveFormDialog"; -import { SettingsFormGroup, SettingsFormRow, SettingsFormSurface, settingsFormOutlinedControlSx } from "../Settings/SettingsFormLayout"; +import { + DialogFieldFeedback, + DialogFormNotice, + dialogFormHelperTextSx, + SettingsFormGroup, + SettingsFormRow, + SettingsFormSurface, + settingsFormOutlinedControlSx, +} from "../Settings/SettingsFormLayout"; import { FILENAME_FIELD_PROPS, FILENAME_INPUT_PROPS, FILENAME_INPUT_SX } from "./filenameFieldProps"; import { NAME_DIALOG_STRINGS, validateItemName } from "./nameDialogStrings"; @@ -46,6 +54,8 @@ interface NameInputDialogProps { submittingLabel: string; /** Optional content that replaces the form during an active operation. */ submittingContent?: React.ReactNode; + /** Read-only operation context shown above the editable field. */ + operationContext?: React.ReactNode; /** Whether an operation is in progress */ isSubmitting: boolean; /** Whether cancellation of an active operation is in progress. */ @@ -86,6 +96,7 @@ const NameInputDialog: React.FC = ({ submitLabel, submittingLabel, submittingContent, + operationContext, isSubmitting, isCancelling = false, onClose, @@ -97,6 +108,7 @@ const NameInputDialog: React.FC = ({ autoSelectRange, }) => { const inputRef = useRef(null); + const cancelSubmittingRef = useRef(null); const [value, setValue] = useState(initialValue); const [validationError, setValidationError] = useState(null); @@ -139,6 +151,12 @@ const NameInputDialog: React.FC = ({ } }, [apiError, open, selectText]); + useEffect(() => { + if (!open || !isSubmitting || !onCancelSubmitting) return; + const frame = requestAnimationFrame(() => cancelSubmittingRef.current?.focus()); + return () => cancelAnimationFrame(frame); + }, [isSubmitting, onCancelSubmitting, open]); + // // handleChange // @@ -194,6 +212,7 @@ const NameInputDialog: React.FC = ({ const formContent = ( + {operationContext} @@ -206,23 +225,16 @@ const NameInputDialog: React.FC = ({ onKeyDown={handleKeyDown} disabled={isSubmitting} error={hasError} - helperText={validationError ?? " "} + helperText={} variant="outlined" {...FILENAME_FIELD_PROPS} - slotProps={{ htmlInput: FILENAME_INPUT_PROPS }} + slotProps={{ htmlInput: FILENAME_INPUT_PROPS, formHelperText: { sx: dialogFormHelperTextSx } }} sx={[settingsFormOutlinedControlSx, FILENAME_INPUT_SX]} /> - - {apiError ?? " "} - + ); @@ -237,14 +249,14 @@ const NameInputDialog: React.FC = ({ actions={ isSubmitting && submittingContent ? ( onCancelSubmitting ? ( - ) : null ) : ( <> {isSubmitting && onCancelSubmitting ? ( - ) : ( @@ -265,7 +277,14 @@ const NameInputDialog: React.FC = ({ } maxWidth="sm" > - {isSubmitting && submittingContent ? submittingContent : formContent} + {isSubmitting && submittingContent ? ( + + {operationContext} + {submittingContent} + + ) : ( + formContent + )} ); }; diff --git a/frontend/src/components/FileBrowser/OverwriteConflictDialog.tsx b/frontend/src/components/FileBrowser/OverwriteConflictDialog.tsx index 4a15c1d8..73838019 100644 --- a/frontend/src/components/FileBrowser/OverwriteConflictDialog.tsx +++ b/frontend/src/components/FileBrowser/OverwriteConflictDialog.tsx @@ -20,7 +20,7 @@ */ import ArrowUpwardOutlinedIcon from "@mui/icons-material/ArrowUpwardOutlined"; -import { Alert, Box, Button, Checkbox, FormControl, FormControlLabel, FormLabel, Radio, RadioGroup, Typography } from "@mui/material"; +import { Box, Button, Checkbox, FormControl, FormControlLabel, FormLabel, Radio, RadioGroup, Typography } from "@mui/material"; import type React from "react"; import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { ConflictInfo } from "../../types"; @@ -28,6 +28,8 @@ import { dialogEnterKeyHandler } from "../../utils/keyboardUtils"; import { formatLocalizedDateTime, formatLocalizedNumber } from "../../utils/localeFormatting"; import { DialogReadOnlyField } from "../Admin/DialogReadOnlyField"; import { ResponsiveFormDialog } from "../Admin/ResponsiveFormDialog"; +import { DialogFieldFeedback, DialogFormNotice } from "../Settings/SettingsFormLayout"; +import { DialogIdentifierDisplay } from "./DialogIdentifierDisplay"; import { validateItemName } from "./nameDialogStrings"; import { OVERWRITE_CONFLICT_STRINGS as S } from "./overwriteConflictStrings"; @@ -115,39 +117,9 @@ function getParentDirectory(path: string): string { return path.slice(0, separatorIndex) || "/"; } -function PathValue({ - path, - testId, - blockLayout = false, - removeHorizontalMargin = false, -}: { - path: string; - testId: string; - blockLayout?: boolean; - removeHorizontalMargin?: boolean; -}) { - return ( - - {path} - - ); +function joinDirectoryAndName(directory: string, name: string): string { + if (!directory || directory === "/") return `/${name}`; + return `${directory.replace(/\/+$/, "")}/${name}`; } // ============================================================================ @@ -182,7 +154,6 @@ const OverwriteConflictDialog: React.FC = ({ const sourcePath = ownerSourcePath ?? conflict?.incoming_file.path ?? ""; const existingTargetName = conflict?.existing_file.name ?? ""; const targetDirectory = ownerTargetDirectoryPath ?? getParentDirectory(conflict?.existing_file.path ?? ""); - const sourceDirectory = getParentDirectory(sourcePath); const conflictIdentity = `${conflict?.incoming_file.path ?? ""}\u0000${conflict?.existing_file.path ?? ""}`; const allowedActionsIdentity = allowedActions.join("\u0000"); const dialogStateIdentity = `${conflictIdentity}\u0000${allowedActionsIdentity}`; @@ -190,7 +161,6 @@ const OverwriteConflictDialog: React.FC = ({ const hasAvailableResolution = allowedActions.length > 0; const isRename = resolution === "rename"; const isSubmittingOrPending = isSubmitting || ownerIsSubmitting; - const displayedTargetName = isRename ? renameDraft : existingTargetName; const targetNameError = isRename ? (validateItemName(renameDraft) ?? (renameDraft === existingTargetName ? S.ERROR_TARGET_NAME_UNCHANGED : null)) : null; @@ -296,12 +266,12 @@ const OverwriteConflictDialog: React.FC = ({ const handleKeyDown = useCallback((event: React.KeyboardEvent) => fallbackEnterHandler(event), [fallbackEnterHandler]); const targetDetails = [ - { label: S.LABEL_PATH, value: targetDirectory, testId: "overwrite-conflict-target-path" }, + { label: S.LABEL_PATH, value: joinDirectoryAndName(targetDirectory, existingTargetName), testId: "overwrite-conflict-target-path" }, { label: S.LABEL_MODIFIED, value: formatDate(conflict?.existing_file.modified_at) }, { label: S.LABEL_SIZE, value: formatBytes(conflict?.existing_file.size) }, ]; const sourceDetails = [ - { label: S.LABEL_PATH, value: sourceDirectory, testId: "overwrite-conflict-source-path" }, + { label: S.LABEL_PATH, value: sourcePath, testId: "overwrite-conflict-source-path" }, { label: S.LABEL_MODIFIED, value: formatDate(conflict?.incoming_file.modified_at) }, { label: S.LABEL_SIZE, value: formatBytes(conflict?.incoming_file.size) }, ]; @@ -333,23 +303,7 @@ const OverwriteConflictDialog: React.FC = ({ tabIndex={hasAvailableResolution ? undefined : -1} aria-busy={isSubmittingOrPending} > - {displayedError ? ( - - {displayedError} - - ) : null} - setRenameDraft(event.target.value)} - inputRef={targetNameRef} - error={Boolean(targetNameError)} - helperText={targetNameError ?? " "} - autoFocus={safeDefaultResolution === "rename" && isRename} - showFormSurface={!isRename} - sx={{ mb: 2 }} - /> + {[ { label: S.LABEL_EXISTING, details: targetDetails, testId: "overwrite-conflict-target-details" }, @@ -377,7 +331,7 @@ const OverwriteConflictDialog: React.FC = ({ {detail.testId ? ( - + ) : ( {detail.value} )} @@ -432,6 +386,22 @@ const OverwriteConflictDialog: React.FC = ({ {S.PROGRESS_CONTEXT(progress.current, progress.total, progress.conflictsSoFar)} )} + {isRename ? ( + + setRenameDraft(event.target.value)} + inputRef={targetNameRef} + error={Boolean(targetNameError)} + helperText={} + autoFocus={safeDefaultResolution === "rename"} + showFormSurface + /> + + ) : null} ); diff --git a/frontend/src/components/FileBrowser/RenameDialog.tsx b/frontend/src/components/FileBrowser/RenameDialog.tsx index c3c61d69..f5775b83 100644 --- a/frontend/src/components/FileBrowser/RenameDialog.tsx +++ b/frontend/src/components/FileBrowser/RenameDialog.tsx @@ -16,9 +16,8 @@ import { Typography } from "@mui/material"; import type React from "react"; import { useMemo } from "react"; -import { Trans } from "react-i18next"; +import { useTranslation } from "react-i18next"; import { FileType } from "../../types"; -import { InlineItemName } from "./InlineItemName"; import NameInputDialog from "./NameInputDialog"; import { RENAME_DIALOG_STRINGS } from "./renameDialogStrings"; @@ -64,16 +63,13 @@ function getNameSelectionRange(name: string, isDirectory: boolean): [number, num // ============================================================================ const RenameDialog: React.FC = ({ open, itemName, itemType, isRenaming, onClose, onConfirm, apiError }) => { + const { t } = useTranslation(); const isDirectory = itemType === FileType.DIRECTORY; const title = isDirectory ? RENAME_DIALOG_STRINGS.TITLE_DIRECTORY : RENAME_DIALOG_STRINGS.TITLE_FILE; const autoSelectRange = useMemo(() => getNameSelectionRange(itemName, isDirectory), [itemName, isDirectory]); const description = ( - }} - /> + {t("fileBrowser.rename.description")} ); diff --git a/frontend/src/components/FileBrowser/__tests__/ArchiveExtractDialog.test.tsx b/frontend/src/components/FileBrowser/__tests__/ArchiveExtractDialog.test.tsx index 4f87c953..d33f8a87 100644 --- a/frontend/src/components/FileBrowser/__tests__/ArchiveExtractDialog.test.tsx +++ b/frontend/src/components/FileBrowser/__tests__/ArchiveExtractDialog.test.tsx @@ -15,6 +15,7 @@ const defaultProps = { archiveName: "project.zip", extractionScope: { kind: "archive" } as const, initialDestinationName: "project", + destinationLabel: "Demo:/Archive", open: true, isExtracting: false, error: null as string | null, @@ -44,8 +45,10 @@ describe("ArchiveExtractDialog", () => { it("describes full archive extraction", () => { render(); - expect(screen.getByTestId("archive-extract-prompt-name")).toHaveTextContent("project.zip"); - expect(screen.getByLabelText("fileBrowser.archive.destinationLabel")).toHaveValue("project"); + expect(screen.getByText("fileBrowser.operationContext.archive")).toBeInTheDocument(); + expect(screen.getByLabelText("project.zip")).toHaveTextContent("project.zip"); + expect(screen.getByLabelText("Demo:/Archive")).toHaveTextContent("Demo:/Archive"); + expect(screen.getByLabelText("fileBrowser.archive.destinationNameLabel")).toHaveValue("project"); }); it("describes a single selected member and shows its fixed destination in a read-only field", async () => { @@ -61,10 +64,10 @@ describe("ArchiveExtractDialog", () => { /> ); - expect(screen.getByTestId("archive-extract-prompt-name")).toHaveTextContent("readme.txt"); - const destination = screen.getByLabelText("fileBrowser.archive.destinationLabel"); - expect(destination).toHaveValue("Demo:/Test"); - expect(destination).toHaveAttribute("readonly"); + expect(screen.getByLabelText("docs/readme.txt")).toHaveTextContent("docs/readme.txt"); + expect(screen.getByText("fileBrowser.operationContext.archiveMember")).toBeInTheDocument(); + expect(screen.getByLabelText("Demo:/Test")).toHaveTextContent("Demo:/Test"); + expect(screen.queryByLabelText("fileBrowser.archive.destinationNameLabel")).not.toBeInTheDocument(); expect(screen.getByRole("heading", { name: "fileBrowser.archive.extractTitle" })).toBeInTheDocument(); await waitFor(() => expect(screen.getByRole("button", { name: "fileBrowser.archive.buttonExtract" })).toHaveFocus()); await user.keyboard("{Enter}"); @@ -82,27 +85,24 @@ describe("ArchiveExtractDialog", () => { /> ); - expect(screen.getByText("2 selected items will be extracted to:")).toBeInTheDocument(); - expect(screen.getByLabelText("fileBrowser.archive.destinationLabel")).toHaveValue("Demo:/Test"); + expect(screen.getByText("fileBrowser.archive.extractDescriptionMembers")).toBeInTheDocument(); + expect(screen.getByLabelText("Demo:/Test")).toHaveTextContent("Demo:/Test"); }); it("prefills the provider-supplied destination name", () => { render(); - expect(screen.getByLabelText("fileBrowser.archive.destinationLabel")).toHaveValue("project"); + expect(screen.getByLabelText("fileBrowser.archive.destinationNameLabel")).toHaveValue("project"); }); - it("focuses Extract and uses code formatting when the destination is fixed", async () => { + it("focuses Extract and shows the source separately when the destination is fixed", async () => { const user = userEvent.setup(); const onConfirm = vi.fn(); render(); - const destination = screen.getByLabelText("fileBrowser.archive.destinationLabel"); - expect(destination).toHaveValue("Demo:/Test"); - expect(destination).toHaveAttribute("readonly"); - expect(destination).not.toHaveAttribute("wrap"); - expect(screen.queryByText("fileBrowser.archive.destinationLabel")).not.toBeInTheDocument(); - expect(screen.getByTestId("archive-extract-prompt-name").tagName).toBe("CODE"); + expect(screen.getByLabelText("Demo:/Test")).toHaveTextContent("Demo:/Test"); + expect(screen.queryByLabelText("fileBrowser.archive.destinationNameLabel")).not.toBeInTheDocument(); + expect(screen.getByLabelText("project.zip")).toHaveTextContent("project.zip"); const extractButton = screen.getByRole("button", { name: "fileBrowser.archive.buttonExtract" }); await waitFor(() => expect(extractButton).toHaveFocus()); await user.keyboard("{Enter}"); @@ -115,8 +115,8 @@ describe("ArchiveExtractDialog", () => { const onConfirm = vi.fn(); render(); - await user.clear(screen.getByLabelText("fileBrowser.archive.destinationLabel")); - await user.type(screen.getByLabelText("fileBrowser.archive.destinationLabel"), "../outside"); + await user.clear(screen.getByLabelText("fileBrowser.archive.destinationNameLabel")); + await user.type(screen.getByLabelText("fileBrowser.archive.destinationNameLabel"), "../outside"); await user.click(screen.getByRole("button", { name: "fileBrowser.archive.buttonExtract" })); expect(await screen.findByText("fileBrowser.archive.validationDestinationUnsafe")).toBeInTheDocument(); @@ -128,8 +128,8 @@ describe("ArchiveExtractDialog", () => { const onConfirm = vi.fn(); render(); - await user.clear(screen.getByLabelText("fileBrowser.archive.destinationLabel")); - await user.type(screen.getByLabelText("fileBrowser.archive.destinationLabel"), "output\\release"); + await user.clear(screen.getByLabelText("fileBrowser.archive.destinationNameLabel")); + await user.type(screen.getByLabelText("fileBrowser.archive.destinationNameLabel"), "output\\release"); await user.click(screen.getByRole("button", { name: "fileBrowser.archive.buttonExtract" })); expect(onConfirm).toHaveBeenCalledWith("output/release"); @@ -139,11 +139,17 @@ describe("ArchiveExtractDialog", () => { const onCancelExtraction = vi.fn(); render(); - expect(screen.queryByLabelText("fileBrowser.archive.destinationLabel")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("fileBrowser.archive.destinationNameLabel")).not.toBeInTheDocument(); expect(screen.getByRole("progressbar")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "fileBrowser.archive.buttonCancelExtraction" })).toBeEnabled(); }); + it("focuses Cancel extraction while extraction is pending", async () => { + render(); + + await waitFor(() => expect(screen.getByRole("button", { name: "fileBrowser.archive.buttonCancelExtraction" })).toHaveFocus()); + }); + it("cancels active extraction when Escape is pressed", () => { const onCancelExtraction = vi.fn(); render(); @@ -153,7 +159,7 @@ describe("ArchiveExtractDialog", () => { expect(onCancelExtraction).toHaveBeenCalledOnce(); }); - it("shows determinate direct-local extraction progress", () => { + it("shows neutral processed-member extraction progress", () => { render( { filesExtracted: 2, directoriesCreated: 1, extractedBytes: 12, + membersProcessed: 3, totalMembers: 6, totalBytes: 24, filesSkipped: 0, @@ -171,8 +178,8 @@ describe("ArchiveExtractDialog", () => { /> ); - expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "50"); - expect(screen.getByText("fileBrowser.archive.progressSourceArchive: project.zip")).toBeInTheDocument(); + expect(screen.getByRole("progressbar")).not.toHaveAttribute("aria-valuenow"); + expect(screen.getByText("fileBrowser.archive.progressProcessedMembers")).toBeInTheDocument(); }); it("offers direct retry and skip actions for a member error", async () => { @@ -249,9 +256,9 @@ describe("ArchiveExtractDialog", () => { ); expect(screen.getByRole("heading", { name: S.TITLE })).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: S.LABEL_TARGET_NAME })).toHaveValue("readme.txt"); - expect(screen.getByTestId("overwrite-conflict-source-path")).toHaveTextContent("docs"); - expect(screen.getByTestId("overwrite-conflict-target-path")).toHaveTextContent("output/docs"); + expect(screen.queryByRole("textbox", { name: S.LABEL_TARGET_NAME })).not.toBeInTheDocument(); + expect(screen.getByTestId("overwrite-conflict-source-path")).toHaveTextContent("docs/readme.txt"); + expect(screen.getByTestId("overwrite-conflict-target-path")).toHaveTextContent("output/docs/readme.txt"); await user.click(screen.getByRole("checkbox", { name: S.APPLY_TO_ALL })); await user.click(screen.getByRole("button", { name: S.BUTTON_CONTINUE })); @@ -333,7 +340,7 @@ describe("ArchiveExtractDialog", () => { ); await waitFor(() => expect(screen.getByRole("radio", { name: S.BUTTON_SKIP })).toBeChecked()); - expect(screen.getByRole("textbox", { name: S.LABEL_TARGET_NAME })).toHaveValue("readme.txt"); + expect(screen.queryByRole("textbox", { name: S.LABEL_TARGET_NAME })).not.toBeInTheDocument(); }); it("does not show a bulk checkbox when only a bulk archive action is allowed", async () => { @@ -391,7 +398,7 @@ describe("ArchiveExtractDialog", () => { /> ); - expect(screen.getByTestId("overwrite-conflict-source-path")).toHaveTextContent("/"); + expect(screen.getByTestId("overwrite-conflict-source-path")).toHaveTextContent("member-0.txt"); expect(screen.queryByDisplayValue("member-999.txt")).not.toBeInTheDocument(); expect(within(screen.getByTestId("responsive-form-dialog-desktop-actions")).getAllByRole("button")).toHaveLength(2); }); diff --git a/frontend/src/components/FileBrowser/__tests__/ConfirmDeleteDialog.test.tsx b/frontend/src/components/FileBrowser/__tests__/ConfirmDeleteDialog.test.tsx index 978d3549..04799fc6 100644 --- a/frontend/src/components/FileBrowser/__tests__/ConfirmDeleteDialog.test.tsx +++ b/frontend/src/components/FileBrowser/__tests__/ConfirmDeleteDialog.test.tsx @@ -30,9 +30,8 @@ describe("ConfirmDeleteDialog", () => { expect(screen.getByText(CONFIRM_DELETE_STRINGS.TITLE_FILE)).toBeInTheDocument(); expect(screen.getByText(CONFIRM_DELETE_STRINGS.CONFIRM_FILE)).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "Item to delete" })).toHaveValue("readme.txt"); - expect(screen.getByRole("textbox", { name: "Item to delete" })).toHaveAttribute("readonly"); - expect(screen.queryByLabelText(/file to delete/i)).not.toBeInTheDocument(); + expect(screen.getByText("Item to delete")).toBeInTheDocument(); + expect(screen.getByLabelText("readme.txt")).toHaveTextContent("readme.txt"); }); it("renders directory title and prompt when itemType is DIRECTORY", () => { @@ -42,14 +41,32 @@ describe("ConfirmDeleteDialog", () => { expect(screen.getByText(CONFIRM_DELETE_STRINGS.CONFIRM_DIRECTORY)).toBeInTheDocument(); }); - it("lists every selected item in a read-only multiline field", () => { - render(); + it("lists selected items as independent one-line values", () => { + render( + + ); expect(screen.getByText(CONFIRM_DELETE_STRINGS.TITLE_MULTI)).toBeInTheDocument(); expect(screen.getByText(CONFIRM_DELETE_STRINGS.CONFIRM_MULTI(2))).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "Items to delete" })).toHaveValue("readme.txt\nnotes.md"); - expect(screen.getByRole("textbox", { name: "Items to delete" })).toHaveAttribute("readonly"); - expect(screen.getByRole("textbox", { name: "Items to delete" })).not.toHaveAttribute("wrap"); + expect(screen.getByText("Items to delete")).toBeInTheDocument(); + expect(screen.getByLabelText("readme.txt")).toHaveTextContent("readme.txt"); + expect(screen.getByLabelText("notes.md")).toHaveTextContent("notes.md"); + }); + + it("limits the preview to six items and identifies the remainder", () => { + const items = Array.from({ length: 8 }, (_, index) => ({ + ...defaultProps.items[0]!, + name: `item-${index}.txt`, + path: `item-${index}.txt`, + })); + render(); + + expect(screen.getByLabelText("item-5.txt")).toBeInTheDocument(); + expect(screen.queryByLabelText("item-6.txt")).not.toBeInTheDocument(); + expect(screen.getByText("and 2 more items")).toBeInTheDocument(); }); it("calls onClose when Cancel is clicked", async () => { diff --git a/frontend/src/components/FileBrowser/__tests__/CopyMoveDialog.test.tsx b/frontend/src/components/FileBrowser/__tests__/CopyMoveDialog.test.tsx index f5f435f6..18cd88a3 100644 --- a/frontend/src/components/FileBrowser/__tests__/CopyMoveDialog.test.tsx +++ b/frontend/src/components/FileBrowser/__tests__/CopyMoveDialog.test.tsx @@ -15,9 +15,10 @@ * - Error message displayed when present */ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; +import i18n from "../../../i18n"; import type { FileEntry } from "../../../types"; import { FileType } from "../../../types"; import CopyMoveDialog from "../CopyMoveDialog"; @@ -74,46 +75,66 @@ describe("CopyMoveDialog", () => { await waitFor(() => expect(screen.getByRole("button", { name: "Close" })).toHaveFocus()); }); - it("shows single-item copy prompt with destination", () => { + it("shows single-item copy context with source and destination on separate lines", () => { const props = { ...defaultProps, files: [createFile("readme.txt")] }; render(); - const itemName = screen.getByTestId("copy-move-prompt-item-name"); - const destination = screen.getByTestId("copy-move-prompt-destination"); + const itemName = screen.getByLabelText("readme.txt"); + const destination = screen.getByLabelText("My Server:/backup"); expect(itemName).toHaveTextContent("readme.txt"); - expect(itemName.tagName).toBe("CODE"); expect(destination).toHaveTextContent("My Server:/backup"); - expect(destination.tagName).toBe("CODE"); - expect(itemName.parentElement).toHaveTextContent("readme.txt will be copied to My Server:/backup:"); - expect(itemName.parentElement).not.toHaveTextContent('"readme.txt"'); - expect(screen.queryByLabelText(S.LABEL_DESTINATION)).not.toBeInTheDocument(); + expect(screen.getByText("Source item")).toBeInTheDocument(); + expect(screen.getByText("Destination directory")).toBeInTheDocument(); expect(screen.queryByTestId("LockOutlinedIcon")).not.toBeInTheDocument(); expect(screen.getByLabelText(S.LABEL_FILENAME)).toHaveValue("readme.txt"); }); it("shows multi-item copy prompt with destination", () => { render(); - expect(screen.getByText(S.PROMPT_COPY_MULTI(2))).toBeInTheDocument(); - const destination = screen.getByLabelText(S.LABEL_DESTINATION); - expect(destination).toHaveValue("My Server:/backup"); - expect(screen.queryByText(S.LABEL_DESTINATION)).not.toBeInTheDocument(); - expect(destination).not.toHaveAttribute("wrap"); + expect(screen.getByText(S.DESCRIPTION_COPY_MULTI(2))).toBeInTheDocument(); + expect(screen.getByLabelText("My Server:/backup")).toHaveTextContent("My Server:/backup"); + expect(screen.getByText("Destination directory")).toBeInTheDocument(); expect(screen.queryByRole("radio")).not.toBeInTheDocument(); }); it("shows move single-item prompt with destination", () => { const props = { ...defaultProps, mode: "move" as const, files: [createFile("readme.txt")] }; render(); - expect(screen.getByTestId("copy-move-prompt-item-name").parentElement).toHaveTextContent( - "readme.txt will be moved to My Server:/backup:" + expect(screen.getByText("Move this item to the destination directory.")).toBeInTheDocument(); + expect(screen.getByLabelText("readme.txt")).toHaveTextContent("readme.txt"); + expect(screen.getByLabelText("My Server:/backup")).toHaveTextContent("My Server:/backup"); + }); + + it("keeps mixed-direction identifiers outside a reordered translated outcome", async () => { + i18n.addResourceBundle( + "en-XA", + "translation", + { + fileBrowser: { + copyMove: { descriptionCopySingle: "انسخ العنصر إلى دليل الوجهة." }, + operationContext: { sourceItem: "عنصر المصدر", destinationDirectory: "دليل الوجهة" }, + }, + }, + true, + true ); - expect(screen.getByTestId("copy-move-prompt-destination")).toHaveTextContent("My Server:/backup"); - expect(screen.queryByLabelText(S.LABEL_DESTINATION)).not.toBeInTheDocument(); + await i18n.changeLanguage("en-XA"); + + try { + const props = { ...defaultProps, files: [createFile("تقارير/annual-report.pdf")] }; + render(); + + expect(screen.getByText("انسخ العنصر إلى دليل الوجهة.")).toBeInTheDocument(); + expect(screen.getByText("عنصر المصدر")).toBeInTheDocument(); + expect(screen.getByLabelText("تقارير/annual-report.pdf")).toHaveAttribute("dir", "auto"); + } finally { + await i18n.changeLanguage("en"); + } }); it("shows the provider-provided root destination label", () => { render(); - expect(screen.getByText(S.PROMPT_COPY_MULTI(2))).toBeInTheDocument(); - expect(screen.getByLabelText(S.LABEL_DESTINATION)).toHaveValue("My Server:/"); + expect(screen.getByText(S.DESCRIPTION_COPY_MULTI(2))).toBeInTheDocument(); + expect(screen.getByLabelText("My Server:/")).toHaveTextContent("My Server:/"); }); it("does not show editable destination path field for multi-item", () => { @@ -205,6 +226,37 @@ describe("CopyMoveDialog", () => { expect(copyingBtn).toBeDisabled(); }); + it("moves focus to Cancel during processing", async () => { + render(); + + await waitFor(() => expect(screen.getByRole("button", { name: S.BUTTON_CANCEL })).toHaveFocus()); + }); + + it("requests cancellation with Escape during processing", () => { + const onCancel = vi.fn(); + render(); + + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("reserves one notice region when no form-level notice is active", () => { + render(); + + const region = screen.getByTestId("copy-move-notice-region"); + expect(region.querySelectorAll(".MuiAlert-root")).toHaveLength(1); + expect(screen.queryByTestId("copy-move-notice")).not.toBeInTheDocument(); + expect(screen.queryByTestId("copy-move-warning")).not.toBeInTheDocument(); + }); + + it("retains simultaneous terminal errors and warnings in one notice region", () => { + render(); + + expect(screen.getByText("Copy failed")).toBeInTheDocument(); + expect(screen.getByText("Source cleanup failed")).toBeInTheDocument(); + }); + it("shows progress bar during processing", () => { render(); expect(screen.getByText(S.PROGRESS_COPY(1, 3))).toBeInTheDocument(); @@ -291,6 +343,7 @@ describe("CopyMoveDialog", () => { await user.type(fileNameInput, "readme.txt"); expect(screen.getByText(S.ERROR_SAME_FILENAME)).toBeInTheDocument(); + expect(screen.getByText(S.ERROR_SAME_FILENAME)).toHaveStyle({ overflow: "hidden", whiteSpace: "nowrap" }); expect(fileNameInput).toHaveAttribute("aria-invalid", "true"); expect(screen.getByRole("button", { name: S.BUTTON_COPY })).toBeDisabled(); }); @@ -328,7 +381,7 @@ describe("CopyMoveDialog", () => { render(); expect(screen.getByRole("button", { name: S.BUTTON_COPY })).toBeEnabled(); - expect(screen.getByTestId("copy-move-destination-error")).not.toBeVisible(); + expect(screen.queryByTestId("copy-move-destination-error")).not.toBeInTheDocument(); }); it("shows error message when error prop is set", () => { diff --git a/frontend/src/components/FileBrowser/__tests__/CreateItemDialog.test.tsx b/frontend/src/components/FileBrowser/__tests__/CreateItemDialog.test.tsx index 907a2f40..e020d461 100644 --- a/frontend/src/components/FileBrowser/__tests__/CreateItemDialog.test.tsx +++ b/frontend/src/components/FileBrowser/__tests__/CreateItemDialog.test.tsx @@ -38,18 +38,16 @@ describe("CreateItemDialog", () => { it("renders with directory title when itemType is DIRECTORY", () => { render(); expect(screen.getByText(CREATE_ITEM_DIALOG_STRINGS.TITLE_DIRECTORY)).toBeInTheDocument(); - const directory = screen.getByTestId("create-item-prompt-directory"); + const directory = screen.getByLabelText("My Server:/documents"); expect(directory).toHaveTextContent("My Server:/documents"); - expect(directory.tagName).toBe("CODE"); - expect(directory.parentElement).toHaveTextContent("A new directory will be created in My Server:/documents:"); + expect(screen.getByText("Destination directory")).toBeInTheDocument(); + expect(screen.getByText("Create the new directory in the destination directory.")).toBeInTheDocument(); }); it("renders with file title when itemType is FILE", () => { render(); expect(screen.getByText(CREATE_ITEM_DIALOG_STRINGS.TITLE_FILE)).toBeInTheDocument(); - expect(screen.getByTestId("create-item-prompt-directory").parentElement).toHaveTextContent( - "A new file will be created in My Server:/documents:" - ); + expect(screen.getByText("Create the new file in the destination directory.")).toBeInTheDocument(); }); it("renders with empty input field", async () => { diff --git a/frontend/src/components/FileBrowser/__tests__/DialogOperationContext.test.tsx b/frontend/src/components/FileBrowser/__tests__/DialogOperationContext.test.tsx new file mode 100644 index 00000000..ad29491f --- /dev/null +++ b/frontend/src/components/FileBrowser/__tests__/DialogOperationContext.test.tsx @@ -0,0 +1,94 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DialogOperationContext } from "../DialogOperationContext"; + +class ResizeObserverMock { + private static callback: ResizeObserverCallback | null = null; + private static width = 12; + + constructor(private readonly callback: ResizeObserverCallback) {} + + observe(target: Element) { + ResizeObserverMock.callback = this.callback; + vi.spyOn(target, "getBoundingClientRect").mockImplementation(() => ({ width: ResizeObserverMock.width }) as DOMRect); + this.callback([], this as unknown as ResizeObserver); + } + + static resize(width: number) { + ResizeObserverMock.width = width; + if (ResizeObserverMock.callback) { + ResizeObserverMock.callback([], {} as ResizeObserver); + } + } + + static reset() { + ResizeObserverMock.callback = null; + ResizeObserverMock.width = 12; + } + + disconnect() {} + + unobserve() {} +} + +describe("DialogOperationContext", () => { + afterEach(() => { + ResizeObserverMock.reset(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("renders labelled values as vertical definition-list entries", () => { + vi.stubGlobal("ResizeObserver", ResizeObserverMock); + render( + + ); + + expect(screen.getByText("Source item").tagName).toBe("DT"); + expect(screen.getByText("Source item").parentElement?.querySelector("dd")).not.toBeNull(); + expect(screen.getByText("Destination directory").tagName).toBe("DT"); + }); + + it("retains the complete accessible value and exposes a shortened value on hover only", async () => { + vi.stubGlobal("ResizeObserver", ResizeObserverMock); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ + measureText: (text: string) => ({ width: text.length }), + } as CanvasRenderingContext2D); + const user = userEvent.setup(); + const value = "annual-report-final.pdf"; + render(); + + const identifier = screen.getByTestId("source"); + expect(identifier).toHaveAccessibleName(value); + expect(identifier).toHaveAttribute("dir", "auto"); + + await user.hover(identifier); + expect(await screen.findByRole("tooltip")).toHaveTextContent(value); + await user.unhover(identifier); + await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument()); + + fireEvent.focus(identifier); + fireEvent.touchStart(identifier); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("recalculates the displayed value when the available width changes", async () => { + vi.stubGlobal("ResizeObserver", ResizeObserverMock); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ + measureText: (text: string) => ({ width: text.length }), + } as CanvasRenderingContext2D); + const value = "annual-report-final.pdf"; + render(); + + expect(screen.getByTestId("source")).not.toHaveTextContent(value); + + ResizeObserverMock.resize(100); + await waitFor(() => expect(screen.getByTestId("source")).toHaveTextContent(value)); + }); +}); diff --git a/frontend/src/components/FileBrowser/__tests__/NameInputDialog.test.tsx b/frontend/src/components/FileBrowser/__tests__/NameInputDialog.test.tsx index fa425fec..d3683f90 100644 --- a/frontend/src/components/FileBrowser/__tests__/NameInputDialog.test.tsx +++ b/frontend/src/components/FileBrowser/__tests__/NameInputDialog.test.tsx @@ -56,6 +56,17 @@ describe("NameInputDialog", () => { expect(screen.getByLabelText("Name").closest(".MuiFormControl-root")?.querySelector(".MuiFormHelperText-root")).toBeInTheDocument(); }); + it("keeps a validation message to one line and exposes its full text on hover", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Submit" })); + const feedback = screen.getByText(NAME_DIALOG_STRINGS.VALIDATION_EMPTY); + expect(feedback).toHaveStyle({ overflow: "hidden", whiteSpace: "nowrap" }); + await user.hover(feedback); + expect(await screen.findByRole("tooltip")).toHaveTextContent(NAME_DIALOG_STRINGS.VALIDATION_EMPTY); + }); + it("reserves a hidden API error alert below the input", () => { render(); @@ -97,6 +108,12 @@ describe("NameInputDialog", () => { expect(onCancelSubmitting).toHaveBeenCalledOnce(); }); + it("focuses the active-operation cancellation action while submitting", async () => { + render(); + + await waitFor(() => expect(screen.getByRole("button", { name: "Cancel archive creation" })).toHaveFocus()); + }); + it("replaces the form with active-operation content while submitting", () => { render( { expect(screen.getByRole("heading", { name: S.TITLE })).toBeInTheDocument(); expect(screen.getByText(S.ALREADY_EXISTS)).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: S.LABEL_TARGET_NAME })).toHaveValue("report.txt"); + expect(screen.queryByRole("textbox", { name: S.LABEL_TARGET_NAME })).not.toBeInTheDocument(); const targetDetails = screen.getByTestId("overwrite-conflict-target-details"); const sourceDetails = screen.getByTestId("overwrite-conflict-source-details"); expect(within(targetDetails).getByRole("heading", { name: S.LABEL_EXISTING })).toBeInTheDocument(); expect(within(sourceDetails).getByRole("heading", { name: S.LABEL_INCOMING })).toBeInTheDocument(); - expect(screen.getByTestId("overwrite-conflict-target-path")).toHaveTextContent("target"); + expect(screen.getByTestId("overwrite-conflict-target-path")).toHaveTextContent("target/report.txt"); const sourcePath = screen.getByTestId("overwrite-conflict-source-path"); - expect(sourcePath).toHaveTextContent("source"); - expect(within(sourcePath).getByText("source").tagName).toBe("SPAN"); + expect(sourcePath).toHaveTextContent("source/report.txt"); + expect(within(sourcePath).getByText("source/report.txt").closest("bdi")).not.toBeNull(); const metadata = screen.getByRole("region", { name: S.METADATA_LABEL }); expect(within(metadata).getAllByText(S.LABEL_PATH)).toHaveLength(2); expect(within(metadata).getAllByText(S.LABEL_MODIFIED)).toHaveLength(2); @@ -73,7 +73,7 @@ describe("OverwriteConflictDialog", () => { expect(screen.getByTestId("overwrite-conflict-source-path")).toHaveTextContent("Source connection:/archive.zip/source"); }); - it("preserves long target and path values without creating additional fields", () => { + it("preserves long target and path values without creating an input until Rename", async () => { const sourceName = `source-${"a".repeat(240)}.txt`; const targetName = `target-${"b".repeat(240)}.txt`; const targetDirectory = Array.from({ length: 20 }, (_, index) => `directory-${index}`).join("/"); @@ -82,20 +82,20 @@ describe("OverwriteConflictDialog", () => { existing_file: { ...conflict.existing_file, name: targetName, path: `${targetDirectory}/${targetName}` }, }; + const user = userEvent.setup(); render(); - const targetInput = screen.getByRole("textbox", { name: S.LABEL_TARGET_NAME }) as HTMLInputElement; const sourcePath = screen.getByTestId("overwrite-conflict-source-path"); const targetDirectoryCode = screen.getByTestId("overwrite-conflict-target-path-value"); - expect(targetInput).toHaveValue(targetName); - expect(sourcePath).toHaveTextContent("source"); - expect(within(sourcePath).getByText("source")).toHaveStyle({ overflow: "hidden", whiteSpace: "nowrap" }); - expect(targetDirectoryCode).toHaveTextContent(targetDirectory); - expect(targetDirectoryCode).toHaveStyle({ whiteSpace: "nowrap" }); - expect(targetInput).toHaveAttribute("readonly"); - targetInput.setSelectionRange(0, targetInput.value.length); - expect(targetInput.selectionEnd).toBe(targetInput.value.length); + expect(screen.queryByRole("textbox", { name: S.LABEL_TARGET_NAME })).not.toBeInTheDocument(); + expect(sourcePath).toHaveTextContent(`source/${sourceName}`); + expect(targetDirectoryCode).toHaveTextContent(`${targetDirectory}/${targetName}`); + expect(targetDirectoryCode.querySelector("code")).toHaveStyle({ overflow: "hidden", whiteSpace: "nowrap" }); + await user.click(screen.getByRole("radio", { name: S.BUTTON_RENAME })); + const targetInput = screen.getByRole("textbox", { name: S.LABEL_TARGET_NAME }) as HTMLInputElement; + expect(targetInput).toHaveValue(`${targetName.slice(0, -4)} (copy).txt`); + expect(targetInput).not.toHaveAttribute("readonly"); }); it("uses a compact grid for desktop resolution choices", () => { @@ -196,19 +196,22 @@ describe("OverwriteConflictDialog", () => { expect(checkbox).not.toBeChecked(); }); - it("edits the existing Target name field in place for Rename", async () => { + it("shows the Target name field below resolution controls for Rename", async () => { const onResolve = vi.fn(); const user = userEvent.setup(); render(); - const targetName = screen.getByRole("textbox", { name: S.LABEL_TARGET_NAME }); - expect(targetName.closest(".MuiFormControl-root")?.querySelector(".MuiFormHelperText-root")).toBeInTheDocument(); + expect(screen.queryByRole("textbox", { name: S.LABEL_TARGET_NAME })).not.toBeInTheDocument(); await user.click(screen.getByRole("radio", { name: S.BUTTON_RENAME })); + const targetName = screen.getByRole("textbox", { name: S.LABEL_TARGET_NAME }); expect(targetName).toHaveValue("report (copy).txt"); expect(targetName).not.toHaveAttribute("readonly"); expect(targetName.closest(".MuiFormControl-root")?.querySelector(".MuiFormHelperText-root")).toBeInTheDocument(); + expect( + screen.getByRole("radiogroup").compareDocumentPosition(targetName.closest(".MuiFormControl-root")!) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); await user.clear(targetName); await user.type(targetName, "renamed.txt"); await user.click(screen.getByRole("button", { name: S.BUTTON_CONTINUE })); diff --git a/frontend/src/components/FileBrowser/__tests__/RenameDialog.test.tsx b/frontend/src/components/FileBrowser/__tests__/RenameDialog.test.tsx index 978d2510..cc520505 100644 --- a/frontend/src/components/FileBrowser/__tests__/RenameDialog.test.tsx +++ b/frontend/src/components/FileBrowser/__tests__/RenameDialog.test.tsx @@ -33,10 +33,7 @@ describe("RenameDialog", () => { render(); expect(screen.getByText(RENAME_DIALOG_STRINGS.TITLE_FILE)).toBeInTheDocument(); - const itemName = screen.getByTestId("rename-prompt-item-name"); - expect(itemName).toHaveTextContent("readme.txt"); - expect(itemName.tagName).toBe("CODE"); - expect(itemName.parentElement).toHaveTextContent("readme.txt will be renamed to:"); + expect(screen.getByText("Choose a new name for this item.")).toBeInTheDocument(); await waitFor(() => { const input = screen.getByLabelText(RENAME_DIALOG_STRINGS.INPUT_LABEL); diff --git a/frontend/src/components/FileBrowser/copyMoveDialogStrings.ts b/frontend/src/components/FileBrowser/copyMoveDialogStrings.ts index 8e3bf660..001eb29a 100644 --- a/frontend/src/components/FileBrowser/copyMoveDialogStrings.ts +++ b/frontend/src/components/FileBrowser/copyMoveDialogStrings.ts @@ -18,12 +18,12 @@ export const COPY_MOVE_STRINGS = { return translate("common.actions.move"); }, - // Prompts (action text — destination is shown separately) - PROMPT_COPY_MULTI(count: number) { - return translate("fileBrowser.copyMove.promptCopyMulti", { count }); + // Outcome descriptions (destination is shown separately) + DESCRIPTION_COPY_MULTI(count: number) { + return translate("fileBrowser.copyMove.descriptionCopyMultiple", { count }); }, - PROMPT_MOVE_MULTI(count: number) { - return translate("fileBrowser.copyMove.promptMoveMulti", { count }); + DESCRIPTION_MOVE_MULTI(count: number) { + return translate("fileBrowser.copyMove.descriptionMoveMultiple", { count }); }, // Labels diff --git a/frontend/src/components/Settings/SettingsFormLayout.tsx b/frontend/src/components/Settings/SettingsFormLayout.tsx index ad7d0299..ef2a9423 100644 --- a/frontend/src/components/Settings/SettingsFormLayout.tsx +++ b/frontend/src/components/Settings/SettingsFormLayout.tsx @@ -1,5 +1,5 @@ -import { alpha, Box, FormLabel, type SxProps, type Theme, Typography } from "@mui/material"; -import type { ReactNode } from "react"; +import { Alert, alpha, Box, FormLabel, type SxProps, type Theme, Tooltip, Typography } from "@mui/material"; +import { forwardRef, type ReactNode } from "react"; import { DIALOG_FORM_SURFACE_CSS_VARIABLE, DIALOG_SURFACE_CSS_VARIABLE, getModeAdjustedSurfaceColor } from "../../theme/palette"; import { SettingsGroup } from "./SettingsGroup"; @@ -23,6 +23,40 @@ interface SettingsFormSectionProps { title: ReactNode; } +interface DialogFormNoticeProps { + message: string | null | undefined; + severity?: "error" | "warning"; + testId?: string; +} + +interface DialogFormNoticeRegionProps { + notices: readonly DialogFormNoticeProps[]; + testId?: string; +} + +interface DialogFieldFeedbackProps { + message: string | null | undefined; +} + +const dialogFeedbackMessageSx = { + display: "block", + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}; + +export const dialogFormHelperTextSx = { + "&.MuiFormHelperText-root": { + height: "1.25rem", + lineHeight: "1.25rem", + mb: 0, + mt: 0.5, + overflow: "hidden", + whiteSpace: "nowrap", + }, +}; + export const settingsFormFieldControlSx = { display: "flex", justifyContent: { md: "flex-end" }, @@ -171,3 +205,70 @@ export function SettingsFormSection({ title }: SettingsFormSectionProps) { /> ); } + +export function DialogFieldFeedback({ message }: DialogFieldFeedbackProps) { + if (!message) { + return ; + } + + return ( + + + {message} + + + ); +} + +export const DialogFormNotice = forwardRef(function DialogFormNotice( + { message, severity = "error", testId }, + ref +) { + const isVisible = Boolean(message); + const notice = ( + + {message ?? " "} + + ); + + return isVisible ? ( + + {notice} + + ) : ( + notice + ); +}); + +/** Reserves one notice line until multiple visible notices genuinely need more room. */ +export function DialogFormNoticeRegion({ notices, testId }: DialogFormNoticeRegionProps) { + const visibleNotices = notices.filter((notice) => Boolean(notice.message)); + + if (visibleNotices.length === 0) { + return ( + + + + ); + } + + return ( + + {visibleNotices.map((notice) => ( + + ))} + + ); +} diff --git a/frontend/src/i18n/resources.ts b/frontend/src/i18n/resources.ts index e9ca3c5f..4cc16bff 100644 --- a/frontend/src/i18n/resources.ts +++ b/frontend/src/i18n/resources.ts @@ -972,13 +972,22 @@ export const EN_TRANSLATIONS = { confirmMulti: "The following {{count}} items will be permanently deleted:", ariaLabelItem: "Item to delete", ariaLabelItems: "Items to delete", + moreItems_one: "and 1 more item", + moreItems_other: "and {{count}} more items", buttonDeleting: "Deleting…", }, + operationContext: { + sourceItem: "Source item", + destinationDirectory: "Destination directory", + archive: "Archive", + archiveMember: "Archive member", + selectedItems: "Selected items", + }, rename: { titleFile: "Rename file", titleDirectory: "Rename directory", inputLabel: "New name", - prompt: "{{name}} will be renamed to:", + description: "Choose a new name for this item.", validationSame: "The name is unchanged.", buttonRenaming: "Renaming…", }, @@ -986,15 +995,18 @@ export const EN_TRANSLATIONS = { titleFile: "New file", titleDirectory: "New directory", inputLabel: "Name", - promptFile: "A new file will be created in {{directory}}:", - promptDirectory: "A new directory will be created in {{directory}}:", + descriptionFile: "Create the new file in the destination directory.", + descriptionDirectory: "Create the new directory in the destination directory.", buttonCreating: "Creating…", }, archive: { createTitle: "Create ZIP Archive", nameLabel: "Archive name", - createPrompt_one: "Create a ZIP archive in {{directory}} from 1 selected item.", - createPrompt_other: "Create a ZIP archive in {{directory}} from {{count}} selected items.", + createDescription_one: "Create a ZIP archive from 1 selected item in the destination directory.", + createDescription_other: "Create a ZIP archive from {{count}} selected items in the destination directory.", + progressCreating: "Creating archive...", + progressPreparing: "Preparing archive...", + progressProcessedMembers: "Processed {{processed}} archive items...", buttonCreate: "Create archive", buttonCreating: "Creating archive…", validationExtension: "Archive names must end with .zip.", @@ -1023,13 +1035,14 @@ export const EN_TRANSLATIONS = { more: "Load more", entryUnavailable: "Unavailable", extractTitle: "Extract from ZIP Archive", - extractPrompt: "{{archive}} will be extracted to:", - extractMemberPrompt: "{{member}} will be extracted to:", - extractMembersPrompt_one: "1 selected item will be extracted to:", - extractMembersPrompt_other: "{{count}} selected items will be extracted to:", + extractDescriptionArchive: "Extract the archive to the destination directory.", + extractDescriptionMember: "Extract the selected item to the destination directory.", + extractDescriptionMembers_one: "Extract 1 selected item to the destination directory.", + extractDescriptionMembers_other: "Extract {{count}} selected items to the destination directory.", memberErrorTitle: "Resolve extraction issue", memberErrorPrompt: "An archive member could not be extracted.", destinationLabel: "Destination directory", + destinationNameLabel: "Destination name", buttonExtract: "Extract", buttonExtracting: "Extracting…", buttonCancelExtraction: "Cancel extraction", @@ -1088,12 +1101,12 @@ export const EN_TRANSLATIONS = { validationTrailing: "Names cannot end in a space or period.", }, copyMove: { - promptCopySingle: "{{name}} will be copied to {{destination}}:", - promptCopyMulti_one: "This item will be copied to:", - promptCopyMulti_other: "These {{count}} items will be copied to:", - promptMoveSingle: "{{name}} will be moved to {{destination}}:", - promptMoveMulti_one: "This item will be moved to:", - promptMoveMulti_other: "These {{count}} items will be moved to:", + descriptionCopySingle: "Copy this item to the destination directory.", + descriptionCopyMultiple_one: "Copy this item to the destination directory.", + descriptionCopyMultiple_other: "Copy {{count}} selected items to the destination directory.", + descriptionMoveSingle: "Move this item to the destination directory.", + descriptionMoveMultiple_one: "Move this item to the destination directory.", + descriptionMoveMultiple_other: "Move {{count}} selected items to the destination directory.", labelDestination: "Directory", labelFilename: "New name", errorSameFilename: "There is already an item in the destination folder with this name.", diff --git a/frontend/src/pages/FileBrowser.tsx b/frontend/src/pages/FileBrowser.tsx index ca1ae52a..b5b38553 100644 --- a/frontend/src/pages/FileBrowser.tsx +++ b/frontend/src/pages/FileBrowser.tsx @@ -21,16 +21,16 @@ import { AppBar, Box, Container, Divider, Snackbar, Toolbar, Typography, useMediaQuery, useTheme } from "@mui/material"; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState, useSyncExternalStore } from "react"; -import { Trans, useTranslation } from "react-i18next"; +import { useTranslation } from "react-i18next"; import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { ArchiveExtractDialog, type ArchiveExtractionScope } from "../components/FileBrowser/ArchiveExtractDialog"; import { ArchiveOperationProgress } from "../components/FileBrowser/ArchiveOperationProgress"; import CopyMoveDialog, { type CopyMoveMode } from "../components/FileBrowser/CopyMoveDialog"; import { DesktopToolbar } from "../components/FileBrowser/DesktopToolbar"; +import { DialogOperationContext } from "../components/FileBrowser/DialogOperationContext"; import { DynamicViewer } from "../components/FileBrowser/DynamicViewer"; import type { CompanionLifecycleStatus } from "../components/FileBrowser/FileBrowserAlerts"; import { FileBrowserAlerts } from "../components/FileBrowser/FileBrowserAlerts"; -import { InlineItemName } from "../components/FileBrowser/InlineItemName"; import { MobileToolbar } from "../components/FileBrowser/MobileToolbar"; import NameInputDialog from "../components/FileBrowser/NameInputDialog"; import { @@ -3290,14 +3290,29 @@ const Browser: React.FC = () => { title={t("fileBrowser.archive.createTitle")} description={ - }} - /> + {t("fileBrowser.archive.createDescription", { count: archiveCreateContext?.sources.length ?? 0 })} } + operationContext={ + + } inputLabel={t("fileBrowser.archive.nameLabel")} initialValue="archive.zip" submitLabel={t("fileBrowser.archive.buttonCreate")} @@ -3316,7 +3331,7 @@ const Browser: React.FC = () => { apiError={archiveCreateError} extraValidate={(name) => (name.toLowerCase().endsWith(".zip") ? null : t("fileBrowser.archive.validationExtension"))} autoSelectRange={[0, "archive".length]} - submittingContent={} + submittingContent={} /> { filesExtracted: 2, directoriesCreated: 1, extractedBytes: 10, + membersProcessed: 4, filesSkipped: 1, filesReplaced: 0, partialMembers: 0, @@ -288,6 +289,7 @@ describe("content providers", () => { filesExtracted: 1, directoriesCreated: 1, extractedBytes: 5, + membersProcessed: 2, filesSkipped: 0, filesReplaced: 0, partialMembers: 0, diff --git a/frontend/src/pages/FileBrowser/contentProviders.ts b/frontend/src/pages/FileBrowser/contentProviders.ts index 085a1bbb..159c6147 100644 --- a/frontend/src/pages/FileBrowser/contentProviders.ts +++ b/frontend/src/pages/FileBrowser/contentProviders.ts @@ -115,6 +115,7 @@ export interface ArchiveExtractionSummary { filesExtracted: number; directoriesCreated: number; extractedBytes: number; + membersProcessed: number; totalMembers?: number; totalBytes?: number; filesSkipped: number; diff --git a/frontend/src/pages/__tests__/FileBrowser-interactions.test.tsx b/frontend/src/pages/__tests__/FileBrowser-interactions.test.tsx index a6e7c6f9..4414fbaf 100644 --- a/frontend/src/pages/__tests__/FileBrowser-interactions.test.tsx +++ b/frontend/src/pages/__tests__/FileBrowser-interactions.test.tsx @@ -325,10 +325,10 @@ describe("Browser Component - Interactions", () => { fireEvent(document, event); expect(event.defaultPrevented).toBe(true); - const directory = await screen.findByTestId("archive-create-prompt-directory"); + const directory = await screen.findByLabelText("Test Server 1:/"); expect(directory).toHaveTextContent("Test Server 1:/"); - expect(directory.tagName).toBe("CODE"); - expect(directory.parentElement).toHaveTextContent("Create a ZIP archive in Test Server 1:/ from 1 selected item."); + expect(screen.getByText("Destination directory")).toBeInTheDocument(); + expect(screen.getByText("Create a ZIP archive from 1 selected item in the destination directory.")).toBeInTheDocument(); fireEvent.keyDown(document, { key: "Tab" }); expect(localStorage.getItem("active-pane")).toBe("right"); @@ -607,8 +607,8 @@ describe("Browser Component - Interactions", () => { expect(targetName).toHaveValue("alternate.txt"); await user.click(screen.getByRole("button", { name: "Continue" })); - expect(await screen.findByRole("textbox", { name: "Target name" })).toHaveValue("alternate.txt"); - await user.click(screen.getByRole("radio", { name: "Rename" })); + await user.click(await screen.findByRole("radio", { name: "Rename" })); + expect(await screen.findByRole("textbox", { name: "Target name" })).toHaveValue("alternate (copy).txt"); await user.click(screen.getByRole("button", { name: "Continue" })); await waitFor(() => expect(api.copyItem).toHaveBeenCalledTimes(3)); @@ -1067,7 +1067,8 @@ describe("Browser Component - Interactions", () => { fireEvent.keyDown(document, { key: "F9", altKey: true }); const extractDialog = await screen.findByRole("dialog", { name: "Extract from ZIP Archive" }); - expect(within(extractDialog).getByLabelText("Destination directory")).toHaveValue("Test Server 2:/"); + expect(within(extractDialog).getByText("Destination directory")).toBeInTheDocument(); + expect(within(extractDialog).getByLabelText("Test Server 2:/")).toHaveTextContent("Test Server 2:/"); const locationBeforeTab = screen.getByTestId("router-location").textContent; fireEvent.keyDown(document, { key: "Tab" }); expect(screen.getByTestId("router-location")).toHaveTextContent(locationBeforeTab ?? ""); @@ -1117,10 +1118,9 @@ describe("Browser Component - Interactions", () => { fireEvent.keyDown(document, { key: "F5" }); const extractDialog = await screen.findByRole("dialog", { name: "Extract from ZIP Archive" }); - expect(within(extractDialog).getByTestId("archive-extract-prompt-name")).toHaveTextContent("inside.txt"); - const destination = within(extractDialog).getByLabelText("Destination directory"); - expect(destination).toHaveValue("Test Server 2:/"); - expect(destination).toHaveAttribute("readonly"); + expect(within(extractDialog).getByLabelText("inside.txt")).toHaveTextContent("inside.txt"); + expect(within(extractDialog).getByText("Destination directory")).toBeInTheDocument(); + expect(within(extractDialog).getByLabelText("Test Server 2:/")).toHaveTextContent("Test Server 2:/"); await user.click(within(extractDialog).getByRole("button", { name: "Extract" })); await waitFor(() => { diff --git a/frontend/src/utils/pathDisplay.test.ts b/frontend/src/utils/pathDisplay.test.ts index 6185e904..9deb0700 100644 --- a/frontend/src/utils/pathDisplay.test.ts +++ b/frontend/src/utils/pathDisplay.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { abbreviatePath } from "./pathDisplay"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { abbreviateFileName, abbreviatePath, truncateTextByGrapheme } from "./pathDisplay"; const measureCharacters = (text: string) => text.length; @@ -15,4 +15,36 @@ describe("abbreviatePath", () => { it("leaves a path unchanged when it fits", () => { expect(abbreviatePath("Demo:/Test/report.pdf", 100, measureCharacters)).toBe("Demo:/Test/report.pdf"); }); + + it("retains a terminal filename suffix when it cannot fit in full", () => { + expect(abbreviatePath("/Users/sambee/very-long-report.pdf", 14, measureCharacters)).toBe("...-report.pdf"); + }); +}); + +describe("abbreviateFileName", () => { + it("preserves the filename prefix and final extension", () => { + expect(abbreviateFileName("annual-report-final.pdf", 18, measureCharacters)).toBe("annual-repor...pdf"); + }); + + it("does not treat a dotfile as having an extension", () => { + expect(abbreviateFileName(".gitignore", 7, measureCharacters)).toBe(".git..."); + }); + + it("does not split a Unicode grapheme cluster", () => { + expect(abbreviateFileName("report-e\u0301-final.txt", 15, measureCharacters)).toBe("report-e\u0301...txt"); + }); + + it("does not split a ZWJ emoji sequence when Intl.Segmenter is unavailable", () => { + vi.stubGlobal("Intl", { ...Intl, Segmenter: undefined }); + + expect(abbreviateFileName("👩‍💻-annual-report.txt", 8, measureCharacters)).toBe("...txt"); + }); +}); + +describe("truncateTextByGrapheme", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("uses the shared grapheme-aware breadcrumb truncation", () => { + expect(truncateTextByGrapheme("report-e\u0301-final", 9)).toBe("report-e\u0301…"); + }); }); diff --git a/frontend/src/utils/pathDisplay.ts b/frontend/src/utils/pathDisplay.ts index 3df53692..5eb64a66 100644 --- a/frontend/src/utils/pathDisplay.ts +++ b/frontend/src/utils/pathDisplay.ts @@ -2,23 +2,85 @@ type TextMeasurer = (text: string) => number; const ELLIPSIS = "..."; +function splitGraphemes(value: string): string[] { + if (typeof Intl.Segmenter === "function") { + return Array.from(new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(value), ({ segment }) => segment); + } + + const graphemes: string[] = []; + let joinNext = false; + let regionalIndicatorCount = 0; + for (const character of Array.from(value)) { + const joinsPrevious = /\p{Mark}|[\uFE00-\uFE0F\u{1F3FB}-\u{1F3FF}]|\u200D/u.test(character) || joinNext; + const isRegionalIndicator = /\p{Regional_Indicator}/u.test(character); + if (joinsPrevious || (isRegionalIndicator && regionalIndicatorCount % 2 === 1)) { + graphemes[graphemes.length - 1] = `${graphemes.at(-1) ?? ""}${character}`; + } else { + graphemes.push(character); + } + joinNext = character === "\u200D"; + regionalIndicatorCount = isRegionalIndicator ? regionalIndicatorCount + 1 : 0; + } + return graphemes; +} + +/** Truncate text to a grapheme-safe character budget for breadcrumb labels. */ +export function truncateTextByGrapheme(value: string, maxGraphemes: number): string { + const graphemes = splitGraphemes(value); + if (graphemes.length <= maxGraphemes) return value; + if (maxGraphemes <= 1) return "…"; + return `${graphemes.slice(0, maxGraphemes - 1).join("")}…`; +} + function shortenTextFromStart(text: string, availableWidth: number, measureText: TextMeasurer): string { if (availableWidth <= 0 || measureText(text) <= availableWidth) { return text; } + const graphemes = splitGraphemes(text); let low = 0; - let high = text.length; + let high = graphemes.length; while (low < high) { const middle = Math.floor((low + high) / 2); - if (measureText(`${ELLIPSIS}${text.slice(middle)}`) <= availableWidth) { + if (measureText(`${ELLIPSIS}${graphemes.slice(middle).join("")}`) <= availableWidth) { high = middle; } else { low = middle + 1; } } - return `${ELLIPSIS}${text.slice(low)}`; + return `${ELLIPSIS}${graphemes.slice(low).join("")}`; +} + +/** Preserve the start and extension of a filename while abbreviating its middle. */ +export function abbreviateFileName(value: string, availableWidth: number, measureText: TextMeasurer): string { + if (availableWidth <= 0 || measureText(value) <= availableWidth) { + return value; + } + + const extensionIndex = value.lastIndexOf("."); + const extension = extensionIndex > 0 ? value.slice(extensionIndex) : ""; + const stem = extension ? value.slice(0, extensionIndex) : value; + const stemGraphemes = splitGraphemes(stem); + const preservedSuffix = extension.startsWith(".") ? extension.slice(1) : extension; + + if (measureText(`${ELLIPSIS}${preservedSuffix}`) > availableWidth) { + return shortenTextFromStart(`${ELLIPSIS}${preservedSuffix}`, availableWidth, measureText); + } + + let low = 0; + let high = stemGraphemes.length; + while (low < high) { + const prefixLength = Math.ceil((low + high + 1) / 2); + const candidate = `${stemGraphemes.slice(0, prefixLength).join("")}${ELLIPSIS}${preservedSuffix}`; + if (measureText(candidate) <= availableWidth) { + low = prefixLength; + } else { + high = prefixLength - 1; + } + } + + return `${stemGraphemes.slice(0, low).join("")}${ELLIPSIS}${preservedSuffix}`; } /** Preserve the connection/root prefix and basename while collapsing ancestor directories to fit. */ @@ -39,15 +101,22 @@ export function abbreviatePath(path: string, availableWidth: number, measureText const abbreviatedBasename = (includeAncestorIndicator: boolean) => { const text = includeAncestorIndicator ? `${ELLIPSIS}${basename}` : basename; if (!prefixedRoot || measureText(prefixedRoot) >= availableWidth) { - return shortenTextFromStart(text, availableWidth, measureText); + return includeAncestorIndicator + ? shortenTextFromStart(text, availableWidth, measureText) + : abbreviateFileName(text, availableWidth, measureText); } - return `${prefixedRoot}${shortenTextFromStart(text, availableWidth - measureText(prefixedRoot), measureText)}`; + const remainingWidth = availableWidth - measureText(prefixedRoot); + return `${prefixedRoot}${includeAncestorIndicator ? shortenTextFromStart(text, remainingWidth, measureText) : abbreviateFileName(text, remainingWidth, measureText)}`; }; - if (measureText(basename) > availableWidth || segments.length === 0) { + if (segments.length === 0) { return abbreviatedBasename(false); } + if (measureText(basename) > availableWidth) { + return abbreviatedBasename(true); + } + const prefix = `${root}${ELLIPSIS}${separator}`; let shortened = `${prefix}${basename}`; if (measureText(shortened) > availableWidth) { diff --git a/website-meta/docs-reports/docs-structure-report.html b/website-meta/docs-reports/docs-structure-report.html index b5b702b2..1d0f7055 100644 --- a/website-meta/docs-reports/docs-structure-report.html +++ b/website-meta/docs-reports/docs-structure-report.html @@ -268,7 +268,7 @@

Content diff

- + + +