diff --git a/.github/skills/propose-fixes/SKILL.md b/.github/skills/propose-fixes/SKILL.md new file mode 100644 index 00000000..fd6b2ef3 --- /dev/null +++ b/.github/skills/propose-fixes/SKILL.md @@ -0,0 +1,11 @@ +--- +name: propose-fixes +description: Guide for proposing fixes for review findings. +--- + +When asked to propose fixes for review findings: + +- Don't overengineer the fixes +- Consider the broader impact of the fixes +- Prefer simple and effective solutions +- Prefer low-risk and low-effort fixes diff --git a/.github/skills/review-code/SKILL.md b/.github/skills/review-code/SKILL.md new file mode 100644 index 00000000..27175a8b --- /dev/null +++ b/.github/skills/review-code/SKILL.md @@ -0,0 +1,37 @@ +--- +name: review-code +description: Guide for reviewing code changes, new implementations, or planning documents. Use this when asked to review. +--- + +When asked to review code, changes, new implementations, or planning documents: + +How the code should be, ideally: + +- Robust +- Maintainable +- Secure +- Performant +- Readable +- Well-structured +- Testable + +Multiple agents: + +- Use one agent to perform the actual review and another agent to challenge the findings of the first agent. + +Things to look for: + +- Code correctness and logic +- Code readability and maintainability +- Adherence to coding standards and best practices +- Potential performance issues +- Security vulnerabilities +- Proper error handling and logging +- Test coverage and quality +- Documentation and comments + +Things to avoid: + +- Focusing on minor issues at the expense of more significant problems +- Ignoring the broader context of the code changes +- Overengineering diff --git a/Dockerfile b/Dockerfile index 9aaf3346..80c9dab3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -139,6 +139,7 @@ COPY --chown=vscode:vscode archive-contract/ ./archive-contract/ COPY --chown=vscode:vscode companion/ ./companion/ COPY --chown=vscode:vscode backend/ ./backend/ COPY --chown=vscode:vscode archive_testdata/ ./archive_testdata/ +COPY --chown=vscode:vscode shared/ ./shared/ COPY --chown=vscode:vscode VERSION ./VERSION COPY --chown=vscode:vscode .github/ ./.github/ COPY --chown=vscode:vscode scripts/ ./scripts/ @@ -165,6 +166,7 @@ RUN --mount=type=cache,target=/root/.cache/pip \ # The production image needs only the application package and startup guards. # Tests, development requirements, and backend-local configuration stay out. COPY --link backend/app ./app +COPY --link shared ./shared COPY --link scripts/preflight-archive-v2-cutover scripts/reset-archive-v2-cutover-state ./scripts/ RUN install -d -o sambee -g sambee /app/data 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/VERSION b/VERSION index b112f913..c787b213 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.21 +1.0.22 diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 3d634577..90a2baf3 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -25,7 +25,7 @@ is_user_expired, verify_password, ) -from app.db.database import get_session +from app.db.database import get_immediate_session, get_session from app.models.oidc import OidcBrowserSession, OidcBrowserSessionStatus, OidcFlowPurpose, OidcIdentity, OidcProviderConfiguration from app.models.oidc_api import OidcBrowserSessionListRead, OidcBrowserSessionRead, OidcBrowserSessionRevokeRead, OidcGrantExchangeRequest from app.models.user import ( @@ -39,7 +39,7 @@ build_current_user_read, normalize_utc_datetime, ) -from app.models.user_settings import CurrentUserSettingsRead, CurrentUserSettingsUpdate +from app.models.user_settings import CurrentUserSettingsRead, CurrentUserSettingsUpdate, CurrentUserSettingsUpdateResult from app.services.audit import AuditDetails, AuditEventName, AuditResult, write_audit_event from app.services.authentication_config import ( build_public_auth_configuration, @@ -1102,20 +1102,18 @@ async def get_current_user_settings( return build_current_user_settings_read(user_id=current_user.id, session=session) -@router.put("/me/settings", response_model=CurrentUserSettingsRead) +@router.put("/me/settings", response_model=CurrentUserSettingsUpdateResult) async def put_current_user_settings( payload: CurrentUserSettingsUpdate, + session: Session = Depends(get_immediate_session), current_user: User = Depends(get_current_user_with_auth_check), - session: Session = Depends(get_session), -) -> CurrentUserSettingsRead: +) -> CurrentUserSettingsUpdateResult: set_user(current_user.username) try: - update_current_user_settings(user_id=current_user.id, payload=payload, session=session) + return update_current_user_settings(user_id=current_user.id, payload=payload, session=session) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc - return build_current_user_settings_read(user_id=current_user.id, session=session) - # # change_password diff --git a/backend/app/api/system_settings.py b/backend/app/api/system_settings.py index cf519fb6..2c296c87 100644 --- a/backend/app/api/system_settings.py +++ b/backend/app/api/system_settings.py @@ -6,18 +6,21 @@ from app.core.authorization import Capability from app.core.logging import set_user from app.core.security import require_capability -from app.db.database import get_session +from app.db.database import get_immediate_session, get_session from app.models.system_settings import ( AboutSettingsRead, AdvancedSystemSettingsRead, - AdvancedSystemSettingsUpdate, + AdvancedSystemSettingUpdate, FileSearchSettingsRead, FileSearchSettingsUpdate, + FileSearchSettingsUpdateResult, NetworkSettingsRead, NetworkSettingsUpdate, + NetworkSettingsUpdateResult, PublicSupportReportRead, SmbSettingsRead, SmbSettingsUpdate, + SmbSettingsUpdateResult, ) from app.models.user import User from app.services.system_settings import ( @@ -48,12 +51,12 @@ async def get_file_search_settings( return build_file_search_settings_read(session) -@router.put("/settings/file-search", response_model=FileSearchSettingsRead) +@router.put("/settings/file-search", response_model=FileSearchSettingsUpdateResult) async def put_file_search_settings( payload: FileSearchSettingsUpdate, + session: Session = Depends(get_immediate_session), current_user: User = Depends(require_capability(Capability.ACCESS_ADMIN_SETTINGS)), - session: Session = Depends(get_session), -) -> FileSearchSettingsRead: +) -> FileSearchSettingsUpdateResult: set_user(current_user.username) try: return update_file_search_settings(payload, updated_by_user_id=current_user.id, session=session) @@ -86,18 +89,17 @@ async def get_advanced_system_settings( return build_advanced_system_settings_read() -@router.put("/settings/advanced", response_model=AdvancedSystemSettingsRead) +@router.put("/settings/advanced", response_model=AdvancedSystemSettingUpdate) async def put_advanced_system_settings( - payload: AdvancedSystemSettingsUpdate, + payload: AdvancedSystemSettingUpdate, current_user: User = Depends(require_capability(Capability.ACCESS_ADMIN_SETTINGS)), session: Session = Depends(get_session), -) -> AdvancedSystemSettingsRead: +) -> AdvancedSystemSettingUpdate: set_user(current_user.username) try: - update_advanced_system_settings(payload, updated_by_user_id=current_user.id, session=session) + return update_advanced_system_settings(payload, updated_by_user_id=current_user.id, session=session) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc - return build_advanced_system_settings_read() @router.get("/settings/smb", response_model=SmbSettingsRead) @@ -108,12 +110,12 @@ async def get_smb_settings( return build_smb_settings_read() -@router.put("/settings/smb", response_model=SmbSettingsRead) +@router.put("/settings/smb", response_model=SmbSettingsUpdateResult) async def put_smb_settings( payload: SmbSettingsUpdate, current_user: User = Depends(require_capability(Capability.ACCESS_ADMIN_SETTINGS)), session: Session = Depends(get_session), -) -> SmbSettingsRead: +) -> SmbSettingsUpdateResult: set_user(current_user.username) try: policy_changed = smb_policy_will_change(payload, session) @@ -135,12 +137,12 @@ async def get_network_settings( return build_network_settings_read(session) -@router.put("/settings/network", response_model=NetworkSettingsRead) +@router.put("/settings/network", response_model=NetworkSettingsUpdateResult) async def put_network_settings( payload: NetworkSettingsUpdate, current_user: User = Depends(require_capability(Capability.ACCESS_ADMIN_SETTINGS)), session: Session = Depends(get_session), -) -> NetworkSettingsRead: +) -> NetworkSettingsUpdateResult: set_user(current_user.username) try: return update_network_settings(payload, updated_by_user_id=current_user.id, session=session) diff --git a/backend/app/core/system_setting_definitions.py b/backend/app/core/system_setting_definitions.py index ab43199c..24b2034d 100644 --- a/backend/app/core/system_setting_definitions.py +++ b/backend/app/core/system_setting_definitions.py @@ -6,7 +6,9 @@ class SystemSettingKey(StrEnum): SMB_READ_CHUNK_SIZE_BYTES = "smb.read_chunk_size_bytes" - SMB_POLICY = "smb.policy" + SMB_AUTHENTICATION_MODE = "smb.authentication_mode" + SMB_ENCRYPTION_MODE = "smb.encryption_mode" + SMB_CONNECTION_TIMEOUT_SECONDS = "smb.connection_timeout_seconds" PREPROCESSOR_IMAGEMAGICK_MAX_FILE_SIZE_BYTES = "preprocessors.imagemagick.max_file_size_bytes" PREPROCESSOR_IMAGEMAGICK_TIMEOUT_SECONDS = "preprocessors.imagemagick.timeout_seconds" PDF_VIEWER_CACHE_QUOTA_BYTES = "pdf.viewer.cache_quota_bytes" @@ -21,7 +23,10 @@ class SystemSettingKey(StrEnum): PDF_NORMALIZER_QUEUE_WAIT_SECONDS = "pdf.normalizer.queue_wait_seconds" PDF_SCREEN_DERIVATIVE_ENABLED = "pdf.screen_derivative.enabled" PDF_SCREEN_MAX_DECODED_PIXELS = "pdf.screen_derivative.max_decoded_pixels" - FILE_SEARCH_POLICY = "file_search.policy" + FILE_SEARCH_RETENTION_LIMIT = "file_search.retention_limit" + FILE_SEARCH_RESULT_LIMIT = "file_search.result_limit" + FILE_SEARCH_EXCLUDED_CATEGORIES = "file_search.excluded_categories" + FILE_SEARCH_EXCLUDED_EXTENSIONS = "file_search.excluded_extensions" class SystemSettingSource(StrEnum): diff --git a/backend/app/db/database.py b/backend/app/db/database.py index c4bab0f1..2086a9bb 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -1,6 +1,7 @@ import os from typing import Any, Generator +from fastapi import Depends from sqlalchemy import event from sqlalchemy.engine import Engine from sqlalchemy.pool import StaticPool @@ -128,3 +129,11 @@ def get_session() -> Generator[Session, None, None]: with Session(engine) as session: yield session + + +def get_immediate_session(session: Session = Depends(get_session)) -> Session: + bind = session.get_bind() + bind_engine = bind if isinstance(bind, Engine) else bind.engine + if bind.dialect.name == "sqlite" and not isinstance(bind_engine.pool, StaticPool): + session.connection().exec_driver_sql("BEGIN IMMEDIATE") + return session diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py index 675147fc..488cdad2 100644 --- a/backend/app/db/migrations.py +++ b/backend/app/db/migrations.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from collections.abc import Callable from dataclasses import dataclass @@ -897,6 +898,74 @@ def _apply_transfer_operations_migration(connection: Connection) -> None: connection.execute(text(f"CREATE INDEX IF NOT EXISTS ix_transfer_operations_{column} ON transfer_operations ({column})")) +def _apply_per_field_system_settings_migration(connection: Connection) -> None: + """Replace legacy policy blobs with independently persisted setting rows.""" + + if not inspect(connection).has_table("systemsetting"): + return + + from app.models.system_settings import FileSearchSettings, SmbPolicySettings + + legacy_settings = ( + ( + "file_search.policy", + FileSearchSettings, + { + "retention_limit": "file_search.retention_limit", + "result_limit": "file_search.result_limit", + "excluded_categories": "file_search.excluded_categories", + "excluded_extensions": "file_search.excluded_extensions", + }, + ), + ( + "smb.policy", + SmbPolicySettings, + { + "authentication_mode": "smb.authentication_mode", + "encryption_mode": "smb.encryption_mode", + "connection_timeout_seconds": "smb.connection_timeout_seconds", + }, + ), + ) + + for legacy_key, model, target_keys in legacy_settings: + legacy_row = ( + connection.execute( + text("SELECT key, value, updated_at, updated_by_user_id FROM systemsetting WHERE key = :key"), {"key": legacy_key} + ) + .mappings() + .one_or_none() + ) + if legacy_row is None: + continue + + try: + parsed = model.model_validate(json.loads(str(legacy_row["value"]))) + except (ValueError, json.JSONDecodeError) as exc: + raise RuntimeError(f"Cannot migrate invalid legacy system setting {legacy_key}: {exc}") from exc + + for field, target_key in target_keys.items(): + value = getattr(parsed, field) + serialized = json.dumps(sorted(value), separators=(",", ":")) if isinstance(value, set) else str(value) + connection.execute( + text( + """ + INSERT INTO systemsetting (key, value, updated_at, updated_by_user_id) + SELECT :key, :value, :updated_at, :updated_by_user_id + WHERE NOT EXISTS (SELECT 1 FROM systemsetting WHERE key = :key) + """ + ), + { + "key": target_key, + "value": serialized, + "updated_at": legacy_row["updated_at"], + "updated_by_user_id": legacy_row["updated_by_user_id"], + }, + ) + + connection.execute(text("DELETE FROM systemsetting WHERE key = :key"), {"key": legacy_key}) + + MIGRATIONS: tuple[Migration, ...] = ( Migration(version=1, name="ensure_connection_slugs", apply=_apply_connection_slug_migration), Migration(version=2, name="add_user_role_and_session_fields", apply=_apply_user_role_migration), @@ -934,6 +1003,7 @@ def _apply_transfer_operations_migration(connection: Connection) -> None: ), Migration(version=33, name="add_archive_operation_member_selection", apply=_apply_archive_operation_member_selection_migration), Migration(version=34, name="add_transfer_operations", apply=_apply_transfer_operations_migration), + Migration(version=35, name="split_system_setting_policy_blobs", apply=_apply_per_field_system_settings_migration), ) diff --git a/backend/app/models/system_settings.py b/backend/app/models/system_settings.py index f22c1b65..dbbfc384 100644 --- a/backend/app/models/system_settings.py +++ b/backend/app/models/system_settings.py @@ -3,10 +3,11 @@ import uuid from datetime import datetime, timezone from enum import StrEnum -from typing import Optional +from typing import Annotated, Literal, Optional from pydantic import field_validator, model_validator from sqlmodel import Field, SQLModel +from sqlmodel._compat import SQLModelConfig from app.core.system_setting_definitions import SystemSettingKey, SystemSettingSource @@ -76,26 +77,54 @@ class SmbPolicySettings(SQLModel): class SmbSettingsRead(SQLModel): read_chunk_size_bytes: IntegerSystemSettingRead policy: SmbPolicySettings - policy_source: SystemSettingSource require_signing: bool = True require_encryption: bool = False -class SmbSettingsUpdate(SQLModel): - read_chunk_size_bytes: Optional[int] = None - policy: Optional[SmbPolicySettings] = None - reset_read_chunk_size_bytes: bool = False - reset_policy: bool = False +class StrictSettingUpdate(SQLModel): + model_config = SQLModelConfig(extra="forbid") + + +class AdvancedSystemSettingUpdate(StrictSettingUpdate): + field: SystemSettingKey + value: int @model_validator(mode="after") - def prevent_conflicting_resets(self) -> "SmbSettingsUpdate": - if self.read_chunk_size_bytes is not None and self.reset_read_chunk_size_bytes: - raise ValueError("Cannot update and reset the SMB read chunk size in one request") - if self.policy is not None and self.reset_policy: - raise ValueError("Cannot update and reset the SMB policy in one request") + def validate_supported_field(self) -> "AdvancedSystemSettingUpdate": + from app.core.system_setting_definitions import SYSTEM_SETTING_DEFINITIONS + + if self.field not in SYSTEM_SETTING_DEFINITIONS or self.field is SystemSettingKey.SMB_READ_CHUNK_SIZE_BYTES: + raise ValueError("Field is not an editable advanced system setting") return self +class SmbReadChunkSizeUpdate(StrictSettingUpdate): + field: Literal["read_chunk_size_bytes"] + value: int + + +class SmbAuthenticationModeUpdate(StrictSettingUpdate): + field: Literal["authentication_mode"] + value: SmbAuthenticationMode + + +class SmbEncryptionModeUpdate(StrictSettingUpdate): + field: Literal["encryption_mode"] + value: SmbEncryptionMode + + +class SmbConnectionTimeoutUpdate(StrictSettingUpdate): + field: Literal["connection_timeout_seconds"] + value: int + + +SmbSettingsUpdate = Annotated[ + SmbReadChunkSizeUpdate | SmbAuthenticationModeUpdate | SmbEncryptionModeUpdate | SmbConnectionTimeoutUpdate, + Field(discriminator="field"), +] +SmbSettingsUpdateResult = SmbSettingsUpdate + + class PreprocessorAdvancedSettingsUpdate(SQLModel): max_file_size_bytes: Optional[int] = None timeout_seconds: Optional[int] = None @@ -172,15 +201,31 @@ class FileSearchSettingsRead(SQLModel): source: SystemSettingSource -class FileSearchSettingsUpdate(SQLModel): - settings: FileSearchSettings | None = None - reset_to_default: bool = False +class FileSearchRetentionLimitUpdate(StrictSettingUpdate): + field: Literal["retention_limit"] + value: int + + +class FileSearchResultLimitUpdate(StrictSettingUpdate): + field: Literal["result_limit"] + value: int + + +class FileSearchExcludedCategoriesUpdate(StrictSettingUpdate): + field: Literal["excluded_categories"] + value: set[str] - @model_validator(mode="after") - def prevent_conflicting_reset(self) -> "FileSearchSettingsUpdate": - if self.settings is not None and self.reset_to_default: - raise ValueError("Cannot update and reset File Search settings in one request") - return self + +class FileSearchExcludedExtensionsUpdate(StrictSettingUpdate): + field: Literal["excluded_extensions"] + value: set[str] + + +FileSearchSettingsUpdate = Annotated[ + FileSearchRetentionLimitUpdate | FileSearchResultLimitUpdate | FileSearchExcludedCategoriesUpdate | FileSearchExcludedExtensionsUpdate, + Field(discriminator="field"), +] +FileSearchSettingsUpdateResult = FileSearchSettingsUpdate class NetworkSettingsRead(SQLModel): @@ -188,9 +233,18 @@ class NetworkSettingsRead(SQLModel): trusted_proxy_cidrs: list[str] -class NetworkSettingsUpdate(SQLModel): - public_url: str = Field(max_length=2048) - trusted_proxy_cidrs: list[str] = Field(default_factory=list, max_length=100) +class NetworkPublicUrlUpdate(StrictSettingUpdate): + field: Literal["public_url"] + value: str = Field(max_length=2048) + + +class NetworkTrustedProxyCidrsUpdate(StrictSettingUpdate): + field: Literal["trusted_proxy_cidrs"] + value: list[str] = Field(default_factory=list, max_length=100) + + +NetworkSettingsUpdate = Annotated[NetworkPublicUrlUpdate | NetworkTrustedProxyCidrsUpdate, Field(discriminator="field")] +NetworkSettingsUpdateResult = NetworkSettingsUpdate class AboutSettingsRead(SQLModel): diff --git a/backend/app/models/user_settings.py b/backend/app/models/user_settings.py index 7a0c0554..2044a4eb 100644 --- a/backend/app/models/user_settings.py +++ b/backend/app/models/user_settings.py @@ -2,9 +2,10 @@ import uuid from datetime import datetime, timezone -from typing import Any, Literal, Optional +from typing import Annotated, Any, Literal, Optional from sqlmodel import Field, SQLModel +from sqlmodel._compat import SQLModelConfig class UserSetting(SQLModel, table=True): @@ -49,32 +50,83 @@ class CurrentUserSettingsRead(SQLModel): text_editor: TextEditorUserSettingsRead -class AppearanceUserSettingsUpdate(SQLModel): - theme_id: Optional[str] = None - custom_themes: Optional[list[dict[str, Any]]] = None +class StrictCurrentUserSettingUpdate(SQLModel): + model_config = SQLModelConfig(extra="forbid") -class LocalizationUserSettingsUpdate(SQLModel): - language: Optional[LanguagePreference] = None - regional_locale: Optional[str] = None +class ThemeIdUserSettingUpdate(StrictCurrentUserSettingUpdate): + field: Literal["appearance.theme_id"] + value: str -class BrowserUserSettingsUpdate(SQLModel): - quick_nav_include_dot_directories: Optional[bool] = None - quick_bar_shortcut_hint_visibility: Optional[QuickBarShortcutHintVisibility] = None - file_browser_view_mode: Optional[str] = None - pane_mode: Optional[str] = None - selected_connection_id: Optional[str] = None - viewer_associations: Optional[dict[str, str]] = None +class CustomThemesUserSettingUpdate(StrictCurrentUserSettingUpdate): + field: Literal["appearance.custom_themes"] + value: list[dict[str, Any]] -class TextEditorUserSettingsUpdate(SQLModel): - max_file_size_bytes: Optional[int] = None - word_wrap_enabled: Optional[bool] = None +class LanguageUserSettingUpdate(StrictCurrentUserSettingUpdate): + field: Literal["localization.language"] + value: LanguagePreference + + +class RegionalLocaleUserSettingUpdate(StrictCurrentUserSettingUpdate): + field: Literal["localization.regional_locale"] + value: str + + +class QuickNavUserSettingUpdate(StrictCurrentUserSettingUpdate): + field: Literal["browser.quick_nav_include_dot_directories"] + value: bool + + +class QuickBarShortcutHintUserSettingUpdate(StrictCurrentUserSettingUpdate): + field: Literal["browser.quick_bar_shortcut_hint_visibility"] + value: QuickBarShortcutHintVisibility + + +class FileBrowserViewModeUserSettingUpdate(StrictCurrentUserSettingUpdate): + field: Literal["browser.file_browser_view_mode"] + value: Literal["list", "details"] + + +class PaneModeUserSettingUpdate(StrictCurrentUserSettingUpdate): + field: Literal["browser.pane_mode"] + value: Literal["single", "dual"] + + +class SelectedConnectionUserSettingUpdate(StrictCurrentUserSettingUpdate): + field: Literal["browser.selected_connection_id"] + value: str | None + + +class ViewerAssociationsUserSettingUpdate(StrictCurrentUserSettingUpdate): + field: Literal["browser.viewer_associations"] + value: dict[str, str] + + +class TextEditorMaxFileSizeUserSettingUpdate(StrictCurrentUserSettingUpdate): + field: Literal["text_editor.max_file_size_bytes"] + value: int + + +class TextEditorWordWrapUserSettingUpdate(StrictCurrentUserSettingUpdate): + field: Literal["text_editor.word_wrap_enabled"] + value: bool | None -class CurrentUserSettingsUpdate(SQLModel): - appearance: Optional[AppearanceUserSettingsUpdate] = None - localization: Optional[LocalizationUserSettingsUpdate] = None - browser: Optional[BrowserUserSettingsUpdate] = None - text_editor: Optional[TextEditorUserSettingsUpdate] = None +CurrentUserSettingsUpdate = Annotated[ + ThemeIdUserSettingUpdate + | CustomThemesUserSettingUpdate + | LanguageUserSettingUpdate + | RegionalLocaleUserSettingUpdate + | QuickNavUserSettingUpdate + | QuickBarShortcutHintUserSettingUpdate + | FileBrowserViewModeUserSettingUpdate + | PaneModeUserSettingUpdate + | SelectedConnectionUserSettingUpdate + | ViewerAssociationsUserSettingUpdate + | TextEditorMaxFileSizeUserSettingUpdate + | TextEditorWordWrapUserSettingUpdate, + Field(discriminator="field"), +] +CurrentUserSettingsUpdateResult = CurrentUserSettingsUpdate diff --git a/backend/app/services/system_settings.py b/backend/app/services/system_settings.py index ef6ab330..8c44c3b8 100644 --- a/backend/app/services/system_settings.py +++ b/backend/app/services/system_settings.py @@ -32,13 +32,15 @@ from app.models.system_settings import ( AboutSettingsRead, AdvancedSystemSettingsRead, - AdvancedSystemSettingsUpdate, + AdvancedSystemSettingUpdate, FileSearchSettings, FileSearchSettingsRead, FileSearchSettingsUpdate, + FileSearchSettingsUpdateResult, IntegerSystemSettingRead, NetworkSettingsRead, NetworkSettingsUpdate, + NetworkSettingsUpdateResult, PdfAdvancedSettingsRead, PreprocessorAdvancedSettingsRead, PublicSupportReportRead, @@ -47,6 +49,7 @@ SmbPolicySettings, SmbSettingsRead, SmbSettingsUpdate, + SmbSettingsUpdateResult, SystemSetting, ) from app.services.authentication_config import ( @@ -68,7 +71,17 @@ UNLIMITED_MEMORY_THRESHOLD_BYTES = 1 << 60 PUBLIC_SUPPORT_REPORT_FORMAT_VERSION = 1 PUBLIC_SUPPORT_REPORT_TITLE = "Sambee public support report" -FILE_SEARCH_POLICY_KEY = SystemSettingKey.FILE_SEARCH_POLICY +FILE_SEARCH_SETTING_KEYS = { + "retention_limit": SystemSettingKey.FILE_SEARCH_RETENTION_LIMIT, + "result_limit": SystemSettingKey.FILE_SEARCH_RESULT_LIMIT, + "excluded_categories": SystemSettingKey.FILE_SEARCH_EXCLUDED_CATEGORIES, + "excluded_extensions": SystemSettingKey.FILE_SEARCH_EXCLUDED_EXTENSIONS, +} +SMB_POLICY_SETTING_KEYS = { + "authentication_mode": SystemSettingKey.SMB_AUTHENTICATION_MODE, + "encryption_mode": SystemSettingKey.SMB_ENCRYPTION_MODE, + "connection_timeout_seconds": SystemSettingKey.SMB_CONNECTION_TIMEOUT_SECONDS, +} STANDARD_OIDC_CLAIMS = frozenset({"sub", "name", "email", "groups", "preferred_username"}) STANDARD_OIDC_SCOPES = frozenset({"openid", "profile", "email", "address", "phone", "offline_access"}) CONFIG_FILE_SETTING_FIELDS = ( @@ -269,14 +282,47 @@ def get_integer_setting_value(key: SystemSettingKey) -> int: return _resolve_integer_setting(SYSTEM_SETTING_DEFINITIONS[key]).value -def build_file_search_settings_read(session: Session) -> FileSearchSettingsRead: - raw_value = session.get(SystemSetting, FILE_SEARCH_POLICY_KEY.value) +def _decode_string_set(raw_value: str | None, *, key: SystemSettingKey) -> set[str]: if raw_value is None: - return FileSearchSettingsRead(settings=FileSearchSettings(), source=SystemSettingSource.DEFAULT) + return set() try: - return FileSearchSettingsRead(settings=FileSearchSettings.model_validate_json(raw_value.value), source=SystemSettingSource.DATABASE) + decoded = json.loads(raw_value) + except json.JSONDecodeError as exc: + raise ValueError(f"Stored override for {key.value} is not a valid JSON string array") from exc + if not isinstance(decoded, list) or any(not isinstance(item, str) for item in decoded): + raise ValueError(f"Stored override for {key.value} is not a valid JSON string array") + return set(decoded) + + +def _encode_string_set(values: set[str]) -> str: + return json.dumps(sorted(values), separators=(",", ":")) + + +def _begin_immediate_transaction(session: Session) -> None: + if session.get_bind().dialect.name == "sqlite" and not session.in_transaction(): + session.connection().exec_driver_sql("BEGIN IMMEDIATE") + + +def build_file_search_settings_read(session: Session) -> FileSearchSettingsRead: + try: + values = {field: session.get(SystemSetting, key.value) for field, key in FILE_SEARCH_SETTING_KEYS.items()} + overrides: dict[str, object] = {} + if values["retention_limit"] is not None: + overrides["retention_limit"] = int(values["retention_limit"].value) + if values["result_limit"] is not None: + overrides["result_limit"] = int(values["result_limit"].value) + if values["excluded_categories"] is not None: + overrides["excluded_categories"] = _decode_string_set( + values["excluded_categories"].value, key=FILE_SEARCH_SETTING_KEYS["excluded_categories"] + ) + if values["excluded_extensions"] is not None: + overrides["excluded_extensions"] = _decode_string_set( + values["excluded_extensions"].value, key=FILE_SEARCH_SETTING_KEYS["excluded_extensions"] + ) + source = SystemSettingSource.DATABASE if overrides else SystemSettingSource.DEFAULT + return FileSearchSettingsRead(settings=FileSearchSettings(**overrides), source=source) except ValueError as exc: - logger.critical("Invalid persisted File Search policy: %s", exc) + logger.critical("Invalid persisted File Search setting: %s", exc) raise ValueError("The persisted File Search policy is invalid") from exc @@ -292,25 +338,22 @@ def get_file_search_settings(session: Session | None = None) -> FileSearchSettin def update_file_search_settings( payload: FileSearchSettingsUpdate, *, updated_by_user_id: uuid.UUID, session: Session -) -> FileSearchSettingsRead: +) -> FileSearchSettingsUpdateResult: + _begin_immediate_transaction(session) current = build_file_search_settings_read(session).settings - if payload.reset_to_default: - setting = session.get(SystemSetting, FILE_SEARCH_POLICY_KEY.value) - if setting is not None: - session.delete(setting) - next_settings = FileSearchSettings() - elif payload.settings is not None: - next_settings = payload.settings - _write_system_setting(session, FILE_SEARCH_POLICY_KEY, next_settings.model_dump_json(), updated_by_user_id) - else: - return build_file_search_settings_read(session) + next_settings = current.model_copy(update={payload.field: payload.value}) + next_settings = FileSearchSettings.model_validate(next_settings) if next_settings.retention_limit < current.retention_limit: from app.services.recent_files import trim_all_recent_files trim_all_recent_files(retention_limit=next_settings.retention_limit, session=session) + + value = getattr(next_settings, payload.field) + serialized_value = _encode_string_set(value) if isinstance(value, set) else str(value) + _write_system_setting(session, FILE_SEARCH_SETTING_KEYS[payload.field], serialized_value, updated_by_user_id) session.commit() - return build_file_search_settings_read(session) + return payload.model_copy(update={"value": value}) def build_advanced_system_settings_read() -> AdvancedSystemSettingsRead: @@ -343,15 +386,16 @@ def build_advanced_system_settings_read() -> AdvancedSystemSettingsRead: def _resolve_smb_policy() -> tuple[SmbPolicySettings, SystemSettingSource]: - raw_value = store.get_override(SystemSettingKey.SMB_POLICY) - if raw_value is not None: - try: - return SmbPolicySettings.model_validate_json(raw_value), SystemSettingSource.DATABASE - except ValueError as exc: - logger.critical("Invalid database override for SMB policy; SMB access is disabled: %s", exc) - raise SmbPolicyConfigurationError("The persisted SMB policy is invalid") from exc - - return SmbPolicySettings(), SystemSettingSource.DEFAULT + overrides = {field: store.get_override(key) for field, key in SMB_POLICY_SETTING_KEYS.items()} + if not any(value is not None for value in overrides.values()): + return SmbPolicySettings(), SystemSettingSource.DEFAULT + try: + return SmbPolicySettings.model_validate( + {field: value for field, value in overrides.items() if value is not None} + ), SystemSettingSource.DATABASE + except ValueError as exc: + logger.critical("Invalid database override for SMB policy; SMB access is disabled: %s", exc) + raise SmbPolicyConfigurationError("The persisted SMB policy is invalid") from exc def get_smb_policy_settings() -> SmbPolicySettings: @@ -386,7 +430,6 @@ def build_smb_settings_read() -> SmbSettingsRead: return SmbSettingsRead( read_chunk_size_bytes=_build_integer_read(SYSTEM_SETTING_DEFINITIONS[SystemSettingKey.SMB_READ_CHUNK_SIZE_BYTES]), policy=policy, - policy_source=policy_source, require_encryption=policy.encryption_mode is SmbEncryptionMode.ENCRYPTION_REQUIRED, ) @@ -530,11 +573,11 @@ def _append_smb_settings_report(lines: list[str]) -> None: _append_report_setting( lines, smb.read_chunk_size_bytes.key.value, smb.read_chunk_size_bytes.value, smb.read_chunk_size_bytes.source.value ) - _append_report_setting(lines, "authentication_mode", smb.policy.authentication_mode.value, smb.policy_source.value) - _append_report_setting(lines, "encryption_mode", smb.policy.encryption_mode.value, smb.policy_source.value) - _append_report_setting(lines, "connection_timeout_seconds", smb.policy.connection_timeout_seconds, smb.policy_source.value) + _append_report_setting(lines, "authentication_mode", smb.policy.authentication_mode.value, "ui") + _append_report_setting(lines, "encryption_mode", smb.policy.encryption_mode.value, "ui") + _append_report_setting(lines, "connection_timeout_seconds", smb.policy.connection_timeout_seconds, "ui") _append_report_setting(lines, "signing_required", smb.require_signing, "built_in") - _append_report_setting(lines, "encryption_required", smb.require_encryption, smb.policy_source.value) + _append_report_setting(lines, "encryption_required", smb.require_encryption, "ui") def _append_network_settings_report(lines: list[str], session: Session, aliases: PublicSupportReportAliases) -> None: @@ -636,86 +679,15 @@ def build_public_support_report_read(session: Session) -> PublicSupportReportRea return PublicSupportReportRead(content="\n".join(lines)) -def _extract_updates(payload: AdvancedSystemSettingsUpdate) -> dict[SystemSettingKey, int]: - updates: dict[SystemSettingKey, int] = {} - - preprocessors = payload.preprocessors - if preprocessors and preprocessors.imagemagick: - imagemagick = preprocessors.imagemagick - if imagemagick.max_file_size_bytes is not None: - updates[SystemSettingKey.PREPROCESSOR_IMAGEMAGICK_MAX_FILE_SIZE_BYTES] = imagemagick.max_file_size_bytes - if imagemagick.timeout_seconds is not None: - updates[SystemSettingKey.PREPROCESSOR_IMAGEMAGICK_TIMEOUT_SECONDS] = imagemagick.timeout_seconds - - pdf = payload.pdf - if pdf: - if pdf.cache_quota_bytes is not None: - updates[SystemSettingKey.PDF_VIEWER_CACHE_QUOTA_BYTES] = pdf.cache_quota_bytes - if pdf.cache_inactivity_ttl_seconds is not None: - updates[SystemSettingKey.PDF_VIEWER_CACHE_INACTIVITY_TTL_SECONDS] = pdf.cache_inactivity_ttl_seconds - if pdf.max_source_size_bytes is not None: - updates[SystemSettingKey.PDF_NORMALIZER_MAX_SOURCE_SIZE_BYTES] = pdf.max_source_size_bytes - if pdf.max_output_size_bytes is not None: - updates[SystemSettingKey.PDF_NORMALIZER_MAX_OUTPUT_SIZE_BYTES] = pdf.max_output_size_bytes - if pdf.address_space_bytes is not None: - updates[SystemSettingKey.PDF_NORMALIZER_ADDRESS_SPACE_BYTES] = pdf.address_space_bytes - if pdf.temporary_disk_bytes is not None: - updates[SystemSettingKey.PDF_NORMALIZER_TEMPORARY_DISK_BYTES] = pdf.temporary_disk_bytes - if pdf.timeout_seconds is not None: - updates[SystemSettingKey.PDF_NORMALIZER_TIMEOUT_SECONDS] = pdf.timeout_seconds - if pdf.cpu_time_seconds is not None: - updates[SystemSettingKey.PDF_NORMALIZER_CPU_TIME_SECONDS] = pdf.cpu_time_seconds - if pdf.max_concurrent is not None: - updates[SystemSettingKey.PDF_NORMALIZER_MAX_CONCURRENT] = pdf.max_concurrent - if pdf.queue_wait_seconds is not None: - updates[SystemSettingKey.PDF_NORMALIZER_QUEUE_WAIT_SECONDS] = pdf.queue_wait_seconds - if pdf.screen_derivative_enabled is not None: - updates[SystemSettingKey.PDF_SCREEN_DERIVATIVE_ENABLED] = pdf.screen_derivative_enabled - if pdf.screen_max_decoded_pixels is not None: - updates[SystemSettingKey.PDF_SCREEN_MAX_DECODED_PIXELS] = pdf.screen_max_decoded_pixels - - return updates - - -def _extract_reset_keys(payload: AdvancedSystemSettingsUpdate) -> set[SystemSettingKey]: - return set(payload.reset_keys) - - def update_advanced_system_settings( - payload: AdvancedSystemSettingsUpdate, *, updated_by_user_id: Optional[uuid.UUID], session: Session -) -> None: - updates = _extract_updates(payload) - reset_keys = _extract_reset_keys(payload) - - conflicting_keys = reset_keys.intersection(updates.keys()) - if conflicting_keys: - conflicts = ", ".join(sorted(key.value for key in conflicting_keys)) - raise ValueError(f"Cannot update and reset the same setting in one request: {conflicts}") - - if not updates and not reset_keys: - return - - for key in reset_keys: - setting = session.get(SystemSetting, key.value) - if setting is not None: - session.delete(setting) - - for key, value in updates.items(): - definition = SYSTEM_SETTING_DEFINITIONS[key] - validated_value = _validate_integer_value(definition, int(value)) - setting = session.get(SystemSetting, key.value) - - if setting is None: - setting = SystemSetting(key=key.value, value=str(validated_value), updated_by_user_id=updated_by_user_id) - else: - setting.value = str(validated_value) - setting.updated_at = datetime.now(timezone.utc) - setting.updated_by_user_id = updated_by_user_id - - session.add(setting) - + payload: AdvancedSystemSettingUpdate, *, updated_by_user_id: Optional[uuid.UUID], session: Session +) -> AdvancedSystemSettingUpdate: + definition = SYSTEM_SETTING_DEFINITIONS[payload.field] + value = _validate_integer_value(definition, payload.value) + _write_system_setting(session, payload.field, str(value), updated_by_user_id) session.commit() store.refresh_from_session(session) + return payload.model_copy(update={"value": value}) def _write_system_setting(session: Session, key: SystemSettingKey, value: str, updated_by_user_id: Optional[uuid.UUID]) -> None: @@ -730,50 +702,40 @@ def _write_system_setting(session: Session, key: SystemSettingKey, value: str, u session.add(setting) -def update_smb_settings(payload: SmbSettingsUpdate, *, updated_by_user_id: Optional[uuid.UUID], session: Session) -> SmbSettingsRead: - """Persist validated SMB policy and resource settings atomically.""" +def update_smb_settings( + payload: SmbSettingsUpdate, *, updated_by_user_id: Optional[uuid.UUID], session: Session +) -> SmbSettingsUpdateResult: + """Persist one validated SMB setting and return its canonical field/value pair.""" - if payload.reset_read_chunk_size_bytes: - setting = session.get(SystemSetting, SystemSettingKey.SMB_READ_CHUNK_SIZE_BYTES.value) - if setting is not None: - session.delete(setting) - elif payload.read_chunk_size_bytes is not None: + if payload.field == "read_chunk_size_bytes": definition = SYSTEM_SETTING_DEFINITIONS[SystemSettingKey.SMB_READ_CHUNK_SIZE_BYTES] - value = _validate_integer_value(definition, payload.read_chunk_size_bytes) - _write_system_setting(session, SystemSettingKey.SMB_READ_CHUNK_SIZE_BYTES, str(value), updated_by_user_id) - - if payload.reset_policy: - setting = session.get(SystemSetting, SystemSettingKey.SMB_POLICY.value) - if setting is not None: - session.delete(setting) - elif payload.policy is not None: - _write_system_setting(session, SystemSettingKey.SMB_POLICY, payload.policy.model_dump_json(), updated_by_user_id) + value = _validate_integer_value(definition, payload.value) + key = SystemSettingKey.SMB_READ_CHUNK_SIZE_BYTES + else: + next_policy = SmbPolicySettings.model_validate(build_smb_settings_read().policy.model_dump() | {payload.field: payload.value}) + value = getattr(next_policy, payload.field) + key = SMB_POLICY_SETTING_KEYS[payload.field] + _write_system_setting(session, key, str(value), updated_by_user_id) session.commit() store.refresh_from_session(session) - return build_smb_settings_read() + return payload.model_copy(update={"value": value}) def smb_policy_will_change(payload: SmbSettingsUpdate, session: Session) -> bool: """Return whether an update changes the effective SMB policy.""" - current_setting = session.get(SystemSetting, SystemSettingKey.SMB_POLICY.value) - if payload.reset_policy: - if current_setting is None: - return False + if payload.field == "read_chunk_size_bytes": + return False + current_setting = session.get(SystemSetting, SMB_POLICY_SETTING_KEYS[payload.field].value) + if current_setting is None: + current_value = getattr(SmbPolicySettings(), payload.field) + else: try: - return SmbPolicySettings.model_validate_json(current_setting.value) != SmbPolicySettings() + current_value = getattr(SmbPolicySettings.model_validate({payload.field: current_setting.value}), payload.field) except ValueError: return True - if payload.policy is None: - return False - if current_setting is None: - return payload.policy != SmbPolicySettings() - try: - current_policy = SmbPolicySettings.model_validate_json(current_setting.value) - except ValueError: - return True - return current_policy != payload.policy + return bool(current_value != payload.value) async def retire_smb_runtime_policy() -> None: @@ -822,30 +784,34 @@ def build_network_settings_read(session: Session) -> NetworkSettingsRead: def update_network_settings( payload: NetworkSettingsUpdate, *, updated_by_user_id: Optional[uuid.UUID], session: Session -) -> NetworkSettingsRead: - public_url = canonicalize_public_url(payload.public_url) - if urlparse(public_url).path: - raise ValueError("Public URL must not include a path") - trusted_proxy_cidrs = _normalized_trusted_proxy_cidrs(payload.trusted_proxy_cidrs) - current_public_url = _network_setting_value(session, NETWORK_PUBLIC_URL_KEY) +) -> NetworkSettingsUpdateResult: now = datetime.now(timezone.utc) - for key, value in ( - (NETWORK_PUBLIC_URL_KEY, public_url), - (NETWORK_TRUSTED_PROXY_CIDRS_KEY, ",".join(trusted_proxy_cidrs)), - ): - setting = session.get(SystemSetting, key) - if setting is None: - session.add(SystemSetting(key=key, value=value, updated_by_user_id=updated_by_user_id)) - else: - setting.value = value - setting.updated_at = now - setting.updated_by_user_id = updated_by_user_id - session.add(setting) - - if current_public_url and current_public_url != public_url: + value: str | list[str] + if payload.field == "public_url": + value = canonicalize_public_url(payload.value) + if urlparse(value).path: + raise ValueError("Public URL must not include a path") + key = NETWORK_PUBLIC_URL_KEY + current_public_url = _network_setting_value(session, key) + else: + value = _normalized_trusted_proxy_cidrs(payload.value) + key = NETWORK_TRUSTED_PROXY_CIDRS_KEY + current_public_url = "" + + setting = session.get(SystemSetting, key) + serialized_value = value if isinstance(value, str) else ",".join(value) + if setting is None: + session.add(SystemSetting(key=key, value=serialized_value, updated_by_user_id=updated_by_user_id)) + else: + setting.value = serialized_value + setting.updated_at = now + setting.updated_by_user_id = updated_by_user_id + session.add(setting) + + if current_public_url and current_public_url != value: for flow in session.exec(select(OidcFlow)).all(): session.delete(flow) session.commit() - return build_network_settings_read(session) + return payload.model_copy(update={"value": value}) diff --git a/backend/app/services/user_settings.py b/backend/app/services/user_settings.py index fbcfd3e8..7485f0b1 100644 --- a/backend/app/services/user_settings.py +++ b/backend/app/services/user_settings.py @@ -5,6 +5,7 @@ import uuid from datetime import datetime, timezone from json import JSONDecodeError +from pathlib import Path from typing import Any, cast from sqlmodel import Session, select @@ -26,6 +27,7 @@ BrowserUserSettingsRead, CurrentUserSettingsRead, CurrentUserSettingsUpdate, + CurrentUserSettingsUpdateResult, LocalizationUserSettingsRead, TextEditorUserSettingsRead, UserSetting, @@ -43,6 +45,10 @@ VALID_THEME_MODES = {"light", "dark"} VALID_LANGUAGE_PREFERENCES = {DEFAULT_LANGUAGE_PREFERENCE, "en", "en-XA"} REGIONAL_LOCALE_PATTERN = re.compile(r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$") +BUILT_IN_THEME_IDS_PATHS = ( + Path(__file__).resolve().parents[3] / "shared" / "built_in_theme_ids.json", + Path(__file__).resolve().parents[2] / "shared" / "built_in_theme_ids.json", +) def _load_user_setting_map(user_id: uuid.UUID, session: Session) -> dict[str, str]: @@ -304,177 +310,108 @@ def _delete_user_setting(*, user_id: uuid.UUID, key: UserSettingKey, session: Se session.delete(setting) -def update_current_user_settings(*, user_id: uuid.UUID, payload: CurrentUserSettingsUpdate, session: Session) -> None: - has_updates = False - - if payload.appearance and payload.appearance.theme_id is not None: - theme_id = payload.appearance.theme_id.strip() - if not theme_id: - raise ValueError("Theme ID cannot be empty") +def _built_in_theme_ids() -> set[str]: + for manifest_path in BUILT_IN_THEME_IDS_PATHS: + try: + values = json.loads(manifest_path.read_text()) + except FileNotFoundError: + continue + except (OSError, JSONDecodeError) as exc: + raise RuntimeError("The built-in theme manifest is unavailable or invalid") from exc + if not isinstance(values, list) or any(not isinstance(value, str) or not value for value in values): + raise RuntimeError("The built-in theme manifest is invalid") + return set(values) - _upsert_user_setting(user_id=user_id, key=UserSettingKey.APPEARANCE_THEME_ID, value=theme_id, session=session) - has_updates = True - - if payload.appearance and "custom_themes" in payload.appearance.model_fields_set: - custom_themes = payload.appearance.custom_themes or [] - if any(not _is_valid_theme_config(theme) for theme in custom_themes): - raise ValueError("Custom themes payload contains an invalid theme definition") - - if custom_themes: - _upsert_user_setting( - user_id=user_id, - key=UserSettingKey.APPEARANCE_CUSTOM_THEMES, - value=json.dumps(custom_themes, separators=(",", ":"), sort_keys=True), - session=session, - ) - else: - _delete_user_setting( - user_id=user_id, - key=UserSettingKey.APPEARANCE_CUSTOM_THEMES, - session=session, - ) - - has_updates = True - - if payload.localization and "language" in payload.localization.model_fields_set: - language = _parse_language_preference(payload.localization.language) - _upsert_user_setting( - user_id=user_id, - key=UserSettingKey.LOCALIZATION_LANGUAGE, - value=language, - session=session, - ) - has_updates = True - - if payload.localization and "regional_locale" in payload.localization.model_fields_set: - regional_locale = _normalize_regional_locale(payload.localization.regional_locale) - _upsert_user_setting( - user_id=user_id, - key=UserSettingKey.LOCALIZATION_REGIONAL_LOCALE, - value=regional_locale, - session=session, - ) - has_updates = True - - if payload.browser and payload.browser.quick_nav_include_dot_directories is not None: - _upsert_user_setting( - user_id=user_id, - key=UserSettingKey.BROWSER_QUICK_NAV_INCLUDE_DOT_DIRECTORIES, - value="true" if payload.browser.quick_nav_include_dot_directories else "false", - session=session, - ) - has_updates = True + raise RuntimeError("The built-in theme manifest is unavailable") - if payload.browser and payload.browser.quick_bar_shortcut_hint_visibility is not None: - shortcut_hint_visibility = payload.browser.quick_bar_shortcut_hint_visibility.strip().lower() - if shortcut_hint_visibility not in VALID_QUICK_BAR_SHORTCUT_HINT_VISIBILITIES: - raise ValueError("Quick Bar shortcut hint visibility must be one of: auto, always, never") - _upsert_user_setting( - user_id=user_id, - key=UserSettingKey.BROWSER_QUICK_BAR_SHORTCUT_HINT_VISIBILITY, - value=shortcut_hint_visibility, - session=session, - ) - has_updates = True +def _validate_custom_themes(custom_themes: list[dict[str, Any]]) -> list[dict[str, Any]]: + if any(not _is_valid_theme_config(theme) for theme in custom_themes): + raise ValueError("Custom themes payload contains an invalid theme definition") + built_in_ids = _built_in_theme_ids() + custom_ids = [str(theme["id"]).strip() for theme in custom_themes] + if len(custom_ids) != len(set(custom_ids)): + raise ValueError("Custom theme IDs must be unique") + if built_in_ids.intersection(custom_ids): + raise ValueError("Custom theme IDs cannot match built-in theme IDs") + return custom_themes - if payload.browser and payload.browser.file_browser_view_mode is not None: - view_mode = payload.browser.file_browser_view_mode.strip().lower() - if view_mode not in VALID_FILE_BROWSER_VIEW_MODES: - raise ValueError("File browser view mode must be one of: list, details") - _upsert_user_setting( - user_id=user_id, - key=UserSettingKey.BROWSER_FILE_BROWSER_VIEW_MODE, - value=view_mode, - session=session, - ) - has_updates = True +def _begin_immediate_transaction(session: Session) -> None: + if session.get_bind().dialect.name == "sqlite" and not session.in_transaction(): + session.connection().exec_driver_sql("BEGIN IMMEDIATE") - if payload.browser and payload.browser.pane_mode is not None: - pane_mode = payload.browser.pane_mode.strip().lower() - if pane_mode not in VALID_PANE_MODES: - raise ValueError("Pane mode must be one of: single, dual") - _upsert_user_setting( - user_id=user_id, - key=UserSettingKey.BROWSER_PANE_MODE, - value=pane_mode, - session=session, - ) - has_updates = True - - if payload.browser and "selected_connection_id" in payload.browser.model_fields_set: - selected_connection_id = payload.browser.selected_connection_id - normalized_connection_id = selected_connection_id.strip() if selected_connection_id is not None else "" - - if normalized_connection_id: - _upsert_user_setting( - user_id=user_id, - key=UserSettingKey.BROWSER_SELECTED_CONNECTION_ID, - value=normalized_connection_id, - session=session, - ) - else: - _delete_user_setting( - user_id=user_id, - key=UserSettingKey.BROWSER_SELECTED_CONNECTION_ID, - session=session, - ) +def update_current_user_settings( + *, user_id: uuid.UUID, payload: CurrentUserSettingsUpdate, session: Session +) -> CurrentUserSettingsUpdateResult: + key = UserSettingKey(payload.field) + value: object = payload.value - has_updates = True + if key in {UserSettingKey.APPEARANCE_THEME_ID, UserSettingKey.APPEARANCE_CUSTOM_THEMES}: + _begin_immediate_transaction(session) - if payload.browser and "viewer_associations" in payload.browser.model_fields_set: - viewer_associations = payload.browser.viewer_associations or {} + if key is UserSettingKey.APPEARANCE_THEME_ID: + value = str(value).strip() + if not value: + raise ValueError("Theme ID cannot be empty") + current = build_current_user_settings_read(user_id=user_id, session=session) + allowed_ids = _built_in_theme_ids().union(theme["id"] for theme in current.appearance.custom_themes) + if value not in allowed_ids: + raise ValueError("Theme ID must identify a built-in or current custom theme") + elif key is UserSettingKey.APPEARANCE_CUSTOM_THEMES: + value = _validate_custom_themes(cast(list[dict[str, Any]], value)) + current_theme_id = build_current_user_settings_read(user_id=user_id, session=session).appearance.theme_id + allowed_ids = _built_in_theme_ids().union(theme["id"] for theme in value) + if current_theme_id not in allowed_ids: + raise ValueError("Cannot remove the active custom theme before selecting another theme") + elif key is UserSettingKey.LOCALIZATION_LANGUAGE: + value = _parse_language_preference(cast(str, value)) + elif key is UserSettingKey.LOCALIZATION_REGIONAL_LOCALE: + value = _normalize_regional_locale(cast(str, value)) + elif key is UserSettingKey.BROWSER_QUICK_BAR_SHORTCUT_HINT_VISIBILITY: + value = cast(str, value).strip().lower() + if value not in VALID_QUICK_BAR_SHORTCUT_HINT_VISIBILITIES: + raise ValueError("Quick Bar shortcut hint visibility must be one of: auto, always, never") + elif key is UserSettingKey.BROWSER_FILE_BROWSER_VIEW_MODE: + value = cast(str, value).strip().lower() + if value not in VALID_FILE_BROWSER_VIEW_MODES: + raise ValueError("File browser view mode must be one of: list, details") + elif key is UserSettingKey.BROWSER_PANE_MODE: + value = cast(str, value).strip().lower() + if value not in VALID_PANE_MODES: + raise ValueError("Pane mode must be one of: single, dual") + elif key is UserSettingKey.BROWSER_SELECTED_CONNECTION_ID: + value = cast(str | None, value) + value = value.strip() if value is not None else None + elif key is UserSettingKey.BROWSER_VIEWER_ASSOCIATIONS: normalized_associations: dict[str, str] = {} - - for raw_key, raw_value in viewer_associations.items(): + for raw_key, raw_value in cast(dict[str, str], value).items(): normalized_key = raw_key.strip().lower() normalized_value = raw_value.strip() if not normalized_key or not normalized_value: raise ValueError("Viewer associations must use non-empty file keys and viewer IDs") - normalized_associations[normalized_key] = normalized_value - - if normalized_associations: - _upsert_user_setting( - user_id=user_id, - key=UserSettingKey.BROWSER_VIEWER_ASSOCIATIONS, - value=json.dumps(normalized_associations, separators=(",", ":"), sort_keys=True), - session=session, - ) - else: - _delete_user_setting( - user_id=user_id, - key=UserSettingKey.BROWSER_VIEWER_ASSOCIATIONS, - session=session, - ) - - has_updates = True - - if payload.text_editor and payload.text_editor.max_file_size_bytes is not None: - max_file_size_bytes = payload.text_editor.max_file_size_bytes - if max_file_size_bytes < MIN_TEXT_EDITOR_MAX_FILE_SIZE_BYTES or max_file_size_bytes > MAX_TEXT_EDITOR_MAX_FILE_SIZE_BYTES: + value = normalized_associations + elif key is UserSettingKey.TEXT_EDITOR_MAX_FILE_SIZE_BYTES: + normalized_max_file_size = int(cast(int, value)) + if normalized_max_file_size < MIN_TEXT_EDITOR_MAX_FILE_SIZE_BYTES or normalized_max_file_size > MAX_TEXT_EDITOR_MAX_FILE_SIZE_BYTES: raise ValueError("Text editor max file size must be between 65536 and 104857600 bytes") + value = normalized_max_file_size - _upsert_user_setting( - user_id=user_id, - key=UserSettingKey.TEXT_EDITOR_MAX_FILE_SIZE_BYTES, - value=str(max_file_size_bytes), - session=session, - ) - has_updates = True - - if payload.text_editor and payload.text_editor.word_wrap_enabled is not None: - _upsert_user_setting( - user_id=user_id, - key=UserSettingKey.TEXT_EDITOR_WORD_WRAP_ENABLED, - value="true" if payload.text_editor.word_wrap_enabled else "false", - session=session, + if value in (None, "", {}): + _delete_user_setting(user_id=user_id, key=key, session=session) + else: + serialized = ( + json.dumps(value, separators=(",", ":"), sort_keys=True) + if isinstance(value, (dict, list)) + else "true" + if value is True + else "false" + if value is False + else str(value) ) - has_updates = True - - if not has_updates: - return + _upsert_user_setting(user_id=user_id, key=key, value=serialized, session=session) session.commit() + return payload.model_copy(update={"value": value}) diff --git a/backend/requirements-dev.lock.txt b/backend/requirements-dev.lock.txt index 4ae4499f..4a50c2fd 100644 --- a/backend/requirements-dev.lock.txt +++ b/backend/requirements-dev.lock.txt @@ -71,64 +71,64 @@ argon2-cffi-bindings==26.1.0 \ --hash=sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638 \ --hash=sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4 # via argon2-cffi -ast-serialize==0.11.0 \ - --hash=sha256:01d6b2aaa91810a377ea06524388e2e77af3a32dd9e1870e38b3627534f195ce \ - --hash=sha256:09c38f1aa78fd664028486ad1e8e7567b88a47adf373a9e56cf1395012108edf \ - --hash=sha256:0cc0c793063fe07e52244c66ceb2b2c5eecaa826dbb250fdeb7f0502e5697782 \ - --hash=sha256:0ecc5e1282b8ce7c73fc204bde4eb3bad500784e1f0e14f0b9ea624124095821 \ - --hash=sha256:1241b3963a07bd474f0a5d53f054bbbda278c805efd1d7b4ce41d0d3c5becc1e \ - --hash=sha256:18e28f523669d07547795aac3d9a5cf9e7c8d6ddf3dd48fe2090009ab35d0f69 \ - --hash=sha256:1d861d8232f6c1661b805f22c96c3923a76c8feed1a4e504cecc0eaa3cd4077e \ - --hash=sha256:1e185d0dc0712e8568eb0477bb29583341fb00542eff3246a39d3841ee9a0d9c \ - --hash=sha256:2110f04937ac932c03839af266a57a8b29fd97b0789d500fa92782bdad70f475 \ - --hash=sha256:2468e68d7f5b6d80fb619f614734f46ab8c2d474437bcba16cf3f63b5e1b3f79 \ - --hash=sha256:2605d538e17e9569643b5c0722da8d39b1d758391e2a1902f44abfe42b8f7f78 \ - --hash=sha256:26803ffd2daac676b4acf6683d527af091ed2fbf1c7eca861216702c3296d408 \ - --hash=sha256:26bd819c06ee10df68dbcf2fb12c8fd2249bcadffe25ba7011f0686cbfd69b94 \ - --hash=sha256:2fc6fcdbf95a358f3be9af5ee8ccad40e0ef61ed6b84b942003e5bd983ac4cc9 \ - --hash=sha256:3e50f6d77d8f619e04625db5a72ab5c5256ab1b13d6de3dadbcaf3547711631d \ - --hash=sha256:3f552edbf9e7e0fe69b1eda569cf775c2279e5f0b839727d63c39a73adaef966 \ - --hash=sha256:4c4c2df749d1dc6bba9a9e8cc4b5c3dfc39a351fbfe4ce22ca49c2d488c7a7ce \ - --hash=sha256:5b08b50d364d80dfe49002c683e9753f6e703b6c9e230737fe22fc5272235448 \ - --hash=sha256:60d3fd70362b539318e1149ca09df0d0f9ff03e7ea72b117453003a9d8598ab7 \ - --hash=sha256:635d63498882a5ba3b1170c6eaf1aa1c78026267e40dcbbdf4bc42b6d7babeeb \ - --hash=sha256:666be7180941373dbef1083b6d5f8b566c8257afa96b21ac521df96272d4e8ef \ - --hash=sha256:6a5879e45a85c0cce453c1302250a9d644bab59c2d615e6720da03c2d1baffe3 \ - --hash=sha256:6cf3edf38d808e3f26094473f593feb3c05d68d4c53b366c9c3a1c3284438f71 \ - --hash=sha256:75d12e18c8d6691ceda09f514de2effcac628b217c0b3ea17851d217bfdbdab8 \ - --hash=sha256:781657cd74567f5b814ce3529c636bb205824bdb86951e79a05d58190ce2599b \ - --hash=sha256:79baeca550ad2d2eac4619e8b848a9ebe01c6c72bf90549dbb7935df83db8e29 \ - --hash=sha256:82f67de691ae29aa44afc6fff5d7c77f93bd5218954757a9453ebdbe924cfb12 \ - --hash=sha256:8a2ed2669adf5e92f4d0cfd70a72c8f949c66309657eabb54a2c7a0ba2577f8d \ - --hash=sha256:8b4b9862436eaf1442d6a16b7e138d4ff6ae558bdb6fde1c4cd87735093d5744 \ - --hash=sha256:8dcae98b677ca0ed9988d09b83394db65385ca6f45b0ee8892e68c90e5d8df74 \ - --hash=sha256:93206e8059d23952475099967174b7a3f573ca7dc19bdcaa59dbfbf86d76e52b \ - --hash=sha256:9c47daae633c843dec15c5ff5f8ec9c471deecc5257dc1f47156ebb7cc8c5b91 \ - --hash=sha256:9e488e94ba0fe4cef06391702387656e283a3fe77653976a9a95a92a15e6ff39 \ - --hash=sha256:a2fbc5cbff379354d6e07296f0f953955543f60134ffd91fb8f14fc0b4f1aeaa \ - --hash=sha256:aea29e01c9ef7f9c46da1fccdbed1811cc2a85233d0c0d4ac9c04ffaf3c3352a \ - --hash=sha256:b19176a6f04aadca38a80ed4e070fef896db47901257df513dceb9c7fc4751ed \ - --hash=sha256:c077a1c8bb3645e49256f4d31679fa4a3cfe1455cbf99080ddb3bbd40afa2457 \ - --hash=sha256:c345fb4e035ca5db948b781d894f5d98d8a271497ce0fee319f9375d5d358c9b \ - --hash=sha256:c394f64c4a7aba67173dda8b70087b37c82dc899265dca160a9ccd0d2c9e1ff5 \ - --hash=sha256:c590495b1212d91bf696b3333c31ca1765c1b980d07bf5825f9278030211df67 \ - --hash=sha256:c912d84a7eed20aa51ce864a37e60c275a3c4f972056aab20247ec8d01b8457e \ - --hash=sha256:cd6b802249a22d0f44cee22452910a653493e4b974a8dbe401f6b85cb4d74219 \ - --hash=sha256:ce307fe34458a9fb87c660730796e55f977acf5c4e9c645a806faf3436d402a9 \ - --hash=sha256:ce35aa812044357c2a2767964def40c06c7b1ecbb94d98c73715b7978a460beb \ - --hash=sha256:d0ee62b0149144785e2ea158cade30a41d90446cbaeae5745cb996adcee99c68 \ - --hash=sha256:d13a1350eb5767501d4a32bbb31ae63c17ab1c9f13f41c08e7d28150f73ac138 \ - --hash=sha256:d2e3fb7476f2ccc2c2e40155f8e04e6110be98b6f51e52cc5476f3be5b1a1956 \ - --hash=sha256:d475173e59fec5dd1bad84d8351c7f5230119b1d5c68bad93264657589a93043 \ - --hash=sha256:d9c73f6e08778d696f7452f9c61e914b3a0c5ec3f24f9664785550b001bb6bb6 \ - --hash=sha256:dd3c69e27b1be3172880bce2867c359106c28c5909f1ca6154bb7bf89847c419 \ - --hash=sha256:e7523fce1258c810c277799557771e35a851e5c1f132d0a68ca4551675f868b7 \ - --hash=sha256:eb22d9300e7a064fa8c45e2c1e568a4e36c4c91487ea0da5e7f589905365c866 \ - --hash=sha256:ed1a8c44bc0557bdc3dbcf9d38df843b72f9950ef1f92d95db7c45cc8efbf136 \ - --hash=sha256:ee8928ae9305f921484239a97030146dbb674cc10327fdf84124c4a20f73382e \ - --hash=sha256:f02d0905a1c1b81ead9b277a4b35c62698619a0625a73c114b81b26f75612a2d \ - --hash=sha256:f504a2c787d22822fcfa4297bbb014cef1dc06962a30d2710c8c1cd5cf8326ec \ - --hash=sha256:fc0a2fc3335a72a750c84e2a8cf113e4265edc930ac1b64b5f52fb7685070e3a +ast-serialize==0.11.1 \ + --hash=sha256:00adb748034c7b1938ec56925f9f6230ffcd9c56d47355fe32485df29b90b977 \ + --hash=sha256:021afb3482d27d1dace9c8999724a13083e96c7ece785e53e418bf5df3b50e0a \ + --hash=sha256:0bc94b81878a3f999fe4cdb606bf00ef7dff6b4f66d0e089b2cf7bfc00d59beb \ + --hash=sha256:110386eebccf200446d5f4dcc215f80866a5d3d3d054d3ced6ec1bf386cd3f02 \ + --hash=sha256:13903a3f212ab9e5d05a6c3f2337288f4f8f4c18e6ab3817417ae7a3d46dee14 \ + --hash=sha256:1e8d20700766171a8a17f89cf53d7bd9712c509460d28a51f7f98340577b78aa \ + --hash=sha256:20b3371c0403099cd55d59f4bcedb6d3994d9db3fa41711c648760a89ef2a575 \ + --hash=sha256:27eef0739e0110f5db1ff5dfa38b3dadfcb6f0c31975a2b412f9f2bd72139cea \ + --hash=sha256:2db9df3bca1431da25946ee8f2c50df01212e34e6029532bc90b92d05fa8d7a0 \ + --hash=sha256:2f8f33e416a4c7aad12ad757e895c676e953b4027b80b90718dccea83d45111a \ + --hash=sha256:311333c5f58f55fc04121be2c5adffa42a19e1038a43a2cef862fdf58c45bf4f \ + --hash=sha256:31df8649761d5cb6eb0de2209bd4a167c7477c0516147c70c0b2f5bddc7839bb \ + --hash=sha256:341dfc9e6b4e47e5c37e1b7257a77d39f407cf49a371ee90225ab624c2fe8b36 \ + --hash=sha256:38f7141881e783bccc362d201d7fd7437934216b669b9fef39febc2f63fd2d9b \ + --hash=sha256:3eebd3dd25a81839ac7d25b5f3fb3527f6141b35eca29f63d2613fe6254307fd \ + --hash=sha256:4ab6df9898600ff45149b86c12bb5aa3359cc71843ad99ad0c924d11392469ab \ + --hash=sha256:4dd7218870c203eff4533cfc04a39f29077d6789b20f5b746e7648fe5762a548 \ + --hash=sha256:57b7d52d1f5c92905cd648bda9efc83cac33097de1e5bb68de8feca9b5f7e87a \ + --hash=sha256:5c2358b341aca08bd2e1005713bc480bc06eee9f345b67fcb826829aa7b8d0d3 \ + --hash=sha256:651a7890558896c3e08089e51ab3b3a43710eab15b72ad861c400bc9a51923ba \ + --hash=sha256:65c836d1ab7af65e64a1dd73de82572566e838dd27529b1124eac0ac86f35e76 \ + --hash=sha256:68929da7fb1c7375f69641baac9519d5c41ee441cd09a591ea5dfc83107ffe6f \ + --hash=sha256:6a57e0e025e9dcee48e466b3cf909178e298d238148664e796fc0f60ded5e52e \ + --hash=sha256:6b43f5a9b9a8dd20ba3124914e63aa7d7427de761ac849081cda403c2112fda0 \ + --hash=sha256:74093ed682ba58456f0d4450da7fb62b826fddd97824d44fc4a81ca6e0bf9e23 \ + --hash=sha256:753afabcc4abf295f515ea33185cf997a1990b88a29d3a3336a9acf62ea85950 \ + --hash=sha256:7b524cb1b08e15db0f116075c39ab51674b6d3c8103ec1a59c8ca464be83e28a \ + --hash=sha256:7b9a6089f1337838492b217707e9a55d0b9b407fe9169a960fa12282abf2234c \ + --hash=sha256:82359d00ea808c260c327e43955c0190bb7baef152a01ae0b5c74d05387b9552 \ + --hash=sha256:8945557ed3015173dbf8d44184da9621add9162720acbc0ac4832b5a091b4e8a \ + --hash=sha256:8b54e44763a851c336ca137c60a2434511f0d155e0b924ef374bfbc8d8db8927 \ + --hash=sha256:8ca9e48edf246f09fd9bdb64fbd9cd8d18cd2a69ffc24d44891466e17595094f \ + --hash=sha256:98b8aba6bb682b9e8859c628382b4881c0600b28056d63c3168c227c449f3284 \ + --hash=sha256:9961abcb93bf03652ab09c00862a2396abacd57551c769ad34490a2ae508a61d \ + --hash=sha256:a015ede631eeb098a23de8ec0cfe11a1dd02159ab7a26bdc7c6bde2befbbc7b4 \ + --hash=sha256:a579ea6f473aab3958a64734194b22fbaec108ec45d994c28d3bd721e1e1da32 \ + --hash=sha256:aa8d0a9f6d02e7626cdf540555bc2f5313eabe803dcca04854fc628ddf4b1058 \ + --hash=sha256:aecb606f67f21fd1c0ffba1826470d2ee400e41b23881ad9a1b2157306865b1a \ + --hash=sha256:b391becd31e9889da438d388aac0362e3dfd222affbe45d920580c351486c787 \ + --hash=sha256:b77d6a2267227d68d8221cfca177f8c6d9f68019f3250401d0355d2638db9fb9 \ + --hash=sha256:b9cbed53bb8992b72e587dd47d0ca71703f5f715420f48ed57587c06c2fcd213 \ + --hash=sha256:b9e61143a5904f46daa0cde74ba60d3b7c0e3e000752ee138e2bc68e126a5803 \ + --hash=sha256:c1dc60251d93beff32d4147ecd4ccc518b0f022a900093f6f3021c2012416f0f \ + --hash=sha256:cc1a78b913f8665dda145999b1b4805ad2ef442ecbe3b819512cf7f95e70498a \ + --hash=sha256:cc5db2983805f6be786488aac8c5998d5b71965488d1b18c44d435a2205a5cb4 \ + --hash=sha256:d1ef9c478d8c8ca83499704d13e5ac32c840ed7ca7884aaaf716166aca5a2806 \ + --hash=sha256:daec16b026c5741950a00c708acdf698c4f17babbe4829dd8232324822e9514f \ + --hash=sha256:e209a797fdf8680d58d2e969a9ddb25058ea682f6cff59dbabc98d8aef4e0db1 \ + --hash=sha256:e47b9b028efbc0486263a49f782ad0ae3e879855fe5a2897b59feefb78e1c40c \ + --hash=sha256:e5a9c53e64732118ae8235d0e57eb90e2333e90ff749a1a7dca6f8bfdfa5200e \ + --hash=sha256:e68ac7d7fd1a5d42a3a6d726d7cc8a7e0c9987934f11c6a5162aa2cf68e81e24 \ + --hash=sha256:ed449d786b9032a7b85fcd6c2f7f54c4cd0e1e1ad254d396805ff922096bf08d \ + --hash=sha256:ef3950747f3a588f692cf4d6f2937eedd1a2d5e199f863af29e8ec7da78a4f36 \ + --hash=sha256:f35bb9ddeed4008c7d3e272dd1999885d712e1ce623b0a65ba8a8ed1d51c35cf \ + --hash=sha256:f7ca1557553fdab313999ad69156de154ff87152cae963698be2e765164bb6c0 \ + --hash=sha256:f9454960bbf185d33c669ccd5c9b76c96709a4542a95a65e27342c4d10dd8e20 \ + --hash=sha256:ff8ddc1453bfe7934292409c7b8e9f68b0a6cff4e4c12535e4b4a66fd83aca0c # via mypy asttokens==3.0.2 \ --hash=sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2 \ @@ -909,9 +909,9 @@ pexpect==4.9.0 \ --hash=sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523 \ --hash=sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f # via ipython -platformdirs==4.11.7 \ - --hash=sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d \ - --hash=sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6 +platformdirs==4.11.8 \ + --hash=sha256:52f2f181bbfde907966932cc8312d967d02976422d66d537ea16092b8e291081 \ + --hash=sha256:f23abafea7dd4276d1f29104b83598d7dcc567cafd07c9c951e66665645437fc # via black pluggy==1.6.0 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ @@ -950,9 +950,9 @@ ptyprocess==0.7.0 \ --hash=sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35 \ --hash=sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220 # via pexpect -pure-eval==0.2.3 \ - --hash=sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0 \ - --hash=sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42 +pure-eval==0.2.4 \ + --hash=sha256:260c2774686e651b79f8b8e7fc9d80b3599ea6a66334b47d5f4abb69fc2c0ea1 \ + --hash=sha256:96cae060a313cfaad51bb761278bfb0e62dc0248d9315a81173752dc546cd37a # via stack-data pycparser==3.0 \ --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ diff --git a/backend/tests/test_recent_files.py b/backend/tests/test_recent_files.py index 34d049cb..3f5a027a 100644 --- a/backend/tests/test_recent_files.py +++ b/backend/tests/test_recent_files.py @@ -289,7 +289,7 @@ async def get_file_info(self, path: str) -> FileInfo: class TestFileSearchSettingsApi: - def test_admin_reads_updates_and_resets_file_search_policy(self, client: TestClient, auth_headers_admin: dict[str, str]) -> None: + def test_admin_reads_and_updates_individual_file_search_fields(self, client: TestClient, auth_headers_admin: dict[str, str]) -> None: initial = client.get("/api/admin/settings/file-search", headers=auth_headers_admin) assert initial.status_code == 200 assert initial.json()["source"] == "default" @@ -297,22 +297,13 @@ def test_admin_reads_updates_and_resets_file_search_policy(self, client: TestCli update = client.put( "/api/admin/settings/file-search", headers=auth_headers_admin, - json={ - "settings": { - "retention_limit": 2, - "result_limit": 1, - "excluded_categories": ["images", "temporary_backup"], - "excluded_extensions": ["bak"], - } - }, + json={"field": "excluded_extensions", "value": ["bak"]}, ) assert update.status_code == 200 - assert update.json()["source"] == "database" - assert update.json()["settings"]["excluded_extensions"] == [".bak"] + assert update.json() == {"field": "excluded_extensions", "value": [".bak"]} - reset = client.put("/api/admin/settings/file-search", headers=auth_headers_admin, json={"reset_to_default": True}) - assert reset.status_code == 200 - assert reset.json()["source"] == "default" + invalid_legacy_payload = client.put("/api/admin/settings/file-search", headers=auth_headers_admin, json={"reset_to_default": True}) + assert invalid_legacy_payload.status_code == 422 def test_policy_clamps_results_trims_history_and_does_not_retroactively_exclude( self, @@ -327,19 +318,13 @@ def test_policy_clamps_results_trims_history_and_does_not_retroactively_exclude( ) assert initial_record.status_code == 200 - update = client.put( - "/api/admin/settings/file-search", - headers=auth_headers_admin, - json={ - "settings": { - "retention_limit": 2, - "result_limit": 1, - "excluded_categories": ["images", "temporary_backup"], - "excluded_extensions": ["txt"], - } - }, - ) - assert update.status_code == 200 + for payload in ( + {"field": "retention_limit", "value": 2}, + {"field": "result_limit", "value": 1}, + {"field": "excluded_extensions", "value": ["txt"]}, + ): + update = client.put("/api/admin/settings/file-search", headers=auth_headers_admin, json=payload) + assert update.status_code == 200 excluded_record = client.post( "/api/browse/recent-files", @@ -357,14 +342,7 @@ def test_policy_clamps_results_trims_history_and_does_not_retroactively_exclude( reduced = client.put( "/api/admin/settings/file-search", headers=auth_headers_admin, - json={ - "settings": { - "retention_limit": 0, - "result_limit": 1, - "excluded_categories": ["images", "temporary_backup"], - "excluded_extensions": ["txt"], - } - }, + json={"field": "retention_limit", "value": 0}, ) assert reduced.status_code == 200 assert client.get("/api/browse/recent-files", headers=auth_headers_user).json()["results"] == [] @@ -388,17 +366,10 @@ def test_admin_updates_policy_and_zero_retention_clears_history( update = client.put( "/api/admin/settings/file-search", headers=auth_headers_admin, - json={ - "settings": { - "retention_limit": 0, - "result_limit": 10, - "excluded_categories": ["images", "temporary_backup"], - "excluded_extensions": ["bak"], - } - }, + json={"field": "retention_limit", "value": 0}, ) assert update.status_code == 200 - assert update.json()["settings"]["retention_limit"] == 0 + assert update.json() == {"field": "retention_limit", "value": 0} assert client.get("/api/browse/recent-files", headers=auth_headers_user).json()["results"] == [] disabled_record = client.post( diff --git a/backend/tests/test_system_settings.py b/backend/tests/test_system_settings.py index 9d57012a..1dfcbc43 100644 --- a/backend/tests/test_system_settings.py +++ b/backend/tests/test_system_settings.py @@ -5,15 +5,17 @@ import pytest from fastapi.testclient import TestClient +from sqlalchemy import text from sqlmodel import Session, create_engine, select from sqlmodel.pool import StaticPool import app.api.system_settings as system_settings_api import app.db.database as database_module from app.core.system_setting_definitions import SystemSettingKey +from app.db.migrations import _apply_per_field_system_settings_migration from app.models.connection import Connection from app.models.oidc import OidcFlow, OidcFlowPurpose, OidcFlowStatus, OidcProviderConfiguration -from app.models.system_settings import SmbPolicySettings, SmbSettingsUpdate, SystemSetting +from app.models.system_settings import SmbAuthenticationModeUpdate, SmbPolicySettings, SystemSetting from app.models.user import User from app.services.system_settings import ( SmbPolicyConfigurationError, @@ -26,6 +28,75 @@ from app.services.system_settings import store as system_settings_store +def _create_system_settings_table(connection) -> None: + connection.execute( + text( + """ + CREATE TABLE systemsetting ( + key VARCHAR PRIMARY KEY, + value VARCHAR NOT NULL, + updated_at TIMESTAMP NOT NULL, + updated_by_user_id CHAR(32) + ) + """ + ) + ) + + +def test_policy_blob_migration_materializes_missing_keys_and_keeps_existing_values() -> None: + engine = create_engine("sqlite://") + try: + with engine.begin() as connection: + _create_system_settings_table(connection) + connection.execute( + text("INSERT INTO systemsetting (key, value, updated_at, updated_by_user_id) VALUES (:key, :value, :updated_at, :user_id)"), + { + "key": "file_search.policy", + "value": '{"retention_limit":25,"result_limit":12,"excluded_categories":["images"],"excluded_extensions":[".tmp"]}', + "updated_at": "2025-01-02T03:04:05+00:00", + "user_id": "legacy-user", + }, + ) + connection.execute( + text( + "INSERT INTO systemsetting (key, value, updated_at, updated_by_user_id) VALUES ('file_search.result_limit', '33', CURRENT_TIMESTAMP, 'new-user')" + ) + ) + _apply_per_field_system_settings_migration(connection) + + rows = connection.execute(text("SELECT key, value, updated_by_user_id FROM systemsetting")).mappings().all() + values = {row["key"]: row for row in rows} + assert "file_search.policy" not in values + assert values["file_search.retention_limit"]["value"] == "25" + assert values["file_search.retention_limit"]["updated_by_user_id"] == "legacy-user" + assert values["file_search.result_limit"]["value"] == "33" + assert values["file_search.result_limit"]["updated_by_user_id"] == "new-user" + assert values["file_search.excluded_categories"]["value"] == '["images"]' + assert values["file_search.excluded_extensions"]["value"] == '[".tmp"]' + finally: + engine.dispose() + + +def test_policy_blob_migration_rolls_back_invalid_legacy_json() -> None: + engine = create_engine("sqlite://") + try: + with engine.begin() as connection: + _create_system_settings_table(connection) + connection.execute( + text("INSERT INTO systemsetting (key, value, updated_at) VALUES ('file_search.policy', 'not-json', CURRENT_TIMESTAMP)") + ) + + with pytest.raises(RuntimeError, match="Cannot migrate invalid legacy system setting file_search.policy"): + with engine.begin() as connection: + _apply_per_field_system_settings_migration(connection) + + with engine.connect() as connection: + rows = connection.execute(text("SELECT key FROM systemsetting")).scalars().all() + assert rows == ["file_search.policy"] + finally: + engine.dispose() + + class TestAboutSettingsApi: def test_admin_can_fetch_safe_about_information(self, client: TestClient, auth_headers_admin: dict[str, str]) -> None: response = client.get("/api/admin/settings/about", headers=auth_headers_admin) @@ -156,33 +227,27 @@ def test_admin_can_update_advanced_settings(self, client: TestClient, auth_heade response = client.put( "/api/admin/settings/advanced", headers=auth_headers_admin, - json={ - "preprocessors": { - "imagemagick": {"timeout_seconds": 45}, - }, - }, + json={"field": "preprocessors.imagemagick.timeout_seconds", "value": 45}, ) assert response.status_code == 200 - data = response.json() - assert data["preprocessors"]["imagemagick"]["timeout_seconds"]["value"] == 45 + assert response.json() == {"field": "preprocessors.imagemagick.timeout_seconds", "value": 45} def test_admin_can_update_pdf_rollout_and_cpu_limits(self, client: TestClient, auth_headers_admin: dict[str, str]) -> None: response = client.put( "/api/admin/settings/advanced", headers=auth_headers_admin, - json={"pdf": {"screen_derivative_enabled": 1, "cpu_time_seconds": 45}}, + json={"field": "pdf.screen_derivative.enabled", "value": 1}, ) assert response.status_code == 200 - assert response.json()["pdf"]["screen_derivative_enabled"]["value"] == 1 - assert response.json()["pdf"]["cpu_time_seconds"]["value"] == 45 + assert response.json() == {"field": "pdf.screen_derivative.enabled", "value": 1} def test_update_rejects_out_of_range_values(self, client: TestClient, auth_headers_admin: dict[str, str]) -> None: response = client.put( "/api/admin/settings/advanced", headers=auth_headers_admin, - json={"preprocessors": {"imagemagick": {"timeout_seconds": 1}}}, + json={"field": "preprocessors.imagemagick.timeout_seconds", "value": 1}, ) assert response.status_code == 400 @@ -204,33 +269,29 @@ def test_admin_can_fetch_default_smb_settings(self, client: TestClient, auth_hea assert data["require_signing"] is True assert data["require_encryption"] is False - def test_admin_can_update_smb_policy_and_streaming_setting( + def test_admin_can_update_independent_smb_fields( self, client: TestClient, auth_headers_admin: dict[str, str], session: Session ) -> None: - payload = { - "read_chunk_size_bytes": 2 * 1024 * 1024, - "policy": { - "authentication_mode": "kerberos_required", - "encryption_mode": "encryption_required", - "connection_timeout_seconds": 45, - }, - } - - response = client.put("/api/admin/settings/smb", headers=auth_headers_admin, json=payload) + for payload in ( + {"field": "read_chunk_size_bytes", "value": 2 * 1024 * 1024}, + {"field": "authentication_mode", "value": "kerberos_required"}, + {"field": "encryption_mode", "value": "encryption_required"}, + {"field": "connection_timeout_seconds", "value": 45}, + ): + response = client.put("/api/admin/settings/smb", headers=auth_headers_admin, json=payload) + assert response.status_code == 200 + assert response.json() == payload - assert response.status_code == 200 - data = response.json() - assert data["read_chunk_size_bytes"]["value"] == 2 * 1024 * 1024 - assert data["policy_source"] == "database" + data = client.get("/api/admin/settings/smb", headers=auth_headers_admin).json() assert data["policy"] == { "authentication_mode": "kerberos_required", "encryption_mode": "encryption_required", "connection_timeout_seconds": 45, } assert data["require_encryption"] is True - assert session.get(SystemSetting, SystemSettingKey.SMB_POLICY.value) is not None + assert session.get(SystemSetting, SystemSettingKey.SMB_AUTHENTICATION_MODE.value) is not None - def test_policy_ignores_retired_target_access_fields(self, client: TestClient, auth_headers_admin: dict[str, str]) -> None: + def test_smb_update_rejects_legacy_policy_payload(self, client: TestClient, auth_headers_admin: dict[str, str]) -> None: response = client.put( "/api/admin/settings/smb", headers=auth_headers_admin, @@ -246,15 +307,12 @@ def test_policy_ignores_retired_target_access_fields(self, client: TestClient, a }, ) - assert response.status_code == 200 - assert response.json()["policy"] == { - "authentication_mode": "negotiate", - "encryption_mode": "signing_only", - "connection_timeout_seconds": 30, - } + assert response.status_code == 422 def test_regular_user_cannot_update_smb_settings(self, client: TestClient, auth_headers_user: dict[str, str]) -> None: - response = client.put("/api/admin/settings/smb", headers=auth_headers_user, json={"reset_policy": True}) + response = client.put( + "/api/admin/settings/smb", headers=auth_headers_user, json={"field": "authentication_mode", "value": "negotiate"} + ) assert response.status_code == 403 @@ -263,7 +321,7 @@ def test_policy_update_retires_existing_smb_runtime_state(self, client: TestClie response = client.put( "/api/admin/settings/smb", headers=auth_headers_admin, - json={"policy": {"authentication_mode": "kerberos_required", "connection_timeout_seconds": 30}}, + json={"field": "authentication_mode", "value": "kerberos_required"}, ) assert response.status_code == 200 @@ -284,7 +342,7 @@ async def wait_for_refresh_completion() -> None: ): update_task = asyncio.create_task( system_settings_api.put_smb_settings( - SmbSettingsUpdate(policy=SmbPolicySettings(authentication_mode="kerberos_required")), + SmbAuthenticationModeUpdate(field="authentication_mode", value="kerberos_required"), current_user=admin_user, session=session, ) @@ -296,7 +354,8 @@ async def wait_for_refresh_completion() -> None: allow_refresh_to_finish.set() response = await update_task - assert response.policy.authentication_mode == "kerberos_required" + assert response.field == "authentication_mode" + assert response.value == "kerberos_required" mock_retire.assert_awaited_once() mock_refresh.assert_awaited_once() @@ -305,15 +364,10 @@ def test_invalid_policy_update_does_not_retire_smb_runtime_state(self, client: T response = client.put( "/api/admin/settings/smb", headers=auth_headers_admin, - json={ - "policy": { - "authentication_mode": "negotiate", - "connection_timeout_seconds": 1, - } - }, + json={"field": "connection_timeout_seconds", "value": 1}, ) - assert response.status_code == 422 + assert response.status_code == 400 mock_refresh.assert_not_awaited() def test_read_chunk_update_does_not_refresh_smb_runtime_state(self, client: TestClient, auth_headers_admin: dict[str, str]) -> None: @@ -321,7 +375,7 @@ def test_read_chunk_update_does_not_refresh_smb_runtime_state(self, client: Test response = client.put( "/api/admin/settings/smb", headers=auth_headers_admin, - json={"read_chunk_size_bytes": 2 * 1024 * 1024}, + json={"field": "read_chunk_size_bytes", "value": 2 * 1024 * 1024}, ) assert response.status_code == 200 @@ -332,7 +386,7 @@ def test_default_policy_save_does_not_refresh_smb_runtime_state(self, client: Te response = client.put( "/api/admin/settings/smb", headers=auth_headers_admin, - json={"policy": {"authentication_mode": "negotiate", "connection_timeout_seconds": 30}}, + json={"field": "authentication_mode", "value": "negotiate"}, ) assert response.status_code == 200 @@ -384,20 +438,19 @@ def test_smbclient_policy_requires_encryption_only_for_strict_mode() -> None: class TestNetworkSettingsApi: def test_admin_can_update_network_settings(self, client: TestClient, auth_headers_admin: dict[str, str], session: Session) -> None: - response = client.put( + public_url_response = client.put( "/api/admin/settings/network", headers=auth_headers_admin, - json={ - "public_url": "https://files.example.test/", - "trusted_proxy_cidrs": ["10.0.0.4/24", "2001:db8::1/64", "10.0.0.0/24"], - }, + json={"field": "public_url", "value": "https://files.example.test/"}, + ) + cidrs_response = client.put( + "/api/admin/settings/network", + headers=auth_headers_admin, + json={"field": "trusted_proxy_cidrs", "value": ["10.0.0.4/24", "2001:db8::1/64", "10.0.0.0/24"]}, ) - assert response.status_code == 200 - assert response.json() == { - "public_url": "https://files.example.test", - "trusted_proxy_cidrs": ["10.0.0.0/24", "2001:db8::/64"], - } + assert public_url_response.json() == {"field": "public_url", "value": "https://files.example.test"} + assert cidrs_response.json() == {"field": "trusted_proxy_cidrs", "value": ["10.0.0.0/24", "2001:db8::/64"]} stored_public_url = session.get(SystemSetting, "network.public_url") assert stored_public_url is not None assert stored_public_url.value == "https://files.example.test" @@ -406,7 +459,7 @@ def test_network_settings_reject_public_url_path(self, client: TestClient, auth_ response = client.put( "/api/admin/settings/network", headers=auth_headers_admin, - json={"public_url": "https://files.example.test/sambee", "trusted_proxy_cidrs": []}, + json={"field": "public_url", "value": "https://files.example.test/sambee"}, ) assert response.status_code == 400 @@ -416,7 +469,7 @@ def test_regular_user_cannot_update_network_settings(self, client: TestClient, a response = client.put( "/api/admin/settings/network", headers=auth_headers_user, - json={"public_url": "https://files.example.test", "trusted_proxy_cidrs": []}, + json={"field": "public_url", "value": "https://files.example.test"}, ) assert response.status_code == 403 @@ -437,7 +490,7 @@ def test_public_url_change_invalidates_oidc_flows( response = client.put( "/api/admin/settings/network", headers=auth_headers_admin, - json={"public_url": "https://new.example.test", "trusted_proxy_cidrs": []}, + json={"field": "public_url", "value": "https://new.example.test"}, ) assert response.status_code == 200 diff --git a/backend/tests/test_user_settings.py b/backend/tests/test_user_settings.py index e678369d..a2f59b1e 100644 --- a/backend/tests/test_user_settings.py +++ b/backend/tests/test_user_settings.py @@ -1,267 +1,128 @@ +import json + from fastapi.testclient import TestClient from sqlmodel import Session, select from app.core.user_setting_definitions import UserSettingKey from app.models.user_settings import UserSetting +from app.services import user_settings class TestCurrentUserSettingsApi: + def test_loads_built_in_theme_ids_from_packaged_manifest_location(self, tmp_path, monkeypatch) -> None: + missing_manifest = tmp_path / "source" / "built_in_theme_ids.json" + packaged_manifest = tmp_path / "package" / "shared" / "built_in_theme_ids.json" + packaged_manifest.parent.mkdir(parents=True) + packaged_manifest.write_text(json.dumps(["sambee-light", "sambee-dark"])) + monkeypatch.setattr(user_settings, "BUILT_IN_THEME_IDS_PATHS", (missing_manifest, packaged_manifest)) + + assert user_settings._built_in_theme_ids() == {"sambee-dark", "sambee-light"} + def test_user_gets_default_settings(self, client: TestClient, auth_headers_user: dict[str, str]) -> None: response = client.get("/api/auth/me/settings", headers=auth_headers_user) assert response.status_code == 200 data = response.json() - assert data["appearance"]["theme_id"] == "sambee-light" - assert data["appearance"]["custom_themes"] == [] - assert data["localization"]["language"] == "browser" - assert data["localization"]["regional_locale"] == "browser" + assert data["appearance"] == {"theme_id": "sambee-light", "custom_themes": []} + assert data["localization"] == {"language": "browser", "regional_locale": "browser"} assert data["browser"]["quick_nav_include_dot_directories"] is False - assert data["browser"]["quick_bar_shortcut_hint_visibility"] == "auto" - assert data["browser"]["file_browser_view_mode"] == "list" - assert data["browser"]["pane_mode"] == "single" assert data["browser"]["selected_connection_id"] is None - assert data["browser"]["viewer_associations"] == {} - assert data["text_editor"]["max_file_size_bytes"] == 52428800 - assert data["text_editor"]["word_wrap_enabled"] is None - - def test_user_can_update_own_settings(self, client: TestClient, auth_headers_user: dict[str, str], session: Session) -> None: - response = client.put( - "/api/auth/me/settings", - headers=auth_headers_user, - json={ - "appearance": { - "theme_id": "sambee-dark", - "custom_themes": [ - { - "id": "custom-theme", - "name": "Custom Theme", - "mode": "light", - "primary": {"main": "#123456"}, - } - ], - }, - "localization": { - "language": "en", - "regional_locale": "en-GB", - }, - "browser": { - "quick_nav_include_dot_directories": True, - "quick_bar_shortcut_hint_visibility": "never", - "file_browser_view_mode": "details", - "pane_mode": "dual", - "selected_connection_id": "conn-123", - "viewer_associations": { - ".md": "markdown", - "application/pdf": "pdf", - }, - }, - "text_editor": { - "max_file_size_bytes": 4194304, - "word_wrap_enabled": True, - }, - }, - ) + assert data["text_editor"] == {"max_file_size_bytes": 52_428_800, "word_wrap_enabled": None} - assert response.status_code == 200 - data = response.json() - assert data["appearance"]["theme_id"] == "sambee-dark" - assert data["appearance"]["custom_themes"][0]["id"] == "custom-theme" - assert data["localization"]["language"] == "en" - assert data["localization"]["regional_locale"] == "en-GB" - assert data["browser"]["quick_nav_include_dot_directories"] is True - assert data["browser"]["quick_bar_shortcut_hint_visibility"] == "never" - assert data["browser"]["file_browser_view_mode"] == "details" - assert data["browser"]["pane_mode"] == "dual" - assert data["browser"]["selected_connection_id"] == "conn-123" - assert data["browser"]["viewer_associations"] == { - ".md": "markdown", - "application/pdf": "pdf", - } - assert data["text_editor"]["max_file_size_bytes"] == 4194304 - assert data["text_editor"]["word_wrap_enabled"] is True - - rows = session.exec(select(UserSetting)).all() - values = {row.key: row.value for row in rows} - assert values[UserSettingKey.APPEARANCE_THEME_ID.value] == "sambee-dark" - assert '"id":"custom-theme"' in values[UserSettingKey.APPEARANCE_CUSTOM_THEMES.value] - assert values[UserSettingKey.LOCALIZATION_LANGUAGE.value] == "en" - assert values[UserSettingKey.LOCALIZATION_REGIONAL_LOCALE.value] == "en-GB" - assert values[UserSettingKey.BROWSER_QUICK_NAV_INCLUDE_DOT_DIRECTORIES.value] == "true" - assert values[UserSettingKey.BROWSER_QUICK_BAR_SHORTCUT_HINT_VISIBILITY.value] == "never" - assert values[UserSettingKey.BROWSER_FILE_BROWSER_VIEW_MODE.value] == "details" - assert values[UserSettingKey.BROWSER_PANE_MODE.value] == "dual" - assert values[UserSettingKey.BROWSER_SELECTED_CONNECTION_ID.value] == "conn-123" - assert values[UserSettingKey.BROWSER_VIEWER_ASSOCIATIONS.value] == '{".md":"markdown","application/pdf":"pdf"}' - assert values[UserSettingKey.TEXT_EDITOR_MAX_FILE_SIZE_BYTES.value] == "4194304" - assert values[UserSettingKey.TEXT_EDITOR_WORD_WRAP_ENABLED.value] == "true" - - def test_user_can_clear_custom_themes(self, client: TestClient, auth_headers_user: dict[str, str], session: Session) -> None: - seed_response = client.put( - "/api/auth/me/settings", - headers=auth_headers_user, - json={ - "appearance": { - "custom_themes": [ - { - "id": "custom-theme", - "name": "Custom Theme", - "mode": "light", - "primary": {"main": "#123456"}, - } - ] - } + def test_user_updates_independent_settings(self, client: TestClient, auth_headers_user: dict[str, str], session: Session) -> None: + updates = ( + { + "field": "appearance.custom_themes", + "value": [{"id": "custom-theme", "name": "Custom", "mode": "light", "primary": {"main": "#123456"}}], }, + {"field": "appearance.theme_id", "value": "custom-theme"}, + {"field": "localization.language", "value": "en"}, + {"field": "localization.regional_locale", "value": "en-GB"}, + {"field": "browser.quick_nav_include_dot_directories", "value": True}, + {"field": "browser.quick_bar_shortcut_hint_visibility", "value": "never"}, + {"field": "browser.file_browser_view_mode", "value": "details"}, + {"field": "browser.pane_mode", "value": "dual"}, + {"field": "browser.selected_connection_id", "value": "conn-123"}, + {"field": "browser.viewer_associations", "value": {".MD": " markdown ", "application/pdf": "pdf"}}, + {"field": "text_editor.max_file_size_bytes", "value": 4_194_304}, + {"field": "text_editor.word_wrap_enabled", "value": True}, ) - assert seed_response.status_code == 200 - - clear_response = client.put( - "/api/auth/me/settings", - headers=auth_headers_user, - json={"appearance": {"custom_themes": []}}, - ) - - assert clear_response.status_code == 200 - assert clear_response.json()["appearance"]["custom_themes"] == [] - - rows = session.exec(select(UserSetting)).all() - keys = {row.key for row in rows} - assert UserSettingKey.APPEARANCE_CUSTOM_THEMES.value not in keys - - def test_user_can_clear_selected_connection_preference( - self, - client: TestClient, - auth_headers_user: dict[str, str], - session: Session, + for payload in updates: + response = client.put("/api/auth/me/settings", headers=auth_headers_user, json=payload) + assert response.status_code == 200 + assert response.json()["field"] == payload["field"] + + data = client.get("/api/auth/me/settings", headers=auth_headers_user).json() + assert data["appearance"]["theme_id"] == "custom-theme" + assert data["browser"]["viewer_associations"] == {".md": "markdown", "application/pdf": "pdf"} + assert data["text_editor"]["max_file_size_bytes"] == 4_194_304 + keys = {row.key for row in session.exec(select(UserSetting)).all()} + assert UserSettingKey.APPEARANCE_THEME_ID.value in keys + assert UserSettingKey.TEXT_EDITOR_WORD_WRAP_ENABLED.value in keys + + def test_user_can_clear_compound_and_nullable_settings( + self, client: TestClient, auth_headers_user: dict[str, str], session: Session ) -> None: - seed_response = client.put( - "/api/auth/me/settings", - headers=auth_headers_user, - json={"browser": {"selected_connection_id": "conn-123"}}, - ) - assert seed_response.status_code == 200 - - clear_response = client.put( - "/api/auth/me/settings", - headers=auth_headers_user, - json={"browser": {"selected_connection_id": None}}, - ) - - assert clear_response.status_code == 200 - assert clear_response.json()["browser"]["selected_connection_id"] is None - - rows = session.exec(select(UserSetting)).all() - keys = {row.key for row in rows} + for payload in ( + {"field": "browser.selected_connection_id", "value": "conn-123"}, + {"field": "browser.viewer_associations", "value": {".md": "markdown"}}, + {"field": "browser.selected_connection_id", "value": None}, + {"field": "browser.viewer_associations", "value": {}}, + {"field": "text_editor.word_wrap_enabled", "value": None}, + ): + response = client.put("/api/auth/me/settings", headers=auth_headers_user, json=payload) + assert response.status_code == 200 + + keys = {row.key for row in session.exec(select(UserSetting)).all()} assert UserSettingKey.BROWSER_SELECTED_CONNECTION_ID.value not in keys - - def test_user_can_clear_viewer_associations( - self, - client: TestClient, - auth_headers_user: dict[str, str], - session: Session, - ) -> None: - seed_response = client.put( - "/api/auth/me/settings", - headers=auth_headers_user, - json={"browser": {"viewer_associations": {".md": "markdown"}}}, - ) - assert seed_response.status_code == 200 - - clear_response = client.put( - "/api/auth/me/settings", - headers=auth_headers_user, - json={"browser": {"viewer_associations": {}}}, - ) - - assert clear_response.status_code == 200 - assert clear_response.json()["browser"]["viewer_associations"] == {} - - rows = session.exec(select(UserSetting)).all() - keys = {row.key for row in rows} assert UserSettingKey.BROWSER_VIEWER_ASSOCIATIONS.value not in keys + assert UserSettingKey.TEXT_EDITOR_WORD_WRAP_ENABLED.value not in keys def test_user_settings_are_isolated_per_user( - self, - client: TestClient, - auth_headers_user: dict[str, str], - auth_headers_admin: dict[str, str], + self, client: TestClient, auth_headers_user: dict[str, str], auth_headers_admin: dict[str, str] ) -> None: - update_response = client.put( - "/api/auth/me/settings", - headers=auth_headers_user, - json={"appearance": {"theme_id": "sambee-dark"}}, - ) - assert update_response.status_code == 200 - - admin_response = client.get("/api/auth/me/settings", headers=auth_headers_admin) - assert admin_response.status_code == 200 - assert admin_response.json()["appearance"]["theme_id"] == "sambee-light" - - def test_update_rejects_empty_theme_id(self, client: TestClient, auth_headers_user: dict[str, str]) -> None: response = client.put( - "/api/auth/me/settings", - headers=auth_headers_user, - json={"appearance": {"theme_id": " "}}, + "/api/auth/me/settings", headers=auth_headers_user, json={"field": "appearance.theme_id", "value": "sambee-dark"} ) - - assert response.status_code == 400 - assert response.json()["detail"] == "Theme ID cannot be empty" - - def test_update_rejects_invalid_custom_theme_payload(self, client: TestClient, auth_headers_user: dict[str, str]) -> None: - response = client.put( - "/api/auth/me/settings", - headers=auth_headers_user, - json={ - "appearance": { - "custom_themes": [ - { - "id": "broken-theme", - "name": "Broken Theme", - "mode": "light", - } - ] - } + assert response.status_code == 200 + assert client.get("/api/auth/me/settings", headers=auth_headers_admin).json()["appearance"]["theme_id"] == "sambee-light" + + def test_rejects_invalid_values_and_legacy_shapes(self, client: TestClient, auth_headers_user: dict[str, str]) -> None: + invalid_values = ( + {"field": "appearance.theme_id", "value": " "}, + {"field": "appearance.theme_id", "value": "unknown-theme"}, + { + "field": "appearance.custom_themes", + "value": [{"id": "sambee-dark", "name": "Collision", "mode": "light", "primary": {"main": "#123456"}}], }, + {"field": "browser.viewer_associations", "value": {" ": "markdown"}}, + {"field": "text_editor.max_file_size_bytes", "value": 1024}, + {"field": "localization.regional_locale", "value": "english_us"}, ) - - assert response.status_code == 400 - assert response.json()["detail"] == "Custom themes payload contains an invalid theme definition" - - def test_update_rejects_invalid_browser_preference_values(self, client: TestClient, auth_headers_user: dict[str, str]) -> None: - response = client.put( - "/api/auth/me/settings", - headers=auth_headers_user, - json={"browser": {"file_browser_view_mode": "grid"}}, + for payload in invalid_values: + assert client.put("/api/auth/me/settings", headers=auth_headers_user, json=payload).status_code == 400 + + for payload in ( + {"appearance": {"theme_id": "sambee-dark"}}, + {"field": "unknown", "value": True}, + {"field": "appearance.theme_id", "value": "sambee-dark", "unexpected": True}, + ): + assert client.put("/api/auth/me/settings", headers=auth_headers_user, json=payload).status_code == 422 + + def test_prevents_removing_the_active_custom_theme(self, client: TestClient, auth_headers_user: dict[str, str]) -> None: + custom_themes = [{"id": "custom-theme", "name": "Custom", "mode": "light", "primary": {"main": "#123456"}}] + assert ( + client.put( + "/api/auth/me/settings", headers=auth_headers_user, json={"field": "appearance.custom_themes", "value": custom_themes} + ).status_code + == 200 ) - - assert response.status_code == 400 - assert response.json()["detail"] == "File browser view mode must be one of: list, details" - - def test_update_rejects_invalid_viewer_associations(self, client: TestClient, auth_headers_user: dict[str, str]) -> None: - response = client.put( - "/api/auth/me/settings", - headers=auth_headers_user, - json={"browser": {"viewer_associations": {" ": "markdown"}}}, - ) - - assert response.status_code == 400 - assert response.json()["detail"] == "Viewer associations must use non-empty file keys and viewer IDs" - - def test_update_rejects_invalid_text_editor_max_file_size(self, client: TestClient, auth_headers_user: dict[str, str]) -> None: - response = client.put( - "/api/auth/me/settings", - headers=auth_headers_user, - json={"text_editor": {"max_file_size_bytes": 1024}}, + assert ( + client.put( + "/api/auth/me/settings", headers=auth_headers_user, json={"field": "appearance.theme_id", "value": "custom-theme"} + ).status_code + == 200 ) - - assert response.status_code == 400 - assert response.json()["detail"] == "Text editor max file size must be between 65536 and 104857600 bytes" - - def test_update_rejects_invalid_regional_locale(self, client: TestClient, auth_headers_user: dict[str, str]) -> None: - response = client.put( - "/api/auth/me/settings", - headers=auth_headers_user, - json={"localization": {"regional_locale": "english_us"}}, - ) - + response = client.put("/api/auth/me/settings", headers=auth_headers_user, json={"field": "appearance.custom_themes", "value": []}) assert response.status_code == 400 - assert response.json()["detail"] == "Regional locale must be a valid locale identifier like en-US" + assert response.json()["detail"] == "Cannot remove the active custom theme before selecting another theme" diff --git a/companion/package-lock.json b/companion/package-lock.json index 099392dc..1718ec66 100644 --- a/companion/package-lock.json +++ b/companion/package-lock.json @@ -1,12 +1,12 @@ { "name": "sambee-companion", - "version": "1.0.21", + "version": "1.0.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sambee-companion", - "version": "1.0.21", + "version": "1.0.22", "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-autostart": "^2.5.1", diff --git a/companion/package.json b/companion/package.json index d5053580..17fc0435 100644 --- a/companion/package.json +++ b/companion/package.json @@ -1,6 +1,6 @@ { "name": "sambee-companion", - "version": "1.0.21", + "version": "1.0.22", "private": true, "type": "module", "scripts": { diff --git a/companion/src-tauri/Cargo.lock b/companion/src-tauri/Cargo.lock index cdd1f182..e22142f8 100644 --- a/companion/src-tauri/Cargo.lock +++ b/companion/src-tauri/Cargo.lock @@ -4093,7 +4093,7 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "sambee-companion" -version = "1.0.21" +version = "1.0.22" dependencies = [ "axum", "base64 0.22.1", diff --git a/companion/src-tauri/Cargo.toml b/companion/src-tauri/Cargo.toml index 12a4f249..da3847e4 100644 --- a/companion/src-tauri/Cargo.toml +++ b/companion/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sambee-companion" -version = "1.0.21" +version = "1.0.22" edition = "2021" description = "Sambee Companion — opens files from Sambee in native desktop editors" authors = [] diff --git a/companion/src-tauri/tauri.conf.json b/companion/src-tauri/tauri.conf.json index e3eedfdd..87b4ee8b 100644 --- a/companion/src-tauri/tauri.conf.json +++ b/companion/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2-schema/refs/heads/main/tauri.conf.json.schema", "productName": "Sambee Companion", "identifier": "app.sambee.companion", - "version": "1.0.21", + "version": "1.0.22", "build": { "beforeDevCommand": "npm run dev", "devUrl": "http://localhost:1420", 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/FILE_TRANSFER_CONFLICT_RESOLUTION_PLAN.md b/documentation_planning/FILE_TRANSFER_CONFLICT_RESOLUTION_PLAN.md deleted file mode 100644 index 47e7f3d4..00000000 --- a/documentation_planning/FILE_TRANSFER_CONFLICT_RESOLUTION_PLAN.md +++ /dev/null @@ -1,183 +0,0 @@ -# File Transfer Conflict Resolution Plan - -## Purpose - -This document defines a consistent conflict-resolution design for regular-file copy and move operations. It addresses the missing **Overwrite** and **Overwrite only older** choices in the F5 conflict dialog and applies the same behavior to every supported source and destination combination. - -The design follows normal file-manager semantics: an overwrite replaces the destination that exists at the time of final commit. It does not attempt to promise a cross-provider compare-and-swap guarantee that ordinary filesystems do not provide. - -## Scope - -### Included - -- F5 copy to other pane and the corresponding command-palette command. -- F6 move to other pane and the corresponding command-palette command. -- Direct SMB-to-SMB transfers. -- Local-to-local transfers through Companion. -- Local-to-SMB and SMB-to-local browser-relayed transfers. -- Regular-file conflicts in single-item and batch operations. -- Recursive directory merging through child-by-child transfer coordination. -- `ask`, `skip`, `replace`, `replace_older`, rename, and apply-to-all behavior. - -### Excluded - -- Recursive deletion or replacement of an existing destination directory. -- Changes to archive extraction, which already owns a separate, richer conflict workflow. -- A storage-plugin redesign. The implementation should use the current SMB and Companion transfer boundaries. - -## User-Visible Semantics - -| Resolution | Result | -| --- | --- | -| Skip | Leave source and destination unchanged for this item. | -| Overwrite | Replace the destination regular file with the source. | -| Overwrite only older | Replace only when the source modification time is strictly newer than the destination examined immediately before commit; otherwise skip the item. | -| Rename | Copy or move under a new destination name using create-only behavior. | - -Timestamp comparison is deliberately conservative: equal, missing, mixed-zone, or otherwise incomparable timestamps must not replace the target. - -An overwrite is last-writer-wins at commit time, as users expect from a file manager. The implementation must still guarantee that a failed or cancelled transfer does not expose partial destination content. - -### Directory merge semantics - -When the source and destination entries are both directories, merge the source tree into the existing destination directory. Do not show a conflict for that directory pair and do not offer Rename for it. Before traversal, the coordinator classifies the top-level target: create the root when it is absent, merge when it is a directory, and show the normal Skip-or-Rename type-mismatch dialog when it is a regular file. - -- Preserve destination entries that do not exist in the source tree. -- Create source-only child entries in the destination tree. -- Recursively merge child directories with the same name. -- Apply the regular-file conflict policy only when two child entries are regular files with the same name. -- Traverse the source tree in the frontend and execute each child through the existing one-item transfer path. When a child returns 409, show the existing dialog, retry only that child with its decision, then continue. Do not retry the root directory or add a durable directory-transfer session. -- Treat file-versus-directory and directory-versus-file child collisions as explicit conflicts. Offer Skip or Rename to a new sibling name of the source type; do not offer Overwrite or Overwrite only older, and do not delete or replace either side implicitly. -- For a move, remove each source file only after its destination file has committed successfully. After all successfully processed children of a source directory are handled, remove that source directory bottom-up using an empty-directory-only operation. If concurrent content prevents that removal, retain the source directory and report the factual partial result; never use recursive deletion to complete a move. -- Continue a tree after a child conflict has been resolved. Stop the current tree on cancellation, `outcome_unknown`, or an unrecoverable child failure, report its factual partial result, and leave later source entries untouched. The existing batch behavior then decides whether to continue with the next top-level selection. - -## Transfer Algorithm - -Every regular-file destination uses this flow, independent of provider: - -1. Create a unique temporary sibling file in the destination directory. -2. Stream the source to the temporary file while tracking progress and cancellation. -3. Flush and close the temporary file; verify the source has not changed where the current transfer code already supports that check. For a direct provider-managed move, retain the source read handle through destination commit and use it for source removal where the provider supports it. A browser relay passes the source modification time with the stream request for `replace_older`; it does not add a separate source-validation or two-phase promotion protocol where retained-handle deletion is unavailable. -4. For `replace_older`, observe the current destination immediately before promotion. If it is no longer an older regular file, discard the temporary file and return `skipped` or a fresh conflict as appropriate. -5. Atomically promote the temporary file to the destination path with replacement enabled. -6. On failure before promotion, clean up the temporary file and leave the prior destination intact. -7. On success, set `ContentTransferResult.replaced` to `true` when an existing regular target was replaced and update the relevant directory caches. - -The staging file must live in the destination directory so its promotion is within one filesystem/share. It must have an unguessable operation-scoped name (format: `.filename.ext.sambee-stage-`) and be cleaned up on cancellation, transfer failure, and final-promotion failure. - -The directory coordinator creates a missing child directory only after classifying its destination sibling. If creation reports that the name appeared concurrently, it re-lists that one destination parent and applies the resulting directory-merge or type-mismatch rule. A create race must not surface as an unclassified transfer failure. - -## Provider Implementations - -### SMB destination - -Extend the existing staged SMB writer path with an atomic rename/replace promotion. The installed `smbclient` library exposes this as `smbclient.renames(stage_path, target_path, **auth_kwargs)`, which sends SMB `FileRenameInformation` with `replace_if_exists=True` in one server-side `SET_INFO` operation. Both direct SMB copy and the `transfer-stream` browser-relay endpoint must use it. - -The current create-only path uses `smbclient.rename(...)`, which sets `replace_if_exists=False`. For `replace` and a qualifying `replace_older`, replace the current `remove(target)` followed by `rename(stage, target)` sequence with `renames(stage, target)`. The replace path must never delete the existing target before the staging file is complete. - -The server can reject promotion for a directory target, a lock/share-mode conflict, read-only attributes, or insufficient access. In each case, discard the stage and preserve the existing target. Directory-to-directory copies use recursive merge and delegate child-file replacement to this same path. - -### Local destination - -Extend the Companion staged-local-copy flow so its final promotion replaces an existing regular destination. Use the platform-native same-directory replacement operation: - -- Unix: `rename`. -- Windows: call `ReplaceFileW(target, stage, NULL, REPLACEFILE_WRITE_THROUGH, NULL, NULL)` on a blocking thread. `ReplaceFileW` replaces an existing regular target in one filesystem operation; its bindings are already available through Companion's `windows` dependency with `Win32_Storage_FileSystem` enabled. Do not request a backup file and do not use `MOVEFILE_COPY_ALLOWED`. - -`ReplaceFileW` requires an existing target. If it reports that the target disappeared before promotion, discard the stage and retry through the existing create-only resolution path; if that path races with a new target, return a refreshed conflict. For sharing violations, access failures, or a non-file target, discard the stage and preserve the target. Map an unobservable post-call outcome to `outcome_unknown`. - -No local operation may emulate replacement through delete-then-rename. - -### Cross-provider transfers - -Cross-provider copies use the destination implementation above. The source provider supplies the stream and its observed modification time; it does not determine conflict semantics. Add an optional RFC 3339 UTC `source_modified_at` parameter to both SMB and Companion `transfer-stream` endpoints, populated from the source metadata the frontend already reads before starting the relay. Omit the parameter when the source metadata has no modification time. Each destination endpoint validates it before consuming the request body. - -For `replace_older`, when the destination exists, a missing or invalid source modification time returns `skipped` before the endpoint consumes the stream. Immediately before promotion, the destination re-reads its target metadata and applies the existing strict-newer comparison. A changed target type produces a fresh conflict. - -For browser-relayed cross-provider moves, the source response and the later source-deletion request are separate operations, so a source read handle cannot be retained through destination commit. Use the existing best-effort source deletion path and accept the theoretical concurrent-rewrite race rather than adding a durable relay protocol. Direct provider-managed transfers may use retained-handle removal where their source implementation supports it. If source removal fails after a committed replacement, report `completed_with_source_retained`; do not attempt to roll back the destination. - -## Backend Changes - -1. Extend the existing staged destination write APIs in the SMB backend and Companion to accept a final-promotion mode: create-only or replace. In the SMB backend, implement replace with `smbclient.renames(...)`, not delete-then-rename. -2. Change `resolve_regular_file_transfer` and its call sites so `replace` and `replace_older` invoke the replace-capable staging operation instead of treating replacement as unavailable. -3. Update direct SMB copy and move routes in `backend/app/api/browser.py`. -4. Update both `transfer-stream` endpoints to accept, validate, and use `source_modified_at` for `replace_older`, then stage and promote with the requested policy instead of rejecting all existing targets except Skip. -5. Update cross-connection copy and move orchestration in `backend/app/services/cross_connection.py` for individual regular-file replacement only; directory roots are owned by the frontend tree coordinator. -6. Update Companion regular-file copy and move execution to replace staging gates currently marked `replacement_supported: false`. -7. Keep backend directory-copy behavior create-only. The frontend tree coordinator implements directory merge by composing existing directory-create and individual child-transfer operations. -8. Add a narrow empty-directory-only removal operation for local and SMB storage. It must fail without deleting when the directory contains entries, including entries added concurrently. Its result must distinguish `not_empty` from access, transport, missing-source, and other operational failures; expose it through the existing storage operation boundary for bottom-up directory moves. -9. Preserve idempotency semantics: a transport retry replays its prior factual result; a user-selected conflict resolution creates a new transfer attempt. - -The existing target-resolution types remain the policy authority. Avoid a new provider-capability model or a parallel policy enum. - -## Frontend Changes - -1. In `frontend/src/pages/FileBrowser.tsx`, offer Skip, Overwrite, Overwrite only older, and Rename for regular-file-to-regular-file conflicts. -2. Add a shared `executeTransferTree` coordinator for top-level directories. It owns every directory transfer: detect the directory before calling `executeTransfer`, never call `executeTransfer` for a directory, and bypass the existing `api.transferDirectoryAcrossBackends` / `copyDirectoryContentsAcrossBackends` staged-directory relay. Retire those legacy frontend helpers once no route reaches them. -3. The coordinator first classifies the top-level destination sibling: absent creates the root directory, a directory starts a merge without a dialog, and a regular file opens the type-mismatch dialog. It then lists source children, classifies destination siblings from a listing of the corresponding destination parent, creates missing destination directories, and recurses into same-name directories. On a directory-create race, re-list that parent and reclassify the child. It calls the existing `executeTransfer` path only for regular-file leaves and type-mismatched children. -4. On a child 409, show the existing dialog and retry only that child. Do not show a dialog for a root directory-to-directory merge or repeat completed children. Stop the current tree on cancellation, `outcome_unknown`, or an unrecoverable child failure; the outer batch loop retains its existing policy for later top-level items. -5. For a file/directory type mismatch, allow only Skip and Rename to a new sibling path; never offer an action that deletes the existing entry. -6. Replace the current `skip-all` special case with a persistent remaining-items policy: `ask`, `skip`, `replace`, or `replace_older`. Use it from the start of every later child transfer. -7. Extend the dialog's apply-to-all contract to include Overwrite only older. Rename remains per-conflict because every item needs a distinct target name. -8. When a retry yields a refreshed 409 conflict, reopen the same dialog with the returned metadata for every action, not just Rename. -9. Add a `removeEmptyDirectory` operation beside the existing generic create/remove operations and invoke it bottom-up only after a directory's successful children have been processed. Its `not_empty` result leaves the source directory intact and records a source-retained partial warning; other cleanup failures remain operational errors. -10. Preserve current cancellation and terminal-status behavior. While a tree is active, its coordinator temporarily owns the existing progress display and conflict-dialog progress, publishing `completed/discovered` child counts. Suppress outer top-level progress updates until that tree terminates, then resume them for the next top-level selection. Keep affected paths and counts in coordinator-local state for logs and pane refreshes; do not widen `ContentTransferResult` merely to carry tree progress. - -The dialog component already renders the two missing radio options when its owner passes them in; this work is primarily orchestration and transfer support. - -## Error Handling - -- A cancelled or failed stage leaves the previous destination untouched. -- A failed promotion leaves the outcome factual: report an error only when the destination can be observed unchanged; otherwise report `outcome_unknown`. -- If the destination changes type before a `replace_older` commit, show a fresh conflict instead of deleting or replacing it. -- Where a direct provider-managed transfer supports source snapshot validation or retained-handle deletion, preserve its existing source-changed handling. Browser-relayed transfers use separate source-stream and source-deletion requests, rely on successful streaming and size validation, and accept the theoretical concurrent-rewrite case without a separate two-phase validation protocol. -- A cancelled or failed directory merge can have `destination: mutated`. Retain entries that were not successfully transferred, along with any source directories that cannot be removed while empty-only cleanup runs. Keep completed-entry counts and affected paths in coordinator-local state for logging and refresh. -- Log the operation ID, source, destination, policy, phase, and failure category without logging file contents or credentials. - -## Test Plan - -### Shared policy tests - -- `replace` selects replacement for an existing regular target. -- `replace_older` replaces only when the source is strictly newer. -- Equal, older, absent, and incomparable timestamps result in skip. -- Relayed `replace_older` receives and validates `source_modified_at`; a missing value skips an existing target without consuming its stream. -- Same-name directories merge recursively without a root conflict or rename choice. -- A top-level directory targeted at an existing regular file offers only Skip and Rename; an absent top-level target is created before traversal. -- Source-only and destination-only directory children are preserved correctly. -- File/directory and directory/file child collisions offer only Skip and Rename. -- A destination directory create race re-lists and merges or reports a type mismatch according to the newly observed sibling. -- Bottom-up move cleanup removes only empty source directories, reports `not_empty` as a source-retained partial outcome, and distinguishes it from an operational cleanup failure. -- A resolved child conflict continues the current tree, while cancellation, an unknown outcome, or an unrecoverable child error stops it and leaves later source entries untouched. -- Direct provider-managed moves use retained-handle source removal where supported; browser-relayed moves retain the existing best-effort source deletion behavior. -- A directory tree's child-level `completed/discovered` progress replaces outer top-level progress while the tree is active, including when it opens a conflict dialog. - -### Provider tests - -For SMB and local destinations: - -- Successful replacement reports `completed`, destination mutated, and `replaced: true`. -- A write, flush, cancellation, or staging failure preserves the old destination. -- A promotion failure removes the stage and reports the correct factual result. -- No partial destination file is observable during a successful replacement. - -### Workflow tests - -- F5 and F6 submit `replace` and `replace_older` correctly. -- Command-palette copy and move use the same execution path. -- Apply-to-all works for Skip, Overwrite, and Overwrite only older. -- A child retry conflict reopens the dialog with fresh metadata without repeating completed tree entries. -- SMB-to-SMB, local-to-local, local-to-SMB, and SMB-to-local copies succeed with replacement. -- F5 and F6 merge same-name source and destination directories, resolving only colliding child files. -- Cancellation and late child failures report a partially mutated destination and leave unprocessed source entries intact. -- Cross-provider move reports `completed_with_source_retained` when destination commit succeeds but source deletion fails. - -## Delivery Order - -1. Add and test atomic staged replacement for local and SMB destination writers. -2. Wire direct and relayed copy operations to `replace` and `replace_older`, including relay source modification times. -3. Enable the frontend actions and batch policies. -4. Add the child-by-child frontend directory merge coordinator, retire the legacy cross-provider staged-directory relay, then wire bottom-up empty-directory move cleanup while preserving source-retention outcomes for cross-provider moves. -5. Run focused backend, Companion, and frontend tests, then exercise F5 and F6 manually across local and SMB panes. - -This order keeps the UI hidden until the underlying operation is correct, while avoiding an unnecessary plugin or provider-capability architecture. 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/package-lock.json b/frontend/package-lock.json index 36e8b951..81440796 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "sambee-frontend", - "version": "1.0.21", + "version": "1.0.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sambee-frontend", - "version": "1.0.21", + "version": "1.0.22", "hasInstallScript": true, "dependencies": { "@codemirror/autocomplete": "^6.20.3", diff --git a/frontend/package.json b/frontend/package.json index d31c9c7d..f4fc1139 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "sambee-frontend", - "version": "1.0.21", + "version": "1.0.22", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 9e158e15..785ce8b7 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -40,8 +40,10 @@ vi.mock("../services/authSession", async (importOriginal) => { isBootstrapComplete: () => true, bootstrap: vi.fn().mockResolvedValue("idle"), getAccessToken: () => null, + getIdentity: () => ({ epoch: 0, userId: null }), getUserId: () => null, hasUsableAccessToken: () => false, + subscribeToIdentity: vi.fn(() => () => undefined), requestRefresh: vi.fn().mockRejectedValue(new Error("No renewable session")), refreshIfNeeded: vi.fn().mockResolvedValue(undefined), setAuthenticated: vi.fn(), diff --git a/frontend/src/components/Admin/ConnectionDialog.tsx b/frontend/src/components/Admin/ConnectionDialog.tsx index 382ed8d9..25773a5a 100644 --- a/frontend/src/components/Admin/ConnectionDialog.tsx +++ b/frontend/src/components/Admin/ConnectionDialog.tsx @@ -24,18 +24,19 @@ import api from "../../services/api"; import type { Connection, ConnectionAccessMode, ConnectionCreate, ConnectionScope, ConnectionVisibilityOption } from "../../types"; import { getApiErrorMessage } from "../../utils/apiErrors"; import { dialogEnterKeyHandler } from "../../utils/keyboardUtils"; +import { ResponsiveDialogShell } from "../Dialog/ResponsiveDialogShell"; import { - SettingsFormFieldLabel, - SettingsFormGroup, - SettingsFormRow, - SettingsFormSection, - SettingsFormSurface, - settingsFormFieldControlSx, - settingsFormOutlinedControlSx, - settingsFormSelectControlSx, - settingsSelectMenuProps, - settingsSelectSx, -} from "../Settings/SettingsFormLayout"; + FormFieldLabel, + FormGroup, + FormRow, + FormSurface, + formFieldControlSx, + formOutlinedControlSx, + formSelectControlSx, + formSelectMenuProps, + formSelectSx, +} from "../Form/FormLayout"; +import { SettingsFormSection } from "../Settings/SettingsFormLayout"; import { SettingsPasswordVisibilityToggle } from "../Settings/SettingsPasswordVisibilityToggle"; import { SettingsSelectMenuItem } from "../Settings/SettingsSelectMenuItem"; import { settingsPrimaryButtonSx, settingsUtilityButtonSx } from "../Settings/settingsButtonStyles"; @@ -46,7 +47,6 @@ import { adminDialogSplitActionRowSx, adminDialogStandaloneSecondaryActionSx, } from "./dialogActionStyles"; -import { ResponsiveFormDialog } from "./ResponsiveFormDialog"; interface ConnectionDialogProps { open: boolean; @@ -345,22 +345,22 @@ const ConnectionDialog: React.FC = ({ open, onClose, onSa hasError = false ) => usesDesktopFormLayout ? ( - ) : null; // Form content (shared between Dialog and Drawer) const formContent = ( - - - + + + {renderDesktopLabel( CONNECTION_DIALOG_STRINGS.LABEL_NAME, errors["name"] || CONNECTION_DIALOG_STRINGS.HELPER_NAME, @@ -370,7 +370,7 @@ const ConnectionDialog: React.FC = ({ open, onClose, onSa undefined, !!errors["name"] )} - + = ({ open, onClose, onSa required variant="outlined" size={usesDesktopFormLayout ? "small" : "medium"} - sx={settingsFormOutlinedControlSx} + sx={formOutlinedControlSx} slotProps={{ htmlInput: { "aria-describedby": usesDesktopFormLayout ? "connection-name-description" : undefined, @@ -391,8 +391,8 @@ const ConnectionDialog: React.FC = ({ open, onClose, onSa }} /> - - + + {renderDesktopLabel( CONNECTION_DIALOG_STRINGS.LABEL_HOST, errors["host"] || CONNECTION_DIALOG_STRINGS.HELPER_HOST, @@ -402,7 +402,7 @@ const ConnectionDialog: React.FC = ({ open, onClose, onSa undefined, !!errors["host"] )} - + = ({ open, onClose, onSa required variant="outlined" size={usesDesktopFormLayout ? "small" : "medium"} - sx={settingsFormOutlinedControlSx} + sx={formOutlinedControlSx} slotProps={{ htmlInput: { "aria-describedby": usesDesktopFormLayout ? "connection-host-description" : undefined, @@ -423,8 +423,8 @@ const ConnectionDialog: React.FC = ({ open, onClose, onSa }} /> - - + + {renderDesktopLabel( CONNECTION_DIALOG_STRINGS.LABEL_SHARE_NAME, errors["share_name"] || CONNECTION_DIALOG_STRINGS.HELPER_SHARE_NAME, @@ -434,7 +434,7 @@ const ConnectionDialog: React.FC = ({ open, onClose, onSa undefined, !!errors["share_name"] )} - + = ({ open, onClose, onSa required variant="outlined" size={usesDesktopFormLayout ? "small" : "medium"} - sx={settingsFormOutlinedControlSx} + sx={formOutlinedControlSx} slotProps={{ htmlInput: { "aria-describedby": usesDesktopFormLayout ? "connection-share-name-description" : undefined, @@ -455,8 +455,8 @@ const ConnectionDialog: React.FC = ({ open, onClose, onSa }} /> - - + + {renderDesktopLabel( CONNECTION_DIALOG_STRINGS.LABEL_USERNAME, errors["username"] || CONNECTION_DIALOG_STRINGS.HELPER_USERNAME, @@ -466,7 +466,7 @@ const ConnectionDialog: React.FC = ({ open, onClose, onSa undefined, !!errors["username"] )} - + = ({ open, onClose, onSa required variant="outlined" size={usesDesktopFormLayout ? "small" : "medium"} - sx={settingsFormOutlinedControlSx} + sx={formOutlinedControlSx} slotProps={{ htmlInput: { "aria-describedby": usesDesktopFormLayout ? "connection-username-description" : undefined, @@ -487,9 +487,9 @@ const ConnectionDialog: React.FC = ({ open, onClose, onSa }} /> - + - + {renderDesktopLabel( CONNECTION_DIALOG_STRINGS.LABEL_PASSWORD, errors["password"] || @@ -500,7 +500,7 @@ const ConnectionDialog: React.FC = ({ open, onClose, onSa undefined, !!errors["password"] )} - + = ({ open, onClose, onSa required={!connection} variant="outlined" size={usesDesktopFormLayout ? "small" : "medium"} - sx={settingsFormOutlinedControlSx} + sx={formOutlinedControlSx} slotProps={{ htmlInput: { "aria-describedby": usesDesktopFormLayout ? "connection-password-description" : undefined, @@ -537,16 +537,16 @@ const ConnectionDialog: React.FC = ({ open, onClose, onSa }} /> - + - + {renderDesktopLabel( CONNECTION_DIALOG_STRINGS.LABEL_PATH_PREFIX, CONNECTION_DIALOG_STRINGS.HELPER_PATH_PREFIX, "connection-path-prefix-description", "connection-path-prefix" )} - + = ({ open, onClose, onSa fullWidth variant="outlined" size={usesDesktopFormLayout ? "small" : "medium"} - sx={settingsFormOutlinedControlSx} + sx={formOutlinedControlSx} slotProps={{ htmlInput: { "aria-describedby": usesDesktopFormLayout ? "connection-path-prefix-description" : undefined, @@ -565,11 +565,11 @@ const ConnectionDialog: React.FC = ({ open, onClose, onSa }} /> - - + + - - + + {renderDesktopLabel( t("settings.connectionDialog.labels.visibility"), CONNECTION_DIALOG_STRINGS.HELPER_VISIBILITY, @@ -582,7 +582,7 @@ const ConnectionDialog: React.FC = ({ open, onClose, onSa fullWidth={!usesDesktopFormLayout} variant="outlined" size={usesDesktopFormLayout ? "small" : "medium"} - sx={usesDesktopFormLayout ? [settingsFormOutlinedControlSx, settingsFormSelectControlSx] : settingsFormOutlinedControlSx} + sx={usesDesktopFormLayout ? [formOutlinedControlSx, formSelectControlSx] : formOutlinedControlSx} > {!usesDesktopFormLayout && ( {t("settings.connectionDialog.labels.visibility")} @@ -596,8 +596,8 @@ const ConnectionDialog: React.FC = ({ open, onClose, onSa size={usesDesktopFormLayout ? "small" : "medium"} onChange={(event) => handleChange("scope", event.target.value as ConnectionScope)} renderValue={(selected) => visibilityOptions.find((option) => option.value === selected)?.label ?? selected} - sx={settingsSelectSx} - MenuProps={settingsSelectMenuProps} + sx={formSelectSx} + MenuProps={formSelectMenuProps} > {visibilityOptions.map((option) => ( = ({ open, onClose, onSa {!usesDesktopFormLayout && {CONNECTION_DIALOG_STRINGS.HELPER_VISIBILITY}} - + - + {renderDesktopLabel( t("settings.connectionDialog.labels.accessMode"), formData.access_mode === "read_only" @@ -628,7 +628,7 @@ const ConnectionDialog: React.FC = ({ open, onClose, onSa fullWidth={!usesDesktopFormLayout} variant="outlined" size={usesDesktopFormLayout ? "small" : "medium"} - sx={usesDesktopFormLayout ? [settingsFormOutlinedControlSx, settingsFormSelectControlSx] : settingsFormOutlinedControlSx} + sx={usesDesktopFormLayout ? [formOutlinedControlSx, formSelectControlSx] : formOutlinedControlSx} > {!usesDesktopFormLayout && ( {t("settings.connectionDialog.labels.accessMode")} @@ -641,8 +641,8 @@ const ConnectionDialog: React.FC = ({ open, onClose, onSa value={formData.access_mode} size={usesDesktopFormLayout ? "small" : "medium"} onChange={(event) => handleChange("access_mode", event.target.value as ConnectionAccessMode)} - sx={settingsSelectSx} - MenuProps={settingsSelectMenuProps} + sx={formSelectSx} + MenuProps={formSelectMenuProps} > {t("settings.connectionDialog.accessMode.readWriteLabel")} {t("settings.connectionDialog.accessMode.readOnlyLabel")} @@ -655,9 +655,9 @@ const ConnectionDialog: React.FC = ({ open, onClose, onSa )} - - - + + + ); // Action buttons (shared between Dialog and Drawer) @@ -720,7 +720,7 @@ const ConnectionDialog: React.FC = ({ open, onClose, onSa return ( <> - = ({ open, onClose, onSa onKeyDown={handleKeyDown} > {formContent} - - + = ({ open, onClose, onSa } > {resultDialog?.message} - + ); }; diff --git a/frontend/src/components/Admin/DeleteDialog.tsx b/frontend/src/components/Admin/DeleteDialog.tsx index c955628d..ff2a1924 100644 --- a/frontend/src/components/Admin/DeleteDialog.tsx +++ b/frontend/src/components/Admin/DeleteDialog.tsx @@ -4,9 +4,9 @@ import type React from "react"; import { useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { dialogEnterKeyHandler } from "../../utils/keyboardUtils"; +import { ResponsiveDialogShell } from "../Dialog/ResponsiveDialogShell"; import { settingsDestructiveButtonSx, settingsPrimaryButtonSx, settingsUtilityButtonSx } from "../Settings/settingsButtonStyles"; import { adminDialogActionButtonSx, adminDialogEndActionRowSx } from "./dialogActionStyles"; -import { ResponsiveFormDialog } from "./ResponsiveFormDialog"; interface DeleteDialogProps { open: boolean; @@ -77,7 +77,7 @@ const DeleteDialog: React.FC = ({ ); return ( - = ({ onTransitionEntered={() => cancelButtonRef.current?.focus()} > {null} - + ); }; diff --git a/frontend/src/components/Admin/__tests__/ResponsiveFormDialog.test.tsx b/frontend/src/components/Admin/__tests__/ResponsiveFormDialog.test.tsx index 16bf2c91..8a22b8c2 100644 --- a/frontend/src/components/Admin/__tests__/ResponsiveFormDialog.test.tsx +++ b/frontend/src/components/Admin/__tests__/ResponsiveFormDialog.test.tsx @@ -2,7 +2,7 @@ import { Button } from "@mui/material"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SambeeThemeProvider } from "../../../theme"; -import { ResponsiveFormDialog } from "../ResponsiveFormDialog"; +import { ResponsiveDialogShell } from "../../Dialog/ResponsiveDialogShell"; vi.mock("react-i18next", () => ({ useTranslation: () => ({ @@ -39,7 +39,7 @@ describe("ResponsiveFormDialog", () => { it("renders the shared description above the dialog body on desktop", () => { render( - { actions={} >
Dialog Body
-
+
); @@ -65,7 +65,7 @@ describe("ResponsiveFormDialog", () => { render( - { actions={} >
Dialog Body
-
+
); @@ -89,20 +89,18 @@ describe("ResponsiveFormDialog", () => { }); const drawerStyles = window.getComputedStyle(drawerPaper as HTMLElement); - expect(drawerStyles.getPropertyValue("--sambee-dialog-surface")).not.toBe(""); - expect(drawerStyles.getPropertyValue("--sambee-dialog-form-surface")).not.toBe(""); - expect(drawerStyles.getPropertyValue("--sambee-dialog-form-surface")).not.toBe( - drawerStyles.getPropertyValue("--sambee-dialog-surface") - ); + expect(drawerStyles.getPropertyValue("--sambee-overlay-surface")).not.toBe(""); + expect(drawerStyles.getPropertyValue("--sambee-form-surface")).not.toBe(""); + expect(drawerStyles.getPropertyValue("--sambee-form-surface")).not.toBe(drawerStyles.getPropertyValue("--sambee-overlay-surface")); }); it("restores focus to the triggering element after the dialog closes", async () => { const { rerender } = render( - Save}> + Save}>
Dialog Body
-
+
); @@ -112,18 +110,18 @@ describe("ResponsiveFormDialog", () => { rerender( - Save}> + Save}>
Dialog Body
-
+
); rerender( - Save}> + Save}>
Dialog Body
-
+
); @@ -139,9 +137,9 @@ describe("ResponsiveFormDialog", () => { - Save}> + Save}>
Dialog Body
-
+
); @@ -153,9 +151,9 @@ describe("ResponsiveFormDialog", () => { - Save}> + Save}>
Dialog Body
-
+
); @@ -163,9 +161,9 @@ describe("ResponsiveFormDialog", () => { - Save}> + Save}>
Dialog Body
-
+
); @@ -182,9 +180,9 @@ describe("ResponsiveFormDialog", () => { render( - Save}> + Save}>
Dialog Body
-
+
); @@ -195,9 +193,9 @@ describe("ResponsiveFormDialog", () => { const onClose = vi.fn(); render( - Save}> + Save}>
Dialog Body
-
+
); @@ -211,9 +209,9 @@ describe("ResponsiveFormDialog", () => { const onClose = vi.fn(); render( - Save}> + Save}>
Dialog Body
-
+
); diff --git a/frontend/src/components/Admin/dialogActionStyles.ts b/frontend/src/components/Admin/dialogActionStyles.ts index 7b29d068..e773193d 100644 --- a/frontend/src/components/Admin/dialogActionStyles.ts +++ b/frontend/src/components/Admin/dialogActionStyles.ts @@ -1,37 +1,37 @@ import type { SxProps, Theme } from "@mui/material"; -export const adminDialogSplitActionRowSx: SxProps = { +export const adminDialogSplitActionRowSx = { width: "100%", display: "flex", gap: 1, alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", -}; +} satisfies SxProps; -export const adminDialogEndActionRowSx: SxProps = { +export const adminDialogEndActionRowSx = { width: "100%", display: "flex", gap: 1, alignItems: "center", justifyContent: "flex-end", flexWrap: "wrap", -}; +} satisfies SxProps; -export const adminDialogActionButtonSx: SxProps = { +export const adminDialogActionButtonSx = { flex: { xs: 1, sm: "0 0 auto" }, minWidth: { sm: 132 }, -}; +} satisfies SxProps; -export const adminDialogActionGroupSx: SxProps = { +export const adminDialogActionGroupSx = { display: "flex", gap: 1, width: { xs: "100%", sm: "auto" }, flexWrap: "wrap", justifyContent: "flex-end", -}; +} satisfies SxProps; -export const adminDialogStandaloneSecondaryActionSx: SxProps = { +export const adminDialogStandaloneSecondaryActionSx = { width: { xs: "100%", sm: "auto" }, minWidth: { sm: 132 }, -}; +} satisfies SxProps; diff --git a/frontend/src/components/AppUpdatePrompt.tsx b/frontend/src/components/AppUpdatePrompt.tsx index 161b01f6..0367ea4d 100644 --- a/frontend/src/components/AppUpdatePrompt.tsx +++ b/frontend/src/components/AppUpdatePrompt.tsx @@ -8,7 +8,7 @@ import { logger } from "../services/logger"; import { CURRENT_BUILD_INFO, hasBuildMismatch, shortenCommit } from "../utils/buildInfo"; import type { VersionInfo } from "../utils/version"; import { fetchVersionInfo } from "../utils/version"; -import { ResponsiveFormDialog } from "./Admin/ResponsiveFormDialog"; +import { ResponsiveDialogShell } from "./Dialog/ResponsiveDialogShell"; const UPDATE_CHECK_INTERVAL_MS = 5 * 60_000; const VISIBILITY_RECHECK_DELAY_MS = 1_500; @@ -108,7 +108,7 @@ export function AppUpdatePrompt() { } return ( -
) : null} - + ); } diff --git a/frontend/src/components/Dialog/DialogNotice.tsx b/frontend/src/components/Dialog/DialogNotice.tsx new file mode 100644 index 00000000..883a22be --- /dev/null +++ b/frontend/src/components/Dialog/DialogNotice.tsx @@ -0,0 +1,42 @@ +import { Alert, Box } from "@mui/material"; +import type { ReactNode } from "react"; + +export interface DialogNoticeProps { + message: ReactNode | null | undefined; + severity?: "error" | "warning"; + role?: "alert" | "status"; + testId?: string; +} + +export function DialogNotice({ message, severity = "error", role, testId }: DialogNoticeProps) { + if (!message) { + return null; + } + + return ( + + {message} + + ); +} + +interface DialogNoticeRegionProps { + notices: readonly DialogNoticeProps[]; + testId?: string; +} + +export function DialogNoticeRegion({ notices, testId }: DialogNoticeRegionProps) { + const visibleNotices = notices.filter((notice) => Boolean(notice.message)); + + if (visibleNotices.length === 0) { + return null; + } + + return ( + + {visibleNotices.map((notice) => ( + + ))} + + ); +} diff --git a/frontend/src/components/Admin/DialogReadOnlyField.tsx b/frontend/src/components/Dialog/DialogReadOnlyField.tsx similarity index 67% rename from frontend/src/components/Admin/DialogReadOnlyField.tsx rename to frontend/src/components/Dialog/DialogReadOnlyField.tsx index e3c65b7c..ebaa2e9a 100644 --- a/frontend/src/components/Admin/DialogReadOnlyField.tsx +++ b/frontend/src/components/Dialog/DialogReadOnlyField.tsx @@ -1,8 +1,7 @@ -import type { SxProps, Theme } from "@mui/material"; -import { TextField } from "@mui/material"; -import type { ChangeEventHandler, FocusEventHandler, Ref } from "react"; -import { DIALOG_FORM_SURFACE_CSS_VARIABLE, getModeAdjustedSurfaceColor } from "../../theme/palette"; -import { settingsFormOutlinedControlSx } from "../Settings/SettingsFormLayout"; +import { type SxProps, TextField, type Theme } from "@mui/material"; +import type { ChangeEventHandler, FocusEventHandler, ReactNode, Ref } from "react"; +import { FORM_SURFACE_CSS_VARIABLE, getModeAdjustedSurfaceColor } from "../../theme/palette"; +import { formOutlinedControlSx } from "../Form/FormLayout"; interface DialogReadOnlyFieldProps { id?: string; @@ -14,7 +13,7 @@ interface DialogReadOnlyFieldProps { onChange?: ChangeEventHandler; inputRef?: Ref; error?: boolean; - helperText?: string; + helperText?: ReactNode; autoFocus?: boolean; onFocus?: FocusEventHandler; size?: "small" | "medium"; @@ -25,6 +24,12 @@ interface DialogReadOnlyFieldProps { sx?: SxProps; } +const dialogFormHelperTextSx = { + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}; + /** Displays a selectable, optionally labelled value in a dialog without allowing edits. */ export function DialogReadOnlyField({ id, @@ -71,27 +76,21 @@ export function DialogReadOnlyField({ "aria-describedby": ariaDescribedBy, "aria-readonly": !editable, }, + formHelperText: { sx: dialogFormHelperTextSx }, }} sx={[ - settingsFormOutlinedControlSx, + formOutlinedControlSx, { "& .MuiOutlinedInput-root": { bgcolor: showFormSurface ? (theme) => - `var(${DIALOG_FORM_SURFACE_CSS_VARIABLE}, ${getModeAdjustedSurfaceColor(theme.palette.background.default, theme.palette.mode)})` + `var(${FORM_SURFACE_CSS_VARIABLE}, ${getModeAdjustedSurfaceColor(theme.palette.background.default, theme.palette.mode)})` : "transparent", cursor: "default", }, - "& .MuiInputBase-input": { - color: "text.primary", - cursor: "text", - }, - "& .MuiInputLabel-root.MuiInputLabel-shrink": { - bgcolor: showFormSurface ? undefined : "transparent", - }, - "& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline": { - borderColor: "divider", - }, + "& .MuiInputBase-input": { color: "text.primary", cursor: "text" }, + "& .MuiInputLabel-root.MuiInputLabel-shrink": { bgcolor: showFormSurface ? undefined : "transparent" }, + "& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline": { borderColor: "divider" }, }, ...(Array.isArray(sx) ? sx : sx ? [sx] : []), ]} diff --git a/frontend/src/components/Admin/ResponsiveFormDialog.tsx b/frontend/src/components/Dialog/ResponsiveDialogShell.tsx similarity index 76% rename from frontend/src/components/Admin/ResponsiveFormDialog.tsx rename to frontend/src/components/Dialog/ResponsiveDialogShell.tsx index 0c117cd1..34e95c89 100644 --- a/frontend/src/components/Admin/ResponsiveFormDialog.tsx +++ b/frontend/src/components/Dialog/ResponsiveDialogShell.tsx @@ -18,6 +18,7 @@ import { useTheme, } from "@mui/material"; import { type ReactNode, type Ref, useEffect, useId, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { mobileFullscreenDrawerPaperSx, mobileSafeAreaAppBarSx, @@ -25,15 +26,17 @@ import { mobileScrollableContentSx, SAFE_AREA_INSET, } from "../../theme/mobileShell"; -import { DIALOG_FORM_SURFACE_CSS_VARIABLE, DIALOG_SURFACE_CSS_VARIABLE, getDialogSurfaceTokens } from "../../theme/palette"; +import { FORM_SURFACE_CSS_VARIABLE, getOverlaySurfaceTokens, OVERLAY_SURFACE_CSS_VARIABLE } from "../../theme/palette"; -interface ResponsiveFormDialogProps { +interface ResponsiveDialogShellProps { open: boolean; onClose: () => void; disableClose?: boolean; title: string; description?: ReactNode; children: ReactNode; + contextualNotice?: ReactNode; + actionNotice?: ReactNode; actions?: ReactNode; showCloseButton?: boolean; closeButtonAriaLabel?: string; @@ -54,20 +57,20 @@ interface ResponsiveFormDialogProps { onTransitionExited?: () => void; } -import { useTranslation } from "react-i18next"; - -export const responsiveFormDialogContentPaddingSx: SxProps = { +export const responsiveDialogShellContentPaddingSx: SxProps = { px: { xs: 2, sm: 3 }, py: 2, }; -export function ResponsiveFormDialog({ +export function ResponsiveDialogShell({ open, onClose, disableClose = false, title, description, children, + contextualNotice, + actionNotice, actions, showCloseButton = false, closeButtonAriaLabel, @@ -86,7 +89,7 @@ export function ResponsiveFormDialog({ disableRestoreFocus = false, onTransitionEntered, onTransitionExited, -}: ResponsiveFormDialogProps) { +}: ResponsiveDialogShellProps) { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); const { t } = useTranslation(); @@ -127,52 +130,42 @@ export function ResponsiveFormDialog({ }, [disableRestoreFocus, open]); const handleRequestClose = () => { - if (disableClose) { - return; + if (!disableClose) { + onClose(); } - - onClose(); }; - const handleDialogClose = (_event: unknown, reason: string) => { - if (reason === "escapeKeyDown") { - if (escapeCloseHandledRef.current) { - escapeCloseHandledRef.current = false; - return; - } + const handleShellKeyDown: DialogProps["onKeyDown"] = (event) => { + if (event.key !== "Escape") { + onKeyDown?.(event); + return; } - handleRequestClose(); - }; - - const handleDrawerClose = (_event: unknown, reason: string) => { - if (reason === "escapeKeyDown") { - if (escapeCloseHandledRef.current) { - escapeCloseHandledRef.current = false; - return; - } + event.preventDefault(); + event.stopPropagation(); + if (onEscape) { + onEscape(); + } else if (!disableClose) { + escapeCloseHandledRef.current = true; + onClose(); } - - handleRequestClose(); }; - const handleShellKeyDown: DialogProps["onKeyDown"] = (event) => { - if (event.key === "Escape") { - event.preventDefault(); - event.stopPropagation(); - if (onEscape) { - onEscape(); - return; - } - if (disableClose) { - return; - } - escapeCloseHandledRef.current = true; - onClose(); + const handleShellClose = (_event: unknown, reason: string) => { + if (reason === "escapeKeyDown" && escapeCloseHandledRef.current) { + escapeCloseHandledRef.current = false; return; } + handleRequestClose(); + }; - onKeyDown?.(event); + const getSurfaceSx = (currentTheme: Theme) => { + const surfaces = getOverlaySurfaceTokens(currentTheme.palette.background.default, currentTheme.palette.mode); + return { + backgroundColor: surfaces.paper, + [OVERLAY_SURFACE_CSS_VARIABLE]: surfaces.paper, + [FORM_SURFACE_CSS_VARIABLE]: surfaces.form, + }; }; if (isMobile) { @@ -180,28 +173,20 @@ export function ResponsiveFormDialog({ currentTheme.zIndex.modal + dialogZIndexOffset }} slotProps={{ - transition: { - onEntered: onTransitionEntered, - onExited: onTransitionExited, - }, + transition: { onEntered: onTransitionEntered, onExited: onTransitionExited }, paper: { - sx: (currentTheme) => { - const dialogSurfaces = getDialogSurfaceTokens(currentTheme.palette.background.default, currentTheme.palette.mode); - - return { - ...mobileFullscreenDrawerPaperSx, - backgroundColor: dialogSurfaces.paper, - [DIALOG_SURFACE_CSS_VARIABLE]: dialogSurfaces.paper, - [DIALOG_FORM_SURFACE_CSS_VARIABLE]: dialogSurfaces.form, - }; - }, + ref: paperRef, + sx: [ + (currentTheme) => ({ ...mobileFullscreenDrawerPaperSx, ...getSurfaceSx(currentTheme) }), + ...(Array.isArray(paperSx) ? paperSx : paperSx ? [paperSx] : []), + ], }, }} > @@ -235,21 +220,18 @@ export function ResponsiveFormDialog({ )} - {renderedDescription} + {contextualNotice} {children} + {actionNotice} - {actions && ( getSurfaceSx(currentTheme), ...(Array.isArray(paperSx) ? paperSx : paperSx ? [paperSx] : [])], }, + transition: { onEntered: onTransitionEntered, onExited: onTransitionExited }, }} sx={{ zIndex: (currentTheme) => currentTheme.zIndex.modal + dialogZIndexOffset }} > @@ -318,17 +297,15 @@ export function ResponsiveFormDialog({ {paperOverlay} {renderedDescription} + {contextualNotice} {children} + {actionNotice} {actions && ( diff --git a/frontend/src/components/Dialog/__tests__/DialogNotice.test.tsx b/frontend/src/components/Dialog/__tests__/DialogNotice.test.tsx new file mode 100644 index 00000000..0fbc724a --- /dev/null +++ b/frontend/src/components/Dialog/__tests__/DialogNotice.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { DialogNotice, DialogNoticeRegion } from "../DialogNotice"; + +describe("DialogNotice", () => { + it("does not render when its message is absent", () => { + render(); + + expect(screen.queryByTestId("dialog-notice")).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("uses the default alert role for errors", () => { + render(); + + expect(screen.getByTestId("dialog-notice")).toHaveAttribute("role", "alert"); + }); + + it("uses a status role for warnings", () => { + render(); + + expect(screen.getByTestId("dialog-notice")).toHaveAttribute("role", "status"); + }); +}); + +describe("DialogNoticeRegion", () => { + it("does not render when every message is absent", () => { + render(); + + expect(screen.queryByTestId("dialog-notice-region")).not.toBeInTheDocument(); + }); + + it("renders only active notices", () => { + render( + + ); + + expect(screen.getByTestId("dialog-notice-region")).toBeInTheDocument(); + expect(screen.queryByTestId("inactive-notice")).not.toBeInTheDocument(); + expect(screen.getByTestId("active-notice")).toHaveAttribute("role", "alert"); + }); +}); diff --git a/frontend/src/components/FileBrowser/ArchiveExtractDialog.tsx b/frontend/src/components/FileBrowser/ArchiveExtractDialog.tsx index a2a4c427..3b3a4cf9 100644 --- a/frontend/src/components/FileBrowser/ArchiveExtractDialog.tsx +++ b/frontend/src/components/FileBrowser/ArchiveExtractDialog.tsx @@ -1,18 +1,18 @@ -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 { DialogNotice } from "../Dialog/DialogNotice"; +import { ResponsiveDialogShell } from "../Dialog/ResponsiveDialogShell"; +import { FormGroup, FormRow, FormSurface, formOutlinedControlSx } from "../Form/FormLayout"; 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[] }; @@ -42,9 +42,9 @@ interface ArchiveExtractDialogProps { } function validateDestinationPath(value: string): string | null { - const normalized = value.trim().replaceAll("\\", "/"); + const normalized = value.trim().replace(/\\/g, "/"); if (!normalized) return "empty"; - if (normalized.startsWith("/") || normalized.split("/").some((part) => part === "" || part === "." || part === "..")) { + if (normalized.startsWith("/") || normalized.split("/").some((part: string) => part === "" || part === "." || part === "..")) { return "unsafe"; } return null; @@ -59,12 +59,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(/^\/+/, "")}`; @@ -81,8 +75,8 @@ function toArchiveConflictInfo(conflict: ArchiveExtractionConflict): ConflictInf name: getItemName(conflict.source.path), path: conflict.source.path, type, - size: conflict.source.size, - modified_at: conflict.source.modifiedAt, + size: conflict.source.size ?? undefined, + modified_at: conflict.source.modifiedAt ?? undefined, is_readable: true, is_hidden: false, }, @@ -90,8 +84,8 @@ function toArchiveConflictInfo(conflict: ArchiveExtractionConflict): ConflictInf name: getItemName(conflict.target.path), path: conflict.target.path, type, - size: conflict.target.size, - modified_at: conflict.target.modifiedAt, + size: conflict.target.size ?? undefined, + modified_at: conflict.target.modifiedAt ?? undefined, is_readable: true, is_hidden: false, }, @@ -153,6 +147,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); @@ -174,7 +169,7 @@ export function ArchiveExtractDialog({ setValidationError(validation); return; } - onConfirm(destinationPath.trim().replaceAll("\\", "/")); + onConfirm(destinationPath.trim().replace(/\\/g, "/")); }; const validationMessage = @@ -182,11 +177,20 @@ 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 : []; const isSingleMemberExtraction = selectedMemberPaths.length === 1; + const actionNotice = memberError ? ( + + ) : ( + + ); const focusInitialControl = () => { if (memberError) { retryMemberButtonRef.current?.focus(); @@ -198,23 +202,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 +218,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) => { @@ -255,7 +255,7 @@ export function ArchiveExtractDialog({ } return ( - @@ -289,7 +290,7 @@ export function ArchiveExtractDialog({ ) : isExtracting ? ( onCancelExtraction ? ( - ) : null @@ -304,27 +305,32 @@ export function ArchiveExtractDialog({ } > - {isExtracting && !awaitingConflictDecision && !memberError ? ( - ) : null} - {error && !awaitingConflictDecision ? {error} : null} + {isExtracting && !awaitingConflictDecision && !memberError ? ( + + ) : null} {memberError ? ( ) : null} {!isExtracting && !awaitingConflictDecision && requiresDestinationName ? ( - - - + + + { setDestinationPath(event.target.value); @@ -338,15 +344,13 @@ export function ArchiveExtractDialog({ }} error={validationError !== null} helperText={validationMessage} - sx={settingsFormOutlinedControlSx} + sx={formOutlinedControlSx} /> - - - - ) : !isExtracting && !awaitingConflictDecision ? ( - + + + ) : null} - + ); } diff --git a/frontend/src/components/FileBrowser/ArchiveMemberErrorResolver.tsx b/frontend/src/components/FileBrowser/ArchiveMemberErrorResolver.tsx index 30b021ab..fb647a84 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 { 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 }; @@ -11,32 +11,14 @@ export function ArchiveMemberErrorResolver({ error }: ArchiveMemberErrorResolver return ( - - {error.message} - - - - {t("fileBrowser.archive.collisionArchiveMemberLabel")} - - - - {error.memberPath} - - - - {t("fileBrowser.archive.memberErrorTargetLabel")} - - - - {error.targetPath} - - - + {error.partialOutput ? ( - + theme.palette.text.secondary }}> {t("fileBrowser.archive.memberErrorPartialOutputNote")} ) : null} 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/BrowserViewerPicker.tsx b/frontend/src/components/FileBrowser/BrowserViewerPicker.tsx index 1eb53763..45cd469c 100644 --- a/frontend/src/components/FileBrowser/BrowserViewerPicker.tsx +++ b/frontend/src/components/FileBrowser/BrowserViewerPicker.tsx @@ -1,14 +1,25 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import VisibilityIcon from "@mui/icons-material/Visibility"; -import { Button, Checkbox, FormControlLabel, List, ListItemButton, ListItemIcon, ListItemText, Radio } from "@mui/material"; +import { + Button, + Checkbox, + CircularProgress, + FormControlLabel, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Radio, +} from "@mui/material"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useSelectableListbox } from "../../hooks/useSelectableListbox"; import type { ViewerId } from "../../utils/FileTypeRegistry"; import { getViewerDefinitions } from "../../utils/FileTypeRegistry"; -import { DialogReadOnlyField } from "../Admin/DialogReadOnlyField"; -import { ResponsiveFormDialog } from "../Admin/ResponsiveFormDialog"; +import { DialogNoticeRegion } from "../Dialog/DialogNotice"; +import { DialogReadOnlyField } from "../Dialog/DialogReadOnlyField"; +import { ResponsiveDialogShell } from "../Dialog/ResponsiveDialogShell"; interface BrowserViewerPickerProps { open?: boolean; @@ -17,9 +28,12 @@ interface BrowserViewerPickerProps { defaultViewerId: ViewerId | null; preferredViewerId: ViewerId | null; showNativeOption: boolean; + saving: boolean; + saveError: string | null; onClose: () => void; onTransitionExited?: () => void; onConfirm: (selection: { viewerId: ViewerId | null; rememberSelection: boolean }) => void; + onOpenWithoutSaving: (selection: { viewerId: ViewerId | null }) => void; } export function BrowserViewerPicker({ @@ -29,9 +43,12 @@ export function BrowserViewerPicker({ defaultViewerId, preferredViewerId, showNativeOption, + saving, + saveError, onClose, onTransitionExited, onConfirm, + onOpenWithoutSaving, }: BrowserViewerPickerProps) { const { t } = useTranslation(); const viewerDefinitions = useMemo(() => getViewerDefinitions().filter((viewer) => viewerIds.includes(viewer.id)), [viewerIds]); @@ -75,6 +92,14 @@ export function BrowserViewerPicker({ onConfirm({ viewerId: selectedViewerId, rememberSelection: rememberSelection && canRememberSelection }); }; + const handleOpenWithoutSaving = () => { + if (!selectedValue) { + return; + } + + onOpenWithoutSaving({ viewerId: selectedViewerId }); + }; + const { listRef, focusList, @@ -83,7 +108,7 @@ export function BrowserViewerPicker({ open, options: viewerOptions, selectedValue, - onSelectValue: setSelectedValue, + onSelectValue: saving ? () => undefined : setSelectedValue, onConfirm: handleConfirm, }); @@ -103,18 +128,29 @@ export function BrowserViewerPicker({ }; return ( - } actions={ <> - + ) : null} + } @@ -126,6 +162,7 @@ export function BrowserViewerPicker({ role="listbox" tabIndex={0} autoFocus + aria-disabled={saving} aria-activedescendant={selectedValue ? `browser-viewer-picker-option-${selectedValue}` : undefined} onKeyDown={handleListKeyDown} sx={{ @@ -144,6 +181,7 @@ export function BrowserViewerPicker({ selected={selectedValue === viewer.id} disableRipple disableTouchRipple + disabled={saving} sx={pickerOptionSx} onClick={() => { setSelectedValue(viewer.id); @@ -171,6 +209,7 @@ export function BrowserViewerPicker({ selected={selectedValue === "native"} disableRipple disableTouchRipple + disabled={saving} sx={pickerOptionSx} onClick={() => { setSelectedValue("native"); @@ -193,11 +232,15 @@ export function BrowserViewerPicker({ setRememberSelection(event.target.checked)} /> + setRememberSelection(event.target.checked)} + disabled={saving} + /> } - disabled={!canRememberSelection} + disabled={!canRememberSelection || saving} label={t("fileBrowser.viewerPicker.alwaysUse")} /> - + ); } diff --git a/frontend/src/components/FileBrowser/CompanionPairingDialog.tsx b/frontend/src/components/FileBrowser/CompanionPairingDialog.tsx index 0c67b6a6..37bee62e 100644 --- a/frontend/src/components/FileBrowser/CompanionPairingDialog.tsx +++ b/frontend/src/components/FileBrowser/CompanionPairingDialog.tsx @@ -16,7 +16,7 @@ import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { logger } from "../../services/logger"; import { dialogEnterKeyHandler } from "../../utils/keyboardUtils"; -import { ResponsiveFormDialog } from "../Admin/ResponsiveFormDialog"; +import { ResponsiveDialogShell } from "../Dialog/ResponsiveDialogShell"; import { COMPANION_PAIRING_DIALOG_COPY } from "../Settings/localDrivesCopy"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -208,7 +208,7 @@ const CompanionPairingDialog: React.FC = ({ open, o ); return ( - = ({ open, o )} {step === "error" && {errorMessage}} - + ); }; diff --git a/frontend/src/components/FileBrowser/ConfirmDeleteDialog.tsx b/frontend/src/components/FileBrowser/ConfirmDeleteDialog.tsx index 7825db46..12b2783b 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 { ResponsiveDialogShell } from "../Dialog/ResponsiveDialogShell"; 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,13 +73,14 @@ 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(), []); return ( - = ({ open, items, } > - - + {isSingleItem && item ? ( + + ) : ( + + theme.palette.text.secondary }}> + {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..0c925187 100644 --- a/frontend/src/components/FileBrowser/CopyMoveDialog.tsx +++ b/frontend/src/components/FileBrowser/CopyMoveDialog.tsx @@ -17,18 +17,19 @@ * 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 { DialogNoticeRegion } from "../Dialog/DialogNotice"; +import { ResponsiveDialogShell } from "../Dialog/ResponsiveDialogShell"; +import { FormGroup, FormRow, FormSurface, formOutlinedControlSx } from "../Form/FormLayout"; 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 +109,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 +118,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 +141,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,12 +182,18 @@ const CopyMoveDialog: React.FC = ({ const formContent = ( - {error ? {error} : null} - {warning ? {warning} : null} + {isSingleItem ? ( - - - + + + = ({ onChange={(event) => setDestFileName(event.target.value)} disabled={isProcessing} error={Boolean(fileNameError)} - helperText={fileNameError ?? " "} + helperText={fileNameError} {...FILENAME_FIELD_PROPS} slotProps={{ htmlInput: FILENAME_INPUT_PROPS }} - sx={[settingsFormOutlinedControlSx, FILENAME_INPUT_SX]} + sx={[formOutlinedControlSx, FILENAME_INPUT_SX]} /> - - - - ) : ( - - - - - - - - )} - - {!isSingleItem ? ( - - {S.ERROR_SAME_DIRECTORY} - + + + ) : null} - {isProcessing && progress ? ( @@ -234,12 +222,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 ? ( @@ -273,18 +265,36 @@ const CopyMoveDialog: React.FC = ({ ); return ( - + } + actionNotice={ + + } description={description} actions={actions} maxWidth="sm" > {formContent} - + ); }; 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..65c79705 --- /dev/null +++ b/frontend/src/components/FileBrowser/DialogIdentifierDisplay.tsx @@ -0,0 +1,80 @@ +import { Box, Tooltip } from "@mui/material"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +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 = ( + + + {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..dce8caed --- /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) => ( + + theme.palette.text.secondary, mb: 0.25 }}> + {entry.label}: + + + + + + ))} + + + ); +} diff --git a/frontend/src/components/FileBrowser/InlineItemName.tsx b/frontend/src/components/FileBrowser/InlineItemName.tsx index 6049fb68..ef235c57 100644 --- a/frontend/src/components/FileBrowser/InlineItemName.tsx +++ b/frontend/src/components/FileBrowser/InlineItemName.tsx @@ -1,6 +1,6 @@ import { alpha, Box, type SxProps, type Theme } from "@mui/material"; import { forwardRef, type ReactNode } from "react"; -import { DIALOG_FORM_SURFACE_CSS_VARIABLE, getModeAdjustedSurfaceColor } from "../../theme/palette"; +import { FORM_SURFACE_CSS_VARIABLE, getModeAdjustedSurfaceColor } from "../../theme/palette"; export type InlineItemNameVariant = "prose" | "metadata"; @@ -32,7 +32,7 @@ export const InlineItemName = forwardRef(funct sx={[ { bgcolor: (theme) => - `var(${DIALOG_FORM_SURFACE_CSS_VARIABLE}, ${getModeAdjustedSurfaceColor(theme.palette.background.default, theme.palette.mode)})`, + `var(${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", diff --git a/frontend/src/components/FileBrowser/NameInputDialog.tsx b/frontend/src/components/FileBrowser/NameInputDialog.tsx index 2a103ee2..c33b3396 100644 --- a/frontend/src/components/FileBrowser/NameInputDialog.tsx +++ b/frontend/src/components/FileBrowser/NameInputDialog.tsx @@ -17,11 +17,12 @@ * - 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 { DialogNotice } from "../Dialog/DialogNotice"; +import { ResponsiveDialogShell } from "../Dialog/ResponsiveDialogShell"; +import { FormGroup, FormRow, FormSurface, formOutlinedControlSx } from "../Form/FormLayout"; import { FILENAME_FIELD_PROPS, FILENAME_INPUT_PROPS, FILENAME_INPUT_SX } from "./filenameFieldProps"; import { NAME_DIALOG_STRINGS, validateItemName } from "./nameDialogStrings"; @@ -46,6 +47,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 +89,7 @@ const NameInputDialog: React.FC = ({ submitLabel, submittingLabel, submittingContent, + operationContext, isSubmitting, isCancelling = false, onClose, @@ -97,6 +101,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 +144,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,9 +205,10 @@ const NameInputDialog: React.FC = ({ const formContent = ( - - - + {operationContext} + + + = ({ onKeyDown={handleKeyDown} disabled={isSubmitting} error={hasError} - helperText={validationError ?? " "} + helperText={validationError} variant="outlined" {...FILENAME_FIELD_PROPS} slotProps={{ htmlInput: FILENAME_INPUT_PROPS }} - sx={[settingsFormOutlinedControlSx, FILENAME_INPUT_SX]} + sx={[formOutlinedControlSx, FILENAME_INPUT_SX]} /> - - - - - {apiError ?? " "} - + + + ); return ( - } actions={ isSubmitting && submittingContent ? ( onCancelSubmitting ? ( - ) : null ) : ( <> {isSubmitting && onCancelSubmitting ? ( - ) : ( @@ -265,8 +270,15 @@ 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..48f8a91e 100644 --- a/frontend/src/components/FileBrowser/OverwriteConflictDialog.tsx +++ b/frontend/src/components/FileBrowser/OverwriteConflictDialog.tsx @@ -20,14 +20,16 @@ */ 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"; import { dialogEnterKeyHandler } from "../../utils/keyboardUtils"; import { formatLocalizedDateTime, formatLocalizedNumber } from "../../utils/localeFormatting"; -import { DialogReadOnlyField } from "../Admin/DialogReadOnlyField"; -import { ResponsiveFormDialog } from "../Admin/ResponsiveFormDialog"; +import { DialogNotice } from "../Dialog/DialogNotice"; +import { DialogReadOnlyField } from "../Dialog/DialogReadOnlyField"; +import { ResponsiveDialogShell } from "../Dialog/ResponsiveDialogShell"; +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}`; } // ============================================================================ @@ -174,7 +146,6 @@ const OverwriteConflictDialog: React.FC = ({ const [isSubmitting, setIsSubmitting] = useState(false); const targetNameRef = useRef(null); const resolutionGroupRef = useRef(null); - const errorAlertRef = useRef(null); const previousConflictIdentityRef = useRef(null); const shouldFocusInitialControlRef = useRef(false); const shouldFocusRenameInputRef = useRef(false); @@ -182,7 +153,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 +160,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; @@ -208,10 +177,7 @@ const OverwriteConflictDialog: React.FC = ({ const focusInitialControl = useCallback(() => { if (!shouldFocusInitialControlRef.current) return; shouldFocusInitialControlRef.current = false; - if (!hasAvailableResolution) { - errorAlertRef.current?.focus(); - return; - } + if (!hasAvailableResolution) return; if (safeDefaultResolution === "rename") { targetNameRef.current?.focus(); return; @@ -266,12 +232,6 @@ const OverwriteConflictDialog: React.FC = ({ } }, [isRename, open]); - useEffect(() => { - if (displayedError) { - requestAnimationFrame(() => errorAlertRef.current?.focus()); - } - }, [displayedError]); - const handleResolutionChange = useCallback( (event: React.ChangeEvent) => { const nextResolution = event.target.value as ConflictResolution; @@ -296,18 +256,18 @@ 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) }, ]; return ( - = ({ maxWidth="sm" disableClose={isSubmittingOrPending} onTransitionEntered={focusInitialControl} + actionNotice={} actions={ <> - - - } + actionNotice={} + actions={} > - handleSelect(theme.id)}> + handleSelect(theme.id)} disabled={themeSetting.pending}> - + {THEME_SELECTOR_STRINGS.themeName(theme)} @@ -138,7 +115,7 @@ export function ThemeSelectorDialog({ open, onClose }: ThemeSelectorDialogProps) ))} - + ); } diff --git a/frontend/src/components/Viewer/CodeMirrorFindReplacePopover.tsx b/frontend/src/components/Viewer/CodeMirrorFindReplacePopover.tsx index 148a7e5a..ee342834 100644 --- a/frontend/src/components/Viewer/CodeMirrorFindReplacePopover.tsx +++ b/frontend/src/components/Viewer/CodeMirrorFindReplacePopover.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { CODEMIRROR_EDITOR_SHORTCUTS, COMMON_SHORTCUTS } from "../../config/keyboardShortcuts"; import { withShortcut } from "../../hooks/useKeyboardShortcuts"; import { SCROLLBAR, TOOLBAR_HEIGHT } from "../../theme/constants"; -import { getDialogSurfaceTokens } from "../../theme/palette"; +import { getOverlaySurfaceTokens } from "../../theme/palette"; import { CODEMIRROR_FIND_HISTORY_STORAGE_KEY, CODEMIRROR_FIND_REPLACE_REPLACE_INPUT_ATTRIBUTE, @@ -127,7 +127,7 @@ export function CodeMirrorFindReplacePopover({ const [replaceHistory, setReplaceHistory] = useState(() => readCodeMirrorFindReplaceHistory(CODEMIRROR_REPLACE_HISTORY_STORAGE_KEY)); const hasMatches = isSearchValid && searchMatches > 0; const replaceDisabled = disabled || !hasMatches; - const dialogSurfaces = getDialogSurfaceTokens(theme.palette.background.default, theme.palette.mode); + const dialogSurfaces = getOverlaySurfaceTokens(theme.palette.background.default, theme.palette.mode); useEffect(() => { if (open) { diff --git a/frontend/src/components/Viewer/MarkdownViewer.tsx b/frontend/src/components/Viewer/MarkdownViewer.tsx index 9b65aac7..cdd52603 100644 --- a/frontend/src/components/Viewer/MarkdownViewer.tsx +++ b/frontend/src/components/Viewer/MarkdownViewer.tsx @@ -56,7 +56,7 @@ import { openExternalUrl } from "../../utils/externalLinks"; import type { ViewerComponentProps } from "../../utils/FileTypeRegistry"; import { blurActiveToolbarControl } from "../../utils/keyboardUtils"; import { createShareFile, shareNativeContent, shouldWarmNativeSharePayload, supportsNativeShare } from "../../utils/nativeShare"; -import { ResponsiveFormDialog } from "../Admin/ResponsiveFormDialog"; +import { ResponsiveDialogShell } from "../Dialog/ResponsiveDialogShell"; import { HelpMenu } from "../FileBrowser/HelpMenu"; import { KeyboardShortcutsHelp } from "../KeyboardShortcutsHelp"; import { CodeMirrorFindReplacePopover } from "./CodeMirrorFindReplacePopover"; @@ -1994,7 +1994,7 @@ export const MarkdownViewer: React.FC = ({ - setRecoveryDraft(null)} onKeyDown={(event) => { @@ -2034,9 +2034,9 @@ export const MarkdownViewer: React.FC = ({ } > {null} - + - { @@ -2073,7 +2073,7 @@ export const MarkdownViewer: React.FC = ({ } > {null} - + = ({ const { viewerBg, toolbarBg, toolbarText, viewerText, linkColor } = getViewerColors(currentTheme, "markdown"); const searchHighlightColors = useMemo(() => getSearchHighlightColors(muiTheme, currentTheme), [currentTheme, muiTheme]); const filename = path.split("/").pop() || path; - const maxFileSizeBytes = readTextEditorMaxFileSizeBytesPreference(); + const [maxFileSizeBytes] = useTextEditorMaxFileSizeBytesPreference(); const [wordWrapEnabled, setWordWrapEnabled] = useTextEditorWordWrapPreference(false); const contentSizeBytes = useMemo(() => new Blob([content]).size, [content]); const exceedsEditorLimit = !loading && !error && contentSizeBytes > maxFileSizeBytes; @@ -1198,7 +1198,7 @@ export const TextViewer: React.FC = ({ - setRecoveryDraft(null)} onKeyDown={(event) => { @@ -1238,9 +1238,9 @@ export const TextViewer: React.FC = ({ } > {null} - + - { setPendingUnsavedChangesAction(null); @@ -1288,7 +1288,7 @@ export const TextViewer: React.FC = ({ } > {null} - + ({ - mockSetWordWrapEnabled: vi.fn(), - mockTextEditorCommands: { - nextSearchResult: vi.fn(), - previousSearchResult: vi.fn(), - replaceAllSearchResults: vi.fn(), - replaceCurrentSearchResult: vi.fn(), - }, - readTextEditorMaxFileSizeBytesPreferenceMock: vi.fn(() => 52_428_800), - useTextEditorWordWrapPreferenceMock: vi.fn(() => [false, mockSetWordWrapEnabled] as const), -})); +const { mockSetWordWrapEnabled, mockTextEditorCommands, useTextEditorMaxFileSizeBytesPreferenceMock, useTextEditorWordWrapPreferenceMock } = + vi.hoisted(() => ({ + mockSetWordWrapEnabled: vi.fn(), + mockTextEditorCommands: { + nextSearchResult: vi.fn(), + previousSearchResult: vi.fn(), + replaceAllSearchResults: vi.fn(), + replaceCurrentSearchResult: vi.fn(), + }, + useTextEditorMaxFileSizeBytesPreferenceMock: vi.fn(() => [52_428_800, vi.fn()] as const), + useTextEditorWordWrapPreferenceMock: vi.fn(() => [false, mockSetWordWrapEnabled] as const), + })); vi.mock("../../../pages/FileBrowser/preferences", () => ({ - readTextEditorMaxFileSizeBytesPreference: readTextEditorMaxFileSizeBytesPreferenceMock, + useTextEditorMaxFileSizeBytesPreference: useTextEditorMaxFileSizeBytesPreferenceMock, useTextEditorWordWrapPreference: useTextEditorWordWrapPreferenceMock, })); @@ -132,7 +128,7 @@ async function enterEditMode(): Promise { describe("TextViewer", () => { beforeEach(() => { vi.clearAllMocks(); - readTextEditorMaxFileSizeBytesPreferenceMock.mockReturnValue(52_428_800); + useTextEditorMaxFileSizeBytesPreferenceMock.mockReturnValue([52_428_800, vi.fn()]); vi.spyOn(apiService, "supportsEditLocks").mockReturnValue(true); vi.spyOn(apiService, "getFileContent").mockResolvedValue("hello world"); vi.spyOn(apiService, "acquireEditLock").mockResolvedValue({ @@ -266,7 +262,7 @@ describe("TextViewer", () => { }); it("falls back to read-only large-file mode when the configured limit is exceeded", async () => { - readTextEditorMaxFileSizeBytesPreferenceMock.mockReturnValue(4); + useTextEditorMaxFileSizeBytesPreferenceMock.mockReturnValue([4, vi.fn()]); vi.spyOn(apiService, "getFileContent").mockResolvedValueOnce("this content is too large"); renderViewer(); diff --git a/frontend/src/components/__tests__/ThemeSelector.test.tsx b/frontend/src/components/__tests__/ThemeSelector.test.tsx index 6a53f9a5..19e9da6a 100644 --- a/frontend/src/components/__tests__/ThemeSelector.test.tsx +++ b/frontend/src/components/__tests__/ThemeSelector.test.tsx @@ -165,37 +165,6 @@ describe("ThemeSelector Component", () => { expect(mockOnClose).not.toHaveBeenCalled(); }); - it("persists a selected theme only after saving", async () => { - const user = userEvent.setup(); - const mockOnClose = vi.fn(); - renderWithProvider(); - - // Find and click dark theme - const darkThemeCard = screen.getByText(/Sambee dark/i).closest("button"); - expect(darkThemeCard).toBeInTheDocument(); - - await user.click(darkThemeCard!); - - expect(mockOnClose).not.toHaveBeenCalled(); - expect(localStorageMock.getItem("theme-id-current")).toBeNull(); - - await user.click(screen.getByRole("button", { name: "Save changes" })); - - expect(mockOnClose).toHaveBeenCalledTimes(1); - expect(localStorageMock.getItem("theme-id-current")).toBe("sambee-dark"); - }); - - it("keeps the save action enabled after previewing a different theme", async () => { - const user = userEvent.setup(); - renderWithProvider(); - - const darkThemeCard = screen.getByText(/Sambee dark/i).closest("button"); - expect(darkThemeCard).toBeInTheDocument(); - await user.click(darkThemeCard!); - - expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled(); - }); - it("should show mode indicator (Light/Dark)", () => { const mockOnClose = vi.fn(); renderWithProvider(); @@ -206,7 +175,7 @@ describe("ThemeSelector Component", () => { }); describe("Theme Switching", () => { - it("should persist theme selection to localStorage", async () => { + it("does not mirror a selected theme in localStorage", async () => { const user = userEvent.setup(); renderWithProvider(); @@ -218,10 +187,7 @@ describe("ThemeSelector Component", () => { const darkThemeCard = screen.getByText(/Sambee dark/i).closest("button"); await user.click(darkThemeCard!); - await user.click(screen.getByRole("button", { name: "Save changes" })); - - // Check localStorage - expect(localStorageMock.getItem("theme-id-current")).toBe("sambee-dark"); + expect(localStorageMock.getItem("theme-id-current")).toBeNull(); }); it("should restore theme from localStorage on mount", async () => { diff --git a/frontend/src/hooks/useSystemSettingPersistence.tsx b/frontend/src/hooks/useSystemSettingPersistence.tsx new file mode 100644 index 00000000..72fbec48 --- /dev/null +++ b/frontend/src/hooks/useSystemSettingPersistence.tsx @@ -0,0 +1,118 @@ +import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlined"; +import { CircularProgress, InputAdornment } from "@mui/material"; +import { useEffect, useRef, useState } from "react"; + +export const SETTING_SUCCESS_DISPLAY_MS = 2_000; + +type FieldUpdate = { field: string; value: unknown }; + +type FieldPersistenceResult = + | { status: "completed"; update: Update } + | { status: "duplicate" } + | { status: "failed"; error: unknown }; + +export function useSystemSettingPersistence( + commit: (update: Update) => Promise, + getErrorMessage: (error: unknown) => string +) { + const [pendingFields, setPendingFields] = useState>({}); + const [savedFields, setSavedFields] = useState>({}); + const [fieldErrors, setFieldErrors] = useState>({}); + const pendingTokens = useRef>({}); + const successTimers = useRef>>({}); + const mounted = useRef(true); + + useEffect( + () => () => { + mounted.current = false; + for (const timer of Object.values(successTimers.current)) clearTimeout(timer); + }, + [] + ); + + const clearFieldFeedback = (field: string) => { + const timer = successTimers.current[field]; + if (timer) clearTimeout(timer); + delete successTimers.current[field]; + setSavedFields((current) => ({ ...current, [field]: false })); + setFieldErrors((current) => ({ ...current, [field]: undefined })); + }; + + const persist = async (update: Update): Promise> => { + if (pendingTokens.current[update.field]) return { status: "duplicate" }; + + const token = Symbol(update.field); + pendingTokens.current[update.field] = token; + clearFieldFeedback(update.field); + setPendingFields((current) => ({ ...current, [update.field]: true })); + try { + const response = await commit(update); + if (response.field !== update.field) throw new Error("The server returned an update for a different setting."); + if (!mounted.current || pendingTokens.current[update.field] !== token) return { status: "duplicate" }; + + setSavedFields((current) => ({ ...current, [update.field]: true })); + successTimers.current[update.field] = setTimeout(() => { + if (mounted.current) setSavedFields((current) => ({ ...current, [update.field]: false })); + delete successTimers.current[update.field]; + }, SETTING_SUCCESS_DISPLAY_MS); + return { status: "completed", update: response }; + } catch (error: unknown) { + if (mounted.current && pendingTokens.current[update.field] === token) { + setFieldErrors((current) => ({ ...current, [update.field]: getErrorMessage(error) })); + } + return { status: "failed", error }; + } finally { + if (pendingTokens.current[update.field] === token) { + delete pendingTokens.current[update.field]; + if (mounted.current) setPendingFields((current) => ({ ...current, [update.field]: false })); + } + } + }; + + return { + clearFieldFeedback, + fieldErrors, + isPending: (field: string) => Boolean(pendingFields[field]), + isSaved: (field: string) => Boolean(savedFields[field]), + persist, + }; +} + +export function SettingPersistenceAdornment({ + pending, + saved, + savingLabel, + savedLabel, +}: { + pending: boolean; + saved: boolean; + savingLabel: string; + savedLabel: string; +}) { + if (!pending && !saved) return null; + return ( + + + + ); +} + +export function SettingPersistenceIndicator({ + pending, + saved, + savingLabel, + savedLabel, +}: { + pending: boolean; + saved: boolean; + savingLabel: string; + savedLabel: string; +}) { + if (pending) { + return ; + } + if (saved) { + return ; + } + return null; +} diff --git a/frontend/src/i18n/CompanionLocalizationSync.tsx b/frontend/src/i18n/CompanionLocalizationSync.tsx index ccb815b8..ee16c344 100644 --- a/frontend/src/i18n/CompanionLocalizationSync.tsx +++ b/frontend/src/i18n/CompanionLocalizationSync.tsx @@ -1,26 +1,17 @@ import { useEffect, useRef, useState } from "react"; import { syncCurrentLocalizationToCompanion } from "../services/companionLocalizationSync"; -import { loadCurrentUserSettings } from "../services/userSettingsSync"; +import { useCurrentUserSetting } from "../services/userSettingsStore"; import { useLocalePreferences } from "./LocalePreferencesProvider"; export function CompanionLocalizationSync() { const { languagePreference, regionalLocale, regionalLocalePreference } = useLocalePreferences(); + const languageSetting = useCurrentUserSetting("localization.language"); const [ready, setReady] = useState(false); const lastSignatureRef = useRef(null); useEffect(() => { - let cancelled = false; - - void loadCurrentUserSettings().finally(() => { - if (!cancelled) { - setReady(true); - } - }); - - return () => { - cancelled = true; - }; - }, []); + setReady(languageSetting.confirmedValue !== undefined); + }, [languageSetting.confirmedValue]); useEffect(() => { if (!ready) { diff --git a/frontend/src/i18n/LocalePreferencesProvider.tsx b/frontend/src/i18n/LocalePreferencesProvider.tsx index 58b8be6b..50c26b88 100644 --- a/frontend/src/i18n/LocalePreferencesProvider.tsx +++ b/frontend/src/i18n/LocalePreferencesProvider.tsx @@ -1,13 +1,13 @@ import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from "react"; -import { loadCurrentUserSettings, USER_SETTINGS_CHANGED_EVENT } from "../services/userSettingsSync"; -import type { CurrentUserSettings, LanguagePreference, RegionalLocalePreference } from "../types"; +import { useCurrentUserSetting } from "../services/userSettingsStore"; +import type { LanguagePreference, RegionalLocalePreference } from "../types"; import i18n, { + setLanguagePreference as applyLanguagePreference, + setRegionalLocalePreference as applyRegionalLocalePreference, getCurrentLanguagePreference, getCurrentRegionalLocale, getCurrentRegionalLocalePreference, REGIONAL_LOCALE_CHANGED_EVENT, - setLanguagePreference, - setRegionalLocalePreference, } from "./index"; interface LocalePreferencesContextValue { @@ -20,15 +20,9 @@ interface LocalePreferencesContextValue { const LocalePreferencesContext = createContext(undefined); -function extractLocalizationSettings(settings: CurrentUserSettings | null): CurrentUserSettings["localization"] | null { - if (!settings) { - return null; - } - - return settings.localization; -} - export function LocalePreferencesProvider({ children }: { children: ReactNode }) { + const languageSetting = useCurrentUserSetting("localization.language"); + const regionalLocaleSetting = useCurrentUserSetting("localization.regional_locale"); const [languagePreference, setLanguagePreferenceState] = useState(() => getCurrentLanguagePreference()); const [regionalLocalePreference, setRegionalLocalePreferenceState] = useState(() => getCurrentRegionalLocalePreference() @@ -42,36 +36,25 @@ export function LocalePreferencesProvider({ children }: { children: ReactNode }) setRegionalLocaleState(getCurrentRegionalLocale()); }; - const applyLocalizationSettings = async (settings: CurrentUserSettings | null) => { - const localization = extractLocalizationSettings(settings); - if (!localization) { + const applyLocalizationSettings = async () => { + if (languageSetting.confirmedValue === undefined || regionalLocaleSetting.confirmedValue === undefined) { return; } - await setLanguagePreference(localization.language); - await setRegionalLocalePreference(localization.regional_locale); + await applyLanguagePreference(languageSetting.confirmedValue); + await applyRegionalLocalePreference(regionalLocaleSetting.confirmedValue); syncFromI18n(); }; - const handleUserSettingsChanged = (event: Event) => { - const settings = (event as CustomEvent).detail; - void applyLocalizationSettings(settings); - }; - i18n.on("languageChanged", syncFromI18n); window.addEventListener(REGIONAL_LOCALE_CHANGED_EVENT, syncFromI18n); - window.addEventListener(USER_SETTINGS_CHANGED_EVENT, handleUserSettingsChanged); - - void loadCurrentUserSettings().then((settings) => { - void applyLocalizationSettings(settings); - }); + void applyLocalizationSettings(); return () => { i18n.off("languageChanged", syncFromI18n); window.removeEventListener(REGIONAL_LOCALE_CHANGED_EVENT, syncFromI18n); - window.removeEventListener(USER_SETTINGS_CHANGED_EVENT, handleUserSettingsChanged); }; - }, []); + }, [languageSetting.confirmedValue, regionalLocaleSetting.confirmedValue]); const value = useMemo( () => ({ @@ -79,18 +62,20 @@ export function LocalePreferencesProvider({ children }: { children: ReactNode }) regionalLocale, regionalLocalePreference, setLanguagePreference: async (nextLanguagePreference) => { - await setLanguagePreference(nextLanguagePreference); + await languageSetting.commit(nextLanguagePreference); + await applyLanguagePreference(nextLanguagePreference); setLanguagePreferenceState(getCurrentLanguagePreference()); setRegionalLocalePreferenceState(getCurrentRegionalLocalePreference()); setRegionalLocaleState(getCurrentRegionalLocale()); }, setRegionalLocalePreference: async (nextRegionalLocalePreference) => { - await setRegionalLocalePreference(nextRegionalLocalePreference); + await regionalLocaleSetting.commit(nextRegionalLocalePreference); + await applyRegionalLocalePreference(nextRegionalLocalePreference); setRegionalLocalePreferenceState(getCurrentRegionalLocalePreference()); setRegionalLocaleState(getCurrentRegionalLocale()); }, }), - [languagePreference, regionalLocale, regionalLocalePreference] + [languagePreference, languageSetting, regionalLocale, regionalLocalePreference, regionalLocaleSetting] ); return {children}; diff --git a/frontend/src/i18n/__tests__/index.test.ts b/frontend/src/i18n/__tests__/index.test.ts index 25951315..27c3a8c4 100644 --- a/frontend/src/i18n/__tests__/index.test.ts +++ b/frontend/src/i18n/__tests__/index.test.ts @@ -7,15 +7,7 @@ import { VIEW_MODE_SELECTOR_STRINGS } from "../../components/FileBrowser/viewMod import { THEME_SELECTOR_STRINGS } from "../../components/themeSelectorStrings"; import { BROWSER_SHORTCUTS, COMMON_SHORTCUTS } from "../../config/keyboardShortcuts"; import { compareLocalizedStrings, formatLocalizedDateTime, formatLocalizedNumber } from "../../utils/localeFormatting"; -import { - getAvailableLanguages, - isPseudoLanguageEnabled, - LOCALE_STORAGE_KEY, - REGIONAL_LOCALE_STORAGE_KEY, - setLocale, - setRegionalLocalePreference, - translate, -} from "../index"; +import { getAvailableLanguages, isPseudoLanguageEnabled, setLocale, setRegionalLocalePreference, translate } from "../index"; describe("frontend i18n", () => { afterEach(async () => { @@ -77,7 +69,7 @@ describe("frontend i18n", () => { expect(translate("viewer.fallback.failedTitle")).toBe("[Ṽíéŵéŕ úńåṽåíĺåƀĺé]"); expect(document.documentElement.lang).toBe("en-XA"); expect(document.documentElement.dir).toBe("ltr"); - expect(window.localStorage.getItem(LOCALE_STORAGE_KEY)).toBe("en-XA"); + expect(window.localStorage.getItem("sambee.locale")).toBeNull(); }); it("updates regional formatting independently from the display language", async () => { @@ -89,7 +81,7 @@ describe("frontend i18n", () => { new Date("2026-03-22T14:35:00Z").toLocaleString("de-DE", { timeZone: "UTC", year: "numeric", month: "2-digit", day: "2-digit" }) ); expect(compareLocalizedStrings("ä", "z")).toBe("ä".localeCompare("z", "de-DE", undefined)); - expect(window.localStorage.getItem(REGIONAL_LOCALE_STORAGE_KEY)).toBe("de-DE"); + expect(window.localStorage.getItem("sambee.regional-locale")).toBeNull(); }); it("hides the pseudo locale from production language options", () => { diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index d7e09061..e9930208 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -112,56 +112,6 @@ function resolveRegionalLocale(localePreference: RegionalLocalePreference, langu return resolveLanguageFromPreference(languagePreference); } -function readStoredLanguagePreference(): LanguagePreference | undefined { - if (typeof window === "undefined") { - return undefined; - } - - try { - const storedLanguage = window.localStorage.getItem(LOCALE_STORAGE_KEY); - return storedLanguage ? resolveLanguagePreference(storedLanguage) : undefined; - } catch { - return undefined; - } -} - -function readStoredRegionalLocalePreference(): RegionalLocalePreference | undefined { - if (typeof window === "undefined") { - return undefined; - } - - try { - const storedLocale = window.localStorage.getItem(REGIONAL_LOCALE_STORAGE_KEY); - return storedLocale ? resolveRegionalLocalePreference(storedLocale) : undefined; - } catch { - return undefined; - } -} - -function persistLanguagePreference(languagePreference: LanguagePreference): void { - if (typeof window === "undefined") { - return; - } - - try { - window.localStorage.setItem(LOCALE_STORAGE_KEY, resolveLanguagePreference(languagePreference)); - } catch { - // Ignore storage failures; locale changes should still work for the current session. - } -} - -function persistRegionalLocalePreference(regionalLocalePreference: RegionalLocalePreference): void { - if (typeof window === "undefined") { - return; - } - - try { - window.localStorage.setItem(REGIONAL_LOCALE_STORAGE_KEY, resolveRegionalLocalePreference(regionalLocalePreference)); - } catch { - // Ignore storage failures; locale changes should still work for the current session. - } -} - function syncDocumentLanguage(language: string): void { if (typeof document === "undefined") { return; @@ -213,9 +163,6 @@ function updateResolvedRegionalLocale(): void { } } -currentLanguagePreference = readStoredLanguagePreference() ?? DEFAULT_LANGUAGE_PREFERENCE; -currentRegionalLocalePreference = readStoredRegionalLocalePreference() ?? DEFAULT_REGIONAL_LOCALE_PREFERENCE; - const initialLanguage = resolveLanguageFromPreference(currentLanguagePreference); currentRegionalLocale = resolveRegionalLocale(currentRegionalLocalePreference, currentLanguagePreference); @@ -245,14 +192,12 @@ export function translate(...args: Parameters): string { export async function setLanguagePreference(languagePreference: string): Promise { currentLanguagePreference = resolveLanguagePreference(languagePreference); - persistLanguagePreference(currentLanguagePreference); updateResolvedRegionalLocale(); await i18n.changeLanguage(resolveLanguageFromPreference(currentLanguagePreference)); } export async function setRegionalLocalePreference(regionalLocalePreference: string): Promise { currentRegionalLocalePreference = resolveRegionalLocalePreference(regionalLocalePreference); - persistRegionalLocalePreference(currentRegionalLocalePreference); updateResolvedRegionalLocale(); } diff --git a/frontend/src/i18n/resources.ts b/frontend/src/i18n/resources.ts index e9ca3c5f..cdcc861a 100644 --- a/frontend/src/i18n/resources.ts +++ b/frontend/src/i18n/resources.ts @@ -609,11 +609,12 @@ export const EN_TRANSLATIONS = { }, }, smbSettings: { - saveChanges: "Save SMB settings", - saveSuccess: "SMB settings saved", loadFailed: "Failed to load SMB settings", saveFailed: "Failed to save SMB settings", - resetOverride: "Reset override", + saving: "Saving SMB setting", + saved: "SMB setting saved", + connectionTimeoutError: "Enter a whole number from 5 to 120.", + readChunkSizeError: "Enter a whole number within the allowed chunk-size range.", sections: { protection: "Protection", connectionBehavior: "Connection behavior", @@ -831,6 +832,7 @@ export const EN_TRANSLATIONS = { title: "Choose Viewer", fileLabel: "File to open", open: "Open", + openWithoutSaving: "Open without saving", alwaysUse: "Always use this viewer for this file type", default: "Default", openInNativeApp: "Open in native app", @@ -972,13 +974,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 +997,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 +1037,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 +1103,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/AccountSettings.tsx b/frontend/src/pages/AccountSettings.tsx index d958b0ec..b0f8a787 100644 --- a/frontend/src/pages/AccountSettings.tsx +++ b/frontend/src/pages/AccountSettings.tsx @@ -16,13 +16,9 @@ import { import { useEffect, useEffectEvent, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { adminDialogActionButtonSx, adminDialogEndActionRowSx } from "../components/Admin/dialogActionStyles"; -import { ResponsiveFormDialog } from "../components/Admin/ResponsiveFormDialog"; -import { - SettingsFormGroup, - SettingsFormRow, - SettingsFormSurface, - settingsFormOutlinedControlSx, -} from "../components/Settings/SettingsFormLayout"; +import { DialogNotice } from "../components/Dialog/DialogNotice"; +import { ResponsiveDialogShell } from "../components/Dialog/ResponsiveDialogShell"; +import { FormGroup, FormRow, FormSurface, formOutlinedControlSx } from "../components/Form/FormLayout"; import { SettingsGroup } from "../components/Settings/SettingsGroup"; import { SettingsList } from "../components/Settings/SettingsList"; import { SettingsPage } from "../components/Settings/SettingsPage"; @@ -402,19 +398,20 @@ export function AccountSettings({ dialogSafe = false }: { dialogSafe?: boolean } )} - } actions={ @@ -423,7 +420,7 @@ export function AccountSettings({ dialogSafe = false }: { dialogSafe?: boolean } variant="contained" disabled={passwordSubmitting || passwordConfirmationError !== null} startIcon={passwordSubmitting ? : undefined} - sx={[settingsPrimaryButtonSx, adminDialogActionButtonSx]} + sx={{ ...settingsPrimaryButtonSx, ...adminDialogActionButtonSx }} > {passwordSubmitting ? "Changing password" : "Change password"} @@ -439,9 +436,9 @@ export function AccountSettings({ dialogSafe = false }: { dialogSafe?: boolean } }} > - - - + + + - - + + updateNewPassword(event.target.value)} disabled={passwordSubmitting} fullWidth - sx={settingsFormOutlinedControlSx} + sx={formOutlinedControlSx} slotProps={{ input: { endAdornment: ( @@ -503,9 +500,9 @@ export function AccountSettings({ dialogSafe = false }: { dialogSafe?: boolean } onChange={(event) => updatePasswordConfirmation(event.target.value)} disabled={passwordSubmitting} error={passwordConfirmationError !== null} - helperText={passwordConfirmationError ?? " "} + helperText={passwordConfirmationError} fullWidth - sx={settingsFormOutlinedControlSx} + sx={formOutlinedControlSx} slotProps={{ input: { endAdornment: ( @@ -520,19 +517,11 @@ export function AccountSettings({ dialogSafe = false }: { dialogSafe?: boolean } }} /> - - - - - {passwordError ?? " "} - + + + - + ); } diff --git a/frontend/src/pages/AdvancedSettings.tsx b/frontend/src/pages/AdvancedSettings.tsx index 98ba0362..e6dd2d86 100644 --- a/frontend/src/pages/AdvancedSettings.tsx +++ b/frontend/src/pages/AdvancedSettings.tsx @@ -1,14 +1,13 @@ -import { Box, Button, CircularProgress, FormControl, FormHelperText, InputAdornment, MenuItem, Stack, TextField } from "@mui/material"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { Alert, Box, Button, FormControl, FormHelperText, InputAdornment, MenuItem, Stack, TextField } from "@mui/material"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { SettingsInlineAlert, SettingsNotificationSnackbar, type SettingsNotificationState } from "../components/Settings/SettingsFeedback"; import { SettingsGroup } from "../components/Settings/SettingsGroup"; import { SettingsPage } from "../components/Settings/SettingsPage"; import { SettingsSectionList } from "../components/Settings/SettingsSectionList"; import { SettingsLoadingState } from "../components/Settings/SettingsState"; -import { settingsPrimaryButtonSx, settingsUtilityButtonSx } from "../components/Settings/settingsButtonStyles"; import { loadAdvancedSettingsData, SETTINGS_DATA_CACHE_KEYS } from "../components/Settings/settingsDataSources"; import { useCachedAsyncData } from "../hooks/useCachedAsyncData"; +import { SettingPersistenceIndicator, useSystemSettingPersistence } from "../hooks/useSystemSettingPersistence"; import { translate } from "../i18n"; import api from "../services/api"; import type { AdvancedSystemSettings, AdvancedSystemSettingsUpdate, IntegerSystemSetting } from "../types"; @@ -139,93 +138,24 @@ function validateByteSizeSetting(setting: IntegerSystemSetting, value: number | return null; } -function buildUpdatePayload(formState: AdvancedSettingsFormState): AdvancedSystemSettingsUpdate { - const toOptionalNumber = (value: number | null): number | undefined => value ?? undefined; - const includesPdfSettings = [ - formState.pdfCacheQuotaBytes, - formState.pdfCacheInactivityTtlSeconds, - formState.pdfMaxSourceSizeBytes, - formState.pdfMaxOutputSizeBytes, - formState.pdfAddressSpaceBytes, - formState.pdfTemporaryDiskBytes, - formState.pdfTimeoutSeconds, - formState.pdfCpuTimeSeconds, - formState.pdfMaxConcurrent, - formState.pdfQueueWaitSeconds, - formState.pdfScreenDerivativeEnabled, - formState.pdfScreenMaxDecodedPixels, - ].some((value) => value !== null); - - return { - preprocessors: { - imagemagick: { - max_file_size_bytes: toOptionalNumber(formState.imagemagickMaxFileSizeBytes), - timeout_seconds: toOptionalNumber(formState.imagemagickTimeoutSeconds), - }, - }, - ...(includesPdfSettings - ? { - pdf: { - cache_quota_bytes: toOptionalNumber(formState.pdfCacheQuotaBytes), - cache_inactivity_ttl_seconds: toOptionalNumber(formState.pdfCacheInactivityTtlSeconds), - max_source_size_bytes: toOptionalNumber(formState.pdfMaxSourceSizeBytes), - max_output_size_bytes: toOptionalNumber(formState.pdfMaxOutputSizeBytes), - address_space_bytes: toOptionalNumber(formState.pdfAddressSpaceBytes), - temporary_disk_bytes: toOptionalNumber(formState.pdfTemporaryDiskBytes), - timeout_seconds: toOptionalNumber(formState.pdfTimeoutSeconds), - cpu_time_seconds: toOptionalNumber(formState.pdfCpuTimeSeconds), - max_concurrent: toOptionalNumber(formState.pdfMaxConcurrent), - queue_wait_seconds: toOptionalNumber(formState.pdfQueueWaitSeconds), - screen_derivative_enabled: toOptionalNumber(formState.pdfScreenDerivativeEnabled), - screen_max_decoded_pixels: toOptionalNumber(formState.pdfScreenMaxDecodedPixels), - }, - } - : {}), - }; -} - -function SettingFieldResetAction({ - setting, - onReset, - resetDisabled, -}: { - setting: IntegerSystemSetting; - onReset?: () => void; - resetDisabled?: boolean; -}) { - const { t } = useTranslation(); - - if (setting.source !== "database" || !onReset) { - return null; - } - - return ( - - - - ); -} - function SettingField({ setting, value, onChange, - onReset, - resetDisabled, + onCommit, + persistenceError, + pending, + saved, unitAdornment, - errorText, - showErrors, }: { setting: IntegerSystemSetting; value: number | null; onChange: (value: number | null) => void; - onReset?: () => void; - resetDisabled?: boolean; + onCommit: (value: number) => void; + persistenceError?: string; + pending: boolean; + saved: boolean; unitAdornment?: string; - errorText?: string | null; - showErrors?: boolean; }) { const { t } = useTranslation(); const [displayValue, setDisplayValue] = useState(value === null ? "" : String(value)); @@ -235,8 +165,9 @@ function SettingField({ setDisplayValue(value === null ? "" : String(value)); }, [value]); - const displayError = Boolean(errorText) && (touched || showErrors); - const helperText = displayError + const validationError = validateIntegerSetting(setting, value, unitAdornment); + const errorText = persistenceError ?? (touched ? validationError : null); + const helperText = errorText ? errorText : t("settings.advanced.helperText.integer", { description: setting.description, @@ -247,7 +178,6 @@ function SettingField({ return ( - setTouched(true)} + onBlur={() => { + setTouched(true); + if (!validationError && value !== null) onCommit(value); + }} variant="outlined" sx={{ width: "100%", maxWidth: { xs: "100%", sm: DESKTOP_NUMERIC_FIELD_MAX_WIDTH } }} - error={displayError} + disabled={pending} + error={Boolean(errorText)} slotProps={{ htmlInput: { inputMode: "numeric", pattern: "[0-9]*" }, - input: unitAdornment - ? { - endAdornment: {unitAdornment}, - } - : undefined, + input: + unitAdornment || pending || saved + ? { + endAdornment: ( + + {unitAdornment} + + + ), + } + : undefined, }} helperText={helperText} /> @@ -283,18 +228,18 @@ function ByteSizeSettingField({ setting, value, onChange, - onReset, - resetDisabled, - errorText, - showErrors, + onCommit, + persistenceError, + pending, + saved, }: { setting: IntegerSystemSetting; value: number | null; onChange: (value: number | null) => void; - onReset?: () => void; - resetDisabled?: boolean; - errorText?: string | null; - showErrors?: boolean; + onCommit: (value: number) => void; + persistenceError?: string; + pending: boolean; + saved: boolean; }) { const { t } = useTranslation(); const [unit, setUnit] = useState(() => getPreferredByteUnit(value ?? setting.min_value)); @@ -321,7 +266,8 @@ function ByteSizeSettingField({ }, [unit, value]); const factor = getByteUnitFactor(unit); - const displayError = Boolean(errorText) && (touched || showErrors); + const validationError = validateByteSizeSetting(setting, value); + const errorText = persistenceError ?? (touched ? validationError : null); const handleValueChange = (nextValue: string) => { if (!/^\d*$/.test(nextValue)) { @@ -340,6 +286,7 @@ function ByteSizeSettingField({ const handleValueBlur = () => { setTouched(true); + if (!validationError && value !== null) onCommit(value); }; const handleUnitChange = (nextUnit: ByteUnitLabel) => { @@ -356,7 +303,7 @@ function ByteSizeSettingField({ }; const availableUnits = BYTE_UNITS.filter((option) => option.factor <= Math.max(value ?? 0, setting.min_value) || option.label === unit); - const helperMessage = displayError + const helperMessage = errorText ? errorText : t("settings.advanced.helperText.byteSize", { description: setting.description, @@ -367,8 +314,7 @@ function ByteSizeSettingField({ return ( - - + + + + ), + } + : undefined, }} variant="outlined" sx={{ width: "100%", maxWidth: { xs: "100%", sm: DESKTOP_VALUE_FIELD_MAX_WIDTH } }} - error={displayError} + disabled={pending} + error={Boolean(errorText)} /> handleUnitChange(event.target.value as ByteUnitLabel)} variant="outlined" - error={displayError} + disabled={pending} + error={Boolean(errorText)} sx={{ width: { xs: "100%", sm: DESKTOP_UNIT_FIELD_WIDTH } }} > {availableUnits.map((option) => ( @@ -405,6 +368,43 @@ function ByteSizeSettingField({ ); } +function applyIntegerSettingUpdate(setting: IntegerSystemSetting, update: AdvancedSystemSettingsUpdate): IntegerSystemSetting { + return setting.key === update.field ? { ...setting, value: update.value, source: "database" } : setting; +} + +function applyAdvancedSettingUpdate(settings: AdvancedSystemSettings, update: AdvancedSystemSettingsUpdate): AdvancedSystemSettings { + const updateSetting = (setting: IntegerSystemSetting) => applyIntegerSettingUpdate(setting, update); + const imagemagick = settings.preprocessors.imagemagick; + return { + ...settings, + preprocessors: { + ...settings.preprocessors, + imagemagick: { + max_file_size_bytes: updateSetting(imagemagick.max_file_size_bytes), + timeout_seconds: updateSetting(imagemagick.timeout_seconds), + }, + }, + ...(settings.pdf + ? { + pdf: { + cache_quota_bytes: updateSetting(settings.pdf.cache_quota_bytes), + cache_inactivity_ttl_seconds: updateSetting(settings.pdf.cache_inactivity_ttl_seconds), + max_source_size_bytes: updateSetting(settings.pdf.max_source_size_bytes), + max_output_size_bytes: updateSetting(settings.pdf.max_output_size_bytes), + address_space_bytes: updateSetting(settings.pdf.address_space_bytes), + temporary_disk_bytes: updateSetting(settings.pdf.temporary_disk_bytes), + timeout_seconds: updateSetting(settings.pdf.timeout_seconds), + cpu_time_seconds: updateSetting(settings.pdf.cpu_time_seconds), + max_concurrent: updateSetting(settings.pdf.max_concurrent), + queue_wait_seconds: updateSetting(settings.pdf.queue_wait_seconds), + screen_derivative_enabled: updateSetting(settings.pdf.screen_derivative_enabled), + screen_max_decoded_pixels: updateSetting(settings.pdf.screen_max_decoded_pixels), + }, + } + : {}), + }; +} + export function AdvancedSettings({ dialogSafeHeader = false }: AdvancedSettingsProps) { const { t } = useTranslation(); const [pageError, setPageError] = useState(null); @@ -417,6 +417,7 @@ export function AdvancedSettings({ dialogSafeHeader = false }: AdvancedSettingsP const { data: settings, loading, + refresh, setData: setCachedSettings, } = useCachedAsyncData({ cacheKey: SETTINGS_DATA_CACHE_KEYS.adminSystem, @@ -424,21 +425,9 @@ export function AdvancedSettings({ dialogSafeHeader = false }: AdvancedSettingsP onError: handleAdvancedSettingsLoadError, }); const [formState, setFormState] = useState(null); - const [saving, setSaving] = useState(false); - const [notification, setNotification] = useState({ - open: false, - message: "", - severity: "success", - }); - const [submitAttempted, setSubmitAttempted] = useState(false); - - const hasUnsavedChanges = useMemo(() => { - if (!settings || !formState) { - return false; - } - - return JSON.stringify(createFormState(settings)) !== JSON.stringify(formState); - }, [formState, settings]); + const persistence = useSystemSettingPersistence(api.updateAdvancedSettings, (saveError) => + getApiErrorMessage(saveError, t("settings.advanced.saveFailed")) + ); useEffect(() => { if (!settings) { @@ -446,145 +435,58 @@ export function AdvancedSettings({ dialogSafeHeader = false }: AdvancedSettingsP } setPageError(null); - setFormState((current) => { - if (current === null || !hasUnsavedChanges) { - return createFormState(settings); - } - - return current; - }); - - if (!hasUnsavedChanges) { - setSubmitAttempted(false); - } - }, [hasUnsavedChanges, settings]); - - const validationErrors = useMemo(() => { - if (!settings || !formState) { - return null; - } - - return { - imagemagickMaxFileSizeBytes: validateByteSizeSetting( - settings.preprocessors.imagemagick.max_file_size_bytes, - formState.imagemagickMaxFileSizeBytes - ), - imagemagickTimeoutSeconds: validateIntegerSetting( - settings.preprocessors.imagemagick.timeout_seconds, - formState.imagemagickTimeoutSeconds, - t("settings.advanced.fields.seconds") - ), - pdfCacheQuotaBytes: settings.pdf ? validateByteSizeSetting(settings.pdf.cache_quota_bytes, formState.pdfCacheQuotaBytes) : null, - pdfCacheInactivityTtlSeconds: settings.pdf - ? validateIntegerSetting( - settings.pdf.cache_inactivity_ttl_seconds, - formState.pdfCacheInactivityTtlSeconds, - t("settings.advanced.fields.seconds") - ) - : null, - pdfMaxSourceSizeBytes: settings.pdf - ? validateByteSizeSetting(settings.pdf.max_source_size_bytes, formState.pdfMaxSourceSizeBytes) - : null, - pdfMaxOutputSizeBytes: settings.pdf - ? validateByteSizeSetting(settings.pdf.max_output_size_bytes, formState.pdfMaxOutputSizeBytes) - : null, - pdfAddressSpaceBytes: settings.pdf ? validateByteSizeSetting(settings.pdf.address_space_bytes, formState.pdfAddressSpaceBytes) : null, - pdfTemporaryDiskBytes: settings.pdf - ? validateByteSizeSetting(settings.pdf.temporary_disk_bytes, formState.pdfTemporaryDiskBytes) - : null, - pdfTimeoutSeconds: settings.pdf - ? validateIntegerSetting(settings.pdf.timeout_seconds, formState.pdfTimeoutSeconds, t("settings.advanced.fields.seconds")) - : null, - pdfCpuTimeSeconds: settings.pdf - ? validateIntegerSetting(settings.pdf.cpu_time_seconds, formState.pdfCpuTimeSeconds, t("settings.advanced.fields.seconds")) - : null, - pdfMaxConcurrent: settings.pdf ? validateIntegerSetting(settings.pdf.max_concurrent, formState.pdfMaxConcurrent) : null, - pdfQueueWaitSeconds: settings.pdf - ? validateIntegerSetting(settings.pdf.queue_wait_seconds, formState.pdfQueueWaitSeconds, t("settings.advanced.fields.seconds")) - : null, - pdfScreenDerivativeEnabled: settings.pdf - ? validateIntegerSetting(settings.pdf.screen_derivative_enabled, formState.pdfScreenDerivativeEnabled) - : null, - pdfScreenMaxDecodedPixels: settings.pdf - ? validateIntegerSetting(settings.pdf.screen_max_decoded_pixels, formState.pdfScreenMaxDecodedPixels) - : null, - }; - }, [formState, settings, t]); - - const hasValidationErrors = useMemo(() => { - if (!validationErrors) { - return false; - } - - return Object.values(validationErrors).some((value) => value !== null); - }, [validationErrors]); - - const handleSave = async () => { - setSubmitAttempted(true); - - if (!formState || hasValidationErrors) { - return; - } - - try { - setSaving(true); - setPageError(null); - const updated = await api.updateAdvancedSettings(buildUpdatePayload(formState)); - setCachedSettings(updated); - setFormState(createFormState(updated)); - setNotification({ - open: true, - message: t("settings.advanced.saveSuccess"), - severity: "success", - }); - setSubmitAttempted(false); - } catch (saveError: unknown) { - setNotification({ - open: true, - message: getApiErrorMessage(saveError, t("settings.advanced.saveFailed")), - severity: "error", - }); - } finally { - setSaving(false); - } + setFormState((current) => current ?? createFormState(settings)); + }, [settings]); + + const updateFormField = ( + field: Field, + value: AdvancedSettingsFormState[Field], + setting: IntegerSystemSetting + ) => { + setFormState((current) => (current ? { ...current, [field]: value } : current)); + persistence.clearFieldFeedback(setting.key); }; - const handleReset = async (key: string, label: string) => { - try { - setSaving(true); - setPageError(null); - const updated = await api.updateAdvancedSettings({ reset_keys: [key] }); - setCachedSettings(updated); - setFormState(createFormState(updated)); - setNotification({ - open: true, - message: t("settings.advanced.resetSuccess", { label }), - severity: "success", - }); - setSubmitAttempted(false); - } catch (resetError: unknown) { - setNotification({ - open: true, - message: getApiErrorMessage(resetError, t("settings.advanced.resetFailed")), - severity: "error", - }); - } finally { - setSaving(false); + const persistField = async (setting: IntegerSystemSetting, value: number) => { + const result = await persistence.persist({ field: setting.key, value }); + if (result.status === "completed") { + setCachedSettings((current) => (current ? applyAdvancedSettingUpdate(current, result.update) : current)); } }; - const saveAction = ( - - ); + const getFieldPersistenceProps = (setting: IntegerSystemSetting) => ({ + onCommit: (value: number) => void persistField(setting, value), + persistenceError: persistence.fieldErrors[setting.key], + pending: persistence.isPending(setting.key), + saved: persistence.isSaved(setting.key), + }); + + const retryLoad = () => { + setPageError(null); + void refresh(true); + }; return ( - + + {t("common.actions.retry")} + + } + > + {pageError} + + ) : null + } + > {loading && !settings && } - {pageError && {pageError}} - {settings && formState && ( @@ -593,31 +495,19 @@ export function AdvancedSettings({ dialogSafeHeader = false }: AdvancedSettingsP setFormState((current) => (current ? { ...current, imagemagickMaxFileSizeBytes: value } : current))} - errorText={validationErrors?.imagemagickMaxFileSizeBytes} - showErrors={submitAttempted} - onReset={() => - handleReset( - settings.preprocessors.imagemagick.max_file_size_bytes.key, - settings.preprocessors.imagemagick.max_file_size_bytes.label - ) + onChange={(value) => + updateFormField("imagemagickMaxFileSizeBytes", value, settings.preprocessors.imagemagick.max_file_size_bytes) } - resetDisabled={saving || loading || hasUnsavedChanges} + {...getFieldPersistenceProps(settings.preprocessors.imagemagick.max_file_size_bytes)} /> setFormState((current) => (current ? { ...current, imagemagickTimeoutSeconds: value } : current))} - errorText={validationErrors?.imagemagickTimeoutSeconds} - showErrors={submitAttempted} - unitAdornment={t("settings.advanced.fields.seconds")} - onReset={() => - handleReset( - settings.preprocessors.imagemagick.timeout_seconds.key, - settings.preprocessors.imagemagick.timeout_seconds.label - ) + onChange={(value) => + updateFormField("imagemagickTimeoutSeconds", value, settings.preprocessors.imagemagick.timeout_seconds) } - resetDisabled={saving || loading || hasUnsavedChanges} + unitAdornment={t("settings.advanced.fields.seconds")} + {...getFieldPersistenceProps(settings.preprocessors.imagemagick.timeout_seconds)} /> {settings.pdf && ( @@ -625,118 +515,78 @@ export function AdvancedSettings({ dialogSafeHeader = false }: AdvancedSettingsP setFormState((current) => (current ? { ...current, pdfCacheQuotaBytes: value } : current))} - errorText={validationErrors?.pdfCacheQuotaBytes} - showErrors={submitAttempted} - onReset={() => handleReset(settings.pdf.cache_quota_bytes.key, settings.pdf.cache_quota_bytes.label)} - resetDisabled={saving || loading || hasUnsavedChanges} + onChange={(value) => updateFormField("pdfCacheQuotaBytes", value, settings.pdf.cache_quota_bytes)} + {...getFieldPersistenceProps(settings.pdf.cache_quota_bytes)} /> - setFormState((current) => (current ? { ...current, pdfCacheInactivityTtlSeconds: value } : current)) - } - errorText={validationErrors?.pdfCacheInactivityTtlSeconds} - showErrors={submitAttempted} + onChange={(value) => updateFormField("pdfCacheInactivityTtlSeconds", value, settings.pdf.cache_inactivity_ttl_seconds)} unitAdornment={t("settings.advanced.fields.seconds")} - onReset={() => - handleReset(settings.pdf.cache_inactivity_ttl_seconds.key, settings.pdf.cache_inactivity_ttl_seconds.label) - } - resetDisabled={saving || loading || hasUnsavedChanges} + {...getFieldPersistenceProps(settings.pdf.cache_inactivity_ttl_seconds)} /> setFormState((current) => (current ? { ...current, pdfMaxSourceSizeBytes: value } : current))} - errorText={validationErrors?.pdfMaxSourceSizeBytes} - showErrors={submitAttempted} - onReset={() => handleReset(settings.pdf.max_source_size_bytes.key, settings.pdf.max_source_size_bytes.label)} - resetDisabled={saving || loading || hasUnsavedChanges} + onChange={(value) => updateFormField("pdfMaxSourceSizeBytes", value, settings.pdf.max_source_size_bytes)} + {...getFieldPersistenceProps(settings.pdf.max_source_size_bytes)} /> setFormState((current) => (current ? { ...current, pdfMaxOutputSizeBytes: value } : current))} - errorText={validationErrors?.pdfMaxOutputSizeBytes} - showErrors={submitAttempted} - onReset={() => handleReset(settings.pdf.max_output_size_bytes.key, settings.pdf.max_output_size_bytes.label)} - resetDisabled={saving || loading || hasUnsavedChanges} + onChange={(value) => updateFormField("pdfMaxOutputSizeBytes", value, settings.pdf.max_output_size_bytes)} + {...getFieldPersistenceProps(settings.pdf.max_output_size_bytes)} /> setFormState((current) => (current ? { ...current, pdfAddressSpaceBytes: value } : current))} - errorText={validationErrors?.pdfAddressSpaceBytes} - showErrors={submitAttempted} - onReset={() => handleReset(settings.pdf.address_space_bytes.key, settings.pdf.address_space_bytes.label)} - resetDisabled={saving || loading || hasUnsavedChanges} + onChange={(value) => updateFormField("pdfAddressSpaceBytes", value, settings.pdf.address_space_bytes)} + {...getFieldPersistenceProps(settings.pdf.address_space_bytes)} /> setFormState((current) => (current ? { ...current, pdfTemporaryDiskBytes: value } : current))} - errorText={validationErrors?.pdfTemporaryDiskBytes} - showErrors={submitAttempted} - onReset={() => handleReset(settings.pdf.temporary_disk_bytes.key, settings.pdf.temporary_disk_bytes.label)} - resetDisabled={saving || loading || hasUnsavedChanges} + onChange={(value) => updateFormField("pdfTemporaryDiskBytes", value, settings.pdf.temporary_disk_bytes)} + {...getFieldPersistenceProps(settings.pdf.temporary_disk_bytes)} /> setFormState((current) => (current ? { ...current, pdfTimeoutSeconds: value } : current))} - errorText={validationErrors?.pdfTimeoutSeconds} - showErrors={submitAttempted} + onChange={(value) => updateFormField("pdfTimeoutSeconds", value, settings.pdf.timeout_seconds)} unitAdornment={t("settings.advanced.fields.seconds")} - onReset={() => handleReset(settings.pdf.timeout_seconds.key, settings.pdf.timeout_seconds.label)} - resetDisabled={saving || loading || hasUnsavedChanges} + {...getFieldPersistenceProps(settings.pdf.timeout_seconds)} /> setFormState((current) => (current ? { ...current, pdfCpuTimeSeconds: value } : current))} - errorText={validationErrors?.pdfCpuTimeSeconds} - showErrors={submitAttempted} + onChange={(value) => updateFormField("pdfCpuTimeSeconds", value, settings.pdf.cpu_time_seconds)} unitAdornment={t("settings.advanced.fields.seconds")} - onReset={() => handleReset(settings.pdf.cpu_time_seconds.key, settings.pdf.cpu_time_seconds.label)} - resetDisabled={saving || loading || hasUnsavedChanges} + {...getFieldPersistenceProps(settings.pdf.cpu_time_seconds)} /> setFormState((current) => (current ? { ...current, pdfMaxConcurrent: value } : current))} - errorText={validationErrors?.pdfMaxConcurrent} - showErrors={submitAttempted} - onReset={() => handleReset(settings.pdf.max_concurrent.key, settings.pdf.max_concurrent.label)} - resetDisabled={saving || loading || hasUnsavedChanges} + onChange={(value) => updateFormField("pdfMaxConcurrent", value, settings.pdf.max_concurrent)} + {...getFieldPersistenceProps(settings.pdf.max_concurrent)} /> setFormState((current) => (current ? { ...current, pdfQueueWaitSeconds: value } : current))} - errorText={validationErrors?.pdfQueueWaitSeconds} - showErrors={submitAttempted} + onChange={(value) => updateFormField("pdfQueueWaitSeconds", value, settings.pdf.queue_wait_seconds)} unitAdornment={t("settings.advanced.fields.seconds")} - onReset={() => handleReset(settings.pdf.queue_wait_seconds.key, settings.pdf.queue_wait_seconds.label)} - resetDisabled={saving || loading || hasUnsavedChanges} + {...getFieldPersistenceProps(settings.pdf.queue_wait_seconds)} /> setFormState((current) => (current ? { ...current, pdfScreenDerivativeEnabled: value } : current))} - errorText={validationErrors?.pdfScreenDerivativeEnabled} - showErrors={submitAttempted} - onReset={() => handleReset(settings.pdf.screen_derivative_enabled.key, settings.pdf.screen_derivative_enabled.label)} - resetDisabled={saving || loading || hasUnsavedChanges} + onChange={(value) => updateFormField("pdfScreenDerivativeEnabled", value, settings.pdf.screen_derivative_enabled)} + {...getFieldPersistenceProps(settings.pdf.screen_derivative_enabled)} /> setFormState((current) => (current ? { ...current, pdfScreenMaxDecodedPixels: value } : current))} - errorText={validationErrors?.pdfScreenMaxDecodedPixels} - showErrors={submitAttempted} - onReset={() => handleReset(settings.pdf.screen_max_decoded_pixels.key, settings.pdf.screen_max_decoded_pixels.label)} - resetDisabled={saving || loading || hasUnsavedChanges} + onChange={(value) => updateFormField("pdfScreenMaxDecodedPixels", value, settings.pdf.screen_max_decoded_pixels)} + {...getFieldPersistenceProps(settings.pdf.screen_max_decoded_pixels)} /> )} @@ -744,10 +594,6 @@ export function AdvancedSettings({ dialogSafeHeader = false }: AdvancedSettingsP )} - setNotification((current) => ({ ...current, open: false }))} - /> ); } diff --git a/frontend/src/pages/AuthenticationSettings.tsx b/frontend/src/pages/AuthenticationSettings.tsx index 74a7322b..c5f6b3ce 100644 --- a/frontend/src/pages/AuthenticationSettings.tsx +++ b/frontend/src/pages/AuthenticationSettings.tsx @@ -21,19 +21,20 @@ import { import { type ReactNode, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { adminDialogActionButtonSx, adminDialogEndActionRowSx } from "../components/Admin/dialogActionStyles"; -import { ResponsiveFormDialog } from "../components/Admin/ResponsiveFormDialog"; +import { DialogNotice } from "../components/Dialog/DialogNotice"; +import { ResponsiveDialogShell } from "../components/Dialog/ResponsiveDialogShell"; import { - SettingsFormFieldLabel, - SettingsFormGroup, - SettingsFormRow, - SettingsFormSection, - SettingsFormSurface, - settingsFormFieldControlSx, - settingsFormOutlinedControlSx, - settingsFormSelectControlSx, - settingsSelectMenuProps, - settingsSelectSx, -} from "../components/Settings/SettingsFormLayout"; + FormFieldLabel, + FormGroup, + FormRow, + FormSurface, + formFieldControlSx, + formOutlinedControlSx, + formSelectControlSx, + formSelectMenuProps, + formSelectSx, +} from "../components/Form/FormLayout"; +import { SettingsFormSection } from "../components/Settings/SettingsFormLayout"; import { SettingsGroup } from "../components/Settings/SettingsGroup"; import { SettingsPage } from "../components/Settings/SettingsPage"; import { SettingsPasswordVisibilityToggle } from "../components/Settings/SettingsPasswordVisibilityToggle"; @@ -100,6 +101,7 @@ const REVIEWABLE_POLICY_KEYS = new Set([ "role_assignment_mode", "uniform_role", "role_mappings", + "auto_link_by_username", ]); function clearAuthenticationCaches() { @@ -130,6 +132,7 @@ const reviewedPolicyFor = (candidate: OidcConfigurationCandidate): OidcReviewedP role_assignment_mode: candidate.role_assignment_mode, uniform_role: candidate.uniform_role, role_mappings: candidate.role_mappings, + auto_link_by_username: candidate.auto_link_by_username, }); const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every((entry) => typeof entry === "string"); const isReviewedPolicy = (value: unknown): value is OidcReviewedPolicy => { @@ -147,7 +150,8 @@ const isReviewedPolicy = (value: unknown): value is OidcReviewedPolicy => { roleMappings !== null && isStringArray((roleMappings as Record)["admin"]) && isStringArray((roleMappings as Record)["editor"]) && - isStringArray((roleMappings as Record)["viewer"]) + isStringArray((roleMappings as Record)["viewer"]) && + typeof policy["auto_link_by_username"] === "boolean" ); }; const storedReviewedPolicy = (): OidcReviewedPolicy | undefined => { @@ -230,6 +234,17 @@ const finalizationFailureKind = (error: unknown): FinalizationFailureKind => { if (getOidcMappingValidationErrors(error).length > 0) return "validation"; return "other"; }; +const mappingValidationFieldErrors = (errors: ReturnType) => + Object.fromEntries( + errors + .filter((mappingError) => mappingError.target_user_id !== null) + .map((mappingError) => [mappingError.target_user_id, mappingError.message]) + ); +const mappingValidationActionError = (errors: ReturnType) => + errors + .filter((mappingError) => mappingError.target_user_id === null) + .map((mappingError) => mappingError.message) + .join(" "); const mappingReviewFor = (identity: OidcTestedIdentity) => Object.fromEntries( identity.replacement_mappings.map((mapping) => [ @@ -247,8 +262,8 @@ export function AuthenticationSettings() { const theme = useTheme(); const usesDesktopFormLayout = useMediaQuery(theme.breakpoints.up("md")); const formFieldSize = usesDesktopFormLayout ? "small" : "medium"; - const providerFieldSx = [settingsFormOutlinedControlSx, { width: { md: PROVIDER_DESKTOP_FIELD_WIDTH_PX } }]; - const providerIntervalFieldSx = [settingsFormOutlinedControlSx, { width: { md: PROVIDER_INTERVAL_DESKTOP_FIELD_WIDTH_PX } }]; + const providerFieldSx = [formOutlinedControlSx, { width: { md: PROVIDER_DESKTOP_FIELD_WIDTH_PX } }]; + const providerIntervalFieldSx = [formOutlinedControlSx, { width: { md: PROVIDER_INTERVAL_DESKTOP_FIELD_WIDTH_PX } }]; const [candidate, setCandidate] = useState(DEFAULT_CANDIDATE); const [configuration, setConfiguration] = useState(() => getCachedAsyncData(SETTINGS_DATA_CACHE_KEYS.adminAuthentication) @@ -269,18 +284,17 @@ export function AuthenticationSettings() { const [error, setError] = useState(""); const [testError, setTestError] = useState(""); const [notice, setNotice] = useState(""); + const [configurationActionError, setConfigurationActionError] = useState(""); + const [configurationActionNotice, setConfigurationActionNotice] = useState(""); + const [reviewContextualError, setReviewContextualError] = useState(""); + const [reviewActionError, setReviewActionError] = useState(""); const [finalizationUnresolved, setFinalizationUnresolved] = useState(() => storedPendingFinalization() !== undefined); - const testErrorRef = useRef(null); const providerNameInputRef = useRef(null); useEffect(() => { setScopesInput(listValue(candidate.scopes)); }, [candidate.scopes]); - useEffect(() => { - if (testError) testErrorRef.current?.focus(); - }, [testError]); - const previewRequestSequence = useRef(0); const selectedMappings = testedIdentity?.replacement_mappings.filter((mapping) => mappingReview[mapping.target_user_id]?.selected) ?? []; const replacementUsernames = selectedMappings.map((mapping) => mappingReview[mapping.target_user_id]?.expectedUsername.trim() ?? ""); @@ -377,13 +391,13 @@ export function AuthenticationSettings() { hasError = false ) => usesDesktopFormLayout ? ( - ) : null; @@ -396,10 +410,10 @@ export function AuthenticationSettings() { required = false, hasError = false ) => ( - + {renderDesktopFieldLabel(label, description, descriptionId, htmlFor, required, hasError)} - {control} - + {control} + ); const pageActionButtonSx = { @@ -474,23 +488,15 @@ export function AuthenticationSettings() { setTestedIdentity(identity); setOidcDialogOpen(true); setOidcReviewOpen(true); - setMappingErrors( - replayFailure === "validation" - ? Object.fromEntries( - replayValidationErrors - .filter((mappingError) => mappingError.target_user_id !== null) - .map((mappingError) => [mappingError.target_user_id, mappingError.message]) - ) - : {} - ); + setMappingErrors(replayFailure === "validation" ? mappingValidationFieldErrors(replayValidationErrors) : {}); setMappingReview(mappingReviewFor(identity)); sessionStorage.setItem(OIDC_REVIEWED_POLICY_STORAGE_KEY, JSON.stringify(reviewedPolicyFor(identity.candidate))); if (replayFailure === "mapping_stale") { - setError("The account mapping review changed. Review the refreshed mappings before activating."); - } else if (replayFailure === "validation") { - setError("Correct the highlighted OIDC mapping errors before activating."); + setReviewContextualError("The account mapping review changed. Review the refreshed mappings before activating."); + } else if (replayFailure === "validation" && mappingValidationActionError(replayValidationErrors)) { + setReviewContextualError(mappingValidationActionError(replayValidationErrors)); } else if (replayFailure === "other") { - setError("The tested configuration could not be activated. Review it before trying again."); + setReviewContextualError("The tested configuration could not be activated. Review it before trying again."); } } catch (caught: unknown) { if (!active) return; @@ -528,7 +534,8 @@ export function AuthenticationSettings() { const reviewedPolicy = reviewedPolicyFor(nextCandidate); sessionStorage.setItem(OIDC_REVIEWED_POLICY_STORAGE_KEY, JSON.stringify(reviewedPolicy)); setReviewPending(true); - setError(""); + setReviewContextualError(""); + setReviewActionError(""); void api .getOidcTestResult(testedIdentity.flow_id, reviewedPolicy) .then((identity) => { @@ -547,7 +554,7 @@ export function AuthenticationSettings() { setTestedIdentity(null); setOidcReviewOpen(false); setMappingReview({}); - setError("The OIDC test expired. Connect and test the provider again."); + setConfigurationActionError("The OIDC test expired. Connect and test the provider again."); return; } if (getApiErrorMessage(caught, "") === "oidc_configuration_changed") { @@ -556,10 +563,10 @@ export function AuthenticationSettings() { setTestedIdentity(null); setOidcReviewOpen(false); setMappingReview({}); - setError("The OIDC configuration changed after this test. Connect and test the provider again."); + setConfigurationActionError("The OIDC configuration changed after this test. Connect and test the provider again."); return; } - setError("The reviewed access policy could not be evaluated. Change it or retry before activating."); + setReviewActionError("The reviewed access policy could not be evaluated. Change it or retry before activating."); }); setNotice(""); return; @@ -584,9 +591,14 @@ export function AuthenticationSettings() { setClientSecret(""); setShowClientSecret(false); setTestError(""); + setConfigurationActionError(""); + setConfigurationActionNotice(""); }; const openOidcDialog = () => { + setConfigurationActionError(""); + setConfigurationActionNotice(""); + setTestError(""); setOidcDialogOpen(true); }; @@ -594,6 +606,8 @@ export function AuthenticationSettings() { if (finalizationUnresolved) return; setBusy(true); setError(""); + setConfigurationActionError(""); + setConfigurationActionNotice(""); setTestError(""); try { const payload = { ...candidateToTest }; @@ -612,6 +626,7 @@ export function AuthenticationSettings() { if (finalizationUnresolved || !testedIdentity || replacementPlanInvalid || !testedIdentityCanActivate) return; setBusy(true); setError(""); + setReviewActionError(""); const finalizationRequest: PendingOidcFinalization = { flow_id: testedIdentity.flow_id, reviewed_policy: reviewedPolicyFor(candidate), @@ -648,7 +663,7 @@ export function AuthenticationSettings() { setTestedIdentity(refreshedIdentity); setMappingErrors({}); setMappingReview(mappingReviewFor(refreshedIdentity)); - setError("The account mapping review changed. Review the refreshed mappings before activating."); + setReviewActionError("The account mapping review changed. Review the refreshed mappings before activating."); } catch (refreshError: unknown) { if (isApiError(refreshError) && refreshError.response?.status === 404) { sessionStorage.removeItem(OIDC_SETUP_FLOW_STORAGE_KEY); @@ -657,9 +672,9 @@ export function AuthenticationSettings() { setOidcReviewOpen(false); setMappingReview({}); setMappingErrors({}); - setError("The OIDC test expired while refreshing mappings. Connect and test the provider again."); + setConfigurationActionError("The OIDC test expired while refreshing mappings. Connect and test the provider again."); } else { - setError("The current mapping review could not be loaded. Retry activation to refresh it."); + setReviewActionError("The current mapping review could not be loaded. Retry activation to refresh it."); } } } else if (failureKind === "configuration_changed") { @@ -669,20 +684,16 @@ export function AuthenticationSettings() { setOidcReviewOpen(false); setMappingReview({}); setMappingErrors({}); - setError("The OIDC configuration changed after this test. Connect and test the provider again."); + setConfigurationActionError("The OIDC configuration changed after this test. Connect and test the provider again."); } else if (failureKind === "validation") { - setMappingErrors( - Object.fromEntries( - validationErrors - .filter((mappingError) => mappingError.target_user_id !== null) - .map((mappingError) => [mappingError.target_user_id, mappingError.message]) - ) - ); - setError("Correct the highlighted OIDC mapping errors before activating."); + setMappingErrors(mappingValidationFieldErrors(validationErrors)); + setReviewActionError(mappingValidationActionError(validationErrors)); } else if (failureKind === "ambiguous") { - setError("Activation may have completed, but the server response was not received. Reload this page to confirm the result."); + setReviewActionError( + "Activation may have completed, but the server response was not received. Reload this page to confirm the result." + ); } else { - setError("The tested configuration could not be activated. Run the test again."); + setReviewActionError("The tested configuration could not be activated. Run the test again."); } setBusy(false); return; @@ -722,6 +733,7 @@ export function AuthenticationSettings() { if (finalizationUnresolved || !testedIdentity) return; setBusy(true); setError(""); + setReviewActionError(""); try { await api.cancelOidcTestFlow(testedIdentity.flow_id); sessionStorage.removeItem(OIDC_SETUP_FLOW_STORAGE_KEY); @@ -734,7 +746,7 @@ export function AuthenticationSettings() { setOidcReviewOpen(false); setNotice("OIDC setup canceled."); } catch { - setError("The OIDC setup flow could not be canceled. It may already have expired."); + setReviewActionError("The OIDC setup flow could not be canceled. It may already have expired."); } finally { setBusy(false); } @@ -753,6 +765,8 @@ export function AuthenticationSettings() { const returnToOidcConfiguration = () => { if (busy || finalizationUnresolved) return; + setReviewActionError(""); + setReviewContextualError(""); setOidcReviewOpen(false); }; @@ -853,9 +867,9 @@ export function AuthenticationSettings() { )} - - - + + + {renderDesktopFieldLabel( "Authentication mode", "Choose available sign-in methods", @@ -890,12 +904,12 @@ export function AuthenticationSettings() { slotProps={{ select: { SelectDisplayProps: { tabIndex: 0 }, - MenuProps: settingsSelectMenuProps, + MenuProps: formSelectMenuProps, renderValue: (selected) => AUTHENTICATION_MODE_LABELS[selected as AuthenticationMode], }, htmlInput: { "aria-describedby": usesDesktopFormLayout ? "authentication-mode-description" : undefined }, }} - sx={[settingsFormSelectControlSx, settingsFormOutlinedControlSx, settingsSelectSx].flat()} + sx={[formSelectControlSx, formOutlinedControlSx, formSelectSx].flat()} > - - - + + + {(isOidcMode || isActiveOidcMode) && ( - + - + )} - + } + actionNotice={ + configurationActionError || configurationActionNotice || testError ? ( + + + {configurationActionNotice && ( + + {configurationActionNotice} + + )} + + Connection test failed + {testError} + + ) : null + } + testId="oidc-configuration-test-error" + /> + + ) : null + } onTransitionEntered={() => { const activeElement = document.activeElement; const userIsAlreadyInteracting = @@ -1004,23 +1054,8 @@ export function AuthenticationSettings() { } > - {error && !oidcReviewOpen && {error}} - {notice && !oidcReviewOpen && {notice}} - {testError && ( - - Connection test failed - {testError} - - )} - {configuration?.health.status === "unhealthy" && ( - - {configuration.health.reasons.includes("public_url_missing") - ? "Set Sambee's externally reachable public URL in network settings before testing this provider." - : "Complete the required configuration before testing this provider."} - - )} - - + + {configuration?.health.redirect_uri && renderFormRow( "Redirect URI", @@ -1046,8 +1081,8 @@ export function AuthenticationSettings() { onClick={() => { void navigator.clipboard .writeText(configuration.health.redirect_uri ?? "") - .then(() => setNotice("Redirect URI copied.")) - .catch(() => setError("The redirect URI could not be copied.")); + .then(() => setConfigurationActionNotice("Redirect URI copied.")) + .catch(() => setConfigurationActionError("The redirect URI could not be copied.")); }} > @@ -1249,9 +1284,9 @@ export function AuthenticationSettings() { /> )} - + - + {renderFormRow( "Admission", "Choose which provider users can sign in", @@ -1267,9 +1302,9 @@ export function AuthenticationSettings() { onChange={(event) => update("admission_mode", event.target.value as OidcConfigurationCandidate["admission_mode"])} disabled={finalizationUnresolved} helperText={usesDesktopFormLayout ? undefined : "Choose which provider users can sign in"} - sx={[settingsFormSelectControlSx, settingsFormOutlinedControlSx, settingsSelectSx].flat()} + sx={[formSelectControlSx, formOutlinedControlSx, formSelectSx].flat()} slotProps={{ - select: { MenuProps: settingsSelectMenuProps }, + select: { MenuProps: formSelectMenuProps }, htmlInput: { "aria-describedby": usesDesktopFormLayout ? "admission-description" : undefined }, }} > @@ -1297,7 +1332,7 @@ export function AuthenticationSettings() { ? undefined : admissionGroupError || "Members of these groups can sign in; enter exact names, separated by commas" } - sx={settingsFormOutlinedControlSx} + sx={formOutlinedControlSx} slotProps={{ htmlInput: { "aria-describedby": usesDesktopFormLayout ? "admission-groups-description" : undefined }, }} @@ -1305,9 +1340,9 @@ export function AuthenticationSettings() { false, Boolean(admissionGroupError) )} - + - + {renderFormRow( "Role assignment", "Set one role for all users or map roles from provider groups", @@ -1325,9 +1360,9 @@ export function AuthenticationSettings() { } disabled={finalizationUnresolved} helperText={usesDesktopFormLayout ? undefined : "Set one role for all users or map roles from provider groups"} - sx={[settingsFormSelectControlSx, settingsFormOutlinedControlSx, settingsSelectSx].flat()} + sx={[formSelectControlSx, formOutlinedControlSx, formSelectSx].flat()} slotProps={{ - select: { MenuProps: settingsSelectMenuProps }, + select: { MenuProps: formSelectMenuProps }, htmlInput: { "aria-describedby": usesDesktopFormLayout ? "role-assignment-description" : undefined }, }} > @@ -1351,9 +1386,9 @@ export function AuthenticationSettings() { onChange={(event) => update("uniform_role", event.target.value as OidcConfigurationCandidate["uniform_role"])} disabled={finalizationUnresolved} helperText={usesDesktopFormLayout ? undefined : "Role assigned to all admitted users"} - sx={[settingsFormSelectControlSx, settingsFormOutlinedControlSx, settingsSelectSx].flat()} + sx={[formSelectControlSx, formOutlinedControlSx, formSelectSx].flat()} slotProps={{ - select: { MenuProps: settingsSelectMenuProps }, + select: { MenuProps: formSelectMenuProps }, htmlInput: { "aria-describedby": usesDesktopFormLayout ? "assigned-role-description" : undefined }, }} > @@ -1385,7 +1420,7 @@ export function AuthenticationSettings() { ? undefined : adminGroupError || "Members get administrator access; enter exact names, separated by commas" } - sx={settingsFormOutlinedControlSx} + sx={formOutlinedControlSx} slotProps={{ htmlInput: { "aria-describedby": usesDesktopFormLayout ? "administrator-groups-description" : undefined, @@ -1416,7 +1451,7 @@ export function AuthenticationSettings() { ? undefined : editorGroupError || "Members can edit content; enter exact names, separated by commas" } - sx={settingsFormOutlinedControlSx} + sx={formOutlinedControlSx} slotProps={{ htmlInput: { "aria-describedby": usesDesktopFormLayout ? "editor-groups-description" : undefined }, }} @@ -1445,7 +1480,7 @@ export function AuthenticationSettings() { ? undefined : viewerGroupError || "Members get viewer access; enter exact names, separated by commas" } - sx={settingsFormOutlinedControlSx} + sx={formOutlinedControlSx} slotProps={{ htmlInput: { "aria-describedby": usesDesktopFormLayout ? "viewer-groups-description" : undefined }, }} @@ -1455,14 +1490,14 @@ export function AuthenticationSettings() { )} )} - + The administrator connecting this provider keeps an individual Administrator assignment. Individual assignments always override this configured role policy. The default claim names work with most providers - + {renderFormRow( "Username claim", "Claim used for the Sambee username", @@ -1478,7 +1513,7 @@ export function AuthenticationSettings() { disabled={finalizationUnresolved} required helperText={usesDesktopFormLayout ? undefined : "Claim used for the Sambee username"} - sx={settingsFormOutlinedControlSx} + sx={formOutlinedControlSx} slotProps={{ htmlInput: { "aria-describedby": usesDesktopFormLayout ? "username-claim-description" : undefined }, }} @@ -1499,7 +1534,7 @@ export function AuthenticationSettings() { onChange={(event) => update("name_claim", optionalClaim(event.target.value))} disabled={finalizationUnresolved} helperText={usesDesktopFormLayout ? undefined : "Optional claim for the user's display name"} - sx={settingsFormOutlinedControlSx} + sx={formOutlinedControlSx} slotProps={{ htmlInput: { "aria-describedby": usesDesktopFormLayout ? "name-claim-description" : undefined }, }} @@ -1519,7 +1554,7 @@ export function AuthenticationSettings() { onChange={(event) => update("email_claim", optionalClaim(event.target.value))} disabled={finalizationUnresolved} helperText={usesDesktopFormLayout ? undefined : "Optional claim for the user's email address"} - sx={settingsFormOutlinedControlSx} + sx={formOutlinedControlSx} slotProps={{ htmlInput: { "aria-describedby": usesDesktopFormLayout ? "email-claim-description" : undefined }, }} @@ -1539,25 +1574,54 @@ export function AuthenticationSettings() { onChange={(event) => update("groups_claim", optionalClaim(event.target.value))} disabled={finalizationUnresolved} helperText={usesDesktopFormLayout ? undefined : "Required for group-based admission or roles"} - sx={settingsFormOutlinedControlSx} + sx={formOutlinedControlSx} slotProps={{ htmlInput: { "aria-describedby": usesDesktopFormLayout ? "groups-claim-description" : undefined }, }} /> )} - - + + - + {testedIdentity && ( - 0 ? ( + + + + 0 ? ( + <> + {omittedPasswordlessMappings.length} active passwordless account + {omittedPasswordlessMappings.length === 1 ? " is" : "s are"} omitted. These users cannot sign in until + mapped. An unmapped OIDC login may collide with an existing username or create a separate account. + + ) : null + } + severity="warning" + testId="oidc-review-omitted-accounts-warning" + /> + + ) : null + } + actionNotice={} actions={ - } - > + updateDraftIncludeDotDirectories(event.target.checked)} /> - } + control={ setIncludeDotDirectories(event.target.checked)} />} label={t("settings.fileBrowserPage.includeDotDirectoriesLabel")} sx={{ m: 0 }} /> @@ -143,8 +81,8 @@ export function FileBrowserSettings() { {languageOptions.map((option) => ( @@ -330,11 +250,11 @@ export function AppearanceSettings() { {t("settings.appearancePage.regionalLocaleLabel")}