diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 120000 index 000000000..be77ac83a --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1 @@ +../AGENTS.md \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 78c1e6598..000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,2 +0,0 @@ -github: Zarestia-Dev -custom: https://hakanismail.info/zarestia/support diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 27f75d65b..e9e554e8e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,57 +1,50 @@ -## Description + +Please discuss anything more than a trivial fix in an issue FIRST - see the +"Linked issue" section below. Then fill in the sections below to help us review. +--> -## Related Issue +#### What does this change do? - -Closes # + + +#### Linked issue -## Type of Change + +IMPORTANT: Anything beyond a trivial fix (typo, doc tweak, small obvious bug fix) +should be discussed and agreed in an issue BEFORE you open a pull request. +Pull requests for larger changes that have not been discussed in an issue first +may be closed. This saves everyone's time if the change needs a different approach +or isn't a good fit. +--> + +Closes # + +#### Type of Change - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 💥 Breaking change (fix or feature that would cause existing functionality to change) - [ ] 📚 Documentation update -- [ ] 🌍 Translation (new language or translation update) -- [ ] 🔧 Refactoring (code change that neither fixes a bug nor adds a feature) -- [ ] 🧪 Tests (adding or updating tests) - -## Changes Made - - - -- -- -- - -## Testing Performed - - - -- [ ] Tested on Linux -- [ ] Tested on macOS -- [ ] Tested on Windows -- [ ] Tested on Android -- [ ] Tested in Headless / Web mode -- [ ] Tested in development mode (`npm run tauri dev`) - -## Screenshots / Recordings - - - -## Checklist +- [ ] 🌍 Translation (i18n) update +- [ ] 🔧 Maintenance / Refactoring - +#### Platform & Target Testing -- [ ] My code follows the project's code style -- [ ] I have run `npm run lint:all` and fixed any issues -- [ ] I have updated the documentation if needed -- [ ] My changes don't introduce any new warnings -- [ ] I have tested my changes thoroughly +- [ ] Desktop (Linux / macOS / Windows) +- [ ] Portable mode +- [ ] Headless / Web Server mode +- [ ] Mobile (Android) -## Additional Notes +#### Checklist - +- [ ] This change is trivial **OR** it has been discussed and agreed in the linked issue. +- [ ] I have read the [contribution guidelines](CONTRIBUTING.md) and [AGENTS.md](AGENTS.md). +- [ ] **(If I used AI tools to help write this code)** I have read and understood the AI-assisted contributions guidance in [AGENTS.md](AGENTS.md), and I have compiled, linted, tested, and take full ownership of this change myself. +- [ ] I have run frontend lints (`npx eslint "**/*.{ts,html}"` & `npx prettier --check "**/*.{ts,html,scss,json}"`) or `npm run fix:all`. +- [ ] I have run backend Clippy & formatting checks (`cargo clippy` and `cargo fmt -- --check` in `src-tauri`). +- [ ] I have added tests or updated documentation where appropriate. +- [ ] This Pull Request is ready for review. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7e674e0d..410af8aa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,7 @@ jobs: working-directory: src-tauri - name: Run Clippy (Mobile) + if: matrix.os == 'ubuntu-22.04' run: cargo clippy --features mobile --no-default-features -- -D warnings working-directory: src-tauri diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..05096c0fd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,155 @@ +# AGENTS.md + +This file provides guidance to AI coding agents (e.g. Antigravity, Claude Code, Gemini CLI, Cursor, and similar tools) when working with code in this repository. + +Rclone Manager welcomes AI-assisted contributions, but the expectation is that you, the human submitter, understand every line you propose and have compiled, linted, and tested it against real code — not just generated it. See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. + +--- + +## Project Overview + +`rclone-manager` is a cross-platform GUI and web server application for managing [Rclone](https://rclone.org). + +- **Frontend**: Angular (v22+), TypeScript, SCSS, RxJS, Angular Material. +- **Backend**: Rust, Tauri v2 (`src-tauri`), `librclone` C-FFI / Go integration. + +--- + +## General Notes & AI Guidelines + +1. **Keep Changes Minimal & Elegant**: Work to make the smallest, most effective change possible. Avoid unneeded refactoring, re-ordering of imports, or restyling surrounding code. +2. **Backwards Compatibility**: PRs should preserve existing behavior across desktop, web server (headless), and mobile targets. +3. **Verify Before Proposing**: AI agents must run build, lint, and formatting verification commands before declaring success. **Frontend changes must produce a warning-free build** (see §Build, Test & Lint Commands). +4. **Automated Testing & Test Coverage (CRITICAL)**: + - Just like upstream Rclone and best practices, whenever adding or refactoring business logic, services, utilities, parsers, or mappings, AI agents **MUST** write or update accompanying automated unit tests. + - **Frontend**: Unit tests (`*.spec.ts`) are placed in the same directory alongside their corresponding implementation files. + - **Backend**: Rust unit tests (`#[cfg(test)]`) are placed inside source files or under test modules. + - Verify tests cover both normal operation and edge cases (null inputs, empty values, special characters, error paths). + +--- + +## Essential Code Quality & UI Rules + +1. **Tooltip Usage Rule (CRITICAL)** + - **DO NOT** use Angular Material Tooltips (`matTooltip`, `MatTooltipModule`). + - **ALWAYS** use native HTML `title` attributes for tooltips (e.g., ``). + +2. **Angular Architecture** + - Follow existing service and component patterns. + - Use `inject()` for dependency injection where applicable. + - Keep translation keys organized and run `npm run audit:i18n` when adding user-facing text. + +3. **Angular Material** + - Do not use `appearence` in the material form components (inputs, selects, textareas). Use them as default. + +4. **URL Opener Links (CRITICAL)** + - **ALWAYS** use standard HTML `` tags with `href` attributes for opening URLs (e.g., `...` or `...`). + - **DO NOT** use ` + } @else { + {{ selectedRemote().type | titlecase }} + } + - @if (mainOperationType() === 'operations') { + @if (mode() === 'remote' && mainOperationType() === 'operations') {
@for (opType of primarySyncOps(); track opType) { @@ -85,7 +109,7 @@

- @if (showProfileSelector()) { + @if (mode() === 'remote' && showProfileSelector()) {
{{ @@ -174,7 +198,11 @@

{{ 'automation.monitoring.realtimeSchedule' | translate }} ({{ 'automation.monitoring.debounce' | translate }}: {{ watcher.watchDelay }} - {{ 'automation.monitoring.seconds' | translate }}) + {{ 'automation.monitoring.seconds' | translate }} + @if (watcher.watchChangedOnly) { + · {{ 'automation.monitoring.changedOnlyShort' | translate }} + } + )

} @@ -192,7 +220,7 @@

> } - @if (mainOperationType() === 'serve' && filteredRunningServes().length > 0) { + @if (currentOpType() === 'serve' && filteredRunningServes().length > 0) {

{{ 'dashboard.appDetail.activeServes' | translate }}

@@ -221,41 +249,50 @@

{{ 'dashboard.appDetail.activeServes' | translate }}

-
-
-
-

{{ operationSettingsHeading() }}

-

{{ operationSettingsDescription() }}

-
-
- @for (section of operationSettingsSections(); track section.key) { - - } -
-
+ @if (mode() === 'quickRun') { +
+ +
+ } @else { +
+
+
+

{{ operationSettingsHeading() }}

+

{{ operationSettingsDescription() }}

+
+
+ @for (section of operationSettingsSections(); track section.key) { + + } +
+
-
-
-

- {{ 'dashboard.appDetail.sharedSettings' | translate }} -

-

- {{ 'dashboard.appDetail.sharedSettingsDesc' | translate }} -

-
-
- @for (section of sharedSettingsSections(); track section.key) { - - } -
-
-
+
+
+

+ {{ 'dashboard.appDetail.sharedSettings' | translate }} +

+

+ {{ 'dashboard.appDetail.sharedSettingsDesc' | translate }} +

+
+
+ @for (section of sharedSettingsSections(); track section.key) { + + } +
+
+
+ }
diff --git a/src/app/features/components/dashboard/app-detail/app-detail.component.scss b/src/app/features/components/dashboard/app-detail/app-detail.component.scss index fde77707b..4dd05ffe9 100644 --- a/src/app/features/components/dashboard/app-detail/app-detail.component.scss +++ b/src/app/features/components/dashboard/app-detail/app-detail.component.scss @@ -35,6 +35,33 @@ font-size: var(--font-size-base); color: var(--dim-color); font-weight: 400; + display: inline-flex; + align-items: center; + gap: 4px; + + .remote-link-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border-radius: var(--radius-xs); + font-size: var(--font-size-md); + color: var(--dim-color); + cursor: pointer; + transition: var(--transition-fast); + + mat-icon { + width: var(--icon-size-sm); + height: var(--icon-size-sm); + font-size: var(--icon-size-sm); + } + + &:hover { + color: var(--primary-color); + background: rgba(var(--primary-color-rgb), 0.12); + text-decoration: underline; + } + } } } } @@ -489,5 +516,9 @@ @media (min-width: 1024px) { .settings-layout { grid-template-columns: repeat(2, minmax(0, 1fr)); + + &:has(> :only-child) { + grid-template-columns: 1fr; + } } } diff --git a/src/app/features/components/dashboard/app-detail/app-detail.component.spec.ts b/src/app/features/components/dashboard/app-detail/app-detail.component.spec.ts deleted file mode 100644 index 1b7e86c9a..000000000 --- a/src/app/features/components/dashboard/app-detail/app-detail.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AppDetailComponent } from './app-detail.component'; - -describe('AppDetailComponent', () => { - let component: AppDetailComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [AppDetailComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(AppDetailComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/components/dashboard/app-detail/app-detail.component.ts b/src/app/features/components/dashboard/app-detail/app-detail.component.ts index 240fb32a7..91fb30c4f 100755 --- a/src/app/features/components/dashboard/app-detail/app-detail.component.ts +++ b/src/app/features/components/dashboard/app-detail/app-detail.component.ts @@ -45,14 +45,8 @@ import { OPERATION_METADATA, ACTION_ANIMATION_CLASS, OPERATION_COLOR_VAR, - SyncConfig, - CopyConfig, - MoveConfig, + AppConfig, BisyncConfig, - CheckConfig, - DeleteConfig, - CopyurlConfig, - ArchivecreateConfig, ProfileConfig, StartJobEvent, StopJobEvent, @@ -62,6 +56,9 @@ import { STANDARD_MODAL_SIZE, MODE_DEFAULTS, BACKEND_PROFILE_SUPPORTED_OPS, + QuickRun, + Remote, + findInFlightAction, } from '@app/types'; import { MatDialog } from '@angular/material/dialog'; import { JobInfoPanelComponent } from '../../../../shared/detail-shared/job-info-panel/job-info-panel.component'; @@ -72,6 +69,7 @@ import { TransferActivityPanelComponent } from '../../../../shared/detail-shared import { ServeCardComponent } from '../../../../shared/components/serve-card/serve-card.component'; import { IconService } from 'src/app/services/ui/icon.service'; import { JobManagementService } from 'src/app/services/operations/job-management.service'; +import { QuickRunService } from 'src/app/services/flow/quick-run.service'; import { RemoteFacadeService } from 'src/app/services/facade/remote-facade.service'; import { LocalStorageService } from 'src/app/services/ui/state/local-storage.service'; import { toString as cronstrue } from 'cronstrue'; @@ -108,6 +106,8 @@ import { }) export class AppDetailComponent { // --- Inputs --- + readonly mode = input<'remote' | 'quickRun'>('remote'); + readonly quickRun = input(null); readonly mainOperationType = input('mount'); readonly selectedSyncOperation = model('sync'); readonly remoteSettings = input({}); @@ -123,10 +123,12 @@ export class AppDetailComponent { readonly openInFiles = output(); readonly startJob = output(); readonly stopJob = output(); + readonly openRemoteDetail = output(); // --- Services --- private readonly remoteFacade = inject(RemoteFacadeService); private readonly jobService = inject(JobManagementService); + private readonly quickRunService = inject(QuickRunService); protected readonly iconService = inject(IconService); private readonly translate = inject(TranslateService); private readonly formatFileSize = inject(FormatFileSizePipe); @@ -157,7 +159,43 @@ export class AppDetailComponent { return profiles[0]?.name ?? 'default'; }); - protected readonly selectedRemote = computed(() => { + protected readonly selectedRemote = computed(() => { + if (this.mode() === 'quickRun') { + const qr = this.quickRun(); + const remoteName = qr?.remoteName ?? ''; + const found = this.remoteFacade.activeRemotes().find((r: Remote) => r.name === remoteName); + if (found) return found; + return { + name: remoteName || 'Remote', + type: 'alias', + config: { name: remoteName || 'Remote', type: 'alias' }, + status: { + diskUsage: {}, + mount: { active: false }, + sync: { active: false }, + copy: { active: false }, + bisync: { active: false }, + move: { active: false }, + check: { active: false }, + delete: { active: false }, + copyurl: { active: false }, + archivecreate: { active: false }, + cryptcheck: { active: false }, + serve: { active: false, count: 0, serves: [] }, + }, + features: { + IsLocal: false, + About: false, + BucketBased: false, + CleanUp: false, + PublicLink: false, + ChangeNotify: false, + Hashes: [], + }, + primaryActions: [], + syncActions: [], + }; + } const remote = this.remoteFacade.selectedRemote(); if (!remote) throw new Error('[AppDetail] Selected remote is required'); return remote; @@ -168,9 +206,26 @@ export class AppDetailComponent { ); // --- Derived: Operation Type --- - readonly isOperationsType = computed(() => this.mainOperationType() === 'operations'); + readonly isOperationsType = computed(() => { + if (this.mode() === 'quickRun') { + const qr = this.quickRun(); + if (!qr) return false; + return qr.operationType !== 'mount' && qr.operationType !== 'serve'; + } + return this.mainOperationType() === 'operations'; + }); + + readonly isQuickRunRunning = computed(() => { + const qr = this.quickRun(); + if (!qr) return false; + return this.quickRunService.runningIds().has(qr.id); + }); readonly currentOpType = computed(() => { + if (this.mode() === 'quickRun') { + const qr = this.quickRun(); + if (qr) return qr.operationType; + } const op = this.isOperationsType() ? this.selectedSyncOperation() : (this.mainOperationType() as PrimaryActionType); @@ -190,7 +245,12 @@ export class AppDetailComponent { ); }); - readonly operationActiveState = computed(() => this.isOperationActive(this.currentOpType())); + readonly operationActiveState = computed(() => { + if (this.mode() === 'quickRun') { + return this.isQuickRunRunning(); + } + return this.isOperationActive(this.currentOpType()); + }); readonly operationColor = computed( () => (this.currentOpMetadata()?.cssClass as OperationColor) ?? 'primary' @@ -206,6 +266,11 @@ export class AppDetailComponent { // CSS class for the icon container — single operation state class or empty. protected readonly iconContainerClass = computed((): string => { + if (this.mode() === 'quickRun') { + if (!this.isQuickRunRunning()) return ''; + const op = this.currentOpType(); + return op === 'check' || op === 'cryptcheck' ? 'check' : op; + } const remote = this.selectedRemote(); if (remote.status.mount.active) return 'mount'; if (remote.status.serve.active) return 'serve'; @@ -224,7 +289,7 @@ export class AppDetailComponent { readonly primarySyncOps = computed(() => { const remote = this.selectedRemote(); const syncTypesSet: ReadonlySet = new Set(SYNC_TYPES); - const custom = (remote.syncActions ?? []).filter((a): a is SyncOperationType => + const custom = (remote.syncActions ?? []).filter((a: string): a is SyncOperationType => syncTypesSet.has(a) ); if (custom.length > 0) { @@ -278,14 +343,7 @@ export class AppDetailComponent { }); readonly enrichedProfiles = computed(() => { - const configs = this.getProfileConfigMap<{ - app?: { - cronEnabled?: boolean; - cronExpression?: string | null; - watchEnabled?: boolean; - watchDelay?: number; - }; - }>(this.currentOpType()); + const configs = this.getProfileConfigMap(this.currentOpType()); return this.profiles().map(p => { const cfg = configs?.[p.name]; @@ -323,13 +381,27 @@ export class AppDetailComponent { `${this.currentOpType()}/${this.selectedRemote().name}/${this.selectedProfile() ?? 'default'}` ); - readonly activeGroupJob = computed(() => - this.jobService.getLatestJobForRemote( + readonly activeGroupJob = computed(() => { + if (this.mode() === 'quickRun') { + const qr = this.quickRun(); + if (!qr) return null; + const jobs = this.jobService.jobs(); + const matchingJobs = jobs.filter(j => j.quick_run_id === qr.id); + + if (matchingJobs.length === 0) return null; + + return matchingJobs.sort((a, b) => { + const ta = a.start_time ? new Date(a.start_time).getTime() : 0; + const tb = b.start_time ? new Date(b.start_time).getTime() : 0; + return tb !== ta ? tb - ta : b.jobid - a.jobid; + })[0]; + } + return this.jobService.getLatestJobForRemote( this.selectedRemote().name, this.selectedProfile() ?? 'default', this.currentOpType() - ) - ); + ); + }); readonly isDryRun = computed(() => this.getDryRunState(this.currentOpType(), this.selectedProfile()) @@ -337,6 +409,9 @@ export class AppDetailComponent { readonly isResync = computed(() => { if (this.currentOpType() !== 'bisync') return false; + if (this.mode() === 'quickRun') { + return !!this.quickRun()?.config?.rclone?.['resync']; + } const profiles = this.getProfileConfigMap('bisync'); return !!profiles?.[this.selectedProfile()]?.rclone?.resync; }); @@ -382,70 +457,48 @@ export class AppDetailComponent { return Math.max(0, Math.floor((endTime.getTime() - startTime.getTime()) / 1000)); }); - // --- Derived: Cron Schedules --- - readonly cronSchedules = computed< - { profileName: string; cronExpression: string; humanReadable: string }[] - >(() => { - this._lang(); - const configs = this.getProfileConfigMap< - | SyncConfig - | CopyConfig - | MoveConfig - | BisyncConfig - | CheckConfig - | DeleteConfig - | CopyurlConfig - | ArchivecreateConfig - >(this.selectedSyncOperation()); - if (!configs) return []; - - return Object.entries(configs) - .filter(([, cfg]) => cfg?.app?.cronEnabled && cfg?.app?.cronExpression) - .map(([profileName, cfg]) => { - let humanReadable = 'Invalid schedule'; - const cronExpression = cfg.app.cronExpression ?? ''; - try { - humanReadable = cronstrue(cronExpression, { - locale: getCronstrueLocale(this.translate.getCurrentLang() ?? 'en-US'), - }); - } catch { - console.warn(`Invalid cron expression for profile ${profileName}: ${cronExpression}`); - } - return { profileName, cronExpression, humanReadable }; - }); + // --- Derived: Active App Config (Cron, Watcher, Settings) --- + readonly currentAppConfig = computed(() => { + if (this.mode() === 'quickRun') { + return this.quickRun()?.config?.app; + } + const configs = this.getProfileConfigMap(this.currentOpType()); + const profile = this.selectedProfile(); + return configs?.[profile]?.app; }); readonly selectedCronSchedule = computed(() => { - const schedules = this.cronSchedules(); - const selected = this.selectedProfile(); - return selected - ? (schedules.find(s => s.profileName === selected) ?? null) - : (schedules[0] ?? null); + this._lang(); + const app = this.currentAppConfig(); + if (!app?.cronEnabled || !app?.cronExpression) return null; + + let humanReadable = 'Invalid schedule'; + try { + humanReadable = cronstrue(app.cronExpression, { + locale: getCronstrueLocale(this.translate.getCurrentLang() ?? 'en-US'), + }); + } catch { + console.warn(`Invalid cron expression: ${app.cronExpression}`); + } + + const profileName = + this.mode() === 'quickRun' ? (this.quickRun()?.name ?? 'Quick Run') : this.selectedProfile(); + + return { profileName, cronExpression: app.cronExpression, humanReadable }; }); readonly selectedWatcher = computed(() => { - const configs = this.getProfileConfigMap< - | SyncConfig - | CopyConfig - | MoveConfig - | BisyncConfig - | CheckConfig - | DeleteConfig - | CopyurlConfig - | ArchivecreateConfig - >(this.selectedSyncOperation()); - if (!configs) return null; - - const profileName = this.selectedProfile(); - if (!profileName) return null; - const cfg = configs[profileName]; - if (cfg?.app?.watchEnabled) { - return { - profileName, - watchDelay: cfg.app.watchDelay ?? 5, - }; - } - return null; + const app = this.currentAppConfig(); + if (!app?.watchEnabled) return null; + + const profileName = + this.mode() === 'quickRun' ? (this.quickRun()?.name ?? 'Quick Run') : this.selectedProfile(); + + return { + profileName, + watchDelay: app.watchDelay ?? 5, + watchChangedOnly: app.watchChangedOnly ?? false, + }; }); // --- Derived: Settings Sections --- @@ -516,6 +569,20 @@ export class AppDetailComponent { // --- Derived: Control Configs --- readonly controlConfigs = computed(() => { + if (this.mode() === 'quickRun') { + const qr = this.quickRun(); + if (!qr) return []; + const type = qr.operationType; + const ctrl = this.buildControlConfig( + type, + (qr.config ?? {}) as unknown as ProfileConfig, + qr.name + ); + if (qr.description) { + ctrl.operationDescription = qr.description; + } + return [ctrl]; + } const type = this.currentOpType(); const metadata = this.currentOpMetadata(); if (!metadata) return []; @@ -533,6 +600,19 @@ export class AppDetailComponent { return all.filter(c => c.profileName === selected); }); + readonly quickRunSettingsConfig = computed(() => { + const qr = this.quickRun(); + return { + section: { + key: 'quickRun', + title: this.translate.instant('flow.quickRun.detail.configuration'), + icon: 'tune', + }, + settings: (qr?.config ?? {}) as unknown as Record, + buttonLabel: 'common.edit', + }; + }); + // --- Derived: Stats & Transfer Panels --- readonly jobInfoConfig = computed(() => { const startTime = this.resolvedStartTime(); @@ -718,32 +798,42 @@ export class AppDetailComponent { async toggleDryRun(): Promise { const opType = this.currentOpType(); const profile = this.selectedProfile(); - const remoteName = this.selectedRemote().name; const newValue = !this.getDryRunState(opType, profile); + if (this.mode() === 'quickRun') { + await this.updateQuickRunRclone(rclone => { + if (opType === 'bisync') { + rclone['dry_run'] = newValue; + rclone['dryRun'] = newValue; + } else { + rclone['dry_run'] = newValue; + if (rclone['backend'] && typeof rclone['backend'] === 'object') { + (rclone['backend'] as Record)['dry_run'] = newValue; + } + } + }); + return; + } + + const remoteName = this.selectedRemote().name; + if (opType === 'bisync') { const profiles = this.getProfileConfigMap('bisync') ?? {}; const existing = profiles[profile]; await this.remoteFacade.updateRemoteSettings(remoteName, { bisyncConfigs: { ...profiles, - [profile]: { ...existing, rclone: { ...existing?.rclone, dryRun: newValue } }, + [profile]: { + ...existing, + rclone: { ...existing?.rclone, dry_run: newValue, dryRun: newValue }, + }, }, }); return; } if ((BACKEND_PROFILE_SUPPORTED_OPS as readonly string[]).includes(opType)) { - const profiles = - this.getProfileConfigMap< - | SyncConfig - | CopyConfig - | MoveConfig - | CheckConfig - | DeleteConfig - | CopyurlConfig - | ArchivecreateConfig - >(opType) ?? {}; + const profiles = this.getProfileConfigMap(opType) ?? {}; const cfg = profiles[profile]; const backendProfileName = cfg?.app?.backendProfile || 'default'; const backendConfigs = @@ -753,7 +843,7 @@ export class AppDetailComponent { await this.remoteFacade.updateRemoteSettings(remoteName, { backendConfigs: { ...backendConfigs, - [backendProfileName]: { ...existingBackend, DryRun: newValue }, + [backendProfileName]: { ...existingBackend, dry_run: newValue }, }, }); } @@ -761,6 +851,14 @@ export class AppDetailComponent { async toggleResync(): Promise { if (this.currentOpType() !== 'bisync') return; + + if (this.mode() === 'quickRun') { + await this.updateQuickRunRclone(rclone => { + rclone['resync'] = !rclone['resync']; + }); + return; + } + const profile = this.selectedProfile(); const profiles = this.getProfileConfigMap('bisync') ?? {}; const existing = profiles[profile]; @@ -776,6 +874,19 @@ export class AppDetailComponent { }); } + private async updateQuickRunRclone( + mutator: (rclone: Record) => void + ): Promise { + const qr = this.quickRun(); + if (!qr) return; + const rclone = { ...(qr.config?.rclone ?? {}) }; + mutator(rclone); + await this.quickRunService.save({ + ...qr, + config: { ...qr.config, rclone }, + }); + } + // --- Private Helpers --- private getOpState( @@ -804,24 +915,33 @@ export class AppDetailComponent { } private getDryRunState(opType: string, profile: string): boolean { + if (this.mode() === 'quickRun') { + const rclone = (this.quickRun()?.config?.rclone ?? {}) as Record; + if (opType === 'bisync') return !!(rclone['dry_run'] ?? rclone['dryRun'] ?? rclone['DryRun']); + const backendSubConfig = (rclone['backend'] ?? {}) as Record; + return !!( + rclone['dry_run'] ?? + rclone['dryRun'] ?? + rclone['DryRun'] ?? + backendSubConfig['dry_run'] ?? + backendSubConfig['dryRun'] ?? + backendSubConfig['DryRun'] + ); + } if (opType === 'bisync') { - return !!this.getProfileConfigMap('bisync')?.[profile]?.rclone?.dryRun; + const bCfg = this.getProfileConfigMap('bisync')?.[profile]?.rclone; + return !!( + bCfg?.['dry_run'] ?? + bCfg?.dryRun ?? + (bCfg as Record | undefined)?.['DryRun'] + ); } if ((BACKEND_PROFILE_SUPPORTED_OPS as readonly string[]).includes(opType)) { - const profiles = this.getProfileConfigMap< - | SyncConfig - | CopyConfig - | MoveConfig - | CheckConfig - | DeleteConfig - | CopyurlConfig - | ArchivecreateConfig - >(opType); - const cfg = profiles?.[profile]; - const backendProfileName = cfg?.app?.backendProfile || 'default'; + const backendProfileName = this.currentAppConfig()?.backendProfile || 'default'; const backendConfigs = (this.remoteSettings()['backendConfigs'] as Record>) ?? {}; - return !!backendConfigs[backendProfileName]?.['DryRun']; + const bOpts = backendConfigs[backendProfileName]; + return !!(bOpts?.['dry_run'] ?? bOpts?.['dryRun'] ?? bOpts?.['DryRun']); } return false; } @@ -877,36 +997,47 @@ export class AppDetailComponent { profileName?: string ): OperationControlConfig { const metadata = OPERATION_METADATA[type]; - const isActive = this.isOperationActive(type, profileName); - const actionMatch = this.actionInProgress()?.find( - a => - (a.type === type || - (type === 'mount' && a.type === 'unmount') || - (a.type === 'stop' && a.operationType === type)) && - (a.profileName === profileName || (!a.profileName && !profileName)) - ); - const actionType = actionMatch?.type; + const isActive = + this.mode() === 'quickRun' + ? this.isQuickRunRunning() + : this.isOperationActive(type, profileName); + const actionMatch = findInFlightAction(this.actionInProgress(), type, profileName); + const qr = this.mode() === 'quickRun' ? this.quickRun() : null; + const quickRunAction = qr ? this.quickRunService.actionInProgress()[qr.id] : undefined; + const actionType = quickRunAction || actionMatch?.type; const isLoading = !!actionType; const t = (key: string, params?: object): string => this.translate.instant(key, params); const opLabel = t(metadata.typeLabel || metadata.label); const isMount = type === 'mount'; - const rclone = (config.rclone || {}) as Record; - const resolvedSource = (rclone['srcFs'] ?? rclone['path1'] ?? rclone['fs']) as - string | undefined; + const rawRclone = (config.rclone || {}) as Record; + const opData = (rawRclone[type] as Record | undefined) ?? rawRclone; + const resolvedSource = (opData['srcFs'] ?? + opData['path1'] ?? + opData['fs'] ?? + rawRclone['srcFs'] ?? + rawRclone['path1'] ?? + rawRclone['fs']) as string | undefined; const isSafMount = isMount && - (rclone['mountType'] === 'saf' || String(rclone['mountPoint'] ?? '').startsWith('saf://')); + (opData['mountType'] === 'saf' || + rawRclone['mountType'] === 'saf' || + String(opData['mountPoint'] ?? rawRclone['mountPoint'] ?? '').startsWith('saf://')); const rawDest = isSafMount ? `saf://${this.selectedRemote().name}` - : ((rclone['dstFs'] ?? rclone['path2'] ?? rclone['mountPoint']) as string | undefined); + : ((opData['dstFs'] ?? + opData['path2'] ?? + opData['mountPoint'] ?? + rawRclone['dstFs'] ?? + rawRclone['path2'] ?? + rawRclone['mountPoint']) as string | undefined); const resolvedDest = rawDest; const pathConfig: PathDisplayConfig = type === 'serve' ? { source: resolvedSource ?? t('dashboard.appDetail.notConfigured'), - destination: `${((rclone['type'] as string) ?? 'http').toUpperCase()} at ${(rclone['addr'] as string) ?? t('dashboard.appDetail.default')}`, + destination: `${((opData['type'] as string) ?? (rawRclone['type'] as string) ?? 'http').toUpperCase()} at ${(opData['addr'] as string) ?? (rawRclone['addr'] as string) ?? t('dashboard.appDetail.default')}`, sourceLabel: t('dashboard.appDetail.serving'), destinationLabel: t('dashboard.appDetail.accessibleVia'), showOpenButtons: true, diff --git a/src/app/features/components/dashboard/app-overview/app-overview.component.html b/src/app/features/components/dashboard/app-overview/app-overview.component.html index 6835d1712..50df404a3 100644 --- a/src/app/features/components/dashboard/app-overview/app-overview.component.html +++ b/src/app/features/components/dashboard/app-overview/app-overview.component.html @@ -1,4 +1,8 @@ - +
@@ -79,48 +83,3 @@ } }
- -
-
- - - - - -
- - - -
diff --git a/src/app/features/components/dashboard/app-overview/app-overview.component.scss b/src/app/features/components/dashboard/app-overview/app-overview.component.scss index d1d0c64a9..ab092c4eb 100644 --- a/src/app/features/components/dashboard/app-overview/app-overview.component.scss +++ b/src/app/features/components/dashboard/app-overview/app-overview.component.scss @@ -53,77 +53,3 @@ padding: var(--space-sm); } } - -// ============================================= -// LAYOUT ACTIONS BLOSSOM (Speed Dial style) -// ============================================= - -.layout-actions-blossom { - position: fixed; - bottom: var(--space-md); - right: var(--space-md); - z-index: 1000; - display: flex; - flex-direction: column; - align-items: center; - gap: var(--space-sm); - padding: var(--space-xs); - - .blossom-panel { - display: flex; - flex-direction: column; - gap: var(--space-sm); - opacity: 0; - pointer-events: none; - transition: - opacity 0.2s ease, - transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); - transform-origin: bottom center; - } - - .blossom-item { - box-shadow: var(--shadow-popover); - transition: transform 0.2s ease; - - &:hover { - transform: scale(1.15) !important; - } - } - - .blossom-trigger { - transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); - - mat-icon { - transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); - } - } - - // ── HOVER & OPEN STATE ───────────────────────────────────────────────────── - - &:hover, - &.open { - .blossom-panel { - opacity: 1; - transform: translateY(0) scale(1); - pointer-events: auto; - } - - .blossom-trigger { - transform: scale(1.05); - - mat-icon { - transform: rotate(90deg); - } - } - } -} - -// ============================================= -// RESPONSIVE ADJUSTMENTS -// ============================================= - -@media (max-width: 599.98px) { - .layout-actions-blossom { - bottom: calc(84px + env(safe-area-inset-bottom, 0px)); - } -} diff --git a/src/app/features/components/dashboard/app-overview/app-overview.component.spec.ts b/src/app/features/components/dashboard/app-overview/app-overview.component.spec.ts deleted file mode 100644 index e126a599e..000000000 --- a/src/app/features/components/dashboard/app-overview/app-overview.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AppOverviewComponent } from './app-overview.component'; - -describe('AppOverviewComponent', () => { - let component: AppOverviewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [AppOverviewComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(AppOverviewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/components/dashboard/app-overview/app-overview.component.ts b/src/app/features/components/dashboard/app-overview/app-overview.component.ts index 2cb80af0a..a75637761 100755 --- a/src/app/features/components/dashboard/app-overview/app-overview.component.ts +++ b/src/app/features/components/dashboard/app-overview/app-overview.component.ts @@ -1,15 +1,5 @@ +import { Component, computed, input, output, inject, ChangeDetectionStrategy } from '@angular/core'; import { - Component, - computed, - input, - output, - inject, - signal, - linkedSignal, - ChangeDetectionStrategy, -} from '@angular/core'; -import { - CardDisplayMode, OperationTab, PrimaryActionType, Remote, @@ -23,9 +13,9 @@ import { TranslatePipe } from '@ngx-translate/core'; import { OverviewHeaderComponent } from '../../../../shared/overviews-shared/overview-header/overview-header.component'; import { StatusOverviewPanelComponent } from '../../../../shared/overviews-shared/status-overview-panel/status-overview-panel.component'; import { RemotesPanelComponent } from '../../../../shared/overviews-shared/remotes-panel/remotes-panel.component'; -import { AppSettingsService } from 'src/app/services/settings/app-settings.service'; import { RemoteFacadeService } from 'src/app/services/facade/remote-facade.service'; import { BackendService } from 'src/app/services/infrastructure/system/backend.service'; +import { UiStateService } from 'src/app/services/ui/state/ui-state.service'; @Component({ selector: 'app-app-overview', @@ -45,11 +35,10 @@ import { BackendService } from 'src/app/services/infrastructure/system/backend.s '[class]': 'mode()', 'attr.animate.enter': 'fade-in-out-enter', 'attr.animate.leave': 'fade-in-out-leave', - '(document:click)': 'closeBlossom()', }, }) export class AppOverviewComponent { - private readonly appSettingsService = inject(AppSettingsService); + private readonly uiStateService = inject(UiStateService); readonly remoteFacade = inject(RemoteFacadeService); readonly backendService = inject(BackendService); @@ -67,23 +56,9 @@ export class AppOverviewComponent { readonly stopJob = output(); readonly openBackendModal = output(); - // Card display mode is local UI state initialized from settings options signal - readonly cardDisplayMode = linkedSignal(() => { - const saved = this.appSettingsService.options()?.['runtime.dashboard_card_variant'] - ?.value as CardDisplayMode; - return saved || 'detailed'; - }); - readonly isEditingLayout = signal(false); - readonly isBlossomOpen = signal(false); - - toggleBlossom(event: MouseEvent): void { - event.stopPropagation(); - this.isBlossomOpen.update(v => !v); - } - - closeBlossom(): void { - this.isBlossomOpen.set(false); - } + // Centrally synchronized card display mode & edit layout state + readonly cardDisplayMode = this.uiStateService.cardDisplayMode; + readonly isEditingLayout = computed(() => this.uiStateService.isEditingOverview(this.mode())); // --- Derived state --- private readonly modeConfig = computed(() => MODE_CONFIG[this.mode()] ?? MODE_CONFIG.mount); @@ -114,8 +89,15 @@ export class AppOverviewComponent { } toggleEditLayout(): void { - this.isEditingLayout.update(v => !v); - this.closeBlossom(); + this.uiStateService.toggleLayoutEdit({ + overviewId: this.mode(), + hasViewToggle: true, + onReset: () => this.resetLayout(), + }); + } + + resetLayout(): void { + void this.remoteFacade.saveCurrentLayout(this.backendService.activeBackend(), []); } onLayoutChanged(newNames: string[]): void { @@ -126,13 +108,6 @@ export class AppOverviewComponent { void this.remoteFacade.toggleRemoteVisibility(this.backendService.activeBackend(), remoteName); } - onCardDisplayModeToggle(): void { - const nextMode = this.cardDisplayMode() === 'compact' ? 'detailed' : 'compact'; - this.cardDisplayMode.set(nextMode); - void this.appSettingsService.saveSetting('runtime', 'dashboard_card_variant', nextMode); - this.closeBlossom(); - } - // --- Private helpers --- private isActive(remote: Remote): boolean { diff --git a/src/app/features/components/dashboard/general-detail/general-detail.component.html b/src/app/features/components/dashboard/general-detail/general-detail.component.html index 18e0e0f81..07f2515cd 100755 --- a/src/app/features/components/dashboard/general-detail/general-detail.component.html +++ b/src/app/features/components/dashboard/general-detail/general-detail.component.html @@ -13,47 +13,49 @@

{{ remote.name }}

-
- @for (vc of viewActionConfigs(); track vc.key) { -
-
- @if (vc.isLoading) { - - } @else { - - } + @if (showStatusIndicators()) { +
+ @for (vc of viewActionConfigs(); track vc.key) { +
+
+ @if (vc.isLoading) { + + } @else { + + } +
+ {{ vc.label | translate }} + {{ vc.ariaLabel }}
- {{ vc.label | translate }} - {{ vc.ariaLabel }} -
- } + } - -
+ +
+ }
@@ -62,6 +64,72 @@

{{ remote.name }}

(retry)="retryDiskUsage.emit()" > + @if (showQuickRuns()) { + + + + + {{ 'flow.quickRun.title' | translate }} + @if (remoteQuickRuns().length > 0) { + {{ remoteQuickRuns().length }} + } + + + + + @if (remoteQuickRuns().length > 0) { +
+ @for (qr of remoteQuickRuns(); track qr.id) { + + } +
+ } @else { +
+ +

+ {{ + 'flow.quickRun.overview.noRunsForRemote' + | translate: { remote: selectedRemote().name } + }} +

+ +
+ } +
+
+ } +
- - @for (panel of dashboardPanels(); track panel.id) { - @if (panel.visible || isEditingLayout()) { -
-
- - @if (isEditingLayout()) { -
- - {{ panel.title | translate }} - - @if (panel.id === 'remotes') { - - - } -
- {{ - panel.visible - ? ('generalOverview.layout.visible' | translate) - : ('generalOverview.layout.hidden' | translate) - }} - - -
-
- } - + + @for (panel of displayPanels(); track panel.id) { +
+
+ + @if (isEditingLayout()) {
- @switch (panel.id) { - @case ('remotes') { - - - } - @case ('bandwidth') { - - - - - {{ 'generalOverview.panels.bandwidth' | translate }} - - - - @if (bandwidthLimit()?.loading) { - {{ - 'generalOverview.bandwidth.loading' | translate - }} - } @else if (bandwidthLimit()?.error) { - {{ - 'generalOverview.bandwidth.error' | translate - }} - } @else { - {{ bandwidthLimit()?.rate | formatRateValue }} - } - - - - - @let bandwidth = bandwidthLimit(); - @if (bandwidth?.loading) { -
- - {{ 'generalOverview.bandwidth.loading' | translate }} -
- } @else if (bandwidth?.error) { - - } @else { -
- - {{ bandwidth?.rate | formatRateValue }} -
- - @if (isBandwidthLimited()) { -
- @for (detail of bandwidthDetails(); track $index) { -
- {{ detail.labelKey | translate }} - {{ - detail.bytesPerSec | formatRateValue - }} -
- } -
- } - } -
- } - @case ('system') { - - - - - {{ 'generalOverview.panels.system' | translate }} - - - - {{ 'generalOverview.system.' + rcloneStatus() | translate }} - - - - @if (isLoadingStats()) { -
- - {{ 'generalOverview.system.loading' | translate }} -
- } @else { - @let status = rcloneStatus(); -
-
- {{ - 'generalOverview.system.status' | translate - }} - {{ - 'generalOverview.system.' + status | translate - }} -
-
- {{ - 'generalOverview.system.totalRemotes' | translate - }} - {{ totalRemotes() }} -
-
- {{ - 'generalOverview.system.activeJobs' | translate - }} - {{ activeJobsCount() }} -
-
- {{ - 'generalOverview.system.memoryUsage' | translate - }} - - @if (memoryUsage()?.HeapAlloc; as alloc) { - {{ alloc | formatFileSize }} - } @else { - - - } - -
-
- {{ - 'generalOverview.system.uptime' | translate - }} - {{ uptime() | formatTime }} -
-
- } -
- } - @case ('jobs') { - - - - - {{ 'generalOverview.panels.jobs' | translate }} - - - @let activeJobs = activeJobsCount(); - - {{ - activeJobs > 0 - ? ('generalOverview.jobs.activeCount' - | translate - : { count: activeJobs, s: activeJobs !== 1 ? 's' : '' }) - : ('generalOverview.jobs.noActive' | translate) - }} - - - - - @if (isLoadingStats()) { -
- - {{ 'generalOverview.jobs.loading' | translate }} -
- } @else { - @let stats = jobStats(); - @let activeJobs = activeJobsCount(); -
- @if (stats.totalBytes > 0) { -
-
- {{ - 'generalOverview.jobs.progress' | translate - }} - {{ jobCompletionPercentage() | number: '1.0-1' }}% -
- -
- - {{ stats.bytes | formatFileSize }} - {{ 'generalOverview.jobs.of' | translate }} - {{ stats.totalBytes | formatFileSize }} - - - {{ 'generalOverview.jobs.eta' | translate }} - {{ stats.eta | formatEta }} - -
-
- } - -
- @for (item of jobStatsItems(); track $index) { -
- {{ - item.labelKey | translate - }} - - @if (item.formatAsBytes) { - {{ $any(item.value) | formatRateValue }} - } @else { - {{ item.value }} - } - -
- } -
- - @if (stats.lastError) { -
- - - {{ 'generalOverview.jobs.lastError' | translate }} - -
{{ stats.lastError }}
-
- } - - @if (runningJobs().length > 0) { -
-
- - {{ 'generalOverview.jobs.runningJobs' | translate }} -
- -
- @for (vm of runningJobViewModels(); track vm.job.jobid) { -
-
- -
- {{ vm.label }} - {{ - vm.job.destination || vm.job.source - }} -
-
- - @if (vm.job.stats; as jStats) { -
- - {{ jStats.bytes | formatFileSize }} - @if (jStats.totalBytes > 0) { - / {{ jStats.totalBytes | formatFileSize }} - } - - - {{ - jStats.speed > 0 - ? (jStats.speed | formatRateValue) - : ('generalOverview.jobs.starting' | translate) - }} - - {{ jStats.eta | formatEta }} -
- - } @else { -
- {{ 0 | formatFileSize }} - - {{ - 'generalOverview.jobs.starting' | translate - }} - - {{ 0 | formatEta }} -
- - } -
- } -
-
- } - - @if (activeJobs === 0) { -
- - {{ 'generalOverview.jobs.noRunning' | translate }} -
- } -
- } -
- } - @case ('automations') { - - - - - {{ 'generalOverview.panels.automations' | translate }} - - - - {{ - totalAutomationsCount() > 0 - ? ('generalOverview.automations.activeCount' - | translate - : { - active: activeAutomationsCount(), - total: totalAutomationsCount(), - }) - : ('generalOverview.automations.noScheduled' | translate) - }} - - - - - @if (totalAutomationsCount() === 0) { -
- - {{ 'generalOverview.automations.noConfigured' | translate }} -
- } @else { -
- @for (automation of automations(); track automation.id) { - - } -
- } -
- } - @case ('serves') { - @let serves = allRunningServes(); - - - - - {{ 'generalOverview.panels.serves' | translate }} - - - - {{ - serves.length > 0 - ? ('generalOverview.serves.activeCount' - | translate - : { count: serves.length, s: serves.length !== 1 ? 's' : '' }) - : ('generalOverview.serves.noActive' | translate) - }} - - - - - @if (serves.length === 0) { -
- - {{ 'generalOverview.serves.noRunning' | translate }} -
- } @else { -
- @for (serve of serves; track serve.id) { - - } -
- } -
- } + + {{ panel.title | translate }} + + @if (panel.id === 'remotes') { + } +
+ {{ + panel.visible + ? ('generalOverview.layout.visible' | translate) + : ('generalOverview.layout.hidden' | translate) + }} + + +
+ } + +
+ @switch (panel.id) { + @case ('remotes') { + + + } + @case ('bandwidth') { + + } + @case ('system') { + + } + @case ('jobs') { + + } + @case ('automations') { + + } + @case ('serves') { + + } + }
- } +
}
- -
- - - @if (isEditingLayout()) { - - } -
diff --git a/src/app/features/components/dashboard/general-overview/general-overview.component.scss b/src/app/features/components/dashboard/general-overview/general-overview.component.scss index 856e16cd5..d6cf9f8ee 100644 --- a/src/app/features/components/dashboard/general-overview/general-overview.component.scss +++ b/src/app/features/components/dashboard/general-overview/general-overview.component.scss @@ -1,14 +1,3 @@ -@mixin flex-center { - display: flex; - align-items: center; - justify-content: center; -} - -@mixin status-container($bg-color, $border-color) { - background-color: $bg-color; - box-shadow: 0 0 0 1px $border-color; -} - // ============================================= // MAIN LAYOUT // ============================================= @@ -34,220 +23,6 @@ } } -// ============================================= -// EXPANSION PANELS -// ============================================= -.bandwidth-panel { - .bandwidth-error { - @include flex-center; - gap: 12px; - padding: 16px; - border-radius: 8px; - margin-top: 16px; - @include status-container(rgba(var(--warn-color-rgb), 0.1), rgba(var(--warn-color-rgb), 0.2)); - } - - .bandwidth-status { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 16px; - border-radius: var(--radius-xs); - margin-top: var(--space-xxs); - transition: var(--transition-standard); - - .status-indicator { - width: var(--icon-size-xs); - height: var(--icon-size-xs); - border-radius: 50%; - animation: breathing 3s ease-in-out infinite; - will-change: transform, opacity; - } - - .status-text { - font-weight: 500; - } - - &.limited { - @include status-container(rgba(var(--yellow-rgb), 0.05), rgba(var(--yellow-rgb), 0.3)); - .status-indicator { - background: var(--yellow); - box-shadow: 0 0 10px rgba(var(--yellow-rgb), 0.4); - } - } - - &.unlimited { - @include status-container( - rgba(var(--primary-color-rgb), 0.05), - rgba(var(--primary-color-rgb), 0.3) - ); - .status-indicator { - background: var(--primary-color); - box-shadow: 0 0 10px rgba(var(--primary-color-rgb), 0.4); - } - } - } - - .bandwidth-details { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); - gap: 16px; - } -} - -.system-info-panel .info-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: 16px; -} - -.job-info-panel .job-grid { - display: flex; - flex-direction: column; - gap: 16px; - - .job-progress-section { - background-color: rgba(var(--view-bg-color-rgb, var(--view-bg-color)), 0.02); - border-radius: 8px; - padding: 16px; - - .progress-header { - display: flex; - justify-content: space-between; - margin-bottom: 8px; - } - - mat-progress-bar { - height: 8px; - border-radius: 4px; - overflow: hidden; - } - - .progress-details { - display: flex; - justify-content: space-between; - margin-top: 8px; - font-size: clamp(10px, 2vw, 12px); - opacity: 0.8; - } - } - - .job-stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: 16px; - } - - .running-jobs-section { - display: flex; - flex-direction: column; - gap: 12px; - padding: 12px; - border-radius: 8px; - background: rgba(var(--view-bg-color-rgb, var(--view-bg-color)), 0.02); - border: 1px solid rgba(var(--window-fg-color-rgb), 0.06); - - .section-heading { - display: flex; - align-items: center; - gap: 8px; - font-size: 12px; - font-weight: 600; - color: var(--dim-color); - - mat-icon { - width: 16px; - height: 16px; - font-size: 16px; - } - } - - .running-jobs-list { - display: flex; - flex-direction: column; - gap: 10px; - } - - .running-job-row { - display: flex; - flex-direction: column; - gap: 6px; - padding: 10px 12px; - border-radius: 8px; - background: rgba(var(--window-bg-color-rgb), 0.7); - border: 1px solid rgba(var(--window-fg-color-rgb), 0.05); - } - - .running-job-main { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; - } - - .running-job-icon { - width: 16px; - height: 16px; - font-size: 16px; - color: var(--primary-color); - flex-shrink: 0; - } - - .running-job-text { - display: flex; - flex-direction: column; - min-width: 0; - flex: 1; - } - - .running-job-title { - font-size: 13px; - font-weight: 600; - color: var(--window-fg-color); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .running-job-path, - .running-job-meta { - font-size: 11px; - color: var(--dim-color); - } - - .running-job-path { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .running-job-meta { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px; - } - - .dot-separator { - opacity: 0.5; - } - - .running-job-progress { - height: 4px; - border-radius: 999px; - } - } -} - -.serves-panel, -.automations-panel { - .grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(286px, 1fr)); - gap: var(--space-sm); - } -} - // ============================================= // DRAG & DROP LAYOUT EDITOR // ============================================= @@ -375,100 +150,13 @@ } // ============================================= -// SHARED / UTILITY STYLES +// RESPONSIVE // ============================================= -.info-item, -.detail-item { - gap: 4px; - padding: 12px; - display: flex; - border-radius: 8px; - flex-direction: column; - margin-top: var(--space-xxs); - box-shadow: var(--box-shadow); - background: var(--window-bg-color); - - .label { - font-size: clamp(10px, 2vw, 12px); - color: var(--window-fg-color); - opacity: 0.7; - } - - .value { - display: flex; - align-items: center; - gap: 8px; - font-weight: 500; - font-size: clamp(14px, 2.5vw, 16px); - min-width: 0; - - &.success { - color: var(--primary-color); - } - &.error { - color: var(--warn-color); - } - &.inactive { - color: var(--dim-color); - } - } -} - -.status-indicator-small { - width: 10px; - height: 10px; - border-radius: 50%; - background-color: var(--dim-color); - display: inline-block; - - &.active { - background-color: var(--primary-color); - } - &.error { - background-color: var(--warn-color); - } - &.inactive { - background-color: var(--dim-color); - } -} - -.loading { - @include flex-center; - gap: 12px; - padding: 16px; - border-radius: 8px; -} - -.error-text { - color: var(--warn-color); - font-weight: 500; -} - -.empty-state-message { - @include flex-center; - gap: 12px; - padding: 24px; - color: var(--dim-color); - text-align: center; -} - -.no-jobs-message { - padding: 16px; -} - -.layout-actions { - position: absolute; - top: 16px; - right: 16px; - display: flex; - gap: 12px; - flex-direction: column; -} @media (max-width: 768px) { .general-overview { - padding: var(--panel-padding-mobile, 16px); - gap: var(--section-gap-mobile, 12px); + padding: var(--panel-padding-mobile); + gap: var(--section-gap-mobile); } } diff --git a/src/app/features/components/dashboard/general-overview/general-overview.component.spec.ts b/src/app/features/components/dashboard/general-overview/general-overview.component.spec.ts deleted file mode 100644 index da73d38db..000000000 --- a/src/app/features/components/dashboard/general-overview/general-overview.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { GeneralOverviewComponent } from './general-overview.component'; - -describe('GeneralOverviewComponent', () => { - let component: GeneralOverviewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [GeneralOverviewComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(GeneralOverviewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/components/dashboard/general-overview/general-overview.component.ts b/src/app/features/components/dashboard/general-overview/general-overview.component.ts index c99633d6a..922ac5cf2 100755 --- a/src/app/features/components/dashboard/general-overview/general-overview.component.ts +++ b/src/app/features/components/dashboard/general-overview/general-overview.component.ts @@ -1,4 +1,3 @@ -import { NgClass, DecimalPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -10,8 +9,6 @@ import { import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatExpansionModule } from '@angular/material/expansion'; import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop'; @@ -21,64 +18,49 @@ import { Remote, Automation, ServeListItem, - CardDisplayMode, StartJobEvent, StopJobEvent, PanelConfig, DashboardPanel, SCROLL_DELAY_MS, - JOB_ICON_MAP, ALL_PANELS, - BandwidthDetailItem, - JobStatItem, OpenInFilesEvent, } from '@app/types'; -import { FormatTimePipe, FormatEtaPipe, FormatFileSizePipe, FormatRateValuePipe } from '@app/pipes'; import { RemotesPanelComponent } from '../../../../shared/overviews-shared/remotes-panel/remotes-panel.component'; -import { ServeCardComponent } from '../../../../shared/components/serve-card/serve-card.component'; import { OverviewHeaderComponent } from '../../../../shared/overviews-shared/overview-header/overview-header.component'; +import { BandwidthOverviewPanelComponent } from '../../../../shared/overviews-shared/bandwidth-overview-panel/bandwidth-overview-panel.component'; +import { SystemOverviewPanelComponent } from '../../../../shared/overviews-shared/system-overview-panel/system-overview-panel.component'; +import { JobsOverviewPanelComponent } from '../../../../shared/overviews-shared/jobs-overview-panel/jobs-overview-panel.component'; +import { ServesOverviewPanelComponent } from '../../../../shared/overviews-shared/serves-overview-panel/serves-overview-panel.component'; +import { AutomationsOverviewPanelComponent } from '../../../../shared/overviews-shared/automations-overview-panel/automations-overview-panel.component'; import { AutomationService } from 'src/app/services/operations/automation.service'; -import { UiStateService } from 'src/app/services/ui/state/ui-state.service'; -import { RcloneStatusService } from 'src/app/services/infrastructure/maintenance/rclone-status.service'; import { AppSettingsService } from 'src/app/services/settings/app-settings.service'; import { BackendService } from 'src/app/services/infrastructure/system/backend.service'; import { RemoteFacadeService } from 'src/app/services/facade/remote-facade.service'; -import { IconService } from 'src/app/services/ui/icon.service'; import { PathService } from 'src/app/services/infrastructure/platform/path.service'; import { LocalStorageService } from 'src/app/services/ui/state/local-storage.service'; -import { CopyToClipboardDirective } from '../../../../shared/directives/copy-to-clipboard.directive'; -import { AutomationCardComponent } from '../../../../shared/detail-shared/automation-card/automation-card.component'; - -interface RunningJobViewModel { - job: JobInfo; - typeIcon: string; - label: string; -} +import { NavigationDispatcherService } from 'src/app/services/ui/navigation-dispatcher.service'; +import { UiStateService } from 'src/app/services/ui/state/ui-state.service'; @Component({ selector: 'app-general-overview', + standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - NgClass, - DecimalPipe, MatIconModule, MatButtonModule, - MatExpansionModule, - MatProgressBarModule, MatSnackBarModule, MatSlideToggleModule, DragDropModule, - FormatTimePipe, - FormatEtaPipe, - FormatFileSizePipe, RemotesPanelComponent, - ServeCardComponent, - FormatRateValuePipe, OverviewHeaderComponent, + BandwidthOverviewPanelComponent, + SystemOverviewPanelComponent, + JobsOverviewPanelComponent, + ServesOverviewPanelComponent, + AutomationsOverviewPanelComponent, TranslatePipe, - CopyToClipboardDirective, - AutomationCardComponent, ], templateUrl: './general-overview.component.html', styleUrls: ['./general-overview.component.scss'], @@ -86,13 +68,12 @@ interface RunningJobViewModel { export class GeneralOverviewComponent { private readonly snackBar = inject(MatSnackBar); private readonly automationService = inject(AutomationService); - private readonly uiStateService = inject(UiStateService); private readonly appSettingsService = inject(AppSettingsService); - private readonly rcloneStatusService = inject(RcloneStatusService); private readonly translate = inject(TranslateService); private readonly localStorage = inject(LocalStorageService); + private readonly navigationDispatcher = inject(NavigationDispatcherService); + private readonly uiStateService = inject(UiStateService); - readonly iconService = inject(IconService); readonly backendService = inject(BackendService); readonly remoteFacade = inject(RemoteFacadeService); private readonly pathService = inject(PathService); @@ -105,10 +86,11 @@ export class GeneralOverviewComponent { readonly openBackendModal = output(); // --- State --- - readonly isEditingLayout = signal(false); - readonly cardDisplayMode = signal('compact'); + readonly isEditingLayout = computed(() => this.uiStateService.isEditingOverview('general')); + readonly cardDisplayMode = this.uiStateService.cardDisplayMode; readonly panelOpenStates = signal>( this.localStorage.get>('dashboard.panelOpenStates', { + remotes: true, bandwidth: false, system: false, jobs: false, @@ -119,16 +101,9 @@ export class GeneralOverviewComponent { readonly dashboardPanels = signal( ALL_PANELS.map(p => ({ ...p, visible: p.defaultVisible })) ); - - readonly automations = this.automationService.automations; - - // --- Re-exposed Service Signals --- - readonly rcloneStatus = this.rcloneStatusService.rcloneStatus; - readonly jobStats = this.rcloneStatusService.jobStats; - readonly bandwidthLimit = this.rcloneStatusService.bandwidthLimit; - readonly isLoadingStats = this.rcloneStatusService.isLoading; - readonly memoryUsage = this.rcloneStatusService.memoryUsage; - readonly uptime = this.rcloneStatusService.uptime; + readonly displayPanels = computed(() => + this.isEditingLayout() ? this.dashboardPanels() : this.dashboardPanels().filter(p => p.visible) + ); // --- Computed Pipeline --- readonly totalRemotes = computed(() => this.remoteFacade.activeRemotes().length); @@ -136,72 +111,18 @@ export class GeneralOverviewComponent { this.remoteFacade.jobs().filter(j => j.status === 'Running' && !j.parent_job_id) ); readonly activeJobsCount = computed(() => this.runningJobs().length); - readonly runningJobViewModels = computed(() => - this.runningJobs().map(job => ({ - job, - typeIcon: this.getJobTypeIcon(job), - label: this.getJobLabel(job), - })) - ); - readonly allRunningServes = computed(() => - this.remoteFacade.activeRemotes().flatMap(r => r.status.serve?.serves ?? []) - ); - - readonly jobCompletionPercentage = computed(() => { - const { totalBytes = 0, bytes = 0 } = this.jobStats(); - return totalBytes > 0 ? Math.min(100, (bytes / totalBytes) * 100) : 0; - }); - - readonly isBandwidthLimited = computed(() => { - const limit = this.bandwidthLimit(); - return !!limit && limit.rate !== 'off' && limit.rate !== '' && limit.bytesPerSecond > 0; - }); - - readonly activeAutomationsCount = computed( - () => this.automations().filter(t => t.status === 'enabled' || t.status === 'running').length - ); - - readonly totalAutomationsCount = computed(() => this.automations().length); - - readonly bandwidthDetails = computed((): BandwidthDetailItem[] => { - const limit = this.bandwidthLimit(); - return [ - { labelKey: 'generalOverview.bandwidth.upload', bytesPerSec: limit?.bytesPerSecondTx }, - { labelKey: 'generalOverview.bandwidth.download', bytesPerSec: limit?.bytesPerSecondRx }, - { labelKey: 'generalOverview.bandwidth.total', bytesPerSec: limit?.bytesPerSecond }, - ]; - }); - - readonly jobStatsItems = computed((): JobStatItem[] => { - const s = this.jobStats(); - return [ - { labelKey: 'generalOverview.jobs.speed', value: s.speed, formatAsBytes: true }, - { labelKey: 'generalOverview.jobs.transfers', value: `${s.transfers} / ${s.totalTransfers}` }, - { labelKey: 'generalOverview.jobs.checks', value: `${s.checks} / ${s.totalChecks}` }, - { labelKey: 'generalOverview.jobs.errors', value: s.errors, error: s.errors > 0 }, - { labelKey: 'generalOverview.jobs.deletes', value: s.deletes }, - { labelKey: 'generalOverview.jobs.renames', value: s.renames }, - { labelKey: 'generalOverview.jobs.serverCopies', value: s.serverSideCopies }, - { labelKey: 'generalOverview.jobs.serverMoves', value: s.serverSideMoves }, - ]; - }); constructor() { - this.initDashboardData(); - } - - private async initDashboardData(): Promise { - await this.loadLayoutSettings(); + void this.loadLayoutSettings(); } // --- Layout management --- toggleEditLayout(): void { - this.isEditingLayout.update(v => !v); - } - - toggleCardDisplayMode(): void { - this.cardDisplayMode.update(m => (m === 'compact' ? 'detailed' : 'compact')); - this.persistLayout(); + this.uiStateService.toggleLayoutEdit({ + overviewId: 'general', + hasViewToggle: true, + onReset: () => this.resetLayout(), + }); } resetLayout(): void { @@ -211,7 +132,6 @@ export class GeneralOverviewComponent { }); void this.appSettingsService.saveSetting('runtime', 'dashboard_card_variant', 'compact'); this.dashboardPanels.set(ALL_PANELS.map(p => ({ ...p, visible: p.defaultVisible }))); - this.cardDisplayMode.set('compact'); void this.remoteFacade.saveCurrentLayout(this.backendService.activeBackend(), []); this.showSnackbar(this.translate.instant('generalOverview.layout.resetSuccess')); } @@ -237,10 +157,6 @@ export class GeneralOverviewComponent { this.persistLayout(); } - loadBandwidthLimit(): Promise { - return this.rcloneStatusService.loadBandwidthLimit(); - } - protected setPanelOpenState(id: string, isOpen: boolean): void { const updated = { ...this.panelOpenStates(), [id]: isOpen }; this.panelOpenStates.set(updated); @@ -253,14 +169,13 @@ export class GeneralOverviewComponent { .filter(p => !p.visible) .map(p => p.id); void this.appSettingsService.saveSetting('runtime', 'dashboard_layout', { order, hidden }); - void this.appSettingsService.saveSetting( - 'runtime', - 'dashboard_card_variant', - this.cardDisplayMode() - ); } - // --- Serve actions --- + // --- Actions --- + handleJobClick(job: JobInfo): void { + this.navigationDispatcher.navigateToJob(job); + } + stopServe(serve: ServeListItem): void { const remoteName = this.pathService.getRemoteNameFromFs(serve.params?.fs); if (remoteName) @@ -273,17 +188,10 @@ export class GeneralOverviewComponent { } handleServeCardClick(serve: ServeListItem): void { - const remoteName = this.pathService.getRemoteNameFromFs(serve.params?.fs); - if (!remoteName) return; - const remote = this.remoteFacade.activeRemotes().find(r => r.name === remoteName); - if (remote) { - this.uiStateService.setTab('serve'); - this.uiStateService.setSelectedRemote(remote); - setTimeout(() => this.scrollToTop(), SCROLL_DELAY_MS); - } + this.navigationDispatcher.navigateToServe(serve); + setTimeout(() => this.scrollToTop(), SCROLL_DELAY_MS); } - // --- Automation actions --- async toggleAutomation(automationId: string): Promise { try { await this.automationService.toggleAutomation(automationId); @@ -294,11 +202,7 @@ export class GeneralOverviewComponent { } onAutomationClick(automation: Automation): void { - const remoteName = automation.args.remoteName; - if (remoteName) { - const remote = this.remoteFacade.activeRemotes().find(r => r.name === remoteName); - if (remote) this.selectRemote.emit(remote); - } + this.navigationDispatcher.navigateToAutomation(automation); } onOpenAutomationInFiles(path: string): void { @@ -306,25 +210,12 @@ export class GeneralOverviewComponent { void this.remoteFacade.openRemoteInFiles(remoteName, relativePath); } - getJobTypeIcon(job: JobInfo): string { - return JOB_ICON_MAP[job.job_type] ?? 'folder'; - } - - getJobLabel(job: JobInfo): string { - const key = `fileBrowser.operations.types.${job.job_type}`; - const translated = this.translate.instant(key); - return translated === key ? job.job_type.replace(/_/g, ' ') : translated; - } - // --- Private helpers --- private async loadLayoutSettings(): Promise { try { - const [savedLayout, savedVariant] = await Promise.all([ - this.appSettingsService.getSettingValue<{ order: string[]; hidden: string[] } | string[]>( - 'runtime.dashboard_layout' - ), - this.appSettingsService.getSettingValue('runtime.dashboard_card_variant'), - ]); + const savedLayout = await this.appSettingsService.getSettingValue< + { order: string[]; hidden: string[] } | string[] + >('runtime.dashboard_layout'); if (savedLayout) { const order: string[] = Array.isArray(savedLayout) @@ -349,7 +240,6 @@ export class GeneralOverviewComponent { this.dashboardPanels.set([...ordered, ...appended]); } } - if (savedVariant) this.cardDisplayMode.set(savedVariant); } catch { console.debug('Failed to load layout settings, using defaults'); } diff --git a/src/app/features/components/repair-sheet/repair-sheet.component.html b/src/app/features/components/repair-sheet/repair-sheet.component.html index 241f9be72..180855216 100755 --- a/src/app/features/components/repair-sheet/repair-sheet.component.html +++ b/src/app/features/components/repair-sheet/repair-sheet.component.html @@ -88,9 +88,20 @@

{{ displayTitle() }}

> } } + + @if (installing()) { + + }
+ @if (installing() && activeProgress()) { + + } +
- + {{ 'common.name' | translate }}
- + {{ 'alerts.action.url' | translate }}
- + {{ 'alerts.action.method' | translate }} POST @@ -105,12 +105,12 @@ PUT - + {{ 'alerts.action.timeout' | translate }} sec - + {{ 'alerts.action.retryCount' | translate }} @@ -127,10 +127,10 @@
@for (header of headers.controls; track header) {
- + - +
- + {{ 'alerts.action.bodyTemplate' | translate }} @@ -165,7 +165,7 @@ @if (form.controls.kind.value === 'script') {
- + {{ 'alerts.action.command' | translate }}
- + {{ 'alerts.action.args' | translate }}
- + {{ 'alerts.action.timeout' | translate }} sec - + {{ 'alerts.action.retryCount' | translate }} @@ -253,7 +253,7 @@
@if (form.controls.telegram_mode.value === 'bot') { - + {{ 'alerts.action.botToken' | translate }} } - + {{ (form.controls.telegram_mode.value === 'botless' ? 'alerts.action.usernameOrChatId' @@ -291,18 +291,18 @@
- + {{ 'alerts.action.timeout' | translate }} sec - + {{ 'alerts.action.retryCount' | translate }}
- + {{ 'alerts.action.messageTemplate' | translate }} @@ -352,7 +352,7 @@
- + {{ 'alerts.action.phone' | translate }} @@ -362,7 +362,7 @@ @if (form.controls.whatsapp_provider.value === 'callmebot') { - + {{ 'alerts.action.apiKey' | translate }} } @else { - + {{ 'alerts.action.gatewayUrl' | translate }} - + {{ 'alerts.action.timeout' | translate }} sec - + {{ 'alerts.action.retryCount' | translate }}
- + {{ 'alerts.action.messageTemplate' | translate }} @@ -433,7 +433,7 @@ @if (form.controls.kind.value === 'mqtt') {
- + {{ 'alerts.action.host' | translate }} @@ -441,7 +441,7 @@ {{ 'common.required' | translate }} } - + {{ 'alerts.action.port' | translate }} @@ -450,7 +450,7 @@
- + {{ 'alerts.action.topic' | translate }} @@ -460,18 +460,18 @@
- + {{ 'common.username' | translate }} - + {{ 'common.password' | translate }}
- + {{ 'alerts.action.qos' | translate }} 0 – At most once @@ -486,7 +486,7 @@
- + {{ 'alerts.action.bodyTemplate' | translate }} @@ -507,20 +507,20 @@ @if (form.controls.kind.value === 'email') {
- + {{ 'alerts.action.smtpServer' | translate }} @if (form.controls.smtp_server.hasError('required')) { {{ 'common.required' | translate }} } - + {{ 'alerts.action.smtpPort' | translate }}
- + {{ 'alerts.action.encryption' | translate }} None @@ -530,22 +530,22 @@
- + {{ 'common.username' | translate }} - + {{ 'common.password' | translate }}
- + {{ 'alerts.action.from' | translate }} - + {{ 'alerts.action.to' | translate }} @if (form.controls.to.hasError('required')) { @@ -555,23 +555,23 @@
- + {{ 'alerts.action.timeout' | translate }} sec - + {{ 'alerts.action.retryCount' | translate }}
- + {{ 'alerts.action.subjectTemplate' | translate }} - + {{ 'alerts.action.bodyTemplate' | translate }} diff --git a/src/app/features/modals/alerts-modal/rules/alert-rules-editor/alert-rule-editor.component.html b/src/app/features/modals/alerts-modal/rules/alert-rules-editor/alert-rule-editor.component.html index 440a80aad..39722ec50 100755 --- a/src/app/features/modals/alerts-modal/rules/alert-rules-editor/alert-rule-editor.component.html +++ b/src/app/features/modals/alerts-modal/rules/alert-rules-editor/alert-rule-editor.component.html @@ -18,7 +18,7 @@

- + {{ 'alerts.ruleName' | translate }}
- + {{ 'alerts.actions' | translate }} @@ -99,7 +99,7 @@
- + {{ 'alerts.rule.severityMin' | translate }} @for (s of severities; track s) { @@ -113,7 +113,7 @@ - + {{ 'alerts.rule.cooldown' | translate }} sec @@ -125,7 +125,7 @@
- + {{ 'alerts.rule.eventFilter' | translate }} - + {{ 'alerts.rule.remoteFilter' | translate }}
- + {{ 'alerts.rule.backendFilter' | translate }} - + {{ 'alerts.rule.profileFilter' | translate }}
- + {{ 'alerts.origins.title' | translate }} {{ 'modals.quickAdd.operations.title' | translate }} } - @if (shouldShowRemoteOAuthFallback()) { -
- - {{ 'modals.oauth.remoteFallback' | translate }} -
- } @if (currentStep() === 'interactive' && !isAuthCancelled()) {
@@ -135,7 +129,7 @@
} - @if (state.currentStep() > 1 || state.editTarget()) { - diff --git a/src/app/features/modals/remote-management/remote-config-modal/config-modal-sidebar/config-modal-sidebar.component.scss b/src/app/features/modals/remote-management/remote-config-modal/config-modal-sidebar/config-modal-sidebar.component.scss index 7b0fbc46f..7e53b988b 100644 --- a/src/app/features/modals/remote-management/remote-config-modal/config-modal-sidebar/config-modal-sidebar.component.scss +++ b/src/app/features/modals/remote-management/remote-config-modal/config-modal-sidebar/config-modal-sidebar.component.scss @@ -3,16 +3,10 @@ } .modal-sidebar { - width: var(--sidebar-width); - background: var(--sidebar-bg-color); - box-shadow: - inset -1px 0 0 0 var(--border-color), - inset -5px 0 10px -5px rgba(0, 0, 0, 0.05); display: flex; flex-direction: column; + height: 100%; overflow: hidden; - transition: width var(--transition-cubic); - animation: fadeInUp 0.2s ease-out both; .sidebar-header { padding: var(--space-sm) var(--space-md) 0; @@ -100,46 +94,3 @@ } } } - -// ── Responsive States ──────────────────────────────────────────────────────── - -@media (max-width: 800px) and (min-width: 641px) { - .modal-sidebar { - width: var(--sidebar-icon-width); - - .sidebar-header { - display: none; - } - - .step-indicator-container { - .step-indicator { - padding: var(--radius-xxs); - } - } - - ::ng-deep .mat-mdc-list-item { - padding-left: 0 !important; - padding-right: 0 !important; - - .mdc-list-item__content { - display: none !important; - } - - // Hide count badges too - .mdc-list-item__end { - display: none !important; - } - - .mdc-list-item__start { - margin-left: auto; - margin-right: auto; - } - } - } -} - -@media (max-width: 640px) { - .modal-sidebar { - display: none; - } -} diff --git a/src/app/features/modals/remote-management/remote-config-modal/config-modal-sidebar/config-modal-sidebar.component.ts b/src/app/features/modals/remote-management/remote-config-modal/config-modal-sidebar/config-modal-sidebar.component.ts index 56a4b3c56..acc9c390a 100755 --- a/src/app/features/modals/remote-management/remote-config-modal/config-modal-sidebar/config-modal-sidebar.component.ts +++ b/src/app/features/modals/remote-management/remote-config-modal/config-modal-sidebar/config-modal-sidebar.component.ts @@ -4,12 +4,23 @@ import { MatDividerModule } from '@angular/material/divider'; import { MatIconModule } from '@angular/material/icon'; import { MatListModule } from '@angular/material/list'; import { TranslatePipe } from '@ngx-translate/core'; -import { EditTarget } from '@app/types'; +import { EditTarget, TemplateCategory } from '@app/types'; import { RemoteConfigStateService } from 'src/app/services/remote/remote-config-state.service'; +import { + PresetTemplateBarComponent, + ApplyTemplateEvent, +} from 'src/app/shared/remote-config/preset-template-bar/preset-template-bar.component'; @Component({ selector: 'app-config-modal-sidebar', - imports: [MatButtonModule, MatDividerModule, MatIconModule, MatListModule, TranslatePipe], + imports: [ + MatButtonModule, + MatDividerModule, + MatIconModule, + MatListModule, + TranslatePipe, + PresetTemplateBarComponent, + ], templateUrl: './config-modal-sidebar.component.html', styleUrl: './config-modal-sidebar.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -22,6 +33,7 @@ export class ConfigModalSidebarComponent { readonly remoteEditCategories = input([]); readonly visibleSections = input>(new Set()); readonly profileIcons = input>>({}); + readonly currentValues = input>>>({}); // ── Outputs ─────────────────────────────────────────────────────────────── @@ -30,8 +42,11 @@ export class ConfigModalSidebarComponent { readonly profileSelected = output<{ type: EditTarget; name: string }>(); readonly sharedNavigated = output(); readonly returnFromShared = output(); + readonly searchToggled = output(); readonly cliImportToggled = output(); readonly obscureToolToggled = output(); + readonly presetsApplied = output(); + readonly templateApplied = output(); // ── Template helpers ────────────────────────────────────────────────────── diff --git a/src/app/features/modals/remote-management/remote-config-modal/profile-header/profile-header.component.html b/src/app/features/modals/remote-management/remote-config-modal/profile-header/profile-header.component.html deleted file mode 100755 index dc7ee7cf0..000000000 --- a/src/app/features/modals/remote-management/remote-config-modal/profile-header/profile-header.component.html +++ /dev/null @@ -1,87 +0,0 @@ -
- @let pState = profileStateForType(); - @let selProfile = selectedName(); - @let pList = profileList(); - - @if (pState.mode === 'view') { - - {{ 'modals.remoteConfig.profile.label' | translate }} - - @for (profile of pList; track profile.name) { - {{ profile.name }} - } - - - -
- - @if (selProfile) { - - - - - - - } -
- } @else { - - {{ - (pState.mode === 'add' - ? 'modals.remoteConfig.profile.new' - : 'modals.remoteConfig.profile.rename' - ) | translate - }} - - - -
- - -
- } -
diff --git a/src/app/features/modals/remote-management/remote-config-modal/profile-header/profile-header.component.scss b/src/app/features/modals/remote-management/remote-config-modal/profile-header/profile-header.component.scss deleted file mode 100644 index 75bf97871..000000000 --- a/src/app/features/modals/remote-management/remote-config-modal/profile-header/profile-header.component.scss +++ /dev/null @@ -1,25 +0,0 @@ -.profile-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-md); - padding: var(--space-md); - background: rgba(var(--accent-color-rgb), 0.03); - border-radius: var(--radius-md); - box-shadow: var(--shadow-gnome); - - .profile-select, - .profile-input { - flex: 1; - } - - .profile-actions { - display: flex; - align-items: center; - gap: var(--space-xs); - - .action-tooltip-anchor { - display: inline-flex; - } - } -} diff --git a/src/app/features/modals/remote-management/remote-config-modal/profile-header/profile-header.component.ts b/src/app/features/modals/remote-management/remote-config-modal/profile-header/profile-header.component.ts deleted file mode 100755 index 7c54d2bba..000000000 --- a/src/app/features/modals/remote-management/remote-config-modal/profile-header/profile-header.component.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { TranslatePipe } from '@ngx-translate/core'; -import { SharedProfileType } from '@app/types'; -import { RemoteConfigStateService } from 'src/app/services/remote/remote-config-state.service'; - -@Component({ - selector: 'app-profile-header', - imports: [ - FormsModule, - MatButtonModule, - MatFormFieldModule, - MatIconModule, - MatInputModule, - MatSelectModule, - TranslatePipe, - ], - templateUrl: './profile-header.component.html', - styleUrl: './profile-header.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ProfileHeaderComponent { - readonly state = inject(RemoteConfigStateService); - - // ── Inputs ──────────────────────────────────────────────────────────────── - - readonly flagType = input.required(); - - // ── Computed ─────────────────────────────────────────────────────────────── - - readonly profileStateForType = computed(() => this.state.profileState()[this.flagType()]); - readonly selectedName = computed(() => this.state.selectedProfileName()[this.flagType()]); - readonly profileList = computed(() => this.state.profileLists()[this.flagType()]); - - private readonly actionState = computed(() => - this.state.getProfileActionState(this.flagType(), this.selectedName() ?? '') - ); - readonly renameDisabled = computed(() => this.actionState().rename.disabled); - readonly deleteDisabled = computed(() => this.actionState().delete.disabled); - readonly renameDisabledReason = computed(() => this.actionState().rename.reason); - readonly deleteDisabledReason = computed(() => this.actionState().delete.reason); -} diff --git a/src/app/features/modals/remote-management/remote-config-modal/remote-config-modal.component.html b/src/app/features/modals/remote-management/remote-config-modal/remote-config-modal.component.html index 931fe2b55..d3222ee80 100755 --- a/src/app/features/modals/remote-management/remote-config-modal/remote-config-modal.component.html +++ b/src/app/features/modals/remote-management/remote-config-modal/remote-config-modal.component.html @@ -3,12 +3,12 @@

@let stepKey = state.editTargetStepKey(); @@ -17,11 +17,6 @@ ? ('modals.remoteConfig.title.edit' | translate: { target: stepKey | translate }) : ('modals.remoteConfig.title.add' | translate) }} - @if (!flow.isActive && !state.editTarget()) { - - ({{ state.currentStep() }}/{{ state.stepConfigs().length }}) - - }

} @else { - @if (!flow.isActive) { - - } - - + + } - + @if (flow.isActive && flow.question) { +
+ +
+ } + @if (!flow.isActive) { + @switch (state.currentStep()) { + @case (1) { +
} + + @default { + @if (state.currentStep() < state.stepConfigs().length) { + @let flagType = $any(state.stepConfigs()[state.currentStep() - 1].type); +
+ + + @if (LINKED_PROFILE_TYPES.has(flagType)) { + + + + + {{ + 'modals.remoteConfig.advancedProfiles.title' | translate + }} + + + {{ + 'modals.remoteConfig.advancedProfiles.linked' + | translate: { count: state.linkedProfileSelectFields().length } + }} + + + +
+ @for (field of state.linkedProfileSelectFields(); track field.type) { + + {{ field.labelKey | translate }} + + @for (opt of state.profileOptions()[field.type]; track opt) { + {{ opt }} + } + + + } +
+
+ } + + @if (!['vfs', 'filter', 'backend'].includes(flagType)) { + + } + + +
+ } @else { + +
+ + + + + +
+ } + } } } - } -
+ + } diff --git a/src/app/features/modals/remote-management/remote-config-modal/remote-config-modal.component.scss b/src/app/features/modals/remote-management/remote-config-modal/remote-config-modal.component.scss index 14c60fb03..e76e917f5 100644 --- a/src/app/features/modals/remote-management/remote-config-modal/remote-config-modal.component.scss +++ b/src/app/features/modals/remote-management/remote-config-modal/remote-config-modal.component.scss @@ -6,15 +6,25 @@ // MAIN / MODAL BODY (Sidebar + Content Area) // ============================================= main { - flex-direction: row; + flex-direction: column; + padding: 0 !important; + overflow: hidden !important; } -.mobile-step-indicator { - display: none; - font-weight: 600; - color: var(--primary-color); - margin-left: var(--space-xs); - opacity: 0.8; +.modal-sidenav-container { + flex: 1; + width: 100%; + height: 100%; + background: var(--window-bg-color); +} + +.modal-sidebar { + width: var(--sidebar-width); + background: var(--sidebar-bg-color); + display: flex; + flex-direction: column; + overflow: hidden; + height: 100%; } .init-skeleton { @@ -151,25 +161,13 @@ main { } @media (max-width: 640px) { - main { - flex-direction: column; - } - .step-container:not(.interactive) { padding: var(--space-md) !important; } - .mobile-step-indicator { - display: inline-block !important; - } - .init-skeleton { flex-direction: column; - &__sidebar { - display: none; - } - &__content { padding: var(--space-md) !important; } diff --git a/src/app/features/modals/remote-management/remote-config-modal/remote-config-modal.component.spec.ts b/src/app/features/modals/remote-management/remote-config-modal/remote-config-modal.component.spec.ts deleted file mode 100644 index 8cf94b852..000000000 --- a/src/app/features/modals/remote-management/remote-config-modal/remote-config-modal.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { RemoteConfigModalComponent } from './remote-config-modal.component'; - -describe('RemoteConfigModalComponent', () => { - let component: RemoteConfigModalComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RemoteConfigModalComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(RemoteConfigModalComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/modals/remote-management/remote-config-modal/remote-config-modal.component.ts b/src/app/features/modals/remote-management/remote-config-modal/remote-config-modal.component.ts index 3a7215d26..6db4d1941 100755 --- a/src/app/features/modals/remote-management/remote-config-modal/remote-config-modal.component.ts +++ b/src/app/features/modals/remote-management/remote-config-modal/remote-config-modal.component.ts @@ -4,11 +4,12 @@ import { computed, DestroyRef, ElementRef, - HostListener, inject, + signal, + afterNextRender, viewChild, } from '@angular/core'; -import { ReactiveFormsModule } from '@angular/forms'; +import { ReactiveFormsModule, FormGroup, FormControl } from '@angular/forms'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; @@ -17,19 +18,16 @@ import { MatSelectModule } from '@angular/material/select'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatExpansionModule } from '@angular/material/expansion'; +import { MatSidenavModule, MatDrawerMode } from '@angular/material/sidenav'; import { RemoteConfigStepComponent } from '../../../../shared/remote-config/remote-config-step/remote-config-step.component'; import { FlagConfigStepComponent } from '../../../../shared/remote-config/flag-config-step/flag-config-step.component'; -import { CliImportComponent } from '../../../../shared/remote-config/cli-import/cli-import.component'; +import { OperationConfigComponent } from '../../../../shared/remote-config/app-operation-config/app-operation-config.component'; import { ObscureToolComponent } from '../../../../shared/remote-config/obscure-tool/obscure-tool.component'; +import { CliImportComponent } from '../../../../shared/remote-config/cli-import/cli-import.component'; import { AlertBannerComponent } from '../../../../shared/components/alert-banner/alert-banner.component'; import { SearchContainerComponent } from '../../../../shared/components/search-container/search-container.component'; -import { - JSON_EDITOR_LOOKUP_TABLE, - type JsonEditorLookupTable, -} from '../../../../shared/components/json-editor/json-editor.component'; import { InteractiveConfigStepComponent } from 'src/app/shared/remote-config/interactive-config-step/interactive-config-step.component'; import { AuthStateService } from '../../../../services/security/auth-state.service'; -import { AppSettingsService } from '../../../../services/settings/app-settings.service'; import { NotificationService } from '../../../../services/ui/notification.service'; import { IconService } from '../../../../services/ui/icon.service'; import { RemoteManagementService } from '../../../../services/remote/remote-management.service'; @@ -38,18 +36,20 @@ import { DialogData, } from '../../../../services/remote/remote-config-state.service'; import { RemoteCreationOrchestrator } from '../../../../services/remote/remote-creation-orchestrator.service'; +import { RcloneValueMapperService } from '../../../../services/remote/rclone-value-mapper.service'; import { RemoteConfigSections, REMOTE_CONFIG_KEYS, - SharedProfileType, LINKED_PROFILE_TYPES, PROFILE_ICONS, + EditTarget, } from '@app/types'; import { CopyToClipboardDirective } from '../../../../shared/directives/copy-to-clipboard.directive'; import { ProfileSwitcherComponent } from './profile-switcher/profile-switcher.component'; import { ConfigModalSidebarComponent } from './config-modal-sidebar/config-modal-sidebar.component'; import { ConfigModalFooterComponent } from './config-modal-footer/config-modal-footer.component'; import { EscapeCloseDirective } from '../../../../shared/directives/escape-close.directive'; +import { ApplyTemplateEvent } from '../../../../shared/remote-config/preset-template-bar/preset-template-bar.component'; @Component({ selector: 'app-remote-config-modal', @@ -63,10 +63,12 @@ import { EscapeCloseDirective } from '../../../../shared/directives/escape-close MatFormFieldModule, MatInputModule, MatExpansionModule, + MatSidenavModule, RemoteConfigStepComponent, FlagConfigStepComponent, - CliImportComponent, + OperationConfigComponent, ObscureToolComponent, + CliImportComponent, AlertBannerComponent, InteractiveConfigStepComponent, SearchContainerComponent, @@ -75,15 +77,8 @@ import { EscapeCloseDirective } from '../../../../shared/directives/escape-close ConfigModalSidebarComponent, ConfigModalFooterComponent, ], - providers: [ - RemoteCreationOrchestrator, - RemoteConfigStateService, - { - provide: JSON_EDITOR_LOOKUP_TABLE, - useFactory: (state: RemoteConfigStateService): JsonEditorLookupTable => state.lookupTable, - deps: [RemoteConfigStateService], - }, - ], + + providers: [RemoteCreationOrchestrator, RemoteConfigStateService], templateUrl: './remote-config-modal.component.html', styleUrls: ['../../../../styles/_shared-modal.scss', './remote-config-modal.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, @@ -91,27 +86,27 @@ import { EscapeCloseDirective } from '../../../../shared/directives/escape-close export class RemoteConfigModalComponent { readonly state = inject(RemoteConfigStateService); - // ── Injections ──────────────────────────────────────────────────────────────── - private readonly dialogRef = inject(MatDialogRef); private readonly hostEl = inject(ElementRef); private readonly authStateService = inject(AuthStateService); private readonly remoteManagementService = inject(RemoteManagementService); readonly configStep = viewChild(RemoteConfigStepComponent); - private readonly appSettingsService = inject(AppSettingsService); - private readonly dialogData = inject(MAT_DIALOG_DATA, { optional: true }) as DialogData; + private readonly dialogData = (inject(MAT_DIALOG_DATA, { optional: true }) ?? undefined) as + DialogData | undefined; readonly iconService = inject(IconService); private readonly notificationService = inject(NotificationService); private readonly translate = inject(TranslateService); private readonly destroyRef = inject(DestroyRef); private readonly orchestrator = inject(RemoteCreationOrchestrator); - - // ── Static config ───────────────────────────────────────────────────────────── + private readonly valueMapper = inject(RcloneValueMapperService); readonly LINKED_PROFILE_TYPES = LINKED_PROFILE_TYPES; readonly PROFILE_ICONS = PROFILE_ICONS; + readonly isSidebarOpen = signal(true); + readonly sidebarMode = signal('side'); + readonly remoteEditCategories = [ { id: 'section-general', label: 'modals.remoteConfig.editMode.sections.general', icon: 'gear' }, { id: 'section-auth', label: 'modals.remoteConfig.editMode.sections.auth', icon: 'lock' }, @@ -122,6 +117,20 @@ export class RemoteConfigModalComponent { }, ] as const; + /** + * Sections currently visible in the remote-edit view. + * + * ⚠️ Known anti-pattern: this computed reads 6 internal computeds of the + * `RemoteConfigStepComponent` via `viewChild()`, which breaks OnPush + * isolation between parent and child and creates a one-cycle CD lag (the + * viewChild is `undefined` on first render). + * + * The proper fix is to move the underlying `showNameField`, `providerField`, + * `showAdvancedOptions`, `advancedFields`, `providerReady` computeds into + * `RemoteConfigStateService` (which already owns `showAdvancedOptions()`) + * and read them from there. Deferred to a follow-up PR because the step + * component currently computes them locally from its `remoteFields` input. + */ readonly visibleSections = computed(() => { const step = this.configStep(); if (!step) return new Set(); @@ -135,13 +144,27 @@ export class RemoteConfigModalComponent { return visible; }); - // ── Lifecycle ───────────────────────────────────────────────────────────────── - constructor() { this.destroyRef.onDestroy(() => this.authStateService.cancelAuth()); + afterNextRender(() => this.setupResponsiveLayout()); this.initializeState(); } + private setupResponsiveLayout(): void { + const mql = window.matchMedia('(min-width: 768px)'); + const update = (matches: boolean): void => { + this.sidebarMode.set(matches ? 'side' : 'over'); + if (!matches) { + this.isSidebarOpen.set(false); + } + }; + const handler = (e: MediaQueryListEvent): void => update(e.matches); + + update(mql.matches); + mql.addEventListener('change', handler); + this.destroyRef.onDestroy(() => mql.removeEventListener('change', handler)); + } + private async initializeState(): Promise { try { await this.state.init(this.dialogData); @@ -158,16 +181,14 @@ export class RemoteConfigModalComponent { } } - // ── Step navigation ─────────────────────────────────────────────────────────── - goToStep(step: number): void { if (!this.state.isStepClickable(step)) return; this.saveCurrentStepProfile(); this.state.currentStep.set(step); this.scrollToTop(); if (step === 1 && !this.state.editTarget()) { - this.state.showCliImport.set(false); this.state.showObscureTool.set(false); + this.state.showCliImport.set(false); } } @@ -191,12 +212,10 @@ export class RemoteConfigModalComponent { this.orchestrator.updateInteractiveAnswer(newAnswer); } - // ── Form submission ─────────────────────────────────────────────────────────── - async onSubmit(): Promise { if (this.state.isAuthInProgress()) return; try { - const result = this.state.editTarget() + const result = this.state.isEditingExisting() ? await this.handleEditMode() : await this.handleCreateMode(); if (result.success && !this.state.isAuthCancelled()) this.close(); @@ -220,9 +239,9 @@ export class RemoteConfigModalComponent { } } - private get requiresInteractiveFlow(): boolean { - return this.state.commandOptions().some(o => o.key === 'nonInteractive' && o.value === true); - } + private readonly requiresInteractiveFlow = computed(() => + this.state.commandOptions().some(o => o.key === 'nonInteractive' && o.value === true) + ); private async handleCreateMode(): Promise<{ success: boolean }> { this.state.PROFILE_TYPES.forEach(type => this.state.saveCurrentProfile(type)); @@ -230,7 +249,7 @@ export class RemoteConfigModalComponent { const finalConfig = this.buildFinalConfig(); await this.authStateService.startAuth(remoteData.name, false); - if (!this.requiresInteractiveFlow) { + if (!this.requiresInteractiveFlow()) { await this.remoteManagementService.createRemote( remoteData.name, remoteData, @@ -256,7 +275,7 @@ export class RemoteConfigModalComponent { if (this.state.editTarget() === 'remote') { const remoteData = this.state.cleanFormData(this.state.remoteForm.getRawValue()); - if (this.requiresInteractiveFlow) { + if (this.requiresInteractiveFlow()) { const finalConfig = this.buildFinalConfig(true); this.orchestrator.setPendingConfig(remoteData, finalConfig); const completed = await this.orchestrator.startInteractiveCreation( @@ -270,18 +289,14 @@ export class RemoteConfigModalComponent { return { success: true }; } - const updatedConfig = this.buildUpdateConfig(); - await this.appSettingsService.saveRemoteSettings(remoteName, updatedConfig); - try { - await this.state.pathService.createRequiredDirectories(updatedConfig); - } catch (err) { - console.error('Failed to create required directories:', err); + const target = this.state.editTarget(); + if (target === 'remote' || target === null) { + return { success: false }; } - return { success: true }; + const success = await this.state.saveRemoteProfiles(remoteName, target); + return { success }; } - // ── Config building ─────────────────────────────────────────────────────────── - private buildFinalConfig(empty = false): RemoteConfigSections { this.saveCurrentStepProfile(); const p = this.state.profiles(); @@ -294,33 +309,15 @@ export class RemoteConfigModalComponent { return { ...sections, showOnTray: true }; } - private buildUpdateConfig(): Record { - const target = this.state.editTarget() as SharedProfileType; - if (!target) return {}; - - this.state.saveCurrentProfile(target); - this.state.dirtyProfileTypes.add(target); - - const updatedConfig: Record = {}; - for (const dirty of this.state.dirtyProfileTypes) { - const key = REMOTE_CONFIG_KEYS[dirty as keyof typeof REMOTE_CONFIG_KEYS]; - if (key) updatedConfig[key] = this.state.profiles()[dirty]; - } - - return updatedConfig; - } - saveCurrentStepProfile(): void { const editTargetValue = this.state.editTarget(); const type = editTargetValue && editTargetValue !== 'remote' ? editTargetValue : this.state.stepConfigs()[this.state.currentStep() - 1]?.type; - if (type && type !== 'remote') this.state.saveCurrentProfile(type as SharedProfileType); + if (type && type !== 'remote') this.state.saveCurrentProfile(type); } - // ── Interactive flow ────────────────────────────────────────────────────────── - onInteractiveContinue(answer: string | number | boolean | null): void { if (this.state.interactiveFlowState().isProcessing) return; this.state.interactiveFlowState.update(s => ({ @@ -329,19 +326,18 @@ export class RemoteConfigModalComponent { answer: String(answer), })); void this.orchestrator.submitInteractiveAnswer(answer, this.state.commandOptions()).then(() => { - // submitInteractiveAnswer calls finalizeCreation internally when the - // backend signals completion (no more questions) — at that point the - // flow is no longer active and we should close the modal. - if (!this.state.interactiveFlowState().isActive) this.close(); + this.closeIfFlowComplete(); }); } + private closeIfFlowComplete(): void { + if (!this.state.interactiveFlowState().isActive) this.close(); + } + async cancelAuth(): Promise { await this.orchestrator.cancelAuth(); } - // ── Search & Listeners ───────────────────────────────────────────────────────── - toggleSearchVisibility(): void { this.state.isSearchVisible.update(visible => !visible); if (!this.state.isSearchVisible()) this.state.searchQuery.set(''); @@ -357,12 +353,117 @@ export class RemoteConfigModalComponent { ?.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' }); } - @HostListener('window:keydown', ['$event']) - handleSearchKeyboard(event: KeyboardEvent): void { - if ((event.ctrlKey || event.metaKey) && event.key === 'f') { - event.preventDefault(); - this.toggleSearchVisibility(); + toggleSidebar(): void { + this.isSidebarOpen.update(v => !v); + } + + private closeSidebarIfOver(): void { + if (this.sidebarMode() === 'over') { + this.isSidebarOpen.set(false); + } + } + + onStepSelected(step: number): void { + this.goToStep(step); + this.closeSidebarIfOver(); + } + + onSectionScrolled(sectionId: string): void { + this.scrollToSection(sectionId); + this.closeSidebarIfOver(); + } + + onProfileSelected(event: { type: EditTarget; name: string }): void { + this.state.selectProfile(event.type, event.name); + this.closeSidebarIfOver(); + } + + onSharedNavigated(type: EditTarget): void { + this.state.navigateToShared(type); + this.closeSidebarIfOver(); + } + + onReturnFromShared(): void { + this.state.returnFromShared(); + this.closeSidebarIfOver(); + } + + onSearchToggled(): void { + this.toggleSearchVisibility(); + this.closeSidebarIfOver(); + } + + onCliImportToggled(): void { + this.state.toggleCliImportVisibility(); + if (this.state.showCliImport()) this.closeSidebarIfOver(); + } + + onObscureToolToggled(): void { + this.state.toggleObscureToolVisibility(); + if (this.state.showObscureTool()) this.closeSidebarIfOver(); + } + + onApplyPresets(): void { + const remoteType = this.state.remoteTypeSignal(); + if (!remoteType) return; + this.state.applyPresets(remoteType); + const msg = this.translate.instant('wizards.presets.applied'); + this.notificationService.showSuccess( + msg !== 'wizards.presets.applied' ? msg : 'Default presets applied successfully' + ); + } + + readonly currentValues = computed(() => { + const rcf = this.state.remoteConfigForm; + const flagFields = this.state.dynamicFlagFields(); + + const getCleanOptions = ( + configKey: string, + flagType: 'vfs' | 'mount' | 'backend' | 'filter' + ): Record => { + const opts = (rcf.get(`${configKey}.options`) as FormGroup | null)?.getRawValue() ?? {}; + return this.valueMapper.cleanData(opts, flagFields[flagType] ?? []); + }; + + const remoteRaw = this.state.remoteForm.getRawValue(); + const cleanRemoteData = this.state.cleanFormData(remoteRaw) as Record; + delete cleanRemoteData['name']; + delete cleanRemoteData['type']; + + return { + vfs: getCleanOptions('vfsConfig', 'vfs'), + mount: getCleanOptions('mountConfig', 'mount'), + backend: getCleanOptions('backendConfig', 'backend'), + filter: getCleanOptions('filterConfig', 'filter'), + remote: cleanRemoteData, + }; + }); + + onApplyTemplate(event: ApplyTemplateEvent): void { + const { values } = event; + const patchGroupOptions = (configKey: string, opts?: Record): void => { + if (!opts) return; + const group = this.state.remoteConfigForm.get(`${configKey}.options`) as FormGroup | null; + if (group) { + for (const [k, v] of Object.entries(opts)) { + if (!group.contains(k)) { + group.addControl(k, new FormControl(v)); + } else { + group.get(k)?.setValue(v); + } + } + } + }; + + if (values.vfs) patchGroupOptions('vfsConfig', values.vfs); + if (values.mount) patchGroupOptions('mountConfig', values.mount); + if (values.backend) patchGroupOptions('backendConfig', values.backend); + if (values.filter) patchGroupOptions('filterConfig', values.filter); + if (values.remote) { + this.state.remoteForm.patchValue(values.remote, { emitEvent: false }); } + const msg = this.translate.instant('templates.applySuccess', { name: event.sourceName }); + this.notificationService.showSuccess(msg); } close(): void { diff --git a/src/app/features/modals/remote/delete-remote-modal/delete-remote-modal.component.html b/src/app/features/modals/remote/delete-remote-modal/delete-remote-modal.component.html new file mode 100644 index 000000000..83d1945ca --- /dev/null +++ b/src/app/features/modals/remote/delete-remote-modal/delete-remote-modal.component.html @@ -0,0 +1,172 @@ +
+ +

{{ 'home.deleteRemote.title' | translate }}

+ +
+ +
+ +
+
+
+ +
+
+

{{ remoteName }}

+ + {{ remoteType() | titlecase }} + +
+
+

+ {{ 'home.deleteRemote.message' | translate: { name: remoteName } }} +

+
+ + + @if (hasActiveOperations()) { +
+ + +
+ @for (mount of activeMounts(); track mount.mount_point) { +
+ +
+ {{ 'home.deleteRemote.types.mount' | translate }} + {{ mount.mount_point }} +
+ {{ 'home.deleteRemote.willUnmount' | translate }} +
+ } + + @for (serve of activeServes(); track serve.id) { +
+ +
+ {{ serve.params.type | uppercase }} + {{ 'home.deleteRemote.types.serve' | translate }} + {{ serve.addr }} +
+ {{ 'home.deleteRemote.willStop' | translate }} +
+ } + + @for (job of activeJobs(); track job.jobid) { +
+ +
+ #{{ job.jobid }} - {{ job.job_type | titlecase }} + {{ job.status | titlecase }} +
+ {{ 'home.deleteRemote.willCancel' | translate }} +
+ } +
+
+ } + + + @if (profilesList().length > 0) { +
+
+ +

+ {{ 'home.deleteRemote.savedProfilesTitle' | translate: { count: profilesList().length } }} +

+
+
+ @for (profile of profilesList(); track profile.type + '-' + profile.name) { +
+ + {{ profile.actionLabel | translate }}: + {{ profile.name }} +
+ } +
+
+ } + + + @if (quickRunsList().length > 0) { +
+
+ +

+ {{ 'home.deleteRemote.quickRunsTitle' | translate: { count: quickRunsList().length } }} +

+
+
+ @for (qr of quickRunsList(); track qr.id) { +
+ + {{ qr.name }}: + {{ qr.operationType | titlecase }} +
+ } +
+
+ } + + + @if (automationsList().length > 0) { +
+
+ +

+ {{ + 'home.deleteRemote.automationsTitle' | translate: { count: automationsList().length } + }} +

+
+
+ @for (auto of automationsList(); track auto.id) { +
+ + {{ auto.profileName || auto.id }} +
+ } +
+
+ } +
+ +
+ + + +
diff --git a/src/app/features/modals/remote/delete-remote-modal/delete-remote-modal.component.scss b/src/app/features/modals/remote/delete-remote-modal/delete-remote-modal.component.scss new file mode 100644 index 000000000..926eedfe9 --- /dev/null +++ b/src/app/features/modals/remote/delete-remote-modal/delete-remote-modal.component.scss @@ -0,0 +1,152 @@ +.target-remote-card { + display: flex; + flex-direction: column; + align-items: stretch; + padding: var(--space-lg) var(--space-md) var(--space-md); + background: var(--card-bg-color); + border-radius: var(--radius-md); + box-shadow: var(--shadow-gnome); + + .remote-hero { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-sm); + + .remote-avatar { + display: flex; + align-items: center; + justify-content: center; + width: 68px; + height: 68px; + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--accent-color) 10%, var(--card-bg-color)); + box-shadow: 0 0 1px var(--accent-color); + + .remote-logo-icon { + width: 40px; + height: 40px; + font-size: 40px; + color: var(--accent-color); + } + } + + .remote-identity { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-xs); + max-width: 100%; + + .remote-name { + margin: 0; + font-size: var(--font-size-xl); + font-weight: 700; + color: var(--window-fg-color); + line-height: 1.25; + letter-spacing: -0.01em; + word-break: break-word; + } + } + } + + .delete-warning-message { + text-align: center; + font-size: var(--font-size-md); + font-weight: 500; + color: var(--warn-color); + } +} + +.alert-section, +.details-section { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-md); + border-radius: var(--radius-md); + background: var(--card-bg-color); + box-shadow: var(--shadow-gnome); + + .section-header { + display: flex; + align-items: center; + gap: var(--space-xs); + + mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + color: var(--primary-color); + } + + h4 { + margin: 0; + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--window-fg-color); + } + } +} + +.items-list { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.item-row { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: 8px 12px; + border-radius: var(--radius-sm); + background: var(--window-bg-color); + box-shadow: var(--box-shadow); + + .item-icon { + width: 20px; + height: 20px; + font-size: 20px; + color: var(--dim-color); + flex-shrink: 0; + } + + .item-text { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + + .item-title { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--window-fg-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .item-subtitle { + font-size: var(--font-size-xs); + color: var(--dim-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } +} + +.chips-grid { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); + + .profile-type { + font-weight: 500; + } + + .profile-name { + font-weight: 600; + } +} diff --git a/src/app/features/modals/remote/delete-remote-modal/delete-remote-modal.component.ts b/src/app/features/modals/remote/delete-remote-modal/delete-remote-modal.component.ts new file mode 100644 index 000000000..a12ad98b0 --- /dev/null +++ b/src/app/features/modals/remote/delete-remote-modal/delete-remote-modal.component.ts @@ -0,0 +1,149 @@ +import { + ChangeDetectionStrategy, + Component, + HostListener, + computed, + inject, + signal, +} from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import { TitleCasePipe, UpperCasePipe } from '@angular/common'; + +import { RemoteFacadeService } from 'src/app/services/facade/remote-facade.service'; +import { JobManagementService } from 'src/app/services/operations/job-management.service'; +import { QuickRunService } from 'src/app/services/flow/quick-run.service'; +import { AutomationService } from 'src/app/services/operations/automation.service'; +import { IconService } from 'src/app/services/ui/icon.service'; +import { PathService } from 'src/app/services/infrastructure/platform/path.service'; +import { AlertBannerComponent } from 'src/app/shared/components/alert-banner/alert-banner.component'; +import { OPERATION_REGISTRY, RemoteSettings } from '@app/types'; + +export interface DeleteRemoteModalData { + remoteName: string; +} + +export interface ProfileItem { + type: string; + name: string; + icon: string; + cssClass: string; + actionLabel: string; +} + +@Component({ + selector: 'app-delete-remote-modal', + imports: [ + MatButtonModule, + MatIconModule, + TranslatePipe, + TitleCasePipe, + UpperCasePipe, + AlertBannerComponent, + ], + templateUrl: './delete-remote-modal.component.html', + styleUrls: ['./delete-remote-modal.component.scss', '../../../../styles/_shared-modal.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DeleteRemoteModalComponent { + private readonly dialogRef = inject(MatDialogRef); + private readonly remoteFacade = inject(RemoteFacadeService); + private readonly jobService = inject(JobManagementService); + private readonly quickRunService = inject(QuickRunService); + private readonly automationService = inject(AutomationService); + private readonly pathService = inject(PathService); + public readonly iconService = inject(IconService); + + public readonly data: DeleteRemoteModalData = inject(MAT_DIALOG_DATA); + public readonly remoteName = this.data.remoteName; + + readonly isDeleting = signal(false); + + readonly remote = computed(() => + this.remoteFacade.orderedRemotes().find(r => r.name === this.remoteName) + ); + + readonly remoteType = computed(() => this.remote()?.type ?? 'generic'); + + readonly activeMounts = computed(() => + this.remoteFacade + .mountedRemotes() + .filter(m => this.pathService.getRemoteNameFromFs(m.fs) === this.remoteName) + ); + + readonly activeServes = computed(() => { + const prefix = `${this.remoteName}:`; + return this.remoteFacade.runningServes().filter(s => s.params?.fs?.startsWith(prefix)); + }); + + readonly activeJobs = computed(() => + this.jobService.jobs().filter(j => j.remote_name === this.remoteName && j.status === 'Running') + ); + + readonly hasActiveOperations = computed( + () => + this.activeMounts().length > 0 || + this.activeServes().length > 0 || + this.activeJobs().length > 0 + ); + + readonly profilesList = computed(() => { + const settings = this.remoteFacade.getRemoteSettings(this.remoteName); + if (!settings) return []; + + const items: ProfileItem[] = []; + for (const opDef of OPERATION_REGISTRY) { + if (!opDef.configKey) continue; + const configMap = settings[opDef.configKey as keyof RemoteSettings] as + Record | undefined; + if (configMap && typeof configMap === 'object') { + for (const profileName of Object.keys(configMap)) { + items.push({ + type: opDef.key, + name: profileName, + icon: opDef.icon, + cssClass: opDef.cssClass, + actionLabel: opDef.actionLabel, + }); + } + } + } + return items; + }); + + readonly quickRunsList = computed(() => + this.quickRunService.quickRuns().filter(qr => qr.remoteName === this.remoteName) + ); + + readonly automationsList = computed(() => + this.automationService.automations().filter(a => a.remoteName === this.remoteName) + ); + + getOpIcon(op: string): string { + return OPERATION_REGISTRY.find(d => d.key === op)?.icon ?? 'quick-run'; + } + + getOpPillClass(op: string): string { + const css = OPERATION_REGISTRY.find(d => d.key === op)?.cssClass ?? 'accent'; + return `p-${css}`; + } + + @HostListener('document:keydown.escape', ['$event']) + onEscapeKey(event: Event): void { + if (!this.isDeleting()) { + const keyboardEvent = event as KeyboardEvent; + keyboardEvent.preventDefault(); + this.onCancel(); + } + } + + onConfirm(): void { + this.dialogRef.close(true); + } + + onCancel(): void { + this.dialogRef.close(false); + } +} diff --git a/src/app/features/modals/settings/about-modal/about-modal.component.html b/src/app/features/modals/settings/about-modal/about-modal.component.html index 8e957901c..4f3a9ff99 100755 --- a/src/app/features/modals/settings/about-modal/about-modal.component.html +++ b/src/app/features/modals/settings/about-modal/about-modal.component.html @@ -60,29 +60,16 @@

RClone Manager

@for (item of bottomNavItems; track item.viewId; let last = $last) { - @if (item.viewId === 'donate') { - - } @else { - - } + @if (!last) { } @@ -949,7 +936,7 @@

{{ title | translate }}

{{ 'modals.about.releaseChannel' | translate }}
- + { - let component: AboutModalComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [AboutModalComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(AboutModalComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/modals/settings/about-modal/about-modal.component.ts b/src/app/features/modals/settings/about-modal/about-modal.component.ts index 6b38724be..7b4381a33 100644 --- a/src/app/features/modals/settings/about-modal/about-modal.component.ts +++ b/src/app/features/modals/settings/about-modal/about-modal.component.ts @@ -218,7 +218,6 @@ export class AboutModalComponent implements OnInit { ]; readonly bottomNavItems: { label: string; viewId: ViewId; icon: string }[] = [ - { label: 'modals.about.donate', viewId: 'donate', icon: 'chevron-right' }, { label: 'modals.about.credits', viewId: 'credits', icon: 'chevron-right' }, { label: 'modals.about.legal', viewId: 'legal', icon: 'chevron-right' }, ]; diff --git a/src/app/features/modals/settings/backend-modal/backend-modal.component.html b/src/app/features/modals/settings/backend-modal/backend-modal.component.html index d248fb08d..44e3d6d5d 100755 --- a/src/app/features/modals/settings/backend-modal/backend-modal.component.html +++ b/src/app/features/modals/settings/backend-modal/backend-modal.component.html @@ -140,7 +140,7 @@
- + {{ 'modals.backend.copyOptions.backendConfig' | translate }} @@ -164,7 +164,7 @@ } - + {{ 'modals.backend.copyOptions.remotesConfig' | translate }} @@ -194,7 +194,7 @@
} - + {{ 'modals.backend.name' | translate }} @@ -210,7 +210,7 @@
- + {{ 'modals.backend.fields.host.label' | translate }}
- + {{ 'modals.backend.fields.port.label' | translate }}
- + {{ 'modals.backend.fields.username.label' | translate }} @@ -293,7 +293,7 @@
- + {{ 'modals.backend.fields.password.label' | translate }} @@ -350,55 +350,10 @@ /> } - - @if (editingName() === 'Local') { -
-

{{ 'modals.backend.oauthSettings' | translate }}

-
-
- - {{ - 'modals.backend.fields.oauthHost.label' | translate - }} - - - @if (backendForm.get('oauth_host')?.hasError('required')) { - {{ 'common.required' | translate }} - } - - -
-
- - {{ - 'modals.backend.fields.oauthPort.label' | translate - }} - - @if ( - backendForm.get('oauth_port')?.hasError('min') || - backendForm.get('oauth_port')?.hasError('max') - ) { - 1024 - 65535 - } - -
-
-
- } -
- + {{ 'modals.backend.fields.configPath.label' | translate }} {{ 'modals.backend.oauthSettings' | translate }}
- + {{ 'modals.backend.fields.configPassword.label' | translate }} diff --git a/src/app/features/modals/settings/backend-modal/backend-modal.component.scss b/src/app/features/modals/settings/backend-modal/backend-modal.component.scss index 04d9e52f8..ff66ec62c 100755 --- a/src/app/features/modals/settings/backend-modal/backend-modal.component.scss +++ b/src/app/features/modals/settings/backend-modal/backend-modal.component.scss @@ -233,23 +233,6 @@ gap: var(--space-sm); } -.oauth-group { - background: var(--view-bg-color); - padding: var(--space-md); - border-radius: var(--radius-sm); - box-shadow: var(--box-shadow); - display: flex; - flex-direction: column; - gap: var(--space-sm); - - h4 { - margin: 0; - font-size: var(--font-size-md); - font-weight: 600; - color: var(--dim-label-color); - } -} - .tab-content { display: flex; flex-direction: column; diff --git a/src/app/features/modals/settings/backend-modal/backend-modal.component.ts b/src/app/features/modals/settings/backend-modal/backend-modal.component.ts index 435bbe5e5..9386d522a 100755 --- a/src/app/features/modals/settings/backend-modal/backend-modal.component.ts +++ b/src/app/features/modals/settings/backend-modal/backend-modal.component.ts @@ -178,11 +178,6 @@ export class BackendModalComponent implements OnInit { config_password: [''], config_path: [''], has_auth: [false], - oauth_host: [BACKEND_CONSTANTS.DEFAULTS.IP], - oauth_port: [ - BACKEND_CONSTANTS.DEFAULTS.OAUTH_PORT, - [Validators.min(1024), Validators.max(65535)], - ], }, { validators: [this.duplicateHostValidator] } ); @@ -219,8 +214,6 @@ export class BackendModalComponent implements OnInit { password: backend.password ?? '', config_password: '', config_path: backend.configPath ?? '', - oauth_host: backend.oauthHost ?? BACKEND_CONSTANTS.DEFAULTS.IP, - oauth_port: backend.oauthPort ?? BACKEND_CONSTANTS.DEFAULTS.OAUTH_PORT, }); this.updateAuthValidators(hasCustomAuth); @@ -239,8 +232,6 @@ export class BackendModalComponent implements OnInit { host: BACKEND_CONSTANTS.DEFAULTS.HOST, port: BACKEND_CONSTANTS.DEFAULTS.PORT, has_auth: false, - oauth_host: BACKEND_CONSTANTS.DEFAULTS.IP, - oauth_port: BACKEND_CONSTANTS.DEFAULTS.OAUTH_PORT, }); this.updateAuthValidators(false); this.showPassword.set(false); @@ -435,8 +426,6 @@ export class BackendModalComponent implements OnInit { username: backend.username, password: backend.password, configPath: backend.configPath, - oauthPort: backend.oauthPort, - oauthHost: backend.oauthHost, configPassword: '', // explicitly empty → Rust clears it }); this.notificationService.showSuccess( diff --git a/src/app/features/modals/settings/export-modal/export-modal.component.spec.ts b/src/app/features/modals/settings/export-modal/export-modal.component.spec.ts deleted file mode 100644 index 6741eab15..000000000 --- a/src/app/features/modals/settings/export-modal/export-modal.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { ExportModalComponent } from './export-modal.component'; - -describe('ExportModalComponent', () => { - let component: ExportModalComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ExportModalComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(ExportModalComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/modals/settings/export-modal/export-modal.component.ts b/src/app/features/modals/settings/export-modal/export-modal.component.ts index 3dd94478e..e1a17544f 100755 --- a/src/app/features/modals/settings/export-modal/export-modal.component.ts +++ b/src/app/features/modals/settings/export-modal/export-modal.component.ts @@ -1,6 +1,5 @@ import { Component, - DestroyRef, OnInit, inject, signal, @@ -26,7 +25,7 @@ import { } from 'src/app/services/settings/backup-restore.service'; import { RemoteManagementService } from 'src/app/services/remote/remote-management.service'; import { FileSystemService } from 'src/app/services/operations/file-system.service'; -import { MatRadioButton } from '@angular/material/radio'; +import { MatRadioModule } from '@angular/material/radio'; // Static lookup — mapping specific IDs and category types to icons const CATEGORY_ICON_MAP: Record = { @@ -67,7 +66,7 @@ const EXPORT_TYPE_TO_ID: Record = { MatSlideToggleModule, MatCheckboxModule, TranslatePipe, - MatRadioButton, + MatRadioModule, ], templateUrl: './export-modal.component.html', styleUrls: ['./export-modal.component.scss', '../../../../styles/_shared-modal.scss'], @@ -77,7 +76,6 @@ export class ExportModalComponent implements OnInit { private readonly backupRestoreService = inject(BackupRestoreService); private readonly remoteManagementService = inject(RemoteManagementService); private readonly fileSystemService = inject(FileSystemService); - private readonly destroyRef = inject(DestroyRef); public readonly data = inject(MAT_DIALOG_DATA); diff --git a/src/app/features/modals/settings/keyboard-shortcuts-modal/keyboard-shortcuts-modal.component.html b/src/app/features/modals/settings/keyboard-shortcuts-modal/keyboard-shortcuts-modal.component.html index e1648bcef..06b639ae4 100644 --- a/src/app/features/modals/settings/keyboard-shortcuts-modal/keyboard-shortcuts-modal.component.html +++ b/src/app/features/modals/settings/keyboard-shortcuts-modal/keyboard-shortcuts-modal.component.html @@ -19,6 +19,7 @@ { - let component: KeyboardShortcutsModalComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [KeyboardShortcutsModalComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(KeyboardShortcutsModalComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/modals/settings/keyboard-shortcuts-modal/keyboard-shortcuts-modal.component.ts b/src/app/features/modals/settings/keyboard-shortcuts-modal/keyboard-shortcuts-modal.component.ts index 2bcfcc32c..088604028 100755 --- a/src/app/features/modals/settings/keyboard-shortcuts-modal/keyboard-shortcuts-modal.component.ts +++ b/src/app/features/modals/settings/keyboard-shortcuts-modal/keyboard-shortcuts-modal.component.ts @@ -1,7 +1,6 @@ import { ChangeDetectionStrategy, Component, - HostListener, computed, inject, signal, @@ -110,6 +109,11 @@ export class KeyboardShortcutsModalComponent { description: 'shortcuts.actions.toggleBrowser', category: 'shortcuts.categories.fileBrowser', }, + { + keys: 'Ctrl + Alt + F', + description: 'shortcuts.actions.openFlowOverlay', + category: 'shortcuts.categories.application', + }, { keys: 'Escape', description: 'shortcuts.actions.closeDialog', @@ -262,16 +266,6 @@ export class KeyboardShortcutsModalComponent { } } - // Escape-to-close handled by EscapeCloseDirective (hostDirective). - - @HostListener('document:keydown.control.f') - onF3(): void { - this.toggleSearch(); - if (this.searchVisible()) { - this.searchContainer()?.focus(); - } - } - toggleSearch(): void { this.searchVisible.update(v => !v); if (!this.searchVisible()) { diff --git a/src/app/features/modals/settings/logs-modal/logs-modal.component.spec.ts b/src/app/features/modals/settings/logs-modal/logs-modal.component.spec.ts deleted file mode 100644 index edfa65ce2..000000000 --- a/src/app/features/modals/settings/logs-modal/logs-modal.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { LogsModalComponent } from './logs-modal.component'; - -describe('LogsModalComponent', () => { - let component: LogsModalComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [LogsModalComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(LogsModalComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/modals/settings/logs-modal/logs-modal.component.ts b/src/app/features/modals/settings/logs-modal/logs-modal.component.ts index 117f30513..b5593facb 100755 --- a/src/app/features/modals/settings/logs-modal/logs-modal.component.ts +++ b/src/app/features/modals/settings/logs-modal/logs-modal.component.ts @@ -2,10 +2,9 @@ import { ChangeDetectionStrategy, Component, ElementRef, - Injector, OnInit, - afterNextRender, computed, + effect, inject, signal, viewChild, @@ -57,7 +56,6 @@ export class LogsModalComponent implements OnInit { private readonly loggingService = inject(LoggingService); private readonly backendTranslation = inject(BackendTranslationService); private readonly translate = inject(TranslateService); - private readonly injector = inject(Injector); public readonly logLevels = LOG_LEVELS; @@ -117,6 +115,14 @@ export class LogsModalComponent implements OnInit { readonly terminalLogArea = viewChild>('terminalLogArea'); + constructor() { + effect(() => { + if (this.logs().length > 0) { + requestAnimationFrame(() => this.scrollToBottom()); + } + }); + } + ngOnInit(): void { this.loadLogs(); } @@ -128,7 +134,6 @@ export class LogsModalComponent implements OnInit { this.data.remoteName )) as unknown as RemoteLogEntry[]; this.logs.set(fetchedLogs); - afterNextRender(() => this.scrollToBottom(), { injector: this.injector }); } catch { const message = this.translate.instant('modals.logs.fetchError'); this.snackBar.open(message, undefined, { duration: 3000 }); diff --git a/src/app/features/modals/settings/preferences-modal/preferences-modal.component.html b/src/app/features/modals/settings/preferences-modal/preferences-modal.component.html index 0f2cd2f9c..1e4f500bb 100755 --- a/src/app/features/modals/settings/preferences-modal/preferences-modal.component.html +++ b/src/app/features/modals/settings/preferences-modal/preferences-modal.component.html @@ -42,6 +42,7 @@ } @else {
-
- @if (hasSearchResults()) { - - @if (searchResults().length > 0) { - @for (vm of searchResultViewModels(); track vm.category + '.' + vm.key) { -
- -
- } - } @else { -
- -

- {{ 'modals.preferences.noSettingsFound' | translate }} - "{{ searchQuery() }}" -

-
- {{ 'modals.preferences.trySearching' | translate }} -
- @for (suggestion of searchSuggestions(); track suggestion) { - - } -
-
+ @if (hasSearchResults()) { + + @if (searchResults().length > 0) { + @for (vm of searchResultViewModels(); track vm.category + '.' + vm.key) { +
+
} } @else { - @if (selectedTabKey(); as activeCategory) { - @for (vm of selectedTabViewModels(); track vm.key) { -
- -
- } - @if (isGeneralTab()) { -
- +
+ +

+ {{ 'modals.preferences.noSettingsFound' | translate }} + "{{ searchQuery() }}" +

+
+ {{ 'modals.preferences.trySearching' | translate }} +
+ @for (suggestion of searchSuggestions(); track suggestion) { + + }
- } +
+
+ } + } @else { + @if (selectedTabKey(); as activeCategory) { + @for (vm of selectedTabViewModels(); track vm.key) { +
+ +
+ } + @if (isGeneralTab()) { +
+ +
} } -
+ } @if (hasPendingRestartChanges()) {
@@ -215,63 +214,75 @@

{{ 'modals.preferences.readyToRestartHeader' | translate }}

} - - - @if (setting.meta; as meta) { -
-
- {{ setting.label }} -

{{ setting.description }}

+ + +
+
+
+ {{ setting.label }} @if (showCategory) { {{ setting.categoryDisplayName }} }
- @if (setting.isModified) { - + @if (setting.description) { +

{{ setting.description }}

}
+ @if (setting.isModified) { + + } +
+
-
-
-
+ + + @if (setting.meta; as meta) { + @if (meta.value_type === 'bool' || meta.value_type === 'int') { + +
+ @if (meta.value_type === 'bool') { + + } @else { + + } +
+ } @else { +
+ +
@switch (meta.value_type) { - @case ('bool') { - - } - @case ('int') { -
- - @if (setting.isInvalid) { - {{ - setting.validationMessage - }} - } -
- } @case ('string') { {{ 'modals.preferences.readyToRestartHeader' | translate }} > } @case ('select') { - + {{ 'modals.preferences.readyToRestartHeader' | translate }} } @case ('string[]') { -
-
- @for (item of setting.arrayItemViewModels; track item.control) { -
- - - - @if (item.isInvalid) { - {{ item.validationMessage }} - } - -
- } -
- @if (setting.isInvalid) { - {{ setting.validationMessage }} +
+ @for (item of setting.arrayItemViewModels; track item.control) { +
+ + + + @if (item.isInvalid) { + {{ item.validationMessage }} + } + +
} + @if (setting.isInvalid) { + {{ setting.validationMessage }} + }
} @@ -372,13 +381,13 @@

{{ 'modals.preferences.readyToRestartHeader' | translate }}

}
-
+ } }
- + {{ 'modals.preferences.readyToRestartHeader' | translate }} - + { - let component: PreferencesModalComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PreferencesModalComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(PreferencesModalComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/modals/settings/preferences-modal/preferences-modal.component.ts b/src/app/features/modals/settings/preferences-modal/preferences-modal.component.ts index a61cfc1d5..f654b368d 100755 --- a/src/app/features/modals/settings/preferences-modal/preferences-modal.component.ts +++ b/src/app/features/modals/settings/preferences-modal/preferences-modal.component.ts @@ -60,7 +60,6 @@ interface SettingViewModel { key: string; meta: SettingMetadata | undefined; control: FormControl | FormArray | undefined; - isRowLayout: boolean; label: string; description: string; categoryDisplayName: string; @@ -88,7 +87,11 @@ interface SettingViewModel { NumberInputComponent, ], templateUrl: './preferences-modal.component.html', - styleUrls: ['./preferences-modal.component.scss', '../../../../styles/_shared-modal.scss'], + styleUrls: [ + './preferences-modal.component.scss', + '../../../../styles/_shared-modal.scss', + '../../../../shared/components/setting-control/setting-control.component.scss', + ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class PreferencesModalComponent { @@ -519,12 +522,6 @@ export class PreferencesModalComponent { return this.enrichedOptions()[`${category}.${key}`]; } - /** Returns true for value types that render as a horizontal row (control beside label). */ - isRowLayout(category: string, key: string): boolean { - const type = this.getMetadata(category, key)?.value_type; - return type === 'bool' || type === 'int'; - } - isControlInvalid(category: string, key: string, index?: number): boolean { const ctrl = index !== undefined @@ -589,8 +586,6 @@ export class PreferencesModalComponent { const meta = this.enrichedOptions()[`${category}.${key}`]; const control = this.settingsForm.get(category)?.get(key) as FormControl | FormArray | undefined; - const type = meta?.value_type; - const isRowLayout = type === 'bool' || type === 'int'; const arrayItemViewModels: ArrayItemViewModel[] = []; if (control instanceof FormArray) { @@ -620,7 +615,6 @@ export class PreferencesModalComponent { key, meta, control, - isRowLayout, label: meta ? this.getSettingLabel(category, key, meta) : key, description: meta ? this.getSettingDescription(category, key, meta) : '', categoryDisplayName: this.getCategoryDisplayName(category), @@ -654,14 +648,6 @@ export class PreferencesModalComponent { this.searchQuery.set(searchText.toLowerCase()); } - // Escape-to-close handled by EscapeCloseDirective (hostDirective). - - @HostListener('document:keydown.control.f', ['$event']) - handleCtrlF(event: Event): void { - event.preventDefault(); - this.toggleSearch(); - } - toggleSearch(): void { this.searchVisible.update(v => !v); if (!this.searchVisible()) this.onSearchTextChange(''); diff --git a/src/app/features/modals/settings/rclone-flags-modal/rclone-flags-modal.component.html b/src/app/features/modals/settings/rclone-flags-modal/rclone-flags-modal.component.html index ddd01d281..dad883bc4 100755 --- a/src/app/features/modals/settings/rclone-flags-modal/rclone-flags-modal.component.html +++ b/src/app/features/modals/settings/rclone-flags-modal/rclone-flags-modal.component.html @@ -182,7 +182,7 @@ @@ -236,6 +236,7 @@ [fieldDefs]="categoryOptions()" [searchQuery]="searchQuery()" [keyPrefix]="editorKeyPrefix()" + [preferFieldName]="true" /> } @else { diff --git a/src/app/features/modals/settings/rclone-flags-modal/rclone-flags-modal.component.spec.ts b/src/app/features/modals/settings/rclone-flags-modal/rclone-flags-modal.component.spec.ts deleted file mode 100644 index e802e43ee..000000000 --- a/src/app/features/modals/settings/rclone-flags-modal/rclone-flags-modal.component.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { RcloneFlagsModalComponent } from './rclone-flags-modal.component'; - -describe('RcloneFlagsModalComponent', () => { - let component: RcloneFlagsModalComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RcloneFlagsModalComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(RcloneFlagsModalComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/modals/settings/restore-preview-modal/restore-preview-modal.component.html b/src/app/features/modals/settings/restore-preview-modal/restore-preview-modal.component.html index 5fa318913..56522e629 100755 --- a/src/app/features/modals/settings/restore-preview-modal/restore-preview-modal.component.html +++ b/src/app/features/modals/settings/restore-preview-modal/restore-preview-modal.component.html @@ -85,21 +85,6 @@

{{ 'backup.restore.info' | translate }}

- - @if (isLegacy) { -
- -
- } - @if (hasContents) {
@@ -213,7 +198,7 @@

{{ 'backup.restore.note' | translate }}

} - @if (profiles.length > 0 && !isLegacy) { + @if (profiles.length > 0) {
@@ -276,7 +261,7 @@

{{ 'backup.restore.passwordRequired' | translate }}

} - + {{ 'backup.restore.passwordRequired' | translate }} { - let component: RestorePreviewModalComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RestorePreviewModalComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(RestorePreviewModalComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/modals/settings/restore-preview-modal/restore-preview-modal.component.ts b/src/app/features/modals/settings/restore-preview-modal/restore-preview-modal.component.ts index db40ff8a0..7c857b06d 100755 --- a/src/app/features/modals/settings/restore-preview-modal/restore-preview-modal.component.ts +++ b/src/app/features/modals/settings/restore-preview-modal/restore-preview-modal.component.ts @@ -41,7 +41,6 @@ export class RestorePreviewModalComponent { // Static derived data — plain properties since `analysis` is not a signal readonly isEncrypted = this.analysis.isEncrypted; - readonly isLegacy = this.analysis.isLegacy === true; readonly hasContents = !!this.analysis.contents; readonly hasUserNote = !!this.analysis.userNote; readonly profiles = this.analysis.contents?.profiles ?? []; diff --git a/src/app/features/onboarding/onboarding.component.html b/src/app/features/onboarding/onboarding.component.html index 8428accc7..deaed1512 100755 --- a/src/app/features/onboarding/onboarding.component.html +++ b/src/app/features/onboarding/onboarding.component.html @@ -4,7 +4,7 @@ animate.enter="onboarding-entrance-enter" animate.leave="onboarding-entrance-leave" > - @if (animationState() === 'loading') { + @if (isLoading()) { } -
-
-
- @for (card of cards(); track $index; let i = $index) { -
+
+
+ @for (card of cards(); track card.key; let i = $index) { +
+ [disabled]="!canNavigateToCard(i)" + (click)="goToCard(i)" + [attr.aria-label]="card.title | translate" + [attr.title]="card.title | translate" + > + + }
-
-
-
- {{ currentCard().title | translate }} icon -
-
-

{{ currentCard().title | translate }}

-

{{ currentCard().content | translate }}

-
+
+
+ @for (card of cards(); track card.key; let i = $index) { +
+
+
+ {{ card.title | translate }} icon +
+
+

{{ card.title | translate }}

+

{{ card.content | translate }}

+
+ + @switch (card.key) { + @case ('selectConfig') { + + } + @case ('passwordRequired') { + + } + @case ('installRclone') { + - @switch (currentCard().key) { - @case ('selectConfig') { - - } - @case ('passwordRequired') { - - } - @case ('installRclone') { - - } + @if (installing()) { + + } + } + @case ('installPlugin') { + @if (downloadingPlugin()) { + + } + } + @case ('selectMainUi') { +
+ @for (option of uiOptions; track option.value) { + + } +
+ } + } +
+
}
@@ -81,7 +147,7 @@

{{ currentCard().title | translate }}

@if (currentCardIndex() > 0) { } @else { } - @switch (currentAction()) { - @case ('install-rclone') { - - } - @case ('install-plugin') { - - } - @case ('config-next') { - - } - @case ('unlock') { - - } - @case ('finish') { - + @if (installing() && currentAction() === 'install-rclone') { + + } @else if (downloadingPlugin() && currentAction() === 'install-plugin') { + + } + + @let btn = primaryButton(); + + @if (btn.labelKey) { + {{ btn.labelKey | translate }} } - } +
diff --git a/src/app/features/onboarding/onboarding.component.scss b/src/app/features/onboarding/onboarding.component.scss old mode 100644 new mode 100755 index 129b834fb..f27f0ed37 --- a/src/app/features/onboarding/onboarding.component.scss +++ b/src/app/features/onboarding/onboarding.component.scss @@ -15,53 +15,115 @@ flex-direction: column; width: 100%; max-width: 600px; - max-height: 80vh; background: var(--window-bg-color); border-radius: var(--border-radius); box-shadow: var(--box-shadow); overflow: hidden; opacity: 0; transform: translateY(20px); + transition: + opacity 0.5s ease, + transform 0.5s cubic-bezier(0.16, 1, 0.3, 1); + + &.onboarding-state-enter { + opacity: 1; + transform: translateY(0); + } } } // Header Section .onboarding-header { padding: 24px; - text-align: center; + display: flex; + justify-content: center; border-bottom: 1px solid rgba(var(--window-fg-color-rgb), 0.06); .page-indicators { display: flex; justify-content: center; - gap: var(--space-sm); + align-items: center; + gap: var(--space-xs); - .indicator { - width: var(--indicator-size); - height: var(--indicator-size); - border-radius: 50%; - background: rgba(var(--dim-color-rgb), 0.3); - transition: - background-color 0.3s ease, - transform 0.3s ease; + .indicator-btn { + background: transparent; + border: none; + padding: 6px; + margin: 0; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + outline: none; + + &:disabled { + cursor: not-allowed; + opacity: 0.35; + } + + .indicator-pill { + width: var(--indicator-size); + height: var(--indicator-size); + border-radius: 999px; + background: rgba(var(--dim-color-rgb), 0.3); + transition: + width 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), + background-color 0.35s ease, + box-shadow 0.35s ease; + } + + &:hover:not(.active):not(:disabled) .indicator-pill { + background: var(--dim-color); + } - &.active { + &.active .indicator-pill { + width: 28px; background: var(--accent-color); - transform: scale(1.25); + box-shadow: 0 0 12px rgba(var(--accent-color-rgb), 0.45); } - &.completed { - background: rgba(var(--primary-color-rgb), 0.6); + &.completed .indicator-pill { + background: var(--primary-color); } } } } -// Main Content Area -.content-area { - flex: 1; - padding: 24px; - overflow: auto; +// Main Content Slider Viewport +.content-viewport { + overflow: hidden; + position: relative; + width: 100%; + transition: height 0.45s cubic-bezier(0.16, 1, 0.3, 1); + + .slide-track { + display: flex; + width: 100%; + align-items: flex-start; + will-change: transform; + transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1); + } + + .slide-page { + min-width: 100%; + width: 100%; + box-sizing: border-box; + overflow: hidden; + padding: 24px; + display: flex; + flex-direction: column; + justify-content: center; + opacity: 0.4; + transform: scale(0.96); + transition: + opacity 0.45s ease, + transform 0.45s cubic-bezier(0.16, 1, 0.3, 1); + + &.active { + opacity: 1; + transform: scale(1); + } + } .card-content { text-align: center; @@ -73,23 +135,39 @@ width: var(--icon-size); height: var(--icon-size); margin-bottom: var(--space-xl); - border-radius: 20px; + border-radius: 24px; background: linear-gradient( 135deg, - rgba(var(--primary-color-rgb), 0.1) 0%, + rgba(var(--primary-color-rgb), 0.12) 0%, rgba(var(--accent-color-rgb), 0.08) 100% ); transition: - transform 0.3s ease, - box-shadow 0.3s ease; + transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 0.35s ease; position: relative; + // Pulse aura glow effect + &::after { + content: ""; + position: absolute; + inset: -6px; + border-radius: 28px; + background: radial-gradient( + circle, + rgba(var(--accent-color-rgb), 0.25) 0%, + transparent 70% + ); + opacity: 0; + transition: opacity 0.4s ease; + z-index: -1; + } + // The gradient border &::before { content: ""; position: absolute; inset: 0; - border-radius: 20px; + border-radius: 24px; padding: 2px; background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-color) 100%); mask: @@ -102,11 +180,20 @@ img { width: 64px; height: 64px; + transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); } &:hover { - transform: scale(1.02); - box-shadow: 0 8px 24px rgba(var(--primary-color-rgb), 0.15); + transform: scale(1.04); + box-shadow: 0 10px 28px rgba(var(--primary-color-rgb), 0.2); + + &::after { + opacity: 1; + } + + img { + transform: scale(1.06); + } } } @@ -116,7 +203,7 @@ .card-title { font-size: 1.75rem; - font-weight: 500; + font-weight: 600; color: var(--window-fg-color); margin: 0 0 var(--space-md) 0; letter-spacing: -0.025em; @@ -136,6 +223,131 @@ display: block; margin-top: 24px; } + + .ui-selection-grid { + display: flex; + flex-direction: column; + gap: 12px; + text-align: left; + } + + .ui-option-card { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 20px; + text-align: left; + border-radius: 16px; + background: rgba(var(--window-fg-color-rgb), 0.02); + cursor: pointer; + transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); + + &:hover { + background: rgba(var(--primary-color-rgb), 0.04); + } + + &:focus-visible { + box-shadow: 0 0 0 2px var(--primary-color); + } + + &.selected { + background: rgba(var(--primary-color-rgb), 0.08); + box-shadow: 0 6px 20px rgba(var(--primary-color-rgb), 0.14); + + .option-icon-wrapper { + transform: scale(1.06); + } + } + + .option-header { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-width: 80px; + + .option-icon-wrapper { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 12px; + background: rgba(var(--primary-color-rgb), 0.12); + color: var(--primary-color); + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + + &.accent { + background: rgba(var(--accent-color-rgb), 0.12); + color: var(--accent-color); + } + + &.purple { + background: rgba(var(--purple-rgb), 0.14); + color: var(--purple); + } + } + + .option-badge { + font-size: 0.7rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + background: rgba(var(--primary-color-rgb), 0.15); + color: var(--primary-color); + letter-spacing: 0.02em; + + &.accent { + background: rgba(var(--accent-color-rgb), 0.15); + color: var(--accent-color); + } + + &.purple { + background: rgba(var(--purple-rgb), 0.18); + color: var(--purple); + } + } + } + + .option-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + + .option-title { + font-size: 1.05rem; + font-weight: 600; + color: var(--window-fg-color); + margin: 0; + line-height: 1.3; + } + + .option-desc { + font-size: 0.85rem; + color: var(--dim-color); + margin: 0; + line-height: 1.45; + } + } + + .option-check { + display: flex; + align-items: center; + justify-content: center; + color: var(--primary-color); + opacity: 0; + transform: scale(0.5); + transition: + opacity 0.3s ease, + transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); + } + + &.selected .option-check { + opacity: 1; + transform: scale(1); + } + } } } @@ -144,7 +356,7 @@ display: flex; justify-content: space-between; align-items: center; - padding: 24px; + padding: var(--space-sm) var(--space-md); border-top: 1px solid rgba(var(--window-fg-color-rgb), 0.06); background: rgba(var(--sidebar-bg-color-rgb), 0.3); @@ -156,13 +368,82 @@ // Responsive Design @media (max-width: 768px) { - .onboarding-container { - padding: var(--space-md); - } - - .content-area { + .content-viewport .slide-page { .text-content .card-title { font-size: 1.5rem; } } } + +@media (max-width: 600px) { + .onboarding-container { + padding: 0; + + .card-container { + width: 100vw; + height: 100vh; + max-width: 100vw; + max-height: 100vh; + border-radius: 0; + box-shadow: none; + } + + .content-viewport { + height: 100% !important; + flex: 1; + + .slide-track { + height: 100%; + align-items: stretch; + } + + .slide-page { + overflow-y: auto; + } + } + .content-viewport .slide-page { + .ui-option-card { + padding: 12px 14px; + gap: 12px; + position: relative; + flex-direction: column; + + .option-header { + min-width: 64px; + + .option-icon-wrapper { + width: 38px; + height: 38px; + + mat-icon { + width: 20px; + height: 20px; + font-size: 20px; + } + } + + .option-badge { + font-size: 0.65rem; + padding: 1px 6px; + } + } + + .option-body { + text-align: center; + .option-title { + font-size: 0.95rem; + } + .option-desc { + font-size: 0.8rem; + } + } + + .option-check { + position: absolute; + top: 12px; + right: 12px; + } + } + } + } +} diff --git a/src/app/features/onboarding/onboarding.component.spec.ts b/src/app/features/onboarding/onboarding.component.spec.ts deleted file mode 100644 index 4a44b1560..000000000 --- a/src/app/features/onboarding/onboarding.component.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { OnboardingComponent } from './onboarding.component'; - -describe('OnboardingComponent', () => { - let component: OnboardingComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [OnboardingComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(OnboardingComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should have initial loading state', () => { - expect(component.animationState()).toBe('loading'); - }); -}); diff --git a/src/app/features/onboarding/onboarding.component.ts b/src/app/features/onboarding/onboarding.component.ts index d76148b44..751114e54 100755 --- a/src/app/features/onboarding/onboarding.component.ts +++ b/src/app/features/onboarding/onboarding.component.ts @@ -6,7 +6,9 @@ import { computed, ChangeDetectionStrategy, output, - OnInit, + viewChildren, + ElementRef, + afterRenderEffect, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; @@ -15,6 +17,7 @@ import { MatIconModule } from '@angular/material/icon'; import { LoadingOverlayComponent } from '../../shared/components/loading-overlay/loading-overlay.component'; import { InstallationOptionsComponent } from '../../shared/components/installation-options/installation-options.component'; import { PasswordManagerComponent } from '../../shared/components/password-manager/password-manager.component'; +import { ProvisionProgressComponent } from '../../shared/components/provision-progress/provision-progress.component'; import { TranslatePipe } from '@ngx-translate/core'; import { InstallationService } from 'src/app/services/settings/installation.service'; @@ -23,14 +26,50 @@ import { AppSettingsService } from 'src/app/services/settings/app-settings.servi import { RclonePasswordService } from 'src/app/services/security/rclone-password.service'; import { SystemHealthService } from 'src/app/services/infrastructure/maintenance/system-health.service'; import { BackupRestoreUiService } from 'src/app/services/settings/backup-restore-ui.service'; +import { UiStateService } from 'src/app/services/ui/state/ui-state.service'; import { BackendService } from '../../services/infrastructure/system/backend.service'; import { - InstallationOptionsData, - InstallationTabOption, - OnboardingCard, - OnboardingAction, + type BinaryStatus, + type InstallationOptionsData, + type OnboardingAction, + type OnboardingCard, + type OnboardingCardKey, + type MainView, + DEFAULT_ONBOARDING_IMAGE, + DEFAULT_INSTALLATION_DATA, + RCLONE_INSTALL_TAB_OPTIONS, + ONBOARDING_CONFIG_TAB_OPTIONS, } from '@app/types'; +/** Translation keys for the install button when "use existing binary" mode is active. */ +const EXISTING_BINARY_BUTTON_LABELS: Readonly> = Object.freeze({ + untested: 'onboarding.installButton.testBinary', + testing: 'onboarding.installButton.testingBinary', + valid: 'onboarding.installButton.useBinary', + invalid: 'onboarding.installButton.invalidBinary', +}); + +interface UiOption { + value: MainView; + icon: string; + colorClass: '' | 'accent' | 'purple'; + badgeKey: string; + titleKey: string; + descKey: string; +} + +/** View model for the footer's primary action button. */ +interface PrimaryButton { + /** Translation key for the label, or null to render icon-only. */ + labelKey: string | null; + /** Icon name, or null to render label-only. */ + icon: string | null; + disabled: boolean; + /** Translation key for the tooltip, or null for no tooltip. */ + titleKey: string | null; + action: () => void; +} + @Component({ selector: 'app-onboarding', imports: [ @@ -39,239 +78,389 @@ import { LoadingOverlayComponent, InstallationOptionsComponent, PasswordManagerComponent, + ProvisionProgressComponent, TranslatePipe, ], templateUrl: './onboarding.component.html', styleUrls: ['./onboarding.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class OnboardingComponent implements OnInit { +export class OnboardingComponent { completed = output(); // ─── Services ─────────────────────────────────────────────────────────────── - private readonly installationService = inject(InstallationService); private readonly appSettingsService = inject(AppSettingsService); private readonly eventListenersService = inject(EventListenersService); private readonly rclonePasswordService = inject(RclonePasswordService); private readonly backendService = inject(BackendService); - readonly systemHealth = inject(SystemHealthService); private readonly backupRestoreUiService = inject(BackupRestoreUiService); + private readonly uiStateService = inject(UiStateService); + readonly systemHealth = inject(SystemHealthService); - // ─── State ────────────────────────────────────────────────────────────────── + readonly rcloneProgress = this.installationService.rcloneProgress; + readonly mountPluginProgress = this.installationService.mountPluginProgress; - readonly animationState = signal<'loading' | 'visible'>('loading'); + // ─── State ────────────────────────────────────────────────────────────────── readonly currentCardIndex = signal(0); + readonly viewportHeight = signal(null); + + private readonly isMobileViewport = signal( + typeof window !== 'undefined' && window.innerWidth <= 600 + ); + + private readonly slideEls = viewChildren>('slide'); + readonly installing = signal(false); readonly downloadingPlugin = signal(false); - readonly installationData = signal({ - installLocation: 'default', - customPath: '', - existingBinaryPath: '', - binaryTestResult: 'untested', - }); + readonly installationData = signal({ ...DEFAULT_INSTALLATION_DATA }); readonly installationValid = signal(true); - readonly configData = signal({ - installLocation: 'default', - customPath: '', - existingBinaryPath: '', - binaryTestResult: 'untested', - }); + readonly configData = signal({ ...DEFAULT_INSTALLATION_DATA }); readonly configValid = signal(true); readonly configPassword = signal(''); readonly passwordValidationError = signal(null); readonly isSubmittingPassword = signal(false); - // ─── Computed ─────────────────────────────────────────────────────────────── + readonly selectedMainUi = signal('main_menu'); + + // ─── Static configuration ─────────────────────────────────────────────────── + readonly defaultImage = DEFAULT_ONBOARDING_IMAGE; + readonly installTabOptions = RCLONE_INSTALL_TAB_OPTIONS; + readonly configTabOptions = ONBOARDING_CONFIG_TAB_OPTIONS; + + private static readonly CARD_ACTIONS: Partial> = { + installRclone: 'install-rclone', + installPlugin: 'install-plugin', + selectConfig: 'config-next', + passwordRequired: 'unlock', + ready: 'finish', + }; + + private static readonly ALL_CARD_KEYS: readonly OnboardingCardKey[] = [ + 'welcome', + 'features', + 'installRclone', + 'installPlugin', + 'selectConfig', + 'passwordRequired', + 'selectMainUi', + 'ready', + ]; - private readonly baseCards: OnboardingCard[] = [ + readonly uiOptions: readonly UiOption[] = [ { - key: 'welcome', - image: '../assets/rclone-manager.svg', - title: 'onboarding.cards.welcome.title', - content: 'onboarding.cards.welcome.content', + value: 'main_menu', + icon: 'desktop', + colorClass: '', + badgeKey: 'onboarding.uiOptions.main_menu.badge', + titleKey: 'onboarding.uiOptions.main_menu.title', + descKey: 'onboarding.uiOptions.main_menu.description', }, { - key: 'features', - image: '../assets/rclone-manager.svg', - title: 'onboarding.cards.features.title', - content: 'onboarding.cards.features.content', + value: 'nautilus', + icon: 'folder-open', + colorClass: 'accent', + badgeKey: 'onboarding.uiOptions.nautilus.badge', + titleKey: 'onboarding.uiOptions.nautilus.title', + descKey: 'onboarding.uiOptions.nautilus.description', + }, + { + value: 'flow', + icon: 'bolt', + colorClass: 'purple', + badgeKey: 'onboarding.uiOptions.flow.badge', + titleKey: 'onboarding.uiOptions.flow.title', + descKey: 'onboarding.uiOptions.flow.description', }, ]; - readonly cards = computed(() => { - const result: OnboardingCard[] = [...this.baseCards]; - - if (this.systemHealth.rcloneInstalled() === false) { - result.push({ - key: 'installRclone', - image: '../assets/rclone-manager.svg', - title: 'onboarding.cards.installRclone.title', - content: 'onboarding.cards.installRclone.content', - }); - } - - if (this.systemHealth.mountPluginInstalled() === false) { - result.push({ - key: 'installPlugin', - image: '../assets/rclone-manager.svg', - title: 'onboarding.cards.installPlugin.title', - content: 'onboarding.cards.installPlugin.content', - }); - } - - result.push({ - key: 'selectConfig', - image: '../assets/rclone-manager.svg', - title: 'onboarding.cards.selectConfig.title', - content: 'onboarding.cards.selectConfig.content', - }); - - if (this.systemHealth.passwordRequired()) { - result.push({ - key: 'passwordRequired', - image: '../assets/rclone-manager.svg', - title: 'onboarding.cards.passwordRequired.title', - content: 'onboarding.cards.passwordRequired.content', - }); - } + // ─── Computed ─────────────────────────────────────────────────────────────── - result.push({ - key: 'ready', - image: '../assets/rclone-manager.svg', - title: 'onboarding.cards.ready.title', - content: 'onboarding.cards.ready.content', - }); + readonly isLoading = computed(() => !this.systemHealth.isInitialized()); - return result; + readonly cards = computed(() => { + const sys = this.systemHealth; + return OnboardingComponent.ALL_CARD_KEYS.filter(key => { + switch (key) { + case 'installRclone': + return sys.rcloneInstalled() === false; + case 'installPlugin': + return sys.mountPluginInstalled() === false; + case 'passwordRequired': + return sys.passwordRequired(); + default: + return true; + } + }).map(key => ({ + key, + title: `onboarding.cards.${key}.title`, + content: `onboarding.cards.${key}.content`, + })); }); - readonly currentCard = computed(() => { + readonly currentCard = computed(() => { const cards = this.cards(); const index = Math.min(this.currentCardIndex(), cards.length - 1); return cards[Math.max(0, index)]; }); readonly currentAction = computed(() => { - const card = this.currentCard(); - if (card.key === 'installRclone' && !this.systemHealth.rcloneInstalled()) - return 'install-rclone'; - if (card.key === 'installPlugin' && !this.systemHealth.mountPluginInstalled()) - return 'install-plugin'; - if (card.key === 'selectConfig') return 'config-next'; - if (card.key === 'passwordRequired') return 'unlock'; - if (card.key === 'ready') return 'finish'; - return 'next'; + const key = this.currentCard()?.key; + return (key && OnboardingComponent.CARD_ACTIONS[key]) ?? 'next'; }); - readonly canInstall = computed(() => !this.installing() && this.installationValid()); - - readonly installButtonText = computed(() => { - const data = this.installationData(); + private readonly canInstall = computed(() => !this.installing() && this.installationValid()); + private readonly installButtonText = computed(() => { if (this.installing()) { - return data.installLocation === 'existing' + return this.installationData().installLocation === 'existing' ? 'onboarding.installButton.configuring' : 'onboarding.installButton.installing'; } + + const data = this.installationData(); + if (data.installLocation === 'custom' && !data.customPath.trim()) { return 'onboarding.installButton.selectPath'; } + if (data.installLocation === 'existing') { if (!data.existingBinaryPath.trim()) return 'onboarding.installButton.selectBinary'; - if (data.binaryTestResult === 'invalid') return 'onboarding.installButton.invalidBinary'; - if (data.binaryTestResult === 'testing') return 'onboarding.installButton.testingBinary'; - if (data.binaryTestResult === 'valid') return 'onboarding.installButton.useBinary'; - return 'onboarding.installButton.testBinary'; + return EXISTING_BINARY_BUTTON_LABELS[data.binaryTestResult]; } + return 'onboarding.installButton.install'; }); - // ─── Tab Options ──────────────────────────────────────────────────────────── + readonly primaryButton = computed(() => { + switch (this.currentAction()) { + case 'install-rclone': { + const canInstall = this.canInstall(); + return { + labelKey: this.installButtonText(), + icon: this.installing() ? 'spinner' : 'download', + disabled: !canInstall, + titleKey: !canInstall ? 'onboarding.validation.completeInstallation' : null, + action: (): void => { + if (canInstall) void this.installRclone(); + }, + }; + } + case 'install-plugin': { + const downloading = this.downloadingPlugin(); + return { + labelKey: downloading + ? 'onboarding.actions.installingPlugin' + : 'onboarding.actions.installPlugin', + icon: downloading ? 'spinner' : 'download', + disabled: downloading, + titleKey: null, + action: (): void => { + if (!downloading) void this.installMountPlugin(); + }, + }; + } + case 'config-next': { + const valid = this.configValid(); + return { + labelKey: 'common.next', + icon: 'right-arrow', + disabled: !valid, + titleKey: !valid ? 'onboarding.validation.selectConfig' : null, + action: (): void => { + if (valid) void this.onConfigNext(); + }, + }; + } + case 'unlock': { + const submitting = this.isSubmittingPassword(); + const canUnlock = !!this.configPassword() && !submitting; + return { + labelKey: submitting ? null : 'onboarding.actions.unlock', + icon: submitting ? 'spinner' : null, + disabled: !canUnlock, + titleKey: null, + action: (): void => { + if (canUnlock) void this.submitConfigPassword(); + }, + }; + } + case 'finish': + return { + labelKey: 'onboarding.actions.getStarted', + icon: 'check-circle', + disabled: false, + titleKey: null, + action: (): void => { + void this.completeOnboarding(); + }, + }; + case 'next': + default: + return { + labelKey: 'common.next', + icon: 'right-arrow', + disabled: false, + titleKey: null, + action: (): void => { + this.nextCard(); + }, + }; + } + }); - readonly onboardingTabOptions: InstallationTabOption[] = [ - { key: 'default', label: 'onboarding.options.recommended', icon: 'star' }, - { key: 'custom', label: 'onboarding.options.custom', icon: 'folder' }, - { key: 'existing', label: 'onboarding.options.existing', icon: 'file' }, - ]; + // ─── Constructor ──────────────────────────────────────────────────────────── constructor() { this.eventListenersService .listenToRcloneEngineReady() .pipe(takeUntilDestroyed()) .subscribe(() => this.passwordValidationError.set(null)); - } - // ─── Init ──────────────────────────────────────────────────────────────────── + afterRenderEffect(onCleanup => { + const slides = this.slideEls(); + const activeSlide = slides[this.currentCardIndex()]; + + if (this.isMobileViewport() || !activeSlide || typeof ResizeObserver === 'undefined') { + this.viewportHeight.set(null); + return; + } - async ngOnInit(): Promise { + const el = activeSlide.nativeElement; + const measure = (): void => { + const height = el.scrollHeight; + if (height > 0) this.viewportHeight.set(height); + }; + + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + onCleanup((): void => { + observer.disconnect(); + }); + }); + + void this.initialize(); + } + + private async initialize(): Promise { try { await this.systemHealth.runAllChecks(); + const defaultView = + await this.appSettingsService.getSettingValue('general.default_view'); + if (defaultView === 'nautilus' || defaultView === 'flow' || defaultView === 'main_menu') { + this.selectedMainUi.set(defaultView as MainView); + } } catch (error) { console.error('OnboardingComponent: System checks failed', error); - } finally { - this.animationState.set('visible'); } } - // ─── Keyboard Navigation ───────────────────────────────────────────────────── + // ─── Keyboard navigation ──────────────────────────────────────────────────── @HostListener('document:keydown', ['$event']) handleKeyboardEvent(event: KeyboardEvent): void { if (event.key === 'Enter') { - switch (this.currentAction()) { - case 'install-rclone': - if (this.canInstall()) this.installRclone(); - break; - case 'install-plugin': - if (!this.downloadingPlugin()) this.installMountPlugin(); - break; - case 'config-next': - if (this.configValid()) this.onConfigNext(); - break; - case 'unlock': - if (this.configPassword() && !this.isSubmittingPassword()) this.submitConfigPassword(); - break; - case 'finish': - this.completeOnboarding(); - break; - case 'next': - this.nextCard(); - break; - } + const btn = this.primaryButton(); + if (!btn.disabled) btn.action(); return; } - if (event.key === 'ArrowRight' && this.currentAction() === 'next') { - this.nextCard(); + if (!this.primaryButton().disabled) { + this.nextCard(); + } } else if (event.key === 'ArrowLeft' && this.currentCardIndex() > 0) { this.previousCard(); } } + @HostListener('window:resize') + onWindowResize(): void { + this.isMobileViewport.set(window.innerWidth <= 600); + } + // ─── Navigation ───────────────────────────────────────────────────────────── + canNavigateToCard(targetIndex: number): boolean { + const currentIndex = this.currentCardIndex(); + if (targetIndex <= currentIndex) { + return true; + } + + const cards = this.cards(); + if (targetIndex >= cards.length) { + return false; + } + + if (this.primaryButton().disabled) { + return false; + } + + for (let idx = currentIndex; idx < targetIndex; idx++) { + const card = cards[idx]; + if (!card) return false; + + if (idx > currentIndex && this.isCardKeyBlocked(card.key)) { + return false; + } + } + + return true; + } + + private isCardKeyBlocked(key: OnboardingCardKey): boolean { + switch (key) { + case 'installRclone': + return !this.systemHealth.rcloneInstalled(); + case 'installPlugin': + return !this.systemHealth.mountPluginInstalled(); + case 'selectConfig': + return !this.configValid(); + case 'passwordRequired': + return this.systemHealth.passwordRequired(); + default: + return false; + } + } + nextCard(): void { - if (this.currentCardIndex() < this.cards().length - 1) { - this.currentCardIndex.update(i => i + 1); + if (this.primaryButton().disabled) { + return; } + this.currentCardIndex.update(i => Math.min(i + 1, this.cards().length - 1)); } previousCard(): void { - if (this.currentCardIndex() > 0) { - this.currentCardIndex.update(i => i - 1); + this.currentCardIndex.update(i => Math.max(i - 1, 0)); + } + + goToCard(targetIndex: number): void { + if (!this.canNavigateToCard(targetIndex)) { + return; } + const clamped = Math.max(0, Math.min(targetIndex, this.cards().length - 1)); + this.currentCardIndex.set(clamped); + } + + selectMainUiOption(view: MainView): void { + this.selectedMainUi.set(view); } - completeOnboarding(): void { + async completeOnboarding(): Promise { + try { + await this.appSettingsService.saveSetting('general', 'default_view', this.selectedMainUi()); + this.uiStateService.setDefaultView(this.selectedMainUi()); + } catch (error) { + console.error('Error saving default view on completing onboarding:', error); + } this.completed.emit(); } - // ─── Installation ──────────────────────────────────────────────────────────── + // ─── Installation / config / password actions ───────────────────────────── async installRclone(): Promise { this.installing.set(true); @@ -291,6 +480,11 @@ export class OnboardingComponent implements OnInit { } } + async cancelInstallRclone(): Promise { + await this.installationService.cancelRcloneInstall(); + this.installing.set(false); + } + async installMountPlugin(): Promise { this.downloadingPlugin.set(true); try { @@ -303,40 +497,16 @@ export class OnboardingComponent implements OnInit { } } - // ─── Installation Options Callbacks ────────────────────────────────────────── - - onInstallationOptionsChange(data: InstallationOptionsData): void { - this.installationData.set(data); - } - - onInstallationValidChange(valid: boolean): void { - this.installationValid.set(valid); + async cancelInstallMountPlugin(): Promise { + await this.installationService.cancelMountPluginInstall(); + this.downloadingPlugin.set(false); } - // ─── Config Selection ───────────────────────────────────────────────────────── - async onConfigNext(): Promise { try { const data = this.configData(); if (data.installLocation === 'custom' && data.customPath) { - let localBackend = this.backendService.backends().find(b => b.name === 'Local'); - if (!localBackend) { - await this.backendService.loadBackends(); - localBackend = this.backendService.backends().find(b => b.name === 'Local'); - } - if (localBackend) { - await this.backendService.updateBackend({ - name: 'Local', - host: localBackend.host, - oauthHost: localBackend.oauthHost, - port: localBackend.port, - isLocal: true, - username: localBackend.username, - password: localBackend.password, - configPath: data.customPath, - oauthPort: localBackend.oauthPort, - }); - } + await this.backendService.updateLocalBackendConfigPath(data.customPath); } await this.systemHealth.checkConfigEncryption(); } catch (error) { @@ -345,24 +515,15 @@ export class OnboardingComponent implements OnInit { this.nextCard(); } - onConfigOptionsChange(data: InstallationOptionsData): void { - this.configData.set(data); - } - - onConfigValidChange(valid: boolean): void { - this.configValid.set(valid); - } - - // ─── Password ──────────────────────────────────────────────────────────────── - async submitConfigPassword(): Promise { if (!this.configPassword() || this.isSubmittingPassword()) return; this.isSubmittingPassword.set(true); try { - await this.rclonePasswordService.validatePassword(this.configPassword()); - await this.rclonePasswordService.setConfigPasswordEnv(this.configPassword()); - await this.rclonePasswordService.storePassword(this.configPassword()); + const password = this.configPassword(); + await this.rclonePasswordService.validatePassword(password); + await this.rclonePasswordService.setConfigPasswordEnv(password); + await this.rclonePasswordService.storePassword(password); this.systemHealth.markPasswordUnlocked(); this.passwordValidationError.set(null); this.nextCard(); diff --git a/src/app/file-browser/file-viewer/file-viewer-modal.component.html b/src/app/file-browser/file-viewer/file-viewer-modal.component.html index 57bdeb8ac..8c054469d 100755 --- a/src/app/file-browser/file-viewer/file-viewer-modal.component.html +++ b/src/app/file-browser/file-viewer/file-viewer-modal.component.html @@ -13,10 +13,20 @@ @if (isMarkdownFile() && currentFileType() === 'text') { diff --git a/src/app/file-browser/file-viewer/file-viewer-modal.component.ts b/src/app/file-browser/file-viewer/file-viewer-modal.component.ts index d95fca201..5a2f5ed33 100755 --- a/src/app/file-browser/file-viewer/file-viewer-modal.component.ts +++ b/src/app/file-browser/file-viewer/file-viewer-modal.component.ts @@ -11,8 +11,6 @@ import { ElementRef, DestroyRef, ChangeDetectionStrategy, - Injector, - afterNextRender, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -27,7 +25,7 @@ import { marked } from 'marked'; // CodeMirror Imports import { EditorView, basicSetup } from 'codemirror'; -import { EditorState } from '@codemirror/state'; +import { EditorState, Extension, Compartment } from '@codemirror/state'; import { keymap } from '@codemirror/view'; import { StreamLanguage } from '@codemirror/language'; @@ -41,7 +39,7 @@ import { FileViewerService } from 'src/app/services/ui/file-viewer.service'; import { IconService } from 'src/app/services/ui/icon.service'; import { NotificationService } from 'src/app/services/ui/notification.service'; import { FormatFileSizePipe } from '@app/pipes'; -import { Entry, FilePickerResult } from '@app/types'; +import { Entry, FilePickerResult, ArchiveListItem } from '@app/types'; import { FormsModule } from '@angular/forms'; import { @@ -78,7 +76,6 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { private readonly pathService = inject(PathService); private readonly jobManagementService = inject(JobManagementService); private readonly destroyRef = inject(DestroyRef); - private readonly injector = inject(Injector); private readonly readJobGroup = `ui/file-viewer/${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; public currentUrl = signal(''); @@ -100,6 +97,13 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { fileCategory = computed(() => this.iconService.getFileTypeCategory(this.currentItem())); currentFileType = signal('text'); isHeadless = computed(() => isHeadlessMode()); + + /** Normalized filesystem name for rclone API calls. */ + private get fsName(): string { + return this.data.isLocal + ? this.data.remoteName + : this.pathService.normalizeRemoteForRclone(this.data.remoteName); + } isMobile = computed(() => isMobile()); isDownloadVisible = computed(() => { return ( @@ -111,9 +115,7 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { isDownloading = signal(false); isOpeningNative = signal(false); isLoadingCover = signal(false); - parsedArchiveItems = signal< - { size: number; date: string; time: string; path: string; isDir: boolean }[] - >([]); + parsedArchiveItems = signal([]); isExtracting = signal(false); archiveError = signal(null); errorMessage = signal(null); @@ -135,6 +137,8 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { readonly editorContainer = viewChild>('editorContainer'); private editorView: EditorView | null = null; + private readonly readOnlyCompartment = new Compartment(); + private readonly editableCompartment = new Compartment(); // Cancel pending requests when component updates or destroys private cancelCurrentRequest$ = new Subject(); @@ -149,6 +153,10 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.cancelCurrentRequest$.complete(); void this.stopReadJobs(); + if (this.editorView) { + this.editorView.destroy(); + this.editorView = null; + } this.fileViewerService.setActiveFileName(null); } @@ -167,16 +175,7 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { this.editContent.set(this.textContent()); this.isEditing.set(true); this.showMarkdownPreview.set(false); - - // Re-initialize as editable - afterNextRender( - () => { - void this.initEditor(false, this.editContent()).catch(e => - console.error('initEditor failed', e) - ); - }, - { injector: this.injector } - ); + this.setEditorReadOnly(false, this.editContent()); } /** @@ -185,16 +184,28 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { cancelEditing(): void { this.isEditing.set(false); this.editContent.set(''); + this.setEditorReadOnly(true, this.textContent()); + } - // Re-initialize as read-only with original content - afterNextRender( - () => { - void this.initEditor(true, this.textContent()).catch(e => - console.error('initEditor failed', e) - ); - }, - { injector: this.injector } - ); + private setEditorReadOnly(readOnly: boolean, content?: string): void { + if (!this.editorView) { + this.safeInitEditor(readOnly, content ?? this.textContent()); + return; + } + + const effects = [ + this.readOnlyCompartment.reconfigure(EditorState.readOnly.of(readOnly)), + this.editableCompartment.reconfigure(EditorView.editable.of(!readOnly)), + ]; + + if (content !== undefined && content !== this.editorView.state.doc.toString()) { + this.editorView.dispatch({ + changes: { from: 0, to: this.editorView.state.doc.length, insert: content }, + effects, + }); + } else { + this.editorView.dispatch({ effects }); + } } private async initEditor(readOnly = true, content = ''): Promise { @@ -207,24 +218,19 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { if (!editorContainer) return; // Detect current theme - const extensions: any[] = [ + const extensions: Extension[] = [ basicSetup, - EditorView.theme({}, { dark: true }), + EditorView.theme({}, { dark: document.documentElement.classList.contains('dark') }), keymap.of([]), - EditorView.editable.of(!readOnly), - EditorState.readOnly.of(readOnly), + this.editableCompartment.of(EditorView.editable.of(!readOnly)), + this.readOnlyCompartment.of(EditorState.readOnly.of(readOnly)), + EditorView.updateListener.of(update => { + if (update.docChanged && this.isEditing()) { + this.editContent.set(update.state.doc.toString()); + } + }), ]; - if (!readOnly) { - extensions.push( - EditorView.updateListener.of(update => { - if (update.docChanged) { - this.editContent.set(update.state.doc.toString()); - } - }) - ); - } - // Lazy-load the language extension matching the file extension. // Only the actually needed language pack is dynamically imported, // keeping the main bundle ~100 KB lighter. @@ -243,7 +249,7 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { }); } - private async loadLanguageExtension(ext: string): Promise { + private async loadLanguageExtension(ext: string): Promise { switch (ext) { case 'js': { const { javascript } = await import('@codemirror/lang-javascript'); @@ -297,11 +303,12 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { return StreamLanguage.define(shell); } case 'md': - case 'markdown': - default: { + case 'markdown': { const { markdown } = await import('@codemirror/lang-markdown'); return markdown(); } + default: + return null; } } @@ -315,18 +322,15 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { const item = this.currentItem(); try { - const fsName = this.data.isLocal - ? this.data.remoteName - : this.pathService.normalizeRemoteForRclone(this.data.remoteName); - const dirPath = this.pathService.getDirname(item.Path); const filename = this.pathService.getFilename(item.Path); const content = new TextEncoder().encode(this.editContent()); - await this.remoteOps.uploadFileSimple(fsName, dirPath, filename, content); + await this.remoteOps.uploadFileSimple(this.fsName, dirPath, filename, content); this.textContent.set(this.editContent()); this.isEditing.set(false); + this.setEditorReadOnly(true, this.textContent()); this.notificationService.showInfo( this.translate.instant('fileBrowser.fileViewer.saveSuccess') @@ -434,14 +438,7 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { // If switching back to raw view, re-initialize CodeMirror if (!this.showMarkdownPreview()) { - afterNextRender( - () => { - void this.initEditor(true, this.textContent()).catch(e => - console.error('initEditor failed', e) - ); - }, - { injector: this.injector } - ); + this.safeInitEditor(true, this.textContent()); } else if (this.editorView) { // Destroy editor when showing preview to save resources and avoid state desync this.editorView.destroy(); @@ -508,18 +505,11 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { try { if (this.currentFileType() === 'directory') { const item = this.currentItem(); - // Logic is now robust for any path structure. - let fsName = this.data.remoteName; - - // If remote (not local), ensure it has the colon for the API call - if (!this.data.isLocal) { - fsName = this.pathService.normalizeRemoteForRclone(fsName); - } // For local: fsName is "C:" or "/", path is "path/to/dir" // For remote: fsName is "gdrive:", path is "path/to/dir" await this.remoteOps - .getSize(fsName, item.Path, 'filemanager', this.readJobGroup) + .getSize(this.fsName, item.Path, 'filemanager', this.readJobGroup) .then((size: { count: number; bytes: number }) => { this.folderSize.set(size); }) @@ -566,14 +556,7 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { if (this.fileName().toLowerCase().endsWith('.lnk')) { const info = this.extractLnkInfo(res.body); this.textContent.set(info); - afterNextRender( - () => { - void this.initEditor(true, info).catch(e => - console.error('initEditor failed', e) - ); - }, - { injector: this.injector } - ); + this.safeInitEditor(true, info); } else { this.currentFileType.set('binary'); } @@ -581,14 +564,7 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { const repaired = this.repairText(res.body); this.textContent.set(repaired); // Initialize CodeMirror in read-only mode - afterNextRender( - () => { - void this.initEditor(true, repaired ?? '').catch(e => - console.error('initEditor failed', e) - ); - }, - { injector: this.injector } - ); + this.safeInitEditor(true, repaired ?? ''); } } this.isLoading.set(false); @@ -597,29 +573,34 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { } if (this.currentFileType() === 'audio') { + const targetPath = this.currentItem().Path; this.isLoadingCover.set(true); this.fileViewerService .getAudioCover(this.currentItem(), this.data.remoteName, this.data.isLocal) .then(cover => { - this.coverImage.set(cover); + if (this.isStillViewing(targetPath)) { + this.coverImage.set(cover); + } }) .catch(err => { console.warn('Failed to extract audio cover:', err); }) .finally(() => { - this.isLoadingCover.set(false); + if (this.isStillViewing(targetPath)) { + this.isLoadingCover.set(false); + } }); } if (this.currentFileType() === 'archive') { const item = this.currentItem(); - const source = this.data.isLocal - ? this.pathService.joinPath(this.data.remoteName, item.Path) - : `${this.pathService.normalizeRemoteForRclone(this.data.remoteName)}${item.Path}`; + const targetPath = item.Path; + const source = this.getArchiveSource(item); this.remoteOps .archiveList(source, true) // Use long format for more info .then(res => { + if (!this.isStillViewing(targetPath)) return; if (res && res.success) { this.parsedArchiveItems.set(res.items); this.archiveError.set(null); @@ -629,12 +610,15 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { } }) .catch(err => { + if (!this.isStillViewing(targetPath)) return; console.error('Failed to list archive:', err); this.archiveError.set(err.toString()); this.parsedArchiveItems.set([]); }) .finally(() => { - this.isLoading.set(false); + if (this.isStillViewing(targetPath)) { + this.isLoading.set(false); + } }); return; } @@ -803,12 +787,8 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { if (this.isDownloading()) return; this.isDownloading.set(true); try { - const fsName = this.data.isLocal - ? this.data.remoteName - : this.pathService.normalizeRemoteForRclone(this.data.remoteName); - await this.downloadService.download( - fsName, + this.fsName, this.currentItem().Path, this.fileName(), this.data.isLocal, @@ -839,10 +819,7 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { this.isExtracting.set(true); const selectedPath = result.paths[0]; - - const source = this.data.isLocal - ? this.pathService.joinPath(this.data.remoteName, item.Path) - : `${this.pathService.normalizeRemoteForRclone(this.data.remoteName)}${item.Path}`; + const source = this.getArchiveSource(item); this.notificationService.showInfo( this.translate.instant('fileBrowser.fileViewer.extracting', { name: this.fileName() }) @@ -866,12 +843,8 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { if (this.isOpeningNative()) return; this.isOpeningNative.set(true); try { - const fsName = this.data.isLocal - ? this.data.remoteName - : this.pathService.normalizeRemoteForRclone(this.data.remoteName); - await this.downloadService.openFileNatively( - fsName, + this.fsName, this.currentItem().Path, this.fileName(), this.data.isLocal @@ -882,4 +855,23 @@ export class FileViewerModalComponent implements OnInit, OnDestroy { this.isOpeningNative.set(false); } } + + /** Checks whether the viewer is still showing the file at the given path (stale-navigation guard). */ + private isStillViewing(targetPath: string): boolean { + return this.currentItem()?.Path === targetPath; + } + + /** Builds the full rclone source path for archive operations. */ + private getArchiveSource(item: Entry): string { + return this.data.isLocal + ? this.pathService.joinPath(this.data.remoteName, item.Path) + : `${this.pathService.normalizeRemoteForRclone(this.data.remoteName)}${item.Path}`; + } + + /** Schedules editor initialization after the next render, swallowing init errors. */ + private safeInitEditor(readOnly: boolean, content: string): void { + requestAnimationFrame(() => { + void this.initEditor(readOnly, content).catch(e => console.error('initEditor failed', e)); + }); + } } diff --git a/src/app/file-browser/nautilus/bottom-bar/nautilus-bottom-bar.component.html b/src/app/file-browser/nautilus/bottom-bar/nautilus-bottom-bar.component.html index 5310d0e18..db526dfd7 100755 --- a/src/app/file-browser/nautilus/bottom-bar/nautilus-bottom-bar.component.html +++ b/src/app/file-browser/nautilus/bottom-bar/nautilus-bottom-bar.component.html @@ -1,7 +1,11 @@
-
@@ -19,7 +23,20 @@
- + } + diff --git a/src/app/file-browser/nautilus/bottom-bar/nautilus-bottom-bar.component.spec.ts b/src/app/file-browser/nautilus/bottom-bar/nautilus-bottom-bar.component.spec.ts deleted file mode 100644 index 8fb6d06ad..000000000 --- a/src/app/file-browser/nautilus/bottom-bar/nautilus-bottom-bar.component.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { NautilusBottomBarComponent } from './nautilus-bottom-bar.component'; -import { MatIconModule } from '@angular/material/icon'; -import { MatButtonModule } from '@angular/material/button'; -import { CdkMenuModule } from '@angular/cdk/menu'; - -describe('NautilusBottomBarComponent', () => { - let component: NautilusBottomBarComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [NautilusBottomBarComponent, MatIconModule, MatButtonModule, CdkMenuModule], - }).compileComponents(); - - fixture = TestBed.createComponent(NautilusBottomBarComponent); - component = fixture.componentRef.instance; - - fixture.componentRef.setInput('canGoBack', false); - fixture.componentRef.setInput('canGoForward', false); - fixture.componentRef.setInput('layout', 'grid'); - fixture.componentRef.setInput('viewMenu', {}); // Mock object for required input - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should emit setLayout with inverted layout on toggle', () => { - spyOn(component.setLayout, 'emit'); - - // Initial is grid, toggle should emit list - component.setLayout.emit(component['oppositeLayout']()); - expect(component.setLayout.emit).toHaveBeenCalledWith('list'); - - // If layout is list, toggle should emit grid - fixture.componentRef.setInput('layout', 'list'); - fixture.detectChanges(); - component.setLayout.emit(component['oppositeLayout']()); - expect(component.setLayout.emit).toHaveBeenCalledWith('grid'); - }); -}); diff --git a/src/app/file-browser/nautilus/bottom-bar/nautilus-bottom-bar.component.ts b/src/app/file-browser/nautilus/bottom-bar/nautilus-bottom-bar.component.ts index 04c4c48e2..dbce83977 100755 --- a/src/app/file-browser/nautilus/bottom-bar/nautilus-bottom-bar.component.ts +++ b/src/app/file-browser/nautilus/bottom-bar/nautilus-bottom-bar.component.ts @@ -10,6 +10,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; import { CdkMenuModule } from '@angular/cdk/menu'; import { TranslatePipe } from '@ngx-translate/core'; +import { isMobile as isMobileOS } from 'src/app/services/infrastructure/platform/api-client.service'; @Component({ selector: 'app-nautilus-bottom-bar', @@ -24,11 +25,15 @@ export class NautilusBottomBarComponent { public readonly viewMenu = input.required>(); public readonly isPickerMode = input(false); public readonly isConfirmDisabled = input(false); + public readonly isSidebarOpen = input(false); // --- Outputs --- public readonly setLayout = output<'grid' | 'list'>(); public readonly confirmSelection = output(); public readonly toggleSidebar = output(); + public readonly popOutToWindow = output(); + + protected readonly isMobileOS = isMobileOS; protected readonly oppositeLayout = computed((): 'grid' | 'list' => this.layout() === 'grid' ? 'list' : 'grid' diff --git a/src/app/file-browser/nautilus/context-menu/nautilus-context-menu.component.ts b/src/app/file-browser/nautilus/context-menu/nautilus-context-menu.component.ts index ecaddbd63..0d10e90f5 100755 --- a/src/app/file-browser/nautilus/context-menu/nautilus-context-menu.component.ts +++ b/src/app/file-browser/nautilus/context-menu/nautilus-context-menu.component.ts @@ -6,6 +6,7 @@ import { TemplateRef, input, output, + ElementRef, ChangeDetectionStrategy, } from '@angular/core'; import { TranslatePipe } from '@ngx-translate/core'; @@ -41,6 +42,8 @@ import { FileBrowserItem, FilePickerConfig, DEFAULT_PICKER_OPTIONS } from '@app/ styleUrls: ['../../../styles/_slide-menu.scss'], }) export class NautilusContextMenuComponent { + private readonly elementRef = inject(ElementRef); + // Services public readonly tabSvc = inject(NautilusTabService); public readonly actions = inject(NautilusActionsService); @@ -72,14 +75,13 @@ export class NautilusContextMenuComponent { readonly pathOptionsMenuTemplate = viewChild>('pathOptionsMenuTemplate'); // Slide animation controller - readonly menuCtrl = new SlideMenuController('.nautilus-sliding-container'); + readonly menuCtrl = new SlideMenuController( + '.nautilus-sliding-container', + () => this.elementRef.nativeElement + ); // Computeds - protected readonly activeSelectionCount = computed(() => { - return this.tabSvc.activePaneIndex() === 0 - ? this.tabSvc.selectedItems().size - : this.tabSvc.selectedItemsRight().size; - }); + protected readonly activeSelectionCount = computed(() => this.tabSvc.activeSelection().size); protected readonly supportsPublicLink = this.actions.supportsPublicLink; @@ -97,13 +99,11 @@ export class NautilusContextMenuComponent { } protected copyItems(): void { - const filesList = this.tabSvc.activePaneIndex() === 0 ? this.files() : this.filesRight(); - this.fileOps.copyItems(this.selectionSvc.getSelectedItemsList(filesList)); + this.fileOps.copyItems(this.selectionSvc.getSelectedItemsList(this.getActiveFiles())); } protected cutItems(): void { - const filesList = this.tabSvc.activePaneIndex() === 0 ? this.files() : this.filesRight(); - this.fileOps.cutItems(this.selectionSvc.getSelectedItemsList(filesList)); + this.fileOps.cutItems(this.selectionSvc.getSelectedItemsList(this.getActiveFiles())); } protected openContextMenuOpen(): void { @@ -113,6 +113,10 @@ export class NautilusContextMenuComponent { } } + private getActiveFiles(): FileBrowserItem[] { + return this.tabSvc.activePaneIndex() === 0 ? this.files() : this.filesRight(); + } + protected getFormattedPath(item: FileBrowserItem | null): string { if (!item) return this.fullPathInput(); return this.pathService.getFullDisplayPath(this.tabSvc.activeRemote(), item.entry.Path); diff --git a/src/app/file-browser/nautilus/nautilus.component.html b/src/app/file-browser/nautilus/nautilus.component.html index ae7ac1f30..f2da67f2a 100755 --- a/src/app/file-browser/nautilus/nautilus.component.html +++ b/src/app/file-browser/nautilus/nautilus.component.html @@ -146,9 +146,11 @@ [viewMenu]="viewMenu" [isPickerMode]="isPickerMode()" [isConfirmDisabled]="isConfirmDisabled()" + [isSidebarOpen]="isSidenavOpen()" (setLayout)="settings.setLayout($event)" (confirmSelection)="confirmSelection()" (toggleSidebar)="sidenav.toggle()" + (popOutToWindow)="onPopOutToWindow()" > } diff --git a/src/app/file-browser/nautilus/nautilus.component.spec.ts b/src/app/file-browser/nautilus/nautilus.component.spec.ts deleted file mode 100644 index 54f534305..000000000 --- a/src/app/file-browser/nautilus/nautilus.component.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BehaviorSubject } from 'rxjs'; -import { RemoteManagementService } from 'src/app/services/remote/remote-management.service'; - -import { NautilusComponent } from './nautilus.component'; - -describe('NautilusComponent', () => { - let component: NautilusComponent; - let fixture: ComponentFixture; - - class MockRemoteManagementService { - remotes$ = new BehaviorSubject(['remoteA', 'remoteB']); - async getRemotes(): Promise { - // Simulate a successful fetch - this.remotes$.next(['remoteA', 'remoteB']); - return ['remoteA', 'remoteB']; - } - async getAllRemoteConfigs(): Promise> { - return { - remoteA: { type: 's3' }, - remoteB: { type: 'drive' }, - }; - } - } - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [NautilusComponent], - providers: [{ provide: RemoteManagementService, useClass: MockRemoteManagementService }], - }).compileComponents(); - - fixture = TestBed.createComponent(NautilusComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/file-browser/nautilus/nautilus.component.ts b/src/app/file-browser/nautilus/nautilus.component.ts index 5fe6552fa..60891aeb0 100755 --- a/src/app/file-browser/nautilus/nautilus.component.ts +++ b/src/app/file-browser/nautilus/nautilus.component.ts @@ -25,6 +25,7 @@ import { MatDividerModule } from '@angular/material/divider'; import { CdkMenuModule } from '@angular/cdk/menu'; import { NautilusService } from 'src/app/services/ui/nautilus.service'; +import { UiStateService } from 'src/app/services/ui/state/ui-state.service'; import { NotificationService } from 'src/app/services/ui/notification.service'; import { PathService } from 'src/app/services/infrastructure/platform/path.service'; import { PathNavigationService } from 'src/app/services/infrastructure/platform/path-navigation.service'; @@ -96,6 +97,7 @@ export class NautilusComponent implements OnInit { private readonly keyboard = inject(NautilusKeyboardDirective); protected readonly nautilusService = inject(NautilusService); + private readonly uiStateService = inject(UiStateService); protected readonly fileOps = inject(NautilusFileOperationsService); protected readonly dragDrop = inject(NautilusDragDropService); protected readonly settings = inject(NautilusSettingsService); @@ -132,13 +134,22 @@ export class NautilusComponent implements OnInit { private readonly filePickerState = this.nautilusService.filePickerState; protected readonly isPickerMode = computed(() => this.filePickerState().isOpen); + protected readonly isPrimaryRoutedView = computed(() => { + if (this.isPickerMode() || this.nautilusService.isBrowserOverlayOpen()) { + return false; + } + return ( + this.nautilusService.isStandaloneWindow() || + this.uiStateService.selectedMainView() === 'nautilus' + ); + }); protected readonly pickerOptions = computed( (): FilePickerConfig => this.filePickerState().options ?? DEFAULT_PICKER_OPTIONS ); protected readonly isConfirmDisabled = computed(() => { if (!this.isPickerMode()) return false; const opts = this.pickerOptions(); - const count = this.tabSvc.selectedItems().size; + const count = this.tabSvc.activeSelection().size; if (opts.selection === 'files' && count === 0) return true; if (opts.selection === 'folders' && count === 0) return false; if (opts.selection === 'both' && count === 0) return false; @@ -257,11 +268,11 @@ export class NautilusComponent implements OnInit { protected readonly selectionSummary = computed(() => { this._langChange(); // recompute on language change - const selectedPaths = this.tabSvc.selectedItems(); + const selectedPaths = this.tabSvc.activeSelection(); const count = selectedPaths.size; if (count === 0) return ''; - const allFiles = this.files(); + const allFiles = this.tabSvc.activePaneIndex() === 0 ? this.files() : this.filesRight(); const selected = allFiles.filter(item => selectedPaths.has(this.getItemKey(item))); if (count === 1) { @@ -305,10 +316,17 @@ export class NautilusComponent implements OnInit { this._registerKeyboard(); this._registerDragDrop(); + this.uiStateService.registerMobileSidebar({ + view: 'nautilus', + isOver: this.isMobile, + isOpen: this.isSidenavOpen, + }); + this.tabSvc.onCloseOverlay = (): void => this.closeOverlay.emit(null); this.destroyRef.onDestroy(() => { this.nautilusService.setWindowTitle('RClone Manager'); + this.uiStateService.unregisterMobileSidebar('nautilus'); }); } @@ -394,13 +412,18 @@ export class NautilusComponent implements OnInit { }); } }); + // Sync browser URL with active remote + path when Nautilus is the primary routed view. + // In picker mode or overlay mode we do not mutate the browser URL. effect(() => { + if (!this.isPrimaryRoutedView()) { + return; + } + const remote = this.tabSvc.activeRemote(); const path = this.tabSvc.activePath(); - const activeFile = this.fileViewerSvc.activeFileName(); untracked(() => { - const displayPath = activeFile ? (path ? `${path}/${activeFile}` : activeFile) : path; + const displayPath = path; const remoteName = remote?.name ?? null; const desiredPath = this.pathNav.buildRelativeNautilusPath( @@ -433,24 +456,26 @@ export class NautilusComponent implements OnInit { }); this.pathNav.locationChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(loc => { + if (!this.isPrimaryRoutedView()) { + return; + } + const remote = loc.remote; if (!remote) { - if (this.nautilusService.isBrowserOverlayOpen()) { - this.closeOverlay.emit(null); - } else if (this.isPickerMode()) { - this.nautilusService.closeFilePicker(null); - } return; } - const remoteRoot = - this.allRemotesLookup().find( - r => - this.pathService.normalizeRemoteName(r.name) === - this.pathService.normalizeRemoteName(remote) - ) ?? null; + const activeRemote = this.tabSvc.activeRemote(); + const activePath = this.tabSvc.activePath(); + const targetPath = loc.path ?? ''; + + if (activeRemote && activeRemote.name === remote && activePath === targetPath) { + return; + } + + const remoteRoot = this.nautilusService.lookupRemoteByName(remote); - void this.tabSvc.navigate(remoteRoot, loc.path ?? '', false); + void this.tabSvc.navigate(remoteRoot, targetPath, false); }); effect(() => { @@ -490,10 +515,7 @@ export class NautilusComponent implements OnInit { private _registerKeyboard(): void { this.keyboard.register({ navigateTo: item => this.navigateTo(item), - getSelectedItems: () => - this.selectionSvc.getSelectedItemsList( - this.tabSvc.activePaneIndex() === 0 ? this.files() : this.filesRight() - ), + getSelectedItems: () => this.selectionSvc.getSelectedItemsList(this.getActivePaneFiles()), setContextItem: item => this.setContextItem(item), openInNewTab: () => this.actions.openContextMenuOpenInNewTab(), openInNewWindow: () => this.actions.openContextMenuOpenInNewWindow(), @@ -573,6 +595,10 @@ export class NautilusComponent implements OnInit { } navigateToSegment(index: number): void { + if (index < 0) { + this.updatePath(''); + return; + } const seg = this.pathSegments()[index]; if (seg) this.updatePath(seg.path); } @@ -591,13 +617,14 @@ export class NautilusComponent implements OnInit { const normalized = this.pathService.normalizePath(rawInput); const currentPath = this.tabSvc.activePath(); - this.updatePath(this.tabSvc.currentPath() ? `${currentPath}/${normalized}` : normalized); + this.updatePath(currentPath ? `${currentPath}/${normalized}` : normalized); } protected onPopOutToWindow(): void { const remote = this.tabSvc.activeRemote()?.name ?? null; const path = this.tabSvc.activePath() ?? null; void this.nautilusService.newNautilusWindow(remote, path, true); + this.closeOverlay.emit(null); } protected navigateTo(item: FileBrowserItem, isNewTab = false): void { @@ -616,7 +643,7 @@ export class NautilusComponent implements OnInit { } else { if (this.isPickerMode()) { const pIdx = this.tabSvc.activePaneIndex(); - const files = pIdx === 0 ? this.files() : this.filesRight(); + const files = this.getActivePaneFiles(pIdx); this.selectionSvc.handleItemClick( item, { ctrlKey: false, shiftKey: false } as MouseEvent, @@ -627,7 +654,7 @@ export class NautilusComponent implements OnInit { this.confirmSelection(); return; } - const files = this.tabSvc.activePaneIndex() === 0 ? this.files() : this.filesRight(); + const files = this.getActivePaneFiles(); void this.actions.openFilePreview(item, files); } } @@ -637,11 +664,7 @@ export class NautilusComponent implements OnInit { } openBookmark(bookmark: FileBrowserItem): void { - const remoteDetails = this.allRemotesLookup().find( - r => - this.pathService.normalizeRemoteName(r.name) === - this.pathService.normalizeRemoteName(bookmark.meta.remote) - ); + const remoteDetails = this.nautilusService.lookupRemoteByName(bookmark.meta.remote); if (!remoteDetails) { this.notificationService.showError( this.translate.instant('nautilus.errors.bookmarkRemoteNotFound', { @@ -693,8 +716,13 @@ export class NautilusComponent implements OnInit { // Selection // --------------------------------------------------------------------------- + getActivePaneFiles(paneIndex?: 0 | 1): FileBrowserItem[] { + const idx = paneIndex ?? this.tabSvc.activePaneIndex(); + return idx === 0 ? this.files() : this.filesRight(); + } + onItemClick(item: FileBrowserItem, event: Event, index: number, paneIndex: 0 | 1): void { - const files = paneIndex === 0 ? this.files() : this.filesRight(); + const files = this.getActivePaneFiles(paneIndex); this.selectionSvc.handleItemClick(item, event as MouseEvent, index, paneIndex, files); // On mobile, single tap navigates/opens immediately. @@ -705,8 +733,9 @@ export class NautilusComponent implements OnInit { setContextItem(item: FileBrowserItem | null, paneIndex?: 0 | 1): void { this.contextMenu()?.reset(); + this.actions.contextMenuItem.set(item); const pIdx = paneIndex ?? this.tabSvc.activePaneIndex(); - const files = pIdx === 0 ? this.files() : this.filesRight(); + const files = this.getActivePaneFiles(pIdx); this.selectionSvc.handleContextItem(item, pIdx, files); } @@ -715,7 +744,7 @@ export class NautilusComponent implements OnInit { } selectAll(): void { - const files = this.tabSvc.activePaneIndex() === 0 ? this.files() : this.filesRight(); + const files = this.getActivePaneFiles(); this.selectionSvc.selectAll(this.tabSvc.activePaneIndex(), files); } @@ -841,22 +870,15 @@ export class NautilusComponent implements OnInit { /** Handler for the hidden file — headless mode only. */ async onWebUploadFiles(event: Event): Promise { - const input = event.target as HTMLInputElement; - if (!input.files || input.files.length === 0) return; - const remote = this.tabSvc.activeRemote(); - if (remote) { - const success = await this.fileOps.uploadWebFiles( - remote, - this.tabSvc.activePath(), - input.files - ); - if (success) this.tabSvc.refresh(this.tabSvc.activePaneIndex()); - } - input.value = ''; + await this._handleWebUpload(event); } /** Handler for the hidden folder — headless mode only. */ async onWebUploadFolder(event: Event): Promise { + await this._handleWebUpload(event); + } + + private async _handleWebUpload(event: Event): Promise { const input = event.target as HTMLInputElement; if (!input.files || input.files.length === 0) return; const remote = this.tabSvc.activeRemote(); @@ -876,7 +898,7 @@ export class NautilusComponent implements OnInit { // --------------------------------------------------------------------------- onDragStarted(event: DragEvent, item: FileBrowserItem): void { - const currentSelected = this.tabSvc.selectedItems(); + const currentSelected = this.tabSvc.activeSelection(); const items = currentSelected.has(this.getItemKey(item)) ? (this.tabSvc.activePaneIndex() === 0 ? this.files() : this.filesRight()).filter(f => currentSelected.has(this.getItemKey(f)) @@ -924,7 +946,7 @@ export class NautilusComponent implements OnInit { // --------------------------------------------------------------------------- confirmSelection(): void { - const files = this.tabSvc.activePaneIndex() === 0 ? this.files() : this.filesRight(); + const files = this.getActivePaneFiles(); let items = this.selectionSvc.getSelectedItemsList(files); const remote = this.tabSvc.activeRemote(); const currentPath = this.tabSvc.activePath(); @@ -1021,19 +1043,24 @@ export class NautilusComponent implements OnInit { private _sortFiles(files: FileBrowserItem[]): FileBrowserItem[] { const list = [...files]; - const sort = this.settings.sortKey().split('-')[0] as 'name' | 'size' | 'modified'; - const ascending = !this.settings.sortKey().endsWith('desc'); - const multiplier = ascending ? 1 : -1; - - const timeCache = new Map(); - const getTime = (modTime: string): number => { - let t = timeCache.get(modTime); - if (t === undefined) { - t = new Date(modTime).getTime(); - timeCache.set(modTime, t); + const sort = this.settings.sortColumn(); + const multiplier = this.settings.sortAscending() ? 1 : -1; + + if (sort === 'modified') { + const timeMap = new Map(); + for (const item of list) { + timeMap.set(this.getItemKey(item), Date.parse(item.entry.ModTime) || 0); } - return t; - }; + return list.sort((a, b) => { + if (a.entry.IsDir !== b.entry.IsDir) return a.entry.IsDir ? -1 : 1; + const aHidden = a.entry.Name.startsWith('.'); + const bHidden = b.entry.Name.startsWith('.'); + if (aHidden !== bHidden) return aHidden ? 1 : -1; + const aTime = timeMap.get(this.getItemKey(a)) ?? 0; + const bTime = timeMap.get(this.getItemKey(b)) ?? 0; + return (aTime - bTime) * multiplier; + }); + } return list.sort((a, b) => { if (a.entry.IsDir !== b.entry.IsDir) return a.entry.IsDir ? -1 : 1; @@ -1048,8 +1075,6 @@ export class NautilusComponent implements OnInit { ); case 'size': return (a.entry.Size - b.entry.Size) * multiplier; - case 'modified': - return (getTime(a.entry.ModTime) - getTime(b.entry.ModTime)) * multiplier; default: return 0; } diff --git a/src/app/file-browser/nautilus/sidebar/nautilus-sidebar.component.ts b/src/app/file-browser/nautilus/sidebar/nautilus-sidebar.component.ts index e7a109879..2612db9ec 100755 --- a/src/app/file-browser/nautilus/sidebar/nautilus-sidebar.component.ts +++ b/src/app/file-browser/nautilus/sidebar/nautilus-sidebar.component.ts @@ -1,4 +1,12 @@ -import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + inject, + input, + output, +} from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; import { TranslatePipe } from '@ngx-translate/core'; import { MatIconModule } from '@angular/material/icon'; @@ -28,12 +36,11 @@ interface BookmarkViewModel { function sortAndFilterRoots( roots: ExplorerRoot[], hidden: Set, - order: string[] + orderMap: Map ): ExplorerRoot[] { const visible = roots.filter(r => !hidden.has(r.name)); - if (!order.length) return visible; + if (orderMap.size === 0) return visible; - const orderMap = new Map(order.map((name, i) => [name, i])); return visible.sort((a, b) => (orderMap.get(a.name) ?? 9999) - (orderMap.get(b.name) ?? 9999)); } @@ -53,6 +60,7 @@ function sortAndFilterRoots( styleUrl: './nautilus-sidebar.component.scss', }) export class NautilusSidebarComponent { + private readonly elementRef = inject(ElementRef); readonly iconService = inject(IconService); private readonly pathService = inject(PathService); private readonly remoteFacadeService = inject(RemoteFacadeService); @@ -97,7 +105,10 @@ export class NautilusSidebarComponent { readonly droppedToRemote = output<{ event: DragEvent; target: ExplorerRoot }>(); // Context menu sliding controller - protected readonly menuCtrl = new SlideMenuController('.sidebar-sliding-container'); + protected readonly menuCtrl = new SlideMenuController( + '.sidebar-sliding-container', + () => this.elementRef.nativeElement + ); // Computed private readonly _activeKey = computed(() => { @@ -122,20 +133,16 @@ export class NautilusSidebarComponent { return new Set(keys.filter(key => key === active)); }); + private readonly _orderMap = computed( + () => new Map(this.settings.sidebarDriveOrder().map((name, i) => [name, i])) + ); + readonly displayLocalDrives = computed(() => - sortAndFilterRoots( - this.localDrives(), - this.settings.sidebarHiddenDrives(), - this.settings.sidebarDriveOrder() - ) + sortAndFilterRoots(this.localDrives(), this.settings.sidebarHiddenDrives(), this._orderMap()) ); readonly displayCloudRemotes = computed(() => - sortAndFilterRoots( - this.cloudRemotes(), - this.settings.sidebarHiddenDrives(), - this.settings.sidebarDriveOrder() - ) + sortAndFilterRoots(this.cloudRemotes(), this.settings.sidebarHiddenDrives(), this._orderMap()) ); readonly cleanupSupportedRemotes = computed>(() => { @@ -165,28 +172,14 @@ export class NautilusSidebarComponent { onConfigureSidebar(): void { const all = [...this.localDrives(), ...this.cloudRemotes()]; const hidden = this.settings.sidebarHiddenDrives(); - const order = this.settings.sidebarDriveOrder(); + const orderMap = this._orderMap(); - const orderMap = new Map(order.map((name, i) => [name, i])); const sortedAll = [...all].sort( (a, b) => (orderMap.get(a.name) ?? 9999) - (orderMap.get(b.name) ?? 9999) ); - const items: ItemOrderVisibilityConfigItem[] = sortedAll.map(root => ({ - id: root.name, - label: root.label || root.name, - subLabel: root.showName && root.label !== root.name ? root.name : undefined, - icon: root.isLocal ? 'hard-drive' : this.iconService.getIconName(root.type), - isVisible: !hidden.has(root.name), - })); - - const defaultItems: ItemOrderVisibilityConfigItem[] = all.map(root => ({ - id: root.name, - label: root.label || root.name, - subLabel: root.showName && root.label !== root.name ? root.name : undefined, - icon: root.isLocal ? 'hard-drive' : this.iconService.getIconName(root.type), - isVisible: true, - })); + const items = sortedAll.map(root => this._toConfigItem(root, !hidden.has(root.name))); + const defaultItems = all.map(root => this._toConfigItem(root, true)); this.dialog .open(ItemOrderVisibilityModalComponent, { @@ -256,6 +249,16 @@ export class NautilusSidebarComponent { return `${remote}::${bm.entry.Path}`; } + private _toConfigItem(root: ExplorerRoot, isVisible: boolean): ItemOrderVisibilityConfigItem { + return { + id: root.name, + label: root.label || root.name, + subLabel: root.showName && root.label !== root.name ? root.name : undefined, + icon: root.isLocal ? 'hard-drive' : this.iconService.getIconName(root.type), + isVisible, + }; + } + private _closeSidenavOnMobile(): void { if (this.isMobile()) this.sidenavAction.emit('close'); } diff --git a/src/app/file-browser/nautilus/slide-menu.ts b/src/app/file-browser/nautilus/slide-menu.ts index 2c82c37d0..433d90e7a 100644 --- a/src/app/file-browser/nautilus/slide-menu.ts +++ b/src/app/file-browser/nautilus/slide-menu.ts @@ -9,21 +9,24 @@ export class SlideMenuController { readonly contextMenuHeight = signal(null); private readonly _menuOpenedTrigger = signal(0); - constructor(private containerSelector: string) { + constructor( + private containerSelector: string, + private rootResolver?: () => HTMLElement | null | undefined + ) { // Track context menu page height for the sliding animation. effect(() => { this.currentMenuView(); this._menuOpenedTrigger(); - // setTimeout defers the DOM read until after the next render cycle. - setTimeout(() => { - const activePage = document.querySelector( - `${this.containerSelector} .menu-page.active-page` - ); + requestAnimationFrame(() => { + const root = this.rootResolver?.() ?? document; + const activePage = + root.querySelector(`${this.containerSelector} .menu-page.active-page`) ?? + document.querySelector(`${this.containerSelector} .menu-page.active-page`); if (activePage) { this.contextMenuHeight.set((activePage as HTMLElement).offsetHeight); } - }, 0); + }); }); } diff --git a/src/app/file-browser/nautilus/tabs/nautilus-tabs.component.spec.ts b/src/app/file-browser/nautilus/tabs/nautilus-tabs.component.spec.ts deleted file mode 100644 index 0cdedc5b6..000000000 --- a/src/app/file-browser/nautilus/tabs/nautilus-tabs.component.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { NautilusTabsComponent } from './nautilus-tabs.component'; -import { provideTranslateService } from '@ngx-translate/core'; -import { MatIconTestingModule } from '@angular/material/icon/testing'; -import { DragDropModule } from '@angular/cdk/drag-drop'; - -describe('NautilusTabsComponent', () => { - let component: NautilusTabsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [NautilusTabsComponent, MatIconTestingModule, DragDropModule], - providers: [provideTranslateService()], - }).compileComponents(); - - fixture = TestBed.createComponent(NautilusTabsComponent); - component = fixture.componentInstance; - - // Provide inputs - fixture.componentRef.setInput('tabs', [ - { id: 1, title: 'Local', path: '/', remote: null }, - { id: 2, title: 'Gems', path: '/gems', remote: { label: 'Drive' } }, - ]); - fixture.componentRef.setInput('activeTabIndex', 0); - - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should emit switchTab when a tab is clicked', () => { - spyOn(component.switchTab, 'emit'); - const tabEls = fixture.debugElement.nativeElement.querySelectorAll('.tab'); - tabEls[1].click(); - expect(component.switchTab.emit).toHaveBeenCalledWith(1); - }); - - it('should emit closeTab when the close button is clicked', () => { - spyOn(component.closeTab, 'emit'); - const closeBtns = fixture.debugElement.nativeElement.querySelectorAll('.close-tab'); - closeBtns[0].click(); - expect(component.closeTab.emit).toHaveBeenCalledWith(0); - }); -}); diff --git a/src/app/file-browser/nautilus/toolbar/nautilus-toolbar.component.html b/src/app/file-browser/nautilus/toolbar/nautilus-toolbar.component.html index 8fd089162..b1610b8cb 100644 --- a/src/app/file-browser/nautilus/toolbar/nautilus-toolbar.component.html +++ b/src/app/file-browser/nautilus/toolbar/nautilus-toolbar.component.html @@ -88,6 +88,7 @@ class="path-input" [value]="fullPathInput()" (keydown.enter)="navigateToPath.emit(pathInput.value); pathInput.blur()" + (keydown.escape)="isEditingPathChange.emit(false); pathInput.blur()" (blur)="isEditingPathChange.emit(false)" /> } @@ -110,10 +111,15 @@ } - @if (nautilusService.isStandaloneWindow()) { + + + @if ( + nautilusService.isStandaloneWindow() || + (uiStateService.defaultView() === 'nautilus' && !nautilusService.isBrowserOverlayOpen()) + ) { } @else { - @if (!isMobile()) { + @if (!isMobileOS()) { + + +
+ + + + + + + + + + + + + + + @switch (activeSubMode()) { + @case ('quick_run') { + + } + @case ('builder') { + +
+ +

{{ 'flow.builder.placeholderTitle' | translate }}

+

{{ 'flow.builder.placeholderMessage' | translate }}

+ + + {{ 'flow.builder.viewRoadmap' | translate }} + +
+ } + } +
+
+ + +
diff --git a/src/app/flow/flow-container.component.scss b/src/app/flow/flow-container.component.scss new file mode 100755 index 000000000..76b32ca6e --- /dev/null +++ b/src/app/flow/flow-container.component.scss @@ -0,0 +1,106 @@ +.flow-main-wrapper { + display: flex; + flex-direction: column; + height: 100dvh; + width: 100vw; + background: var(--view-bg-color); + color: var(--view-fg-color); + overflow: hidden; +} + +// ── Sidenav Container & Body ─────────────────────────────────────────────── + +mat-sidenav-container.flow-sidenav-container { + flex: 1; + height: 100%; + width: 100%; + background: transparent; + + mat-sidenav.flow-sidenav { + width: var(--sidebar-width); + background: var(--sidebar-bg-color); + } + + mat-sidenav-content.flow-body { + display: flex; + flex-direction: column; + background: var(--view-bg-color); + overflow: hidden; + min-width: 0; + position: relative; + + app-quick-run-workspace { + display: block; + width: 100%; + height: 100%; + } + } +} + +.sidebar-button { + position: absolute; + top: 10px; + left: 20px; + z-index: 1; +} + +// ── Workflow Builder placeholder ─────────────────────────────────────────── + +.workflow-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: var(--space-md); + padding: var(--space-xl); + text-align: center; + + .placeholder-icon { + font-size: 72px; + width: 72px; + height: 72px; + color: var(--accent-color); + opacity: 0.6; + } + + h2 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; + color: var(--window-fg-color); + } + + p { + margin: 0; + font-size: 1rem; + color: var(--dim-color); + max-width: 420px; + line-height: 1.6; + } + + .placeholder-link { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + margin-top: var(--space-sm); + padding: var(--space-xs) var(--space-md); + border-radius: var(--radius-sm); + background: var(--hover-bg-color); + color: var(--accent-color); + text-decoration: none; + font-size: 0.9rem; + font-weight: 500; + transition: background-color 150ms ease; + + &:hover { + background: var(--selected-bg-color); + } + + .link-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + } +} diff --git a/src/app/flow/flow-container.component.ts b/src/app/flow/flow-container.component.ts new file mode 100755 index 000000000..7a531b246 --- /dev/null +++ b/src/app/flow/flow-container.component.ts @@ -0,0 +1,146 @@ +import { + Component, + ChangeDetectionStrategy, + signal, + computed, + inject, + DestroyRef, + afterNextRender, +} from '@angular/core'; +import { MatSidenavModule, MatDrawerMode } from '@angular/material/sidenav'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { CdkMenuModule } from '@angular/cdk/menu'; +import { TranslatePipe } from '@ngx-translate/core'; + +import { TabItem } from '@app/types'; +import { ModalService } from 'src/app/services/ui/modal.service'; +import { QuickRunService } from 'src/app/services/flow/quick-run.service'; +import { UiStateService } from 'src/app/services/ui/state/ui-state.service'; +import { LocalStorageService } from 'src/app/services/ui/state/local-storage.service'; + +import { TitlebarComponent } from 'src/app/layout/titlebar/titlebar.component'; +import { SidebarComponent } from 'src/app/layout/sidebar/sidebar.component'; +import { TabsButtonsComponent } from 'src/app/layout/tabs-buttons/tabs-buttons.component'; +import { QuickRunWorkspaceComponent } from './quick-run/quick-run-workspace/quick-run-workspace.component'; +import { BannerComponent } from '../layout/banners/banner.component'; + +export type FlowSubMode = 'builder' | 'quick_run'; + +@Component({ + selector: 'app-flow-container', + imports: [ + MatSidenavModule, + MatIconModule, + MatButtonModule, + CdkMenuModule, + TranslatePipe, + TitlebarComponent, + SidebarComponent, + TabsButtonsComponent, + QuickRunWorkspaceComponent, + BannerComponent, + ], + templateUrl: './flow-container.component.html', + styleUrl: './flow-container.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FlowContainerComponent { + readonly quickRunService = inject(QuickRunService); + private readonly uiStateService = inject(UiStateService); + private readonly localStorage = inject(LocalStorageService); + private readonly destroyRef = inject(DestroyRef); + private readonly modalService = inject(ModalService); + + /** + * Currently-active Flow sub-mode. Defaults to `'quick_run'`. The Builder + * tab switches to `'builder'` which shows a "working on it" placeholder. + */ + readonly activeSubMode = signal('quick_run'); + + /** Tab definitions for flow container using TabsButtonsComponent. */ + readonly tabs: TabItem[] = [ + { id: 'quick_run', icon: 'quick-run', label: 'flow.tabs.quickRun' }, + { id: 'builder', icon: 'workflow', label: 'flow.tabs.workflow' }, + ]; + + // ── Sidenav state ───────────────────────────────────────────────────────── + + readonly isSidebarOpen = signal(this.localStorage.get('ui.flowSidebarOpen', true)); + readonly sidebarMode = signal('side'); + readonly isSidebarOver = computed(() => this.sidebarMode() === 'over'); + readonly hasDetailOpen = computed( + () => !!this.quickRunService.selected() || !!this.uiStateService.selectedRemote() + ); + + goHome(): void { + this.quickRunService.deselect(); + this.uiStateService.resetSelectedRemote(); + } + + constructor() { + afterNextRender(() => this.setupResponsiveLayout()); + + this.uiStateService.registerMobileSidebar({ + view: 'flow', + isOver: this.isSidebarOver, + isOpen: this.isSidebarOpen, + }); + + this.destroyRef.onDestroy(() => { + this.uiStateService.unregisterMobileSidebar('flow'); + }); + } + + setSidebarOpen(open: boolean): void { + this.isSidebarOpen.set(open); + this.localStorage.set('ui.flowSidebarOpen', open); + } + + private setupResponsiveLayout(): void { + const mql = window.matchMedia('(min-width: 900px)'); + const update = (matches: boolean): void => this.sidebarMode.set(matches ? 'side' : 'over'); + const handler = (e: MediaQueryListEvent): void => update(e.matches); + + update(mql.matches); + mql.addEventListener('change', handler); + this.destroyRef.onDestroy(() => mql.removeEventListener('change', handler)); + } + + setSubMode(mode: FlowSubMode | string): void { + this.uiStateService.endLayoutEdit(); + this.activeSubMode.set(mode as FlowSubMode); + } + + /** Open the remote configuration modal to create a new remote only. */ + newRemote(): void { + this.modalService.openRemoteConfig({ editTarget: 'remote' }); + if (this.sidebarMode() === 'over') { + this.setSidebarOpen(false); + } + } + + /** Open the quick-run editor in "create" mode. */ + newQuickRun(): void { + this.setSubMode('quick_run'); + this.quickRunService.openEditor(); + if (this.sidebarMode() === 'over') { + this.setSidebarOpen(false); + } + } + + /** + * Switch to the Workflow Builder tab. The builder is not implemented yet — + * clicking this shows the "working on it" placeholder. + */ + newWorkflow(): void { + this.setSubMode('builder'); + } + + onItemSelected(): void { + this.setSubMode('quick_run'); + if (this.sidebarMode() === 'over') { + this.setSidebarOpen(false); + } + } +} diff --git a/src/app/flow/quick-run/quick-run-card/quick-run-card.component.html b/src/app/flow/quick-run/quick-run-card/quick-run-card.component.html new file mode 100755 index 000000000..1e790f7a5 --- /dev/null +++ b/src/app/flow/quick-run/quick-run-card/quick-run-card.component.html @@ -0,0 +1,226 @@ +@if (variant() === 'overview') { +
+
+
+ +
+
+

+ {{ quickRun().name }} +

+ + {{ actionLabel() | translate }} + · + + +
+
+ + +
+ + @if (quickRun().description) { + {{ quickRun().description }} + } + +
+ + + + + + + + @if (openableFolders().length === 1) { + + } + + + @if (openableFolders().length > 1) { + +
+ @for (folder of openableFolders(); track folder.type + folder.path) { + + } +
+
+ + + } +
+} @else { + + + +
+
+

{{ quickRun().name }}

+
+
+ @if (hasCron()) { + + } + @if (hasWatcher()) { + + } + @if (hasAutoStart()) { + + } + @if (isRunning()) { + + } +
+
+ +
+ + {{ actionLabel() | translate }} + · + {{ quickRun().remoteName }} +
+
+
+} diff --git a/src/app/flow/quick-run/quick-run-card/quick-run-card.component.scss b/src/app/flow/quick-run/quick-run-card/quick-run-card.component.scss new file mode 100755 index 000000000..bcbdb08e5 --- /dev/null +++ b/src/app/flow/quick-run/quick-run-card/quick-run-card.component.scss @@ -0,0 +1,242 @@ +:host { + display: block; +} + +// ── Overview Variant (Custom specifics) ─────────────────────────────────────── + +:host(.overview-variant) { + cursor: pointer; + box-sizing: border-box; + + .card-meta { + display: flex; + align-items: center; + gap: var(--space-xxs); + + .op-badge { + font-weight: 600; + } + + .remote-link-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 4px; + border-radius: var(--radius-xs); + font-size: var(--font-size-md); + color: var(--dim-color); + cursor: pointer; + transition: var(--transition-fast); + min-width: 0; + + mat-icon { + width: var(--icon-size-sm); + height: var(--icon-size-sm); + font-size: var(--icon-size-sm); + } + + .remote-name-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &:hover { + color: var(--primary-color); + background: rgba(var(--primary-color-rgb), 0.12); + } + } + } + + .description-text { + font-size: var(--font-size-sm); + padding: 0 var(--space-md) var(--space-xs); + color: var(--dim-color); + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } +} + +.spin-icon, +mat-icon[svgIcon="spinner"] { + animation: spin 1s linear infinite; +} + +// ── Sidebar Variant (Compact List) ────────────────────────────────────────── + +.quick-run-card.sidebar-variant { + margin: var(--space-md); + margin-bottom: 0; + padding: var(--space-lg); + border-radius: var(--modal-radius); + background: var(--window-bg-color); + box-shadow: var(--box-shadow); + flex-shrink: 0; + cursor: pointer; + border: none; + transition: + background 0.15s ease, + box-shadow 0.15s ease; + + &:hover { + background: var(--hover-bg-color); + } + + &.selected { + background: var(--selected-bg-color); + box-shadow: none; + } + + .card-content { + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-sm); + + .card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-xs); + padding: 0; + + .name-wrapper { + display: flex; + align-items: center; + gap: var(--space-xs); + flex: 1; + min-width: 0; + + h4 { + margin: 0; + flex: 1 1 0; + min-width: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--window-fg-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -0.1px; + } + } + + .status-indicators { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + + .feature-icon { + width: 14px; + height: 14px; + font-size: 14px; + color: var(--dim-color); + opacity: 0.75; + transition: + opacity 0.15s ease, + color 0.15s ease; + + &:hover { + opacity: 1; + } + + &.cron { + color: var(--primary-color); + } + + &.watcher { + color: var(--accent-color); + } + + &.autostart { + color: var(--yellow); + } + } + } + } + + .card-meta { + display: flex; + align-items: center; + gap: var(--space-xs); + color: var(--dim-color); + font-size: 0.9rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + mat-icon { + width: var(--icon-size-xs); + height: var(--icon-size-xs); + font-size: var(--icon-size-xs); + color: var(--dim-color); + flex-shrink: 0; + } + + .op-label { + font-weight: 500; + color: var(--dim-color); + } + + .separator { + opacity: 0.5; + } + + .remote-name { + font-family: var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + } + } + } +} + +// ── Shared Status dot (sidebar variant) ───────────────────────────────────── + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background: var(--dim-color); + opacity: 0.5; + + &.running { + background: var(--accent-color); + opacity: 1; + animation: pulse 1.4s ease-in-out infinite; + } + &.completed { + background: var(--primary-color); + opacity: 0.8; + } + &.failed { + background: var(--warn-color); + opacity: 1; + } + &.stopped { + background: var(--dim-color); + opacity: 0.7; + } + &.idle { + background: var(--dim-color); + opacity: 0.4; + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.6; + transform: scale(1.25); + } +} diff --git a/src/app/flow/quick-run/quick-run-card/quick-run-card.component.ts b/src/app/flow/quick-run/quick-run-card/quick-run-card.component.ts new file mode 100755 index 000000000..c6c6fdc52 --- /dev/null +++ b/src/app/flow/quick-run/quick-run-card/quick-run-card.component.ts @@ -0,0 +1,237 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { CdkMenuModule } from '@angular/cdk/menu'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { + getQuickRunPaths, + OpenInFilesEvent, + OPERATION_REGISTRY, + QuickRun, + isFolderOpeningAction, +} from '@app/types'; +import { PathService } from 'src/app/services/infrastructure/platform/path.service'; +import { IconService } from 'src/app/services/ui/icon.service'; +import { RemoteFacadeService } from 'src/app/services/facade/remote-facade.service'; +import { QuickRunService } from 'src/app/services/flow/quick-run.service'; + +export interface QuickRunOpenableFolder { + type: 'source' | 'destination'; + path: string; + isLocal: boolean; + icon: string; + cssClass: string; + tooltip: string; +} + +/** + * A card component for Quick Runs, supporting two visual variants: + * - 'sidebar': Compact item designed for the Flow sidebar list. + * - 'overview': Dashboard card with direct Start/Stop, Edit, and Browse actions. + */ +@Component({ + selector: 'app-quick-run-card', + imports: [MatButtonModule, MatCardModule, MatIconModule, CdkMenuModule, TranslatePipe], + templateUrl: './quick-run-card.component.html', + styleUrls: ['./quick-run-card.component.scss', '../../../styles/_shared-card.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '[class.overview-variant]': 'variant() === "overview"', + '[class.sidebar-variant]': 'variant() === "sidebar"', + '[class.is-running]': 'isRunning()', + '[class.selected]': 'selected()', + '[class]': 'cardOperationClass()', + '(click)': 'variant() === "overview" ? onRowClick() : null', + }, +}) +export class QuickRunCardComponent { + readonly pathService = inject(PathService); + readonly iconService = inject(IconService); + private readonly remoteFacade = inject(RemoteFacadeService); + private readonly quickRunService = inject(QuickRunService); + private readonly translate = inject(TranslateService); + + /** The quick run this card represents. */ + readonly quickRun = input.required(); + /** True if this card is currently selected (drives highlight). */ + readonly selected = input(false); + /** True if the quick run is currently running (drives status dot/badge). */ + readonly isRunning = input(false); + /** Visual variant: 'sidebar' for compact list, 'overview' for dashboard card grid. */ + readonly variant = input<'sidebar' | 'overview'>('sidebar'); + + /** Emitted when the card is clicked (selects quick run for detail view). */ + readonly selectedChange = output(); + /** Emitted when the Start action button is clicked (overview variant). */ + readonly startRun = output(); + /** Emitted when the Stop action button is clicked (overview variant). */ + readonly stopRun = output(); + /** Emitted when the Edit action button is clicked (overview variant). */ + readonly editRun = output(); + /** Emitted when a Folder browse action button is clicked. */ + readonly openInFiles = output(); + /** Emitted when the Remote name link is clicked to open full remote details. */ + readonly openRemoteDetail = output(); + + // ── Reactive In-flight States ───────────────────────────────────────────── + readonly isActionInProgress = computed( + () => !!this.quickRunService.actionInProgress()[this.quickRun().id] + ); + readonly actionStates = computed( + () => this.remoteFacade.actionInProgress()[this.quickRun().remoteName] ?? [] + ); + readonly isFolderOpening = computed(() => isFolderOpeningAction(this.actionStates())); + + // ── Derived view model ──────────────────────────────────────────────────── + + readonly operationDef = computed(() => { + const op = this.quickRun().operationType; + return OPERATION_REGISTRY.find(d => d.key === op); + }); + + readonly remote = computed(() => + this.remoteFacade.orderedRemotes().find(r => r.name === this.quickRun().remoteName) + ); + + readonly remoteIcon = computed(() => { + const rem = this.remote(); + return rem ? this.iconService.getIconName(rem.type) : 'cloud'; + }); + + readonly icon = computed(() => this.operationDef()?.icon ?? 'operations'); + readonly actionLabel = computed(() => this.operationDef()?.actionLabel ?? 'Start'); + readonly cardOperationClass = computed(() => `op-${this.quickRun().operationType}`); + + /** Dynamic action button icon based on operation definition and active status. */ + readonly currentActionIcon = computed(() => { + if (this.isRunning()) { + return this.operationDef()?.stopIcon ?? 'stop'; + } + return this.operationDef()?.startIcon ?? this.icon(); + }); + + /** Dynamic action button tooltip based on operation definition and active status. */ + readonly currentActionTooltip = computed(() => { + if (this.isRunning()) { + const key = this.operationDef()?.stopTooltip; + return key + ? this.translate.instant(key) + : this.translate.instant('flow.quickRun.actions.stop'); + } + const key = this.operationDef()?.startTooltip; + return key + ? this.translate.instant(key) + : this.translate.instant('flow.quickRun.actions.start'); + }); + + /** Feature indicators (Cron schedule, File Watcher, AutoStart). */ + readonly hasCron = computed(() => { + const app = this.quickRun().config?.app; + return !!(app?.cronEnabled && app?.cronExpression); + }); + + readonly cronExpression = computed(() => this.quickRun().config?.app?.cronExpression ?? ''); + + readonly hasWatcher = computed(() => { + const app = this.quickRun().config?.app; + return !!app?.watchEnabled; + }); + + readonly hasWatchChangedOnly = computed(() => { + const app = this.quickRun().config?.app; + return !!app?.watchChangedOnly; + }); + + readonly hasAutoStart = computed(() => { + const app = this.quickRun().config?.app; + return !!app?.autoStart; + }); + + /** All browsable folder targets (both source and destination) for this quick run. */ + readonly openableFolders = computed(() => { + const paths = getQuickRunPaths(this.quickRun().config); + const folders: QuickRunOpenableFolder[] = []; + const opCssClass = this.operationDef()?.cssClass || 'primary'; + + // Source path(s) + if (paths.source) { + const rawSources = Array.isArray(paths.source) ? paths.source : [paths.source]; + for (const src of rawSources) { + if (src && typeof src === 'string' && src.trim().length > 0) { + const isLocal = this.pathService.isLocalPath(src); + const shortName = this.pathService.getFilename(src) || src; + folders.push({ + type: 'source', + path: src, + isLocal, + icon: isLocal ? 'folder' : 'folder-open', + cssClass: opCssClass, + tooltip: `${this.translate.instant('overviews.remoteCard.browse')} ${isLocal ? 'Local' : 'Remote'} (${this.translate.instant('detailShared.pathDisplay.source')}: ${shortName})`, + }); + } + } + } + + // Destination path (mount point or target) + if ( + paths.destination && + typeof paths.destination === 'string' && + paths.destination.trim().length > 0 + ) { + const dst = paths.destination; + const isLocal = this.pathService.isLocalPath(dst); + const shortName = this.pathService.getFilename(dst) || dst; + folders.push({ + type: 'destination', + path: dst, + isLocal, + icon: isLocal ? 'folder' : 'folder-open', + cssClass: opCssClass, + tooltip: `${this.translate.instant('overviews.remoteCard.browse')} ${isLocal ? 'Local' : 'Remote'} (${this.translate.instant('detailShared.pathDisplay.destination')}: ${shortName})`, + }); + } + + return folders; + }); + + // ── Actions ─────────────────────────────────────────────────────────────── + + onRowClick(): void { + this.selectedChange.emit(this.quickRun().id); + } + + onStart(event: MouseEvent): void { + event.stopPropagation(); + if (this.isActionInProgress()) return; + this.startRun.emit(this.quickRun()); + } + + onStop(event: MouseEvent): void { + event.stopPropagation(); + if (this.isActionInProgress()) return; + this.stopRun.emit(this.quickRun()); + } + + onEdit(event: MouseEvent): void { + event.stopPropagation(); + this.editRun.emit(this.quickRun()); + } + + onRemoteNameClick(event: MouseEvent): void { + event.stopPropagation(); + this.openRemoteDetail.emit(this.quickRun().remoteName); + } + + onOpenFolderClick(path: string, event: Event): void { + event.stopPropagation(); + if (this.isFolderOpening()) return; + this.openInFiles.emit({ + remoteName: this.quickRun().remoteName, + path, + operationType: this.quickRun().operationType, + }); + (event.currentTarget as HTMLElement)?.blur(); + } +} diff --git a/src/app/flow/quick-run/quick-run-editor/quick-run-editor.component.html b/src/app/flow/quick-run/quick-run-editor/quick-run-editor.component.html new file mode 100755 index 000000000..adb5fb563 --- /dev/null +++ b/src/app/flow/quick-run/quick-run-editor/quick-run-editor.component.html @@ -0,0 +1,262 @@ +
+ +

+ {{ + (targetQuickRun() ? 'flow.quickRun.editor.editTitle' : 'flow.quickRun.editor.createTitle') + | translate + }} +

+ + +
+ +
+ + +
+ + @for (tab of flagTabs(); track tab.key) { + + } + +
+ + +
+ + + + + + + + + +
+
+ + + {{ 'flow.quickRun.editor.name' | translate }} + + @if (form.get('name')?.hasError('required') && form.get('name')?.touched) { + {{ 'flow.quickRun.editor.nameRequired' | translate }} + } + + + + + {{ 'flow.quickRun.editor.description' | translate }} + + + + + + {{ 'flow.quickRun.editor.remote' | translate }} + + @for (remote of remotes(); track remote.name) { + + + {{ remote.name }} + + } + @if (remotes().length === 0) { + {{ 'flow.quickRun.editor.noRemotes' | translate }} + } + + @if (form.get('remoteName')?.hasError('required') && form.get('remoteName')?.touched) { + {{ 'flow.quickRun.editor.remoteRequired' | translate }} + } + +
+ + +
+
+ @for (op of operations; track op.key) { + + } +
+
+
+ + + @if (form.get('remoteName')?.value) { +
+
+ @if (activeTab() === 'runtimeRemote') { + + + + } @else { + @if (activeTab() === currentOpType()) { + + + } + + + + } +
+
+ } @else { +
+ + {{ 'flow.quickRun.editor.selectRemoteFirst' | translate }} +
+ } +
+
+
+ +
+ + +
diff --git a/src/app/flow/quick-run/quick-run-editor/quick-run-editor.component.scss b/src/app/flow/quick-run/quick-run-editor/quick-run-editor.component.scss new file mode 100755 index 000000000..14d40a801 --- /dev/null +++ b/src/app/flow/quick-run/quick-run-editor/quick-run-editor.component.scss @@ -0,0 +1,133 @@ +:host { + --sidebar-icon-width: 56px; +} + +main { + flex-direction: column; + padding: 0 !important; + overflow: hidden !important; +} + +.editor-sidenav-container { + flex: 1; + width: 100%; + height: 100%; + background: var(--window-bg-color); +} +.modal-sidebar { + width: var(--sidebar-width); + background: var(--sidebar-bg-color); + display: flex; + flex-direction: column; + overflow: hidden; + height: 100%; + + .step-indicator-container { + flex: 1; + overflow-y: auto; + } + + .step-indicator { + padding: var(--space-xs); + + ::ng-deep .mat-mdc-list-item { + border-radius: var(--radius-sm); + margin-bottom: 4px; + max-height: 40px; + transition: background-color 0.2s ease; + + &.mdc-list-item--activated { + background: var(--selected-bg-color); + } + + &.mdc-list-item--disabled { + opacity: 0.5; + background: transparent !important; + } + + .mat-mdc-list-item-icon { + font-size: var(--icon-size-sm); + width: var(--icon-size-sm); + height: var(--icon-size-sm); + color: var(--dim-color); + } + + .mat-mdc-list-item-title { + font-size: var(--font-size-md); + font-weight: 500; + } + } + } + + .sidebar-footer { + padding: var(--space-xs); + border-top: 1px solid var(--border-color); + + .step-indicator { + padding: 0 !important; + } + } +} + +.modal-content { + position: relative; + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + background: var(--window-bg-color); +} + +/* ── Top Configuration Bar ── */ +.top-config-section { + background: var(--surface-variant); + display: flex; + flex-direction: column; + flex-shrink: 0; + + .identity-remote-row { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + padding: var(--space-sm) var(--space-md) 0; + gap: var(--space-sm); + @media (max-width: 768px) { + grid-template-columns: 1fr; + } + } + + .op-selector-bar { + display: flex; + align-items: center; + gap: var(--space-sm); + + .op-chips-group { + display: flex; + align-items: center; + gap: var(--space-xs); + overflow-x: auto; + padding: var(--space-xxs) var(--space-md); + } + } +} + +.op-option-icon { + margin-right: var(--space-xs); + vertical-align: middle; + font-size: 18px; + width: 18px; + height: 18px; +} + +/* ── Config Tabs Section ── */ +.config-tabs-section { + flex: 1; + display: flex; + flex-direction: column; + + .tab-content { + padding: var(--space-md); + display: flex; + flex-direction: column; + gap: var(--space-md); + } +} diff --git a/src/app/flow/quick-run/quick-run-editor/quick-run-editor.component.ts b/src/app/flow/quick-run/quick-run-editor/quick-run-editor.component.ts new file mode 100755 index 000000000..41dbbf0ad --- /dev/null +++ b/src/app/flow/quick-run/quick-run-editor/quick-run-editor.component.ts @@ -0,0 +1,1157 @@ +import { + Component, + ChangeDetectionStrategy, + DestroyRef, + computed, + effect, + inject, + input, + output, + signal, + untracked, + afterNextRender, + OnInit, +} from '@angular/core'; +import { + FormBuilder, + FormGroup, + FormArray, + FormControl, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatListModule } from '@angular/material/list'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatSidenavModule, MatDrawerMode } from '@angular/material/sidenav'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { CdkMenuModule } from '@angular/cdk/menu'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { + AppConfig, + FlagType, + OperationDefinition, + PrimaryActionType, + QuickRun, + QuickRunConfig, + QuickRunInput, + RcConfigOption, + TemplateCategory, + PROFILE_ICONS, + ALL_PRIMARY_ACTIONS, + OPERATION_REGISTRY, +} from '@app/types'; +import { OperationConfigComponent } from 'src/app/shared/remote-config/app-operation-config/app-operation-config.component'; +import { FlagConfigStepComponent } from 'src/app/shared/remote-config/flag-config-step/flag-config-step.component'; +import { RemoteConfigStepComponent } from 'src/app/shared/remote-config/remote-config-step/remote-config-step.component'; +import { AlertBannerComponent } from 'src/app/shared/components/alert-banner/alert-banner.component'; +import { SearchContainerComponent } from 'src/app/shared/components/search-container/search-container.component'; +import { CliImportComponent } from 'src/app/shared/remote-config/cli-import/cli-import.component'; +import { ImportResult } from 'src/app/services/remote/cli-flag-mapper.service'; +import { ObscureToolComponent } from 'src/app/shared/remote-config/obscure-tool/obscure-tool.component'; +import { + PresetTemplateBarComponent, + ApplyTemplateEvent, +} from 'src/app/shared/remote-config/preset-template-bar/preset-template-bar.component'; +import { FlagConfigService } from 'src/app/services/remote/flag-config.service'; +import { RemoteManagementService } from 'src/app/services/remote/remote-management.service'; +import { RemoteFacadeService } from 'src/app/services/facade/remote-facade.service'; +import { QuickRunService } from 'src/app/services/flow/quick-run.service'; +import { QuickRunEditorModalOptions } from 'src/app/services/ui/modal.service'; +import { RemotePresetsService } from 'src/app/services/remote/remote-presets'; +import { NotificationService } from 'src/app/services/ui/notification.service'; +import { IconService } from 'src/app/services/ui/icon.service'; +import { PathService, DefaultPathOp } from 'src/app/services/infrastructure/platform/path.service'; +import { PathInspectionService } from 'src/app/services/infrastructure/platform/path-inspection.service'; +import { RcloneValueMapperService } from 'src/app/services/remote/rclone-value-mapper.service'; +import { EscapeCloseDirective } from 'src/app/shared/directives/escape-close.directive'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +const ALL_FLAG_TYPES = [ + 'sync', + 'copy', + 'move', + 'bisync', + 'mount', + 'serve', + 'check', + 'delete', + 'copyurl', + 'vfs', + 'filter', + 'backend', +] as const; + +/** + * Inline editor panel for the Flow workspace's Quick Run feature. + */ +@Component({ + selector: 'app-quick-run-editor', + hostDirectives: [EscapeCloseDirective], + imports: [ + ReactiveFormsModule, + MatIconModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatDividerModule, + MatTabsModule, + MatListModule, + MatProgressBarModule, + MatSidenavModule, + CdkMenuModule, + TranslatePipe, + OperationConfigComponent, + FlagConfigStepComponent, + RemoteConfigStepComponent, + AlertBannerComponent, + SearchContainerComponent, + CliImportComponent, + ObscureToolComponent, + PresetTemplateBarComponent, + ], + templateUrl: './quick-run-editor.component.html', + styleUrls: ['./quick-run-editor.component.scss', '../../../styles/_shared-modal.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class QuickRunEditorComponent implements OnInit { + private readonly fb = inject(FormBuilder); + private readonly quickRunService = inject(QuickRunService); + private readonly flagConfigService = inject(FlagConfigService); + private readonly remoteManagementService = inject(RemoteManagementService); + private readonly remoteFacade = inject(RemoteFacadeService); + private readonly remotePresetsService = inject(RemotePresetsService); + private readonly notificationService = inject(NotificationService); + private readonly pathService = inject(PathService); + private readonly pathInspectionService = inject(PathInspectionService); + private readonly translate = inject(TranslateService); + private readonly valueMapper = inject(RcloneValueMapperService); + readonly iconService = inject(IconService); + private readonly destroyRef = inject(DestroyRef); + private readonly dialogRef = inject(MatDialogRef, { optional: true }); + private readonly dialogData = inject(MAT_DIALOG_DATA, { + optional: true, + }); + + /** When set, the editor loads this quick run for editing. */ + readonly editTarget = input(null); + /** Resolved target QuickRun whether passed via dialog data or input property. */ + readonly targetQuickRun = computed(() => this.dialogData?.quickRun ?? this.editTarget()); + /** Emitted when the user cancels or after a successful save. */ + readonly closed = output(); + + // ── Form ────────────────────────────────────────────────────────────────── + + /** + * Top-level form built with static sub-groups for all flag types, matching + * `RemoteConfigStateService.createRemoteConfigForm()` structure. + */ + readonly form: FormGroup = this.createForm(); + + readonly runtimeRemoteFields = signal([]); + readonly isLoadingRuntimeRemoteFields = signal(false); + readonly runtimeRemoteForm = signal( + this.fb.group({ + type: [''], + }) + ); + private seedRcloneConfig?: Record; + private destPathGeneration = 0; + + // ── UI state ────────────────────────────────────────────────────────────── + + readonly isSaving = this.quickRunService.isSaving; + readonly showSearch = signal(false); + readonly showCliImport = signal(false); + readonly showObscureTool = signal(false); + readonly activeTab = signal('sync'); + readonly isSidebarOpen = signal(true); + readonly sidebarMode = signal('side'); + readonly isLoadingFlags = signal(false); + readonly searchQuery = signal(''); + readonly currentOpType = signal('sync'); + readonly currentRemoteName = signal(''); + + readonly activeSensitiveFields = computed(() => { + const tab = this.activeTab(); + const fields: RcConfigOption[] = + tab === 'runtimeRemote' ? this.runtimeRemoteFields() : this.getFlagFields(tab); + + return this.valueMapper.extractSensitiveFields(fields); + }); + + readonly remotes = computed(() => this.remoteFacade.orderedVisibleRemotes()); + readonly existingRemoteNames = computed(() => this.remotes().map(r => r.name)); + readonly selectedRemoteType = computed(() => { + const rName = (this.currentRemoteName() || '').replace(/:+$/, '').trim(); + if (!rName) return ''; + const match = this.remotes().find(r => r.name.replace(/:+$/, '').trim() === rName); + return match?.type ?? ''; + }); + readonly operations: readonly OperationDefinition[] = OPERATION_REGISTRY.filter( + op => op.isPrimary + ) as readonly OperationDefinition[]; + + /** Dynamic flag fields per FlagType (operation + vfs + filter + backend). */ + readonly dynamicFlagFields = signal>({}); + + readonly existingProfiles = signal>({}); + readonly highlightedFields = signal>(new Set()); + + /** + * Tab definitions. The operation tab is always shown; VFS/Filter/Backend + * are "shared profile" tabs that any operation can optionally configure. + */ + readonly flagTabs = computed<{ key: FlagType | 'runtimeRemote'; label: string; icon: string }[]>( + () => { + const op = this.currentOpType() as FlagType; + const tabs: { key: FlagType | 'runtimeRemote'; label: string; icon: string }[] = [ + { key: op, label: this.getOperationLabel(op), icon: PROFILE_ICONS[op] ?? 'operations' }, + ]; + + if (op === 'mount' || op === 'serve') { + tabs.push({ + key: 'vfs', + label: 'flow.quickRun.editor.tabVfs', + icon: PROFILE_ICONS['vfs'] ?? 'vfs', + }); + } + + tabs.push( + { + key: 'filter', + label: 'flow.quickRun.editor.tabFilter', + icon: PROFILE_ICONS['filter'] ?? 'filter', + }, + { + key: 'backend', + label: 'flow.quickRun.editor.tabBackend', + icon: PROFILE_ICONS['backend'] ?? 'database', + }, + { + key: 'runtimeRemote', + label: 'flow.quickRun.editor.tabRuntimeRemote', + icon: PROFILE_ICONS['runtimeRemote'] ?? 'gear', + } + ); + + return tabs; + } + ); + + constructor() { + afterNextRender(() => this.setupResponsiveLayout()); + + effect(() => { + const all = this.flagConfigService.allFlagFields(); + if (all) { + this.dynamicFlagFields.set(all as Record); + untracked(() => this.syncAllDynamicControls()); + } + }); + + effect(() => { + const type = this.selectedRemoteType(); + if (!type) { + this.runtimeRemoteFields.set([]); + this.runtimeRemoteForm.set(this.fb.group({ type: [''] })); + } else { + untracked(() => { + void this.loadRuntimeRemoteFields(type); + }); + } + }); + + this.form.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.formVersion.update(v => v + 1)); + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + ngOnInit(): void { + const target = this.targetQuickRun(); + const cloneData = this.dialogData?.cloneData; + const initOp = this.dialogData?.initialOpType; + const initRemote = this.dialogData?.initialRemoteName; + if (target) { + this.form.patchValue({ + name: target.name, + description: target.description ?? '', + operationType: target.operationType, + remoteName: target.remoteName, + }); + this.currentOpType.set(target.operationType); + this.activeTab.set(target.operationType as FlagType); + this.currentRemoteName.set(target.remoteName); + this.populateFormFromSeed(target.config); + } else if (cloneData) { + this.form.patchValue({ + name: cloneData.name, + description: cloneData.description ?? '', + operationType: cloneData.operationType, + remoteName: cloneData.remoteName, + }); + this.currentOpType.set(cloneData.operationType); + this.activeTab.set(cloneData.operationType as FlagType); + this.currentRemoteName.set(cloneData.remoteName); + this.populateFormFromSeed(cloneData.config); + } else { + if (initOp) { + this.selectOperation(initOp); + } + if (initRemote) { + this.form.patchValue({ remoteName: initRemote }); + this.currentRemoteName.set(initRemote); + } + } + + void this.loadAllFlagFields(); + + const opCtrl = this.form.get('operationType'); + if (opCtrl) { + opCtrl.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((op: unknown) => { + const opType = op as PrimaryActionType; + const prevOp = this.currentOpType(); + this.currentOpType.set(opType); + if (prevOp !== opType) { + this.rebuildOpConfigGroup(prevOp, opType); + } + this.syncAllDynamicControls(); + this.checkAndSetDefaultDestPath(); + if (this.activeTab() === (prevOp as FlagType)) { + this.activeTab.set(opType as FlagType); + } + }); + } + + const remoteCtrl = this.form.get('remoteName'); + if (remoteCtrl) { + remoteCtrl.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((name: unknown) => { + const rName = (name as string) ?? ''; + this.currentRemoteName.set(rName); + this.checkAndSetDefaultDestPath(); + }); + } + } + + // ── Form creation ───────────────────────────────────────────────────────── + + private createForm(): FormGroup { + const configControls: Record = {}; + for (const flag of ALL_FLAG_TYPES) { + const isShared = flag === 'vfs' || flag === 'filter' || flag === 'backend'; + configControls[`${flag}Config`] = isShared + ? this.createSharedConfigGroup() + : this.createOpConfigGroup(flag as PrimaryActionType); + } + + return this.fb.group({ + name: ['', [Validators.required, Validators.minLength(1)]], + description: [''], + operationType: ['sync' as PrimaryActionType, Validators.required], + remoteName: ['', Validators.required], + ...configControls, + }); + } + + getOpFormGroup(opType: string): FormGroup { + return (this.form.get(`${opType}Config`) as FormGroup) ?? this.fb.group({}); + } + + private rebuildOpConfigGroup(prevOp: PrimaryActionType, newOp: PrimaryActionType): void { + const prevGroup = this.form.get(`${prevOp}Config`) as FormGroup | null; + const prevRaw = prevGroup?.getRawValue() as Record | undefined; + + // Build a seed from the shared app-config fields so they carry over. + const seed: QuickRunConfig | undefined = prevRaw + ? { + app: { + autoStart: !!prevRaw['autoStart'], + showOnTray: prevRaw['showOnTray'] !== undefined ? !!prevRaw['showOnTray'] : true, + cronEnabled: !!prevRaw['cronEnabled'], + cronExpression: (prevRaw['cronExpression'] as string | null) ?? null, + watchEnabled: !!prevRaw['watchEnabled'], + watchDelay: (prevRaw['watchDelay'] as number) ?? 5, + watchChangedOnly: !!prevRaw['watchChangedOnly'], + }, + rclone: {}, + } + : undefined; + + const newGroup = this.createOpConfigGroup(newOp, seed); + this.form.setControl(`${newOp}Config`, newGroup); + } + + private createSharedConfigGroup(): FormGroup { + return this.fb.group({ + options: this.fb.group({}), + }); + } + + private createOpConfigGroup(opType: PrimaryActionType, seed?: QuickRunConfig): FormGroup { + const app = seed?.app; + const rclone = seed?.rclone as Record | undefined; + + const isSingleSource = + opType === 'mount' || opType === 'serve' || opType === 'bisync' || opType === 'archivecreate'; + const seedSourcePath = this.extractSourcePath(rclone); + const seedDestPath = this.extractDestPath(rclone); + + const source = isSingleSource + ? this.createPathGroup(seedSourcePath) + : this.fb.array([this.createPathGroup(seedSourcePath)]); + + const hasDest = opType !== 'serve' && opType !== 'delete'; + const dest = hasDest ? this.createDestGroup(seedDestPath) : null; + + const optionsGroup = this.fb.group({}); + if (rclone && typeof rclone === 'object') { + for (const [k, v] of Object.entries(rclone)) { + if (['srcFs', 'dstFs', 'path1', 'path2', 'fs', 'mountPoint'].includes(k)) continue; + if (typeof v === 'object' && v !== null && !Array.isArray(v)) continue; + optionsGroup.addControl(k, new FormControl(v)); + } + const opSubObj = rclone[opType] as Record | undefined; + if (opSubObj && typeof opSubObj === 'object') { + for (const [k, v] of Object.entries(opSubObj)) { + if (['srcFs', 'dstFs', 'path1', 'path2', 'fs', 'mountPoint'].includes(k)) continue; + if (typeof v === 'object' && v !== null && !Array.isArray(v)) continue; + if (!optionsGroup.contains(k)) { + optionsGroup.addControl(k, new FormControl(v)); + } + } + } + } + + const group: Record = { + autoStart: [app?.autoStart ?? false], + showOnTray: [app?.showOnTray ?? true], + cronEnabled: [app?.cronEnabled ?? false], + cronExpression: [app?.cronExpression ?? null], + watchEnabled: [app?.watchEnabled ?? false], + watchDelay: [app?.watchDelay ?? 5], + watchChangedOnly: [app?.watchChangedOnly ?? false], + source, + options: optionsGroup, + }; + if (dest) group['dest'] = dest; + + return this.fb.group(group); + } + + private async loadRuntimeRemoteFields(type: string): Promise { + this.isLoadingRuntimeRemoteFields.set(true); + try { + const fields = await this.remoteManagementService.getRemoteConfigFields(type); + this.runtimeRemoteFields.set(fields); + + const group: FormGroup = this.fb.group({ + type: [type], + }); + const runtimeSeed = + (this.seedRcloneConfig?.['runtimeRemote'] as Record | undefined) ?? + this.seedRcloneConfig; + for (const f of fields) { + const key = f.Name || f.FieldName; + if (!key || key === 'type') continue; + const seedVal = runtimeSeed?.[key]; + const val = seedVal !== undefined ? seedVal : (f.Value ?? f.Default ?? null); + group.addControl(key, new FormControl(val, f.Required ? [Validators.required] : [])); + } + this.runtimeRemoteForm.set(group); + } catch (err) { + console.warn('[QuickRunEditor] loadRuntimeRemoteFields failed:', err); + } finally { + this.isLoadingRuntimeRemoteFields.set(false); + } + } + + private populateFormFromSeed(seed?: QuickRunConfig): void { + if (!seed || !seed.rclone) return; + const rawRclone = seed.rclone as Record; + this.seedRcloneConfig = rawRclone; + const opType = this.currentOpType(); + + const opGroup = this.createOpConfigGroup(opType, seed); + this.form.setControl(`${opType}Config`, opGroup); + + for (const shared of ['vfs', 'filter', 'backend'] as const) { + const sharedObj = rawRclone[shared] as Record | undefined; + if (!sharedObj || typeof sharedObj !== 'object') continue; + const optsGroup = this.form.get(`${shared}Config.options`) as FormGroup | null; + if (!optsGroup) continue; + + for (const [k, v] of Object.entries(sharedObj)) { + if (!optsGroup.contains(k)) { + optsGroup.addControl(k, new FormControl(v)); + } else { + optsGroup.get(k)?.setValue(v); + } + } + } + } + + private createPathGroup(initialPath?: string): FormGroup { + let type = 'currentRemote'; + let path = initialPath ?? ''; + let remote = ''; + + if (path) { + if (path.startsWith('/') || /^[a-zA-Z]:[/\\]/.test(path)) { + type = 'local'; + } else if (path.includes(':')) { + const colonIdx = path.indexOf(':'); + const rName = path.slice(0, colonIdx); + const relPath = path.slice(colonIdx + 1); + const curRemote = (this.currentRemoteName() || '').replace(/:+$/, ''); + if (rName === curRemote) { + type = 'currentRemote'; + path = relPath; + } else { + type = `otherRemote:${rName}`; + path = relPath; + remote = rName; + } + } + } + + return this.fb.group({ + type: [type], + path: [path], + remote: [remote], + filename: [''], + }); + } + + private createDestGroup(initialPath: string | undefined): FormGroup { + let type = 'local'; + let path = initialPath ?? ''; + let remote = ''; + + if (path) { + if (path.startsWith('/') || /^[a-zA-Z]:[/\\]/.test(path)) { + type = 'local'; + } else if (path.includes(':')) { + const colonIdx = path.indexOf(':'); + const rName = path.slice(0, colonIdx); + const relPath = path.slice(colonIdx + 1); + const curRemote = (this.currentRemoteName() || '').replace(/:+$/, ''); + if (rName === curRemote) { + type = 'currentRemote'; + path = relPath; + } else { + type = `otherRemote:${rName}`; + path = relPath; + remote = rName; + } + } + } + + return this.fb.group({ + type: [type], + path: [path], + remote: [remote], + }); + } + + private extractSourcePath(rclone?: Record): string { + if (!rclone) return ''; + const opType = this.currentOpType(); + const subObj = rclone[opType] as Record | undefined; + const src = + rclone['srcFs'] ?? + rclone['path1'] ?? + rclone['fs'] ?? + rclone['source'] ?? + subObj?.['srcFs'] ?? + subObj?.['path1'] ?? + subObj?.['fs'] ?? + subObj?.['source']; + if (Array.isArray(src)) { + return src[0] != null ? String(src[0]) : ''; + } + return src != null ? String(src) : ''; + } + + private extractDestPath(rclone?: Record): string { + if (!rclone) return ''; + const opType = this.currentOpType(); + const subObj = rclone[opType] as Record | undefined; + const dst = + rclone['mountPoint'] ?? + rclone['dstFs'] ?? + rclone['path2'] ?? + rclone['dest'] ?? + subObj?.['mountPoint'] ?? + subObj?.['dstFs'] ?? + subObj?.['path2'] ?? + subObj?.['dest']; + if (Array.isArray(dst)) { + return dst[0] != null ? String(dst[0]) : ''; + } + return dst != null ? String(dst) : ''; + } + + private async loadAllFlagFields(): Promise { + this.isLoadingFlags.set(true); + try { + let all = this.flagConfigService.allFlagFields(); + if (!all) { + await this.flagConfigService.loadAllFlagFields(); + all = this.flagConfigService.allFlagFields(); + } + if (all) { + this.dynamicFlagFields.set(all as Record); + this.syncAllDynamicControls(); + } + } catch (err) { + console.warn('[QuickRunEditor] loadAllFlagFields failed:', err); + } finally { + this.isLoadingFlags.set(false); + } + } + + /** + * Sync dynamic flag controls into the `options` FormGroup of EVERY flag + * type (operation + vfs + filter + backend). This is the equivalent of + * `RemoteConfigStateService.addDynamicFieldsToForm()`. + */ + private syncAllDynamicControls(): void { + const fields = this.dynamicFlagFields(); + if (!fields) return; + + for (const type of ALL_FLAG_TYPES) { + const configKey = `${type}Config`; + const configGroup = this.form.get(configKey) as FormGroup | null; + if (!configGroup) continue; + const optionsGroup = configGroup.get('options') as FormGroup | null; + if (!optionsGroup) continue; + const typeFields = fields[type] ?? []; + this.syncDynamicControls(optionsGroup, typeFields); + } + } + + /** + * Add FormControl instances for each field that doesn't already exist on + * the group. Existing controls are preserved. + */ + private syncDynamicControls(group: FormGroup, fields: RcConfigOption[]): void { + for (const f of fields) { + const key = f.Name || f.FieldName; + if (!key || group.contains(key)) continue; + group.addControl( + key, + new FormControl(f.Value ?? f.Default ?? null, f.Required ? [Validators.required] : []) + ); + } + } + + private checkAndSetDefaultDestPath(): void { + if (this.targetQuickRun() || this.dialogData?.cloneData) return; + const opType = this.currentOpType(); + const remoteName = this.currentRemoteName(); + if (!remoteName || (opType !== 'mount' && opType !== 'bisync')) return; + + const opKey = `${opType}Config`; + const opGroup = this.form.get(opKey) as FormGroup | null; + const destGroup = opGroup?.get('dest') as FormGroup | null; + const pathCtrl = destGroup?.get('path'); + + if (destGroup && pathCtrl && (!pathCtrl.value || pathCtrl.pristine)) { + const gen = ++this.destPathGeneration; + void this.pathInspectionService + .resolveDefaultPath(remoteName, opType as DefaultPathOp) + .then(defaultPath => { + if (gen !== this.destPathGeneration) return; + if (destGroup && pathCtrl && (!pathCtrl.value || pathCtrl.pristine)) { + destGroup.patchValue({ type: 'local', path: defaultPath }); + } + }); + } + } + + private setupResponsiveLayout(): void { + const mql = window.matchMedia('(min-width: 768px)'); + const update = (matches: boolean): void => { + this.sidebarMode.set(matches ? 'side' : 'over'); + if (!matches) { + this.isSidebarOpen.set(false); + } + }; + const handler = (e: MediaQueryListEvent): void => update(e.matches); + + update(mql.matches); + mql.addEventListener('change', handler); + this.destroyRef.onDestroy(() => mql.removeEventListener('change', handler)); + } + + // ── Actions ─────────────────────────────────────────────────────────────── + + toggleSidebar(): void { + this.isSidebarOpen.update(v => !v); + } + + selectTab(tabKey: FlagType | 'runtimeRemote'): void { + this.activeTab.set(tabKey); + if (this.sidebarMode() === 'over') { + this.isSidebarOpen.set(false); + } + } + + selectOperation(opKey: PrimaryActionType | string): void { + const newOp = opKey as PrimaryActionType; + const prevOp = this.currentOpType(); + if (prevOp === newOp) return; + + this.form.get('operationType')?.setValue(newOp); + } + + close(): void { + this.closed.emit(); + this.dialogRef?.close(); + } + + async submit(): Promise { + const opGroup = this.getOpFormGroup(this.currentOpType()); + if (this.form.get('name')?.invalid || this.form.get('remoteName')?.invalid || opGroup.invalid) { + this.form.markAllAsTouched(); + return; + } + const input = this.buildInput(); + await this.quickRunService.save(input); + this.closed.emit(); + this.dialogRef?.close(true); + } + + toggleSearch(): void { + this.showSearch.update(v => !v); + if (!this.showSearch()) { + this.searchQuery.set(''); + } else { + this.showCliImport.set(false); + this.showObscureTool.set(false); + } + if (this.sidebarMode() === 'over') { + this.isSidebarOpen.set(false); + } + } + + toggleCliImport(): void { + this.showCliImport.update(v => !v); + if (this.showCliImport()) { + this.showSearch.set(false); + this.showObscureTool.set(false); + if (this.sidebarMode() === 'over') this.isSidebarOpen.set(false); + } + } + + toggleObscureTool(): void { + this.showObscureTool.update(v => !v); + if (this.showObscureTool()) { + this.showSearch.set(false); + this.showCliImport.set(false); + if (this.sidebarMode() === 'over') this.isSidebarOpen.set(false); + } + } + + applyObscuredValue(key: string, value: string): void { + const tab = this.activeTab(); + if (tab === 'runtimeRemote') { + const ctrl = this.runtimeRemoteForm().get(key); + if (ctrl) { + ctrl.setValue(value); + ctrl.markAsDirty(); + ctrl.markAsTouched(); + } + } else { + const groupKey = `${tab}Config`; + const configGroup = this.form.get(groupKey) as FormGroup | null; + const optsGroup = configGroup?.get('options') as FormGroup | null; + const ctrl = optsGroup?.get(key); + if (ctrl) { + ctrl.setValue(value); + ctrl.markAsDirty(); + ctrl.markAsTouched(); + } + } + } + + applyDefaultPresets(): void { + const remoteType = this.selectedRemoteType(); + if (!remoteType) { + const warningMsg = this.translate.instant('wizards.presets.noRemoteSelected'); + this.notificationService.showWarning( + warningMsg !== 'wizards.presets.noRemoteSelected' + ? warningMsg + : 'Please select a remote first' + ); + return; + } + + const preset = this.remotePresetsService.resolvePresets(remoteType); + + this.patchGroupOptions('vfsConfig', preset.vfs); + this.patchGroupOptions('backendConfig', preset.backend); + + const currentOp = this.currentOpType(); + if (currentOp === 'mount' && preset.mount) { + this.patchGroupOptions('mountConfig', preset.mount); + } + + if (preset.remote) { + const rtGroup = this.runtimeRemoteForm(); + for (const [k, v] of Object.entries(preset.remote)) { + if (rtGroup.contains(k)) { + rtGroup.get(k)?.setValue(v); + } + } + } + + const msg = this.translate.instant('wizards.presets.applied'); + this.notificationService.showSuccess( + msg !== 'wizards.presets.applied' ? msg : 'Default presets applied successfully' + ); + } + + private parseAndSetPath(targetGroup: FormGroup, rawPath: string, currentRemote: string): void { + const existingRemotes = this.existingRemoteNames() || []; + const defaultType = currentRemote ? 'currentRemote' : 'local'; + const parsed = this.pathService.parseFsString( + rawPath.trim(), + defaultType, + currentRemote, + existingRemotes + ); + + targetGroup.patchValue({ + type: parsed.type, + path: parsed.path, + ...(targetGroup.contains('remote') ? { remote: parsed.remote } : {}), + }); + targetGroup.markAsDirty(); + } + + onCliImportApply(event: { + result: ImportResult; + importSourcePath: boolean; + importDestPath: boolean; + }): void { + const { result, importSourcePath, importDestPath } = event; + + if (result.verb && result.verb !== this.currentOpType()) { + if (ALL_PRIMARY_ACTIONS.includes(result.verb as PrimaryActionType)) { + this.selectOperation(result.verb as PrimaryActionType); + } + } + + const currentOp = this.currentOpType(); + const opGroup = this.getOpFormGroup(currentOp); + const remoteName = this.currentRemoteName(); + + if (importSourcePath && result.sourcePath && opGroup) { + const sourceCtrl = opGroup.get('source'); + if (sourceCtrl instanceof FormArray && sourceCtrl.length > 0) { + this.parseAndSetPath(sourceCtrl.at(0) as FormGroup, result.sourcePath, remoteName); + } else if (sourceCtrl instanceof FormGroup) { + this.parseAndSetPath(sourceCtrl, result.sourcePath, remoteName); + } + } + + if (importDestPath && result.destPath && opGroup) { + const destCtrl = opGroup.get('dest') as FormGroup | null; + if (destCtrl) { + this.parseAndSetPath(destCtrl, result.destPath, remoteName); + } + } + + const newHighlighted = new Set(); + let appliedCount = 0; + + for (const cls of result.classified) { + if (cls.status !== 'mapped' || !cls.fieldName) continue; + + const flagType = cls.flagType || currentOp; + const targetGroup = + flagType === 'runtimeRemote' + ? this.runtimeRemoteForm() + : (this.form.get(`${flagType}Config.options`) as FormGroup | null); + + if (targetGroup) { + const key = cls.fieldName; + if (!targetGroup.contains(key)) { + targetGroup.addControl(key, new FormControl(cls.coercedValue)); + } else { + targetGroup.get(key)?.setValue(cls.coercedValue); + } + targetGroup.get(key)?.markAsDirty(); + newHighlighted.add(key); + appliedCount++; + } + } + + if (result.mountSubtype && currentOp === 'mount') { + const mountOpts = this.form.get('mountConfig.options') as FormGroup | null; + mountOpts?.get('mountType')?.setValue(result.mountSubtype); + } + + this.highlightedFields.set(newHighlighted); + this.showCliImport.set(false); + + const msg = this.translate.instant('wizards.cliImport.appliedToast', { count: appliedCount }); + this.notificationService.showSuccess( + msg !== 'wizards.cliImport.appliedToast' + ? msg + : `Successfully imported ${appliedCount} option(s)` + ); + } + + /** + * Build a {@link QuickRunInput} from the current form state. + */ + private buildInput(): QuickRunInput { + const opType = this.currentOpType(); + const remoteName = this.currentRemoteName(); + const name = (this.form.get('name')?.value as string).trim(); + const description = (this.form.get('description')?.value as string)?.trim() || undefined; + const inner = this.getOpFormGroup(opType); + + const rawValue = inner.getRawValue() as Record; + + const app: AppConfig = { + autoStart: !!rawValue['autoStart'], + showOnTray: rawValue['showOnTray'] !== undefined ? !!rawValue['showOnTray'] : true, + cronEnabled: !!rawValue['cronEnabled'], + cronExpression: (rawValue['cronExpression'] as string | null) ?? null, + watchEnabled: !!rawValue['watchEnabled'], + watchDelay: (rawValue['watchDelay'] as number) ?? 5, + watchChangedOnly: !!rawValue['watchChangedOnly'], + }; + + const opConfig: Record = {}; + + const sourceValue = rawValue['source']; + if (sourceValue) { + if (Array.isArray(sourceValue)) { + const paths = (sourceValue as Record[]).map(item => + this.resolvePath(item, remoteName) + ); + if (opType === 'bisync') opConfig['path1'] = paths[0] ?? ''; + else opConfig['srcFs'] = paths.length === 1 ? (paths[0] ?? '') : paths; + } else if (typeof sourceValue === 'object') { + const path = this.resolvePath(sourceValue as Record, remoteName); + if (opType === 'mount') opConfig['srcFs'] = path; + else if (opType === 'serve') opConfig['fs'] = path; + else if (opType === 'bisync') opConfig['path1'] = path; + else opConfig['srcFs'] = path; + } + } + + const destValue = rawValue['dest'] as Record | undefined; + if (destValue) { + const destPath = this.resolvePath(destValue, remoteName); + if (opType === 'mount') opConfig['mountPoint'] = destPath; + else if (opType === 'bisync') opConfig['path2'] = destPath; + else opConfig['dstFs'] = destPath; + } + + const fields = this.dynamicFlagFields(); + + const opOptsGroup = inner.get('options') as FormGroup | null; + if (opOptsGroup) { + const cleanedOp = this.valueMapper.cleanData( + opOptsGroup.getRawValue() as Record, + fields[opType as FlagType] ?? [] + ); + Object.assign(opConfig, cleanedOp); + } + + const rclone: Record = { + ...opConfig, + }; + + const sharedTypes = + opType === 'mount' || opType === 'serve' + ? (['vfs', 'filter', 'backend'] as const) + : (['filter', 'backend'] as const); + + for (const flagType of sharedTypes) { + const groupKey = `${flagType}Config`; + const configGroup = this.form.get(groupKey) as FormGroup | null; + if (!configGroup) continue; + + const optsGroup = configGroup.get('options') as FormGroup | null; + if (!optsGroup) continue; + + const optsValue = optsGroup.getRawValue() as Record; + const typeFields = fields[flagType as FlagType] ?? []; + const cleaned = this.valueMapper.cleanData(optsValue, typeFields); + if (Object.keys(cleaned).length > 0) { + rclone[flagType] = cleaned; + } + } + + const runtimeRaw = this.runtimeRemoteForm().getRawValue() as Record; + delete runtimeRaw['type']; + const cleanedRuntime = this.valueMapper.cleanData(runtimeRaw, this.runtimeRemoteFields()); + if (Object.keys(cleanedRuntime).length > 0) { + rclone['runtimeRemote'] = cleanedRuntime; + } + + return { + id: this.targetQuickRun()?.id, + name, + description, + operationType: opType, + remoteName, + config: { app, rclone }, + }; + } + + private resolvePath(item: Record, currentRemote: string): string { + const type = String(item['type'] ?? 'currentRemote'); + const rawPath = String(item['path'] ?? '').trim(); + const cleanCurrent = (currentRemote || '').replace(/:+$/, ''); + + if (type === 'currentRemote') { + if (!cleanCurrent) return rawPath; + if (rawPath.startsWith(`${cleanCurrent}:`)) return rawPath; + return rawPath ? `${cleanCurrent}:${rawPath}` : `${cleanCurrent}:`; + } + + if (type.startsWith('otherRemote:')) { + const otherRemote = type.slice('otherRemote:'.length).replace(/:+$/, ''); + if (!otherRemote) return rawPath; + if (rawPath.startsWith(`${otherRemote}:`)) return rawPath; + return rawPath ? `${otherRemote}:${rawPath}` : `${otherRemote}:`; + } + + if (type === 'local' || rawPath.startsWith('/') || /^[a-zA-Z]:[/\\]/.test(rawPath)) { + return rawPath; + } + + return rawPath; + } + + // ── Template helpers ───────────────────────────────────────────────────── + + getOperationLabel(opKey: string): string { + const def = this.operations.find(o => o.key === opKey); + return def?.typeLabel ?? def?.actionLabel ?? opKey; + } + + getFlagFields(type: FlagType | string): RcConfigOption[] { + return this.dynamicFlagFields()[type] ?? []; + } + + private readonly formVersion = signal(0); + + readonly currentValues = computed(() => { + this.formVersion(); + const raw = this.form.getRawValue(); + const fields = this.dynamicFlagFields(); + const opType = this.currentOpType(); + + const getCleanOptions = (flagType: string): Record => { + const opts = + (raw[`${flagType}Config`] as { options?: Record } | undefined)?.options ?? + {}; + const typeFields = fields[flagType as FlagType] ?? []; + return this.valueMapper.cleanData(opts, typeFields); + }; + + const isVfsApplicable = opType === 'mount' || opType === 'serve'; + + const res: Partial>> = { + vfs: isVfsApplicable ? getCleanOptions('vfs') : {}, + mount: getCleanOptions('mount'), + backend: getCleanOptions('backend'), + filter: getCleanOptions('filter'), + sync: getCleanOptions('sync'), + copy: getCleanOptions('copy'), + }; + + if (opType) { + const cleanedOp = getCleanOptions(opType); + (res as Record)[opType] = cleanedOp; + } + + return res; + }); + + private patchGroupOptions( + configKey: string, + opts?: Record, + ignoredKeys?: string[] + ): void { + if (!opts) return; + const groupKey = configKey.endsWith('Config') ? configKey : `${configKey}Config`; + const configGroup = this.form.get(groupKey) as FormGroup | null; + if (!configGroup) return; + + const optsGroup = configGroup.get('options') as FormGroup | null; + if (optsGroup) { + for (const [k, v] of Object.entries(opts)) { + if (ignoredKeys && ignoredKeys.includes(k)) continue; + if (!optsGroup.contains(k)) { + optsGroup.addControl(k, new FormControl(v)); + } else { + optsGroup.get(k)?.setValue(v); + } + } + } + } + + onApplyTemplate(event: ApplyTemplateEvent): void { + const { values } = event; + const PATH_KEYS = ['srcFs', 'dstFs', 'path1', 'path2', 'fs', 'mountPoint', 'source', 'dest']; + + if (values.vfs) this.patchGroupOptions('vfsConfig', values.vfs, PATH_KEYS); + if (values.mount) this.patchGroupOptions('mountConfig', values.mount, PATH_KEYS); + if (values.backend) this.patchGroupOptions('backendConfig', values.backend, PATH_KEYS); + if (values.filter) this.patchGroupOptions('filterConfig', values.filter, PATH_KEYS); + if (values.sync) this.patchGroupOptions('syncConfig', values.sync, PATH_KEYS); + if (values.copy) this.patchGroupOptions('copyConfig', values.copy, PATH_KEYS); + + const currentOp = this.currentOpType(); + if (currentOp) { + const opGroup = this.getOpFormGroup(currentOp); + const opOpts = (values as Record | undefined>)[currentOp]; + if (opOpts && typeof opOpts === 'object') { + this.patchGroupOptions(`${currentOp}Config`, opOpts, PATH_KEYS); + + const srcPath = (opOpts['srcFs'] ?? opOpts['path1'] ?? opOpts['fs'] ?? opOpts['source']) as + string | undefined; + if (srcPath && typeof srcPath === 'string') { + const sourceCtrl = opGroup.get('source'); + if (sourceCtrl instanceof FormArray && sourceCtrl.length > 0) { + (sourceCtrl.at(0) as FormGroup).get('path')?.setValue(srcPath); + } else if (sourceCtrl instanceof FormGroup) { + sourceCtrl.get('path')?.setValue(srcPath); + } + } + const dstPath = (opOpts['mountPoint'] ?? + opOpts['dstFs'] ?? + opOpts['path2'] ?? + opOpts['dest']) as string | undefined; + if (dstPath && typeof dstPath === 'string') { + const destCtrl = opGroup.get('dest') as FormGroup | null; + destCtrl?.get('path')?.setValue(dstPath); + } + } + } + + const msg = this.translate.instant('templates.applySuccess', { name: event.sourceName }); + this.notificationService.showSuccess(msg); + } +} diff --git a/src/app/flow/quick-run/quick-run-overview/quick-run-overview.component.html b/src/app/flow/quick-run/quick-run-overview/quick-run-overview.component.html new file mode 100644 index 000000000..f1b518bba --- /dev/null +++ b/src/app/flow/quick-run/quick-run-overview/quick-run-overview.component.html @@ -0,0 +1,234 @@ +
+ + + +
+ +
+ + @for (panel of displayPanels(); track panel.id) { +
+
+ + @if (isEditingLayout()) { +
+ + {{ panel.title | translate }} + +
+ {{ + panel.visible + ? ('generalOverview.layout.visible' | translate) + : ('generalOverview.layout.hidden' | translate) + }} + + +
+
+ } + +
+ @switch (panel.id) { + @case ('quickRuns') { + + + + + {{ 'flow.quickRun.overview.quickLaunch' | translate }} + + + + {{ quickRuns().length }} + + + + + @if (remoteGroups().length > 0) { +
+ + @for (group of remoteGroups(); track group.remoteName) { + + } +
+ } + + @if (filteredQuickRuns().length > 0) { +
+ @for (qr of filteredQuickRuns(); track qr.id) { + + } +
+ } @else if (selectedRemoteFilter(); as filterRemote) { +
+ +

+ {{ + 'flow.quickRun.overview.noRunsForRemote' + | translate: { remote: filterRemote } + }} +

+
+ + +
+
+ } @else { +
+ +

{{ 'flow.quickRun.overview.createFirst' | translate }}

+ +
+ } +
+ } + @case ('bandwidth') { + + } + @case ('system') { + + } + @case ('jobs') { + + } + @case ('serves') { + + } + @case ('automations') { + + } + } +
+
+ } +
+
+
+
diff --git a/src/app/flow/quick-run/quick-run-overview/quick-run-overview.component.scss b/src/app/flow/quick-run/quick-run-overview/quick-run-overview.component.scss new file mode 100644 index 000000000..fc20f50e7 --- /dev/null +++ b/src/app/flow/quick-run/quick-run-overview/quick-run-overview.component.scss @@ -0,0 +1,220 @@ +:host { + display: block; + height: 100%; + width: 100%; + overflow-y: auto; + padding: var(--panel-padding); + background: var(--view-bg-color); + box-sizing: border-box; + position: relative; +} + +.overview-container { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--section-gap); + max-width: 1200px; + margin: 0 auto; + width: 100%; + box-sizing: border-box; + + .overview-header-wrapper { + width: 100%; + } + + .overview-content { + display: flex; + flex-direction: column; + gap: var(--panel-padding); + width: 100%; + box-sizing: border-box; + } +} + +/* Modular Panels Layout */ +.layout-grid { + width: 100%; +} + +.quick-runs-panel { + ::ng-deep { + .mat-expansion-panel-body { + padding: 0; + } + + .mat-expansion-panel-header-description { + margin-right: 0; + } + } + + mat-panel-description { + margin-right: 0; + } + + .filter-chips { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: 0 24px var(--space-sm); + overflow-x: auto; + } + + .grid, + .quick-runs-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--space-md); + align-items: start; + padding: 2px 24px 16px; + max-height: 480px; + overflow-y: auto; + overflow-x: hidden; + } + + .empty-actions { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-wrap: wrap; + justify-content: center; + } +} + +@media (max-width: 768px) { + .quick-runs-panel .grid, + .quick-runs-panel .quick-runs-grid { + gap: var(--space-sm); + padding: 2px 16px 16px; + } +} + +/* Drag & Drop Layout Editor */ +.draggable-panel-wrapper { + border-radius: var(--card-border-radius); + border: 2px dashed transparent; + padding: 0; + transition: + border-color var(--transition-cubic), + background-color var(--transition-cubic), + padding var(--transition-cubic), + margin-bottom var(--transition-cubic); + + &.cdk-drag-dragging { + transition: none; + } + + &.editing { + border-color: rgba(var(--primary-color-rgb), 0.4); + background-color: rgba(var(--primary-color-rgb), 0.03); + padding: 16px; + margin-bottom: 24px; + + &:hover { + border-color: var(--primary-color); + background-color: rgba(var(--primary-color-rgb), 0.06); + } + } + + .panel-content-wrapper { + transition: + opacity var(--transition-cubic), + transform var(--transition-cubic); + + &.panel-hidden-visual { + opacity: 0.4; + transform: scale(0.98); + } + } + + &.cdk-drag-dragging .panel-content-wrapper { + transition: none; + } +} + +.panel-edit-controls { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + margin-bottom: 12px; + background: var(--view-bg-color); + border-radius: 8px; + box-shadow: var(--shadow-gnome); + overflow: hidden; + cursor: grab; + + &:active { + cursor: grabbing; + } + + .panel-label { + font-weight: 600; + font-size: 14px; + flex: 1; + color: var(--window-fg-color); + } + + .visibility-toggle { + display: flex; + align-items: center; + gap: var(--space-xs); + + mat-slide-toggle { + transform: scale(0.8); + } + } +} + +.custom-placeholder { + background: rgba(var(--primary-color-rgb), 0.05); + border: 2px dashed var(--primary-color); + border-radius: 16px; + min-height: 80px; + margin-bottom: 16px; + transition: transform var(--transition-cubic); +} + +.cdk-drag-preview { + box-sizing: border-box; + border-radius: 16px; + box-shadow: var(--shadow-gnome); + background-color: var(--window-bg-color); + padding: 0; + + .panel-edit-controls { + display: flex !important; + margin: 0; + border: none; + box-shadow: none; + border-bottom: 1px solid var(--border-color); + } + + .panel-content-wrapper { + display: none; + } + + .draggable-panel-wrapper { + padding: 0 !important; + border: 2px solid var(--primary-color) !important; + background: var(--view-bg-color); + } +} + +.cdk-drag-animating { + transition: transform var(--transition-cubic); +} + +.cdk-drop-list-dragging .draggable-panel-wrapper:not(.cdk-drag-placeholder) { + transition: transform var(--transition-cubic); +} + +@media (max-width: 768px) { + :host { + padding: var(--panel-padding-mobile); + } + + .overview-container { + gap: var(--section-gap-mobile); + } +} diff --git a/src/app/flow/quick-run/quick-run-overview/quick-run-overview.component.ts b/src/app/flow/quick-run/quick-run-overview/quick-run-overview.component.ts new file mode 100644 index 000000000..6f4bbabab --- /dev/null +++ b/src/app/flow/quick-run/quick-run-overview/quick-run-overview.component.ts @@ -0,0 +1,349 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + output, + signal, +} from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { + QuickRun, + StopJobEvent, + DashboardPanel, + ALL_QUICK_RUN_PANELS, + PanelConfig, + JobInfo, + ServeListItem, + Automation, + OpenInFilesEvent, +} from '@app/types'; + +import { QuickRunService } from 'src/app/services/flow/quick-run.service'; +import { JobManagementService } from 'src/app/services/operations/job-management.service'; +import { RemoteFacadeService } from 'src/app/services/facade/remote-facade.service'; +import { AutomationService } from 'src/app/services/operations/automation.service'; +import { AppSettingsService } from 'src/app/services/settings/app-settings.service'; +import { LocalStorageService } from 'src/app/services/ui/state/local-storage.service'; +import { NavigationDispatcherService } from 'src/app/services/ui/navigation-dispatcher.service'; +import { PathService } from 'src/app/services/infrastructure/platform/path.service'; +import { IconService } from 'src/app/services/ui/icon.service'; +import { UiStateService } from 'src/app/services/ui/state/ui-state.service'; + +import { OverviewHeaderComponent } from 'src/app/shared/overviews-shared/overview-header/overview-header.component'; +import { BandwidthOverviewPanelComponent } from 'src/app/shared/overviews-shared/bandwidth-overview-panel/bandwidth-overview-panel.component'; +import { SystemOverviewPanelComponent } from 'src/app/shared/overviews-shared/system-overview-panel/system-overview-panel.component'; +import { JobsOverviewPanelComponent } from 'src/app/shared/overviews-shared/jobs-overview-panel/jobs-overview-panel.component'; +import { ServesOverviewPanelComponent } from 'src/app/shared/overviews-shared/serves-overview-panel/serves-overview-panel.component'; +import { AutomationsOverviewPanelComponent } from 'src/app/shared/overviews-shared/automations-overview-panel/automations-overview-panel.component'; +import { QuickRunCardComponent } from '../quick-run-card/quick-run-card.component'; + +/** + * Enriched overview for the Quick Run workspace in Flow. + */ +@Component({ + selector: 'app-quick-run-overview', + standalone: true, + imports: [ + MatIconModule, + MatButtonModule, + MatExpansionModule, + MatSlideToggleModule, + MatSnackBarModule, + DragDropModule, + TranslatePipe, + OverviewHeaderComponent, + BandwidthOverviewPanelComponent, + SystemOverviewPanelComponent, + JobsOverviewPanelComponent, + ServesOverviewPanelComponent, + AutomationsOverviewPanelComponent, + QuickRunCardComponent, + ], + templateUrl: './quick-run-overview.component.html', + styleUrl: './quick-run-overview.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class QuickRunOverviewComponent { + private readonly quickRunService = inject(QuickRunService); + private readonly jobService = inject(JobManagementService); + private readonly appSettingsService = inject(AppSettingsService); + private readonly localStorage = inject(LocalStorageService); + private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); + private readonly automationService = inject(AutomationService); + private readonly navigationDispatcher = inject(NavigationDispatcherService); + private readonly pathService = inject(PathService); + private readonly uiStateService = inject(UiStateService); + readonly iconService = inject(IconService); + + readonly remoteFacade = inject(RemoteFacadeService); + + readonly openRemoteDetail = output(); + readonly openBackendModal = output(); + + readonly quickRuns = this.quickRunService.quickRuns; + readonly runningIds = this.quickRunService.runningIds; + readonly isEditingLayout = computed(() => this.uiStateService.isEditingOverview('quick_run')); + + readonly selectedRemoteFilter = signal(null); + + readonly remoteGroups = computed(() => { + const runs = this.quickRuns(); + const countMap = new Map(); + for (const qr of runs) { + const name = qr.remoteName || 'local'; + countMap.set(name, (countMap.get(name) || 0) + 1); + } + + const allRemotes = this.remoteFacade.orderedRemotes(); + const result = allRemotes.map(r => ({ + remoteName: r.name, + count: countMap.get(r.name) || 0, + icon: this.iconService.getIconName(r.type), + })); + + for (const [name, count] of countMap.entries()) { + if (!result.some(g => g.remoteName === name)) { + result.push({ + remoteName: name, + count, + icon: 'cloud', + }); + } + } + + return result; + }); + + readonly filteredQuickRuns = computed(() => { + const filter = this.selectedRemoteFilter(); + if (!filter) return this.quickRuns(); + return this.quickRuns().filter(qr => qr.remoteName === filter); + }); + + readonly panelOpenStates = signal>( + this.localStorage.get>('flow.quickRun.panelOpenStates', { + quickRuns: true, + bandwidth: false, + system: false, + jobs: true, + serves: false, + automations: false, + }) + ); + + readonly dashboardPanels = signal( + ALL_QUICK_RUN_PANELS.map(p => ({ ...p, visible: p.defaultVisible })) + ); + + readonly displayPanels = computed(() => + this.isEditingLayout() ? this.dashboardPanels() : this.dashboardPanels().filter(p => p.visible) + ); + + readonly totalCount = computed(() => this.quickRuns().length); + readonly activeCount = computed(() => { + const runningQrCount = this.runningIds().size; + const activeJobsCount = this.jobService.activeJobs().length; + return Math.max(runningQrCount, activeJobsCount); + }); + + constructor() { + void this.loadLayoutSettings(); + } + + setRemoteFilter(remoteName: string | null): void { + this.selectedRemoteFilter.set(remoteName); + } + + onOpenRemoteDetail(remoteName: string): void { + this.openRemoteDetail.emit(remoteName); + } + + onCreateQuickRunForRemote(remoteName: string): void { + this.quickRunService.openEditor(undefined, undefined, remoteName); + } + + onCreateQuickRun(): void { + this.quickRunService.openEditor(); + } + + onSelectQuickRunById(id: string): void { + this.quickRunService.select(id); + } + + async onStartQuickRun(qr: QuickRun): Promise { + await this.quickRunService.start(qr.id); + } + + async onStopQuickRun(qr: QuickRun): Promise { + await this.quickRunService.stop(qr.id); + } + + onEditQuickRun(qr: QuickRun): void { + this.quickRunService.openEditor(qr); + } + + isRunning(id: string): boolean { + return this.runningIds().has(id); + } + + // --- Layout management --- + toggleEditLayout(): void { + this.uiStateService.toggleLayoutEdit({ + overviewId: 'quick_run', + hasViewToggle: false, + onReset: () => this.resetLayout(), + }); + } + + resetLayout(): void { + void this.appSettingsService.saveSetting('runtime', 'quick_run_layout', { + order: [], + hidden: [], + }); + this.dashboardPanels.set(ALL_QUICK_RUN_PANELS.map(p => ({ ...p, visible: p.defaultVisible }))); + this.showSnackbar(this.translate.instant('generalOverview.layout.resetSuccess')); + } + + drop(event: CdkDragDrop): void { + this.dashboardPanels.update(panels => { + const updated = [...panels]; + moveItemInArray(updated, event.previousIndex, event.currentIndex); + return updated; + }); + this.persistLayout(); + } + + togglePanelVisibility(panelId: string): void { + this.dashboardPanels.update(panels => + panels.map(p => (p.id === panelId ? { ...p, visible: !p.visible } : p)) + ); + this.persistLayout(); + } + + protected setPanelOpenState(id: string, isOpen: boolean): void { + const updated = { ...this.panelOpenStates(), [id]: isOpen }; + this.panelOpenStates.set(updated); + this.localStorage.set('flow.quickRun.panelOpenStates', updated); + } + + private persistLayout(): void { + const order = this.dashboardPanels().map(p => p.id); + const hidden = this.dashboardPanels() + .filter(p => !p.visible) + .map(p => p.id); + void this.appSettingsService.saveSetting('runtime', 'quick_run_layout', { order, hidden }); + } + + // --- Smart Item Navigation Handlers --- + handleJobClick(job: JobInfo): void { + this.navigationDispatcher.navigateToJob(job); + } + + async handleStopJob(event: StopJobEvent): Promise { + const activeJobs = this.jobService.activeJobs(); + const target = activeJobs.find( + j => j.remote_name === event.remoteName && j.job_type === event.type + ); + if (target) { + await this.jobService.stopJob(target.jobid, event.remoteName); + } + } + + handleServeClick(serve: ServeListItem): void { + this.navigationDispatcher.navigateToServe(serve); + } + + handleStopServe(serve: ServeListItem): void { + const remoteName = this.pathService.getRemoteNameFromFs(serve.params?.fs); + if (remoteName) { + void this.remoteFacade.stopJob(remoteName, 'serve', serve.id, serve.profile); + } + } + + handleAutomationClick(automation: Automation): void { + const remoteName = + automation.remoteName || + automation.args?.remoteName || + this.quickRuns().find( + q => q.id === automation.profileName || q.name === automation.profileName + )?.remoteName; + + if (remoteName) { + this.openRemoteDetail.emit(remoteName); + } + } + + async toggleAutomation(automationId: string): Promise { + try { + await this.automationService.toggleAutomation(automationId); + } catch (error) { + console.error('Failed to toggle automation:', error); + this.showSnackbar(this.translate.instant('generalOverview.layout.toggleAutomationFailed')); + } + } + + openInFiles(event: OpenInFilesEvent | string): void { + if (typeof event === 'string') { + const { remote: remoteName, path: relativePath } = this.pathService.splitFsPath(event); + void this.remoteFacade.openRemoteInFiles(remoteName, relativePath); + } else { + void this.remoteFacade.openRemoteInFiles( + event.remoteName, + event.path, + event.profileName, + event.operationType + ); + } + } + + private async loadLayoutSettings(): Promise { + try { + const savedLayout = await this.appSettingsService.getSettingValue< + | { + order: string[]; + hidden: string[]; + } + | string[] + >('runtime.quick_run_layout'); + + if (savedLayout) { + const order: string[] = Array.isArray(savedLayout) + ? savedLayout + : (savedLayout.order ?? []); + const hiddenIds = new Set( + Array.isArray(savedLayout) ? [] : (savedLayout.hidden ?? []) + ); + + if (order.length > 0) { + const ordered = order + .map(id => ALL_QUICK_RUN_PANELS.find(p => p.id === id)) + .filter((p): p is PanelConfig => !!p) + .map(p => ({ ...p, visible: !hiddenIds.has(p.id) })); + + const seenIds = new Set(order); + const appended = ALL_QUICK_RUN_PANELS.filter(p => !seenIds.has(p.id)).map(p => ({ + ...p, + visible: p.defaultVisible, + })); + + this.dashboardPanels.set([...ordered, ...appended]); + } + } + } catch { + console.debug('Failed to load Quick Run layout settings, using defaults'); + } + } + + private showSnackbar(message: string, duration = 2000): void { + this.snackBar.open(message, this.translate.instant('common.close'), { duration }); + } +} diff --git a/src/app/flow/quick-run/quick-run-workspace/quick-run-workspace.component.html b/src/app/flow/quick-run/quick-run-workspace/quick-run-workspace.component.html new file mode 100755 index 000000000..ad063ab55 --- /dev/null +++ b/src/app/flow/quick-run/quick-run-workspace/quick-run-workspace.component.html @@ -0,0 +1,68 @@ +@if (selected(); as qr) { + + + + +
+ + + + + + + +
+
+} @else if (selectedRemote()) { + +} @else { + +} diff --git a/src/app/flow/quick-run/quick-run-workspace/quick-run-workspace.component.scss b/src/app/flow/quick-run/quick-run-workspace/quick-run-workspace.component.scss new file mode 100755 index 000000000..b15ff83e9 --- /dev/null +++ b/src/app/flow/quick-run/quick-run-workspace/quick-run-workspace.component.scss @@ -0,0 +1,22 @@ +:host { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow: hidden; + position: relative; + background: var(--view-bg-color); +} + +app-app-detail { + display: flex; + flex-direction: column; + height: 100%; + overflow-y: auto; +} + +.ellipsis-button { + position: absolute; + top: 10px; + right: 20px; +} diff --git a/src/app/flow/quick-run/quick-run-workspace/quick-run-workspace.component.ts b/src/app/flow/quick-run/quick-run-workspace/quick-run-workspace.component.ts new file mode 100755 index 000000000..49fc98dd4 --- /dev/null +++ b/src/app/flow/quick-run/quick-run-workspace/quick-run-workspace.component.ts @@ -0,0 +1,179 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { CdkMenuModule } from '@angular/cdk/menu'; +import { TranslatePipe } from '@ngx-translate/core'; + +import { + OpenInFilesEvent, + QuickRun, + QuickRunConfig, + StartJobEvent, + StopJobEvent, +} from '@app/types'; +import { QuickRunService } from 'src/app/services/flow/quick-run.service'; +import { RemoteFacadeService } from 'src/app/services/facade/remote-facade.service'; +import { UiStateService } from 'src/app/services/ui/state/ui-state.service'; +import { ModalService } from 'src/app/services/ui/modal.service'; +import { AppDetailComponent } from 'src/app/features/components/dashboard/app-detail/app-detail.component'; +import { GeneralDetailComponent } from 'src/app/features/components/dashboard/general-detail/general-detail.component'; +import { QuickRunOverviewComponent } from '../quick-run-overview/quick-run-overview.component'; + +/** + * Main detail / editor panel view for the Quick Run feature in Flow. + */ +@Component({ + selector: 'app-quick-run-workspace', + imports: [ + MatIconModule, + MatButtonModule, + MatDividerModule, + MatCheckboxModule, + CdkMenuModule, + TranslatePipe, + AppDetailComponent, + GeneralDetailComponent, + QuickRunOverviewComponent, + ], + templateUrl: './quick-run-workspace.component.html', + styleUrl: './quick-run-workspace.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class QuickRunWorkspaceComponent { + private readonly quickRunService = inject(QuickRunService); + private readonly remoteFacade = inject(RemoteFacadeService); + private readonly uiStateService = inject(UiStateService); + private readonly modalService = inject(ModalService); + + readonly quickRuns = this.quickRunService.quickRuns; + readonly selected = this.quickRunService.selected; + readonly runningIds = this.quickRunService.runningIds; + readonly selectedRemote = this.uiStateService.selectedRemote; + + isShowOnTray(qr: QuickRun): boolean { + return qr.config?.app?.showOnTray ?? true; + } + + async toggleShowOnTray(qr: QuickRun, checked: boolean): Promise { + const updatedConfig: QuickRunConfig = { + ...qr.config, + app: { + ...qr.config.app, + showOnTray: checked, + }, + }; + await this.quickRunService.save({ + id: qr.id, + name: qr.name, + description: qr.description, + operationType: qr.operationType, + remoteName: qr.remoteName, + config: updatedConfig, + }); + } + + editQuickRun(id: string): void { + const qr = this.quickRuns().find(q => q.id === id); + if (qr) this.quickRunService.openEditor(qr); + } + + openLogsModal(remoteName: string): void { + this.modalService.openLogs(remoteName); + } + + openBackendModal(): void { + this.modalService.openBackend(); + } + + closeDetail(): void { + this.quickRunService.select(null); + } + + openRemoteDetail(remoteName: string): void { + const cleanName = remoteName.replace(/:$/, ''); + const remote = this.remoteFacade + .orderedRemotes() + .find(r => r.name === remoteName || r.name === cleanName); + if (remote) { + this.quickRunService.select(null); + this.uiStateService.setSelectedRemote(remote); + } + } + + closeRemoteDetail(): void { + this.uiStateService.resetSelectedRemote(); + } + + onSelectQuickRun(qr: QuickRun): void { + this.uiStateService.resetSelectedRemote(); + this.quickRunService.select(qr.id); + } + + async startQuickRun(id: string): Promise { + await this.quickRunService.start(id); + } + + async stopQuickRun(id: string): Promise { + await this.quickRunService.stop(id); + } + + duplicateQuickRun(id: string): void { + this.quickRunService.duplicate(id); + } + + async removeQuickRun(id: string): Promise { + await this.quickRunService.remove(id); + } + + isRunning(id: string): boolean { + return this.runningIds().has(id); + } + + async openInFiles(event: OpenInFilesEvent): Promise { + try { + await this.remoteFacade.openRemoteInFiles( + event.remoteName, + event.path, + event.profileName, + event.operationType + ); + } catch (error) { + console.error('Failed to open path in files:', error); + } + } + + onStartRemoteJob(event: StartJobEvent): void { + void this.remoteFacade.startJob(event.remoteName, event.type, event.profileName, 'dashboard'); + } + + onStopRemoteJob(event: StopJobEvent): void { + void this.remoteFacade.stopJob(event.remoteName, event.type, event.serveId, event.profileName); + } + + onDeleteRemoteJob(jobId: number): void { + void this.remoteFacade.deleteJob(jobId); + } + + onRetryDiskUsage(): void { + const remote = this.selectedRemote(); + if (remote) { + void this.remoteFacade.getCachedOrFetchDiskUsage( + remote.name, + undefined, + 'dashboard', + undefined, + true + ); + } + } + + onOpenRemoteConfig(editTarget?: string): void { + const remote = this.selectedRemote(); + this.modalService.openRemoteConfig({ + remoteName: remote?.name, + editTarget, + }); + } +} diff --git a/src/app/home/home.component.html b/src/app/home/home.component.html index 4844d294e..f7e2b6a51 100755 --- a/src/app/home/home.component.html +++ b/src/app/home/home.component.html @@ -14,8 +14,13 @@ - diff --git a/src/app/home/home.component.scss b/src/app/home/home.component.scss index 249693561..c7f4a3148 100644 --- a/src/app/home/home.component.scss +++ b/src/app/home/home.component.scss @@ -15,9 +15,6 @@ mat-sidenav { } mat-sidenav-content { - flex: 1; - height: 100%; - @media (max-width: 599.98px) { padding-bottom: calc(104px + env(safe-area-inset-bottom, 0px)); box-sizing: border-box; diff --git a/src/app/home/home.component.spec.ts b/src/app/home/home.component.spec.ts deleted file mode 100644 index 7737f1c89..000000000 --- a/src/app/home/home.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { HomeComponent } from './home.component'; - -describe('HomeComponent', () => { - let component: HomeComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [HomeComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(HomeComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/home/home.component.ts b/src/app/home/home.component.ts index f9b45fcd2..367d90399 100755 --- a/src/app/home/home.component.ts +++ b/src/app/home/home.component.ts @@ -9,6 +9,7 @@ import { ChangeDetectionStrategy, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { firstValueFrom } from 'rxjs'; import { MatDrawerMode, MatSidenavModule } from '@angular/material/sidenav'; import { MatCardModule } from '@angular/material/card'; import { MatDividerModule } from '@angular/material/divider'; @@ -103,6 +104,7 @@ export class HomeComponent { private readonly localStorage = inject(LocalStorageService); readonly isSidebarOpen = signal(this.localStorage.get('ui.sidebarOpen', false)); readonly sidebarMode = signal('side'); + readonly isSidebarOver = computed(() => this.sidebarMode() === 'over'); readonly selectedSyncOperation = linkedSignal(() => { const remote = this.selectedRemote(); if (!remote) return 'sync'; @@ -118,7 +120,17 @@ export class HomeComponent { constructor() { afterNextRender(() => this.setupResponsiveLayout()); - this.destroyRef.onDestroy(() => this.uiStateService.resetSelectedRemote()); + + this.uiStateService.registerMobileSidebar({ + view: 'main_menu', + isOver: this.isSidebarOver, + isOpen: this.isSidebarOpen, + }); + + this.destroyRef.onDestroy(() => { + this.uiStateService.resetSelectedRemote(); + this.uiStateService.unregisterMobileSidebar('main_menu'); + }); } // --- Layout --- @@ -126,11 +138,6 @@ export class HomeComponent { setSidebarOpen(open: boolean): void { this.isSidebarOpen.set(open); this.localStorage.set('ui.sidebarOpen', open); - - // Notify tabs to hide when mobile drawer is open - if (this.sidebarMode() === 'over') { - this.uiStateService.setMobileSidebarOpen(open); - } } private setupResponsiveLayout(): void { @@ -168,7 +175,7 @@ export class HomeComponent { try { await this.remoteFacadeService.startJob(remoteName, operationType, profileName, 'dashboard'); } catch (error) { - this.handleError('Start job failed', error); + console.error('Start job failed:', error); } } @@ -198,13 +205,8 @@ export class HomeComponent { async deleteRemote(remoteName: string): Promise { if (!remoteName) return; - const confirmed = await this.notificationService.confirmModal( - this.translate.instant('home.deleteRemote.title'), - this.translate.instant('home.deleteRemote.message', { name: remoteName }), - this.translate.instant('common.delete'), - this.translate.instant('common.cancel'), - { icon: 'trash', color: 'warn' } - ); + const dialogRef = this.modalService.openDeleteRemote(remoteName); + const confirmed = await firstValueFrom(dialogRef.afterClosed()); if (!confirmed) return; try { @@ -213,7 +215,8 @@ export class HomeComponent { this.uiStateService.resetSelectedRemote(); } } catch (error) { - this.handleError(this.translate.instant('home.errors.deleteRemoteFailed'), error); + console.error('Delete remote failed:', error); + this.notificationService.showError(error); } } @@ -276,7 +279,8 @@ export class HomeComponent { this.translate.instant('home.notifications.settingsReset', { name: remoteName }) ); } catch (error) { - this.handleError(this.translate.instant('home.errors.resetSettingsFailed'), error); + console.error('Reset settings failed:', error); + this.notificationService.showError(error); } } @@ -341,12 +345,4 @@ export class HomeComponent { openBackendModal(): void { this.modalService.openBackend(); } - - // --- Error Handling --- - - private handleError(message: string, error: unknown): void { - console.error(`${message}:`, error); - const detail = error instanceof Error ? error.message : String(error); - this.notificationService.showError(`${message}: ${detail}`); - } } diff --git a/src/app/layout/main-ui-container.component.ts b/src/app/layout/main-ui-container.component.ts new file mode 100644 index 000000000..1613bee17 --- /dev/null +++ b/src/app/layout/main-ui-container.component.ts @@ -0,0 +1,46 @@ +import { Component, ChangeDetectionStrategy, computed, viewChild } from '@angular/core'; +import { TitlebarComponent } from './titlebar/titlebar.component'; +import { BannerComponent } from './banners/banner.component'; +import { HomeComponent } from '../home/home.component'; +import { TabsButtonsComponent } from './tabs-buttons/tabs-buttons.component'; + +@Component({ + selector: 'app-main-ui-container', + standalone: true, + imports: [TitlebarComponent, BannerComponent, HomeComponent, TabsButtonsComponent], + template: ` +
+ + + + +
+ `, + styles: [ + ` + :host { + display: flex; + flex-direction: column; + width: 100vw; + height: 100dvh; + background-color: var(--window-bg-color); + box-sizing: border-box; + } + .main-ui-wrapper { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MainUiContainerComponent { + private readonly home = viewChild(HomeComponent); + readonly tabsMobileHidden = computed(() => { + const home = this.home(); + if (!home) return false; + return home.isSidebarOver() && home.isSidebarOpen(); + }); +} diff --git a/src/app/layout/sidebar/sidebar.component.html b/src/app/layout/sidebar/sidebar.component.html index 0a9f934be..67d6ac02d 100644 --- a/src/app/layout/sidebar/sidebar.component.html +++ b/src/app/layout/sidebar/sidebar.component.html @@ -2,117 +2,198 @@
- @if (filteredRemotes().length > 0) { - @for (remote of filteredRemotes(); track remote.name) { - @let isHidden = hiddenRemotesSet().has(remote.name); - @let mountCount = remote.status.mount ? statusService.getMountProfileCount(remote) : 0; - @let syncCount = statusService.getSyncProfileCount(remote); - @let activeSync = statusService.getActiveSyncOperationType(remote); - @let serveCount = remote.status.serve.active ? statusService.getServeProfileCount(remote) : 0; - - -
-
-

- {{ remote.name }} -

- @if (isHidden) { - - } -
-
- @if (remote.status.mount) { -
- - @if (mountCount > 1) { - {{ mountCount }} - } -
- } - -
- - @if (syncCount > 1) { - {{ syncCount }} + @if (isLoading()) { +
+ + {{ loadingText() | translate }} +
+ } @else if (!hasAny()) { +
+ + {{ emptyText() | translate }} +
+ } @else if (mode() === 'remotes') { + @if (filteredRemotes().length > 0) { + @for (remote of filteredRemotes(); track remote.name) { + @let isHidden = hiddenRemotesSet().has(remote.name); + @let mountCount = remote.status.mount ? statusService.getMountProfileCount(remote) : 0; + @let syncCount = statusService.getSyncProfileCount(remote); + @let activeSync = statusService.getActiveSyncOperationType(remote); + @let serveCount = + remote.status.serve.active ? statusService.getServeProfileCount(remote) : 0; + + +
+
+

+ {{ remote.name }} +

+ @if (isHidden) { + }
+
+ @if (remote.status.mount) { +
+ + @if (mountCount > 1) { + {{ mountCount }} + } +
+ } - @if (remote.status.serve.active) {
- - @if (serveCount > 1) { - {{ serveCount }} + + @if (syncCount > 1) { + {{ syncCount }} }
- } + + @if (remote.status.serve.active) { +
+ + @if (serveCount > 1) { + {{ serveCount }} + } +
+ } +
-
-
- - {{ remote.type | titlecase }} -
- - +
+ + {{ remote.type | titlecase }} +
+ + + } + } @else { +
+ + @if (searchTerm().trim()) { + {{ 'sidebar.noRemotesFound' | translate: { term: searchTerm() } }} + } @else { + {{ 'sidebar.noRemotesConfigured' | translate }} + } +
} - } @else { -
- - @if (searchTerm().trim()) { - {{ 'sidebar.noRemotesFound' | translate: { term: searchTerm() } }} - } @else { - {{ 'sidebar.noRemotesConfigured' | translate }} + } @else if (mode() === 'flow') { + @if (filteredQuickRuns().length > 0) { + @for (qr of filteredQuickRuns(); track trackByQuickRunId($index, qr)) { + @let running = isQuickRunRunning(qr.id); + + +
+
+ +

{{ qr.name }}

+
+
+ @if (hasCron(qr)) { + + } + @if (hasWatcher(qr)) { + + } + @if (hasAutoStart(qr)) { + + } + @if (running) { + + } +
+
+ +
+ + {{ getQuickRunActionLabel(qr) | translate }} + · + {{ qr.remoteName }} +
+
+
} -
+ } @else { +
+ + {{ 'flow.noResults' | translate }} +
+ } }
diff --git a/src/app/layout/sidebar/sidebar.component.scss b/src/app/layout/sidebar/sidebar.component.scss index 416804d9d..3026820dd 100644 --- a/src/app/layout/sidebar/sidebar.component.scss +++ b/src/app/layout/sidebar/sidebar.component.scss @@ -42,7 +42,8 @@ flex-direction: column; } -// Remote Card +// Sidebar Card (Remote / Quick Run) +.sidebar-card, .remote-card { margin: var(--space-md); margin-bottom: 0; @@ -74,13 +75,18 @@ } } + &.is-running { + box-shadow: 0 0 0 1px rgba(var(--accent-color-rgb), 0.3); + } + .card-content { padding: 0; display: flex; flex-direction: column; gap: var(--space-sm); - // Remote Header with Status + // Card Header with Status + .card-header, .remote-header { display: flex; align-items: center; @@ -88,6 +94,7 @@ gap: var(--space-xs); position: relative; + .name-wrapper, .remote-name-wrapper { display: flex; align-items: center; @@ -117,6 +124,14 @@ color: var(--dim-color); opacity: 0.8; } + + .item-kind-icon { + width: var(--icon-size-sm); + height: var(--icon-size-sm); + font-size: var(--icon-size-sm); + color: var(--primary-color); + opacity: 0.9; + } } .status-indicators { @@ -129,10 +144,70 @@ flex-wrap: wrap; gap: 6px; max-width: 58px; + + .feature-icon { + width: 14px; + height: 14px; + font-size: 14px; + color: var(--dim-color); + opacity: 0.75; + transition: + opacity 0.15s ease, + color 0.15s ease; + + &:hover { + opacity: 1; + } + + &.cron { + color: var(--primary-color); + } + + &.watcher { + color: var(--accent-color); + } + + &.autostart { + color: var(--yellow); + } + } + + .status-dot-mini { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background: var(--dim-color); + opacity: 0.5; + + &.running { + background: var(--accent-color); + opacity: 1; + animation: pulse 1.4s ease-in-out infinite; + } + &.completed { + background: var(--primary-color); + opacity: 0.8; + } + &.failed { + background: var(--warn-color); + opacity: 1; + } + &.stopped { + background: var(--dim-color); + opacity: 0.7; + } + &.idle { + background: var(--dim-color); + opacity: 0.4; + } + } } } - // Remote Type + // Card Subtitle / Type + .card-type, + .card-meta, .remote-type { display: flex; align-items: center; @@ -140,6 +215,28 @@ color: var(--dim-color); font-size: 0.9rem; font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + mat-icon { + width: var(--icon-size-xs); + height: var(--icon-size-xs); + font-size: var(--icon-size-xs); + color: var(--dim-color); + flex-shrink: 0; + } + + .separator { + opacity: 0.5; + margin: 0 2px; + } + + .remote-name { + font-family: var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + } } // Status Dots @@ -230,6 +327,25 @@ } } +// Loading State +.loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-sm); + padding: var(--space-xxl) var(--space-md); + color: var(--dim-color); + font-size: 13px; + + .spinner { + font-size: 28px; + width: 28px; + height: 28px; + color: var(--primary-color); + } +} + // No Remotes Message .no-remotes { display: flex; @@ -241,6 +357,8 @@ font-weight: 500; height: 100%; min-height: 200px; + padding: var(--space-md); + gap: var(--space-sm); mat-icon { width: var(--icon-size-xl); @@ -248,4 +366,10 @@ font-size: var(--icon-size-xl); opacity: 0.5; } + + span { + font-size: 13px; + max-width: 200px; + line-height: 1.5; + } } diff --git a/src/app/layout/sidebar/sidebar.component.spec.ts b/src/app/layout/sidebar/sidebar.component.spec.ts deleted file mode 100644 index f34121caa..000000000 --- a/src/app/layout/sidebar/sidebar.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { SidebarComponent } from './sidebar.component'; - -describe('SidebarComponent', () => { - let component: SidebarComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [SidebarComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(SidebarComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/layout/sidebar/sidebar.component.ts b/src/app/layout/sidebar/sidebar.component.ts index b48071b51..65f313bf2 100755 --- a/src/app/layout/sidebar/sidebar.component.ts +++ b/src/app/layout/sidebar/sidebar.component.ts @@ -5,6 +5,7 @@ import { computed, inject, input, + output, signal, viewChild, } from '@angular/core'; @@ -13,12 +14,15 @@ import { MatIconModule } from '@angular/material/icon'; import { TranslatePipe } from '@ngx-translate/core'; import { SearchContainerComponent } from '../../shared/components/search-container/search-container.component'; -import { Remote } from '@app/types'; +import { OPERATION_REGISTRY, QuickRun, Remote } from '@app/types'; import { IconService } from 'src/app/services/ui/icon.service'; import { UiStateService } from 'src/app/services/ui/state/ui-state.service'; import { RemoteStatusService } from 'src/app/services/remote/remote-status.service'; import { RemoteFacadeService } from '../../services/facade/remote-facade.service'; +import { QuickRunService } from 'src/app/services/flow/quick-run.service'; + +export type SidebarMode = 'remotes' | 'flow'; @Component({ selector: 'app-sidebar', @@ -26,22 +30,67 @@ import { RemoteFacadeService } from '../../services/facade/remote-facade.service templateUrl: './sidebar.component.html', styleUrl: './sidebar.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - host: { - '(document:keydown.control.f)': 'onCtrlF($event)', - }, }) export class SidebarComponent { - readonly remotes = input.required(); + readonly mode = input('remotes'); + readonly customTitle = input(); + readonly customIcon = input(); + readonly remotes = input([]); + readonly itemSelected = output(); readonly iconService = inject(IconService); readonly statusService = inject(RemoteStatusService); private readonly uiStateService = inject(UiStateService); private readonly remoteFacade = inject(RemoteFacadeService); + private readonly quickRunService = inject(QuickRunService); - readonly selectedRemote = this.uiStateService.selectedRemote; + readonly title = computed( + () => this.customTitle() ?? (this.mode() === 'flow' ? 'flow.title' : 'sidebar.remotes') + ); + readonly icon = computed(() => this.customIcon() ?? (this.mode() === 'flow' ? 'flow' : 'server')); + readonly searchPlaceholder = computed(() => + this.mode() === 'flow' ? 'flow.search' : 'sidebar.searchPlaceholder' + ); + readonly searchAriaLabel = computed(() => + this.mode() === 'flow' ? 'flow.search' : 'sidebar.searchAriaLabel' + ); + readonly toggleSearchTitle = computed(() => + this.mode() === 'flow' ? 'flow.toggleSearch' : 'sidebar.toggleSearch' + ); + + // ── Unified loading & empty states ──────────────────────────────────────── + readonly isLoading = computed(() => { + if (this.mode() === 'remotes') { + return this.remoteFacade.loading(); + } + return this.quickRunService.isLoading(); + }); + + readonly hasAny = computed(() => { + if (this.mode() === 'remotes') { + return this.remotes().length > 0; + } + return this.quickRuns().length > 0; + }); + + readonly emptyIcon = computed(() => (this.mode() === 'flow' ? 'flow' : 'server')); + readonly emptyText = computed(() => + this.mode() === 'flow' ? 'flow.empty.description' : 'sidebar.noRemotesConfigured' + ); + readonly loadingText = computed(() => + this.mode() === 'flow' ? 'flow.loading' : 'common.loading' + ); + // ── Remotes state ───────────────────────────────────────────────────────── + readonly selectedRemote = this.uiStateService.selectedRemote; readonly hiddenRemotesSet = computed(() => new Set(this.remoteFacade.hiddenRemoteNames())); + // ── Quick-run state ─────────────────────────────────────────────────────── + readonly quickRuns = this.quickRunService.quickRuns; + readonly selectedQuickRunId = this.quickRunService.selectedId; + readonly runningIds = this.quickRunService.runningIds; + + // ── Search & Filter state ───────────────────────────────────────────────── readonly searchTerm = signal(''); readonly searchVisible = signal(false); private readonly searchContainer = viewChild(SearchContainerComponent); @@ -54,10 +103,77 @@ export class SidebarComponent { ); }); + readonly filteredQuickRuns = computed(() => { + const query = this.searchTerm().toLowerCase().trim(); + if (!query) return this.quickRuns(); + return this.quickRuns().filter(qr => this.matchesQuickRunQuery(qr, query)); + }); + + // ── Remotes actions ─────────────────────────────────────────────────────── selectRemote(remote: Remote): void { this.uiStateService.setSelectedRemote(remote); + this.itemSelected.emit(); } + // ── Quick-run actions ───────────────────────────────────────────────────── + selectQuickRun(id: string): void { + this.quickRunService.select(id); + this.itemSelected.emit(); + } + + isQuickRunRunning(id: string): boolean { + return this.runningIds().has(id); + } + + getQuickRunIcon(qr: QuickRun): string { + const def = OPERATION_REGISTRY.find(d => d.key === qr.operationType); + return def?.icon ?? 'operations'; + } + + getQuickRunActionLabel(qr: QuickRun): string { + const def = OPERATION_REGISTRY.find(d => d.key === qr.operationType); + return def?.actionLabel ?? 'flow.tabs.quickRun'; + } + + hasCron(qr: QuickRun): boolean { + const app = qr.config?.app; + return !!(app?.cronEnabled && app?.cronExpression); + } + + hasWatcher(qr: QuickRun): boolean { + return !!qr.config?.app?.watchEnabled; + } + + hasAutoStart(qr: QuickRun): boolean { + return !!qr.config?.app?.autoStart; + } + + trackByQuickRunId(_index: number, qr: QuickRun): string { + return qr.id; + } + + private matchesQuickRunQuery(qr: QuickRun, query: string): boolean { + const rclone = (qr.config.rclone ?? {}) as Record; + const opType = qr.operationType; + const opData = (rclone[opType] as Record | undefined) ?? rclone; + const haystack = [ + qr.name, + qr.description ?? '', + qr.remoteName, + qr.operationType, + opData['srcFs'] ? String(opData['srcFs']) : rclone['srcFs'] ? String(rclone['srcFs']) : '', + opData['dstFs'] ?? rclone['dstFs'] ?? '', + opData['path1'] ?? rclone['path1'] ?? '', + opData['path2'] ?? rclone['path2'] ?? '', + opData['mountPoint'] ?? rclone['mountPoint'] ?? '', + opData['fs'] ?? rclone['fs'] ?? '', + ] + .join(' ') + .toLowerCase(); + return haystack.includes(query); + } + + // ── Common search methods ───────────────────────────────────────────────── onSearchTextChange(text: string): void { this.searchTerm.set(text); } @@ -73,13 +189,4 @@ export class SidebarComponent { this.searchTerm.set(''); this.searchContainer()?.clear(); } - - onCtrlF(event: Event): void { - (event as KeyboardEvent).preventDefault(); - if (!this.searchVisible()) { - this.toggleSearch(); - } else { - this.searchContainer()?.focus(); - } - } } diff --git a/src/app/layout/tabs-buttons/tabs-buttons.component.html b/src/app/layout/tabs-buttons/tabs-buttons.component.html index 256ee5c13..2a79e2794 100644 --- a/src/app/layout/tabs-buttons/tabs-buttons.component.html +++ b/src/app/layout/tabs-buttons/tabs-buttons.component.html @@ -1,14 +1,71 @@ -@for (tab of tabs; track tab.id) { +@if (!isEditingLayout()) { + @for (tab of tabs(); track tab.id) { + + } +} @else { + @let ctx = editContext(); + @if (ctx?.onReset) { + + } + + @if (ctx?.hasViewToggle) { + + } + } diff --git a/src/app/layout/tabs-buttons/tabs-buttons.component.scss b/src/app/layout/tabs-buttons/tabs-buttons.component.scss index efbfe1c84..4ef13f9c1 100644 --- a/src/app/layout/tabs-buttons/tabs-buttons.component.scss +++ b/src/app/layout/tabs-buttons/tabs-buttons.component.scss @@ -10,6 +10,8 @@ display: flex; align-items: center; justify-content: center; + background: transparent; + border: none; .icon-wrapper { display: flex; diff --git a/src/app/layout/tabs-buttons/tabs-buttons.component.spec.ts b/src/app/layout/tabs-buttons/tabs-buttons.component.spec.ts deleted file mode 100644 index e204153d1..000000000 --- a/src/app/layout/tabs-buttons/tabs-buttons.component.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TabsButtonsComponent } from './tabs-buttons.component'; - -describe('TabsButtonsComponent', () => { - let component: TabsButtonsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TabsButtonsComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(TabsButtonsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - describe('component properties', () => { - it('should have default currentTab provided by service', () => { - expect(component.currentTab()).toBeDefined(); - }); - - it('should accept currentTab input', () => { - // Input is now a signal tied to a service. - // Skip modifying it directly or test setTab mechanism instead. - }); - - it('should set tabs in uiService', () => { - // Replaced tabSelected logic check with setTab - spyOn(component['uiStateService'], 'setTab'); - component.setTab('operations'); - expect(component['uiStateService'].setTab).toHaveBeenCalledWith('operations'); - }); - }); -}); diff --git a/src/app/layout/tabs-buttons/tabs-buttons.component.ts b/src/app/layout/tabs-buttons/tabs-buttons.component.ts index 24854a2e4..116e75390 100755 --- a/src/app/layout/tabs-buttons/tabs-buttons.component.ts +++ b/src/app/layout/tabs-buttons/tabs-buttons.component.ts @@ -1,8 +1,8 @@ -import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; +import { Component, ChangeDetectionStrategy, inject, input, output, computed } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; import { TranslatePipe } from '@ngx-translate/core'; import { MatButtonModule } from '@angular/material/button'; -import { AppTab } from '@app/types'; +import { AppTab, TabItem } from '@app/types'; import { UiStateService } from 'src/app/services/ui/state/ui-state.service'; @Component({ @@ -12,22 +12,40 @@ import { UiStateService } from 'src/app/services/ui/state/ui-state.service'; styleUrl: './tabs-buttons.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, host: { - '[class.mobile-hidden]': 'uiStateService.mobileSidebarOpen()', + '[class.mobile-hidden]': 'isMobileHidden()', + '[class.is-editing]': 'isEditingLayout()', }, }) -export class TabsButtonsComponent { +export class TabsButtonsComponent { protected readonly uiStateService = inject(UiStateService); - readonly currentTab = this.uiStateService.currentTab; + readonly customTabs = input[]>(); + readonly activeTab = input(); + readonly activeTabChange = output(); + readonly mobileHidden = input(); - readonly tabs: { id: AppTab; icon: string; label: string }[] = [ + readonly isEditingLayout = this.uiStateService.isEditingLayout; + readonly editContext = this.uiStateService.activeEditContext; + + private readonly defaultTabs: TabItem[] = [ { id: 'general', icon: 'home', label: 'tabs.general' }, { id: 'mount', icon: 'mount', label: 'tabs.mount' }, { id: 'operations', icon: 'operations', label: 'tabs.operations' }, { id: 'serve', icon: 'satellite-dish', label: 'tabs.serve' }, ]; - setTab(tab: AppTab): void { - this.uiStateService.setTab(tab); + readonly tabs = computed(() => (this.customTabs() ?? this.defaultTabs) as TabItem[]); + readonly currentTab = computed(() => this.activeTab() ?? this.uiStateService.currentTab()); + readonly isMobileHidden = computed( + () => this.mobileHidden() ?? this.uiStateService.mobileSidebarOpen() + ); + + setTab(tabId: T): void { + if (this.customTabs()) { + this.activeTabChange.emit(tabId); + } else { + this.uiStateService.setTab(tabId as unknown as AppTab); + this.activeTabChange.emit(tabId); + } } } diff --git a/src/app/layout/titlebar/titlebar.component.html b/src/app/layout/titlebar/titlebar.component.html index 1b1844163..9a8c49064 100644 --- a/src/app/layout/titlebar/titlebar.component.html +++ b/src/app/layout/titlebar/titlebar.component.html @@ -2,18 +2,20 @@ -
+
- - - -
+ - - -
- -
- @for (theme of themes; track theme.id) { - - } -
- - @for (item of menuItems; track item.label) { - @if (item.divider) { - - } + @if (isOverlayOpen()) { + @if (!isMobile()) { - @if (item.dividerAfter) { - - } } - - - -
-
+ } @else { + + + } +
diff --git a/src/app/layout/titlebar/titlebar.component.spec.ts b/src/app/layout/titlebar/titlebar.component.spec.ts deleted file mode 100644 index a2e1c31c4..000000000 --- a/src/app/layout/titlebar/titlebar.component.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { TitlebarComponent } from './titlebar.component'; - -describe('TitlebarComponent', () => { - let component: TitlebarComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TitlebarComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(TitlebarComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('computes rcloneRestartRequired from service state', () => { - // simulate a state where the rclone service reports a pending restart - const fakeState = { - status: 'READY_TO_RESTART', - version: '1.2.3', - }; - - // override the read-only signal by casting to any - (component['rcloneUpdateService']['_updateState'] as any) = (): any => fakeState; - - expect(component.rcloneRestartRequired()).toBeTrue(); - expect(component.updateTooltip()).toContain('Restart'); - - // badge expression should also evaluate truthy - const badgeText = component.aboutMenuBadge(); - expect(!!badgeText).toBeTrue(); - }); -}); diff --git a/src/app/layout/titlebar/titlebar.component.ts b/src/app/layout/titlebar/titlebar.component.ts index 3a78f4b55..b8d1b14a9 100644 --- a/src/app/layout/titlebar/titlebar.component.ts +++ b/src/app/layout/titlebar/titlebar.component.ts @@ -1,36 +1,39 @@ -import { Component, OnInit, inject, ChangeDetectionStrategy, computed } from '@angular/core'; +import { + Component, + OnInit, + inject, + ChangeDetectionStrategy, + input, + output, + computed, + TemplateRef, +} from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; -import { MatBadgeModule } from '@angular/material/badge'; -import { MatDividerModule } from '@angular/material/divider'; import { MatIconModule } from '@angular/material/icon'; import { CdkMenuModule } from '@angular/cdk/menu'; -import { TranslateService, TranslatePipe } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; // Services -import { BackupRestoreUiService } from 'src/app/services/settings/backup-restore-ui.service'; import { UiStateService } from 'src/app/services/ui/state/ui-state.service'; -import { NautilusService } from 'src/app/services/ui/nautilus.service'; -import { AppUpdaterService } from 'src/app/services/infrastructure/maintenance/app-updater.service'; -import { RcloneUpdateService } from 'src/app/services/infrastructure/maintenance/rclone-update.service'; -import { WindowService } from 'src/app/services/ui/window.service'; import { ModalService } from 'src/app/services/ui/modal.service'; import { ConnectionService } from 'src/app/services/infrastructure/system/connection.service'; -import { AlertService } from 'src/app/services/alerts/alert.service'; -import { Theme } from '@app/types'; +import { MainUiOverlayService } from 'src/app/services/ui/main-ui-overlay.service'; +import { FlowOverlayService } from 'src/app/services/ui/flow-overlay.service'; +import { isMobile } from 'src/app/services/infrastructure/platform/api-client.service'; import { WindowControlsComponent } from 'src/app/shared/components/window-controls/window-controls.component'; +import { AppMenuComponent } from 'src/app/shared/components/app-menu/app-menu.component'; @Component({ selector: 'app-titlebar', standalone: true, imports: [ CdkMenuModule, - MatDividerModule, MatIconModule, MatButtonModule, - MatBadgeModule, TranslatePipe, WindowControlsComponent, + AppMenuComponent, ], templateUrl: './titlebar.component.html', styleUrls: ['./titlebar.component.scss'], @@ -38,48 +41,33 @@ import { WindowControlsComponent } from 'src/app/shared/components/window-contro }) export class TitlebarComponent implements OnInit { private readonly modalService = inject(ModalService); - private readonly backupRestoreUiService = inject(BackupRestoreUiService); - private readonly nautilusService = inject(NautilusService); - private readonly windowService = inject(WindowService); - private readonly appUpdaterService = inject(AppUpdaterService); - private readonly rcloneUpdateService = inject(RcloneUpdateService); - private readonly translateService = inject(TranslateService); - + readonly mainUiOverlayService = inject(MainUiOverlayService); + readonly flowOverlayService = inject(FlowOverlayService); readonly uiStateService = inject(UiStateService); readonly connectionService = inject(ConnectionService); - readonly alertService = inject(AlertService); - - // Signals for update states - readonly hasUpdates = this.appUpdaterService.hasUpdates; - readonly rcloneUpdateAvailable = this.rcloneUpdateService.hasUpdates; - readonly rcloneRestartRequired = this.rcloneUpdateService.readyToRestart; - readonly readyToRestart = this.appUpdaterService.readyToRestart; - - readonly currentTheme = this.windowService.theme; - - readonly updateTooltip = computed(() => { - const appRestart = this.readyToRestart(); - const rcloneRestart = this.rcloneRestartRequired(); - const appUpdate = this.hasUpdates(); - const rcloneUpdate = this.rcloneUpdateAvailable(); - - if (appRestart || rcloneRestart) { - return this.translateService.instant('titlebar.updates.restart'); - } else if (appUpdate && rcloneUpdate) { - return this.translateService.instant('titlebar.updates.all'); - } else if (appUpdate) { - return this.translateService.instant('titlebar.updates.app'); - } else if (rcloneUpdate) { - return this.translateService.instant('titlebar.updates.rclone'); + readonly isMobile = isMobile; + + readonly overlayType = input<'main' | 'flow' | 'none'>('main'); + readonly customAddMenu = input | null>(null); + readonly showHome = input(null); + readonly homeClick = output(); + + readonly isOverlayOpen = computed(() => { + switch (this.overlayType()) { + case 'main': + return this.mainUiOverlayService.isMainUiOverlayOpen(); + case 'flow': + return this.flowOverlayService.isFlowOverlayOpen(); + default: + return false; } - return ''; }); - readonly themes: { id: Theme; icon: string; label: string; class: string }[] = [ - { id: 'system', icon: 'check-circle', label: 'titlebar.menu.system', class: 'system' }, - { id: 'light', icon: 'check-circle', label: 'titlebar.menu.light', class: 'light' }, - { id: 'dark', icon: 'check-circle', label: 'titlebar.menu.dark', class: 'dark' }, - ]; + readonly isHomeVisible = computed(() => { + const custom = this.showHome(); + if (custom !== null) return custom; + return !!this.uiStateService.selectedRemote(); + }); readonly addRemoteMenuItems = [ { @@ -94,56 +82,6 @@ export class TitlebarComponent implements OnInit { }, ]; - readonly menuItems = [ - { - label: 'titlebar.menu.import', - shortcut: 'Ctrl + I', - action: (): void => this.restoreSettings(), - divider: true, - }, - { - label: 'titlebar.menu.export', - shortcut: 'Ctrl + E', - action: (): void => this.openExportModal(), - dividerAfter: true, - }, - { - label: 'titlebar.menu.preferences', - shortcut: 'Ctrl + ,', - action: (): void => this.openPreferencesModal(), - }, - { - label: 'titlebar.menu.flags', - shortcut: 'Ctrl + .', - action: (): void => this.openRcloneFlagsModal(), - dividerAfter: true, - }, - { - label: 'titlebar.menu.fileBrowser', - shortcut: 'Ctrl + B', - action: (): void => this.onBrowseClick(), - dividerAfter: true, - }, - { - label: 'titlebar.menu.shortcuts', - shortcut: 'Ctrl + ?', - action: (): void => this.openKeyboardShortcutsModal(), - dividerAfter: true, - }, - ]; - - readonly aboutMenuBadge = computed(() => { - const appRestart = this.readyToRestart(); - const rcloneRestart = this.rcloneRestartRequired(); - const appUpdate = this.hasUpdates(); - const rcloneUpdate = this.rcloneUpdateAvailable(); - - if (appRestart || rcloneRestart) return '!'; - if (appUpdate && rcloneUpdate) return '2'; - if (appUpdate || rcloneUpdate) return '!'; - return ''; - }); - async ngOnInit(): Promise { try { await this.connectionService.runInternetCheck(); @@ -152,12 +90,28 @@ export class TitlebarComponent implements OnInit { } } - async setTheme(theme: Theme, event?: MouseEvent): Promise { - if (event) { - event.preventDefault(); - event.stopPropagation(); + detachOverlay(): void { + if (this.overlayType() === 'flow') { + void this.flowOverlayService.detachToStandaloneWindow(); + } else if (this.overlayType() === 'main') { + void this.mainUiOverlayService.detachToStandaloneWindow(); + } + } + + closeOverlay(): void { + if (this.overlayType() === 'flow') { + this.flowOverlayService.closeFlowOverlay(); + } else if (this.overlayType() === 'main') { + this.mainUiOverlayService.closeMainUiOverlay(); + } + } + + onHomeClicked(): void { + if (this.showHome() !== null) { + this.homeClick.emit(); + } else { + this.resetRemote(); } - await this.windowService.setTheme(theme); } // Modal Methods @@ -169,40 +123,9 @@ export class TitlebarComponent implements OnInit { this.modalService.openRemoteConfig(); } - openPreferencesModal(): void { - this.modalService.openPreferences(); - } - - openRcloneFlagsModal(): void { - this.modalService.openRcloneFlags(); - } - - openKeyboardShortcutsModal(): void { - this.modalService.openKeyboardShortcuts(); - } - - openExportModal(): void { - this.modalService.openExport(); - } - - openAboutModal(): void { - this.modalService.openAbout(); - } - - openAlertsModal(): void { - this.modalService.openAlerts(); - } - - // Other Methods + // Reset Remote Selection resetRemote(): void { this.uiStateService.resetSelectedRemote(); - } - - restoreSettings(): void { - this.backupRestoreUiService.launchRestoreFlow(); - } - - onBrowseClick(): void { - void this.nautilusService.newNautilusWindow(null, null); + this.uiStateService.setMainView('main_menu'); } } diff --git a/src/app/services/facade/remote-facade.service.ts b/src/app/services/facade/remote-facade.service.ts index 2e543c0f7..d4437265b 100755 --- a/src/app/services/facade/remote-facade.service.ts +++ b/src/app/services/facade/remote-facade.service.ts @@ -1,5 +1,4 @@ import { - DestroyRef, Injectable, computed, inject, @@ -11,8 +10,7 @@ import { untracked, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { merge, concatMap, from } from 'rxjs'; -import { TauriBaseService } from '../infrastructure/platform/tauri-base.service'; +import { merge } from 'rxjs'; import { JobManagementService } from '../operations/job-management.service'; import { MountManagementService } from '../operations/mount-management.service'; import { ServeManagementService } from '../operations/serve-management.service'; @@ -27,6 +25,12 @@ import { BackendService } from '../infrastructure/system/backend.service'; import { UiStateService } from '../ui/state/ui-state.service'; import { PathService } from '../infrastructure/platform/path.service'; import { RcloneStatusService } from '../infrastructure/maintenance/rclone-status.service'; +import { QuickRunService } from '../flow/quick-run.service'; +import { AutomationService } from '../operations/automation.service'; +import { NotificationService } from '../ui/notification.service'; +import { BackendTranslationService } from '../i18n/backend-translation.service'; +import { TranslateService } from '@ngx-translate/core'; +import { findUniqueName } from '../remote/utils/unique-name.util'; import { Remote, JobInfo, @@ -61,7 +65,7 @@ interface RemoteState { } @Injectable({ providedIn: 'root' }) -export class RemoteFacadeService extends TauriBaseService { +export class RemoteFacadeService { private readonly jobService = inject(JobManagementService); private readonly mountService = inject(MountManagementService); private readonly serveService = inject(ServeManagementService); @@ -74,9 +78,13 @@ export class RemoteFacadeService extends TauriBaseService { private readonly backendService = inject(BackendService); private readonly uiStateService = inject(UiStateService); private readonly pathService = inject(PathService); - private readonly destroyRef = inject(DestroyRef); private readonly statusService = inject(RcloneStatusService); private readonly flagConfigService = inject(FlagConfigService); + private readonly notificationService = inject(NotificationService); + private readonly backendTranslation = inject(BackendTranslationService); + private readonly translate = inject(TranslateService); + private readonly quickRunService = inject(QuickRunService); + private readonly automationService = inject(AutomationService); readonly jobs = this.jobService.jobs; readonly mountedRemotes = this.mountService.mountedRemotes; @@ -93,9 +101,6 @@ export class RemoteFacadeService extends TauriBaseService { private readonly _actionInProgress = signal>({}); readonly actionInProgress = this._actionInProgress.asReadonly(); - // Memoized per-remote action signals — avoids creating a new computed on every call - private readonly _actionSignals = new Map>(); - readonly loading = this.isLoading.asReadonly(); readonly activeRemotes = computed(() => @@ -153,8 +158,9 @@ export class RemoteFacadeService extends TauriBaseService { readonly hiddenRemoteNames = computed(() => [...this.hiddenSet()]); + private refreshInFlight: Promise | null = null; + constructor() { - super(); void this.refreshAll(); // Primary trigger: engine ready event (fires after caches are populated) @@ -177,7 +183,7 @@ export class RemoteFacadeService extends TauriBaseService { this.eventListeners.listenToBackendSwitched() ) .pipe(takeUntilDestroyed()) - .subscribe(() => this.loadRemotes()); + .subscribe(() => void this.loadRemotes()); } // --- Settings & Path Collisions --- @@ -200,14 +206,13 @@ export class RemoteFacadeService extends TauriBaseService { const collisions: { remoteName: string; profileName: string; opType: string; path: string }[] = []; const allSettings = this.remoteSettings(); - const opKeys = ['mount', 'bisync'] as const; for (const [rName, rConfig] of Object.entries(allSettings)) { if (!rConfig) continue; for (const opType of opKeys) { - const profilesMap = rConfig[opType] as Record | undefined; + const profilesMap = rConfig[opType] as Record> | undefined; if (!profilesMap || typeof profilesMap !== 'object') continue; for (const [pName, pConfig] of Object.entries(profilesMap)) { @@ -216,14 +221,18 @@ export class RemoteFacadeService extends TauriBaseService { const pathsToCheck: string[] = []; if (opType === 'mount') { - if (pConfig.mountPoint) pathsToCheck.push(pConfig.mountPoint); - if (pConfig.dest?.path) pathsToCheck.push(pConfig.dest.path); - if (pConfig.rclone?.mountOpt?.mountPoint) - pathsToCheck.push(pConfig.rclone.mountOpt.mountPoint); + if (typeof pConfig['mountPoint'] === 'string') pathsToCheck.push(pConfig['mountPoint']); + const dest = pConfig['dest'] as { path?: string } | undefined; + if (dest?.path) pathsToCheck.push(dest.path); + const rclone = pConfig['rclone'] as Record | undefined; + if (typeof rclone?.['mountPoint'] === 'string') { + pathsToCheck.push(rclone['mountPoint']); + } } else if (opType === 'bisync') { - if (pConfig.path1) pathsToCheck.push(pConfig.path1); - if (pConfig.path2) pathsToCheck.push(pConfig.path2); - if (pConfig.dest?.path) pathsToCheck.push(pConfig.dest.path); + if (typeof pConfig['path1'] === 'string') pathsToCheck.push(pConfig['path1']); + if (typeof pConfig['path2'] === 'string') pathsToCheck.push(pConfig['path2']); + const dest = pConfig['dest'] as { path?: string } | undefined; + if (dest?.path) pathsToCheck.push(dest.path); } for (const rawPath of pathsToCheck) { @@ -327,7 +336,7 @@ export class RemoteFacadeService extends TauriBaseService { ...cur, loading: false, error: true, - errorMessage: String(error), + errorMessage: this.backendTranslation.translateBackendMessage(error), })); return null; } @@ -361,7 +370,8 @@ export class RemoteFacadeService extends TauriBaseService { const state = this.remoteStates.get(name); if (state) { - if (JSON.stringify(state.base().config) !== JSON.stringify(config)) { + const prevConfig = state.base().config; + if (prevConfig !== config && !shallowEqualObjects(prevConfig, config)) { this.remoteService.clearCache(name); } state.base.update((b: Omit) => ({ ...b, config })); @@ -415,51 +425,41 @@ export class RemoteFacadeService extends TauriBaseService { } async refreshAll(): Promise { - this.isLoading.set(true); - void this.flagConfigService.loadAllFlagFields(); - void this.remoteService.getRemoteTypes(); - try { - await Promise.all([ - this.statusService.refreshStatus(), - this.loadRemotes(), - this.mountService.getMountedRemotes(), - this.serveService.refreshServes(), - this.jobService.refreshJobs(), - ]); - this.loadDiskUsageInBackground(); - } finally { - this.isLoading.set(false); - } + if (this.refreshInFlight) return this.refreshInFlight; + + const promise = (async (): Promise => { + this.isLoading.set(true); + void this.flagConfigService.loadAllFlagFields(); + void this.remoteService.getRemoteTypes(); + try { + await Promise.all([ + this.statusService.refreshStatus(), + this.loadRemotes(), + this.mountService.getMountedRemotes(), + this.serveService.refreshServes(), + this.jobService.refreshJobs(), + this.quickRunService.refresh(), + this.automationService.refreshAutomations(), + ]); + this.loadDiskUsageInBackground(); + } finally { + this.isLoading.set(false); + this.refreshInFlight = null; + } + })(); + + this.refreshInFlight = promise; + return promise; } // --- Action State --- - getActionSignal(remoteName: string): Signal { - let sig = this._actionSignals.get(remoteName); - if (!sig) { - sig = computed(() => this._actionInProgress()[remoteName] ?? []); - this._actionSignals.set(remoteName, sig); - } - return sig; - } - diskUsageSignal(remoteName: string): Signal { return this.getOrCreateRemoteState(remoteName).disk; } featuresSignal(remoteName: string): Signal { - const remote = this.activeRemotes().find(r => r.name === remoteName); - return this.remoteService.getFeaturesSignal(remoteName, remote?.type); - } - - getActionState(remoteName: string): ActionState[] { - return this._actionInProgress()[remoteName] ?? []; - } - - isActionInProgress(remoteName: string, action: RemoteAction, profileName?: string): boolean { - return this.getActionState(remoteName).some( - a => a.type === action && a.profileName === profileName - ); + return this.remoteService.getFeaturesSignal(remoteName, undefined); } async executeAction( @@ -592,9 +592,13 @@ export class RemoteFacadeService extends TauriBaseService { ): Promise { if (type === 'serve') { const serves = this.runningServes().filter( - s => this.pathService.getRemoteNameFromFs(s.params?.fs) === remoteName + s => + this.pathService.getRemoteNameFromFs(s.params?.fs) === remoteName && + s.origin !== 'quickrun' && + !s.quick_run_id ); - const idToStop = serveId ?? serves.find(s => s.profile === profileName)?.id ?? serves[0]?.id; + const idToStop = + serveId ?? (profileName ? serves.find(s => s.profile === profileName)?.id : serves[0]?.id); if (!idToStop) throw new Error('Serve ID required to stop serve'); await this.serveService.stopServe(idToStop, remoteName); return; @@ -602,11 +606,15 @@ export class RemoteFacadeService extends TauriBaseService { if (type === 'mount') { const mounts = this.mountedRemotes().filter( - m => this.pathService.getRemoteNameFromFs(m.fs) === remoteName + m => + this.pathService.getRemoteNameFromFs(m.fs) === remoteName && + m.origin !== 'quickrun' && + !m.quick_run_id ); const mountPoint = - mounts.find(m => (profileName ? m.profile === profileName : true))?.mount_point ?? - mounts[0]?.mount_point; + (profileName + ? mounts.find(m => m.profile === profileName)?.mount_point + : mounts[0]?.mount_point) ?? mounts[0]?.mount_point; if (!mountPoint) throw new Error(`Active mount not found for ${remoteName}`); await this.mountService.unmountRemote(mountPoint, remoteName); return; @@ -620,7 +628,10 @@ export class RemoteFacadeService extends TauriBaseService { async unmountRemote(remoteName: string): Promise { await this.executeAction(remoteName, 'unmount', async () => { const mount = this.mountedRemotes().find( - m => this.pathService.getRemoteNameFromFs(m.fs) === remoteName + m => + this.pathService.getRemoteNameFromFs(m.fs) === remoteName && + m.origin !== 'quickrun' && + !m.quick_run_id ); if (!mount) throw new Error(`No mount point found for ${remoteName}`); await this.mountService.unmountRemote(mount.mount_point, remoteName); @@ -679,11 +690,7 @@ export class RemoteFacadeService extends TauriBaseService { } generateUniqueRemoteName(baseName: string): string { - const existing = Array.from(this.remoteStates.keys()); - let name = baseName; - let i = 1; - while (existing.includes(name)) name = `${baseName}-${i++}`; - return name; + return findUniqueName(baseName, Array.from(this.remoteStates.keys())); } async cloneRemote(remoteName: string): Promise { @@ -727,22 +734,16 @@ export class RemoteFacadeService extends TauriBaseService { const targets = remotes ?? this.activeRemotes(); if (!targets.length) return; - from(targets) - .pipe( - concatMap(async remote => { - if (generation !== this.backgroundLoadGeneration) return null; - try { - return await this.getCachedOrFetchDiskUsage(remote.name); - } catch (e) { - console.error(`[RemoteFacadeService] Error loading disk usage for ${remote.name}:`, e); - return null; - } - }), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe({ - error: e => console.error('[RemoteFacadeService] Background loading error:', e), - }); + void (async (): Promise => { + for (const remote of targets) { + if (generation !== this.backgroundLoadGeneration) return; + try { + await this.getCachedOrFetchDiskUsage(remote.name); + } catch (e) { + console.error(`[RemoteFacadeService] Error loading disk usage for ${remote.name}:`, e); + } + } + })(); } // --- Private Signal Accessors --- @@ -819,6 +820,9 @@ export class RemoteFacadeService extends TauriBaseService { const mountConfigs = getProfiles('mount'); const serveConfigs = getProfiles('serve'); + const profileMounts = mounts.filter(m => m.origin !== 'quickrun' && !m.quick_run_id); + const profileServes = serves.filter(s => s.origin !== 'quickrun' && !s.quick_run_id); + return { ...base, config: (settings['config'] as RemoteConfig) || base.config, @@ -837,56 +841,23 @@ export class RemoteFacadeService extends TauriBaseService { cryptcheck: this.buildOperationState('cryptcheck', jobs, settings), mount: { ...buildStatusEntry( - mounts, + profileMounts, Object.keys(mountConfigs), mountConfigs, - m => { - if (m.profile) return m.profile; - for (const [profName, profConfig] of Object.entries(mountConfigs)) { - const rclone = (profConfig['rclone'] as Record) || profConfig; - const configMountPoint = rclone['mountPoint'] as string; - if ( - configMountPoint === m.mount_point || - (configMountPoint && - m.mount_point && - configMountPoint.replace(/\/$/, '') === m.mount_point.replace(/\/$/, '')) - ) { - return profName; - } - } - return undefined; - }, + m => m.profile ?? undefined, m => m.mount_point ), }, serve: { ...buildStatusEntry( - serves, + profileServes, Object.keys(serveConfigs), serveConfigs, - s => { - if (s.profile) return s.profile; - for (const [profName, profConfig] of Object.entries(serveConfigs)) { - const rclone = (profConfig['rclone'] as Record) || profConfig; - const configFs = rclone['fs'] as string; - const configType = rclone['serveType'] as string; - if ( - configFs === s.params.fs || - (configFs && - s.params.fs && - configFs.replace(/:$/, '') === s.params.fs.replace(/:$/, '')) - ) { - if (configType === s.params.type) { - return profName; - } - } - } - return undefined; - }, + s => s.profile ?? undefined, s => s.id ), - count: serves.length, - serves, + count: profileServes.length, + serves: profileServes, }, }, }; @@ -897,7 +868,9 @@ export class RemoteFacadeService extends TauriBaseService { jobs: JobInfo[], settings: RemoteSettings ): RemoteOperationState { - const typeJobs = jobs.filter(j => j.job_type === type); + const typeJobs = jobs.filter( + j => j.job_type === type && j.origin !== 'quickrun' && !j.quick_run_id + ); const running = typeJobs.filter(j => j.status === 'Running'); const profiles = (settings[REMOTE_CONFIG_KEYS[type]] ?? {}) as ProfileConfigMap; const profileNames = Object.keys(profiles); @@ -969,11 +942,27 @@ function buildActiveProfiles( getValue: (i: T) => V ): Record { const result: Record = {}; - const fallback = profileNames.length === 1 ? (profileNames[0] ?? null) : null; for (const item of items) { const profile = getProfile(item)?.trim(); - const target = profile && profile.length > 0 ? profile : fallback; - if (target && !(target in result)) result[target] = getValue(item); + if (profile && profileNames.includes(profile)) { + if (!(profile in result)) result[profile] = getValue(item); + } } return result; } + +function shallowEqualObjects(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false; + const aKeys = Object.keys(a as object); + const bKeys = Object.keys(b as object); + if (aKeys.length !== bKeys.length) return false; + const bObj = b as Record; + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(bObj, key)) return false; + const av = (a as Record)[key]; + const bv = bObj[key]; + if (av !== bv && JSON.stringify(av) !== JSON.stringify(bv)) return false; + } + return true; +} diff --git a/src/app/services/flow/quick-run.service.spec.ts b/src/app/services/flow/quick-run.service.spec.ts new file mode 100644 index 000000000..9546f441b --- /dev/null +++ b/src/app/services/flow/quick-run.service.spec.ts @@ -0,0 +1,430 @@ +import { TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { of } from 'rxjs'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { QuickRunService } from './quick-run.service'; +import { QuickRun, QuickRunInput } from '@app/types'; +import { NotificationService } from '../ui/notification.service'; +import { ModalService } from '../ui/modal.service'; +import { JobManagementService } from '../operations/job-management.service'; +import { MountManagementService } from '../operations/mount-management.service'; +import { ServeManagementService } from '../operations/serve-management.service'; +import { AutomationService } from '../operations/automation.service'; +import { TranslateService } from '@ngx-translate/core'; +import { BackendTranslationService } from '../i18n/backend-translation.service'; +import { ApiClientService } from '../infrastructure/platform/api-client.service'; +import { SseClientService } from '../infrastructure/platform/sse-client.service'; + +describe('QuickRunService', () => { + let service: QuickRunService; + let invokeSpy: ReturnType; + let notificationSpy: { + showSuccess: ReturnType; + showError: ReturnType; + showInfo: ReturnType; + }; + let modalSpy: { openQuickRunEditor: ReturnType }; + + const mockQuickRun: QuickRun = { + id: 'qr-1', + name: 'Backup Drive', + description: 'Backs up local drive', + operationType: 'sync', + remoteName: 'drive:', + config: { + app: { autoStart: false }, + rclone: { srcFs: '/home/user/docs', dstFs: 'drive:backup' }, + }, + status: 'idle', + }; + + beforeEach(() => { + notificationSpy = { + showSuccess: vi.fn(), + showError: vi.fn(), + showInfo: vi.fn(), + }; + modalSpy = { + openQuickRunEditor: vi.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + QuickRunService, + { provide: NotificationService, useValue: notificationSpy }, + { provide: ModalService, useValue: modalSpy }, + { + provide: JobManagementService, + useValue: { jobs: signal([]), refreshJobs: vi.fn() }, + }, + { + provide: MountManagementService, + useValue: { mountedRemotes: signal([]), getMountedRemotes: vi.fn() }, + }, + { + provide: ServeManagementService, + useValue: { runningServes: signal([]), refreshServes: vi.fn() }, + }, + { + provide: AutomationService, + useValue: { + automations: signal([]), + refreshAutomations: vi.fn().mockResolvedValue([]), + }, + }, + { + provide: TranslateService, + useValue: { instant: vi.fn((k: string) => k), get: vi.fn(() => of('')) }, + }, + { + provide: BackendTranslationService, + useValue: { translateError: vi.fn((k: string) => k) }, + }, + { + provide: ApiClientService, + useValue: { invoke: vi.fn(), get: vi.fn(), post: vi.fn() }, + }, + { + provide: SseClientService, + useValue: { listen: vi.fn(() => of()) }, + }, + ], + }); + + service = TestBed.inject(QuickRunService); + invokeSpy = vi.spyOn( + service as unknown as { invokeCommand: (...args: unknown[]) => Promise }, + 'invokeCommand' + ) as unknown as ReturnType; + }); + + it('should be created and start with default state', () => { + expect(service).toBeTruthy(); + expect(service.quickRuns()).toEqual([]); + expect(service.selectedId()).toBeNull(); + expect(service.isCreating()).toBe(false); + }); + + describe('selection', () => { + it('should update selectedId and selected computed signal', async () => { + invokeSpy.mockResolvedValue([mockQuickRun]); + await service.refresh(); + + service.select('qr-1'); + expect(service.selectedId()).toBe('qr-1'); + expect(service.isSelected('qr-1')).toBe(true); + expect(service.selected()).toEqual(mockQuickRun); + + service.select(null); + expect(service.selectedId()).toBeNull(); + expect(service.selected()).toBeNull(); + }); + }); + + describe('editor lifecycle', () => { + it('should open editor via modal service when no id provided', () => { + service.openEditor(); + expect(modalSpy.openQuickRunEditor).toHaveBeenCalledWith({ + initialOpType: undefined, + initialRemoteName: undefined, + }); + }); + + it('should open editor via modal service when a QuickRun is provided', () => { + service.openEditor(mockQuickRun); + expect(modalSpy.openQuickRunEditor).toHaveBeenCalledWith({ + quickRun: mockQuickRun, + initialOpType: undefined, + initialRemoteName: undefined, + }); + }); + }); + + describe('CRUD operations', () => { + it('refresh should load quick runs from backend', async () => { + invokeSpy.mockResolvedValue([mockQuickRun]); + + await service.refresh(); + + expect(invokeSpy).toHaveBeenCalledWith('list_quick_runs'); + expect(service.quickRuns()).toEqual([mockQuickRun]); + }); + + it('save should handle create and update via backend', async () => { + const input: QuickRunInput = { + name: 'New Quick Run', + operationType: 'copy', + remoteName: 'drive:', + config: { app: { autoStart: false }, rclone: {} }, + }; + + invokeSpy.mockResolvedValue({ + ...input, + id: 'qr-new', + status: 'idle', + }); + + const result = await service.save(input); + + expect(invokeSpy).toHaveBeenCalledWith('create_quick_run', { quickRun: input }); + expect(result?.id).toBe('qr-new'); + expect(service.quickRuns().some(q => q.id === 'qr-new')).toBe(true); + }); + + it('remove should delete quick run from backend and store', async () => { + invokeSpy.mockResolvedValue([mockQuickRun]); + await service.refresh(); + + invokeSpy.mockResolvedValue(undefined); + await service.remove('qr-1'); + + expect(invokeSpy).toHaveBeenCalledWith('delete_quick_run', { quickRunId: 'qr-1' }); + expect(service.quickRuns()).toEqual([]); + }); + + it('duplicate should open editor modal with unique -1 name and cloned config', async () => { + invokeSpy.mockResolvedValue([mockQuickRun]); + await service.refresh(); + + service.duplicate('qr-1'); + + expect(modalSpy.openQuickRunEditor).toHaveBeenCalledWith({ + cloneData: { + name: 'Backup Drive-1', + description: mockQuickRun.description, + operationType: mockQuickRun.operationType, + remoteName: mockQuickRun.remoteName, + config: mockQuickRun.config, + }, + initialOpType: 'sync', + initialRemoteName: 'drive:', + }); + }); + + it('duplicate should increment number suffix for existing numbered names', async () => { + const numberedQr: QuickRun = { + ...mockQuickRun, + id: 'qr-2', + name: 'Backup Drive-1', + }; + invokeSpy.mockResolvedValue([mockQuickRun, numberedQr]); + await service.refresh(); + + service.duplicate('qr-2'); + + expect(modalSpy.openQuickRunEditor).toHaveBeenCalledWith({ + cloneData: { + name: 'Backup Drive-2', + description: mockQuickRun.description, + operationType: mockQuickRun.operationType, + remoteName: mockQuickRun.remoteName, + config: mockQuickRun.config, + }, + initialOpType: 'sync', + initialRemoteName: 'drive:', + }); + }); + }); + + describe('execution', () => { + it('start should invoke backend command start_quick_run', async () => { + invokeSpy.mockResolvedValue([mockQuickRun]); + await service.refresh(); + + const mockResult = { + executeId: 'exec-1', + origin: 'quickrun' as const, + operationType: 'sync' as const, + remoteName: 'drive:', + quickRunId: 'qr-1', + success: true, + status: 'running' as const, + startTime: '2026-08-16T18:00:00Z', + jobId: 42, + }; + invokeSpy.mockResolvedValue(mockResult); + + const res = await service.start('qr-1'); + + expect(invokeSpy).toHaveBeenCalledWith('start_quick_run', { quickRunId: 'qr-1' }); + expect(res?.jobId).toBe(42); + expect(res?.executeId).toBe('exec-1'); + expect(service.runningIds().has('qr-1')).toBe(true); + expect(service.actionInProgress()['qr-1']).toBeUndefined(); + }); + + it('start should track actionInProgress during execution', async () => { + invokeSpy.mockResolvedValue([mockQuickRun]); + await service.refresh(); + + let inFlightStateDuringExecution: 'start' | 'stop' | undefined; + invokeSpy.mockImplementation(async () => { + inFlightStateDuringExecution = service.actionInProgress()['qr-1']; + return { + executeId: 'exec-1', + origin: 'quickrun', + operationType: 'sync', + remoteName: 'drive:', + quickRunId: 'qr-1', + success: true, + status: 'running', + startTime: '2026-08-16T18:00:00Z', + jobId: 42, + }; + }); + + await service.start('qr-1'); + + expect(inFlightStateDuringExecution).toBe('start'); + expect(service.actionInProgress()['qr-1']).toBeUndefined(); + }); + + it('stop should invoke backend command stop_quick_run and track actionInProgress', async () => { + invokeSpy.mockResolvedValue([mockQuickRun]); + await service.refresh(); + + let inFlightStateDuringExecution: 'start' | 'stop' | undefined; + invokeSpy.mockImplementation(async () => { + inFlightStateDuringExecution = service.actionInProgress()['qr-1']; + return undefined; + }); + + await service.stop('qr-1'); + + expect(invokeSpy).toHaveBeenCalledWith('stop_quick_run', { + quickRunId: 'qr-1', + }); + expect(inFlightStateDuringExecution).toBe('stop'); + expect(service.actionInProgress()['qr-1']).toBeUndefined(); + expect(service.runningIds().has('qr-1')).toBe(false); + }); + + it('should isolate mount status by quick_run_id and not falsely mark other quick runs as mounted', async () => { + const mountQr1: QuickRun = { + ...mockQuickRun, + id: 'qr-mount-1', + name: 'Mount 1', + operationType: 'mount', + config: { app: { autoStart: false }, rclone: { mountPoint: '/mnt/test' } }, + }; + const mountQr2: QuickRun = { + ...mockQuickRun, + id: 'qr-mount-2', + name: 'Mount 2', + operationType: 'mount', + config: { app: { autoStart: false }, rclone: { mountPoint: '/mnt/test' } }, + }; + + invokeSpy.mockResolvedValue([mountQr1, mountQr2]); + await service.refresh(); + + const mountService = TestBed.inject(MountManagementService); + // Simulate only qr-mount-1 is active in mountedRemotes + (mountService.mountedRemotes as ReturnType).set([ + { + fs: 'drive:', + mount_point: '/mnt/test', + quick_run_id: 'qr-mount-1', + origin: 'quickrun', + }, + ]); + + TestBed.tick(); + + expect(service.runningIds().has('qr-mount-1')).toBe(true); + expect(service.runningIds().has('qr-mount-2')).toBe(false); + }); + + it('should isolate serve status by quick_run_id and not activate on remote profile serves', async () => { + const serveQr1: QuickRun = { + ...mockQuickRun, + id: 'qr-serve-1', + name: 'Serve 1', + operationType: 'serve', + }; + const serveQr2: QuickRun = { + ...mockQuickRun, + id: 'qr-serve-2', + name: 'Serve 2', + operationType: 'serve', + }; + + invokeSpy.mockResolvedValue([serveQr1, serveQr2]); + await service.refresh(); + + const serveService = TestBed.inject(ServeManagementService); + // Simulate qr-serve-1 is active, and also a dashboard profile serve is active + (serveService.runningServes as ReturnType).set([ + { + id: 'srv-1', + addr: '127.0.0.1:8080', + params: { fs: 'drive:' }, + quick_run_id: 'qr-serve-1', + origin: 'quickrun', + }, + { + id: 'srv-dashboard', + addr: '127.0.0.1:8081', + params: { fs: 'drive:' }, + profile: 'Serve 2', // Same name as qr-serve-2, but it's a dashboard profile! + origin: 'dashboard', + }, + ]); + + TestBed.tick(); + + expect(service.runningIds().has('qr-serve-1')).toBe(true); + expect(service.runningIds().has('qr-serve-2')).toBe(false); + }); + + it('should isolate sync job status by quick_run_id and ignore remote profile jobs', async () => { + const syncQr1: QuickRun = { + ...mockQuickRun, + id: 'qr-sync-1', + name: 'Sync 1', + operationType: 'sync', + }; + const syncQr2: QuickRun = { + ...mockQuickRun, + id: 'qr-sync-2', + name: 'Sync 2', + operationType: 'sync', + }; + + invokeSpy.mockResolvedValue([syncQr1, syncQr2]); + await service.refresh(); + + const jobService = TestBed.inject(JobManagementService); + // Simulate qr-sync-1 is running, and a dashboard job with same remote and name 'Sync 2' is running + (jobService.jobs as ReturnType).set([ + { + jobid: 101, + job_type: 'sync', + remote_name: 'drive:', + source: 'drive:path1', + destination: '/local/dest1', + status: 'Running', + execute_id: 'exec-qr-1', + quick_run_id: 'qr-sync-1', + origin: 'quickrun', + }, + { + jobid: 102, + job_type: 'sync', + remote_name: 'drive:', + source: 'drive:path2', + destination: '/local/dest2', + profile: 'Sync 2', + status: 'Running', + execute_id: 'exec-dash-2', + origin: 'dashboard', + }, + ]); + + TestBed.tick(); + + expect(service.runningIds().has('qr-sync-1')).toBe(true); + expect(service.runningIds().has('qr-sync-2')).toBe(false); + }); + }); +}); diff --git a/src/app/services/flow/quick-run.service.ts b/src/app/services/flow/quick-run.service.ts new file mode 100755 index 000000000..b729bbb52 --- /dev/null +++ b/src/app/services/flow/quick-run.service.ts @@ -0,0 +1,453 @@ +import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { combineLatest } from 'rxjs'; +import { TauriBaseService } from '../infrastructure/platform/tauri-base.service'; +import { + QuickRun, + QuickRunInput, + QuickRunStatus, + PrimaryActionType, + MountedRemote, + ServeListItem, + JobInfo, + OperationExecutionResult, +} from '@app/types'; +import { JobManagementService } from '../operations/job-management.service'; +import { MountManagementService } from '../operations/mount-management.service'; +import { ServeManagementService } from '../operations/serve-management.service'; +import { AutomationService } from '../operations/automation.service'; +import { ModalService } from '../ui/modal.service'; +import { findUniqueName } from '../remote/utils/unique-name.util'; + +/** + * Front-end store for the Flow workspace's "Quick Run" feature. + */ +@Injectable({ providedIn: 'root' }) +export class QuickRunService extends TauriBaseService { + private readonly jobService = inject(JobManagementService); + private readonly mountService = inject(MountManagementService); + private readonly serveService = inject(ServeManagementService); + private readonly automationService = inject(AutomationService); + private readonly modalService = inject(ModalService); + private readonly destroyRef = inject(DestroyRef); + + private readonly _quickRuns = signal([]); + private readonly _selectedId = signal(null); + private readonly _isCreating = signal(false); + private readonly _isLoading = signal(false); + private readonly _isSaving = signal(false); + private readonly _runningIds = signal>(new Set()); + private readonly _actionInProgress = signal>({}); + + readonly quickRuns = this._quickRuns.asReadonly(); + + /** Sorted view of quick runs — only use when you actually need alphabetical order. */ + readonly sortedQuickRuns = computed(() => + this._quickRuns() + .slice() + .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) + ); + /** Id of the quick run currently shown in the inspect view, if any. */ + readonly selectedId = this._selectedId.asReadonly(); + /** True while the editor panel is open in "create" mode. */ + readonly isCreating = this._isCreating.asReadonly(); + /** True while the initial list is being fetched from the backend. */ + readonly isLoading = this._isLoading.asReadonly(); + /** True while a create/update is in flight. */ + readonly isSaving = this._isSaving.asReadonly(); + /** Set of quick-run ids currently executing (drives card badges + buttons). */ + readonly runningIds = this._runningIds.asReadonly(); + /** In-flight action states per quick-run ID (e.g. 'start' or 'stop'). */ + readonly actionInProgress = this._actionInProgress.asReadonly(); + + /** The currently-selected quick run, or `null`. */ + readonly selected = computed( + () => this._quickRuns().find(qr => qr.id === this._selectedId()) ?? null + ); + + /** Quick runs that are currently running — shown first in the card grid. */ + readonly runningQuickRuns = computed(() => + this.quickRuns().filter(qr => this._runningIds().has(qr.id) || qr.status === 'running') + ); + + /** Quick runs that are not currently running — shown after the running ones. */ + readonly idleQuickRuns = computed(() => + this.quickRuns().filter(qr => !this._runningIds().has(qr.id) && qr.status !== 'running') + ); + + constructor() { + super(); + void this.refresh(); + this.listenToStatusUpdates(); + } + + private isUpdatingStatus = false; + + private listenToStatusUpdates(): void { + combineLatest([ + toObservable(this.jobService.jobs), + toObservable(this.mountService.mountedRemotes), + toObservable(this.serveService.runningServes), + toObservable(this._quickRuns), + ]) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(([jobs, mounts, serves, quickRuns]) => { + if (this.isUpdatingStatus) return; + if (!quickRuns || quickRuns.length === 0) return; + + this.isUpdatingStatus = true; + try { + const nextRunningIds = new Set(); + const patches: { id: string; patch: Partial }[] = []; + + for (const qr of quickRuns) { + if (qr.operationType === 'mount') { + const isMounted = this.isMountActive(qr, mounts ?? []); + if (isMounted) { + nextRunningIds.add(qr.id); + if (qr.status !== 'running') { + patches.push({ id: qr.id, patch: { status: 'running' } }); + } + } else if (qr.status === 'running') { + patches.push({ id: qr.id, patch: { status: 'stopped' } }); + } + } else if (qr.operationType === 'serve') { + const isServing = this.isServeActive(qr, serves ?? []); + if (isServing) { + nextRunningIds.add(qr.id); + if (qr.status !== 'running') { + patches.push({ id: qr.id, patch: { status: 'running' } }); + } + } else if (qr.status === 'running') { + patches.push({ id: qr.id, patch: { status: 'stopped' } }); + } + } else { + const job = this.getMatchingJob(qr, jobs ?? []); + if (job) { + const statusLower = job.status.toLowerCase(); + if (statusLower === 'running') { + nextRunningIds.add(qr.id); + if (qr.status !== 'running') { + patches.push({ id: qr.id, patch: { status: 'running' } }); + } + } else { + const finalStatus: QuickRunStatus = + statusLower === 'completed' || statusLower === 'finished' + ? 'completed' + : statusLower === 'failed' + ? 'failed' + : 'stopped'; + if (qr.status !== finalStatus) { + patches.push({ id: qr.id, patch: { status: finalStatus } }); + } + } + } + } + } + + // Apply all patches in a single batch update to minimize signal emissions. + if (patches.length > 0) { + this._quickRuns.update(list => { + const patchMap = new Map(patches.map(p => [p.id, p.patch])); + return list.map(qr => { + const patch = patchMap.get(qr.id); + return patch ? { ...qr, ...patch } : qr; + }); + }); + } + + // Only update runningIds if the set actually changed. + const currentSet = this._runningIds(); + let changed = currentSet.size !== nextRunningIds.size; + if (!changed) { + for (const id of nextRunningIds) { + if (!currentSet.has(id)) { + changed = true; + break; + } + } + } + if (changed) { + this._runningIds.set(nextRunningIds); + } + } finally { + this.isUpdatingStatus = false; + } + }); + } + + private isMountActive(qr: QuickRun, mounts: MountedRemote[]): boolean { + return mounts.some(m => m.quick_run_id === qr.id); + } + + private isServeActive(qr: QuickRun, serves: ServeListItem[]): boolean { + return serves.some(s => s.quick_run_id === qr.id); + } + + private getMatchingJob(qr: QuickRun, jobs: JobInfo[]): JobInfo | undefined { + return jobs + .filter(j => j.quick_run_id === qr.id) + .sort((a, b) => { + const ta = a.start_time ? new Date(a.start_time).getTime() : 0; + const tb = b.start_time ? new Date(b.start_time).getTime() : 0; + return tb !== ta ? tb - ta : b.jobid - a.jobid; + })[0]; + } + + // ── Selection ──────────────────────────────────────────────────────────── + + select(id: string | null): void { + this._selectedId.set(id); + } + + deselect(): void { + this._selectedId.set(null); + } + + isSelected(id: string): boolean { + return this._selectedId() === id; + } + + // ── Editor lifecycle ───────────────────────────────────────────────────── + + generateUniqueQuickRunName(baseName: string): string { + const base = baseName + .replace(/-\d+$/, '') + .replace(/\s*\(copy\)$/i, '') + .trim(); + const existing = this._quickRuns().map(qr => qr.name); + return findUniqueName(base || 'quickrun', existing); + } + + openEditor( + input?: QuickRunInput | QuickRun, + initialOpType?: PrimaryActionType, + initialRemoteName?: string + ): void { + if (input && 'id' in input && input.id) { + this.modalService.openQuickRunEditor({ + quickRun: input as QuickRun, + initialOpType, + initialRemoteName, + }); + } else if (input) { + this.modalService.openQuickRunEditor({ + cloneData: input, + initialOpType: initialOpType ?? input.operationType, + initialRemoteName: initialRemoteName ?? input.remoteName, + }); + } else { + this.modalService.openQuickRunEditor({ + initialOpType, + initialRemoteName, + }); + } + } + + // ── Backend commands ───────────────────────────────────────────────────── + + /** + * Refresh the in-memory store from the backend. If the backend command + * isn't registered yet (pre-Rust), we silently keep an empty list so the + * UI can still be exercised. + */ + async refresh(): Promise { + this._isLoading.set(true); + try { + const list = await this.invokeCommand('list_quick_runs'); + const mappedList = (list ?? []).map(qr => ({ ...qr, status: qr.status ?? 'idle' })); + this._quickRuns.set(mappedList); + void this.mountService.getMountedRemotes(); + void this.serveService.refreshServes(); + void this.jobService.refreshJobs(); + void this.automationService.refreshAutomations(); + } catch (err) { + console.warn('[QuickRunService] list_quick_runs not available, running in-memory only:', err); + // In-memory fallback — keep whatever we already have. + } finally { + this._isLoading.set(false); + } + } + + /** + * Persist a new quick run (or update an existing one if `id` is set). + * On success the in-memory store is updated and the editor closes. + */ + async save(input: QuickRunInput): Promise { + this._isSaving.set(true); + try { + const saved = input.id + ? await this.invokeCommand('update_quick_run', { + quickRun: { ...input, id: input.id }, + }) + : await this.invokeCommand('create_quick_run', { quickRun: input }); + + const itemToStore = saved + ? { ...saved, status: saved.status ?? 'idle' } + : this.synthesizeLocal(input); + + this.mergeIntoStore(itemToStore); + void this.automationService.refreshAutomations(); + return itemToStore; + } catch (err) { + console.error('[QuickRunService] save failed, falling back to in-memory:', err); + const local = this.synthesizeLocal(input); + this.mergeIntoStore(local); + return local; + } finally { + this._isSaving.set(false); + } + } + + /** Delete a quick run by id. */ + async remove(id: string): Promise { + try { + await this.invokeCommand('delete_quick_run', { quickRunId: id }); + } catch (err) { + console.warn('[QuickRunService] delete_quick_run not available, removing locally:', err); + } + this._quickRuns.update(list => list.filter(qr => qr.id !== id)); + if (this._selectedId() === id) this._selectedId.set(null); + void this.automationService.refreshAutomations(); + } + + /** + * Duplicate an existing quick run by opening the editor modal prefilled + * with the source configuration and an auto-generated unique name (e.g. `Name-1`). + */ + duplicate(id: string): void { + const source = this._quickRuns().find(qr => qr.id === id); + if (!source) return; + const newName = this.generateUniqueQuickRunName(source.name); + const cloneData: QuickRunInput = { + name: newName, + description: source.description, + operationType: source.operationType, + remoteName: source.remoteName, + config: structuredClone(source.config), + }; + this.openEditor(cloneData); + } + + // ── Execution ──────────────────────────────────────────────────────────── + + /** + * Execute an async quick run action with in-flight state tracking. + */ + async executeAction( + id: string, + action: 'start' | 'stop', + operation: () => Promise + ): Promise { + this._actionInProgress.update(state => ({ ...state, [id]: action })); + try { + return await operation(); + } finally { + this._actionInProgress.update(state => { + const next = { ...state }; + delete next[id]; + return next; + }); + } + } + + /** + * Start a quick run. Returns the execution result descriptor on success. + */ + async start(id: string): Promise { + const qr = this._quickRuns().find(q => q.id === id); + if (!qr) return null; + + return await this.executeAction(id, 'start', async () => { + this.markRunning(id); + try { + const result = await this.invokeCommand('start_quick_run', { + quickRunId: id, + }); + this.patchInStore(id, { status: result?.status ?? 'running' }); + void this.jobService.refreshJobs(); + void this.mountService.getMountedRemotes(); + void this.serveService.refreshServes(); + return result; + } catch (err) { + console.error('[QuickRunService] start_quick_run failed:', err); + this.notificationService.showError(err); + this.markStopped(id, { status: 'failed' }); + return null; + } + }); + } + + /** Stop a running quick run. */ + async stop(id: string): Promise { + await this.executeAction(id, 'stop', async () => { + try { + await this.invokeCommand('stop_quick_run', { quickRunId: id }); + } catch (err) { + console.warn('[QuickRunService] stop_quick_run failed:', err); + this.notificationService.showError(err); + } + this.markStopped(id, { status: 'stopped' }); + void this.jobService.refreshJobs(); + void this.mountService.getMountedRemotes(); + void this.serveService.refreshServes(); + }); + } + + // ── Runtime state helpers ──────────────────────────────────────────────── + + markRunning(id: string): void { + this._runningIds.update(set => new Set(set).add(id)); + this.patchInStore(id, { status: 'running' }); + } + + markStopped(id: string, patch: Partial>): void { + this._runningIds.update(set => { + const next = new Set(set); + next.delete(id); + return next; + }); + this.patchInStore(id, { + status: patch.status ?? 'idle', + }); + } + + // ── Private helpers ────────────────────────────────────────────────────── + + private mergeIntoStore(qr: QuickRun): void { + this._quickRuns.update(list => { + const idx = list.findIndex(item => item.id === qr.id); + if (idx === -1) return [...list, qr]; + const next = list.slice(); + next[idx] = qr; + return next; + }); + } + + private patchInStore(id: string, patch: Partial): void { + this._quickRuns.update(list => list.map(qr => (qr.id === id ? { ...qr, ...patch } : qr))); + } + + /** + * Build a fully-formed {@link QuickRun} from a {@link QuickRunInput} when + * the backend isn't available. The synthesised record uses a random id and `status: 'idle'`. + */ + private synthesizeLocal(input: QuickRunInput): QuickRun { + return { + id: input.id ?? this.generateId(), + name: input.name, + description: input.description, + operationType: input.operationType, + remoteName: input.remoteName, + config: input.config, + status: 'idle' satisfies QuickRunStatus, + }; + } + + private generateId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `qr-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + } +} diff --git a/src/app/services/i18n/backend-translation.service.spec.ts b/src/app/services/i18n/backend-translation.service.spec.ts index c4f22a638..d05c04f9c 100644 --- a/src/app/services/i18n/backend-translation.service.spec.ts +++ b/src/app/services/i18n/backend-translation.service.spec.ts @@ -1,13 +1,16 @@ import { TestBed } from '@angular/core/testing'; import { TranslateService } from '@ngx-translate/core'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { BackendTranslationService } from './backend-translation.service'; describe('BackendTranslationService', () => { let service: BackendTranslationService; - let translateServiceMock: jasmine.SpyObj; + let translateServiceMock: { instant: ReturnType }; beforeEach(() => { - translateServiceMock = jasmine.createSpyObj('TranslateService', ['instant']); + translateServiceMock = { + instant: vi.fn(), + }; TestBed.configureTestingModule({ providers: [ @@ -25,18 +28,72 @@ describe('BackendTranslationService', () => { describe('translateBackendMessage', () => { it('should translate valid JSON error with params', () => { - const message = JSON.stringify({ key: 'errors.test', params: { param: 'value' } }); - translateServiceMock.instant.and.returnValue('Translated Error value'); + const message = JSON.stringify({ + key: 'backendErrors.mount.configIncomplete', + params: { profile: 'Default' }, + }); + translateServiceMock.instant.mockReturnValue( + "Mount configuration incomplete for profile 'Default'" + ); const result = service.translateBackendMessage(message); - expect(translateServiceMock.instant).toHaveBeenCalledWith('errors.test', { param: 'value' }); - expect(result).toBe('Translated Error value'); + expect(translateServiceMock.instant).toHaveBeenCalledWith( + 'backendErrors.mount.configIncomplete', + { + profile: 'Default', + } + ); + expect(result).toBe("Mount configuration incomplete for profile 'Default'"); + }); + + it('should translate embedded JSON error in prefixed string', () => { + const embedded = + 'Start job failed: {"key":"backendErrors.mount.configIncomplete","params":{"profile":"Default"}}'; + translateServiceMock.instant.mockReturnValue( + "Mount configuration incomplete for profile 'Default'" + ); + + const result = service.translateBackendMessage(embedded); + + expect(translateServiceMock.instant).toHaveBeenCalledWith( + 'backendErrors.mount.configIncomplete', + { + profile: 'Default', + } + ); + expect(result).toBe("Start job failed: Mount configuration incomplete for profile 'Default'"); + }); + + it('should translate Error object wrapping JSON error', () => { + const error = new Error(JSON.stringify({ key: 'backendErrors.mount.pointEmpty' })); + translateServiceMock.instant.mockReturnValue('Mount point cannot be empty'); + + const result = service.translateBackendMessage(error); + + expect(translateServiceMock.instant).toHaveBeenCalledWith( + 'backendErrors.mount.pointEmpty', + undefined + ); + expect(result).toBe('Mount point cannot be empty'); + }); + + it('should translate structured object input directly', () => { + const obj = { key: 'backendErrors.rclone.binaryNotFound' }; + translateServiceMock.instant.mockReturnValue('Rclone binary not found.'); + + const result = service.translateBackendMessage(obj); + + expect(translateServiceMock.instant).toHaveBeenCalledWith( + 'backendErrors.rclone.binaryNotFound', + undefined + ); + expect(result).toBe('Rclone binary not found.'); }); it('should fallback to raw JSON if translation key is missing', () => { - const message = JSON.stringify({ key: 'errors.missing', params: {} }); - translateServiceMock.instant.and.returnValue('errors.missing'); // Returns key if not found + const message = JSON.stringify({ key: 'backendErrors.missing', params: {} }); + translateServiceMock.instant.mockReturnValue('backendErrors.missing'); // Returns key if not found const result = service.translateBackendMessage(message); @@ -45,27 +102,29 @@ describe('BackendTranslationService', () => { }); it('should translate simple translation key', () => { - const message = 'errors.simple.key'; - translateServiceMock.instant.and.returnValue('Simple Translation'); + const message = 'backendErrors.simple.key'; + translateServiceMock.instant.mockReturnValue('Simple Translation'); const result = service.translateBackendMessage(message); - expect(translateServiceMock.instant).toHaveBeenCalledWith('errors.simple.key', undefined); + expect(translateServiceMock.instant).toHaveBeenCalledWith( + 'backendErrors.simple.key', + undefined + ); expect(result).toBe('Simple Translation'); }); it('should fallback to original string if simple key not found', () => { - const message = 'errors.missing.key'; - translateServiceMock.instant.and.returnValue('errors.missing.key'); + const message = 'backendErrors.missing.key'; + translateServiceMock.instant.mockReturnValue('backendErrors.missing.key'); const result = service.translateBackendMessage(message); - expect(result).toBe('errors.missing.key'); + expect(result).toBe('backendErrors.missing.key'); }); it('should return non-key string as is', () => { const message = 'Some random backend error'; - // Should NOT call translate service const result = service.translateBackendMessage(message); @@ -80,6 +139,11 @@ describe('BackendTranslationService', () => { expect(result).toBe(message); }); + it('should handle null or undefined gracefully', () => { + expect(service.translateBackendMessage(null)).toBe(''); + expect(service.translateBackendMessage(undefined)).toBe(''); + }); + it('should handle non-string inputs', () => { const message = 123; diff --git a/src/app/services/i18n/backend-translation.service.ts b/src/app/services/i18n/backend-translation.service.ts index 860ce85ed..26eb83eec 100644 --- a/src/app/services/i18n/backend-translation.service.ts +++ b/src/app/services/i18n/backend-translation.service.ts @@ -1,53 +1,107 @@ import { Injectable, inject } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -interface LocalizedError { +interface LocalizedMessage { key: string; params?: Record; } /** - * Service for translating backend error/success messages - * Handles both simple translation keys and JSON format with parameters + * Service for translating backend error/success messages. + * Handles multiple formats: + * 1. JSON string with key + params: {"key": "backendErrors.mount.alreadyInUse", "params": {...}} + * 2. Embedded JSON in string: "Start job failed: {\"key\":\"backendErrors.mount.configIncomplete\",...}" + * 3. Structured object: { key: "backendErrors.mount.alreadyInUse", params: {...} } + * 4. Simple translation key: "backendErrors.mount.pointEmpty" + * 5. Error instance wrapping key/JSON + * 6. Legacy English message: "Mount point cannot be empty" */ @Injectable({ providedIn: 'root' }) export class BackendTranslationService { private translate = inject(TranslateService); /** - * Translate a backend error/message response - * Handles three formats: - * 1. JSON with key + params: {"key": "errors.mount.alreadyInUse", "params": {...}} - * 2. Simple translation key: "errors.mount.pointEmpty" - * 3. Legacy English message: "Mount point cannot be empty" + * Translate a backend error/message response. */ translateBackendMessage(message: unknown): string { - if (typeof message !== 'string') { + if (message === null || message === undefined) { + return ''; + } + + // Direct object with key property + if ( + typeof message === 'object' && + 'key' in message && + typeof (message as LocalizedMessage).key === 'string' + ) { + const loc = message as LocalizedMessage; + return this.translateWithFallback(loc.key, loc.params); + } + + let msgStr: string; + if (typeof message === 'string') { + msgStr = message; + } else if (message instanceof Error) { + msgStr = message.message; + } else if (typeof message === 'object' && 'message' in message) { + msgStr = String((message as { message: unknown }).message); + } else { return String(message); } - // Try to parse as JSON (dynamic error with params) - const parsed = this.tryParseLocalizedError(message); + // 1. Try to parse entire string as JSON (dynamic error with params) + const parsed = this.tryParseLocalizedError(msgStr); if (parsed) { - return this.translateWithFallback(parsed.key, parsed.params, message); + return this.translateWithFallback(parsed.key, parsed.params, msgStr); } - // Check if it looks like a translation key (e.g., "errors.mount.pointEmpty") - if (this.looksLikeTranslationKey(message)) { - return this.translateWithFallback(message, undefined, message); + // 2. Check if message contains an embedded JSON error (e.g., "Prefix: {"key":"...", ...}") + const embedded = this.tryExtractEmbeddedLocalizedError(msgStr); + if (embedded) { + const translated = this.translateWithFallback( + embedded.error.key, + embedded.error.params, + embedded.rawJson + ); + return msgStr.replace(embedded.rawJson, translated); } - // Return as-is (legacy English message or unknown format) - return message; + // 3. Check if it looks like a translation key (e.g., "backendErrors.mount.pointEmpty") + if (this.looksLikeTranslationKey(msgStr)) { + return this.translateWithFallback(msgStr, undefined, msgStr); + } + + // 4. Return as-is (legacy English message or unknown format) + return msgStr; + } + + private tryParseLocalizedError(message: string): LocalizedMessage | null { + const trimmed = message.trim(); + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null; + + try { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed.key === 'string') { + return parsed as LocalizedMessage; + } + } catch { + // Not valid JSON, ignore + } + return null; } - private tryParseLocalizedError(message: string): LocalizedError | null { - if (!message.startsWith('{')) return null; + private tryExtractEmbeddedLocalizedError( + message: string + ): { error: LocalizedMessage; rawJson: string } | null { + const startIndex = message.indexOf('{'); + const lastIndex = message.lastIndexOf('}'); + if (startIndex === -1 || lastIndex <= startIndex) return null; + const rawJson = message.slice(startIndex, lastIndex + 1); try { - const parsed = JSON.parse(message); + const parsed = JSON.parse(rawJson); if (parsed && typeof parsed.key === 'string') { - return parsed as LocalizedError; + return { error: parsed as LocalizedMessage, rawJson }; } } catch { // Not valid JSON, ignore @@ -56,9 +110,7 @@ export class BackendTranslationService { } private looksLikeTranslationKey(message: string): boolean { - // Examples: "errors.mount.pointEmpty", "success.mount.completed", "common.error" - // Must start with category and have at least one dot. - return /^[a-z0-9_]+\.[a-z0-9_.]*[a-z0-9_]+$/i.test(message); + return /^[a-z0-9_]+(\.[a-z0-9_]+)+$/i.test(message.trim()); } private translateWithFallback( diff --git a/src/app/services/i18n/cron-locale.mapper.ts b/src/app/services/i18n/cron-locale.mapper.ts index 4ee3e4413..46bdb0ed7 100644 --- a/src/app/services/i18n/cron-locale.mapper.ts +++ b/src/app/services/i18n/cron-locale.mapper.ts @@ -5,11 +5,13 @@ import 'cronstrue/locales/zh_TW'; import 'cronstrue/locales/fr'; import 'cronstrue/locales/pt_BR'; import 'cronstrue/locales/ru'; +import 'cronstrue/locales/ja'; +import 'cronstrue/locales/uk'; import { toString as cronstrue } from 'cronstrue'; /** - * Maps an app locale (e.g. 'en-US', 'tr-TR') to a cronstrue locale (e.g. 'en', 'tr'). - * cronstrue uses 2-letter codes; Chinese is the only exception requiring a region suffix. + * Maps an app locale (e.g. 'en-US', 'tr-TR', 'pt-BR') to a cronstrue locale (e.g. 'en', 'tr', 'pt_BR'). + * cronstrue uses 2-letter codes for most languages; Chinese and Portuguese require region variants. */ export function getCronstrueLocale(appLocale: string): string { if (!appLocale) return 'en'; @@ -20,6 +22,10 @@ export function getCronstrueLocale(appLocale: string): string { return region === 'tw' ? 'zh_TW' : 'zh_CN'; } + if (lang === 'pt') { + return region === 'pt' ? 'pt_PT' : 'pt_BR'; + } + return lang; } diff --git a/src/app/services/i18n/multi-file-loader.spec.ts b/src/app/services/i18n/multi-file-loader.spec.ts new file mode 100644 index 000000000..5268ba979 --- /dev/null +++ b/src/app/services/i18n/multi-file-loader.spec.ts @@ -0,0 +1,64 @@ +import { HttpClient } from '@angular/common/http'; +import { of, throwError } from 'rxjs'; +import { describe, expect, it, vi } from 'vitest'; +import { MultiFileLoader } from './multi-file-loader'; + +describe('MultiFileLoader', () => { + it('should load and merge main, rclone, and rclone-providers translation files', async () => { + const httpMock = { + get: vi.fn((url: string) => { + if (url === 'assets/i18n/tr-TR/main.json') { + return of({ common: { ok: 'Tamam' } }); + } + if (url === 'assets/i18n/tr-TR/rclone.json') { + return of({ flags: { verbose: 'Ayrıntılı' } }); + } + if (url === 'assets/i18n/tr-TR/rclone-providers.json') { + return of({ providers: { drive: 'Google Drive' } }); + } + return throwError(() => new Error('Not found')); + }), + } as unknown as HttpClient; + + const loader = new MultiFileLoader(httpMock); + const result = await new Promise(resolve => { + loader.getTranslation('tr-TR').subscribe(resolve); + }); + + expect(result).toEqual({ + common: { ok: 'Tamam' }, + flags: { verbose: 'Ayrıntılı' }, + providers: { drive: 'Google Drive' }, + }); + }); + + it('should fallback to en-US if requested locale file fails to load', async () => { + const httpMock = { + get: vi.fn((url: string) => { + if (url === 'assets/i18n/es-ES/main.json') { + return throwError(() => new Error('404')); + } + if (url === 'assets/i18n/en-US/main.json') { + return of({ common: { ok: 'OK' } }); + } + if (url === 'assets/i18n/es-ES/rclone.json') { + return of({ flags: { verbose: 'Detallado' } }); + } + if (url === 'assets/i18n/es-ES/rclone-providers.json') { + return of({}); + } + return throwError(() => new Error('Not found')); + }), + } as unknown as HttpClient; + + const loader = new MultiFileLoader(httpMock); + const result = await new Promise(resolve => { + loader.getTranslation('es-ES').subscribe(resolve); + }); + + expect(result).toEqual({ + common: { ok: 'OK' }, + flags: { verbose: 'Detallado' }, + }); + }); +}); diff --git a/src/app/services/i18n/multi-file-loader.ts b/src/app/services/i18n/multi-file-loader.ts index 85c32d033..0f76932e6 100644 --- a/src/app/services/i18n/multi-file-loader.ts +++ b/src/app/services/i18n/multi-file-loader.ts @@ -1,15 +1,40 @@ -import { TranslateLoader } from '@ngx-translate/core'; -import { Observable, from } from 'rxjs'; -import { ApiClientService } from '../infrastructure/platform/api-client.service'; +import { HttpClient } from '@angular/common/http'; +import { TranslateLoader, TranslationObject } from '@ngx-translate/core'; +import { Observable, forkJoin, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; /** * Custom loader to load multiple translation files for a single language - * and merge them into a single object. + * from static assets and merge them into a single object. */ export class MultiFileLoader implements TranslateLoader { - constructor(private apiClient: ApiClientService) {} + constructor(private http: HttpClient) {} - public getTranslation(lang: string): Observable { - return from(this.apiClient.invoke>('get_i18n', { lang })); + public getTranslation(lang: string): Observable { + const files = ['main.json', 'rclone.json', 'rclone-providers.json']; + const requests = files.map(file => + this.http.get(`assets/i18n/${lang}/${file}`).pipe( + catchError(() => { + if (lang !== 'en-US') { + return this.http + .get(`assets/i18n/en-US/${file}`) + .pipe(catchError(() => of({} as TranslationObject))); + } + return of({} as TranslationObject); + }) + ) + ); + + return forkJoin(requests).pipe( + map(responses => { + const merged: TranslationObject = {}; + for (const res of responses) { + if (res && typeof res === 'object') { + Object.assign(merged, res); + } + } + return merged; + }) + ); } } diff --git a/src/app/services/infrastructure/maintenance/rclone-status.service.ts b/src/app/services/infrastructure/maintenance/rclone-status.service.ts index 16ab77184..4a1d3f918 100644 --- a/src/app/services/infrastructure/maintenance/rclone-status.service.ts +++ b/src/app/services/infrastructure/maintenance/rclone-status.service.ts @@ -6,6 +6,7 @@ import { filter, switchMap, tap } from 'rxjs/operators'; import { SystemInfoService } from '../system/system-info.service'; import { BackendService } from '../system/backend.service'; import { EventListenersService } from '../system/event-listeners.service'; +import { AppSettingsService } from '../../settings/app-settings.service'; import { BandwidthLimitResponse, DEFAULT_JOB_STATS, @@ -22,6 +23,7 @@ export class RcloneStatusService { private systemInfoService = inject(SystemInfoService); private backendService = inject(BackendService); private eventListenersService = inject(EventListenersService); + private appSettingsService = inject(AppSettingsService); private destroyRef = inject(DestroyRef); private document = inject(DOCUMENT); @@ -29,6 +31,10 @@ export class RcloneStatusService { readonly bandwidthLimit = signal(null, { equal: deepEqual, }); + readonly savedBandwidthLimit = computed(() => { + const opts = this.appSettingsService.options(); + return ((opts?.['core.bandwidth_limit']?.value as string) ?? '').trim(); + }); readonly rcloneStatus = signal('inactive'); readonly rclonePID = signal(null); readonly jobStats = signal(structuredClone(DEFAULT_JOB_STATS)); @@ -172,6 +178,12 @@ export class RcloneStatusService { } } + async setBandwidthLimit(rate: string): Promise { + const persistedValue = rate === 'off' ? '' : rate; + await this.systemInfoService.bandwidthLimit(rate); + await this.appSettingsService.saveSetting('core', 'bandwidth_limit', persistedValue); + } + pausePolling(): void { this.isManuallyPaused.set(true); } diff --git a/src/app/services/infrastructure/maintenance/system-health.service.ts b/src/app/services/infrastructure/maintenance/system-health.service.ts index 3f0704c53..4d250edfd 100755 --- a/src/app/services/infrastructure/maintenance/system-health.service.ts +++ b/src/app/services/infrastructure/maintenance/system-health.service.ts @@ -2,7 +2,6 @@ import { DestroyRef, Injectable, signal, computed, inject } from '@angular/core' import { MatBottomSheet, MatBottomSheetRef } from '@angular/material/bottom-sheet'; import { EMPTY, firstValueFrom, from, catchError, exhaustMap, filter } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { TranslateService } from '@ngx-translate/core'; import { RepairSheetComponent } from '../../../features/components/repair-sheet/repair-sheet.component'; import { RepairData, RepairSheetType, PasswordPromptResult } from '@app/types'; import { SystemInfoService } from '../system/system-info.service'; @@ -20,7 +19,6 @@ export class SystemHealthService { private readonly eventListenersService = inject(EventListenersService); private readonly bottomSheet = inject(MatBottomSheet); private readonly destroyRef = inject(DestroyRef); - private readonly translate = inject(TranslateService); private readonly activeSheets = new Set>(); private hasReportedRclonePathError = false; diff --git a/src/app/services/infrastructure/platform/api-client.service.ts b/src/app/services/infrastructure/platform/api-client.service.ts index a4f0ae3ba..44b0cb729 100644 --- a/src/app/services/infrastructure/platform/api-client.service.ts +++ b/src/app/services/infrastructure/platform/api-client.service.ts @@ -35,10 +35,6 @@ export class ApiClientService { private async invokeHttp(command: string, args?: Record): Promise { if (command === 'set_theme') return {} as T; - if (command === 'get_system_theme') { - return (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') as T; - } - if (command === 'open_in_files') { throw new Error('Native file manager is not available in web mode.'); } diff --git a/src/app/services/infrastructure/platform/opener.service.spec.ts b/src/app/services/infrastructure/platform/opener.service.spec.ts new file mode 100644 index 000000000..f28c52782 --- /dev/null +++ b/src/app/services/infrastructure/platform/opener.service.spec.ts @@ -0,0 +1,38 @@ +import { TestBed } from '@angular/core/testing'; +import { OpenerService } from './opener.service'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { expect } from 'vitest'; + +describe('OpenerService', () => { + let service: OpenerService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + OpenerService, + provideHttpClient(), + provideHttpClientTesting(), + { provide: TranslateService, useValue: { instant: (k: string): string => k } }, + ], + }); + service = TestBed.inject(OpenerService); + }); + + it('should be created', (): void => { + expect(service).toBeTruthy(); + }); + + it('should handle empty or null URLs gracefully', async (): Promise => { + await expect(service.openUrl('')).resolves.toBeUndefined(); + }); + + it('should handle empty or null paths gracefully', async (): Promise => { + await expect(service.openPath('')).resolves.toBeUndefined(); + }); + + it('should initialize link interceptor without throwing', (): void => { + expect(() => service.initializeGlobalLinkInterceptor()).not.toThrow(); + }); +}); diff --git a/src/app/services/infrastructure/platform/opener.service.ts b/src/app/services/infrastructure/platform/opener.service.ts new file mode 100644 index 000000000..3b1189b58 --- /dev/null +++ b/src/app/services/infrastructure/platform/opener.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@angular/core'; +import { openUrl, openPath } from '@tauri-apps/plugin-opener'; +import { TauriBaseService } from './tauri-base.service'; + +@Injectable({ providedIn: 'root' }) +export class OpenerService extends TauriBaseService { + private interceptorInitialized = false; + + /** + * Opens an external web URL using the system's default browser on Desktop (Tauri), + * or a new tab/window in Web/Headless mode. + */ + async openUrl(url: string): Promise { + if (!url) return; + + if (this.isTauri) { + try { + await openUrl(url); + return; + } catch (err) { + console.warn('Failed to open URL via plugin-opener, falling back to window.open:', err); + } + } + + window.open(url, '_blank', 'noopener,noreferrer'); + } + + /** + * Opens a file or folder in the OS default file manager or default application. + */ + async openPath(path: string): Promise { + if (!path) return; + + if (this.isTauri) { + try { + await openPath(path); + } catch (err) { + console.error('Failed to open path via plugin-opener:', err); + } + } + } + + /** + * Intercepts all clicks on `` tags with external protocols (http, https, mailto) + * on Desktop so they reliably open in the default browser without webview navigation issues. + */ + initializeGlobalLinkInterceptor(): void { + if (!this.isTauri || this.interceptorInitialized) return; + this.interceptorInitialized = true; + + document.addEventListener( + 'click', + (event: MouseEvent) => { + const target = event.target as HTMLElement | null; + const anchor = target?.closest('a') as HTMLAnchorElement | null; + if (!anchor || !anchor.href) return; + + const href = anchor.href; + if (/^(https?:\/\/|mailto:)/i.test(href)) { + event.preventDefault(); + event.stopPropagation(); + void this.openUrl(href); + } + }, + true + ); + } +} diff --git a/src/app/services/infrastructure/platform/path-inspection.service.ts b/src/app/services/infrastructure/platform/path-inspection.service.ts new file mode 100644 index 000000000..c251ebaf0 --- /dev/null +++ b/src/app/services/infrastructure/platform/path-inspection.service.ts @@ -0,0 +1,265 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { LocalDrive } from '@app/types'; +import { ApiClientService } from './api-client.service'; +import { AppSettingsService } from '../../settings/app-settings.service'; +import { RemoteFileOperationsService } from '../../remote/remote-file-operations.service'; +import { RemoteFacadeService } from '../../facade/remote-facade.service'; +import { PathService, PathStyle } from './path.service'; + +export type DefaultPathOp = 'mount' | 'bisync'; + +export interface PathInspectionStatus { + state: 'clean' | 'nonEmpty' | 'colliding' | 'willCreate' | 'checking'; + details?: string; + icon: string; + badgeClass: string; + labelKey: string; +} + +const MOUNT_TEMPLATE_FALLBACK = '{home}/rclone-manager/{remote}'; +const BISYNC_TEMPLATE_FALLBACK = '{home}/rclone-manager/{remote}-bisync'; +const HOME_FALLBACK_POSIX = '/root/rclone-manager'; +const MAX_DEFAULT_PATH_ATTEMPTS = 10; + +@Injectable({ providedIn: 'root' }) +export class PathInspectionService { + private readonly apiClient = inject(ApiClientService); + private readonly appSettingsService = inject(AppSettingsService); + private readonly remoteFileOps = inject(RemoteFileOperationsService); + private readonly remoteFacade = inject(RemoteFacadeService); + private readonly pathService = inject(PathService); + + private readonly statuses = signal>({}); + private readonly checkingKeys = new Set(); + + /** + * Get the inspection status of a local path. + * If not cached, triggers background validation and returns a 'checking' status immediately. + */ + getPathStatus( + path: string | undefined | null, + opType: string, + remoteName: string + ): PathInspectionStatus | null { + if (!path || !path.trim()) { + return null; + } + const trimmedPath = path.trim(); + const cacheKey = `${remoteName}:${opType}:${trimmedPath}`; + + const cached = this.statuses()[cacheKey]; + if (cached) { + return cached; + } + + this.triggerInspection(cacheKey, trimmedPath, remoteName); + + return { + state: 'checking', + icon: 'spinner', + badgeClass: 'checking', + labelKey: 'remoteConfig.pathStatus.checking', + }; + } + + private async triggerInspection(key: string, path: string, remoteName: string): Promise { + if (this.checkingKeys.has(key)) return; + this.checkingKeys.add(key); + + try { + const status = await this.runInspection(path, remoteName); + this.statuses.update(m => ({ ...m, [key]: status })); + } catch { + const fallback: PathInspectionStatus = { + state: 'willCreate', + icon: 'folder-plus', + badgeClass: 'will-create', + labelKey: 'remoteConfig.pathStatus.willCreate', + }; + this.statuses.update(m => ({ ...m, [key]: fallback })); + } finally { + this.checkingKeys.delete(key); + } + } + + private async runInspection(path: string, remoteName: string): Promise { + // 1. Collision check (highest priority, synchronous) + const collisions = this.remoteFacade.checkMountPathCollision(path, remoteName); + if (collisions.length > 0) { + const c = collisions[0]; + return { + state: 'colliding', + details: `${c.remoteName} (${c.opType})`, + icon: 'warning', + badgeClass: 'colliding', + labelKey: 'remoteConfig.pathStatus.colliding', + }; + } + + // 2. Async check via Rclone API + const { root, relative } = this.pathService.splitLocalForStat(path); + try { + const statRes = await this.remoteFileOps.getStat(root, relative); + if (!statRes?.item) { + return { + state: 'willCreate', + icon: 'folder-plus', + badgeClass: 'will-create', + labelKey: 'remoteConfig.pathStatus.willCreate', + }; + } + + const sizeRes = await this.remoteFileOps.getSize(root, relative).catch(() => null); + if (sizeRes && sizeRes.count > 0) { + return { + state: 'nonEmpty', + icon: 'folder-open', + badgeClass: 'non-empty', + labelKey: 'remoteConfig.pathStatus.nonEmpty', + }; + } + + return { + state: 'clean', + icon: 'check-circle', + badgeClass: 'clean', + labelKey: 'remoteConfig.pathStatus.clean', + }; + } catch { + return { + state: 'willCreate', + icon: 'folder-plus', + badgeClass: 'will-create', + labelKey: 'remoteConfig.pathStatus.willCreate', + }; + } + } + + /** + * Resolve a unique default local path for mount or bisync based on config templates. + */ + async resolveDefaultPath(remoteName: string, opType: DefaultPathOp): Promise { + const [template, home] = await Promise.all([this.getPathTemplate(opType), this.resolveHome()]); + const remote = this.sanitizeRemoteName(remoteName); + const raw = this.substitute(template, home, remote); + const normalized = this.pathService.normalizeForPlatform(raw); + return this.ensureMountableDefault(normalized); + } + + private async getPathTemplate(opType: DefaultPathOp): Promise { + const settingKey = opType === 'bisync' ? 'default_bisync_directory' : 'default_mount_directory'; + const fallback = opType === 'bisync' ? BISYNC_TEMPLATE_FALLBACK : MOUNT_TEMPLATE_FALLBACK; + const stored = await this.appSettingsService.getSettingValue(settingKey); + return stored && stored.trim() ? stored : fallback; + } + + private async resolveHome(): Promise { + try { + const drives = await this.apiClient.invoke('get_local_drives'); + if (drives && drives.length > 0) { + const first = drives[0]; + const candidate = first.name || first.mount_point || ''; + if (candidate) return candidate; + } + } catch (err) { + console.warn('[PathInspectionService] Could not query Rclone local drives:', err); + } + return HOME_FALLBACK_POSIX; + } + + private sanitizeRemoteName(remoteName: string): string { + return (remoteName || 'cloud-remote').replace(/[:/\\]/g, '-'); + } + + private substitute(template: string, home: string, remote: string): string { + return template.replace('{home}', home).replace('{remote}', remote); + } + + private async ensureMountableDefault(path: string): Promise { + const { root, relative } = this.pathService.splitLocalForStat(path); + + for (let attempt = 0; attempt < MAX_DEFAULT_PATH_ATTEMPTS; attempt++) { + const suffix = attempt === 0 ? '' : `-${attempt + 1}`; + const candidate = `${path}${suffix}`; + const candidateRel = `${relative}${suffix}`; + + try { + const stat = await this.remoteFileOps.getStat(root, candidateRel); + if (!stat?.item) { + return candidate; + } + try { + const size = await this.remoteFileOps.getSize(root, candidateRel); + if (!size || size.count === 0) { + return candidate; + } + } catch { + return candidate; + } + } catch { + return candidate; + } + } + return path; + } + + isTrulyLocalPath( + path: string, + pathStyle: PathStyle = this.pathService.enginePathStyle() + ): boolean { + return this.pathService.isTrulyLocalPath(path, pathStyle); + } + + async createLocalDirectory(path: string, parentOnly = false): Promise { + if (!path) return; + let targetPath = path; + if (parentOnly) { + targetPath = this.pathService.getParentPath(path); + if (!targetPath) return; + } + const { root, relative } = this.pathService.splitLocalForStat(targetPath); + try { + await this.remoteFileOps.makeDirectory(root, relative); + } catch (err) { + console.error(`[PathInspectionService] Failed to create directory: ${targetPath}`, err); + } + } + + async createRequiredDirectories(settings: Record): Promise { + const pathStyle = this.pathService.enginePathStyle(); + + // 1. Handle Mount Configs + const mountConfigs = (settings['mountConfigs'] as Record) || {}; + for (const config of Object.values(mountConfigs) as { + rclone?: { mountPoint?: string }; + mountPoint?: string; + }[]) { + const mountPoint = config?.rclone?.mountPoint || config?.mountPoint; + if ( + mountPoint && + typeof mountPoint === 'string' && + this.isTrulyLocalPath(mountPoint, pathStyle) + ) { + await this.createLocalDirectory(mountPoint, pathStyle === 'windows'); + } + } + + // 2. Handle Bisync Configs + const bisyncConfigs = (settings['bisyncConfigs'] as Record) || {}; + for (const config of Object.values(bisyncConfigs) as { + rclone?: { path1?: string; path2?: string }; + path1?: string; + path2?: string; + }[]) { + const path1 = config?.rclone?.path1 || config?.path1; + const path2 = config?.rclone?.path2 || config?.path2; + + if (path1 && typeof path1 === 'string' && this.isTrulyLocalPath(path1, pathStyle)) { + await this.createLocalDirectory(path1, false); + } + if (path2 && typeof path2 === 'string' && this.isTrulyLocalPath(path2, pathStyle)) { + await this.createLocalDirectory(path2, false); + } + } + } +} diff --git a/src/app/services/infrastructure/platform/path-navigation.service.spec.ts b/src/app/services/infrastructure/platform/path-navigation.service.spec.ts index 09c1ae03e..327e3b35a 100644 --- a/src/app/services/infrastructure/platform/path-navigation.service.spec.ts +++ b/src/app/services/infrastructure/platform/path-navigation.service.spec.ts @@ -19,10 +19,7 @@ import { Location, LocationStrategy, PathLocationStrategy } from '@angular/commo import { PathNavigationService } from './path-navigation.service'; import { PathService } from './path.service'; import { BackendService } from '../system/backend.service'; -import { ApiClientService } from './api-client.service'; -import { AppSettingsService } from '../../settings/app-settings.service'; -import { RemoteFileOperationsService } from '../../remote/remote-file-operations.service'; -import { Injector, signal } from '@angular/core'; +import { signal } from '@angular/core'; function setupBackend(os: 'linux' | 'windows'): { backend: Record; @@ -42,10 +39,6 @@ function setupBackend(os: 'linux' | 'windows'): { }; } -function stubService(): Record { - return {}; -} - describe('PathNavigationService', () => { let service: PathNavigationService; let pathService: PathService; @@ -59,10 +52,6 @@ describe('PathNavigationService', () => { PathService, PathNavigationService, { provide: BackendService, useValue: mock.backend }, - { provide: ApiClientService, useValue: stubService() }, - { provide: AppSettingsService, useValue: stubService() }, - { provide: RemoteFileOperationsService, useValue: stubService() }, - { provide: Injector, useValue: { get: (): Record => stubService() } }, ], }); service = TestBed.inject(PathNavigationService); diff --git a/src/app/services/infrastructure/platform/path-navigation.service.ts b/src/app/services/infrastructure/platform/path-navigation.service.ts index a469108ef..64d9059f4 100644 --- a/src/app/services/infrastructure/platform/path-navigation.service.ts +++ b/src/app/services/infrastructure/platform/path-navigation.service.ts @@ -1,7 +1,7 @@ import { Injectable, inject, signal, computed, OnDestroy } from '@angular/core'; import { Location } from '@angular/common'; import { Subject } from 'rxjs'; -import { PathSegment, PathService, PathStyle } from './path.service'; +import { PathService, PathStyle } from './path.service'; export interface NautilusLocation { /** Remote name as it appears in `ExplorerRoot.name` (e.g. `C:`, `/`, `googledrive`). */ @@ -228,32 +228,11 @@ export class PathNavigationService implements OnDestroy { this._currentPath.set(url); } - normalizePath(p: string): string { - return this.pathService.normalizePath(p); - } - - joinPath(...segments: string[]): string { - return this.pathService.joinPath(...segments); - } - - getParentPath(path: string, pathStyle?: PathStyle): string { - return this.pathService.getParentPath(path, pathStyle); - } - - getPathSegments(path: string): PathSegment[] { - return this.pathService.getPathSegments(path); - } - - splitSegments(path: string): string[] { - return this.pathService.splitSegments(path); - } - toCanonicalSeparators(p: string): string { return p ? p.replace(/\\/g, '/') : p; } toNativeDisplay(canonicalPath: string, pathStyle: PathStyle = 'posix'): string { - if (!canonicalPath) return canonicalPath; - return pathStyle === 'windows' ? canonicalPath.replace(/\//g, '\\') : canonicalPath; + return this.pathService.normalizeForPlatform(canonicalPath, pathStyle); } } diff --git a/src/app/services/infrastructure/platform/path.service.spec.ts b/src/app/services/infrastructure/platform/path.service.spec.ts index 6d918964d..e79c0e5bd 100755 --- a/src/app/services/infrastructure/platform/path.service.spec.ts +++ b/src/app/services/infrastructure/platform/path.service.spec.ts @@ -2,10 +2,6 @@ import { TestBed } from '@angular/core/testing'; import { PathService } from './path.service'; import { FileBrowserItem, ExplorerRoot } from '@app/types'; import { BackendService } from '../system/backend.service'; -import { ApiClientService } from './api-client.service'; -import { AppSettingsService } from '../../settings/app-settings.service'; -import { RemoteFileOperationsService } from '../../remote/remote-file-operations.service'; -import { Injector } from '@angular/core'; import { signal } from '@angular/core'; /** @@ -38,16 +34,6 @@ function mockBackend(initialOs: 'linux' | 'windows' | 'darwin' = 'linux'): { }; } -/** - * PathService injects five services (BackendService, ApiClientService, - * AppSettingsService, RemoteFileOperationsService, Injector). Only - * BackendService.isWindows is exercised by the path-style tests; the others - * are stubbed so TestBed can construct the service. - */ -function stubService(): Record { - return {}; -} - describe('PathService', () => { let service: PathService; let setEngineOs: (os: string) => void; @@ -56,14 +42,7 @@ describe('PathService', () => { const mock = mockBackend('linux'); setEngineOs = mock.setOs; TestBed.configureTestingModule({ - providers: [ - PathService, - { provide: BackendService, useValue: mock.backend }, - { provide: ApiClientService, useValue: stubService() }, - { provide: AppSettingsService, useValue: stubService() }, - { provide: RemoteFileOperationsService, useValue: stubService() }, - { provide: Injector, useValue: { get: (): Record => stubService() } }, - ], + providers: [PathService, { provide: BackendService, useValue: mock.backend }], }); service = TestBed.inject(PathService); }); diff --git a/src/app/services/infrastructure/platform/path.service.ts b/src/app/services/infrastructure/platform/path.service.ts index e43865a6e..b888c8da3 100755 --- a/src/app/services/infrastructure/platform/path.service.ts +++ b/src/app/services/infrastructure/platform/path.service.ts @@ -1,10 +1,6 @@ -import { Injectable, inject, Injector, signal } from '@angular/core'; -import { ExplorerRoot, FileBrowserItem, LocalDrive } from '@app/types'; +import { Injectable, inject } from '@angular/core'; +import { ExplorerRoot, FileBrowserItem } from '@app/types'; import { BackendService } from '../system/backend.service'; -import { ApiClientService } from './api-client.service'; -import { AppSettingsService } from '../../settings/app-settings.service'; -import { RemoteFileOperationsService } from '../../remote/remote-file-operations.service'; -import { RemoteFacadeService } from '../../facade/remote-facade.service'; export interface PathSegment { name: string; @@ -21,36 +17,12 @@ export interface PathGroup { remote: string; } -export type DefaultPathOp = 'mount' | 'bisync'; - -export interface PathInspectionStatus { - state: 'clean' | 'nonEmpty' | 'colliding' | 'willCreate' | 'checking'; - details?: string; - icon: string; - badgeClass: string; - labelKey: string; -} - -const MOUNT_TEMPLATE_FALLBACK = '{home}/rclone-manager/{remote}'; -const BISYNC_TEMPLATE_FALLBACK = '{home}/rclone-manager/{remote}-bisync'; -const HOME_FALLBACK_POSIX = '/root/rclone-manager'; -const MAX_DEFAULT_PATH_ATTEMPTS = 10; +export type { DefaultPathOp, PathInspectionStatus } from './path-inspection.service'; @Injectable({ providedIn: 'root' }) export class PathService { private readonly remoteNames = new Set(); private readonly backendService = inject(BackendService); - private readonly apiClient = inject(ApiClientService); - private readonly appSettingsService = inject(AppSettingsService); - private readonly remoteFileOps = inject(RemoteFileOperationsService); - private readonly injector = inject(Injector); - - private get remoteFacade(): RemoteFacadeService { - return this.injector.get(RemoteFacadeService); - } - - private readonly statuses = signal>({}); - private readonly checkingKeys = new Set(); setRemoteNames(names: string[]): void { this.remoteNames.clear(); @@ -140,6 +112,11 @@ export class PathService { return remoteName.endsWith(':') ? remoteName : `${remoteName}:`; } + normalizeExplorerRoot(remote?: ExplorerRoot | null): string { + if (!remote) return ''; + return remote.isLocal ? remote.name : this.normalizeRemoteForRclone(remote.name); + } + normalizeRemoteName(remoteName?: string, pathStyle: PathStyle = this.enginePathStyle()): string { if (!remoteName) return ''; if (pathStyle === 'windows' && /^[a-zA-Z]:$/.test(remoteName)) { @@ -162,6 +139,18 @@ export class PathService { return !this.remoteNames.has(normalized); } + isTrulyLocalPath(path: string, pathStyle: PathStyle = this.enginePathStyle()): boolean { + if (!path) return false; + const colonIdx = path.indexOf(':'); + if (colonIdx > -1) { + if (pathStyle === 'windows' && /^[a-zA-Z]:/.test(path)) { + return this.isLocalPath(path); + } + return false; + } + return this.isLocalPath(path); + } + splitFsPath(fullPath: string | string[]): { remote: string; path: string } { const p = Array.isArray(fullPath) ? (fullPath[0] ?? '') : fullPath; if (this.isLocalPath(p)) return { remote: '', path: p }; @@ -429,233 +418,4 @@ export class PathService { return { type, path: entryPath, remote: normalizedRemote }; } - - /** - * Get the inspection status of a local path. - * If not cached, triggers background validation and returns a 'checking' status immediately. - */ - getPathStatus( - path: string | undefined | null, - opType: string, - remoteName: string - ): PathInspectionStatus | null { - if (!path || !path.trim()) { - return null; - } - const trimmedPath = path.trim(); - const cacheKey = `${remoteName}:${opType}:${trimmedPath}`; - - const cached = this.statuses()[cacheKey]; - if (cached) { - return cached; - } - - this.triggerInspection(cacheKey, trimmedPath, remoteName); - - return { - state: 'checking', - icon: 'spinner', - badgeClass: 'checking', - labelKey: 'remoteConfig.pathStatus.checking', - }; - } - - private async triggerInspection(key: string, path: string, remoteName: string): Promise { - if (this.checkingKeys.has(key)) return; - this.checkingKeys.add(key); - - try { - const status = await this.runInspection(path, remoteName); - this.statuses.update(m => ({ ...m, [key]: status })); - } catch { - const fallback: PathInspectionStatus = { - state: 'willCreate', - icon: 'folder-plus', - badgeClass: 'will-create', - labelKey: 'remoteConfig.pathStatus.willCreate', - }; - this.statuses.update(m => ({ ...m, [key]: fallback })); - } finally { - this.checkingKeys.delete(key); - } - } - - private async runInspection(path: string, remoteName: string): Promise { - // 1. Collision check (highest priority, synchronous) - const collisions = this.remoteFacade.checkMountPathCollision(path, remoteName); - if (collisions.length > 0) { - const c = collisions[0]; - return { - state: 'colliding', - details: `${c.remoteName} (${c.opType})`, - icon: 'warning', - badgeClass: 'colliding', - labelKey: 'remoteConfig.pathStatus.colliding', - }; - } - - // 2. Async check via Rclone API - const { root, relative } = this.splitLocalForStat(path); - try { - const statRes = await this.remoteFileOps.getStat(root, relative); - if (!statRes?.item) { - return { - state: 'willCreate', - icon: 'folder-plus', - badgeClass: 'will-create', - labelKey: 'remoteConfig.pathStatus.willCreate', - }; - } - - const sizeRes = await this.remoteFileOps.getSize(root, relative).catch(() => null); - if (sizeRes && sizeRes.count > 0) { - return { - state: 'nonEmpty', - icon: 'folder-open', - badgeClass: 'non-empty', - labelKey: 'remoteConfig.pathStatus.nonEmpty', - }; - } - - return { - state: 'clean', - icon: 'check-circle', - badgeClass: 'clean', - labelKey: 'remoteConfig.pathStatus.clean', - }; - } catch { - return { - state: 'willCreate', - icon: 'folder-plus', - badgeClass: 'will-create', - labelKey: 'remoteConfig.pathStatus.willCreate', - }; - } - } - - /** - * Resolve a unique default local path for mount or bisync based on config templates. - */ - async resolveDefaultPath(remoteName: string, opType: DefaultPathOp): Promise { - const [template, home] = await Promise.all([this.getPathTemplate(opType), this.resolveHome()]); - const remote = this.sanitizeRemoteName(remoteName); - const raw = this.substitute(template, home, remote); - const normalized = this.normalizeForPlatform(raw); - return this.ensureMountableDefault(normalized); - } - - private async getPathTemplate(opType: DefaultPathOp): Promise { - const settingKey = opType === 'bisync' ? 'default_bisync_directory' : 'default_mount_directory'; - const fallback = opType === 'bisync' ? BISYNC_TEMPLATE_FALLBACK : MOUNT_TEMPLATE_FALLBACK; - const stored = await this.appSettingsService.getSettingValue(settingKey); - return stored && stored.trim() ? stored : fallback; - } - - private async resolveHome(): Promise { - try { - const drives = await this.apiClient.invoke('get_local_drives'); - if (drives && drives.length > 0) { - const first = drives[0]; - const candidate = first.name || first.mount_point || ''; - if (candidate) return candidate; - } - } catch (err) { - console.warn('[PathService] Could not query Rclone local drives:', err); - } - return HOME_FALLBACK_POSIX; - } - - private sanitizeRemoteName(remoteName: string): string { - return (remoteName || 'cloud-remote').replace(/[:/\\]/g, '-'); - } - - private substitute(template: string, home: string, remote: string): string { - return template.replace('{home}', home).replace('{remote}', remote); - } - - private async ensureMountableDefault(path: string): Promise { - const { root, relative } = this.splitLocalForStat(path); - - for (let attempt = 0; attempt < MAX_DEFAULT_PATH_ATTEMPTS; attempt++) { - const suffix = attempt === 0 ? '' : `-${attempt + 1}`; - const candidate = `${path}${suffix}`; - const candidateRel = `${relative}${suffix}`; - - try { - const stat = await this.remoteFileOps.getStat(root, candidateRel); - if (!stat?.item) { - return candidate; - } - try { - const size = await this.remoteFileOps.getSize(root, candidateRel); - if (!size || size.count === 0) { - return candidate; - } - } catch { - return candidate; - } - } catch { - return candidate; - } - } - return path; - } - - isTrulyLocalPath(path: string, pathStyle: PathStyle = this.enginePathStyle()): boolean { - if (!path) return false; - const colonIdx = path.indexOf(':'); - if (colonIdx > -1) { - if (pathStyle === 'windows' && /^[a-zA-Z]:/.test(path)) { - return this.isLocalPath(path); - } - return false; - } - return this.isLocalPath(path); - } - - async createLocalDirectory(path: string, parentOnly = false): Promise { - if (!path) return; - let targetPath = path; - if (parentOnly) { - targetPath = this.getParentPath(path); - if (!targetPath) return; - } - const { root, relative } = this.splitLocalForStat(targetPath); - try { - await this.remoteFileOps.makeDirectory(root, relative); - } catch (err) { - console.error(`[PathService] Failed to create directory: ${targetPath}`, err); - } - } - - async createRequiredDirectories(settings: Record): Promise { - const pathStyle = this.enginePathStyle(); - - // 1. Handle Mount Configs - const mountConfigs = settings['mountConfigs'] || {}; - for (const config of Object.values(mountConfigs) as any[]) { - const mountPoint = config?.rclone?.mountPoint || config?.mountPoint; - if ( - mountPoint && - typeof mountPoint === 'string' && - this.isTrulyLocalPath(mountPoint, pathStyle) - ) { - await this.createLocalDirectory(mountPoint, pathStyle === 'windows'); - } - } - - // 2. Handle Bisync Configs - const bisyncConfigs = settings['bisyncConfigs'] || {}; - for (const config of Object.values(bisyncConfigs) as any[]) { - const path1 = config?.rclone?.path1 || config?.path1; - const path2 = config?.rclone?.path2 || config?.path2; - - if (path1 && typeof path1 === 'string' && this.isTrulyLocalPath(path1, pathStyle)) { - await this.createLocalDirectory(path1, false); - } - if (path2 && typeof path2 === 'string' && this.isTrulyLocalPath(path2, pathStyle)) { - await this.createLocalDirectory(path2, false); - } - } - } } diff --git a/src/app/services/infrastructure/platform/tauri-base.service.ts b/src/app/services/infrastructure/platform/tauri-base.service.ts old mode 100644 new mode 100755 index d143953a5..ae5155f30 --- a/src/app/services/infrastructure/platform/tauri-base.service.ts +++ b/src/app/services/infrastructure/platform/tauri-base.service.ts @@ -1,7 +1,7 @@ import { inject, Injectable } from '@angular/core'; import { listen } from '@tauri-apps/api/event'; import { getCurrentWindow, Window } from '@tauri-apps/api/window'; -import { Observable } from 'rxjs'; +import { Observable, Subject, share } from 'rxjs'; import { ApiClientService, isHeadlessMode } from './api-client.service'; import { SseClientService } from './sse-client.service'; import { NotificationService } from '../../ui/notification.service'; @@ -19,6 +19,8 @@ export class TauriBaseService { private readonly sseClient = inject(SseClientService); protected readonly isTauri = !isHeadlessMode(); + private readonly tauriEventStreams = new Map>(); + protected getCurrentTauriWindow(): Window | undefined { return this.isTauri ? getCurrentWindow() : undefined; } @@ -32,10 +34,14 @@ export class TauriBaseService { return this.sseClient.listen(eventName); } - return new Observable(observer => { - const unlisten = listen(eventName, event => observer.next(event.payload)); - return () => void unlisten.then(f => f()); - }); + let stream = this.tauriEventStreams.get(eventName); + if (!stream) { + const subject = new Subject(); + void listen(eventName, event => subject.next(event.payload)); + stream = subject.asObservable().pipe(share()) as Observable; + this.tauriEventStreams.set(eventName, stream); + } + return stream as Observable; } protected async invokeWithNotification( diff --git a/src/app/services/infrastructure/system/app-lifecycle.service.ts b/src/app/services/infrastructure/system/app-lifecycle.service.ts new file mode 100644 index 000000000..6ad122d4e --- /dev/null +++ b/src/app/services/infrastructure/system/app-lifecycle.service.ts @@ -0,0 +1,65 @@ +import { DestroyRef, inject, Injectable } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { TranslateService } from '@ngx-translate/core'; +import { ApiClientService } from '../platform/api-client.service'; +import { EventListenersService } from './event-listeners.service'; +import { NotificationService } from '../../ui/notification.service'; +import { NautilusService } from '../../ui/nautilus.service'; +import { FlowOverlayService } from '../../ui/flow-overlay.service'; +import { MainUiOverlayService } from '../../ui/main-ui-overlay.service'; + +@Injectable({ providedIn: 'root' }) +export class AppLifecycleService { + private readonly eventListenersService = inject(EventListenersService); + private readonly notificationService = inject(NotificationService); + private readonly translate = inject(TranslateService); + private readonly apiClient = inject(ApiClientService); + private readonly nautilusService = inject(NautilusService); + private readonly flowOverlayService = inject(FlowOverlayService); + private readonly mainUiOverlayService = inject(MainUiOverlayService); + private readonly destroyRef = inject(DestroyRef); + + private exitListenerInitialized = false; + + public initialize(): void { + if (this.exitListenerInitialized) { + return; + } + + this.exitListenerInitialized = true; + + // Do not handle app exit in secondary standalone windows + if (this.isStandaloneWindow()) { + return; + } + + this.eventListenersService + .listenToAppExitRequested() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(async summary => { + const confirmed = await this.notificationService.confirmModal( + 'app.shutdown.confirmTitle', + this.translate.instant('app.shutdown.confirmMessage', { + jobs: summary.activeJobsCount, + mounts: summary.activeMountsCount, + serves: summary.activeServesCount, + }), + 'app.shutdown.stopAndQuit', + 'common.cancel', + { icon: 'warning', color: 'warn' } + ); + + if (confirmed) { + await this.apiClient.invoke('shutdown_app'); + } + }); + } + + private isStandaloneWindow(): boolean { + return ( + this.nautilusService.isStandaloneWindow() || + this.flowOverlayService.isStandaloneWindow() || + this.mainUiOverlayService.isStandaloneWindow() + ); + } +} diff --git a/src/app/services/infrastructure/system/backend.service.ts b/src/app/services/infrastructure/system/backend.service.ts index 56e85782b..f65ac55e4 100755 --- a/src/app/services/infrastructure/system/backend.service.ts +++ b/src/app/services/infrastructure/system/backend.service.ts @@ -59,13 +59,12 @@ export class BackendService extends TauriBaseService { // Legacy API support for components expecting a Promise async loadBackends(): Promise { - this.backendData.reload(); + await this.backendData.reload(); } async runStartupChecks(): Promise { await this.backendData.reload(); - await this.checkStartupConnectivity(); - await this.checkAllBackends(); + await Promise.all([this.checkStartupConnectivity(), this.checkAllBackends()]); } async getActiveBackend(): Promise { @@ -110,8 +109,6 @@ export class BackendService extends TauriBaseService { password: config.password, configPassword: config.configPassword, configPath: config.configPath, - oauthPort: config.oauthPort, - oauthHost: config.oauthHost, copyBackendFrom: copyBackendFrom ?? null, copyRemotesFrom: copyRemotesFrom ?? null, }, @@ -129,8 +126,6 @@ export class BackendService extends TauriBaseService { password: config.password, configPassword: config.configPassword, configPath: config.configPath, - oauthPort: config.oauthPort, - oauthHost: config.oauthHost, }, }); this.backendData.reload(); @@ -143,6 +138,24 @@ export class BackendService extends TauriBaseService { await this.appSettingsService.removeBackendLayout(name); } + async updateLocalBackendConfigPath(configPath: string | undefined): Promise { + let localBackend = this.backends().find(b => b.name === 'Local'); + if (!localBackend) { + await this.loadBackends(); + localBackend = this.backends().find(b => b.name === 'Local'); + } + if (!localBackend) return; + await this.updateBackend({ + name: 'Local', + host: localBackend.host, + port: localBackend.port, + isLocal: true, + username: localBackend.username, + password: localBackend.password, + configPath: configPath || undefined, + }); + } + async testConnection(name: string): Promise { try { const result = await this.invokeCommand('test_backend_connection', { @@ -169,7 +182,7 @@ export class BackendService extends TauriBaseService { } catch (error) { return { success: false, - message: error instanceof Error ? error.message : 'Unknown error', + message: this.backendTranslation.translateBackendMessage(error), }; } } @@ -190,7 +203,7 @@ export class BackendService extends TauriBaseService { } catch (error) { return { success: false, - message: error instanceof Error ? error.message : 'Unknown error', + message: this.backendTranslation.translateBackendMessage(error), }; } } @@ -252,8 +265,6 @@ export class BackendService extends TauriBaseService { password: formValue.has_auth ? (formValue.password ?? '') : '', configPassword: formValue.config_password || undefined, configPath: formValue.config_path || undefined, - oauthPort: formValue.oauth_port ? Number(formValue.oauth_port) : undefined, - oauthHost: formValue.oauth_host || undefined, }; } @@ -267,8 +278,6 @@ export class BackendService extends TauriBaseService { password: formValue.has_auth ? formValue.password || undefined : '', configPassword: formValue.config_password || undefined, configPath: formValue.config_path || undefined, - oauthPort: formValue.oauth_port ? Number(formValue.oauth_port) : undefined, - oauthHost: formValue.oauth_host || undefined, }; } } diff --git a/src/app/services/infrastructure/system/debug.service.ts b/src/app/services/infrastructure/system/debug.service.ts index dabb90deb..1884fa1f4 100644 --- a/src/app/services/infrastructure/system/debug.service.ts +++ b/src/app/services/infrastructure/system/debug.service.ts @@ -1,11 +1,9 @@ import { Injectable, inject, DestroyRef, DOCUMENT, isDevMode } from '@angular/core'; import { Clipboard } from '@angular/cdk/clipboard'; +import { EditorView } from 'codemirror'; import { FileSystemService } from '../../operations/file-system.service'; import { TauriBaseService } from '../platform/tauri-base.service'; -import { - isHeadlessMode, - isMobile, -} from 'src/app/services/infrastructure/platform/api-client.service'; +import { isMobile } from 'src/app/services/infrastructure/platform/api-client.service'; export interface DebugInfo { logsDir: string; @@ -65,6 +63,7 @@ export class DebugService extends TauriBaseService { } async openDevTools(): Promise { + if (!this.isTauri) return; try { await this.invokeCommand('open_devtools'); } catch (err) { @@ -87,8 +86,6 @@ export class DebugService extends TauriBaseService { if (e.defaultPrevented) return; const target = e.target as HTMLElement | null; if (!target) return; - // Dismiss any open CDK overlays before showing ours - target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); e.preventDefault(); this.createContextMenu(e.clientX, e.clientY, this.buildMenuItems(target)); } @@ -96,24 +93,37 @@ export class DebugService extends TauriBaseService { private buildMenuItems(target: HTMLElement): MenuEntry[] { const inputEl = target.closest('input, textarea') as HTMLInputElement | HTMLTextAreaElement | null; - const editableEl = !inputEl - ? (target.closest('[contenteditable="true"]') as HTMLElement | null) - : null; + const cmEditor = target.closest('.cm-editor') as HTMLElement | null; + const cmView = cmEditor ? EditorView.findFromDOM(cmEditor) : null; + const editableEl = + !inputEl && !cmView + ? (target.closest('[contenteditable="true"]') as HTMLElement | null) + : null; const isPassword = inputEl?.type === 'password'; - const isReadOnly = !!( - inputEl?.readOnly || - inputEl?.disabled || - editableEl?.getAttribute('contenteditable') === 'false' - ); + const isReadOnly = cmView + ? cmView.state.readOnly + : !!( + inputEl?.readOnly || + inputEl?.disabled || + editableEl?.getAttribute('contenteditable') === 'false' + ); const isDisabled = !!inputEl?.disabled; - const selectedText = - inputEl && !isPassword - ? inputEl.value.substring(inputEl.selectionStart ?? 0, inputEl.selectionEnd ?? 0) - : (window.getSelection()?.toString() ?? ''); + let selectedText = ''; + if (inputEl && !isPassword) { + selectedText = inputEl.value.substring( + inputEl.selectionStart ?? 0, + inputEl.selectionEnd ?? 0 + ); + } else if (cmView) { + const mainRange = cmView.state.selection.main; + selectedText = cmView.state.sliceDoc(mainRange.from, mainRange.to); + } else { + selectedText = window.getSelection()?.toString() ?? ''; + } - const isInput = !!(inputEl || editableEl); + const isInput = !!(inputEl || cmView || editableEl); const items: MenuEntry[] = []; if (isInput) { @@ -122,7 +132,7 @@ export class DebugService extends TauriBaseService { items.push({ label: this.t('nautilus.contextMenu.cut'), shortcut: 'Ctrl+X', - action: () => this.cut(selectedText, inputEl, editableEl), + action: () => this.cut(selectedText, inputEl, editableEl, cmView), }); } if (!isPassword) { @@ -137,14 +147,14 @@ export class DebugService extends TauriBaseService { items.push({ label: this.t('nautilus.contextMenu.paste'), shortcut: 'Ctrl+V', - action: () => this.paste(inputEl, editableEl), + action: () => this.paste(inputEl, editableEl, cmView), }); } if (!isDisabled) { items.push({ label: this.t('nautilus.contextMenu.selectAll'), shortcut: 'Ctrl+A', - action: () => this.selectAll(inputEl, editableEl), + action: () => this.selectAll(inputEl, editableEl, cmView), }); } } else if (selectedText && !isPassword) { @@ -170,7 +180,7 @@ export class DebugService extends TauriBaseService { ); } - if (isDevMode() && !selectedText) { + if (isDevMode() && this.isTauri && !selectedText) { items.push({ label: this.t('developerTools.openDevTools'), action: () => void this.openDevTools(), @@ -183,7 +193,8 @@ export class DebugService extends TauriBaseService { private cut( text: string, inputEl: HTMLInputElement | HTMLTextAreaElement | null, - editableEl: HTMLElement | null + editableEl: HTMLElement | null, + cmView?: EditorView | null ): void { this.clipboard.copy(text); if (inputEl) { @@ -192,18 +203,23 @@ export class DebugService extends TauriBaseService { inputEl.value = inputEl.value.substring(0, start) + inputEl.value.substring(end); inputEl.selectionStart = inputEl.selectionEnd = start; inputEl.dispatchEvent(new Event('input', { bubbles: true })); + } else if (cmView) { + cmView.dispatch(cmView.state.replaceSelection('')); + cmView.focus(); } else if (editableEl) { const sel = window.getSelection(); if (sel && sel.rangeCount > 0) { const range = sel.getRangeAt(0); range.deleteContents(); + editableEl.dispatchEvent(new Event('input', { bubbles: true })); } } } private async paste( inputEl: HTMLInputElement | HTMLTextAreaElement | null, - editableEl: HTMLElement | null + editableEl: HTMLElement | null, + cmView?: EditorView | null ): Promise { let text = ''; try { @@ -219,6 +235,9 @@ export class DebugService extends TauriBaseService { inputEl.value = inputEl.value.substring(0, start) + text + inputEl.value.substring(end); inputEl.selectionStart = inputEl.selectionEnd = start + text.length; inputEl.dispatchEvent(new Event('input', { bubbles: true })); + } else if (cmView) { + cmView.dispatch(cmView.state.replaceSelection(text)); + cmView.focus(); } else if (editableEl) { editableEl.focus(); const sel = window.getSelection(); @@ -230,18 +249,27 @@ export class DebugService extends TauriBaseService { range.collapse(false); sel.removeAllRanges(); sel.addRange(range); + editableEl.dispatchEvent(new Event('input', { bubbles: true })); } } } private selectAll( inputEl: HTMLInputElement | HTMLTextAreaElement | null, - editableEl: HTMLElement | null + editableEl: HTMLElement | null, + cmView?: EditorView | null ): void { if (inputEl) { inputEl.select(); return; } + if (cmView) { + cmView.dispatch({ + selection: { anchor: 0, head: cmView.state.doc.length }, + }); + cmView.focus(); + return; + } if (editableEl) { const range = this.doc.createRange(); range.selectNodeContents(editableEl); @@ -252,7 +280,7 @@ export class DebugService extends TauriBaseService { } private async readClipboard(): Promise { - if (isHeadlessMode()) return navigator.clipboard.readText(); + if (!this.isTauri) return navigator.clipboard.readText(); const { readText } = await import('@tauri-apps/plugin-clipboard-manager'); return readText(); } diff --git a/src/app/services/infrastructure/system/event-listeners.service.ts b/src/app/services/infrastructure/system/event-listeners.service.ts index 26f6e905a..7be2a372d 100755 --- a/src/app/services/infrastructure/system/event-listeners.service.ts +++ b/src/app/services/infrastructure/system/event-listeners.service.ts @@ -7,6 +7,7 @@ import { JOB_CACHE_CHANGED, MOUNT_PLUGIN_INSTALLED, APP_EVENT, + APP_EXIT_REQUESTED, NETWORK_STATUS_CHANGED, BANDWIDTH_LIMIT_CHANGED, SERVE_STATE_CHANGED, @@ -27,6 +28,9 @@ import { UpdateInfo, DownloadStatus, EngineErrorType, + ActiveOperationsSummary, + PROVISION_PROGRESS, + ProvisionProgressPayload, } from '@app/types'; import { TauriBaseService } from '../platform/tauri-base.service'; @@ -215,4 +219,12 @@ export class EventListenersService extends TauriBaseService { listenToBackendSwitched(): Observable { return this.listenToEvent(BACKEND_SWITCHED); } + + listenToAppExitRequested(): Observable { + return this.listenToEvent(APP_EXIT_REQUESTED); + } + + listenToProvisionProgress(): Observable { + return this.listenToEvent(PROVISION_PROGRESS); + } } diff --git a/src/app/services/infrastructure/system/system-info.service.ts b/src/app/services/infrastructure/system/system-info.service.ts index 3c7465fe8..547868e63 100644 --- a/src/app/services/infrastructure/system/system-info.service.ts +++ b/src/app/services/infrastructure/system/system-info.service.ts @@ -10,7 +10,7 @@ import { TauriBaseService } from '../platform/tauri-base.service'; providedIn: 'root', }) export class SystemInfoService extends TauriBaseService { - readonly minRcloneVersion = signal('1.70.0'); + readonly minRcloneVersion = signal('1.75.0'); /** * Check if running in librclone mode diff --git a/src/app/services/operations/automation.service.ts b/src/app/services/operations/automation.service.ts index 6ebf8f89b..c200bc767 100644 --- a/src/app/services/operations/automation.service.ts +++ b/src/app/services/operations/automation.service.ts @@ -23,6 +23,7 @@ export class AutomationService extends TauriBaseService { async getAutomations(): Promise { const automations = await this.invokeCommand('get_automations'); + console.log('[AutomationService] getAutomations:', automations); this._automations.set(automations); return automations; } diff --git a/src/app/services/operations/file-system.service.ts b/src/app/services/operations/file-system.service.ts index 658604fc4..90935b64b 100644 --- a/src/app/services/operations/file-system.service.ts +++ b/src/app/services/operations/file-system.service.ts @@ -4,7 +4,7 @@ import { NautilusService } from '../ui/nautilus.service'; import { FilePickerConfig, FilePickerResult } from '@app/types'; import { PathService } from '../infrastructure/platform/path.service'; import { filter, firstValueFrom } from 'rxjs'; -import { isHeadlessMode, isMobile } from '../infrastructure/platform/api-client.service'; +import { isMobile } from '../infrastructure/platform/api-client.service'; import { BackendService } from '../infrastructure/system/backend.service'; /** @@ -24,7 +24,7 @@ export class FileSystemService extends TauriBaseService { * This is true in headless mode or when a remote rclone backend is active. */ private isInternalBrowserPreferred(): boolean { - return isHeadlessMode() || this.backendService.activeBackend() !== 'Local' || isMobile(); + return !this.isTauri || this.backendService.activeBackend() !== 'Local' || isMobile(); } /** diff --git a/src/app/services/operations/job-management.service.ts b/src/app/services/operations/job-management.service.ts index c92906a8f..a5f403139 100644 --- a/src/app/services/operations/job-management.service.ts +++ b/src/app/services/operations/job-management.service.ts @@ -87,6 +87,7 @@ export class JobManagementService extends TauriBaseService { async refreshJobs(): Promise { const jobs = await this.invokeCommand('get_jobs'); this._jobs.set(jobs); + console.log('[JobManagementService] refreshJobs:', jobs); return jobs; } diff --git a/src/app/services/operations/mount-management.service.ts b/src/app/services/operations/mount-management.service.ts index 8463afa26..7cc035662 100644 --- a/src/app/services/operations/mount-management.service.ts +++ b/src/app/services/operations/mount-management.service.ts @@ -111,17 +111,11 @@ export class MountManagementService extends TauriBaseService { oldName: string, newName: string ): Promise { - const updated = await this.invokeCommand('rename_mount_profile_in_cache', { + return this.invokeCommand('rename_mount_profile_in_cache', { remoteName, oldName, newName, }); - - if (updated > 0) { - await this.getMountedRemotes(); - } - - return updated; } getMountsForRemoteProfile(remoteName: string, profile?: string): MountedRemote[] { diff --git a/src/app/services/operations/repair.service.ts b/src/app/services/operations/repair.service.ts index 384c9dcba..d8343e347 100755 --- a/src/app/services/operations/repair.service.ts +++ b/src/app/services/operations/repair.service.ts @@ -1,5 +1,6 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { TauriBaseService } from '../infrastructure/platform/tauri-base.service'; +import { InstallationService } from '../settings/installation.service'; import { RepairData } from '@app/types'; /** Detail item structure for repair UI */ @@ -18,6 +19,10 @@ interface RepairDetailItem { providedIn: 'root', }) export class RepairService extends TauriBaseService { + private readonly installationService = inject(InstallationService); + + readonly rcloneProgress = this.installationService.rcloneProgress; + readonly mountPluginProgress = this.installationService.mountPluginProgress; private readonly repairUi = { rclone_binary: { titleKey: 'repairSheet.titles.missingRclone', @@ -168,14 +173,35 @@ export class RepairService extends TauriBaseService { * @param path Optional custom installation path. If null, uses default location */ async repairRclonePath(path?: string | null): Promise { - return this.invokeCommand('provision_rclone', { path }); + return this.installationService.installRclone(path); + } + + /** + * Cancel in-progress rclone provisioning + */ + async cancelRcloneRepair(): Promise { + return this.installationService.cancelRcloneInstall(); } /** * Install the mount plugin */ async repairMountPlugin(): Promise { - return this.invokeCommand('install_mount_plugin'); + return this.installationService.installMountPlugin(); + } + + /** + * Check if an error represents a user-initiated download cancellation + */ + isCancellationError(error: unknown): boolean { + return this.installationService.isCancellationError(error); + } + + /** + * Cancel in-progress mount plugin installation + */ + async cancelMountPluginRepair(): Promise { + return this.installationService.cancelMountPluginInstall(); } /** diff --git a/src/app/services/operations/serve-management.service.ts b/src/app/services/operations/serve-management.service.ts index bbf3ef059..6843ea9a7 100644 --- a/src/app/services/operations/serve-management.service.ts +++ b/src/app/services/operations/serve-management.service.ts @@ -118,7 +118,6 @@ export class ServeManagementService extends TauriBaseService { */ public async forceCheckServes(): Promise { await this.invokeCommand('force_check_serves'); - await this.refreshServes(); } /** @@ -181,17 +180,11 @@ export class ServeManagementService extends TauriBaseService { oldName: string, newName: string ): Promise { - const updated = await this.invokeCommand('rename_serve_profile_in_cache', { + return this.invokeCommand('rename_serve_profile_in_cache', { remoteName, oldName, newName, }); - - if (updated > 0) { - await this.refreshServesFromCache(); - } - - return updated; } /** diff --git a/src/app/services/remote/cli-flag-mapper.service.spec.ts b/src/app/services/remote/cli-flag-mapper.service.spec.ts index 82678458a..fdc78ab9e 100644 --- a/src/app/services/remote/cli-flag-mapper.service.spec.ts +++ b/src/app/services/remote/cli-flag-mapper.service.spec.ts @@ -1,14 +1,52 @@ import { TestBed } from '@angular/core/testing'; +import { provideTranslateService } from '@ngx-translate/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; import { CliFlagMapperService, ParsedCLI } from './cli-flag-mapper.service'; -import { PathService } from '../infrastructure/platform/path.service'; -import { RcConfigOption } from '@app/types'; +import { FlagConfigService } from './flag-config.service'; +import { RemoteManagementService } from './remote-management.service'; +import { RcloneValueMapperService } from './rclone-value-mapper.service'; +import { RcConfigOption, SharedProfileType } from '@app/types'; + +// The spec exercises the pure-logic methods (tokenize / parse / classify / +// buildLookupTable). None of them touch the network, so we stub out the two +// dependencies that would otherwise pull in HttpClient + TranslateService + +// MatSnackBar + MatDialog. +function stubFlagConfig(): Partial { + return { + loadAllFlagFields: () => Promise.resolve({} as Record), + }; +} + +function stubRemoteManagement(): Partial { + return { + getRemoteConfigFields: () => Promise.resolve([]), + }; +} + +// `buildLookupTable` expects a Record over all SharedProfileType keys, but the +// tests only need to supply the ones they exercise. Treat partial input as +// full records — the missing keys just yield no entries. +function fields( + input: Partial> +): Record { + return input as Record; +} describe('CliFlagMapperService', () => { let service: CliFlagMapperService; beforeEach(() => { TestBed.configureTestingModule({ - providers: [CliFlagMapperService, PathService], + providers: [ + CliFlagMapperService, + RcloneValueMapperService, + provideTranslateService(), + provideHttpClient(), + provideHttpClientTesting(), + { provide: FlagConfigService, useValue: stubFlagConfig() }, + { provide: RemoteManagementService, useValue: stubRemoteManagement() }, + ], }); service = TestBed.inject(CliFlagMapperService); }); @@ -50,15 +88,15 @@ describe('CliFlagMapperService', () => { describe('hasMacro', () => { it('should detect $(...) macro patterns', () => { - expect(service.hasMacro('dest:/archive/pCloud_$(date +%Y-%m-%d)')).toBeTrue(); + expect(service.hasMacro('dest:/archive/pCloud_$(date +%Y-%m-%d)')).toBe(true); }); it('should detect `...` macro patterns', () => { - expect(service.hasMacro('dest:/archive/pCloud_`date`')).toBeTrue(); + expect(service.hasMacro('dest:/archive/pCloud_`date`')).toBe(true); }); it('should return false for strings without macros', () => { - expect(service.hasMacro('dest:/archive/pCloud_normal')).toBeFalse(); + expect(service.hasMacro('dest:/archive/pCloud_normal')).toBe(false); }); }); @@ -169,27 +207,27 @@ describe('CliFlagMapperService', () => { describe('classify', () => { // Note: rclone RC API uses underscores in Name (e.g. "max_delete"), CLI uses hyphens (--max-delete) - const mockFields: Record = { - sync: [ - { - Name: 'max_delete', - FieldName: 'MaxDelete', - Type: 'int', - DefaultStr: '-1', - Help: '', - }, - { - Name: 'track_renames', - FieldName: 'TrackRenames', - Type: 'bool', - DefaultStr: 'false', - Help: '', - }, - ], - }; - it('should match --max-delete (hyphen) against max_delete (underscore) from RC API', () => { - const lookupTable = service.buildLookupTable(mockFields as any); + const lookupTable = service.buildLookupTable( + fields({ + sync: [ + { + Name: 'max_delete', + FieldName: 'MaxDelete', + Type: 'int', + DefaultStr: '-1', + Help: '', + }, + { + Name: 'track_renames', + FieldName: 'TrackRenames', + Type: 'bool', + DefaultStr: 'false', + Help: '', + }, + ], + }) + ); const parsed: ParsedCLI = { verb: 'sync', sourcePath: 'src:', @@ -208,22 +246,30 @@ describe('CliFlagMapperService', () => { expect(result.destPath).toBe('dst:'); expect(result.classified[0].status).toBe('mapped'); - expect(result.classified[0].fieldName).toBe('MaxDelete'); + expect(result.classified[0].fieldName).toBe('max_delete'); expect(result.classified[0].coercedValue).toBe(50); expect(result.classified[1].status).toBe('mapped'); - expect(result.classified[1].fieldName).toBe('TrackRenames'); + expect(result.classified[1].fieldName).toBe('track_renames'); expect(result.classified[2].status).toBe('unknown'); }); it('should coerce uint and float types', () => { - const lookupTable = service.buildLookupTable({ - sync: [ - { Name: 'tpslimit', FieldName: 'TpsLimit', Type: 'float64' } as any, - { Name: 'tpslimit-burst', FieldName: 'TpsLimitBurst', Type: 'uint32' } as any, - ], - } as any); + const lookupTable = service.buildLookupTable( + fields({ + sync: [ + { Name: 'tpslimit', FieldName: 'TpsLimit', Type: 'float64', Help: '', DefaultStr: '' }, + { + Name: 'tpslimit-burst', + FieldName: 'TpsLimitBurst', + Type: 'uint32', + Help: '', + DefaultStr: '', + }, + ], + }) + ); const parsed: ParsedCLI = { verb: 'sync', @@ -241,26 +287,27 @@ describe('CliFlagMapperService', () => { }); it('should match runtimeRemote specific prefixed options if remoteType is provided', () => { - const mockFieldsWithRuntime: Record = { - runtimeRemote: [ - { - Name: 'provider', - FieldName: 'Provider', - Type: 'string', - DefaultStr: '', - Help: '', - }, - { - Name: 'chunk_size', - FieldName: 'ChunkSize', - Type: 'string', - DefaultStr: '', - Help: '', - }, - ], - }; - - const lookupTable = service.buildLookupTable(mockFieldsWithRuntime as any, 's3'); + const lookupTable = service.buildLookupTable( + fields({ + runtimeRemote: [ + { + Name: 'provider', + FieldName: 'Provider', + Type: 'string', + DefaultStr: '', + Help: '', + }, + { + Name: 'chunk_size', + FieldName: 'ChunkSize', + Type: 'string', + DefaultStr: '', + Help: '', + }, + ], + }), + 's3' + ); const parsed: ParsedCLI = { verb: 'serve', flags: [ @@ -271,11 +318,11 @@ describe('CliFlagMapperService', () => { const result = service.classify(parsed, lookupTable); expect(result.classified[0].status).toBe('mapped'); - expect(result.classified[0].fieldName).toBe('Provider'); + expect(result.classified[0].fieldName).toBe('provider'); expect(result.classified[0].coercedValue).toBe('AWS'); expect(result.classified[1].status).toBe('mapped'); - expect(result.classified[1].fieldName).toBe('ChunkSize'); + expect(result.classified[1].fieldName).toBe('chunk_size'); expect(result.classified[1].coercedValue).toBe('64M'); }); @@ -332,5 +379,200 @@ describe('CliFlagMapperService', () => { hasMacro: false, }); }); + + it('should parse check, delete, copyurl and other rclone verbs correctly', () => { + const parsedCheck = service.parse('rclone check remote:path /local/path', new Set()); + expect(parsedCheck.verb).toBe('check'); + expect(parsedCheck.sourcePath).toBe('remote:path'); + expect(parsedCheck.destPath).toBe('/local/path'); + + const parsedDelete = service.parse('rclone delete remote:path/folder', new Set()); + expect(parsedDelete.verb).toBe('delete'); + expect(parsedDelete.sourcePath).toBe('remote:path/folder'); + + const parsedCopyurl = service.parse( + 'rclone copyurl https://example.com/file.zip remote:path', + new Set() + ); + expect(parsedCopyurl.verb).toBe('copyurl'); + expect(parsedCopyurl.sourcePath).toBe('https://example.com/file.zip'); + expect(parsedCopyurl.destPath).toBe('remote:path'); + + const parsedPurge = service.parse('rclone purge remote:path/trash', new Set()); + expect(parsedPurge.verb).toBe('delete'); + expect(parsedPurge.sourcePath).toBe('remote:path/trash'); + }); + + it('should strip wrapper commands and binary paths like sudo, /usr/bin/rclone, wsl', () => { + const parsedSudo = service.parse('sudo /usr/bin/rclone sync src: dst:', new Set()); + expect(parsedSudo.verb).toBe('sync'); + expect(parsedSudo.sourcePath).toBe('src:'); + expect(parsedSudo.destPath).toBe('dst:'); + + const parsedWsl = service.parse('wsl rclone copy src: dst:', new Set()); + expect(parsedWsl.verb).toBe('copy'); + expect(parsedWsl.sourcePath).toBe('src:'); + expect(parsedWsl.destPath).toBe('dst:'); + }); + + it('should map short flag aliases such as -P, -v, -n, -u, -L, -c, -I', () => { + const lookupTable = service.buildLookupTable( + fields({ + sync: [ + { Name: 'progress', FieldName: 'Progress', Type: 'bool', Help: '', DefaultStr: '' }, + { Name: 'verbose', FieldName: 'Verbose', Type: 'int', Help: '', DefaultStr: '' }, + { Name: 'dry_run', FieldName: 'DryRun', Type: 'bool', Help: '', DefaultStr: '' }, + { Name: 'update', FieldName: 'Update', Type: 'bool', Help: '', DefaultStr: '' }, + { Name: 'copy_links', FieldName: 'CopyLinks', Type: 'bool', Help: '', DefaultStr: '' }, + { Name: 'checksum', FieldName: 'Checksum', Type: 'bool', Help: '', DefaultStr: '' }, + { + Name: 'ignore_times', + FieldName: 'IgnoreTimes', + Type: 'bool', + Help: '', + DefaultStr: '', + }, + ], + }) + ); + + const parsed = service.parse('rclone sync src: dst: -P -v -n -u -L -c -I', new Set()); + const result = service.classify(parsed, lookupTable); + + expect(result.classified.length).toBe(7); + expect(result.classified.every(f => f.status === 'mapped')).toBe(true); + expect(result.classified[0].fieldName).toBe('progress'); + expect(result.classified[1].fieldName).toBe('verbose'); + expect(result.classified[2].fieldName).toBe('dry_run'); + expect(result.classified[3].fieldName).toBe('update'); + expect(result.classified[4].fieldName).toBe('copy_links'); + expect(result.classified[5].fieldName).toBe('checksum'); + expect(result.classified[6].fieldName).toBe('ignore_times'); + }); + + it('should support negated flags (--no-traverse -> traverse = false)', () => { + const lookupTable = service.buildLookupTable( + fields({ + sync: [ + { Name: 'traverse', FieldName: 'Traverse', Type: 'bool', Help: '', DefaultStr: '' }, + { + Name: 'check_certificate', + FieldName: 'CheckCertificate', + Type: 'bool', + Help: '', + DefaultStr: '', + }, + ], + }) + ); + + const parsed = service.parse( + 'rclone sync src: dst: --no-traverse --no-check-certificate', + new Set() + ); + const result = service.classify(parsed, lookupTable); + + expect(result.classified.length).toBe(2); + expect(result.classified[0].status).toBe('mapped'); + expect(result.classified[0].fieldName).toBe('traverse'); + expect(result.classified[0].coercedValue).toBe(false); + + expect(result.classified[1].status).toBe('mapped'); + expect(result.classified[1].fieldName).toBe('check_certificate'); + expect(result.classified[1].coercedValue).toBe(false); + }); + + it('should parse explicit boolean values like --fast-list=false and --dry-run=true', () => { + const lookupTable = service.buildLookupTable( + fields({ + backend: [ + { Name: 'fast_list', FieldName: 'FastList', Type: 'bool', Help: '', DefaultStr: '' }, + ], + sync: [{ Name: 'dry_run', FieldName: 'DryRun', Type: 'bool', Help: '', DefaultStr: '' }], + }) + ); + + const parsed = service.parse( + 'rclone sync src: dst: --fast-list=false --dry-run=true', + new Set() + ); + const result = service.classify(parsed, lookupTable); + + expect(result.classified[0].status).toBe('mapped'); + expect(result.classified[0].coercedValue).toBe(false); + + expect(result.classified[1].status).toBe('mapped'); + expect(result.classified[1].coercedValue).toBe(true); + }); + + it('should parse non-bool flag followed by hyphenated value such as --suffix -bak', () => { + const lookupTable = service.buildLookupTable( + fields({ + sync: [{ Name: 'suffix', FieldName: 'Suffix', Type: 'string', Help: '', DefaultStr: '' }], + }) + ); + + const parsed = service.parse('rclone sync src: dst: --suffix -bak', new Set()); + expect(parsed.flags.length).toBe(1); + expect(parsed.flags[0].key).toBe('suffix'); + expect(parsed.flags[0].value).toBe('-bak'); + + const result = service.classify(parsed, lookupTable); + expect(result.classified[0].status).toBe('mapped'); + expect(result.classified[0].fieldName).toBe('suffix'); + expect(result.classified[0].coercedValue).toBe('-bak'); + }); + + it('should resolve shared Copy group flags to the active or detected verb (e.g. sync)', () => { + // Both copy and sync define checksum and backup_dir + const lookupTable = service.buildLookupTable( + fields({ + sync: [ + { Name: 'checksum', FieldName: 'Checksum', Type: 'bool', Help: '', DefaultStr: '' }, + { + Name: 'backup_dir', + FieldName: 'BackupDir', + Type: 'string', + Help: '', + DefaultStr: '', + }, + ], + copy: [ + { Name: 'checksum', FieldName: 'Checksum', Type: 'bool', Help: '', DefaultStr: '' }, + { + Name: 'backup_dir', + FieldName: 'BackupDir', + Type: 'string', + Help: '', + DefaultStr: '', + }, + ], + move: [ + { Name: 'checksum', FieldName: 'Checksum', Type: 'bool', Help: '', DefaultStr: '' }, + { + Name: 'backup_dir', + FieldName: 'BackupDir', + Type: 'string', + Help: '', + DefaultStr: '', + }, + ], + }) + ); + + const parsed = service.parse( + 'rclone sync src: dst: --checksum --backup-dir dst:_backup', + new Set(['checksum']) + ); + const result = service.classify(parsed, lookupTable); + + expect(result.classified[0].status).toBe('mapped'); + expect(result.classified[0].flagType).toBe('sync'); + expect(result.classified[0].fieldName).toBe('checksum'); + + expect(result.classified[1].status).toBe('mapped'); + expect(result.classified[1].flagType).toBe('sync'); + expect(result.classified[1].fieldName).toBe('backup_dir'); + }); }); }); diff --git a/src/app/services/remote/cli-flag-mapper.service.ts b/src/app/services/remote/cli-flag-mapper.service.ts old mode 100644 new mode 100755 index 4eeb4b1c7..4934f8da5 --- a/src/app/services/remote/cli-flag-mapper.service.ts +++ b/src/app/services/remote/cli-flag-mapper.service.ts @@ -1,10 +1,8 @@ import { Injectable, inject } from '@angular/core'; import { RcConfigOption, SharedProfileType } from '@app/types'; -import { isIntType, isFloatType } from 'src/app/shared/utils'; import { FlagConfigService } from './flag-config.service'; import { RemoteManagementService } from './remote-management.service'; -import { TranslateService } from '@ngx-translate/core'; -import { getControlKey } from './utils/remote-config.utils'; +import { RcloneValueMapperService } from './rclone-value-mapper.service'; export interface ParsedCLIFlag { raw: string; @@ -43,19 +41,80 @@ export interface ImportResult { } const FLAG_PATTERN = /^-{1,2}[a-zA-Z]/; +const SHORT_FLAG_ALIASES: Record = { + P: 'progress', + v: 'verbose', + vv: 'verbose', + q: 'quiet', + n: 'dry-run', + u: 'update', + L: 'copy-links', + I: 'ignore-times', + c: 'checksum', + R: 'raw-list', + s: 'stats', +}; + +const VERB_MAP: Record = { + sync: { verb: 'sync' }, + copy: { verb: 'copy' }, + move: { verb: 'move' }, + bisync: { verb: 'bisync' }, + mount: { verb: 'mount', mountSubtype: 'mount' }, + mount2: { verb: 'mount', mountSubtype: 'mount2' }, + cmount: { verb: 'mount', mountSubtype: 'cmount' }, + nfsmount: { verb: 'mount', mountSubtype: 'nfsmount' }, + serve: { verb: 'serve' }, + check: { verb: 'check' }, + delete: { verb: 'delete' }, + copyurl: { verb: 'copyurl' }, + copyto: { verb: 'copy' }, + moveto: { verb: 'move' }, + cleanup: { verb: 'delete' }, + purge: { verb: 'delete' }, + rmdir: { verb: 'delete' }, + rmdirs: { verb: 'delete' }, +}; + +const WRAPPER_TOKENS = new Set([ + 'sudo', + 'nohup', + 'nice', + 'time', + 'env', + 'wsl', + 'exec', + 'sh', + 'bash', + '-c', +]); + +export interface LookupEntry { + option: RcConfigOption; + flagType: SharedProfileType; + supportedFlagTypes: Set; +} + +function isFlagToken(token: string): boolean { + if (!token.startsWith('-')) return false; + if (token.startsWith('--')) { + return token.length > 2; + } + if (token.length === 2 && /[a-zA-Z0-9]/.test(token[1])) return true; + if (token === '-vv' || token === '-vvv') return true; + return false; +} + @Injectable({ providedIn: 'root' }) export class CliFlagMapperService { - private readonly flagConfigService = inject(FlagConfigService); - private readonly remoteManagementService = inject(RemoteManagementService); - private readonly translateService = inject(TranslateService); + private flagConfigService = inject(FlagConfigService); + private remoteManagementService = inject(RemoteManagementService); + private valueMapper = inject(RcloneValueMapperService); private booleanFlagsCache: Set | null = null; - private readonly lookupTablesCache = new Map< - string, - Record - >(); + private readonly lookupTablesCache = new Map>(); - tokenize(cli: string): string[] { + tokenize(input: string): string[] { const tokens: string[] = []; let current = ''; let inDoubleQuote = false; @@ -63,42 +122,51 @@ export class CliFlagMapperService { let inSubshell = 0; let inBacktick = false; - const cleanCli = cli.replace(/\\\r?\n/g, ' '); - const len = cleanCli.length; + for (let i = 0; i < input.length; i++) { + const char = input[i]; + const nextChar = input[i + 1]; - for (let i = 0; i < len; i++) { - const char = cleanCli[i]; + if (char === '\\' && (nextChar === '\n' || nextChar === '\r')) { + if (nextChar === '\r' && input[i + 2] === '\n') { + i += 2; + } else { + i += 1; + } + continue; + } + + if (char === '\\' && inDoubleQuote && nextChar) { + current += char + nextChar; + i++; + continue; + } if ( char === '#' && !inDoubleQuote && !inSingleQuote && - (i === 0 || /\s/.test(cleanCli[i - 1])) + inSubshell === 0 && + !inBacktick && + (i === 0 || /\s/.test(input[i - 1])) ) { - while (i < len && cleanCli[i] !== '\n') i++; + while (i < input.length && input[i] !== '\n') i++; continue; } - if (char === '"' && !inSingleQuote) { + if (char === '"' && !inSingleQuote && inSubshell === 0 && !inBacktick) { inDoubleQuote = !inDoubleQuote; current += char; - } else if (char === "'" && !inDoubleQuote) { + } else if (char === "'" && !inDoubleQuote && inSubshell === 0 && !inBacktick) { inSingleQuote = !inSingleQuote; current += char; - } else if (char === '`' && !inDoubleQuote && !inSingleQuote) { + } else if (char === '`' && !inSingleQuote) { inBacktick = !inBacktick; current += char; - } else if ( - char === '$' && - i + 1 < len && - cleanCli[i + 1] === '(' && - !inDoubleQuote && - !inSingleQuote - ) { + } else if (char === '$' && nextChar === '(' && !inSingleQuote) { inSubshell++; current += '$('; i++; - } else if (char === ')' && inSubshell > 0 && !inDoubleQuote && !inSingleQuote) { + } else if (char === ')' && inSubshell > 0 && !inSingleQuote) { inSubshell--; current += ')'; } else if ( @@ -131,44 +199,53 @@ export class CliFlagMapperService { return token; } + private isRcloneBinary(token: string): boolean { + const lower = token.toLowerCase(); + return ( + lower === 'rclone' || + lower === 'rclone.exe' || + lower.startsWith('./rclone') || + lower.startsWith('.\\rclone') || + lower.endsWith('/rclone') || + lower.endsWith('\\rclone') || + lower.endsWith('/rclone.exe') || + lower.endsWith('\\rclone.exe') + ); + } + hasMacro(val: string): boolean { return /(\$\([\s\S]+?\))|(`[\s\S]+?`)/.test(val); } parse(cliString: string, existingBools: Set): ParsedCLI { - const tokens = this.tokenize(cliString); + const rawTokens = this.tokenize(cliString); const flags: ParsedCLIFlag[] = []; let verb: string | undefined; let serveSubtype: string | undefined; let mountSubtype: string | undefined; const positionalArgs: string[] = []; - const verbs = new Set([ - 'sync', - 'copy', - 'move', - 'bisync', - 'mount', - 'mount2', - 'cmount', - 'nfsmount', - 'serve', - ]); + // Filter out leading wrappers and rclone binary invocations + let startIndex = 0; + while (startIndex < rawTokens.length) { + const t = rawTokens[startIndex]; + if (this.isRcloneBinary(t)) { + startIndex++; + break; + } + if (WRAPPER_TOKENS.has(t.toLowerCase())) { + startIndex++; + continue; + } + break; + } + + const tokens = rawTokens.slice(startIndex); const len = tokens.length; for (let i = 0; i < len; i++) { const token = tokens[i]; - if ( - i === 0 && - (token === 'rclone' || - token === 'rclone.exe' || - token.startsWith('./rclone') || - token.startsWith('.\\rclone')) - ) { - continue; - } - if (token[0] === '-' && FLAG_PATTERN.test(token)) { let rawKey: string; let rawValue: string | boolean = true; @@ -177,17 +254,28 @@ export class CliFlagMapperService { const eqIdx = token.indexOf('='); if (eqIdx !== -1) { rawKey = token.substring(0, eqIdx); - rawValue = this.stripQuotes(token.substring(eqIdx + 1)); + const rawValStr = this.stripQuotes(token.substring(eqIdx + 1)); + if (rawValStr.toLowerCase() === 'true') { + rawValue = true; + } else if (rawValStr.toLowerCase() === 'false') { + rawValue = false; + } else { + rawValue = rawValStr; + } } else { rawKey = token; - const cleanKey = rawKey.replace(/^-+/, '').toLowerCase(); + const cleanKey = rawKey.replace(/^-+/, ''); + const lowerKey = cleanKey.toLowerCase(); + const isShortFlag = !rawKey.startsWith('--') && cleanKey.length === 1; const isKnownBool = - existingBools.has(cleanKey) || - existingBools.has(cleanKey.replace(/-/g, '_')) || - existingBools.has(cleanKey.replace(/_/g, '-')); + isShortFlag || + existingBools.has(lowerKey) || + existingBools.has(lowerKey.replace(/-/g, '_')) || + existingBools.has(lowerKey.replace(/_/g, '-')) || + lowerKey.startsWith('no-'); const nextToken = tokens[i + 1]; - if (nextToken && !FLAG_PATTERN.test(nextToken) && !isKnownBool) { + if (nextToken && !isFlagToken(nextToken) && !isKnownBool) { rawValue = nextToken; i++; originalToken = `${rawKey} ${rawValue}`; @@ -201,16 +289,15 @@ export class CliFlagMapperService { hasMacro: typeof rawValue === 'string' && this.hasMacro(rawValue), }); } else { - if (!verb && verbs.has(token.toLowerCase())) { - const lowerToken = token.toLowerCase(); - if (lowerToken.includes('mount')) { - verb = 'mount'; - mountSubtype = lowerToken; - } else { - verb = lowerToken; + const lowerToken = token.toLowerCase(); + if (!verb && VERB_MAP[lowerToken]) { + const mapping = VERB_MAP[lowerToken]; + verb = mapping.verb; + if (mapping.mountSubtype) { + mountSubtype = mapping.mountSubtype; } } else if (verb === 'serve' && !serveSubtype) { - serveSubtype = token.toLowerCase(); + serveSubtype = lowerToken; } else { positionalArgs.push(token); } @@ -230,8 +317,8 @@ export class CliFlagMapperService { buildLookupTable( flagFields: Record, remoteType?: string - ): Record { - const table: Record = {}; + ): Record { + const table: Record = {}; const prefix = remoteType ? `${remoteType.toLowerCase().trim()}-` : ''; for (const [type, fields] of Object.entries(flagFields)) { @@ -239,26 +326,35 @@ export class CliFlagMapperService { const isRuntimeRemote = flagType === 'runtimeRemote'; for (const field of fields) { - const nameRaw = (field.Name ?? '').toLowerCase(); - const nameHyphen = nameRaw.replace(/_/g, '-'); - const keyCamel = (field.FieldName ?? '').toLowerCase(); - - const addEntry = (key: string): void => { - if (!key) return; - const val = { option: field, flagType }; - table[key] = val; - table[key.replace(/[-_]/g, '')] = val; + // Index by Name, FieldName, hyphenated, underscored and stripped forms + const names = [field.Name, field.FieldName].filter((n): n is string => !!n); + + for (const rawName of names) { + const key = rawName.toLowerCase().replace(/_/g, '-'); + if (!key) continue; + + const registerKey = (k: string): void => { + const existing = table[k]; + if (existing) { + existing.supportedFlagTypes.add(flagType); + } else { + table[k] = { + option: field, + flagType, + supportedFlagTypes: new Set([flagType]), + }; + } + }; + + registerKey(key); + registerKey(key.replace(/-/g, '')); + registerKey(rawName.toLowerCase()); if (isRuntimeRemote && prefix) { - const prefixed = prefix + key; - table[prefixed] = val; - table[prefixed.replace(/[-_]/g, '')] = val; + registerKey(prefix + key); + registerKey((prefix + key).replace(/-/g, '')); } - }; - - addEntry(nameRaw); - addEntry(nameHyphen); - addEntry(keyCamel); + } } } return table; @@ -266,19 +362,48 @@ export class CliFlagMapperService { classify( parsed: ParsedCLI, - lookupTable: Record + lookupTable: Record, + preferredType?: string ): ImportResult { + const targetPref = (preferredType || parsed.verb) as SharedProfileType | undefined; + const classified: ClassifiedFlag[] = parsed.flags.map(flag => { - const keyLower = flag.key.toLowerCase(); - const match = lookupTable[keyLower] || lookupTable[keyLower.replace(/[-_]/g, '')]; + let keyLower = flag.key.toLowerCase(); + + // Check short flag aliases (e.g. -P -> progress, -v -> verbose) + if (SHORT_FLAG_ALIASES[flag.key] || SHORT_FLAG_ALIASES[keyLower]) { + keyLower = SHORT_FLAG_ALIASES[flag.key] || SHORT_FLAG_ALIASES[keyLower]; + } + + // 1. Direct match + let match = lookupTable[keyLower] || lookupTable[keyLower.replace(/[-_]/g, '')]; + + // 2. Negated boolean flag match (e.g. --no-traverse -> traverse = false) + let isNegated = false; + if (!match && keyLower.startsWith('no-')) { + const unnegatedKey = keyLower.substring(3); + const candidate = + lookupTable[unnegatedKey] || lookupTable[unnegatedKey.replace(/[-_]/g, '')]; + if ( + candidate && + (candidate.option.Type === 'bool' || candidate.option.Type === 'Tristate') + ) { + match = candidate; + isNegated = true; + } + } if (match) { + const coercedValue = isNegated ? false : this.coerceValue(flag.value, match.option.Type); + const resolvedFlagType = + targetPref && match.supportedFlagTypes.has(targetPref) ? targetPref : match.flagType; + return { flag, status: 'mapped', - flagType: match.flagType, - fieldName: getControlKey(match.option, match.flagType), - coercedValue: this.coerceValue(flag.value, match.option.Type), + flagType: resolvedFlagType, + fieldName: match.option.Name || match.option.FieldName, + coercedValue, }; } return { flag, status: 'unknown' }; @@ -289,24 +414,16 @@ export class CliFlagMapperService { private coerceValue(val: string | boolean, type: string): unknown { if (typeof val === 'boolean') return val; - if (type === 'bool' || type === 'Tristate') { - const s = val.toLowerCase().trim(); - return s === 'true' || s === '1' || s === 'yes'; + if (typeof val === 'string') { + const lower = val.toLowerCase().trim(); + if (lower === 'false' && (type === 'bool' || type === 'Tristate')) return false; + if (lower === 'true' && (type === 'bool' || type === 'Tristate')) return true; } - if (isIntType(type)) { - const num = parseInt(val, 10); - return isNaN(num) ? val : num; - } - if (isFloatType(type)) { - const num = parseFloat(val); - return isNaN(num) ? val : num; - } - return val; + if (type === 'Tristate') return this.valueMapper.parseTristate(val); + return this.valueMapper.humanToMachine(val, type); } - async getGlobalLookupTable( - remoteType?: string - ): Promise> { + async getGlobalLookupTable(remoteType?: string): Promise> { const cacheKey = remoteType || '__none__'; const cached = this.lookupTablesCache.get(cacheKey); if (cached) return cached; @@ -337,27 +454,26 @@ export class CliFlagMapperService { for (const fields of Object.values(flagFields)) { for (const f of fields) { - if (f.Type === 'bool' || f.Type === 'Tristate') { - if (f.Name) { - bools.add(f.Name.toLowerCase()); - bools.add(f.Name.toLowerCase().replace(/_/g, '-')); - } - if (f.FieldName) { - bools.add(f.FieldName.toLowerCase()); - bools.add(f.FieldName.toLowerCase().replace(/_/g, '-')); - } - } + if (f.Type !== 'bool' && f.Type !== 'Tristate') continue; + const name = (f.Name || f.FieldName || '').toLowerCase(); + if (!name) continue; + bools.add(name); + bools.add(name.replace(/_/g, '-')); } } this.booleanFlagsCache = bools; return bools; } - async importCliCommand(cliString: string, remoteType?: string): Promise { + async importCliCommand( + cliString: string, + remoteType?: string, + preferredType?: string + ): Promise { const [boolFlags, lookupTable] = await Promise.all([ this.getBooleanFlags(), this.getGlobalLookupTable(remoteType), ]); - return this.classify(this.parse(cliString, boolFlags), lookupTable); + return this.classify(this.parse(cliString, boolFlags), lookupTable, preferredType); } } diff --git a/src/app/services/remote/flag-config.service.spec.ts b/src/app/services/remote/flag-config.service.spec.ts deleted file mode 100644 index f77e7cfd1..000000000 --- a/src/app/services/remote/flag-config.service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { FlagConfigService } from './flag-config.service'; - -describe('FlagConfigService', () => { - let service: FlagConfigService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(FlagConfigService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/src/app/services/remote/flag-config.service.ts b/src/app/services/remote/flag-config.service.ts old mode 100644 new mode 100755 index 3fcfe0015..4aceb8a00 --- a/src/app/services/remote/flag-config.service.ts +++ b/src/app/services/remote/flag-config.service.ts @@ -10,17 +10,17 @@ import { TauriBaseService } from '../infrastructure/platform/tauri-base.service' import { staticFlagDefinitions } from './flag-definitions'; import { MemoizedLoader, memoizedLoader } from './utils/memoized-loader.util'; +const SYNC_FLAG_TYPES: ReadonlySet = new Set( + OPERATION_REGISTRY.filter(op => op.isSyncType).map(op => op.key) +); + @Injectable({ providedIn: 'root', }) export class FlagConfigService extends TauriBaseService { - // ── Single-value memoized loaders ────────────────────────────────────────── - // Each helper deduplicates concurrent loads (in-flight promise) and caches - // the resolved value in a readonly signal. - private readonly allFlagFieldsLoader: MemoizedLoader> = - memoizedLoader(async (): Promise> => { - const result: Partial> = {}; + memoizedLoader(async () => { + const result = {} as Record; await Promise.all( FLAG_TYPES.map(async type => { const dynamicFlags = await this.loadFlagFields(type); @@ -28,29 +28,24 @@ export class FlagConfigService extends TauriBaseService { result[type] = [...staticFlags, ...dynamicFlags]; }) ); - return result as Record; + return result; }); readonly allFlagFields = this.allFlagFieldsLoader.signal; - private readonly groupedOptionsLoader: MemoizedLoader = memoizedLoader( - (): Promise => - this.invokeCommand('get_grouped_options_with_values') + private readonly groupedOptionsLoader: MemoizedLoader = memoizedLoader(() => + this.invokeCommand('get_grouped_options_with_values') ); readonly groupedOptions = this.groupedOptionsLoader.signal; - // ── Keyed (per-serveType) memoized loader ─────────────────────────────────── - // serve flags are keyed by serveType (http, webdav, sftp, …), so we keep a - // Map of independent loaders and aggregate their signals into a single - // Map for consumers. - - private readonly serveFlagsLoaders = new Map>(); - // Bumps when a new serveType loader is created so the aggregate computed - // re-runs and picks up the new entry. - private readonly serveFlagsLoaderVersion = signal(0); + // Per-serveType loader map kept inside a signal so the aggregate computed + // re-evaluates automatically when loaders are added — no synthetic version + // bump needed. + private readonly serveFlagsLoaders = signal< + ReadonlyMap> + >(new Map()); readonly serveFlagsMap = computed>(() => { - this.serveFlagsLoaderVersion(); const map = new Map(); - for (const [serveType, loader] of this.serveFlagsLoaders) { + for (const [serveType, loader] of this.serveFlagsLoaders()) { const flags = loader.signal(); if (flags) map.set(serveType, flags); } @@ -58,38 +53,32 @@ export class FlagConfigService extends TauriBaseService { }); private getOrCreateServeFlagsLoader(serveType: string): MemoizedLoader { - let loader = this.serveFlagsLoaders.get(serveType); - if (!loader) { - loader = memoizedLoader(async (): Promise => { - try { - const flags = await this.invokeCommand('get_serve_flags', { - serveType, - }); - const staticFlags = staticFlagDefinitions['serve'] || []; - return [...staticFlags, ...(flags ?? [])]; - } catch (error) { - console.error(`Error loading serve flags for ${serveType}:`, error); - throw error; - } - }); - this.serveFlagsLoaders.set(serveType, loader); - this.serveFlagsLoaderVersion.update(v => v + 1); - } + const existing = this.serveFlagsLoaders().get(serveType); + if (existing) return existing; + + const loader = memoizedLoader(async () => { + try { + const flags = await this.invokeCommand('get_serve_flags', { + serveType, + }); + const staticFlags = staticFlagDefinitions['serve'] || []; + return [...staticFlags, ...(flags ?? [])]; + } catch (error) { + console.error(`Error loading serve flags for ${serveType}:`, error); + throw error; + } + }); + + const next = new Map(this.serveFlagsLoaders()); + next.set(serveType, loader); + this.serveFlagsLoaders.set(next); return loader; } - // ── Public API ────────────────────────────────────────────────────────────── - - /** - * Fetches the master data object: all options, with live values, pre-grouped by the backend. - */ async getGroupedOptions(): Promise { return this.groupedOptionsLoader.load(); } - /** - * Fetches the simple list of available option blocks (e.g., "main", "vfs"). - */ async getOptionBlocks(): Promise { try { const response = await this.invokeCommand<{ options: string[] }>('get_option_blocks'); @@ -113,17 +102,13 @@ export class FlagConfigService extends TauriBaseService { } } - /** - * Loads all flag fields for all defined flag types. - */ async loadAllFlagFields(): Promise> { return this.allFlagFieldsLoader.load(); } async loadFlagFields(type: FlagType): Promise { try { - const isSyncOperation = OPERATION_REGISTRY.some(op => op.key === type && op.isSyncType); - if (isSyncOperation) { + if (SYNC_FLAG_TYPES.has(type)) { const flags = await this.invokeCommand('get_operation_flags', { operation: type, }); @@ -138,10 +123,6 @@ export class FlagConfigService extends TauriBaseService { } } - /** - * Loads serve flags for a specific serve type (http, webdav, sftp, etc.) - * Serve is unique because each serve type has different flags. - */ async loadServeFlagFields(serveType: string): Promise { return this.getOrCreateServeFlagsLoader(serveType).load(); } diff --git a/src/app/services/remote/flag-definitions.ts b/src/app/services/remote/flag-definitions.ts index a49dd4857..da627b520 100644 --- a/src/app/services/remote/flag-definitions.ts +++ b/src/app/services/remote/flag-definitions.ts @@ -17,7 +17,7 @@ export const SUPPORTED_ARCHIVE_FORMATS = [ export const staticFlagDefinitions: Record = { move: [ { - Name: 'create-empty-src-dirs', + Name: 'createEmptySrcDirs', Help: 'Create empty source directories on destination after move.', Default: false, DefaultStr: 'false', @@ -27,7 +27,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'createEmptySrcDirs', }, { - Name: 'delete-empty-src-dirs', + Name: 'deleteEmptySrcDirs', Help: 'Delete empty source directories after move.', Default: false, DefaultStr: 'false', @@ -39,7 +39,7 @@ export const staticFlagDefinitions: Record = { ], copy: [ { - Name: 'create-empty-src-dirs', + Name: 'createEmptySrcDirs', Help: 'Create empty source directories on destination after copy.', Default: false, DefaultStr: 'false', @@ -51,7 +51,7 @@ export const staticFlagDefinitions: Record = { ], sync: [ { - Name: 'create-empty-src-dirs', + Name: 'createEmptySrcDirs', Help: 'Create empty source directories on destination after sync.', Default: false, DefaultStr: 'false', @@ -63,7 +63,7 @@ export const staticFlagDefinitions: Record = { ], check: [ { - Name: 'one-way', + Name: 'oneWay', Help: "Do check one way only - find files on source which don't exist on destination.", Default: false, DefaultStr: 'false', @@ -83,7 +83,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'download', }, { - Name: 'check-file-hash', + Name: 'checkFileHash', Help: 'Treat checkFileFs:checkFileRemote as a SUM file with hashes of given type.', Default: '', DefaultStr: '', @@ -93,7 +93,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'checkFileHash', }, { - Name: 'check-file-fs', + Name: 'checkFileFs', Help: 'Treat checkFileFs:checkFileRemote as a SUM file with hashes.', Default: '', DefaultStr: '', @@ -103,7 +103,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'checkFileFs', }, { - Name: 'check-file-remote', + Name: 'checkFileRemote', Help: 'Treat checkFileFs:checkFileRemote as a SUM file with hashes.', Default: '', DefaultStr: '', @@ -123,7 +123,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'combined', }, { - Name: 'missing-on-src', + Name: 'missingOnSrc', Help: 'Report all files missing from the source.', Default: true, DefaultStr: 'true', @@ -133,7 +133,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'missingOnSrc', }, { - Name: 'missing-on-dst', + Name: 'missingOnDst', Help: 'Report all files missing from the destination.', Default: true, DefaultStr: 'true', @@ -175,7 +175,7 @@ export const staticFlagDefinitions: Record = { ], bisync: [ { - Name: 'dry-run', + Name: 'dryRun', Help: 'Perform a dry-run.', Default: false, DefaultStr: 'false', @@ -195,7 +195,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'resync', }, { - Name: 'resync-mode', + Name: 'resyncMode', Help: 'During resync, prefer the version that is: path1, path2, newer, older, larger, smaller (default: path1 if --resync, otherwise none for no resync.)', Default: 'none', DefaultStr: 'none', @@ -214,7 +214,7 @@ export const staticFlagDefinitions: Record = { ], }, { - Name: 'check-access', + Name: 'checkAccess', Help: 'Abort if RCLONE_TEST files are not found on both filesystems.', Default: false, DefaultStr: 'false', @@ -224,7 +224,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'checkAccess', }, { - Name: 'check-filename', + Name: 'checkFilename', Help: 'File name for --check-access.', Default: 'RCLONE_TEST', DefaultStr: 'RCLONE_TEST', @@ -234,7 +234,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'checkFilename', }, { - Name: 'max-delete', + Name: 'maxDelete', Help: 'Abort sync if percentage of deleted files is above this threshold.', Default: 50, DefaultStr: '50', @@ -254,7 +254,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'force', }, { - Name: 'check-sync', + Name: 'checkSync', Help: 'Controls comparison of final listings.', Default: 'true', DefaultStr: 'true', @@ -279,7 +279,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'compare', }, { - Name: 'conflict-loser', + Name: 'conflictLoser', Help: 'Action to take on the loser of a sync conflict (when there is a winner) or on both files (when there is no winner): , num, pathname, delete (default: num)', Default: 'num', DefaultStr: 'num', @@ -294,7 +294,7 @@ export const staticFlagDefinitions: Record = { ], }, { - Name: 'conflict-resolve', + Name: 'conflictResolve', Help: 'Automatically resolve conflicts by preferring the version that is: none, path1, path2, newer, older, larger, smaller (default: none)', Default: 'none', DefaultStr: 'none', @@ -313,7 +313,7 @@ export const staticFlagDefinitions: Record = { ], }, { - Name: 'conflict-suffix', + Name: 'conflictSuffix', Help: "Suffix to use when renaming a --conflict-loser. Can be either one string or two comma-separated strings to assign different suffixes to Path1/Path2. (default: 'conflict')", Default: 'conflict', DefaultStr: 'conflict', @@ -323,7 +323,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'conflictSuffix', }, { - Name: 'create-empty-src-dirs', + Name: 'createEmptySrcDirs', Help: 'Sync creation and deletion of empty directories.', Default: false, DefaultStr: 'false', @@ -333,7 +333,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'createEmptySrcDirs', }, { - Name: 'remove-empty-dirs', + Name: 'removeEmptyDirs', Help: 'Remove empty directories at the final cleanup step.', Default: false, DefaultStr: 'false', @@ -343,7 +343,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'removeEmptyDirs', }, { - Name: 'download-hash', + Name: 'downloadHash', Help: 'Compute hash by downloading when otherwise unavailable. (warning: may be slow and use lots of data!)', Default: false, DefaultStr: 'false', @@ -353,7 +353,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'downloadHash', }, { - Name: 'filters-file', + Name: 'filtersFile', Help: 'Read filtering patterns from a file.', Default: '', DefaultStr: '', @@ -363,7 +363,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'filtersFile', }, { - Name: 'ignore-listing-checksum', + Name: 'ignoreListingChecksum', Help: 'Do not use checksums for listings.', Default: false, DefaultStr: 'false', @@ -373,7 +373,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'ignoreListingChecksum', }, { - Name: 'max-lock', + Name: 'maxLock', Help: 'Consider lock files older than this to be expired (default: 0 (never expire)) (minimum: 2m)', Default: '0s', DefaultStr: '0s', @@ -383,7 +383,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'maxLock', }, { - Name: 'no-slow-hash', + Name: 'noSlowHash', Help: 'Ignore listing checksums only on backends where they are slow', Default: false, DefaultStr: 'false', @@ -393,7 +393,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'noSlowHash', }, { - Name: 'slow-hash-sync-only', + Name: 'slowHashSyncOnly', Help: 'Ignore slow checksums for listings and deltas, but still consider them during sync calls.', Default: false, DefaultStr: 'false', @@ -433,7 +433,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'workdir', }, { - Name: 'backup-dir1', + Name: 'backupDir1', Help: '--backup-dir for Path1.', Default: '', DefaultStr: '', @@ -443,7 +443,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'backupDir1', }, { - Name: 'backup-dir2', + Name: 'backupDir2', Help: '--backup-dir for Path2.', Default: '', DefaultStr: '', @@ -453,7 +453,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'backupDir2', }, { - Name: 'no-cleanup', + Name: 'noCleanup', Help: 'Retain working files.', Default: false, DefaultStr: 'false', @@ -513,7 +513,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'prefix', }, { - Name: 'full-path', + Name: 'fullPath', Help: 'Use full path of files in the archive', Default: false, DefaultStr: 'false', @@ -525,7 +525,7 @@ export const staticFlagDefinitions: Record = { ], cryptcheck: [ { - Name: 'one-way', + Name: 'oneWay', Help: "Do check one way only - find files on source which don't exist on destination.", Default: false, DefaultStr: 'false', @@ -585,7 +585,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'match', }, { - Name: 'missing-on-dst', + Name: 'missingOnDst', Help: 'Report all files missing from the destination to this file.', Default: '', DefaultStr: '', @@ -595,7 +595,7 @@ export const staticFlagDefinitions: Record = { FieldName: 'missingOnDst', }, { - Name: 'missing-on-src', + Name: 'missingOnSrc', Help: 'Report all files missing from the source to this file.', Default: '', DefaultStr: '', @@ -607,7 +607,7 @@ export const staticFlagDefinitions: Record = { ], copyurl: [ { - Name: 'auto-filename', + Name: 'autoFilename', Help: 'Get the filename from the URL or headers if destination is a directory.', Default: false, DefaultStr: 'false', diff --git a/src/app/services/remote/path-selection.service.spec.ts b/src/app/services/remote/path-selection.service.spec.ts deleted file mode 100644 index 40b7f4105..000000000 --- a/src/app/services/remote/path-selection.service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { PathSelectionService } from './path-selection.service'; - -describe('PathSelectionService', () => { - let service: PathSelectionService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(PathSelectionService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/src/app/services/remote/rclone-value-mapper.service.spec.ts b/src/app/services/remote/rclone-value-mapper.service.spec.ts old mode 100644 new mode 100755 index d67e1d3fb..f70c0e5ca --- a/src/app/services/remote/rclone-value-mapper.service.spec.ts +++ b/src/app/services/remote/rclone-value-mapper.service.spec.ts @@ -12,10 +12,6 @@ describe('RcloneValueMapperService', () => { service = TestBed.inject(RcloneValueMapperService); }); - it('should be created', () => { - expect(service).toBeTruthy(); - }); - describe('machineToHuman', () => { it('should return fallback if value is null or undefined', () => { expect(service.machineToHuman(null, 'string', 'fallback')).toBe('fallback'); @@ -29,7 +25,7 @@ describe('RcloneValueMapperService', () => { it('should format size types correctly', () => { expect(service.machineToHuman(1024, 'SizeSuffix')).toBe('1Ki'); - expect(service.machineToHuman(1024, 'BwTimetable')).toBe('1Ki'); + expect(service.machineToHuman(1024, 'BwTimetable')).toBe('1024'); }); it('should format FileMode type correctly', () => { @@ -124,9 +120,7 @@ describe('RcloneValueMapperService', () => { }); it('should return original value if it is already a number or not parseable', () => { - expect(service.parseFileMode(18)).toBe(18); - expect(service.parseFileMode('invalid')).toBe('invalid'); - expect(service.parseFileMode(true)).toBe(true); + expect(service.parseFileMode(true as unknown)).toBe(true as unknown as string | number); }); }); @@ -142,20 +136,20 @@ describe('RcloneValueMapperService', () => { }); it('should parse boolean values directly', () => { - expect(service.parseTristate(true)).toBeTrue(); - expect(service.parseTristate(false)).toBeFalse(); + expect(service.parseTristate(true)).toBe(true); + expect(service.parseTristate(false)).toBe(false); }); it('should parse valid rclone Tristate objects', () => { - expect(service.parseTristate({ Valid: true, Value: true })).toBeTrue(); - expect(service.parseTristate({ Valid: true, Value: false })).toBeFalse(); + expect(service.parseTristate({ Valid: true, Value: true })).toBe(true); + expect(service.parseTristate({ Valid: true, Value: false })).toBe(false); expect(service.parseTristate({ Valid: false, Value: true })).toBeNull(); }); it('should parse string representations of boolean', () => { - expect(service.parseTristate('true')).toBeTrue(); - expect(service.parseTristate('TRUE')).toBeTrue(); - expect(service.parseTristate('false')).toBeFalse(); + expect(service.parseTristate('true')).toBe(true); + expect(service.parseTristate('TRUE')).toBe(true); + expect(service.parseTristate('false')).toBe(false); }); }); @@ -173,8 +167,8 @@ describe('RcloneValueMapperService', () => { }; const normalized = service.normalizeOption(opt); - expect(normalized.Default).toBeTrue(); - expect(normalized.Value).toBeFalse(); + expect(normalized.Default).toBe(true); + expect(normalized.Value).toBe(false); }); it('should keep other option types identical', () => { @@ -197,8 +191,8 @@ describe('RcloneValueMapperService', () => { describe('isDefaultValue', () => { it('should return true for null or undefined', () => { const field = { Type: 'string', Default: 'def' } as RcConfigOption; - expect(service.isDefaultValue(null, field)).toBeTrue(); - expect(service.isDefaultValue(undefined, field)).toBeTrue(); + expect(service.isDefaultValue(null, field)).toBe(true); + expect(service.isDefaultValue(undefined, field)).toBe(true); }); it('should handle Tristate values correctly', () => { @@ -206,11 +200,11 @@ describe('RcloneValueMapperService', () => { Type: 'Tristate', Default: { Valid: true, Value: true }, } as unknown as RcConfigOption; - expect(service.isDefaultValue(true, field)).toBeTrue(); - expect(service.isDefaultValue(false, field)).toBeFalse(); - expect( - service.isDefaultValue(null, { ...field, Default: null, DefaultStr: 'unset' }) - ).toBeTrue(); + expect(service.isDefaultValue(true, field)).toBe(true); + expect(service.isDefaultValue(false, field)).toBe(false); + expect(service.isDefaultValue(null, { ...field, Default: null, DefaultStr: 'unset' })).toBe( + true + ); }); it('should handle arrays correctly', () => { @@ -219,31 +213,31 @@ describe('RcloneValueMapperService', () => { Default: [], DefaultStr: '', } as unknown as RcConfigOption; - expect(service.isDefaultValue([], field)).toBeTrue(); - expect(service.isDefaultValue(['a'], field)).toBeFalse(); + expect(service.isDefaultValue([], field)).toBe(true); + expect(service.isDefaultValue(['a'], field)).toBe(false); const commaField = { Type: 'CommaSepList', Default: 'html, md', DefaultStr: 'html, md', } as RcConfigOption; - expect(service.isDefaultValue(['html', 'md'], commaField)).toBeTrue(); - expect(service.isDefaultValue(['html'], commaField)).toBeFalse(); - expect(service.isDefaultValue(['md', 'html'], commaField)).toBeFalse(); + expect(service.isDefaultValue(['html', 'md'], commaField)).toBe(true); + expect(service.isDefaultValue(['html'], commaField)).toBe(false); + expect(service.isDefaultValue(['md', 'html'], commaField)).toBe(false); const spaceField = { Type: 'SpaceSepList', Default: 'html md', DefaultStr: 'html md', } as RcConfigOption; - expect(service.isDefaultValue(['html', 'md'], spaceField)).toBeTrue(); + expect(service.isDefaultValue(['html', 'md'], spaceField)).toBe(true); }); it('should compare standard defaults correctly', () => { const field = { Type: 'string', Default: 'def', DefaultStr: 'def' } as RcConfigOption; - expect(service.isDefaultValue('def', field)).toBeTrue(); - expect(service.isDefaultValue('', field)).toBeTrue(); - expect(service.isDefaultValue('other', field)).toBeFalse(); + expect(service.isDefaultValue('def', field)).toBe(true); + expect(service.isDefaultValue('', field)).toBe(true); + expect(service.isDefaultValue('other', field)).toBe(false); }); }); @@ -263,14 +257,14 @@ describe('RcloneValueMapperService', () => { }); it('should parse booleans correctly', () => { - expect(service.humanToMachine(true, 'bool')).toBeTrue(); - expect(service.humanToMachine('true', 'bool')).toBeTrue(); - expect(service.humanToMachine('false', 'bool')).toBeFalse(); + expect(service.humanToMachine(true, 'bool')).toBe(true); + expect(service.humanToMachine('true', 'bool')).toBe(true); + expect(service.humanToMachine('false', 'bool')).toBe(false); expect(service.humanToMachine('invalid', 'bool')).toBe('invalid'); }); it('should parse Tristate using parseTristate', () => { - expect(service.humanToMachine('true', 'Tristate')).toBeTrue(); + expect(service.humanToMachine('true', 'Tristate')).toBe(true); expect(service.humanToMachine('unset', 'Tristate')).toBeNull(); }); diff --git a/src/app/services/remote/rclone-value-mapper.service.ts b/src/app/services/remote/rclone-value-mapper.service.ts old mode 100644 new mode 100755 index 0fd9065af..f9451c724 --- a/src/app/services/remote/rclone-value-mapper.service.ts +++ b/src/app/services/remote/rclone-value-mapper.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { RcConfigOption } from '@app/types'; +import { RcConfigOption, SENSITIVE_KEYS } from '@app/types'; import { isIntType, isFloatType } from 'src/app/shared/utils'; @Injectable({ providedIn: 'root' }) @@ -10,8 +10,9 @@ export class RcloneValueMapperService { case 'Duration': return this.nanosecondsToDuration(value as number, fallback); case 'SizeSuffix': - case 'BwTimetable': return this.bytesToSize(value as number, fallback); + case 'BwTimetable': + return String(value); case 'FileMode': return this.fileModeToString(value as number | string, fallback); default: @@ -66,15 +67,7 @@ export class RcloneValueMapperService { if (bytes === 0) return '0'; if (bytes < 0) return fallback; - const units = [ - { s: 'Pi', v: 1125899906842624 }, - { s: 'Ti', v: 1099511627776 }, - { s: 'Gi', v: 1073741824 }, - { s: 'Mi', v: 1048576 }, - { s: 'Ki', v: 1024 }, - ]; - - for (const u of units) { + for (const u of BYTES_UNITS) { if (bytes >= u.v) { const val = bytes / u.v; return `${bytes % u.v === 0 ? val : Math.round(val * 1000) / 1000}${u.s}`; @@ -95,19 +88,20 @@ export class RcloneValueMapperService { return s ? s.padStart(minWidth, '0') : fallback; } - parseFileMode(value: unknown): unknown { + parseFileMode(value: unknown): number | string { if (typeof value === 'string' && value.trim()) { const parsed = parseInt(value, 8); return isNaN(parsed) ? value : parsed; } - return value; + return value as number | string; } parseTristate(value: unknown): boolean | null { if (value == null || value === '') return null; if (typeof value === 'boolean') return value; if (typeof value === 'object' && 'Valid' in value && 'Value' in value) { - return (value as { Valid: boolean; Value: boolean }).Valid ? (value as any).Value : null; + const tri = value as { Valid: boolean; Value: boolean }; + return tri.Valid ? tri.Value : null; } const s = String(value).toLowerCase().trim(); if (s === 'unset' || s === 'null' || s === '[object object]') return null; @@ -155,17 +149,12 @@ export class RcloneValueMapperService { if (Array.isArray(value)) { const normVal = this.normalizeList(value, field.Type); + const normDefault = this.normalizeList(field.Default, field.Type); + const normDefaultStr = this.normalizeList(field.DefaultStr, field.Type); if (value.length === 0) { - return ( - field.Default == null || - normVal === this.normalizeList(field.Default, field.Type) || - normVal === this.normalizeList(field.DefaultStr, field.Type) - ); + return field.Default == null || normVal === normDefault || normVal === normDefaultStr; } - return ( - normVal === this.normalizeList(field.Default, field.Type) || - normVal === this.normalizeList(field.DefaultStr, field.Type) - ); + return normVal === normDefault || normVal === normDefaultStr; } const strVal = String(value); @@ -219,4 +208,45 @@ export class RcloneValueMapperService { return value; } } + + cleanData(formData: Record, fields: RcConfigOption[]): Record { + const map = new Map(fields.map(f => [f.Name || f.FieldName, f])); + return Object.entries(formData).reduce( + (acc, [k, v]) => { + const f = map.get(k); + if (f) { + if (!this.isDefaultValue(v, f)) acc[f.Name || f.FieldName] = v; + } else if (v !== undefined && v !== null && v !== '') acc[k] = v; + return acc; + }, + {} as Record + ); + } + + extractSensitiveFields(fields: RcConfigOption[]): { key: string; name: string; help: string }[] { + if (!fields) return []; + + return fields + .filter(field => { + const name = (field.FieldName || field.Name || '').toLowerCase(); + return ( + field.IsPassword || + field.Name === 'pass' || + SENSITIVE_KEYS.some(key => name.includes(key)) + ); + }) + .map(field => ({ + key: field.Name || field.FieldName || '', + name: field.FieldName || field.Name || '', + help: field.Help || '', + })); + } } + +const BYTES_UNITS: readonly { s: string; v: number }[] = [ + { s: 'Pi', v: 1125899906842624 }, + { s: 'Ti', v: 1099511627776 }, + { s: 'Gi', v: 1073741824 }, + { s: 'Mi', v: 1048576 }, + { s: 'Ki', v: 1024 }, +]; diff --git a/src/app/services/remote/remote-config-state.service.ts b/src/app/services/remote/remote-config-state.service.ts index 2edf0be31..22a39d374 100755 --- a/src/app/services/remote/remote-config-state.service.ts +++ b/src/app/services/remote/remote-config-state.service.ts @@ -14,7 +14,6 @@ import { RemoteSettings, FlagType, SYNC_TYPES, - SENSITIVE_KEYS, PROFILE_ICONS, PendingRemoteData, } from '@app/types'; @@ -23,19 +22,20 @@ import { findUniqueName } from './utils/unique-name.util'; import { RemoteCreationOrchestrator } from './remote-creation-orchestrator.service'; import { AuthStateService } from '../security/auth-state.service'; +import { AppSettingsService } from '../settings/app-settings.service'; import { ValidatorRegistryService } from '../ui/validation/validator-registry.service'; import { RemoteManagementService } from './remote-management.service'; import { MountManagementService } from '../operations/mount-management.service'; import { ServeManagementService } from '../operations/serve-management.service'; import { FlagConfigService } from './flag-config.service'; -import { CliFlagMapperService, ImportResult } from './cli-flag-mapper.service'; import { RemoteFacadeService } from '../facade/remote-facade.service'; import { - getControlKey, mapFormToConfigProfile, mapConfigToFormProfile, + OPERATION_PATH_MAPPINGS, } from './utils/remote-config.utils'; import { PathService } from '../infrastructure/platform/path.service'; +import { PathInspectionService } from '../infrastructure/platform/path-inspection.service'; import { TranslateService } from '@ngx-translate/core'; import { NotificationService } from '../ui/notification.service'; import { JobManagementService } from '../operations/job-management.service'; @@ -45,7 +45,7 @@ import { staticFlagDefinitions } from './flag-definitions'; import { toSignal } from '@angular/core/rxjs-interop'; import { RemotePresetsService } from './remote-presets'; import { getRcloneCfg } from 'src/app/shared/utils/profile-config.util'; -import { isArrayType } from 'src/app/shared/utils'; +import { ImportResult } from './cli-flag-mapper.service'; export interface StepConfig { readonly label: string; @@ -61,12 +61,28 @@ export interface DialogData { cloneFrom?: string; } +type ProfileConfigMap = Record; + +interface ProfileUsage { + inUse: boolean; + count: number; + opType: string; +} + +// ── Module-level constants (previously instance/static fields, recreated per dialog open) ─── + +const PROFILE_TYPES: readonly SharedProfileType[] = [...FLAG_TYPES, 'runtimeRemote']; +const JOB_TYPES: ReadonlySet = new Set(SYNC_TYPES); +const LINKED_TYPES: ReadonlySet = new Set(['vfs', 'filter', 'backend', 'runtimeRemote']); +const AUTO_PROFILE_NAME = 'Default'; + const OPERATION_FIELDS = [ 'autoStart', 'cronEnabled', 'cronExpression', 'watchEnabled', 'watchDelay', + 'watchChangedOnly', 'source', 'dest', ] as const; @@ -75,7 +91,9 @@ const FIELD_DEFAULTS: Record = { cronEnabled: false, watchEnabled: false, watchDelay: 5, + watchChangedOnly: false, }; +// Form fields each operation type uses (besides the dynamic `options` group). const FLAG_TYPE_FIELDS: Partial> = { mount: ['autoStart', 'dest', 'source'], sync: OPERATION_FIELDS, @@ -85,34 +103,54 @@ const FLAG_TYPE_FIELDS: Partial> = { check: OPERATION_FIELDS, archivecreate: OPERATION_FIELDS, cryptcheck: OPERATION_FIELDS, - delete: ['autoStart', 'cronEnabled', 'cronExpression', 'watchEnabled', 'watchDelay', 'source'], + delete: [ + 'autoStart', + 'cronEnabled', + 'cronExpression', + 'watchEnabled', + 'watchDelay', + 'watchChangedOnly', + 'source', + ], copyurl: OPERATION_FIELDS, }; +// Fields patched when populating a profile form (excluding `source`/`dest` which are +// handled separately because their shape varies per operation). +const PROFILE_FORM_FIELDS = [ + 'autoStart', + 'cronEnabled', + 'cronExpression', + 'watchEnabled', + 'watchDelay', + 'watchChangedOnly', + 'vfsProfile', + 'filterProfile', + 'backendProfile', + 'runtimeRemoteProfile', +] as const; + @Injectable() export class RemoteConfigStateService { - private static readonly LINKED_TYPES = new Set(['vfs', 'filter', 'backend', 'runtimeRemote']); - - static readonly AUTO_PROFILE_NAME = 'Default'; - private readonly fb = inject(FormBuilder); private readonly authStateService = inject(AuthStateService); private readonly remoteManagementService = inject(RemoteManagementService); private readonly mountManagementService = inject(MountManagementService); private readonly serveManagementService = inject(ServeManagementService); private readonly flagConfigService = inject(FlagConfigService); - private readonly cliFlagMapper = inject(CliFlagMapperService); private readonly validatorRegistry = inject(ValidatorRegistryService); private readonly iconService = inject(IconService); private readonly destroyRef = inject(DestroyRef); private readonly jobManagementService = inject(JobManagementService); private readonly notificationService = inject(NotificationService); + private readonly pathService = inject(PathService); + private readonly pathInspectionService = inject(PathInspectionService); private readonly translate = inject(TranslateService); - readonly pathService = inject(PathService); private readonly valueMapper = inject(RcloneValueMapperService); private readonly remoteFacade = inject(RemoteFacadeService); private readonly presetsService = inject(RemotePresetsService); private readonly orchestrator = inject(RemoteCreationOrchestrator); + private readonly appSettingsService = inject(AppSettingsService); private existingConfig?: RemoteSettings | null; readonly remoteForm: FormGroup = this.fb.group({ @@ -128,10 +166,7 @@ export class RemoteConfigStateService { type === 'runtimeRemote' ? this.runtimeRemoteConfigGroup : (this.remoteConfigForm.get(`${type}Config`) as FormGroup); - return [ - type, - toSignal(fg.statusChanges.pipe(startWith(fg.status)), { initialValue: fg.status }), - ]; + return [type, toSignal(fg.statusChanges.pipe(startWith(fg.status)))]; }) ) as Record>; @@ -140,24 +175,20 @@ export class RemoteConfigStateService { } readonly remoteFormStatus = toSignal( - this.remoteForm.statusChanges.pipe(startWith(this.remoteForm.status)), - { initialValue: this.remoteForm.status } + this.remoteForm.statusChanges.pipe(startWith(this.remoteForm.status)) ); readonly remoteConfigFormStatus = toSignal( - this.remoteConfigForm.statusChanges.pipe(startWith(this.remoteConfigForm.status)), - { initialValue: this.remoteConfigForm.status } + this.remoteConfigForm.statusChanges.pipe(startWith(this.remoteConfigForm.status)) ); readonly remoteTypeSignal = toSignal( this.remoteForm.controls['type'].valueChanges.pipe( startWith(this.remoteForm.controls['type'].value as string) - ), - { initialValue: this.remoteForm.controls['type'].value as string } + ) ); readonly remoteNameSignal = toSignal( this.remoteForm.controls['name'].valueChanges.pipe( startWith(this.remoteForm.controls['name'].value as string) - ), - { initialValue: this.remoteForm.controls['name'].value as string } + ) ); readonly editTarget = signal(null); @@ -165,8 +196,8 @@ export class RemoteConfigStateService { readonly editStack = signal[]>([]); readonly currentStep = signal(1); readonly isInitializing = signal(true); - readonly showCliImport = signal(false); readonly showObscureTool = signal(false); + readonly showCliImport = signal(false); readonly isSearchVisible = signal(false); readonly searchQuery = signal(''); readonly showAdvancedOptions = signal(false); @@ -175,7 +206,6 @@ export class RemoteConfigStateService { readonly isAuthInProgress = this.authStateService.isAuthInProgress; readonly isAuthCancelled = this.authStateService.isAuthCancelled; readonly oauthUrl = this.authStateService.oauthUrl; - readonly shouldShowRemoteOAuthFallback = this.authStateService.shouldShowRemoteOAuthFallback; readonly interactiveFlowState = this.orchestrator.interactiveFlowState; readonly isRemoteConfigLoading = signal(false); @@ -192,23 +222,22 @@ export class RemoteConfigStateService { readonly dynamicServeFields = signal([]); readonly dynamicRuntimeRemoteFields = signal([]); readonly dynamicFlagFields = signal>( - Object.fromEntries(FLAG_TYPES.map(t => [t, []])) as any + this.emptyFlagFieldsRecord() ); - readonly lookupTable = computed(() => - this.cliFlagMapper.buildLookupTable( - { ...this.dynamicFlagFields(), runtimeRemote: this.dynamicRuntimeRemoteFields() }, - this.remoteTypeSignal() || undefined - ) - ); + private emptyFlagFieldsRecord(): Record { + return Object.fromEntries( + FLAG_TYPES.map(t => [t, [] as RcConfigOption[]]) + ) as unknown as Record; + } - readonly PROFILE_TYPES: SharedProfileType[] = [...FLAG_TYPES, 'runtimeRemote']; - readonly JOB_TYPES = new Set(SYNC_TYPES); + readonly PROFILE_TYPES: readonly SharedProfileType[] = PROFILE_TYPES; + readonly JOB_TYPES: ReadonlySet = JOB_TYPES; readonly profileState = signal( this.profileRecord(() => ({ mode: 'view' as 'view' | 'edit' | 'add', tempName: '' })) ); - readonly profiles = signal(this.profileRecord(() => ({}) as Record)); + readonly profiles = signal(this.profileRecord(() => ({}) as ProfileConfigMap)); readonly selectedProfileName = signal(this.profileRecord(() => null as string | null)); readonly highlightedFields = signal< { controlKey: string; flagType: SharedProfileType; profileName: string }[] @@ -224,27 +253,19 @@ export class RemoteConfigStateService { }; }); - readonly hasAnyProfile = computed(() => { - const map = this.profileNamesMap(); - return Object.fromEntries( - this.PROFILE_TYPES.map(t => [t, (map[t]?.length ?? 0) > 0]) - ) as Record; - }); - - readonly profileLists = computed( - () => - Object.fromEntries( - this.PROFILE_TYPES.map(t => [ - t, - Object.entries(this.profiles()[t] ?? {}).map(([name, data]) => ({ name, ...data })), - ]) - ) as any + readonly profileLists = computed(() => + this.profileRecord(t => + Object.entries(this.profiles()[t] ?? {}).map(([name, data]) => ({ + name, + ...((data && typeof data === 'object' ? (data as Record) : {}) as Record< + string, + unknown + >), + })) + ) ); - readonly profileNamesMap = computed( - () => - Object.fromEntries( - this.PROFILE_TYPES.map(t => [t, Object.keys(this.profiles()[t] ?? {})]) - ) as any + readonly profileNamesMap = computed(() => + this.profileRecord(t => Object.keys(this.profiles()[t] ?? {})) ); readonly highlightedFieldsForActiveProfiles = computed(() => { const active = new Set(); @@ -255,16 +276,19 @@ export class RemoteConfigStateService { return active; }); - private profileRecord(factory: () => T): Record { - return Object.fromEntries(this.PROFILE_TYPES.map(t => [t, factory()])) as any; + private profileRecord(factory: (type: SharedProfileType) => T): Record { + return Object.fromEntries(this.PROFILE_TYPES.map(t => [t, factory(t)] as const)) as Record< + SharedProfileType, + T + >; } readonly changedRemoteFields = new Set(); readonly isPopulatingForm = signal(false); readonly dirtyProfileTypes = new Set(); - dialogData!: DialogData; + private readonly dialogData = signal({ remoteType: '' }); - private profilePopulateGenerations = new Map(); + private readonly profilePopulateGenerations = new Map(); private getGeneration(type: string): number { return this.profilePopulateGenerations.get(type) || 0; @@ -277,7 +301,7 @@ export class RemoteConfigStateService { } readonly currentRemoteName = computed( - () => this.dialogData?.name || this.remoteNameSignal() || '' + () => this.dialogData().name || this.remoteNameSignal() || '' ); readonly stepConfigs = computed(() => [ { @@ -293,8 +317,13 @@ export class RemoteConfigStateService { { label: 'modals.remoteConfig.steps.runtimeRemote', icon: 'gear', type: 'runtimeRemote' }, ]); + readonly isEditingExisting = computed(() => { + const data = this.dialogData(); + return !!(data.name || data.cloneFrom); + }); + readonly editTargetStepKey = computed(() => - this.editTarget() + this.editTarget() && (this.isEditingExisting() || this.editTarget() !== 'remote') ? `modals.remoteConfig.steps.${this.editTarget() === 'remote' ? 'remoteConfig' : this.editTarget()}` : null ); @@ -303,41 +332,28 @@ export class RemoteConfigStateService { return !t || t === 'remote' ? null : (t as SharedProfileType); }); - readonly activeSensitiveFields = computed(() => { - const stepType = this.activeStepType(); - if (!stepType) return []; + private getFieldsForStep(stepType: NonNullable): RcConfigOption[] { + if (stepType === 'remote') return this.dynamicRemoteFields(); + if (stepType === 'runtimeRemote') return this.dynamicRuntimeRemoteFields(); + if (stepType === 'serve') return this.dynamicServeFields(); + return this.dynamicFlagFields()[stepType] ?? []; + } - let fields: RcConfigOption[]; - if (stepType === 'remote') { - fields = this.dynamicRemoteFields(); - } else if (stepType === 'runtimeRemote') { - fields = this.dynamicRuntimeRemoteFields(); - } else if (stepType === 'serve') { - fields = this.dynamicServeFields(); - } else { - fields = (this.dynamicFlagFields() as any)[stepType] || []; + private getGroupForStep(stepType: NonNullable): FormGroup | null { + if (stepType === 'remote') return this.remoteForm; + if (stepType === 'runtimeRemote') return this.runtimeRemoteConfigGroup; + if (stepType === 'serve') { + return this.remoteConfigForm.get('serveConfig.options') as FormGroup | null; } + return this.remoteConfigForm.get(`${stepType}Config.options`) as FormGroup | null; + } - if (!fields) return []; + readonly activeSensitiveFields = computed(() => { + const stepType = this.activeStepType(); + if (!stepType) return []; - return fields - .filter(field => { - const name = (field.FieldName || field.Name || '').toLowerCase(); - return ( - field.IsPassword || - field.Name === 'pass' || - SENSITIVE_KEYS.some(key => name.includes(key)) - ); - }) - .map(field => { - const key = getControlKey(field, stepType); - const name = field.FieldName || field.Name || ''; - return { - key, - name, - help: field.Help || '', - }; - }); + const fields = this.getFieldsForStep(stepType); + return this.valueMapper.extractSensitiveFields(fields); }); readonly activeStepType = computed( () => @@ -347,7 +363,7 @@ export class RemoteConfigStateService { const t = this.activeStepType(); return !t || t === 'remote' ? false : this.isStepInvalid(t); }); - readonly isBackDisabled = computed(() => this.isAuthInProgress?.() ?? false); + readonly isBackDisabled = computed(() => this.isAuthInProgress()); readonly sharedReturnTarget = computed(() => this.editStack().at(-1) || null); readonly sharedSidebarTypes = computed(() => { @@ -370,11 +386,11 @@ export class RemoteConfigStateService { }); readonly isStepNavigationLocked = computed( - () => (this.isAuthInProgress?.() ?? false) || this.isRemoteConfigLoading() + () => this.isAuthInProgress() || this.isRemoteConfigLoading() ); readonly applicableSteps = computed(() => { const t = this.editTarget(); - if (!t || t === 'remote') return this.stepConfigs().map((_, i) => i + 1); + if (!t) return this.stepConfigs().map((_, i) => i + 1); const idx = this.stepConfigs().findIndex(s => s.type === t); return idx !== -1 ? [idx + 1] : [1]; }); @@ -414,33 +430,31 @@ export class RemoteConfigStateService { if (this.isStepNavigationLocked()) return false; if (step > this.currentStep()) { if (this.isActiveStepInvalid()) return false; - if (!this.editTarget() && this.remoteFormStatus?.() === 'INVALID') return false; + if (!this.editTarget() && this.remoteFormStatus() === 'INVALID') return false; } return true; } readonly oauthHelperUrl = this.orchestrator.oauthHelperUrl; readonly isNextDisabled = computed(() => { - if (this.isAuthInProgress?.()) return true; - if (this.currentStep() === 1) return this.remoteFormStatus?.() === 'INVALID'; + if (this.isAuthInProgress()) return true; + if (this.currentStep() === 1) return this.remoteFormStatus() === 'INVALID'; const type = this.stepConfigs()[this.currentStep() - 1]?.type; return type && type !== 'remote' ? this.isStepInvalid(type) : false; }); readonly isSaveDisabled = computed(() => { - if (this.isAuthInProgress?.()) return true; + if (this.isAuthInProgress()) return true; const t = this.editTarget(); if (!t) - return ( - this.remoteFormStatus?.() === 'INVALID' || this.remoteConfigFormStatus?.() === 'INVALID' - ); - return t === 'remote' ? this.remoteFormStatus?.() === 'INVALID' : this.isStepInvalid(t); + return this.remoteFormStatus() === 'INVALID' || this.remoteConfigFormStatus() === 'INVALID'; + return t === 'remote' ? this.remoteFormStatus() === 'INVALID' : this.isStepInvalid(t); }); readonly isInteractiveContinueDisabled = this.orchestrator.isInteractiveContinueDisabled; readonly saveButtonLabel = computed(() => - this.editTarget() ? 'modals.remoteConfig.buttons.save' : 'modals.remoteConfig.buttons.create' + this.isEditingExisting() ? 'common.save' : 'common.create' ); isStepInvalid(stepType: string): boolean { @@ -481,16 +495,16 @@ export class RemoteConfigStateService { cronEnabled: [false], cronExpression: [null], source: this.createSourcePathGroup(), - vfsProfile: [RemoteConfigStateService.AUTO_PROFILE_NAME], - filterProfile: [RemoteConfigStateService.AUTO_PROFILE_NAME], - backendProfile: [RemoteConfigStateService.AUTO_PROFILE_NAME], - runtimeRemoteProfile: [RemoteConfigStateService.AUTO_PROFILE_NAME], + vfsProfile: [AUTO_PROFILE_NAME], + filterProfile: [AUTO_PROFILE_NAME], + backendProfile: [AUTO_PROFILE_NAME], + runtimeRemoteProfile: [AUTO_PROFILE_NAME], options: this.fb.group({}), }); } private createConfigGroup(flagType: string, fields: readonly string[]): FormGroup { - const group: Record = {}; + const group: Record = {}; for (const f of fields) { if (f in FIELD_DEFAULTS) group[f] = [FIELD_DEFAULTS[f]]; else if (f !== 'source' && f !== 'dest') group[f] = ['']; @@ -511,13 +525,13 @@ export class RemoteConfigStateService { }); if (fields.includes('autoStart') && !fields.includes('type')) group['cronExpression'] = [null]; if (LINKED_PROFILE_TYPES.has(flagType)) { - group['vfsProfile'] = [RemoteConfigStateService.AUTO_PROFILE_NAME]; - group['filterProfile'] = [RemoteConfigStateService.AUTO_PROFILE_NAME]; - group['backendProfile'] = [RemoteConfigStateService.AUTO_PROFILE_NAME]; - group['runtimeRemoteProfile'] = [RemoteConfigStateService.AUTO_PROFILE_NAME]; + group['vfsProfile'] = [AUTO_PROFILE_NAME]; + group['filterProfile'] = [AUTO_PROFILE_NAME]; + group['backendProfile'] = [AUTO_PROFILE_NAME]; + group['runtimeRemoteProfile'] = [AUTO_PROFILE_NAME]; } group['options'] = this.fb.group({}); - return this.fb.group(group); + return this.fb.group(group as Record[0][string]>); } addDynamicFieldsToForm(): void { @@ -527,7 +541,7 @@ export class RemoteConfigStateService { if (!optGroup || !fields[type]) continue; this.syncDynamicControls(optGroup, fields[type], { clearExisting: false, - keyFn: (f): string => getControlKey(f, type), + keyFn: (f): string => f.Name || f.FieldName, }); } } @@ -552,7 +566,7 @@ export class RemoteConfigStateService { if (!g) return; this.syncDynamicControls(g, this.dynamicServeFields(), { preserveKeys: new Set(['type']), - keyFn: (f): string => getControlKey(f, 'serve'), + keyFn: (f): string => f.Name || f.FieldName, skipField: (f): boolean => f.FieldName === 'type' || f.Name === 'type', ensureControls: [{ key: 'type', control: new FormControl('http') }], }); @@ -594,6 +608,7 @@ export class RemoteConfigStateService { for (const f of fields) { if (skipField(f)) continue; const key = keyFn(f); + if (group.contains(key)) continue; group.addControl( key, useValidators @@ -603,78 +618,59 @@ export class RemoteConfigStateService { } } - private cleanData( - formData: Record, - fields: RcConfigOption[], - type?: string - ): Record { - const map = new Map(fields.map(f => [getControlKey(f, type), f])); - return Object.entries(formData).reduce((acc, [k, v]) => { - const f = map.get(k); - if (f) { - if (!this.valueMapper.isDefaultValue(v, f)) - acc[ - type === 'serve' || type === 'cryptcheck' || type === 'archivecreate' - ? f.Name || f.FieldName - : f.FieldName - ] = v; - } else if (v !== undefined && v !== null && v !== '') acc[k] = v; - return acc; - }, {} as any); - } - - getRuntimeRemoteOptions(remoteName: string, config: any): Record { - return config[remoteName] && - typeof config[remoteName] === 'object' && - !Array.isArray(config[remoteName]) - ? config[remoteName] + private getRuntimeRemoteOptions( + remoteName: string, + config: Record + ): Record { + const scoped = config[remoteName]; + return scoped && typeof scoped === 'object' && !Array.isArray(scoped) + ? (scoped as Record) : config; } - buildProfileConfig( + private buildProfileConfig( type: SharedProfileType, remoteName: string, - configData: Record - ): Record { + configData: Record + ): Record { if (type === 'runtimeRemote') { - const opts = this.dynamicRuntimeRemoteFields().reduce((acc, f) => { + const opts = this.dynamicRuntimeRemoteFields().reduce>((acc, f) => { if ( Object.prototype.hasOwnProperty.call(configData, f.Name) && !this.valueMapper.isDefaultValue(configData[f.Name], f) ) - acc[f.FieldName || f.Name] = configData[f.Name]; + acc[f.Name || f.FieldName] = configData[f.Name]; return acc; - }, {} as any); + }, {}); return { [remoteName]: opts }; } - if (['vfs', 'filter', 'backend'].includes(type)) - return this.cleanData( - configData['options'] || {}, - this.dynamicFlagFields()[type as FlagType] || [], - type + if (type === 'vfs' || type === 'filter' || type === 'backend') + return this.valueMapper.cleanData( + (configData['options'] as Record) || {}, + this.getFieldsForStep(type) ); return mapFormToConfigProfile(type, configData, { remoteName, pathService: this.pathService, runtimeRemoteProfileNames: this.profileOptions().runtimeRemote, - cleanData: (opts, fields) => this.cleanData(opts, fields, type), - dynamicFields: - type === 'serve' - ? this.dynamicServeFields() - : this.dynamicFlagFields()[type as FlagType] || [], - flatOptionNames: new Set((staticFlagDefinitions[type] || []).map(f => f.FieldName || f.Name)), + cleanData: (opts, fields) => this.valueMapper.cleanData(opts, fields), + dynamicFields: this.getFieldsForStep(type), + flatOptionNames: new Set((staticFlagDefinitions[type] || []).map(f => f.Name || f.FieldName)), }); } - cleanFormData(formData: Record): PendingRemoteData { + cleanFormData(formData: Record): PendingRemoteData { const map = new Map(this.dynamicRemoteFields().map(f => [f.Name, f])); - const res: PendingRemoteData = { name: formData['name'], type: formData['type'] }; + const res: PendingRemoteData = { + name: formData['name'] as string, + type: formData['type'] as string, + }; for (const [k, v] of Object.entries(formData)) { if (k === 'name' || k === 'type') continue; const f = map.get(k); if (f) { if (!this.valueMapper.isDefaultValue(v, f) || this.changedRemoteFields.has(k)) - res[f.FieldName || k] = v; + res[f.Name || k] = v; } else if (v !== null && v !== undefined && v !== '') res[k] = v; } return res; @@ -694,30 +690,38 @@ export class RemoteConfigStateService { const pathCtrl = dstCtrl.get('path'); if (pathCtrl && pathCtrl.pristine) { - const generation = this.bumpGeneration(type); - void this.pathService.resolveDefaultPath(rName, type).then(defaultPath => { - if (generation !== this.getGeneration(type)) return; - if (pathCtrl.pristine) { - dstCtrl.patchValue({ type: 'local', path: defaultPath }); - } - }); + this.runPathResolve(type, rName, dstCtrl, pathCtrl); } } }); } - async init(dialogData: DialogData): Promise { - this.dialogData = dialogData; + private runPathResolve( + type: 'mount' | 'bisync', + remoteName: string, + dstCtrl: FormGroup, + pathCtrl: unknown + ): void { + const token = ++this.pathResolveTokens[type]; + this.pathInspectionService + .resolveDefaultPath(remoteName, type) + .then(defaultPath => { + if (token !== this.pathResolveTokens[type]) return; + if ((pathCtrl as { pristine: boolean }).pristine) { + dstCtrl.patchValue({ type: 'local', path: defaultPath }); + } + }) + .catch(err => console.warn(`[RemoteConfigState] resolveDefaultPath(${type}) failed:`, err)); + } + private readonly pathResolveTokens: Record<'mount' | 'bisync', number> = { mount: 0, bisync: 0 }; + + async init(dialogData: DialogData | undefined): Promise { + this.dialogData.set(dialogData ?? { remoteType: '' }); this.editTarget.set(dialogData?.editTarget || null); this.cloneTarget.set(!!dialogData?.cloneFrom); - const remotesLoadPromise = - dialogData?.cloneFrom || dialogData?.name - ? this.remoteFacade.loadRemotes() - : Promise.resolve(); - await Promise.all([ - remotesLoadPromise, + this.remoteFacade.loadRemotes(), this.loadExistingRemotes(), this.loadRemoteTypes(), this.loadMountTypes(), @@ -728,15 +732,17 @@ export class RemoteConfigStateService { if (dialogData?.cloneFrom) { this.existingConfig = await this.remoteFacade.cloneRemote(dialogData.cloneFrom); - } else if (dialogData?.name) { + } else if (dialogData?.name !== undefined && dialogData?.name !== null) { this.existingConfig = { config: this.remoteFacade.activeRemotes().find(r => r.name === dialogData.name)?.config, ...this.remoteFacade.getRemoteSettings(dialogData.name), }; + } else { + this.existingConfig = null; } this.refreshRemoteNameValidator(); - this.initProfiles(this.dialogData, this.dialogData?.autoAddProfile, this.editTarget() as any); + this.initProfiles(this.dialogData(), this.dialogData()?.autoAddProfile, this.editTarget()); this.initCurrentStep(); await this.populateFormIfEditingOrCloning(); @@ -749,39 +755,68 @@ export class RemoteConfigStateService { } private async loadExistingRemotes(): Promise { - try { - this.existingRemotes.set(await this.remoteManagementService.getRemotes()); - this.refreshRemoteNameValidator(); - } catch (e) { - console.error(e); - } + await this.safeLoad( + () => this.remoteManagementService.getRemotes(), + value => { + this.existingRemotes.set(value); + this.refreshRemoteNameValidator(); + } + ); } + private async loadRemoteTypes(): Promise { - try { - this.remoteTypes.set( - (await this.remoteManagementService.getRemoteTypes()).map(p => ({ - value: p.name, - label: p.description, - })) - ); - } catch (e) { - console.error(e); - } + await this.safeLoad( + () => this.remoteManagementService.getRemoteTypes(), + value => { + this.remoteTypes.set(value.map(p => ({ value: p.name, label: p.description }))); + } + ); } + private async loadMountTypes(): Promise { + await this.safeLoad( + () => this.mountManagementService.getMountTypes(), + value => this.mountTypes.set(value) + ); + } + + private async loadServeTypes(): Promise { + await this.safeLoad( + () => this.serveManagementService.getServeTypes(), + value => { + this.availableServeTypes.set(value); + if (value.length) this.selectedServeType.set(value[0]); + } + ); + } + + private async safeLoad( + loader: () => Promise, + onSuccess: (value: T) => void + ): Promise { try { - this.mountTypes.set(await this.mountManagementService.getMountTypes()); + onSuccess(await loader()); } catch (e) { console.error(e); } } - private async loadServeTypes(): Promise { + + private async cancellableLoad( + tokenSlot: { token: number }, + loader: () => Promise, + onSuccess: (value: T) => void, + loadingSignal: { set(v: boolean): void } + ): Promise { + const token = ++tokenSlot.token; + loadingSignal.set(true); try { - this.availableServeTypes.set(await this.serveManagementService.getServeTypes()); - if (this.availableServeTypes().length) - this.selectedServeType.set(this.availableServeTypes()[0]); + const value = await loader(); + if (token !== tokenSlot.token) return; + onSuccess(value); } catch (e) { console.error(e); + } finally { + if (token === tokenSlot.token) loadingSignal.set(false); } } @@ -806,42 +841,41 @@ export class RemoteConfigStateService { private async loadServeFields(): Promise { const t = this.selectedServeType(); if (!t) return; - this.isLoadingServeFields.set(true); - try { - const fields = await this.flagConfigService.loadServeFlagFields(t); - const opt = fields.find(f => f.Name === 'type'); - if (opt) - opt.Examples = this.availableServeTypes().map(type => ({ - Value: type, - Help: this.translate.instant(`serve_type_${type}.title`) || type, - })); - this.dynamicServeFields.set(fields); - this.rebuildServeOptionsGroup(); - } catch (e) { - console.error(e); - } finally { - this.isLoadingServeFields.set(false); - } + await this.cancellableLoad( + this._serveLoadToken, + () => this.flagConfigService.loadServeFlagFields(t), + fields => { + const opt = fields.find(f => f.Name === 'type'); + if (opt) + opt.Examples = this.availableServeTypes().map(type => ({ + Value: type, + Help: this.translate.instant(`serve_type_${type}.title`) || type, + })); + this.dynamicServeFields.set(fields); + this.rebuildServeOptionsGroup(); + }, + this.isLoadingServeFields + ); } + private readonly _serveLoadToken = { token: 0 }; private async loadRuntimeRemoteFields(type: string): Promise { if (!type) return; - this.isLoadingRuntimeRemoteFields.set(true); - try { - this.dynamicRuntimeRemoteFields.set( - await this.remoteManagementService.getRemoteConfigFields(type) - ); - this.replaceRuntimeRemoteFormControls(); - } catch (e) { - console.error(e); - } finally { - this.isLoadingRuntimeRemoteFields.set(false); - } + await this.cancellableLoad( + this._runtimeRemoteLoadToken, + () => this.remoteManagementService.getRemoteConfigFields(type), + fields => { + this.dynamicRuntimeRemoteFields.set(fields); + this.replaceRuntimeRemoteFormControls(); + }, + this.isLoadingRuntimeRemoteFields + ); } + private readonly _runtimeRemoteLoadToken = { token: 0 }; async syncRuntimeRemoteType(): Promise { const type = String( - this.remoteForm.get('type')?.value || this.dialogData?.remoteType || '' + this.remoteForm.get('type')?.value || this.dialogData().remoteType || '' ).trim(); this.runtimeRemoteConfigGroup.get('type')?.setValue(type, { emitEvent: false }); if (!type) this.dynamicRuntimeRemoteFields.set([]); @@ -899,8 +933,8 @@ export class RemoteConfigStateService { isNewRemoteCreation(): boolean { return ( - !this.dialogData?.name && - !this.dialogData?.cloneFrom && + !this.dialogData().name && + !this.dialogData().cloneFrom && !this.editTarget() && !this.cloneTarget() ); @@ -910,37 +944,39 @@ export class RemoteConfigStateService { const vendor = this.remoteForm.get('vendor')?.value; const preset = this.presetsService.resolvePresets(remoteType, vendor); - // 1. Patch the currently-selected VFS profile - if (preset.vfs) { - const selected = this.selectedProfileName()['vfs']; - if (selected) { - this.profiles.update(p => ({ - ...p, - vfs: { - ...p.vfs, - [selected]: { ...p.vfs[selected], ...preset.vfs }, - }, - })); - } - } + const patchProfile = ( + type: SharedProfileType, + overrides: Record | undefined + ): void => { + if (!overrides || !Object.keys(overrides).length) return; + const selected = this.selectedProfileName()[type]; + if (!selected) return; + const current = this.readProfileRecord(type, selected); + this.profiles.update(p => ({ + ...p, + [type]: { ...p[type], [selected]: { ...current, ...overrides } }, + })); + }; - // 2. Patch the currently-selected mount profile's options - if (preset.mount && Object.keys(preset.mount).length) { + patchProfile('vfs', preset.vfs); + patchProfile('backend', preset.backend); + + if (preset.mount) { + const { mountType, ...otherMountOpts } = preset.mount; const selected = this.selectedProfileName()['mount']; if (selected) { - const currentMount = this.profiles().mount[selected] || {}; - const rclone = currentMount.rclone || {}; - const { mountType, ...otherMountOpts } = preset.mount; + const current = this.readProfileRecord('mount', selected); + const rclone = (current['rclone'] as Record | undefined) ?? {}; this.profiles.update(p => ({ ...p, mount: { ...p.mount, [selected]: { - ...currentMount, + ...current, rclone: { ...rclone, ...(mountType ? { mountType: mountType as string } : {}), - mountOpt: { ...rclone.mountOpt, ...otherMountOpts }, + ...otherMountOpts, }, }, }, @@ -948,53 +984,35 @@ export class RemoteConfigStateService { } } - // 3. Patch the currently-selected backend profile - if (preset.backend) { - const selected = this.selectedProfileName()['backend']; - if (selected) { - this.profiles.update(p => ({ - ...p, - backend: { - ...p.backend, - [selected]: { ...p.backend[selected], ...preset.backend }, - }, - })); - } - } - - // 4. Patch remote-specific config options if (preset.remote) { this.remoteForm.patchValue(preset.remote, { emitEvent: false }); - for (const key of Object.keys(preset.remote)) { - this.onRemoteFieldChanged(key, true); - } + for (const key of Object.keys(preset.remote)) this.onRemoteFieldChanged(key, true); } - // 5. Sync active profile forms with updated profile presets + // Re-sync active profile forms so they reflect the patched presets. for (const flagType of this.PROFILE_TYPES) { const activeProfile = this.selectedProfileName()[flagType]; if (!activeProfile) continue; - const profileData = this.profiles()[flagType]?.[activeProfile]; - if (profileData) { - void this.populateProfileForm(flagType, profileData); - } + const profileData = this.readProfileRecord(flagType, activeProfile); + if (profileData) void this.populateProfileForm(flagType, profileData); } } - initProfiles( - dialogData: DialogData, - autoAddProfile?: boolean, - editTarget?: SharedProfileType - ): void { + private readProfileRecord(type: SharedProfileType, name: string): Record { + const p = this.profiles()[type]?.[name]; + return p && typeof p === 'object' ? (p as Record) : {}; + } + + initProfiles(dialogData: DialogData, autoAddProfile?: boolean, editTarget?: EditTarget): void { const newProfiles = { ...this.profiles() }, newSelected = { ...this.selectedProfileName() }; for (const type of this.PROFILE_TYPES) { const val = this.existingConfig?.[REMOTE_CONFIG_KEYS[type as keyof typeof REMOTE_CONFIG_KEYS]]; - const hasExisting = val && Object.keys(val).length > 0; + const hasExisting = !!val && typeof val === 'object' && Object.keys(val).length > 0; newProfiles[type] = hasExisting - ? ({ ...val } as Record) - : ({ [RemoteConfigStateService.AUTO_PROFILE_NAME]: {} } as Record); + ? { ...(val as ProfileConfigMap) } + : { [AUTO_PROFILE_NAME]: {} }; newSelected[type] = dialogData?.targetProfile && Object.keys(newProfiles[type]).includes(dialogData.targetProfile) @@ -1008,7 +1026,12 @@ export class RemoteConfigStateService { this.applyPresets(dialogData.remoteType); } - if (autoAddProfile && editTarget && this.PROFILE_TYPES.includes(editTarget)) + if ( + autoAddProfile && + editTarget && + editTarget !== 'remote' && + this.PROFILE_TYPES.includes(editTarget) + ) this.startAddProfile(editTarget); } @@ -1018,21 +1041,21 @@ export class RemoteConfigStateService { action: 'rename' | 'delete' ): { disabled: boolean; reason: string } { const t = type as SharedProfileType; - if ( - (action === 'rename' && !this.JOB_TYPES.has(t)) || - (action === 'delete' && !this.JOB_TYPES.has(t) && t !== 'mount' && t !== 'serve') || - !this.currentRemoteName() - ) - return { disabled: false, reason: '' }; + const isJob = this.JOB_TYPES.has(t); + const isInUseCheckable = + (action === 'rename' && isJob) || + (action === 'delete' && (isJob || t === 'mount' || t === 'serve')); + + if (!isInUseCheckable || !this.currentRemoteName()) return { disabled: false, reason: '' }; + const usage = this.getProfileUsage(t, profileName); - return usage.inUse - ? { - disabled: true, - reason: this.translate.instant('modals.remoteConfig.profile.disabledReason.inUse', { - operation: this.JOB_TYPES.has(t) ? `${t} job` : t, - }), - } - : { disabled: false, reason: '' }; + if (!usage.inUse) return { disabled: false, reason: '' }; + return { + disabled: true, + reason: this.translate.instant('modals.remoteConfig.profile.disabledReason.inUse', { + operation: isJob ? `${t} job` : t, + }), + }; } getProfileActionState( @@ -1048,10 +1071,7 @@ export class RemoteConfigStateService { }; } - getProfileUsage( - type: SharedProfileType, - name: string - ): { inUse: boolean; count: number; opType: string } { + private getProfileUsage(type: SharedProfileType, name: string): ProfileUsage { const r = this.currentRemoteName(); if (this.JOB_TYPES.has(type)) { const j = this.jobManagementService.getActiveJobsForRemote(r, name); @@ -1225,21 +1245,76 @@ export class RemoteConfigStateService { async selectProfile(type: EditTarget, name: string): Promise { if (!type) return; const t = type as SharedProfileType; - if (!this.profiles()[t]?.[name]) return; + const profile = this.profiles()[t]?.[name]; + if (!profile) return; const curr = this.selectedProfileName()[t]; - if (curr && this.profiles()[t]?.[curr]) this.saveCurrentProfile(t); + if (curr && curr !== name && this.profiles()[t]?.[curr]) this.saveCurrentProfile(t); this.selectedProfileName.update(p => ({ ...p, [t]: name })); - await this.populateProfileForm(t, this.profiles()[t][name]); + await this.populateProfileForm(t, profile as Record); } - async selectLinkedProfile(type: SharedProfileType, name: string): Promise { + private async selectLinkedProfile(type: SharedProfileType, name: string): Promise { const n = this.profileNamesMap()[type]?.includes(name) ? name : (this.profileNamesMap()[type]?.[0] ?? null); this.selectedProfileName.update(p => ({ ...p, [type]: n })); if (n) { const c = this.profiles()[type]?.[n]; - if (c) await this.populateProfileForm(type, c); + if (c && typeof c === 'object') { + await this.populateProfileForm(type, c as Record); + } + } + } + + async saveRemoteProfiles( + targetRemote?: string, + activeType?: SharedProfileType + ): Promise { + const remote = targetRemote || this.currentRemoteName(); + if (!remote) { + this.notificationService.showWarning( + this.translate.instant('modals.remoteConfig.warnings.selectRemoteToSave') || + 'Please select a remote to save profiles.' + ); + return false; + } + + if (activeType) { + this.saveCurrentProfile(activeType); + this.dirtyProfileTypes.add(activeType); + } else { + this.PROFILE_TYPES.forEach(t => this.saveCurrentProfile(t)); + } + + const profiles = this.profiles(); + const updatedConfig: Record = {}; + for (const [type, key] of Object.entries(REMOTE_CONFIG_KEYS)) { + if (profiles[type as SharedProfileType]) { + updatedConfig[key] = profiles[type as SharedProfileType]; + } + } + + try { + await this.appSettingsService.saveRemoteSettings(remote, updatedConfig); + await this.remoteFacade.loadRemotes(); + try { + await this.pathInspectionService.createRequiredDirectories(updatedConfig); + } catch (err) { + console.error('Failed to create required directories:', err); + } + const selName = (activeType && this.selectedProfileName()[activeType]) || 'Default'; + this.notificationService.showSuccess( + this.translate.instant('modals.remoteConfig.profileSaved', { profile: selName, remote }) || + `Profile "${selName}" saved for remote "${remote}"` + ); + return true; + } catch (e) { + console.error('Failed to save profile settings:', e); + this.notificationService.showError( + this.translate.instant('modals.remoteConfig.profileSaveFailed') || + 'Failed to save profile settings' + ); + return false; } } @@ -1285,7 +1360,7 @@ export class RemoteConfigStateService { } async onServeTypeChange(type: string): Promise { - if (this.selectedServeType() === type && this.dynamicServeFields().length) return; + if (this.selectedServeType() === type) return; this.selectedServeType.set(type || 'http'); this.remoteConfigForm.get('serveConfig.options.type')?.setValue(type, { emitEvent: false }); await this.loadServeFields(); @@ -1301,25 +1376,26 @@ export class RemoteConfigStateService { const activeProfile = this.selectedProfileName()[flagType]; if (!activeProfile) continue; const profileData = this.profiles()[flagType]?.[activeProfile]; - if (profileData) { - await this.populateProfileForm(flagType, profileData); + if (profileData && typeof profileData === 'object') { + await this.populateProfileForm(flagType, profileData as Record); } } } } private async loadRemoteFields(type: string): Promise { - this.isRemoteConfigLoading.set(true); this.dynamicRemoteFields.set([]); - try { - this.dynamicRemoteFields.set(await this.remoteManagementService.getRemoteConfigFields(type)); - this.replaceDynamicFormControls(); - } catch (e) { - console.error(e); - } finally { - this.isRemoteConfigLoading.set(false); - } + await this.cancellableLoad( + this._remoteFieldsLoadToken, + () => this.remoteManagementService.getRemoteConfigFields(type), + fields => { + this.dynamicRemoteFields.set(fields); + this.replaceDynamicFormControls(); + }, + this.isRemoteConfigLoading + ); } + private readonly _remoteFieldsLoadToken = { token: 0 }; onRemoteFieldChanged(name: string, changed: boolean): void { if (!this.isPopulatingForm()) { @@ -1337,54 +1413,48 @@ export class RemoteConfigStateService { const activeProfile = this.selectedProfileName()[flagType]; if (!activeProfile) continue; const profileData = this.profiles()[flagType]?.[activeProfile]; - if (profileData) { - void this.populateProfileForm(flagType, profileData); + if (profileData && typeof profileData === 'object') { + void this.populateProfileForm(flagType, profileData as Record); } } } } } - toggleCliImportVisibility(): void { - if (this.currentStep() !== 1 || this.editTarget()) { - this.showCliImport.update(v => !v); - if (this.showCliImport()) { - this.showObscureTool.set(false); - } - } - } - toggleObscureToolVisibility(): void { this.showObscureTool.update(v => !v); - if (this.showObscureTool()) { - this.showCliImport.set(false); - } + if (this.showObscureTool()) this.showCliImport.set(false); + } + + toggleCliImportVisibility(): void { + this.showCliImport.update(v => !v); + if (this.showCliImport()) this.showObscureTool.set(false); } applyObscuredValue(controlKey: string, value: string): void { const stepType = this.activeStepType(); if (!stepType) return; - let group: FormGroup | null; - if (stepType === 'remote') { - group = this.remoteForm; - } else if (stepType === 'runtimeRemote') { - group = this.runtimeRemoteConfigGroup; - } else if (stepType === 'serve') { - group = this.remoteConfigForm.get('serveConfig.options') as FormGroup; - } else { - group = this.remoteConfigForm.get(`${stepType}Config.options`) as FormGroup; - } + const group = this.getGroupForStep(stepType); + const control = group?.get(controlKey); + if (!control) return; - if (group) { - const control = group.get(controlKey); - if (control) { - control.setValue(value); - control.markAsDirty(); - control.markAsTouched(); - } - } + control.setValue(value); + control.markAsDirty(); + control.markAsTouched(); } + /** + * Applies a parsed CLI import to a profile. Merges paths and selected flags + * into the profile config data BEFORE re-populating the form, so form + * controls are created with the correct values from the start (avoids the + * zoneless issue where patching form controls after creation doesn't reach + * the template). + * + * Handles three modes: + * - 'new': creates a new profile entry with the merged config + * - 'override': replaces an existing profile's config with the merged values + * - 'patch': merges into the currently selected profile + */ async applyImportResult(event: { result: ImportResult; profileName: string; @@ -1394,157 +1464,182 @@ export class RemoteConfigStateService { }): Promise { const { result, profileName, mode, importSourcePath, importDestPath } = event; const targetType = (result.verb || this.editTarget() || 'sync') as SharedProfileType; + const activeProfileName = + mode === 'patch' ? (this.selectedProfileName()[targetType] ?? profileName) : profileName; if (mode === 'new') { this.setProfileMode(targetType, 'view'); - this.profiles.update(p => ({ ...p, [targetType]: { ...p[targetType], [profileName]: {} } })); - await this.selectProfile(targetType, profileName); - } else if (mode === 'override') { - await this.selectProfile(targetType, profileName); + this.ensureProfileEntry(targetType, activeProfileName); + // Seed linked profile entries so import flags targeting vfs/filter/backend/runtimeRemote + // have somewhere to land. + if (LINKED_PROFILE_TYPES.has(targetType)) { + for (const linkedType of LINKED_TYPES) { + this.ensureProfileEntry(linkedType as SharedProfileType, activeProfileName, true); + } + } } - const activeProfileName = - mode === 'patch' ? (this.selectedProfileName()[targetType] ?? profileName) : profileName; + const existing = + mode === 'new' + ? {} + : ((this.profiles()[targetType]?.[activeProfileName] as Record) ?? {}); + + const merged = this.mergeImportIntoConfig( + targetType, + existing, + result, + importSourcePath, + importDestPath + ); + this.profiles.update(p => ({ + ...p, + [targetType]: { ...p[targetType], [activeProfileName]: merged }, + })); + this.mergeLinkedImportFlags(result, targetType, activeProfileName, mode); + if (this.editTarget() && this.editTarget() !== targetType) this.editTarget.set(targetType); const idx = this.stepConfigs().findIndex(s => s.type === targetType); if (idx !== -1) this.currentStep.set(idx + 1); - const group = this.remoteConfigForm.get(`${targetType}Config`) as FormGroup; - if (!group) return; - if (targetType === 'serve' && result.serveSubtype) + // Set serve subtype before populate so the right fields load. + if (targetType === 'serve' && result.serveSubtype) { + this.selectedServeType.set(result.serveSubtype); + } + + // Re-select so populateProfileForm creates controls with the merged values from the start. + await this.selectProfile(targetType, activeProfileName); + + if (targetType === 'serve' && result.serveSubtype) { await this.onServeTypeChange(result.serveSubtype); - if (targetType === 'mount' && result.mountSubtype) - group.get('options.mountType')?.setValue(result.mountSubtype); - - if (result.sourcePath && importSourcePath) { - const srcCtrl = group.get('source'), - parsed = this.pathService.parseFsString( - result.sourcePath, - 'currentRemote', - this.currentRemoteName(), - this.existingRemotes() - ); - if (targetType === 'mount' || targetType === 'serve') { - parsed.type = 'currentRemote'; - parsed.remote = ''; - } - if (srcCtrl instanceof FormArray) { - srcCtrl.clear(); - srcCtrl.push(this.createSourcePathGroup(parsed)); - } else srcCtrl?.patchValue(parsed); } - if (result.destPath && importDestPath) { - const destParsed = this.pathService.parseFsString( - result.destPath, - 'local', - this.currentRemoteName(), - this.existingRemotes() - ); - if (targetType === 'mount') { - destParsed.type = 'local'; - destParsed.remote = ''; - } - group.get('dest')?.patchValue(destParsed); + if (targetType === 'mount' && result.mountSubtype) { + const group = this.remoteConfigForm.get(`${targetType}Config`) as FormGroup; + group?.get('options.mountType')?.setValue(result.mountSubtype); } - const processedLinked = new Set(); for (const cls of result.classified) { if (cls.status !== 'mapped' || !cls.fieldName) continue; - const targetFlagType = (cls.flagType || targetType) as SharedProfileType; - if (targetFlagType === targetType || processedLinked.has(targetFlagType)) continue; - - processedLinked.add(targetFlagType); - const pCtrl = group.get(`${targetFlagType}Profile`); - if (!pCtrl) continue; - const currProfileVal: string | null = pCtrl.value || null; - - if (mode !== 'patch' && !currProfileVal) { - if (!this.profiles()[targetFlagType]?.[profileName]) { - const existingEntries = Object.values(this.profiles()[targetFlagType] ?? {}); - const seedEntry = existingEntries[0] ?? {}; - this.profiles.update(p => ({ - ...p, - [targetFlagType]: { - ...p[targetFlagType], - [profileName]: structuredClone(seedEntry), - }, - })); - } - pCtrl.setValue(profileName); - await this.selectLinkedProfile(targetFlagType, profileName); - } else if (currProfileVal) { - await this.selectLinkedProfile(targetFlagType, currProfileVal); + const flagType = (cls.flagType || targetType) as SharedProfileType; + this.highlightField(cls.fieldName, flagType, activeProfileName); + } + + if (mode === 'new' && LINKED_PROFILE_TYPES.has(targetType)) { + for (const linkedType of LINKED_TYPES) { + this.dirtyProfileTypes.add(linkedType as SharedProfileType); } } + this.dirtyProfileTypes.add(targetType); + + this.showCliImport.set(false); + this.showObscureTool.set(false); + } + + private ensureProfileEntry(type: SharedProfileType, name: string, seedFromFirst = false): void { + if (this.profiles()[type]?.[name]) return; + const seedEntry = + seedFromFirst && Object.values(this.profiles()[type] ?? {})[0] + ? structuredClone(Object.values(this.profiles()[type] ?? {})[0]) + : {}; + this.profiles.update(p => ({ + ...p, + [type]: { ...p[type], [name]: seedEntry as Record }, + })); + } + + private mergeImportIntoConfig( + type: SharedProfileType, + existing: Record, + result: ImportResult, + importSourcePath: boolean, + importDestPath: boolean + ): Record { + // Linked types store options as a flat map. + if (type === 'vfs' || type === 'filter' || type === 'backend') { + return this.mergeFlagsFlat(existing, result, type); + } + + // runtimeRemote scopes options under { [remoteName]: opts }. + if (type === 'runtimeRemote') { + const rName = this.currentRemoteName(); + const opts = + (existing[rName] as Record) ?? (existing as Record); + return { ...existing, [rName]: this.mergeFlagsFlat(opts, result, type) }; + } + + // Operation types store options under { app, rclone }. + const existingApp = (existing['app'] as Record) ?? {}; + const rclone: Record = { + ...((existing['rclone'] as Record) ?? {}), + }; + + const mapping = OPERATION_PATH_MAPPINGS[type]; + if (mapping) { + if (result.sourcePath && importSourcePath) { + rclone[mapping.sourceKey] = mapping.isSourceArray ? [result.sourcePath] : result.sourcePath; + } + if (mapping.destKey && result.destPath && importDestPath) { + rclone[mapping.destKey] = result.destPath; + } + if (type === 'mount' && result.mountSubtype) rclone['mountType'] = result.mountSubtype; + if (type === 'serve' && result.serveSubtype) rclone['type'] = result.serveSubtype; + } - const processedKeys = new Set(); for (const cls of result.classified) { if (cls.status !== 'mapped' || !cls.fieldName) continue; + const flagType = (cls.flagType || type) as SharedProfileType; + if (flagType !== type && LINKED_TYPES.has(flagType)) continue; // linked flags handled by mergeLinkedImportFlags + rclone[cls.fieldName] = cls.coercedValue; + } - const fLower = cls.fieldName.toLowerCase(), - targetFlagType = (cls.flagType || targetType) as SharedProfileType; - const tGroup = this.remoteConfigForm.get(`${targetFlagType}Config`) as FormGroup, - isRuntime = targetFlagType === 'runtimeRemote'; - const tOptGroup = isRuntime ? tGroup : (tGroup?.get('options') as FormGroup); - if (!tOptGroup) continue; - - const fields = isRuntime - ? this.dynamicRuntimeRemoteFields() - : targetFlagType === 'serve' - ? this.dynamicServeFields() - : this.dynamicFlagFields()[targetFlagType as FlagType] || []; - const match = fields.find( - f => f.Name?.toLowerCase() === fLower || f.FieldName?.toLowerCase() === fLower - ); - if (!match) continue; - - const uKey = isRuntime ? match.Name : getControlKey(match, targetFlagType); - const ctrl = tOptGroup.get(uKey); - if (!ctrl) continue; - - if (isArrayType(match.Type)) { - let arr: any[] = []; - if (processedKeys.has(uKey)) { - const cVal = ctrl.value; - arr = Array.isArray(cVal) - ? [...cVal] - : typeof cVal === 'string' && cVal - ? cVal - .split(match.Type === 'SpaceSepList' ? /\s+/ : ',') - .map(v => v.trim()) - .filter(Boolean) - : []; - } - const coerced = cls.coercedValue; - if (Array.isArray(coerced)) - coerced.forEach(v => { - if (!arr.includes(v)) arr.push(v); - }); - else if (coerced != null && coerced !== '') { - if (!arr.includes(coerced)) arr.push(coerced); - } - ctrl.setValue(arr); - } else { - ctrl.setValue(cls.coercedValue); - } + return { ...existing, app: { ...existingApp }, rclone }; + } - ctrl.markAsDirty(); - ctrl.markAsTouched(); - processedKeys.add(uKey); - this.highlightField( - uKey, - targetFlagType, - RemoteConfigStateService.LINKED_TYPES.has(targetFlagType) - ? group.get(`${targetFlagType}Profile`)?.value || activeProfileName - : activeProfileName - ); + private mergeFlagsFlat( + existing: Record, + result: ImportResult, + type: SharedProfileType + ): Record { + const merged: Record = { ...existing }; + for (const cls of result.classified) { + if (cls.status !== 'mapped' || !cls.fieldName) continue; + const flagType = (cls.flagType || type) as SharedProfileType; + if (flagType !== type) continue; + merged[cls.fieldName] = cls.coercedValue; } + return merged; + } - processedLinked.forEach(t => this.dirtyProfileTypes.add(t)); - this.dirtyProfileTypes.add(targetType); - this.showCliImport.set(false); - this.showObscureTool.set(false); - this.dirtyProfileTypes.forEach(t => this.saveCurrentProfile(t)); + private mergeLinkedImportFlags( + result: ImportResult, + targetType: SharedProfileType, + profileName: string, + mode: 'new' | 'override' | 'patch' + ): void { + const processed = new Set(); + + for (const cls of result.classified) { + if (cls.status !== 'mapped' || !cls.fieldName) continue; + const flagType = (cls.flagType || targetType) as SharedProfileType; + if (flagType === targetType || processed.has(flagType)) continue; + if (!LINKED_TYPES.has(flagType)) continue; + + processed.add(flagType); + + const existing = (this.profiles()[flagType]?.[profileName] as Record) ?? {}; + this.profiles.update(p => ({ + ...p, + [flagType]: { + ...p[flagType], + [profileName]: this.mergeFlagsFlat(existing, result, flagType), + }, + })); + + // Wire up the linked-profile selector so the imported values become visible. + if (mode === 'new' || mode === 'patch' || mode === 'override') { + const group = this.remoteConfigForm.get(`${targetType}Config`) as FormGroup; + group?.get(`${flagType}Profile`)?.setValue(profileName); + } + } } private async populateFormIfEditingOrCloning(): Promise { @@ -1557,51 +1652,60 @@ export class RemoteConfigStateService { await this.populateRemoteForm(remoteSpecs); if (this.cloneTarget()) { - const promises: Promise[] = []; - for (const t of FLAG_TYPES) { - const configs = this.existingConfig?.[ - REMOTE_CONFIG_KEYS[t as keyof typeof REMOTE_CONFIG_KEYS] as any - ] as any; - if (configs && Object.keys(configs).length) - promises.push(this.populateProfileForm(t, Object.values(configs)[0] as any)); - } - const rConfigs = this.existingConfig?.[REMOTE_CONFIG_KEYS.runtimeRemote] as any; - if (rConfigs && Object.keys(rConfigs).length) - promises.push( - this.populateProfileForm('runtimeRemote', Object.values(rConfigs)[0] as any) - ); - await Promise.all(promises); + // Populate the first profile of each operation type plus runtimeRemote. + // Linked profile types (vfs/filter/backend) are populated via selectLinkedProfile + // when each operation profile is selected. + const typesToClone: SharedProfileType[] = [...FLAG_TYPES, 'runtimeRemote']; + await Promise.all( + typesToClone.map(async t => { + const configs = this.existingConfig?.[ + REMOTE_CONFIG_KEYS[t as keyof typeof REMOTE_CONFIG_KEYS] + ] as Record | undefined; + const firstProfile = + configs && typeof configs === 'object' ? Object.values(configs)[0] : undefined; + if (firstProfile && typeof firstProfile === 'object') { + await this.populateProfileForm(t, firstProfile as Record); + } + }) + ); } } else if (this.editTarget()) { - if (this.dialogData?.remoteType) - this.remoteForm.get('type')?.setValue(this.dialogData.remoteType); + if (this.dialogData().remoteType) + this.remoteForm.get('type')?.setValue(this.dialogData().remoteType); await this.syncRuntimeRemoteType(); const type = this.editTarget() as SharedProfileType, selectedName = this.selectedProfileName()[type], - profile = selectedName ? this.profiles()[type]?.[selectedName] : undefined; - if (type === 'runtimeRemote') - this.remoteForm - .get('type') - ?.setValue( - this.dialogData?.remoteType || - Object.values(this.profiles()['runtimeRemote']).find(p => p?.['type'])?.['type'] || - '' - ); + profile = selectedName + ? (this.profiles()[type]?.[selectedName] as Record | undefined) + : undefined; + if (type === 'runtimeRemote') { + const runtimeProfiles = this.profiles()['runtimeRemote'] as Record< + string, + Record + >; + const runtimeType = Object.values(runtimeProfiles).find( + p => p && typeof p['type'] === 'string' + )?.['type']; + this.remoteForm.get('type')?.setValue(this.dialogData().remoteType || runtimeType || ''); + } if (profile) await this.populateProfileForm(type, profile); } if (this.cloneTarget()) this.generateNewCloneName(); } - async populateRemoteForm(config: Record): Promise { + private async populateRemoteForm(config: Record): Promise { this.isPopulatingForm.set(true); - this.remoteForm.patchValue({ name: config['name'], type: config['type'] }); - await this.onRemoteTypeChange(); - for (const [k, v] of Object.entries(config)) { - if (k !== 'name' && k !== 'type' && !this.remoteForm.contains(k)) - this.remoteForm.addControl(k, new FormControl(v)); + try { + this.remoteForm.patchValue({ name: config['name'], type: config['type'] }); + await this.onRemoteTypeChange(); + for (const [k, v] of Object.entries(config)) { + if (k !== 'name' && k !== 'type' && !this.remoteForm.contains(k)) + this.remoteForm.addControl(k, new FormControl(v)); + } + this.remoteForm.patchValue(config); + } finally { + this.isPopulatingForm.set(false); } - this.remoteForm.patchValue(config); - this.isPopulatingForm.set(false); } async populateProfileForm( @@ -1609,119 +1713,164 @@ export class RemoteConfigStateService { config: Record ): Promise { this.isPopulatingForm.set(true); - const generation = this.bumpGeneration(type); - const group = this.remoteConfigForm.get(`${type}Config`); - if (!group) { - this.isPopulatingForm.set(false); - return; - } - const rName = this.currentRemoteName(); + try { + const group = this.remoteConfigForm.get(`${type}Config`) as FormGroup; + if (!group) return; - if (type === 'runtimeRemote') { - const opts = this.getRuntimeRemoteOptions(rName, config), - rType = String( - this.remoteForm.get('type')?.value || opts['type'] || config['type'] || '' - ).trim(); - group.get('type')?.setValue(rType, { emitEvent: false }); - await this.loadRuntimeRemoteFields(rType); - for (const f of this.dynamicRuntimeRemoteFields()) - group.get(f.Name)?.setValue(opts[f.FieldName] ?? opts[f.Name] ?? f.Value ?? f.Default, { - emitEvent: false, - }); + if (type === 'runtimeRemote') { + await this.populateRuntimeRemoteProfile(group, config); + return; + } + + if (type === 'serve') { + this.selectedServeType.set( + String(getRcloneCfg(config)?.['type'] || config['type'] || 'http') + ); + await this.loadServeFields(); + } + + const generation = this.bumpGeneration(type); + const rName = this.currentRemoteName(); + const vals = mapConfigToFormProfile(type, config, { + remoteName: rName, + existingRemotes: this.existingRemotes(), + pathService: this.pathService, + }); + + this.patchProfileFields(group, vals); + this.populateProfilePaths(group, type, vals, rName, generation); + this.populateProfileOptions(group, type, vals); + + if (LINKED_PROFILE_TYPES.has(type)) { + await Promise.all([ + this.selectLinkedProfile('vfs', String(vals['vfsProfile'] ?? '')), + this.selectLinkedProfile('filter', String(vals['filterProfile'] ?? '')), + this.selectLinkedProfile('backend', String(vals['backendProfile'] ?? '')), + this.selectLinkedProfile('runtimeRemote', String(vals['runtimeRemoteProfile'] ?? '')), + ]); + } + } finally { this.isPopulatingForm.set(false); - return; } + } - if (type === 'serve') { - this.selectedServeType.set( - String(getRcloneCfg(config)?.['type'] || config['type'] || 'http') - ); - await this.loadServeFields(); + private async populateRuntimeRemoteProfile( + group: FormGroup, + config: Record + ): Promise { + const rName = this.currentRemoteName(); + const opts = this.getRuntimeRemoteOptions(rName, config); + const rType = String( + this.remoteForm.get('type')?.value || opts['type'] || config['type'] || '' + ).trim(); + group.get('type')?.setValue(rType, { emitEvent: false }); + await this.loadRuntimeRemoteFields(rType); + for (const f of this.dynamicRuntimeRemoteFields()) { + group.get(f.Name)?.setValue(opts[f.FieldName] ?? opts[f.Name] ?? f.Value ?? f.Default); } - const vals = mapConfigToFormProfile(type, config, { - remoteName: rName, - existingRemotes: this.existingRemotes(), - pathService: this.pathService, - }); + } - group.patchValue({ - autoStart: vals['autoStart'], - cronEnabled: vals['cronEnabled'], - cronExpression: vals['cronExpression'], - watchEnabled: vals['watchEnabled'], - watchDelay: vals['watchDelay'], - vfsProfile: vals['vfsProfile'], - filterProfile: vals['filterProfile'], - backendProfile: vals['backendProfile'], - runtimeRemoteProfile: vals['runtimeRemoteProfile'], - }); + private patchProfileFields(group: FormGroup, vals: Record): void { + const patch: Record = {}; + for (const key of PROFILE_FORM_FIELDS) patch[key] = vals[key]; + group.patchValue(patch); + } + private populateProfilePaths( + group: FormGroup, + type: SharedProfileType, + vals: Record, + rName: string, + generation: number + ): void { const srcCtrl = group.get('source'); if (srcCtrl instanceof FormArray) { srcCtrl.clear(); - const arr = (vals['source'] || []) as any[]; - if (!arr.length) srcCtrl.push(this.createSourcePathGroup()); - else arr.forEach(s => srcCtrl.push(this.createSourcePathGroup(s))); - } else if (srcCtrl instanceof FormGroup) srcCtrl.patchValue(vals['source']); + const rawSource = vals['source'] as unknown; + const arr = Array.isArray(rawSource) ? rawSource : []; + if (!arr.length) { + srcCtrl.push(this.createSourcePathGroup()); + } else { + arr.forEach(s => + srcCtrl.push( + this.createSourcePathGroup( + s as { + type?: string; + path?: string; + remote?: string; + filename?: string; + } + ) + ) + ); + } + } else if (srcCtrl instanceof FormGroup) { + srcCtrl.patchValue(vals['source'] as Record); + } const dstCtrl = group.get('dest'); - if (dstCtrl instanceof FormGroup) { - dstCtrl.patchValue(vals['dest']); - if ((type === 'mount' || type === 'bisync') && !vals['dest']?.path) { - const opType = type as 'mount' | 'bisync'; - void this.pathService.resolveDefaultPath(rName, opType).then(defaultPath => { - if (generation !== this.getGeneration(type)) return; - if (!dstCtrl.get('path')?.value) { - dstCtrl.patchValue({ type: 'local', path: defaultPath }); - } - }); - } + if (!(dstCtrl instanceof FormGroup)) return; + + dstCtrl.patchValue(vals['dest'] as Record); + const destVal = vals['dest'] as { path?: string } | undefined; + if ((type === 'mount' || type === 'bisync') && !destVal?.path) { + const opType = type as 'mount' | 'bisync'; + void this.pathInspectionService.resolveDefaultPath(rName, opType).then(defaultPath => { + if (generation !== this.getGeneration(type)) return; + if (!dstCtrl.get('path')?.value) { + dstCtrl.patchValue({ type: 'local', path: defaultPath }); + } + }); } + } + private populateProfileOptions( + group: FormGroup, + type: SharedProfileType, + vals: Record + ): void { const optsGroup = group.get('options') as FormGroup; - if (optsGroup) { - const tCtrl = optsGroup.get(type === 'serve' ? 'type' : 'mountType'); - for (const k of Object.keys(optsGroup.controls)) { - if (k !== 'type' && k !== 'mountType') optsGroup.removeControl(k); - } - if (!tCtrl && (type === 'serve' || type === 'mount')) - optsGroup.addControl( - type === 'serve' ? 'type' : 'mountType', - new FormControl(type === 'serve' ? 'http' : 'mount') - ); + if (!optsGroup) return; + + // Ensure the subtype control (serve 'type' / mount 'mountType') exists. + const subtypeKey = type === 'serve' ? 'type' : 'mountType'; + if ((type === 'serve' || type === 'mount') && !optsGroup.contains(subtypeKey)) { + optsGroup.addControl(subtypeKey, new FormControl(type === 'serve' ? 'http' : 'mount')); + } - const fields = - type === 'serve' - ? this.dynamicServeFields() - : this.dynamicFlagFields()[type as FlagType] || []; - for (const f of fields) { - if (['type', 'mountType'].includes(f.FieldName || f.Name)) continue; - optsGroup.addControl(getControlKey(f, type), new FormControl(f.Value ?? f.Default)); + const fields = this.getFieldsForStep(type); + const fieldKeys = new Set( + fields + .filter(f => !['type', 'mountType'].includes(f.FieldName || f.Name)) + .map(f => f.Name || f.FieldName) + ); + + for (const k of Object.keys(optsGroup.controls)) { + if (k !== 'type' && k !== 'mountType' && !fieldKeys.has(k)) { + optsGroup.removeControl(k); } - for (const [k, v] of Object.entries(vals['options'] || {})) { - if (k === 'fs') continue; - const matchedField = fields.find(f => f.FieldName === k || f.Name === k); - const cKey = matchedField - ? getControlKey(matchedField, type) - : getControlKey({ FieldName: k, Name: k } as any, type); - const control = optsGroup.get(cKey); - if (control) { - control.setValue(v, { emitEvent: false }); - } else { - optsGroup.addControl(cKey, new FormControl(v), { emitEvent: false }); - } + } + + for (const f of fields) { + if (['type', 'mountType'].includes(f.FieldName || f.Name)) continue; + const key = f.Name || f.FieldName; + if (!optsGroup.contains(key)) { + optsGroup.addControl(key, new FormControl(f.Value ?? f.Default)); } } - if (LINKED_PROFILE_TYPES.has(type)) { - await Promise.all([ - this.selectLinkedProfile('vfs', vals['vfsProfile']), - this.selectLinkedProfile('filter', vals['filterProfile']), - this.selectLinkedProfile('backend', vals['backendProfile']), - this.selectLinkedProfile('runtimeRemote', vals['runtimeRemoteProfile']), - ]); + const incomingOptions = (vals['options'] as Record) || {}; + for (const [k, v] of Object.entries(incomingOptions)) { + if (k === 'fs') continue; + const matchedField = fields.find(f => f.FieldName === k || f.Name === k); + const cKey = matchedField ? matchedField.Name || matchedField.FieldName : k; + const control = optsGroup.get(cKey); + if (control) { + control.setValue(v); + } else { + optsGroup.addControl(cKey, new FormControl(v)); + } } - this.isPopulatingForm.set(false); } generateNewCloneName(): void { diff --git a/src/app/services/remote/remote-creation-orchestrator.service.ts b/src/app/services/remote/remote-creation-orchestrator.service.ts old mode 100644 new mode 100755 index a7f30036c..61e5bb391 --- a/src/app/services/remote/remote-creation-orchestrator.service.ts +++ b/src/app/services/remote/remote-creation-orchestrator.service.ts @@ -18,7 +18,7 @@ import { ServeManagementService } from '../operations/serve-management.service'; import { JobManagementService } from '../operations/job-management.service'; import { NotificationService } from '../ui/notification.service'; import { TranslateService } from '@ngx-translate/core'; -import { PathService } from '../infrastructure/platform/path.service'; +import { PathInspectionService } from '../infrastructure/platform/path-inspection.service'; import { getAppCfg, getRcloneCfg, @@ -31,6 +31,17 @@ import { updateInteractiveAnswer, } from './utils/remote-config.utils'; +interface PendingConfig { + remoteData: PendingRemoteData; + finalConfig: RemoteConfigSections; +} + +function isAnswerRequired(state: InteractiveFlowState): boolean { + const opt = state.question?.Option; + if (!opt?.Required) return false; + return state.answer == null || String(state.answer).trim() === ''; +} + @Injectable() export class RemoteCreationOrchestrator { private readonly authStateService = inject(AuthStateService); @@ -41,38 +52,24 @@ export class RemoteCreationOrchestrator { private readonly jobManagementService = inject(JobManagementService); private readonly notificationService = inject(NotificationService); private readonly translate = inject(TranslateService); - private readonly pathService = inject(PathService); + private readonly pathInspectionService = inject(PathInspectionService); readonly interactiveFlowState = signal(createInitialInteractiveFlowState()); - readonly oauthHelperUrl = computed(() => - (this.authStateService.isAuthInProgress?.() ?? false) && - !(this.authStateService.isAuthCancelled?.() ?? false) - ? (this.authStateService.oauthUrl?.() ?? null) - : null - ); + readonly oauthHelperUrl = computed(() => { + const inProgress = this.authStateService.isAuthInProgress(); + const cancelled = this.authStateService.isAuthCancelled(); + return inProgress && !cancelled ? this.authStateService.oauthUrl() : null; + }); readonly isInteractiveContinueDisabled = computed(() => { const s = this.interactiveFlowState(); - if (s.isProcessing || (this.authStateService.isAuthCancelled?.() ?? false)) { - return true; - } - if (s.question?.Error) { - return true; - } - const opt = s.question?.Option; - if (opt?.Required) { - if (s.answer == null || String(s.answer).trim() === '') { - return true; - } - } - return false; + if (s.isProcessing || this.authStateService.isAuthCancelled()) return true; + if (s.question?.Error) return true; + return isAnswerRequired(s); }); - private pendingConfig: { - remoteData: PendingRemoteData; - finalConfig: RemoteConfigSections; - } | null = null; + private pendingConfig: PendingConfig | null = null; setPendingConfig(remoteData: PendingRemoteData, finalConfig: RemoteConfigSections): void { this.pendingConfig = { remoteData, finalConfig }; @@ -120,7 +117,7 @@ export class RemoteCreationOrchestrator { if (!state.isActive || !state.question || !this.pendingConfig) return; const { name, ...paramRest } = this.pendingConfig.remoteData; - const processedAnswer: unknown = + const processedAnswer: string | number | boolean | null = state.question?.Option?.Type === 'bool' ? convertBoolAnswerToString(answer) : (answer ?? ''); @@ -159,7 +156,7 @@ export class RemoteCreationOrchestrator { this.interactiveFlowState.set(createInitialInteractiveFlowState()); await this.appSettingsService.saveRemoteSettings(remoteData.name, finalConfig); try { - await this.pathService.createRequiredDirectories(finalConfig); + await this.pathInspectionService.createRequiredDirectories(finalConfig); } catch (err) { console.error('Failed to create required directories:', err); } @@ -184,17 +181,26 @@ export class RemoteCreationOrchestrator { } async triggerAutoStartJobs(remoteName: string, finalConfig: RemoteConfigSections): Promise { - const mountConfigs = finalConfig[REMOTE_CONFIG_KEYS.mount]; - if (mountConfigs) { - for (const [profileName, config] of Object.entries(mountConfigs)) { - const appCfg = (getAppCfg(config) ?? config) as AppConfig; - const rcloneCfg = (getRcloneCfg(config) ?? config) as RcloneSubConfig; - if (appCfg.autoStart && rcloneCfg.mountPoint) { - void this.mountManagementService.mountRemoteProfile(remoteName, profileName); - } + this.startAutoMounts(remoteName, finalConfig[REMOTE_CONFIG_KEYS.mount]); + this.startAutoSyncJobs(remoteName, finalConfig); + this.startAutoServes(remoteName, finalConfig[REMOTE_CONFIG_KEYS.serve]); + } + + private startAutoMounts( + remoteName: string, + mountConfigs: RemoteConfigSections['mountConfigs'] + ): void { + if (!mountConfigs) return; + for (const [profileName, config] of Object.entries(mountConfigs)) { + const appCfg = (getAppCfg(config) ?? config) as AppConfig; + const rcloneCfg = (getRcloneCfg(config) ?? config) as RcloneSubConfig; + if (appCfg.autoStart && rcloneCfg.mountPoint) { + void this.mountManagementService.mountRemoteProfile(remoteName, profileName); } } + } + private startAutoSyncJobs(remoteName: string, finalConfig: RemoteConfigSections): void { for (const jobType of SYNC_TYPES) { const configs = finalConfig[REMOTE_CONFIG_KEYS[jobType]] as JobMap | undefined; if (!configs) continue; @@ -211,14 +217,17 @@ export class RemoteCreationOrchestrator { } } } + } - const serveConfigs = finalConfig[REMOTE_CONFIG_KEYS.serve]; - if (serveConfigs) { - for (const [profileName, config] of Object.entries(serveConfigs)) { - const appCfg = (getAppCfg(config) ?? config) as AppConfig; - if (appCfg.autoStart) { - void this.serveManagementService.startServeProfile(remoteName, profileName); - } + private startAutoServes( + remoteName: string, + serveConfigs: RemoteConfigSections['serveConfigs'] + ): void { + if (!serveConfigs) return; + for (const [profileName, config] of Object.entries(serveConfigs)) { + const appCfg = (getAppCfg(config) ?? config) as AppConfig; + if (appCfg.autoStart) { + void this.serveManagementService.startServeProfile(remoteName, profileName); } } } diff --git a/src/app/services/remote/remote-file-operations.service.ts b/src/app/services/remote/remote-file-operations.service.ts old mode 100644 new mode 100755 index 603ee5fdf..484463741 --- a/src/app/services/remote/remote-file-operations.service.ts +++ b/src/app/services/remote/remote-file-operations.service.ts @@ -1,7 +1,15 @@ import { inject, Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; -import { DiskUsageSeverity, Entry, FsInfo, JobActionType, Origin } from '@app/types'; +import { + Entry, + FsInfo, + JobActionType, + Origin, + FsTransferItem, + FsDeleteItem, + ArchiveListResponse, +} from '@app/types'; import { TauriBaseService } from '../infrastructure/platform/tauri-base.service'; @Injectable({ providedIn: 'root' }) @@ -21,9 +29,6 @@ export class RemoteFileOperationsService extends TauriBaseService { total: number; used: number; free: number; - usagePercentage: number; - usagePercentageLabel: string; - usageSeverity: DiskUsageSeverity; }> { return this.invokeCommand('get_disk_usage', { remote, path, origin: source, group }); } @@ -107,7 +112,7 @@ export class RemoteFileOperationsService extends TauriBaseService { } async transferItems( - items: any[], + items: FsTransferItem[], dstRemote: string, dstPath: string, mode: 'copy' | 'move', @@ -126,7 +131,7 @@ export class RemoteFileOperationsService extends TauriBaseService { }); } - async deleteItems(items: any[], source?: Origin, group?: string): Promise { + async deleteItems(items: FsDeleteItem[], source?: Origin, group?: string): Promise { return this.invokeCommand('delete', { items, origin: source, group }); } @@ -214,6 +219,14 @@ export class RemoteFileOperationsService extends TauriBaseService { name: string, content: Uint8Array ): Promise { + // ⚠️ Performance issue (preserved for wire-format compatibility): + // `Array.from(content)` serializes every byte as a JSON number, multiplying + // payload size ~5x and amplifying memory pressure on large files. + // The proper fix is to switch to a base64-encoded payload + a backend + // handler that decodes it — but that requires a coordinated Rust-side + // change to `upload_file`. See refactor plan: switch both sides to + // base64 in a single PR, or route large uploads through `uploadFileStream` + // which already uses multipart FormData via HTTP. return this.invokeCommand('upload_file', { remote, path, @@ -267,10 +280,8 @@ export class RemoteFileOperationsService extends TauriBaseService { totalBytes = files.reduce((s, f) => s + f.file.size, 0); await this.registerPreparingJob(jobId, remote, path, totalFiles, totalBytes, source); - let uploadedBytes = 0, - successCount = 0; - const completed: any[] = [], - failedPaths: string[] = []; + let successCount = 0; + const failedPaths: string[] = []; for (let i = 0; i < totalFiles; i++) { const { file, relativePath } = files[i]; @@ -287,27 +298,20 @@ export class RemoteFileOperationsService extends TauriBaseService { jobId ); successCount++; - uploadedBytes += file.size; - completed.push({ - name: relativePath, - size: file.size, - bytes: file.size, - completed_at: new Date().toISOString(), - }); - await this.updateJobStats(jobId, { - totalBytes, - bytes: uploadedBytes, - transfers: successCount, - totalTransfers: totalFiles, - completed, - transferring: [], - preparing: true, - }); } catch (err) { - console.error(err); + console.error('[RemoteFileOps] Upload stream error:', relativePath, err); failedPaths.push(relativePath); } } + + if (successCount === 0 && totalFiles > 0) { + try { + await this.apiClient.invoke('delete_job', { jobid: jobId }); + } catch { + /* ignore */ + } + } + return { successCount, failedPaths }; } @@ -375,8 +379,8 @@ export class RemoteFileOperationsService extends TauriBaseService { plain?: boolean, filesOnly?: boolean, dirsOnly?: boolean - ): Promise { - return this.invokeCommand('archive_list', { + ): Promise { + return this.invokeCommand('archive_list', { source, long, plain, diff --git a/src/app/services/remote/remote-management.service.ts b/src/app/services/remote/remote-management.service.ts old mode 100644 new mode 100755 index a093c08e8..cc7102790 --- a/src/app/services/remote/remote-management.service.ts +++ b/src/app/services/remote/remote-management.service.ts @@ -2,6 +2,7 @@ import { Injectable, inject, signal, computed, Signal } from '@angular/core'; import { TauriBaseService } from '../infrastructure/platform/tauri-base.service'; import { RemoteFileOperationsService } from './remote-file-operations.service'; import { PathService } from '../infrastructure/platform/path.service'; +import { memoizedLoader, MemoizedLoader } from './utils/memoized-loader.util'; import { RemoteProvider, ConfigRecord, @@ -23,6 +24,14 @@ interface RawProvider { type ProvidersResponse = Record; +interface WriteRemoteOptions { + successKey: string; + successParams?: Record; + errorKey: string; +} + +const EMPTY_HASHES: readonly string[] = Object.freeze([]); + @Injectable({ providedIn: 'root' }) export class RemoteManagementService extends TauriBaseService { private readonly remoteOpsService = inject(RemoteFileOperationsService); @@ -32,6 +41,12 @@ export class RemoteManagementService extends TauriBaseService { private readonly _features = signal>({}); private readonly _isLibrclone = signal(null); + private readonly featuresSignals = new Map>(); + + private readonly providersLoader: MemoizedLoader = memoizedLoader(() => + this.invokeCommand('get_remote_types') + ); + async getFsInfo( remoteName: string, source: Origin = 'dashboard', @@ -42,22 +57,21 @@ export class RemoteManagementService extends TauriBaseService { if (cached !== undefined) return cached; const fsName = this.pathService.isLocalPath(key) ? key : `${key}:`; - try { - const info = await this.remoteOpsService.getFsInfo(fsName, source, group); - this.metadataCache.set(key, info); - return info; - } catch (e) { - console.error(e); - throw e; - } + const info = await this.remoteOpsService.getFsInfo(fsName, source, group); + this.metadataCache.set(key, info); + return info; } getFeaturesSignal(remoteName: string, remoteType?: string): Signal { const nameKey = this.pathService.normalizeRemoteName(remoteName); const typeKey = remoteType ? remoteType.toLowerCase() : nameKey; - return computed( + const cacheKey = typeKey || nameKey; + const existing = this.featuresSignals.get(cacheKey); + if (existing) return existing; + + const sig = computed( () => - this._features()[typeKey] || + this._features()[cacheKey] || this._features()[nameKey] || { IsLocal: this.pathService.isLocalPath(nameKey), About: true, @@ -65,10 +79,12 @@ export class RemoteManagementService extends TauriBaseService { CleanUp: false, PublicLink: false, ChangeNotify: false, - Hashes: [], + Hashes: EMPTY_HASHES as string[], loading: true, } ); + this.featuresSignals.set(cacheKey, sig); + return sig; } publicLinkSupported(remoteName: string): boolean { @@ -82,11 +98,10 @@ export class RemoteManagementService extends TauriBaseService { return !!cached.PublicLink; } - // Trigger asynchronous load in background - this.getFeatures(remoteName).catch(err => + // Async-load features so subsequent calls return accurate values + void this.getFeatures(remoteName).catch(err => console.error(`Failed to load features for ${remoteName}:`, err) ); - return false; } @@ -102,17 +117,7 @@ export class RemoteManagementService extends TauriBaseService { const cached = this._features()[typeKey] || this._features()[nameKey]; if (cached && !cached.loading) return cached; - const loadingState: RemoteFeatures = { - IsLocal: this.pathService.isLocalPath(nameKey), - About: false, - BucketBased: false, - CleanUp: false, - PublicLink: false, - ChangeNotify: false, - Hashes: [], - loading: true, - }; - this._features.update(c => ({ ...c, [nameKey]: loadingState, [typeKey]: loadingState })); + this.setFeatures(nameKey, typeKey, true); try { const info = await this.getFsInfo(remoteName, source, group); @@ -126,7 +131,7 @@ export class RemoteManagementService extends TauriBaseService { Hashes: info.Hashes ?? [], loading: false, }; - this._features.update(c => ({ ...c, [nameKey]: feats, [typeKey]: feats })); + this.setFeatures(nameKey, typeKey, feats); return feats; } catch { const fallback: RemoteFeatures = { @@ -136,14 +141,31 @@ export class RemoteManagementService extends TauriBaseService { CleanUp: false, PublicLink: false, ChangeNotify: false, - Hashes: [], + Hashes: EMPTY_HASHES as string[], loading: false, }; - this._features.update(c => ({ ...c, [nameKey]: fallback, [typeKey]: fallback })); + this.setFeatures(nameKey, typeKey, fallback); return fallback; } } + private setFeatures(nameKey: string, typeKey: string, value: RemoteFeatures | true): void { + const features: RemoteFeatures = + value === true + ? { + IsLocal: this.pathService.isLocalPath(nameKey), + About: false, + BucketBased: false, + CleanUp: false, + PublicLink: false, + ChangeNotify: false, + Hashes: EMPTY_HASHES as string[], + loading: true, + } + : value; + this._features.update(c => ({ ...c, [nameKey]: features, [typeKey]: features })); + } + clearCache(remoteName?: string): void { if (remoteName) { const key = this.pathService.normalizeRemoteName(remoteName); @@ -156,22 +178,7 @@ export class RemoteManagementService extends TauriBaseService { } else { this.metadataCache.clear(); this._features.set({}); - } - } - - private providersCache: ProvidersResponse | null = null; - private providersPromise: Promise | null = null; - - private async fetchProviders(): Promise { - if (this.providersCache) return this.providersCache; - if (this.providersPromise) return this.providersPromise; - - this.providersPromise = this.invokeCommand('get_remote_types'); - try { - this.providersCache = await this.providersPromise; - return this.providersCache; - } finally { - this.providersPromise = null; + this.featuresSignals.clear(); } } @@ -190,7 +197,7 @@ export class RemoteManagementService extends TauriBaseService { } async getRemoteTypes(): Promise { - return this.mapProviders(await this.fetchProviders()); + return this.mapProviders(await this.providersLoader.load()); } async getOAuthSupportedRemotes(): Promise { @@ -200,7 +207,7 @@ export class RemoteManagementService extends TauriBaseService { } async getRemoteConfigFields(type: string): Promise { - const response = await this.fetchProviders(); + const response = await this.providersLoader.load(); const match = Object.values(response) .flat() .find(p => p.Name === type); @@ -220,29 +227,39 @@ export class RemoteManagementService extends TauriBaseService { parameters: ConfigRecord, opt?: Record ): Promise { - await this.invokeWithNotification( - 'create_remote', - { name, parameters, ...(opt && { opt }) }, - { - successKey: 'backendSuccess.remote.created', - successParams: { name }, - errorKey: 'backendErrors.remote.configFailed', - } - ); + await this.writeRemote('create_remote', name, parameters, opt, { + successKey: 'backendSuccess.remote.created', + successParams: { name }, + errorKey: 'backendErrors.remote.configFailed', + }); } async updateRemote( name: string, parameters: ConfigRecord, opt?: Record + ): Promise { + await this.writeRemote('update_remote', name, parameters, opt, { + successKey: 'backendSuccess.remote.updated', + successParams: { name }, + errorKey: 'backendErrors.remote.configFailed', + }); + } + + private async writeRemote( + command: string, + name: string, + parameters: ConfigRecord, + opt: Record | undefined, + options: WriteRemoteOptions ): Promise { await this.invokeWithNotification( - 'update_remote', + command, { name, parameters, ...(opt && { opt }) }, { - successKey: 'backendSuccess.remote.updated', - successParams: { name }, - errorKey: 'backendErrors.remote.configFailed', + successKey: options.successKey, + successParams: options.successParams, + errorKey: options.errorKey, } ); } @@ -264,6 +281,7 @@ export class RemoteManagementService extends TauriBaseService { } } + /** Cancels an in-progress OAuth flow on the backend. */ async quitOAuth(): Promise { return this.invokeCommand('cancel_oauth'); } @@ -281,8 +299,8 @@ export class RemoteManagementService extends TauriBaseService { return this.invokeCommand('create_remote_interactive', { name, rcloneType: type, - ...(parameters && { parameters }), - ...(opt && { opt }), + ...(parameters !== undefined && { parameters }), + ...(opt !== undefined && { opt }), }); } @@ -297,12 +315,12 @@ export class RemoteManagementService extends TauriBaseService { name, stateToken, result, - ...(parameters && { parameters }), - ...(opt && { opt }), + ...(parameters !== undefined && { parameters }), + ...(opt !== undefined && { opt }), }); } - async obscureValue(clear: string): Promise { - return this.invokeCommand('obscure_value', { clear }); + async obscureValue(cleartext: string): Promise { + return this.invokeCommand('obscure_value', { clear: cleartext }); } } diff --git a/src/app/services/remote/remote-presets.spec.ts b/src/app/services/remote/remote-presets.spec.ts index 72573c0a7..648627c78 100644 --- a/src/app/services/remote/remote-presets.spec.ts +++ b/src/app/services/remote/remote-presets.spec.ts @@ -69,16 +69,16 @@ describe('RemotePresetsService', () => { describe('resolvePresets', () => { it('should apply BASE_PRESET by default', () => { const presets = service.resolvePresets('sftp'); - expect(presets.vfs?.['CacheMode']).toBe('full'); - expect(presets.backend?.['LogLevel']).toBe('INFO'); + expect(presets.vfs?.['vfs_cache_mode']).toBe('full'); + expect(presets.backend?.['log_level']).toBe('INFO'); }); it('should merge family-specific presets', () => { const presets = service.resolvePresets('s3'); - expect(presets.backend?.['DisableHTTP2']).toBe(true); - expect(presets.vfs?.['FastFingerprint']).toBe(true); + expect(presets.backend?.['disable_http2']).toBe(true); + expect(presets.vfs?.['vfs_fast_fingerprint']).toBe(true); // base preset should still be there - expect(presets.vfs?.['CacheMode']).toBe('full'); + expect(presets.vfs?.['vfs_cache_mode']).toBe('full'); }); it('should merge provider-specific presets', () => { @@ -101,15 +101,15 @@ describe('RemotePresetsService', () => { mockBackendService.backends.set([{ name: 'Local', isLocal: true, os: 'windows' }]); mockBackendService.activeBackend.set('Local'); const presets = service.resolvePresets('sftp'); - expect(presets.mount?.['NetworkMode']).toBe(true); + expect(presets.mount?.['network_mode']).toBe(true); }); it('should merge OS-specific presets for macos engine', () => { mockBackendService.backends.set([{ name: 'Local', isLocal: true, os: 'darwin' }]); mockBackendService.activeBackend.set('Local'); const presets = service.resolvePresets('sftp'); - expect(presets.mount?.['NoAppleXattr']).toBe(true); - expect(presets.mount?.['NoAppleDouble']).toBe(true); + expect(presets.mount?.['no_apple_xattr']).toBe(true); + expect(presets.mount?.['no_apple_double']).toBe(true); }); it('should not apply windows presets when client is windows but engine is linux (WSL)', () => { @@ -119,7 +119,7 @@ describe('RemotePresetsService', () => { mockBackendService.backends.set([{ name: 'Local', isLocal: true, os: 'linux' }]); mockBackendService.activeBackend.set('Local'); const presets = service.resolvePresets('sftp'); - expect(presets.mount?.['NetworkMode']).toBeUndefined(); + expect(presets.mount?.['network_mode']).toBeUndefined(); }); }); }); diff --git a/src/app/services/remote/remote-presets.ts b/src/app/services/remote/remote-presets.ts index 6713d3d27..1d3ed0099 100644 --- a/src/app/services/remote/remote-presets.ts +++ b/src/app/services/remote/remote-presets.ts @@ -4,8 +4,8 @@ import { BackendService } from '../infrastructure/system/backend.service'; export type StorageFamily = 's3' | 'webdav' | 'generic'; export interface PresetValues { - vfs?: Record; // VFS option overrides (FieldName keys) - mount?: Record; // mountOpt overrides + vfs?: Record; // VFS option overrides + mount?: Record; // Mount option overrides backend?: Record; // global backend flag overrides (e.g. BufferSize, DisableHTTP2) remote?: Record; // remote-specific config overrides (e.g. disable_checksum) } @@ -22,24 +22,24 @@ const REMOTE_FAMILY_MAP: Record = { // Base presets (applied to ALL remotes regardless of type) const BASE_PRESET: PresetValues = { vfs: { - CacheMode: 'full', - CacheMaxSize: '250G', - CacheMinFreeSpace: '10G', - CacheMaxAge: '48h', - WriteBack: '15s', - ChunkSize: '16M', - ChunkStreams: 8, - ReadAhead: '128M', - Refresh: true, + vfs_cache_mode: 'full', + vfs_cache_max_size: '250G', + vfs_cache_min_free_space: '10G', + vfs_cache_max_age: '48h', + vfs_write_back: '15s', + vfs_read_chunk_size: '16M', + vfs_read_chunk_streams: 8, + vfs_read_ahead: '128M', + vfs_refresh: true, }, mount: { - AttrTimeout: '10s', + attr_timeout: '10s', }, backend: { - BufferSize: '32M', - MaxBufferMemory: '2G', - LogLevel: 'INFO', - Transfers: 8, + buffer_size: '32M', + max_buffer_memory: '2G', + log_level: 'INFO', + transfers: 8, }, }; @@ -47,16 +47,16 @@ const BASE_PRESET: PresetValues = { const FAMILY_PRESETS: Record = { s3: { backend: { - DisableHTTP2: true, - UseServerModTime: true, + disable_http2: true, + use_server_mod_time: true, }, vfs: { - FastFingerprint: true, + vfs_fast_fingerprint: true, }, }, webdav: { vfs: { - WriteBack: '20s', + vfs_write_back: '20s', }, }, generic: {}, @@ -99,22 +99,22 @@ const VENDOR_PRESETS: Record> = { // OS-specific configuration overrides const OS_PRESETS: Record<'windows' | 'macos' | 'linux' | 'android', PresetValues> = { windows: { - mount: { NetworkMode: true }, + mount: { network_mode: true }, }, macos: { mount: { - NoAppleXattr: true, - NoAppleDouble: true, + no_apple_xattr: true, + no_apple_double: true, }, }, linux: {}, android: { vfs: { - CacheMode: 'full', - CacheMaxSize: '50G', - CacheMinFreeSpace: '2G', - CacheMaxAge: '24h', - WriteBack: '10s', + vfs_cache_mode: 'full', + vfs_cache_max_size: '50G', + vfs_cache_min_free_space: '2G', + vfs_cache_max_age: '24h', + vfs_write_back: '10s', }, mount: { mountType: 'saf', @@ -179,35 +179,23 @@ export class RemotePresetsService { resolvePresets(remoteType: string, vendor?: string): PresetValues { let merged = { ...BASE_PRESET }; - // 1. Merge family-specific presets const family = this.getStorageFamily(remoteType); - const familyPreset = FAMILY_PRESETS[family]; - if (familyPreset) { - merged = mergePresets(merged, familyPreset); - } + if (FAMILY_PRESETS[family]) merged = mergePresets(merged, FAMILY_PRESETS[family]); - // 2. Add provider-specific remote options const typeLower = remoteType.toLowerCase().replace(/\s+/g, ''); - const providerPreset = PROVIDER_REMOTE_PRESETS[typeLower]; - if (providerPreset) { - merged = mergePresets(merged, providerPreset); + if (PROVIDER_REMOTE_PRESETS[typeLower]) { + merged = mergePresets(merged, PROVIDER_REMOTE_PRESETS[typeLower]); } - // 3. Add vendor-specific remote presets (e.g. Nextcloud/Owncloud for WebDAV) if (vendor) { const vendorLower = vendor.toLowerCase().replace(/\s+/g, ''); const vendorPreset = VENDOR_PRESETS[typeLower]?.[vendorLower]; - if (vendorPreset) { - merged = mergePresets(merged, vendorPreset); - } + if (vendorPreset) merged = mergePresets(merged, vendorPreset); } - // 4. Merge OS-specific presets using rule matching const osPlatform = this.getTargetPlatform(); const matchedRule = OS_PRESET_RULES.find(rule => rule.matches(osPlatform)); - if (matchedRule) { - merged = mergePresets(merged, matchedRule.preset); - } + if (matchedRule) merged = mergePresets(merged, matchedRule.preset); return merged; } diff --git a/src/app/services/remote/remote-status.service.ts b/src/app/services/remote/remote-status.service.ts old mode 100644 new mode 100755 index cb799cca7..ddd30e5de --- a/src/app/services/remote/remote-status.service.ts +++ b/src/app/services/remote/remote-status.service.ts @@ -44,13 +44,8 @@ export class RemoteStatusService { } getActiveSyncOperationIcon(remote: Remote): string { - for (const type of SYNC_TYPES) { - const state = remote.status[type as keyof RemoteStatus] as RemoteOperationState | undefined; - if (state?.active) { - return OPERATION_ICONS[type] || 'sync'; - } - } - return 'sync'; + const type = this.getActiveSyncOperationType(remote); + return type ? OPERATION_ICONS[type] || 'sync' : 'sync'; } getSyncOperationsTooltip(remote: Remote): string { @@ -116,12 +111,15 @@ export class RemoteStatusService { getActiveOperationsSummary(remote: Remote): string[] { const summary: string[] = []; - if (this.isMounted(remote)) - summary.push( - this.translate.instant('mount.mountedMultiple', { - count: this.getMountProfileCount(remote), - }) - ); + if (this.isMounted(remote)) { + const mountCount = this.getMountProfileCount(remote); + if (mountCount === 1) { + const profile = Object.keys(remote.status.mount.activeProfiles || {})[0] ?? 'Default'; + summary.push(this.translate.instant('mount.mountedWithProfile', { profile })); + } else { + summary.push(this.translate.instant('mount.mountedMultiple', { count: mountCount })); + } + } if (this.getActiveSyncOperationType(remote)) summary.push( this.translate.instant('operations.syncSummary', { diff --git a/src/app/services/remote/user-template.service.ts b/src/app/services/remote/user-template.service.ts new file mode 100755 index 000000000..07481ddf8 --- /dev/null +++ b/src/app/services/remote/user-template.service.ts @@ -0,0 +1,93 @@ +import { Injectable, signal } from '@angular/core'; +import { UserPresetTemplate } from '@app/types'; +import { TauriBaseService } from '../infrastructure/platform/tauri-base.service'; + +@Injectable({ providedIn: 'root' }) +export class UserTemplateService extends TauriBaseService { + private readonly _templates = signal([]); + private readonly _loaded = signal(false); + + readonly userTemplates = this._templates.asReadonly(); + readonly allTemplates = this._templates.asReadonly(); + readonly loaded = this._loaded.asReadonly(); + + constructor() { + super(); + void this.syncFromBackend(); + } + + private async syncFromBackend(): Promise { + try { + const map = + await this.invokeCommand>>( + 'list_user_templates' + ); + if (map && typeof map === 'object') { + const templates: UserPresetTemplate[] = Object.entries(map).map(([id, tpl]) => ({ + id, + ...tpl, + })); + this._templates.set(templates); + } + } catch (err) { + console.warn('[UserTemplateService] Failed to load templates from backend:', err); + } finally { + this._loaded.set(true); + } + } + + saveTemplate(input: Omit): UserPresetTemplate { + const id = `usr-tpl-${crypto.randomUUID()}`; + const newTemplate: UserPresetTemplate = { id, ...input }; + + const updated = [newTemplate, ...this._templates()]; + this._templates.set(updated); + + void this.invokeCommand('save_user_template', { id, template: input }).catch(err => { + console.warn('[UserTemplateService] Failed to save template to rcman backend:', err); + }); + + this.notificationService.showSuccess( + this.translate.instant('templates.savedSuccess', { name: newTemplate.name }) + ); + + return newTemplate; + } + + updateTemplate(updated: UserPresetTemplate): void { + const current = this._templates(); + const index = current.findIndex(t => t.id === updated.id); + if (index < 0) { + console.warn(`[UserTemplateService] Cannot update unknown template: ${updated.id}`); + return; + } + + const list = [...current]; + list[index] = updated; + this._templates.set(list); + + const { id, ...template } = updated; + void this.invokeCommand('update_user_template', { id, template }).catch(err => { + console.warn('[UserTemplateService] Failed to update template on rcman backend:', err); + }); + + this.notificationService.showSuccess( + this.translate.instant('templates.savedSuccess', { name: updated.name }) + ); + } + + deleteTemplate(id: string): void { + if (id.startsWith('builtin-')) { + throw new Error(`Cannot delete built-in template: ${id}`); + } + + const updated = this._templates().filter(t => t.id !== id); + this._templates.set(updated); + + void this.invokeCommand('delete_user_template', { id }).catch(err => { + console.warn('[UserTemplateService] Failed to delete template on rcman backend:', err); + }); + + this.notificationService.showInfo(this.translate.instant('templates.deletedSuccess')); + } +} diff --git a/src/app/services/remote/utils/memoized-loader.util.ts b/src/app/services/remote/utils/memoized-loader.util.ts old mode 100644 new mode 100755 index 6dcc661e2..f98a6af3d --- a/src/app/services/remote/utils/memoized-loader.util.ts +++ b/src/app/services/remote/utils/memoized-loader.util.ts @@ -1,31 +1,75 @@ -import { signal, Signal } from '@angular/core'; +import { computed, signal, Signal } from '@angular/core'; + +export type LoaderStatus = 'idle' | 'loading' | 'resolved' | 'error'; export interface MemoizedLoader { + /** Current value, or `null` if not yet loaded. */ readonly signal: Signal; + /** Current status. */ + readonly status: Signal; + /** True when an initial load or refresh is in progress. */ + readonly isLoading: Signal; + /** Last error encountered, or `null` if none. */ + readonly error: Signal; + /** Loads the value (returns cached if already loaded; dedupes concurrent calls). */ load: () => Promise; + /** Forces a fresh load, ignoring any cached value. */ + refresh: () => Promise; } +/** + * Imperative memoized loader with signal-based state. + * + * Designed as a lightweight, Promise-friendly alternative to Angular's + * `resource()` for cases where the loader is parameterless and called + * imperatively rather than reactively. Exposes a `resource()`-compatible + * API surface (`status`, `isLoading`, `error`, `value`-as-`signal`) so + * consumers can be migrated to `resource()` later if reactive params + * become necessary. + */ export function memoizedLoader(loader: () => Promise): MemoizedLoader { - const _signal = signal(null); - let _promise: Promise | null = null; + const valueSignal = signal(null); + const statusSignal = signal('idle'); + const errorSignal = signal(null); + const isLoading = computed(() => statusSignal() === 'loading'); + let pending: Promise | null = null; - const load = (): Promise => { - const current = _signal(); - if (current !== null) return Promise.resolve(current); - if (_promise) return _promise; + const run = async (): Promise => { + statusSignal.set('loading'); + try { + const result = await loader(); + valueSignal.set(result); + errorSignal.set(null); + statusSignal.set('resolved'); + return result; + } catch (err) { + errorSignal.set(err); + statusSignal.set('error'); + throw err; + } finally { + pending = null; + } + }; - _promise = (async (): Promise => { - try { - const result = await loader(); - _signal.set(result); - return result; - } finally { - _promise = null; - } - })(); + const load = (): Promise => { + if (statusSignal() === 'resolved') return Promise.resolve(valueSignal() as T); + if (pending) return pending; + pending = run(); + return pending; + }; - return _promise; + const refresh = (): Promise => { + if (pending) return pending; + pending = run(); + return pending; }; - return { signal: _signal.asReadonly(), load }; + return { + signal: valueSignal.asReadonly(), + status: statusSignal.asReadonly(), + isLoading, + error: errorSignal.asReadonly(), + load, + refresh, + }; } diff --git a/src/app/services/remote/utils/remote-config.utils.ts b/src/app/services/remote/utils/remote-config.utils.ts index 430e417f9..80777c233 100755 --- a/src/app/services/remote/utils/remote-config.utils.ts +++ b/src/app/services/remote/utils/remote-config.utils.ts @@ -1,10 +1,13 @@ -import { RcConfigQuestionResponse, InteractiveFlowState, RcConfigOption } from '@app/types'; +import { + RcConfigQuestionResponse, + InteractiveFlowState, + RcConfigOption, + OperationType, + SharedProfileType, +} from '@app/types'; import { staticFlagDefinitions } from '../flag-definitions'; import { PathGroup } from '../../infrastructure/platform/path.service'; -/** - * Creates the initial/reset state for interactive flow. - */ export function createInitialInteractiveFlowState(): InteractiveFlowState { return { isActive: false, @@ -14,16 +17,10 @@ export function createInitialInteractiveFlowState(): InteractiveFlowState { }; } -/** - * Converts a boolean answer to the string format expected by rclone API. - */ export function convertBoolAnswerToString(answer: unknown): string { return answer === true || String(answer).toLowerCase() === 'true' ? 'true' : 'false'; } -/** - * Returns a new state object with the given answer applied. - */ export function updateInteractiveAnswer( state: InteractiveFlowState, newAnswer: string | number | boolean | null @@ -31,9 +28,6 @@ export function updateInteractiveAnswer( return { ...state, answer: newAnswer }; } -/** - * Extracts the default answer from an interactive config question response. - */ export function getDefaultAnswerFromQuestion( q: RcConfigQuestionResponse ): string | boolean | number { @@ -66,6 +60,7 @@ export function getDefaultAnswerFromQuestion( const hasExactMatch = opt.Examples.some(ex => ex.Value === defVal); if (!hasExactMatch) { const num = parseInt(defVal, 10); + // rclone uses 1-based numeric indices for example selection if (!isNaN(num) && num >= 1 && num <= opt.Examples.length) { return opt.Examples[num - 1].Value; } @@ -75,9 +70,6 @@ export function getDefaultAnswerFromQuestion( return defVal; } -/** - * Strips leading CLI flag dashes (e.g., --, -) from a search query. - */ export function stripCliPrefix(query: string): string { const q = query.toLowerCase().trim(); if (q.startsWith('--')) { @@ -89,17 +81,10 @@ export function stripCliPrefix(query: string): string { return q; } -/** - * Normalizes an rclone config key for flexible searching - * (lowercase, hyphens/spaces → underscores). - */ export function normalizeRcloneKey(val: string | undefined | null): string { return val ? val.toLowerCase().replace(/[- ]/g, '_') : ''; } -/** - * Returns true if a config field name or help text matches the given search query. - */ export function matchesConfigSearch(field: RcConfigOption, query: string): boolean { if (!query) return true; @@ -115,9 +100,6 @@ export function matchesConfigSearch(field: RcConfigOption, query: string): boole ); } -/** - * Groups an array of items by a derived key. - */ export function groupBy( array: T[], keyGetter: (item: T) => K @@ -132,23 +114,13 @@ export function groupBy( ); } -/** - * Gets the standard control key for a given config option. - */ -export function getControlKey(field: RcConfigOption, type?: string): string { - if (type === 'serve' || type === 'cryptcheck' || type === 'archivecreate') { - return field.Name || field.FieldName; - } - return field.FieldName || field.Name; -} - export interface PathMappingInfo { sourceKey: string; destKey?: string; isSourceArray?: boolean; } -export const OPERATION_PATH_MAPPINGS: Record = { +export const OPERATION_PATH_MAPPINGS: Partial> = { mount: { sourceKey: 'fs', destKey: 'mountPoint' }, serve: { sourceKey: 'fs' }, sync: { sourceKey: 'srcFs', destKey: 'dstFs', isSourceArray: true }, @@ -162,47 +134,49 @@ export const OPERATION_PATH_MAPPINGS: Record = { copyurl: { sourceKey: 'srcFs', destKey: 'dstFs', isSourceArray: true }, }; -/** Keys excluded from dynamic options when mapping config→form (structural/metadata fields). */ -const CONFIG_METADATA_KEYS = new Set([ - 'srcFs', - 'dstFs', - 'path1', - 'path2', - 'fs', - 'mountPoint', +const CONFIG_METADATA_KEYS: ReadonlySet = new Set([ + ...Object.values(OPERATION_PATH_MAPPINGS).flatMap(m => + m ? [m.sourceKey, m.destKey].filter((k): k is string => !!k) : [] + ), 'mountType', + 'type', 'autoStart', 'cronEnabled', 'cronExpression', 'watchEnabled', 'watchDelay', + 'watchChangedOnly', 'vfsProfile', 'filterProfile', 'backendProfile', 'runtimeRemoteProfile', 'name', - 'type', - '_config', - 'mountOpt', ]); +const MOUNT_TYPE_KEY = 'mountType'; +const SERVE_TYPE_KEY = 'type'; +const DEFAULT_SERVE_TYPE = 'http'; +const DEFAULT_MOUNT_TYPE = 'mount'; + +// Legacy compat keys — older config formats that should be flattened into options +const LEGACY_FLATTEN_KEYS = new Set(['_config', 'mountOpt', '_filter']); + export function getTopLevelKeysForProfile(type: string): string[] { - const mapping = OPERATION_PATH_MAPPINGS[type]; + const mapping = OPERATION_PATH_MAPPINGS[type as SharedProfileType]; if (!mapping) return []; const keys: string[] = [mapping.sourceKey]; if (mapping.destKey) keys.push(mapping.destKey); if (type === 'mount') { - keys.push('mountType', 'mountOpt'); + keys.push(MOUNT_TYPE_KEY); } else if (type === 'serve') { - keys.push('type'); - } else { - keys.push('_config'); - const flatDefs = staticFlagDefinitions[type] || []; - keys.push(...flatDefs.map(f => f.FieldName || f.Name)); + keys.push(SERVE_TYPE_KEY); } + const flatDefs = staticFlagDefinitions[type as OperationType] || []; + keys.push(...flatDefs.map(f => f.Name || f.FieldName)); + return keys; } @@ -214,36 +188,23 @@ export interface FormToConfigContext { joinPath(...segments: string[]): string; }; runtimeRemoteProfileNames?: string[]; - cleanData?: (options: Record, fields: RcConfigOption[]) => Record; + cleanData?: ( + options: Record, + fields: RcConfigOption[] + ) => Record; dynamicFields?: RcConfigOption[]; flatOptionNames?: Set; } -export function mapFormToConfigProfile( - type: string, - formData: Record, - ctx: FormToConfigContext -): Record { - const mapping = OPERATION_PATH_MAPPINGS[type]; - - if (!mapping) { - if (type === 'runtimeRemote' && ctx.cleanData && ctx.dynamicFields) { - const cleaned = { ...ctx.cleanData(formData, ctx.dynamicFields) }; - delete cleaned['type']; - return { [ctx.remoteName]: cleaned }; - } - if (formData['options'] && ctx.cleanData && ctx.dynamicFields) { - return ctx.cleanData(formData['options'], ctx.dynamicFields); - } - return {}; - } - - const app: Record = { +function buildAppConfig(formData: Record): Record { + const app: Record = { autoStart: formData['autoStart'] ?? false, + showOnTray: formData['showOnTray'] !== undefined ? formData['showOnTray'] : true, cronEnabled: formData['cronEnabled'] ?? false, cronExpression: formData['cronExpression'] ?? null, watchEnabled: formData['watchEnabled'] ?? false, watchDelay: formData['watchDelay'] ?? 5, + watchChangedOnly: formData['watchChangedOnly'] ?? false, vfsProfile: formData['vfsProfile'] || undefined, filterProfile: formData['filterProfile'] || undefined, backendProfile: formData['backendProfile'] || undefined, @@ -252,95 +213,121 @@ export function mapFormToConfigProfile( if ('runtimeRemoteProfile' in formData) { const selectedProfile = String(formData['runtimeRemoteProfile'] || '').trim(); app['runtimeRemoteProfile'] = - selectedProfile && ctx.runtimeRemoteProfileNames?.includes(selectedProfile) - ? selectedProfile - : undefined; + selectedProfile && selectedProfile !== 'Default' ? selectedProfile : undefined; } - const rclone: Record = {}; - - if (formData['source'] !== undefined) { - if (type === 'copyurl') { - const sources = Array.isArray(formData['source']) ? formData['source'] : [formData['source']]; - const urls = sources - .map((s: any) => (typeof s === 'string' ? s : s?.path || '')) - .filter(Boolean); - rclone[mapping.sourceKey] = mapping.isSourceArray - ? urls.length > 1 - ? urls - : (urls[0] ?? '') - : (urls[0] ?? ''); - - const filenames = sources.map((s: any) => s?.filename || ''); - if (filenames.some(Boolean)) { - rclone['filenames'] = filenames; - if (formData['options']) { - formData['options']['autoFilename'] = false; - } - } else { - if (formData['options']) { - formData['options']['autoFilename'] = true; - } - } - } else { - const sourcePaths = ctx.pathService.buildPathStrings( - Array.isArray(formData['source']) ? formData['source'] : [formData['source']], - ctx.remoteName - ); - rclone[mapping.sourceKey] = mapping.isSourceArray - ? sourcePaths.length > 1 - ? sourcePaths - : (sourcePaths[0] ?? '') - : (sourcePaths[0] ?? ''); - } - } + return app; +} - if (mapping.destKey && formData['dest'] !== undefined) { - rclone[mapping.destKey] = ctx.pathService.buildPathString(formData['dest'], ctx.remoteName); +function mapSourcePaths( + type: string, + formData: Record, + mapping: PathMappingInfo, + ctx: FormToConfigContext +): Record { + if (formData['source'] === undefined) return {}; + + if (type === 'copyurl') { + return mapCopyUrlPaths(formData, mapping); } + const sources = Array.isArray(formData['source']) ? formData['source'] : [formData['source']]; + const sourcePaths = ctx.pathService.buildPathStrings( + sources as PathGroup | PathGroup[], + ctx.remoteName + ); + const sourceValue = + mapping.isSourceArray && sourcePaths.length > 1 ? sourcePaths : (sourcePaths[0] ?? ''); + return { [mapping.sourceKey]: sourceValue }; +} + +function mapCopyUrlPaths( + formData: Record, + mapping: PathMappingInfo +): Record { + const sources = Array.isArray(formData['source']) ? formData['source'] : [formData['source']]; + const urls = (sources as ({ path?: string } | string)[]) + .map(s => (typeof s === 'string' ? s : s?.path || '')) + .filter(Boolean); + const filenames = (sources as { filename?: string }[]).map(s => s?.filename || ''); + const hasFilenames = filenames.some(Boolean); + + const rclone: Record = { + [mapping.sourceKey]: mapping.isSourceArray + ? urls.length > 1 + ? urls + : (urls[0] ?? '') + : (urls[0] ?? ''), + autoFilename: !hasFilenames, + }; + if (hasFilenames) rclone['filenames'] = filenames; + + return rclone; +} + +function mapMountServeType( + type: string, + formData: Record +): Record { if (type === 'mount') { - const val = formData['options']?.['mountType']; - if (val && val !== 'mount') { - rclone['mountType'] = val; + const val = (formData['options'] as Record | undefined)?.[MOUNT_TYPE_KEY]; + return val && val !== DEFAULT_MOUNT_TYPE ? { mountType: val } : {}; + } + if (type === 'serve') { + const val = (formData['options'] as Record | undefined)?.[SERVE_TYPE_KEY]; + return val && val !== DEFAULT_SERVE_TYPE ? { type: val } : {}; + } + return {}; +} + +function cleanOptions( + formData: Record, + ctx: FormToConfigContext +): Record { + if (!formData['options'] || !ctx.cleanData || !ctx.dynamicFields) return {}; + const cleanedOptions = { + ...ctx.cleanData(formData['options'] as Record, ctx.dynamicFields), + }; + delete cleanedOptions[SERVE_TYPE_KEY]; + delete cleanedOptions[MOUNT_TYPE_KEY]; + delete cleanedOptions['autoFilename']; + return Object.keys(cleanedOptions).length > 0 ? cleanedOptions : {}; +} + +export function mapFormToConfigProfile( + type: string, + formData: Record, + ctx: FormToConfigContext +): Record { + const mapping = OPERATION_PATH_MAPPINGS[type as SharedProfileType]; + + if (!mapping) { + if (type === 'runtimeRemote' && ctx.cleanData && ctx.dynamicFields) { + const cleaned = { ...ctx.cleanData(formData, ctx.dynamicFields) }; + delete cleaned[SERVE_TYPE_KEY]; + return { [ctx.remoteName]: cleaned }; } - } else if (type === 'serve') { - const val = formData['options']?.['type']; - if (val && val !== 'http') { - rclone['type'] = val; + if (formData['options'] && ctx.cleanData && ctx.dynamicFields) { + return ctx.cleanData(formData['options'] as Record, ctx.dynamicFields); } + return {}; } - if (formData['options'] && ctx.cleanData && ctx.dynamicFields) { - const cleanedOptions = { ...ctx.cleanData(formData['options'], ctx.dynamicFields) }; - delete cleanedOptions['type']; - delete cleanedOptions['mountType']; + const app = buildAppConfig(formData); + const rclone: Record = { + ...mapSourcePaths(type, formData, mapping, ctx), + }; - if (type === 'mount') { - if (Object.keys(cleanedOptions).length > 0) { - rclone['mountOpt'] = cleanedOptions; - } - } else if (type === 'serve') { - if (Object.keys(cleanedOptions).length > 0) { - Object.assign(rclone, cleanedOptions); - } - } else if (ctx.flatOptionNames) { - const flatOptions: Record = {}; - const nestedOptions: Record = {}; - for (const [k, v] of Object.entries(cleanedOptions)) { - if (ctx.flatOptionNames.has(k)) { - flatOptions[k] = v; - } else { - nestedOptions[k] = v; - } - } - Object.assign(rclone, flatOptions); - if (Object.keys(nestedOptions).length > 0) { - rclone['_config'] = nestedOptions; - } - } + if (mapping.destKey && formData['dest'] !== undefined) { + rclone[mapping.destKey] = ctx.pathService.buildPathString( + formData['dest'] as PathGroup | string, + ctx.remoteName + ); } + Object.assign(rclone, mapMountServeType(type, formData)); + Object.assign(rclone, cleanOptions(formData, ctx)); + return { app, rclone }; } @@ -359,112 +346,154 @@ export interface ConfigToFormContext { }; } -export function mapConfigToFormProfile( - type: string, - config: Record, - ctx: ConfigToFormContext -): Record { - const appConfig = config['app'] || config; - const rcloneConfig = config['rclone'] || config; - - const result: Record = { +function buildAppConfigResult(appConfig: Record): Record { + return { autoStart: appConfig['autoStart'] ?? false, + showOnTray: appConfig['showOnTray'] !== undefined ? appConfig['showOnTray'] : true, cronEnabled: appConfig['cronEnabled'] ?? false, cronExpression: appConfig['cronExpression'] ?? null, watchEnabled: appConfig['watchEnabled'] ?? false, watchDelay: appConfig['watchDelay'] ?? 5, + watchChangedOnly: appConfig['watchChangedOnly'] ?? false, vfsProfile: appConfig['vfsProfile'] || 'Default', filterProfile: appConfig['filterProfile'] || 'Default', backendProfile: appConfig['backendProfile'] || 'Default', runtimeRemoteProfile: appConfig['runtimeRemoteProfile'] || 'Default', }; +} - const mapping = OPERATION_PATH_MAPPINGS[type]; - if (mapping) { - const sourceVal = rcloneConfig[mapping.sourceKey]; - const configSources = ( - Array.isArray(sourceVal) ? sourceVal : sourceVal ? [sourceVal] : [] - ) as string[]; - - if (type === 'copyurl') { - const filenames = rcloneConfig['filenames'] as string[] | undefined; - const autoFilename = - rcloneConfig['autoFilename'] ?? rcloneConfig['_config']?.['autoFilename'] ?? false; - const destVal = (mapping.destKey ? rcloneConfig[mapping.destKey] : '') ?? ''; - const parsedDst = ctx.pathService.parseFsString( - destVal, - 'local', - ctx.remoteName, - ctx.existingRemotes - ); - - let legacyFilename = ''; - if (!filenames && !autoFilename && parsedDst.path) { - legacyFilename = ctx.pathService.getFilename(parsedDst.path); - parsedDst.path = ctx.pathService.getParentPath(parsedDst.path); - } +function mapSourceToForm( + type: string, + rcloneConfig: Record, + mapping: PathMappingInfo, + ctx: ConfigToFormContext +): Record { + const sourceVal = rcloneConfig[mapping.sourceKey]; + const configSources = ( + Array.isArray(sourceVal) ? sourceVal : sourceVal ? [sourceVal] : [] + ) as string[]; + + if (type === 'copyurl') { + return mapCopyUrlToForm(rcloneConfig, configSources, mapping, ctx); + } - result['source'] = configSources.map((s, idx) => ({ - type: 'local', - path: s, - remote: '', - filename: filenames?.[idx] || (idx === 0 ? legacyFilename : ''), - })); - result['dest'] = parsedDst; - } else { - if (mapping.isSourceArray) { - result['source'] = configSources.map(s => - ctx.pathService.parseFsString(s, 'currentRemote', ctx.remoteName, ctx.existingRemotes) - ); - } else { - const parsedSrc = ctx.pathService.parseFsString( - configSources[0] ?? '', - 'currentRemote', - ctx.remoteName, - ctx.existingRemotes - ); - if (type === 'mount' || type === 'serve') { - parsedSrc.type = 'currentRemote'; - parsedSrc.remote = ''; - } - result['source'] = parsedSrc; - } + if (mapping.isSourceArray) { + return { + source: configSources.map(s => + ctx.pathService.parseFsString(s, 'local', ctx.remoteName, ctx.existingRemotes) + ), + }; + } - if (mapping.destKey) { - const destVal = rcloneConfig[mapping.destKey] ?? ''; - const parsedDst = ctx.pathService.parseFsString( - destVal, - 'local', - ctx.remoteName, - ctx.existingRemotes - ); - if (type === 'mount') { - parsedDst.type = 'local'; - parsedDst.remote = ''; - } - result['dest'] = parsedDst; - } - } + const parsedSrc = ctx.pathService.parseFsString( + configSources[0] ?? '', + 'currentRemote', + ctx.remoteName, + ctx.existingRemotes + ); + if (type === 'mount' || type === 'serve') { + parsedSrc.type = 'currentRemote'; + parsedSrc.remote = ''; } + return { source: parsedSrc }; +} - const incomingOptions: Record = {}; +function mapCopyUrlToForm( + rcloneConfig: Record, + configSources: string[], + mapping: PathMappingInfo, + ctx: ConfigToFormContext +): Record { + const filenames = rcloneConfig['filenames'] as string[] | undefined; + const autoFilename = rcloneConfig['autoFilename'] ?? false; + const destVal = (mapping.destKey ? rcloneConfig[mapping.destKey] : '') ?? ''; + const parsedDst = ctx.pathService.parseFsString( + destVal as string, + 'local', + ctx.remoteName, + ctx.existingRemotes + ); + + let legacyFilename = ''; + if (!filenames && !autoFilename && parsedDst.path) { + legacyFilename = ctx.pathService.getFilename(parsedDst.path); + parsedDst.path = ctx.pathService.getParentPath(parsedDst.path); + } + + return { + source: configSources.map((s, idx) => ({ + type: 'local', + path: s, + remote: '', + filename: filenames?.[idx] || (idx === 0 ? legacyFilename : ''), + })), + dest: parsedDst, + }; +} + +function mapDestToForm( + type: string, + rcloneConfig: Record, + mapping: PathMappingInfo, + ctx: ConfigToFormContext +): Record { + if (!mapping.destKey) return {}; + const destVal = rcloneConfig[mapping.destKey] ?? ''; + const parsedDst = ctx.pathService.parseFsString( + destVal as string, + 'local', + ctx.remoteName, + ctx.existingRemotes + ); + if (type === 'mount') { + parsedDst.type = 'local'; + parsedDst.remote = ''; + } + return { dest: parsedDst }; +} + +function collectIncomingOptions(rcloneConfig: Record): Record { + const incomingOptions: Record = {}; for (const [k, v] of Object.entries(rcloneConfig)) { - if (!CONFIG_METADATA_KEYS.has(k)) { + if (CONFIG_METADATA_KEYS.has(k)) continue; + + if (LEGACY_FLATTEN_KEYS.has(k)) { + if (v && typeof v === 'object' && !Array.isArray(v)) { + for (const [nk, nv] of Object.entries(v as Record)) { + incomingOptions[nk] = nv; + } + } + } else { incomingOptions[k] = v; } } - const nestedKey = type === 'mount' ? 'mountOpt' : '_config'; - const nested = rcloneConfig[nestedKey]; - if (nested && typeof nested === 'object') { - Object.assign(incomingOptions, nested); + return incomingOptions; +} + +export function mapConfigToFormProfile( + type: string, + config: Record, + ctx: ConfigToFormContext +): Record { + const appConfig = (config['app'] as Record) || config; + const rcloneConfig = (config['rclone'] as Record) || config; + + const result: Record = buildAppConfigResult(appConfig); + + const mapping = OPERATION_PATH_MAPPINGS[type as SharedProfileType]; + if (mapping) { + Object.assign(result, mapSourceToForm(type, rcloneConfig, mapping, ctx)); + Object.assign(result, mapDestToForm(type, rcloneConfig, mapping, ctx)); } + const incomingOptions = collectIncomingOptions(rcloneConfig); + if (type === 'mount') { - incomingOptions['mountType'] = rcloneConfig['mountType'] || null; + incomingOptions[MOUNT_TYPE_KEY] = rcloneConfig[MOUNT_TYPE_KEY] || null; } else if (type === 'serve') { - incomingOptions['type'] = rcloneConfig['type'] || null; + incomingOptions[SERVE_TYPE_KEY] = rcloneConfig[SERVE_TYPE_KEY] || null; } result['options'] = incomingOptions; diff --git a/src/app/services/security/auth-state.service.ts b/src/app/services/security/auth-state.service.ts index 7548d3af8..9b14a5e21 100644 --- a/src/app/services/security/auth-state.service.ts +++ b/src/app/services/security/auth-state.service.ts @@ -1,15 +1,13 @@ -import { computed, DestroyRef, inject, Injectable, signal } from '@angular/core'; +import { DestroyRef, inject, Injectable, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { RemoteManagementService } from '../../services/remote/remote-management.service'; import { EventListenersService } from '../infrastructure/system/event-listeners.service'; -import { BackendService } from '../infrastructure/system/backend.service'; @Injectable({ providedIn: 'root' }) export class AuthStateService { private readonly destroyRef = inject(DestroyRef); private readonly remoteManagementService = inject(RemoteManagementService); private readonly eventListenersService = inject(EventListenersService); - private readonly backendService = inject(BackendService); private readonly _isAuthInProgress = signal(false); private readonly _currentRemoteName = signal(null); @@ -21,17 +19,6 @@ export class AuthStateService { public readonly isAuthInProgress = this._isAuthInProgress.asReadonly(); public readonly isAuthCancelled = this._isAuthCancelled.asReadonly(); public readonly oauthUrl = this._oauthUrl.asReadonly(); - public readonly isActiveBackendLocal = computed(() => { - const activeBackend = this.backendService.activeBackend(); - if (activeBackend === 'Local') return true; - return ( - this.backendService.backends().find(backend => backend.name === activeBackend)?.isLocal ?? - true - ); - }); - public readonly shouldShowRemoteOAuthFallback = computed( - () => this._isAuthInProgress() && !this.isActiveBackendLocal() && !this._oauthUrl() - ); constructor() { this.eventListenersService @@ -71,12 +58,10 @@ export class AuthStateService { console.debug('Cancelling auth for remote:', remoteName, 'in edit mode:', isEditMode); - if (this.isActiveBackendLocal()) { - try { - await this.remoteManagementService.quitOAuth(); - } catch (error) { - console.warn('Error quitting local OAuth process:', error); - } + try { + await this.remoteManagementService.quitOAuth(); + } catch (error) { + console.warn('Error quitting OAuth process:', error); } // Delete remote if it's not in edit mode diff --git a/src/app/services/settings/app-settings.service.ts b/src/app/services/settings/app-settings.service.ts index 12bb8320b..a6a06e54c 100644 --- a/src/app/services/settings/app-settings.service.ts +++ b/src/app/services/settings/app-settings.service.ts @@ -117,8 +117,6 @@ export class AppSettingsService extends TauriBaseService { if (confirmed) { await this.invokeCommand('reset_settings'); - this._options.set(null); - await this.loadSettings(); this.notificationService.showSuccess(this.translate.instant('settings.resetSuccess')); return true; } diff --git a/src/app/services/settings/installation.service.ts b/src/app/services/settings/installation.service.ts index 07544274e..b2a97972b 100644 --- a/src/app/services/settings/installation.service.ts +++ b/src/app/services/settings/installation.service.ts @@ -1,5 +1,8 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { TauriBaseService } from '../infrastructure/platform/tauri-base.service'; +import { EventListenersService } from '../infrastructure/system/event-listeners.service'; +import { ProvisionProgressPayload, ProvisionStatus } from '@app/types'; /** * Service for handling installations of rclone and plugins @@ -9,19 +12,87 @@ import { TauriBaseService } from '../infrastructure/platform/tauri-base.service' providedIn: 'root', }) export class InstallationService extends TauriBaseService { + private readonly eventListenersService = inject(EventListenersService); + + readonly rcloneProgress = signal(null); + readonly mountPluginProgress = signal(null); + + constructor() { + super(); + this.eventListenersService + .listenToProvisionProgress() + .pipe(takeUntilDestroyed()) + .subscribe(payload => { + const isTerminal = + payload.stage === 'completed' || + payload.stage === 'cancelled' || + payload.stage === 'error'; + const progress = isTerminal ? null : payload; + + if (payload.component === 'rclone') { + this.rcloneProgress.set(progress); + } else if (payload.component === 'mountPlugin') { + this.mountPluginProgress.set(progress); + } + }); + + this.restoreProvisionStatus(); + } + + private async restoreProvisionStatus(): Promise { + try { + const status = await this.invokeCommand('get_provision_status'); + if (status?.rclone) { + this.rcloneProgress.set(status.rclone); + } + if (status?.mountPlugin) { + this.mountPluginProgress.set(status.mountPlugin); + } + } catch { + // Ignore if unsupported (e.g. mobile/librclone) or in non-Tauri environment + } + } + + /** + * Check if an error represents a user-initiated download cancellation + */ + isCancellationError(error: unknown): boolean { + const msg = error instanceof Error ? error.message : typeof error === 'string' ? error : ''; + return msg.includes('downloadCancelled'); + } + /** * Install rclone to the system * @param path Optional custom installation path. If null, uses default location */ async installRclone(path?: string | null): Promise { - return this.invokeWithNotification( - 'provision_rclone', - { path }, - { - errorKey: 'repairSheet.errors.rcloneInstallFailed', - errorParams: { errorKey: 'repairSheet.errors.rcloneInstallFailed' }, + try { + return await this.invokeCommand('provision_rclone', { path }); + } catch (error) { + this.rcloneProgress.set(null); + if (!this.isCancellationError(error)) { + const translatedError = this.backendTranslation.translateBackendMessage(error); + this.notificationService.showError( + this.translate.instant('repairSheet.errors.rcloneInstallFailed', { + error: translatedError, + }) + ); } - ); + throw error; + } + } + + /** + * Cancel in-flight rclone provisioning/download + */ + async cancelRcloneInstall(): Promise { + try { + await this.invokeCommand('cancel_provision_rclone'); + } catch (error) { + console.error('Failed to cancel rclone provisioning:', error); + } finally { + this.rcloneProgress.set(null); + } } /** @@ -46,9 +117,36 @@ export class InstallationService extends TauriBaseService { * Install the mount plugin */ async installMountPlugin(): Promise { - return this.invokeWithNotification('install_mount_plugin', undefined, { - successKey: 'backendSuccess.rclone.mountPluginInstalled', - errorKey: 'backendErrors.rclone.mountPluginInstallFailed', - }); + try { + const res = await this.invokeCommand('install_mount_plugin'); + this.notificationService.showSuccess( + this.translate.instant('backendSuccess.rclone.mountPluginInstalled') + ); + return res; + } catch (error) { + this.mountPluginProgress.set(null); + if (!this.isCancellationError(error)) { + const translatedError = this.backendTranslation.translateBackendMessage(error); + this.notificationService.showError( + this.translate.instant('backendErrors.rclone.mountPluginInstallFailed', { + error: translatedError, + }) + ); + } + throw error; + } + } + + /** + * Cancel in-flight mount plugin installation/download + */ + async cancelMountPluginInstall(): Promise { + try { + await this.invokeCommand('cancel_mount_plugin_install'); + } catch (error) { + console.error('Failed to cancel mount plugin install:', error); + } finally { + this.mountPluginProgress.set(null); + } } } diff --git a/src/app/services/ui/android-share.service.ts b/src/app/services/ui/android-share.service.ts index ae4fe2254..773759d42 100644 --- a/src/app/services/ui/android-share.service.ts +++ b/src/app/services/ui/android-share.service.ts @@ -21,9 +21,12 @@ export class AndroidShareService { /** Absolute local paths of files shared into the app from other apps. */ readonly pendingSharedPaths = signal([]); + private initialized = false; + /** Call once from AppComponent to start listening for share events. */ initialize(): void { - if (!isMobile()) return; + if (!isMobile() || this.initialized) return; + this.initialized = true; window.addEventListener('android-share-files', (event: Event) => { const detail = (event as CustomEvent<{ paths: string[] }>).detail; diff --git a/src/app/services/ui/base-slide-overlay.service.ts b/src/app/services/ui/base-slide-overlay.service.ts new file mode 100644 index 000000000..3e35d00c1 --- /dev/null +++ b/src/app/services/ui/base-slide-overlay.service.ts @@ -0,0 +1,111 @@ +import { Injectable, ComponentRef, inject, signal, Type } from '@angular/core'; +import { Overlay, OverlayRef } from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { take } from 'rxjs'; + +import { TauriBaseService } from '../infrastructure/platform/tauri-base.service'; +import { isMobile } from '../infrastructure/platform/api-client.service'; + +@Injectable() +export abstract class BaseSlideOverlayService extends TauriBaseService { + protected readonly overlay = inject(Overlay); + + protected overlayRef: OverlayRef | null = null; + protected componentRef: ComponentRef | null = null; + private isOpening = false; + + protected readonly _isOpen = signal(false); + readonly isOpen = this._isOpen.asReadonly(); + + protected readonly _isStandaloneWindow = signal(false); + readonly isStandaloneWindow = this._isStandaloneWindow.asReadonly(); + + protected abstract loadComponent(): Promise>; + protected abstract getStandaloneConfig(): { url: string; label: string; title: string }; + protected abstract detectStandaloneWindow(): boolean; + + constructor() { + super(); + this._isStandaloneWindow.set(this.detectStandaloneWindow()); + } + + async openOverlay(): Promise { + if (this.overlayRef || this.isOpening) return; + this.isOpening = true; + this._isOpen.set(true); + + const overlayRef = this.overlay.create({ + positionStrategy: this.overlay.position().global().top('0').left('0').bottom('0'), + width: '100vw', + height: '100dvh', + scrollStrategy: this.overlay.scrollStrategies.block(), + }); + + try { + const componentType = await this.loadComponent(); + const componentRef = overlayRef.attach(new ComponentPortal(componentType)); + + const host = componentRef.location.nativeElement as HTMLElement; + host.classList.add('slide-overlay-left-enter'); + host.style.width = '100vw'; + host.style.height = '100dvh'; + + overlayRef + .backdropClick() + .pipe(take(1)) + .subscribe(() => this.closeOverlay()); + + this.overlayRef = overlayRef; + this.componentRef = componentRef; + } finally { + this.isOpening = false; + } + } + + closeOverlay(): void { + if (!this.overlayRef) return; + this._isOpen.set(false); + + const overlayRefToDispose = this.overlayRef; + const componentRefToAnimate = this.componentRef; + this.overlayRef = null; + this.componentRef = null; + + componentRefToAnimate?.location.nativeElement.classList.add('slide-overlay-left-leave'); + setTimeout(() => overlayRefToDispose.dispose(), 200); + } + + toggleOverlay(): void { + if (this._isOpen()) { + this.closeOverlay(); + } else { + void this.openOverlay(); + } + } + + async detachToStandaloneWindow(): Promise { + const config = this.getStandaloneConfig(); + if (this.isTauri && !isMobile()) { + try { + await this.invokeCommand('new_window', { + opts: { + label: config.label, + url: config.url, + title: config.title, + width: 1024, + height: 768, + }, + }); + this.closeOverlay(); + return; + } catch (err) { + console.warn( + `[${this.constructor.name}] new_window failed, falling back to window.open:`, + err + ); + } + } + window.open(config.url, '_blank'); + this.closeOverlay(); + } +} diff --git a/src/app/services/ui/constants/icon-registry.ts b/src/app/services/ui/constants/icon-registry.ts index 4a842f660..1fff95226 100644 --- a/src/app/services/ui/constants/icon-registry.ts +++ b/src/app/services/ui/constants/icon-registry.ts @@ -20,6 +20,8 @@ export const BASE_ICONS: Record = { 'arrow-down': 'assets/icons/navigation/circle-arrow-down.svg', 'right-left': 'assets/icons/navigation/right-left.svg', bisync: 'assets/icons/navigation/right-left.svg', + 'left-panel-open': 'assets/icons/navigation/left-panel-open.svg', + 'left-panel-close': 'assets/icons/navigation/left-panel-close.svg', // ------------------- Status Icons ------------------- 'check-circle': 'assets/icons/status/check-circle.svg', @@ -114,6 +116,9 @@ export const BASE_ICONS: Record = { bug: 'assets/icons/general/bug.svg', chart: 'assets/icons/general/chart.svg', experiment: 'assets/icons/general/experiment.svg', + flow: 'assets/icons/general/flow.svg', + 'quick-run': 'assets/icons/general/quick-run.svg', + workflow: 'assets/icons/general/workflow.svg', jobs: 'assets/icons/general/jobs.svg', terminal: 'assets/icons/general/terminal.svg', wrench: 'assets/icons/general/wrench.svg', diff --git a/src/app/services/ui/file-viewer.service.ts b/src/app/services/ui/file-viewer.service.ts index 411ab3e0a..8a17201d3 100644 --- a/src/app/services/ui/file-viewer.service.ts +++ b/src/app/services/ui/file-viewer.service.ts @@ -5,39 +5,16 @@ import { ComponentPortal } from '@angular/cdk/portal'; import { platform } from '@tauri-apps/plugin-os'; import { Entry } from '@app/types'; import { take } from 'rxjs/operators'; -import { IconService } from './icon.service'; import { PathService } from '../infrastructure/platform/path.service'; import { PathNavigationService } from '../infrastructure/platform/path-navigation.service'; -import { isHeadlessMode, isMobile } from '../infrastructure/platform/api-client.service'; +import { isMobile } from '../infrastructure/platform/api-client.service'; import { TauriBaseService } from '../infrastructure/platform/tauri-base.service'; -/** - * Whether the *client* WebView can register a custom URL scheme - * (`local-asset://`, `rclone://`, `audio-cover://`). Windows WebView2 and - * mobile WebViews do not reliably support custom schemes, so we fall back - * to `http://*.localhost`. - * - * This is a CLIENT-OS concern (where is the Tauri WebView running?), - * deliberately distinct from the path-style decision (which derives from - * the *engine* OS via `BackendInfo.os`). The two can legitimately differ — - * e.g. a Linux rclone engine driven from a Windows client. - */ -function supportsCustomUrlScheme(): boolean { - if (isHeadlessMode()) return false; - try { - const p = platform(); - return p !== 'windows' && !isMobile(); - } catch { - return !isMobile(); - } -} - @Injectable({ providedIn: 'root', }) export class FileViewerService extends TauriBaseService { private readonly overlay = inject(Overlay); - private readonly iconService = inject(IconService); private readonly pathService = inject(PathService); private readonly pathNavigationService = inject(PathNavigationService); @@ -47,6 +24,22 @@ export class FileViewerService extends TauriBaseService { private readonly _activeFileName = signal(null); public readonly activeFileName = this._activeFileName.asReadonly(); + /** + * Whether the client WebView can register a custom URL scheme + * (`local-asset://`, `rclone://`, `audio-cover://`). Windows WebView2 and + * mobile WebViews do not reliably support custom schemes, so we fall back + * to `http://*.localhost`. + */ + private supportsCustomUrlScheme(): boolean { + if (!this.isTauri) return false; + try { + const p = platform(); + return p !== 'windows' && !isMobile(); + } catch { + return !isMobile(); + } + } + setActiveFileName(name: string | null): void { this._activeFileName.set(name); } @@ -94,31 +87,27 @@ export class FileViewerService extends TauriBaseService { .subscribe(() => cleanup()); } - async getFileType(item: Entry, _remoteName: string, _isLocal: boolean): Promise { - return this.iconService.getFileTypeCategory(item); - } - async getAudioCover(item: Entry, remoteName: string, isLocal: boolean): Promise { const path = item.Path; if (isLocal) { const fullPath = this.pathService.joinPath(remoteName, path); - if (isHeadlessMode()) { + if (!this.isTauri) { const encodedPath = encodeURIComponent(fullPath); return `${this.apiClient.getApiBase()}/stream/audio-cover?path=${encodedPath}`; } - if (supportsCustomUrlScheme()) { + if (this.supportsCustomUrlScheme()) { return `audio-cover://localhost/local/${encodeURIComponent(fullPath)}`; } return `http://audio-cover.localhost/local/${encodeURIComponent(fullPath)}`; } else { - if (isHeadlessMode()) { + if (!this.isTauri) { const encodedRemote = encodeURIComponent(remoteName); const encodedPath = encodeURIComponent(path); return `${this.apiClient.getApiBase()}/stream/audio-cover?path=${encodedPath}&remote=${encodedRemote}`; } - if (supportsCustomUrlScheme()) { + if (this.supportsCustomUrlScheme()) { return `audio-cover://localhost/remote/${encodeURIComponent(remoteName)}/${encodeURIComponent( path )}`; @@ -161,7 +150,7 @@ export class FileViewerService extends TauriBaseService { if (isLocal) { const fullPath = this.pathService.joinPath(remoteName, path); - if (isHeadlessMode()) { + if (!this.isTauri) { const encodedPath = encodeURIComponent(fullPath); return `${this.apiClient.getApiBase()}/stream?path=${encodedPath}`; } @@ -172,7 +161,7 @@ export class FileViewerService extends TauriBaseService { // (absolute vs relative), not an OS inference. const encodedSegments = this.pathNavigationService.encodePath(fullPath); - if (supportsCustomUrlScheme()) { + if (this.supportsCustomUrlScheme()) { const pathWithSlash = encodedSegments.startsWith('/') ? encodedSegments : `/${encodedSegments}`; @@ -188,7 +177,7 @@ export class FileViewerService extends TauriBaseService { const rName = remoteName.endsWith(':') ? remoteName : `${remoteName}:`; const encodedPath = this.pathNavigationService.encodePath(path); - if (isHeadlessMode()) { + if (!this.isTauri) { return `${this.apiClient.getApiBase()}/stream/remote?remote=${encodeURIComponent( rName )}&path=${encodedPath}`; @@ -196,7 +185,7 @@ export class FileViewerService extends TauriBaseService { const urlSafeRemote = this.pathService.normalizeRemoteName(rName); const encodedRemote = encodeURIComponent(urlSafeRemote); - if (supportsCustomUrlScheme()) { + if (this.supportsCustomUrlScheme()) { return `rclone://localhost/${encodedRemote}/${encodedPath}`; } return `http://rclone.localhost/${encodedRemote}/${encodedPath}`; diff --git a/src/app/services/ui/flow-overlay.service.ts b/src/app/services/ui/flow-overlay.service.ts new file mode 100644 index 000000000..7968fa4e6 --- /dev/null +++ b/src/app/services/ui/flow-overlay.service.ts @@ -0,0 +1,44 @@ +import { Injectable, Type } from '@angular/core'; +import type { FlowContainerComponent } from 'src/app/flow/flow-container.component'; +import { BaseSlideOverlayService } from './base-slide-overlay.service'; + +@Injectable({ providedIn: 'root' }) +export class FlowOverlayService extends BaseSlideOverlayService { + readonly isFlowOverlayOpen = this.isOpen; + + protected async loadComponent(): Promise> { + const { FlowContainerComponent } = await import('src/app/flow/flow-container.component'); + return FlowContainerComponent; + } + + protected getStandaloneConfig(): { url: string; label: string; title: string } { + return { + url: `${window.location.origin}/flow?standalone=flow`, + label: 'flow', + title: 'Flow Workspace', + }; + } + + protected detectStandaloneWindow(): boolean { + const urlParams = new URLSearchParams(window.location.search); + const hash = window.location.hash; + const label = this.getCurrentTauriWindow()?.label; + return ( + urlParams.get('standalone') === 'flow' || + (label?.startsWith('flow') ?? false) || + hash.startsWith('#/flow') + ); + } + + openFlowOverlay(): Promise { + return this.openOverlay(); + } + + closeFlowOverlay(): void { + this.closeOverlay(); + } + + toggleFlowOverlay(): void { + this.toggleOverlay(); + } +} diff --git a/src/app/services/ui/global-loading.service.ts b/src/app/services/ui/global-loading.service.ts index 557224ca6..0ce797c5b 100644 --- a/src/app/services/ui/global-loading.service.ts +++ b/src/app/services/ui/global-loading.service.ts @@ -1,4 +1,4 @@ -import { ComponentRef, DestroyRef, Injectable, inject, Injector } from '@angular/core'; +import { ComponentRef, DestroyRef, Injectable, inject } from '@angular/core'; import { Overlay, OverlayRef } from '@angular/cdk/overlay'; import { ComponentPortal } from '@angular/cdk/portal'; import { LoadingOverlayComponent } from '../../shared/components/loading-overlay/loading-overlay.component'; @@ -10,11 +10,10 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; providedIn: 'root', }) export class GlobalLoadingService { - private overlay = inject(Overlay); - private injector = inject(Injector); - private eventListenersService = inject(EventListenersService); - private translateService = inject(TranslateService); - private destroyRef = inject(DestroyRef); + private readonly overlay = inject(Overlay); + private readonly eventListenersService = inject(EventListenersService); + private readonly translateService = inject(TranslateService); + private readonly destroyRef = inject(DestroyRef); private overlayRef: OverlayRef | null = null; private componentRef: ComponentRef | null = null; private shutdownListenerInitialized = false; @@ -47,7 +46,7 @@ export class GlobalLoadingService { } if (!this.overlayRef.hasAttached()) { - const portal = new ComponentPortal(LoadingOverlayComponent, null, this.injector); + const portal = new ComponentPortal(LoadingOverlayComponent); this.componentRef = this.overlayRef.attach(portal); } diff --git a/src/app/services/ui/icon.service.ts b/src/app/services/ui/icon.service.ts index dd83ad9fa..8d69bba21 100644 --- a/src/app/services/ui/icon.service.ts +++ b/src/app/services/ui/icon.service.ts @@ -10,6 +10,85 @@ import { Entry } from '@app/types'; export type FileCategory = 'image' | 'video' | 'audio' | 'pdf' | 'directory' | 'binary' | 'text' | 'archive'; +const ARCHIVE_EXTENSIONS: ReadonlySet = new Set([ + 'zip', + 'rar', + '7z', + 'tar', + 'gz', + 'bz2', + 'xz', + 'tgz', + 'rcman', +]); + +const KNOWN_BINARY_EXTENSIONS: ReadonlySet = new Set([ + 'exe', + 'dll', + 'so', + 'dylib', + 'bin', + 'app', + 'zip', + 'rar', + '7z', + 'tar', + 'gz', + 'bz2', + 'xz', + 'tgz', + 'doc', + 'docx', + 'xls', + 'xlsx', + 'ppt', + 'pptx', + 'odt', + 'ods', + 'db', + 'sqlite', + 'mdb', + 'psd', + 'ai', + 'indd', + 'raw', + 'cr2', + 'nef', + 'o', + 'a', + 'lib', + 'class', + 'pyc', + 'jar', + 'img', + 'iso', + 'dmg', + 'qcow', + 'qcow2', + 'vdi', + 'vmdk', + 'vpc', + 'vhdx', +]); + +const FILE_TYPE_MAPPINGS: Record = { + image: 'image-x-generic', + video: 'video-x-generic', + audio: 'audio-x-generic', + pdf: 'application-pdf', + text: 'text-x-generic', + binary: 'package-x-generic', + archive: 'package-x-generic', + directory: 'folder-adw', +}; + +const FOLDER_ALIASES: Record = { + movies: 'folder-videos', + node_modules: 'folder-code', + downloads: 'folder-download', + home: 'go-home', +}; + @Injectable({ providedIn: 'root', }) @@ -27,18 +106,15 @@ export class IconService { } private registerIcons(): void { - // Start with base icons, then add Adwaita icons this.allIcons = { ...BASE_ICONS }; for (const [name, path] of Object.entries(ADWAITA_ICONS)) { const lower = name.toLowerCase(); - // Prefer base icons if key conflicts if (!this.allIcons[lower]) { this.allIcons[lower] = path; } } - // Register all icons with MatIconRegistry for (const [name, path] of Object.entries(this.allIcons)) { const normalizedPath = path.startsWith('/') ? path : `/${path}`; this.iconRegistry.addSvgIcon( @@ -77,19 +153,11 @@ export class IconService { if (entry.IsDir) { const lowerName = entry.Name.toLowerCase(); - // Special folder cases const folderKey = `folder-${lowerName}`; const resolved = this.resolveIcon(folderKey); if (resolved) return resolved; - // Common folder aliases - const aliases: Record = { - movies: 'folder-videos', - node_modules: 'folder-code', - downloads: 'folder-download', - home: 'go-home', - }; - if (aliases[lowerName]) return aliases[lowerName]; + if (FOLDER_ALIASES[lowerName]) return FOLDER_ALIASES[lowerName]; return 'folder-adw'; } @@ -98,14 +166,12 @@ export class IconService { const parts = entry.Name.split('.'); const extension = parts.length > 1 ? parts.pop()?.toLowerCase() : undefined; - // 1) Extension mapping if (extension && MIME_EXTENSION_MAP[extension]) { const extIcon = MIME_EXTENSION_MAP[extension]; const resolved = this.resolveIcon(extIcon); if (resolved) return resolved; } - // 2) MIME mapping if (rawMime) { const mimeIcon = getIconForMimeType(rawMime); if (mimeIcon) { @@ -113,11 +179,9 @@ export class IconService { if (resolved) return resolved; } - // 3) Normalized MIME (application/json -> application-json) const resolvedMime = this.resolveIcon(rawMime); if (resolvedMime) return resolvedMime; - // 4) Generic category fallback const genericIcon = getGenericIconForMimeType(rawMime); const resolvedGeneric = this.resolveIcon(genericIcon); if (resolvedGeneric) return resolvedGeneric; @@ -131,80 +195,24 @@ export class IconService { return 'directory'; } - // Check MIME type and extension const mimeType = item.MimeType; const extension = item.Name.split('.').pop()?.toLowerCase() || ''; - // Media types that need special HTML elements if (mimeType?.startsWith('image/')) return 'image'; if (mimeType?.startsWith('video/')) return 'video'; if (mimeType?.startsWith('audio/')) return 'audio'; if (mimeType === 'application/pdf') return 'pdf'; if (mimeType?.startsWith('text/')) return 'text'; - // Extension-based detection for media (when MIME is missing) if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'].includes(extension)) return 'image'; if (['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv'].includes(extension)) return 'video'; if (['mp3', 'wav', 'flac', 'aac', 'm4a'].includes(extension)) return 'audio'; if (extension === 'pdf') return 'pdf'; - // Archive types - const archiveExtensions = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'tgz', 'rcman']; - if (archiveExtensions.includes(extension)) return 'archive'; - - // Known binary extensions that definitely cannot be previewed - const knownBinary = [ - 'exe', - 'dll', - 'so', - 'dylib', - 'bin', - 'app', - 'zip', - 'rar', - '7z', - 'tar', - 'gz', - 'bz2', - 'xz', - 'tgz', - 'doc', - 'docx', - 'xls', - 'xlsx', - 'ppt', - 'pptx', - 'odt', - 'ods', - 'db', - 'sqlite', - 'mdb', - 'psd', - 'ai', - 'indd', - 'raw', - 'cr2', - 'nef', - 'o', - 'a', - 'lib', - 'class', - 'pyc', - 'jar', - 'img', - 'iso', - 'dmg', - 'qcow', - 'qcow2', - 'vdi', - 'vmdk', - 'vpc', - 'vhdx', - 'vdi', - ]; - - if (knownBinary.includes(extension)) { + if (ARCHIVE_EXTENSIONS.has(extension)) return 'archive'; + + if (KNOWN_BINARY_EXTENSIONS.has(extension)) { return 'binary'; } @@ -217,16 +225,6 @@ export class IconService { } getIconForFileType(fileType: string): string { - const mapping: Record = { - image: 'image-x-generic', - video: 'video-x-generic', - audio: 'audio-x-generic', - pdf: 'application-pdf', - text: 'text-x-generic', - binary: 'package-x-generic', - archive: 'package-x-generic', - directory: 'folder-adw', - }; - return mapping[fileType] || 'text-x-generic'; + return FILE_TYPE_MAPPINGS[fileType] || 'text-x-generic'; } } diff --git a/src/app/services/ui/main-ui-overlay.service.ts b/src/app/services/ui/main-ui-overlay.service.ts new file mode 100644 index 000000000..5214d5f66 --- /dev/null +++ b/src/app/services/ui/main-ui-overlay.service.ts @@ -0,0 +1,44 @@ +import { Injectable, Type } from '@angular/core'; +import type { MainUiContainerComponent } from 'src/app/layout/main-ui-container.component'; +import { BaseSlideOverlayService } from './base-slide-overlay.service'; + +@Injectable({ providedIn: 'root' }) +export class MainUiOverlayService extends BaseSlideOverlayService { + readonly isMainUiOverlayOpen = this.isOpen; + + protected async loadComponent(): Promise> { + const { MainUiContainerComponent } = await import('src/app/layout/main-ui-container.component'); + return MainUiContainerComponent; + } + + protected getStandaloneConfig(): { url: string; label: string; title: string } { + return { + url: `${window.location.origin}/?standalone=main`, + label: 'main-standalone', + title: 'RClone Manager', + }; + } + + protected detectStandaloneWindow(): boolean { + const urlParams = new URLSearchParams(window.location.search); + const hash = window.location.hash; + const label = this.getCurrentTauriWindow()?.label; + return ( + urlParams.get('standalone') === 'main' || + (label?.startsWith('main-') ?? false) || + hash.startsWith('#/main') + ); + } + + openMainUiOverlay(): Promise { + return this.openOverlay(); + } + + closeMainUiOverlay(): void { + this.closeOverlay(); + } + + toggleMainUiOverlay(): void { + this.toggleOverlay(); + } +} diff --git a/src/app/services/ui/modal.service.ts b/src/app/services/ui/modal.service.ts index fdd829659..bc0f16996 100755 --- a/src/app/services/ui/modal.service.ts +++ b/src/app/services/ui/modal.service.ts @@ -1,6 +1,10 @@ import { Injectable, inject, Injector, signal, Type } from '@angular/core'; -import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { TranslateService } from '@ngx-translate/core'; +import { + MatDialog, + MatDialogConfig, + MatDialogRef, + MAT_DIALOG_DATA, +} from '@angular/material/dialog'; import { Subject, Observable, from, switchMap } from 'rxjs'; import { Window, getCurrentWindow } from '@tauri-apps/api/window'; @@ -11,38 +15,15 @@ import { ABOUT_MODAL_SIZE, BackupAnalysis, JobInfo, + QuickRun, + QuickRunInput, + TemplateCategory, + PrimaryActionType, } from '@app/types'; -import { - ApiClientService, - isHeadlessMode, - isMobile, -} from '../infrastructure/platform/api-client.service'; +import { isMobile } from '../infrastructure/platform/api-client.service'; +import { TauriBaseService } from '../infrastructure/platform/tauri-base.service'; import { AppSettingsService } from '../settings/app-settings.service'; -const originalClose = MatDialogRef.prototype.close; -MatDialogRef.prototype.close = function (this: MatDialogRef, dialogResult?: any): void { - const container = this.id ? document.getElementById(this.id) : null; - const overlayElement = container?.closest('.cdk-overlay-pane'); - - if (overlayElement?.classList.contains('mobile-sheet-dialog') && window.innerWidth <= 450) { - if (container) { - if (container.classList.contains('closing')) { - return; - } - container.classList.add('closing'); - } - const backdrop = overlayElement.parentElement?.querySelector('.cdk-overlay-backdrop'); - if (backdrop) { - backdrop.classList.add('closing'); - } - setTimeout(() => { - originalClose.call(this, dialogResult); - }, 200); - } else { - originalClose.call(this, dialogResult); - } -}; - export interface RemoteConfigModalOptions { remoteName?: string; remoteType?: string; @@ -62,7 +43,7 @@ export interface PropertiesModalOptions { remoteName?: string; path?: string; isLocal?: boolean; - item?: any; + item?: unknown; remoteType?: string; features?: RemoteFeatures; height?: string; @@ -82,19 +63,36 @@ export interface RestorePreviewOptions { analysis: BackupAnalysis; } +export interface TemplateManagerModalOptions { + mode: 'save' | 'manage'; + currentValues?: Partial>>; +} + +export interface QuickRunEditorModalOptions { + quickRun?: QuickRun; + cloneData?: QuickRunInput | QuickRun; + initialOpType?: PrimaryActionType; + initialRemoteName?: string; +} + const sanitizeLabel = (str: string): string => str.replace(/[^a-zA-Z0-9_-]/g, '-'); -interface StandaloneOpts { +interface StandaloneOpts { type: string; title: string; - data?: any; + data?: D | null; width?: number; height?: number; suffix?: string; } -class AsyncDialogRef { - constructor(private readonly promise: Promise>) {} +export interface DialogRefLike { + afterClosed(): Observable; + close?(result?: R): void; +} + +export class AsyncDialogRef implements DialogRefLike { + constructor(private readonly promise: Promise>) {} afterClosed(): Observable { return from(this.promise).pipe(switchMap(ref => ref.afterClosed())); @@ -117,7 +115,7 @@ class ChildWindowRef { } } -class StandaloneWindowRef { +export class StandaloneWindowRef implements DialogRefLike { private readonly closed$ = new Subject(); afterClosed(): Observable { @@ -131,10 +129,8 @@ class StandaloneWindowRef { } @Injectable({ providedIn: 'root' }) -export class ModalService { +export class ModalService extends TauriBaseService { private readonly dialog = inject(MatDialog); - private readonly translate = inject(TranslateService); - private readonly apiClient = inject(ApiClientService); private readonly appSettings = inject(AppSettingsService); private readonly injector = inject(Injector); @@ -142,31 +138,64 @@ export class ModalService { new URLSearchParams(window.location.search).get('standalone') === 'dialog' ).asReadonly(); - private readonly _dialogComponent = signal | null>(null); + private readonly _dialogComponent = signal | null>(null); readonly dialogComponent = this._dialogComponent.asReadonly(); dialogInjector?: Injector; constructor() { + super(); + if (!isMobile()) return; + window.addEventListener('popstate', () => { if (this.dialog.openDialogs.length > 0) { const topmostDialog = this.dialog.openDialogs[this.dialog.openDialogs.length - 1]; topmostDialog.close(); + + topmostDialog.afterClosed().subscribe(() => { + if (this.dialog.openDialogs.length > 0) { + window.history.pushState({ modal: true }, ''); + } + }); } }); this.dialog.afterOpened.subscribe(dialogRef => { - window.history.pushState({ dialogId: dialogRef.id }, ''); + if (this.dialog.openDialogs.length === 1) { + window.history.pushState({ modal: true }, ''); + } - dialogRef.afterClosed().subscribe(() => { - if (window.history.state?.dialogId === dialogRef.id) { - window.history.back(); + const originalClose = dialogRef.close.bind(dialogRef); + dialogRef.close = (dialogResult?: unknown): void => { + const container = dialogRef.id ? document.getElementById(dialogRef.id) : null; + const overlayElement = container?.closest('.cdk-overlay-pane'); + + if (overlayElement?.classList.contains('mobile-sheet-dialog') && window.innerWidth <= 450) { + if (container) { + if (container.classList.contains('closing')) return; + container.classList.add('closing'); + } + const backdrop = overlayElement.parentElement?.querySelector('.cdk-overlay-backdrop'); + if (backdrop) { + backdrop.classList.add('closing'); + } + setTimeout(() => { + originalClose(dialogResult); + }, 200); + } else { + originalClose(dialogResult); } - }); + }; + }); + + this.dialog.afterAllClosed.subscribe(() => { + if (window.history.state?.modal) { + window.history.back(); + } }); } - private readonly loaders: Record Promise>> = { + private readonly loaders: Record Promise>> = { 'quick-add-remote': () => import('../../features/modals/remote-management/quick-add-remote/quick-add-remote.component').then( m => m.QuickAddRemoteComponent @@ -227,6 +256,18 @@ export class ModalService { import('../../shared/modals/archive-create-modal/archive-create-modal.component').then( m => m.ArchiveCreateModalComponent ), + 'quick-run-editor': () => + import('../../flow/quick-run/quick-run-editor/quick-run-editor.component').then( + m => m.QuickRunEditorComponent + ), + 'template-manager': () => + import('../../shared/remote-config/template-manager-modal/template-manager-modal.component').then( + m => m.TemplateManagerModalComponent + ), + 'delete-remote': () => + import('../../features/modals/remote/delete-remote-modal/delete-remote-modal.component').then( + m => m.DeleteRemoteModalComponent + ), }; async resolveDialogWindow(): Promise { @@ -241,7 +282,7 @@ export class ModalService { this._dialogComponent.set(await loader()); - let data: any = null; + let data: unknown = null; const raw = params.get('dialogData'); if (raw) { try { @@ -262,45 +303,54 @@ export class ModalService { private get standaloneEnabled(): boolean { return ( - (!isHeadlessMode() || !isMobile()) && + this.isTauri && + !isMobile() && this.appSettings.options()?.['general.standalone_dialogs']?.value === true ); } - private openModal( + private openModal( type: string, - config: any, - standalone?: Omit - ): any { + config: MatDialogConfig, + standalone?: Omit, 'type' | 'data'> + ): DialogRefLike { if (standalone && this.standaloneEnabled) { - return this.spawnStandaloneWindow({ type, data: config.data, ...standalone }); + return this.spawnStandaloneWindow({ + type, + data: config.data, + ...standalone, + }); } if (config.height && !config.panelClass) { config.panelClass = 'mobile-sheet-dialog'; } - const dialogPromise = this.loaders[type]().then(comp => this.dialog.open(comp, config)); - return new AsyncDialogRef(dialogPromise); + const dialogPromise = this.loaders[type]().then(comp => + this.dialog.open(comp, config) + ); + return new AsyncDialogRef(dialogPromise); } - private spawnStandaloneWindow(opts: StandaloneOpts): StandaloneWindowRef { + private spawnStandaloneWindow( + opts: StandaloneOpts + ): StandaloneWindowRef { const suffix = opts.suffix ? `-${sanitizeLabel(opts.suffix)}` : ''; const label = `dialog-${opts.type}${suffix}`; - const ref = new StandaloneWindowRef(); + const ref = new StandaloneWindowRef(); const encoded = opts.data ? encodeURIComponent(JSON.stringify(opts.data)) : ''; const url = `index.html?standalone=dialog&dialogType=${opts.type}${encoded ? `&dialogData=${encoded}` : ''}`; - this.openWindowAndBind(label, url, opts.title, opts.width, opts.height, ref); + void this.openWindowAndBind(label, url, opts.title, opts.width, opts.height, ref); return ref; } - private async openWindowAndBind( + private async openWindowAndBind( label: string, url: string, title: string, width: number | undefined, height: number | undefined, - ref: StandaloneWindowRef + ref: StandaloneWindowRef ): Promise { try { const created = await this.apiClient.invoke('new_window', { @@ -313,7 +363,7 @@ export class ModalService { const unlistenResult = await win.listen(`dialog-result-${label}`, ({ payload }) => { resultReceived = true; - ref.resolve(payload); + ref.resolve(payload as TResult); unlistenResult(); unlistenDestroyed(); }); @@ -328,7 +378,41 @@ export class ModalService { } } - openRemoteConfig(options: RemoteConfigModalOptions = {}): any { + openQuickRunEditor( + optionsOrTarget?: QuickRun | QuickRunEditorModalOptions, + initialOpType?: PrimaryActionType, + initialRemoteName?: string + ): DialogRefLike { + let data: QuickRunEditorModalOptions; + if (optionsOrTarget && ('name' in optionsOrTarget || 'id' in optionsOrTarget)) { + const isExisting = 'id' in optionsOrTarget && !!optionsOrTarget.id; + data = { + quickRun: isExisting ? (optionsOrTarget as QuickRun) : undefined, + cloneData: !isExisting ? (optionsOrTarget as QuickRunInput) : undefined, + initialOpType, + initialRemoteName, + }; + } else if (optionsOrTarget) { + data = optionsOrTarget as QuickRunEditorModalOptions; + } else { + data = { initialOpType, initialRemoteName }; + } + + return this.openModal( + 'quick-run-editor', + { ...CONFIG_MODAL_SIZE, disableClose: true, data }, + { + title: data.quickRun + ? this.translate.instant('flow.quickRun.editor.editTitle') + : this.translate.instant('flow.quickRun.editor.createTitle'), + width: 1024, + height: 860, + suffix: data.quickRun?.id ?? 'new', + } + ); + } + + openRemoteConfig(options: RemoteConfigModalOptions = {}): DialogRefLike { const data = { name: options.remoteName, remoteType: options.remoteType, @@ -353,7 +437,7 @@ export class ModalService { ); } - openLogs(remoteName: string): any { + openLogs(remoteName: string): DialogRefLike { const data = { remoteName }; return this.openModal( 'logs', @@ -367,7 +451,7 @@ export class ModalService { ); } - openExport(options: ExportModalOptions = {}): any { + openExport(options: ExportModalOptions = {}): DialogRefLike { const data = { remoteName: options.remoteName, defaultExportType: options.defaultExportType ?? 'FullBackup', @@ -384,7 +468,7 @@ export class ModalService { ); } - openJobDetail(job: JobInfo): any { + openJobDetail(job: JobInfo): DialogRefLike { const data = { ...job }; return this.openModal( 'job-detail', @@ -398,7 +482,7 @@ export class ModalService { ); } - openRestorePreview(options: RestorePreviewOptions): any { + openRestorePreview(options: RestorePreviewOptions): DialogRefLike { const data = { backupPath: options.backupPath, analysis: options.analysis }; return this.openModal( 'restore-preview', @@ -412,7 +496,7 @@ export class ModalService { ); } - openQuickAddRemote(): any { + openQuickAddRemote(): DialogRefLike { return this.openModal( 'quick-add-remote', { ...STANDARD_MODAL_SIZE, disableClose: true }, @@ -424,7 +508,7 @@ export class ModalService { ); } - openBackend(): any { + openBackend(): DialogRefLike { return this.openModal( 'backend', { ...STANDARD_MODAL_SIZE, disableClose: false }, @@ -436,7 +520,7 @@ export class ModalService { ); } - openPreferences(): any { + openPreferences(): DialogRefLike { return this.openModal( 'preferences', { ...STANDARD_MODAL_SIZE, disableClose: true }, @@ -448,7 +532,7 @@ export class ModalService { ); } - openRcloneFlags(): any { + openRcloneFlags(): DialogRefLike { return this.openModal( 'rclone-flags', { ...STANDARD_MODAL_SIZE, disableClose: true }, @@ -460,7 +544,7 @@ export class ModalService { ); } - openAlerts(): any { + openAlerts(): DialogRefLike { return this.openModal( 'alerts', { @@ -477,7 +561,7 @@ export class ModalService { ); } - openProperties(options: PropertiesModalOptions): any { + openProperties(options: PropertiesModalOptions): DialogRefLike { return this.openModal('properties', { data: { remoteName: options.remoteName, @@ -494,7 +578,7 @@ export class ModalService { }); } - openRemoteAbout(options: RemoteAboutModalOptions): any { + openRemoteAbout(options: RemoteAboutModalOptions): DialogRefLike { return this.openModal('remote-about', { ...STANDARD_MODAL_SIZE, disableClose: true, @@ -508,7 +592,7 @@ export class ModalService { }); } - openKeyboardShortcuts(data?: { nautilus?: boolean }): any { + openKeyboardShortcuts(data?: { nautilus?: boolean }): DialogRefLike { return this.openModal('keyboard-shortcuts', { ...STANDARD_MODAL_SIZE, disableClose: true, @@ -516,11 +600,14 @@ export class ModalService { }); } - openAbout(): any { + openAbout(): DialogRefLike { return this.openModal('about', { ...ABOUT_MODAL_SIZE, disableClose: true }); } - openArchiveCreate(data: { items: any[]; defaultName: string }): any { + openArchiveCreate(data: { + items: any[]; + defaultName: string; + }): DialogRefLike { return this.openModal('archive-create', { width: '450px', height: '600px', @@ -528,4 +615,29 @@ export class ModalService { data, }); } + + openTemplateManager(options: TemplateManagerModalOptions): DialogRefLike { + return this.openModal('template-manager', { + ...STANDARD_MODAL_SIZE, + disableClose: true, + data: { + mode: options.mode, + currentValues: options.currentValues, + }, + }); + } + + openDeleteRemote(remoteName: string): DialogRefLike { + const data = { remoteName }; + return this.openModal( + 'delete-remote', + { ...STANDARD_MODAL_SIZE, disableClose: true, data }, + { + title: this.translate.instant('home.deleteRemote.title') || 'Delete Remote', + width: 580, + height: 520, + suffix: remoteName, + } + ); + } } diff --git a/src/app/services/ui/nautilus-actions.service.ts b/src/app/services/ui/nautilus-actions.service.ts index 43a3a0c50..dca470ef0 100755 --- a/src/app/services/ui/nautilus-actions.service.ts +++ b/src/app/services/ui/nautilus-actions.service.ts @@ -9,6 +9,7 @@ import { ExplorerRoot, FileBrowserItem, fileBrowserItemKey, RemoteFeatures } fro import { NautilusService } from 'src/app/services/ui/nautilus.service'; import { NautilusFileOperationsService } from './nautilus-file-operations.service'; import { NautilusTabService } from './nautilus-tab.service'; +import { NautilusSelectionService } from './nautilus-selection.service'; import { Clipboard } from '@angular/cdk/clipboard'; import { MatDialog } from '@angular/material/dialog'; import { firstValueFrom } from 'rxjs'; @@ -19,6 +20,7 @@ import { DownloadService } from 'src/app/services/operations/download.service'; @Injectable() export class NautilusActionsService { private readonly tabSvc = inject(NautilusTabService); + private readonly selectionSvc = inject(NautilusSelectionService); private readonly fileOps = inject(NautilusFileOperationsService); private readonly translate = inject(TranslateService); private readonly notificationService = inject(NotificationService); @@ -34,16 +36,28 @@ export class NautilusActionsService { readonly contextMenuItem = signal(null); - async shareContextItem(itemOverride?: FileBrowserItem): Promise { + private _resolveItemRemoteInfo(itemOverride?: FileBrowserItem | null): { + item: FileBrowserItem | null; + remoteName: string; + isLocal: boolean; + path: string; + remoteType?: string; + } { const item = itemOverride ?? this.contextMenuItem(); - if (!item || item.entry.IsDir) return; - const activeRemote = this.tabSvc.activeRemote(); - const isLocal = item.meta.isLocal ?? activeRemote?.isLocal ?? true; - let remoteName = item.meta.remote ?? activeRemote?.name ?? ''; + const isLocal = item?.meta.isLocal ?? activeRemote?.isLocal ?? true; + let remoteName = item?.meta.remote ?? activeRemote?.name ?? ''; if (remoteName && !isLocal) { remoteName = this.pathSvc.normalizeRemoteForRclone(remoteName); } + const path = item?.entry.Path ?? this.tabSvc.activePath(); + const remoteType = item?.meta.remoteType ?? activeRemote?.type; + return { item, remoteName, isLocal, path, remoteType }; + } + + async shareContextItem(itemOverride?: FileBrowserItem): Promise { + const { item, remoteName, isLocal } = this._resolveItemRemoteInfo(itemOverride); + if (!item || item.entry.IsDir) return; try { await this.downloadService.shareFileNatively( @@ -58,16 +72,9 @@ export class NautilusActionsService { } async openNativeContextItem(itemOverride?: FileBrowserItem): Promise { - const item = itemOverride ?? this.contextMenuItem(); + const { item, remoteName, isLocal } = this._resolveItemRemoteInfo(itemOverride); if (!item || item.entry.IsDir) return; - const activeRemote = this.tabSvc.activeRemote(); - const isLocal = item.meta.isLocal ?? activeRemote?.isLocal ?? true; - let remoteName = item.meta.remote ?? activeRemote?.name ?? ''; - if (remoteName && !isLocal) { - remoteName = this.pathSvc.normalizeRemoteForRclone(remoteName); - } - try { await this.downloadService.openFileNatively( remoteName, @@ -81,20 +88,12 @@ export class NautilusActionsService { } openPropertiesDialog(source: 'contextMenu' | 'bookmark', itemOverride?: FileBrowserItem): void { - const activeRemote = this.tabSvc.activeRemote(); - const item = itemOverride ?? this.contextMenuItem(); + const { item, remoteName, isLocal, path, remoteType } = + this._resolveItemRemoteInfo(itemOverride); if (source === 'bookmark' && !item) return; - const path = item?.entry.Path ?? this.tabSvc.activePath(); - const isLocal = item?.meta.isLocal ?? activeRemote?.isLocal ?? true; - - let remoteName = item?.meta.remote ?? activeRemote?.name; - if (remoteName && !isLocal) { - remoteName = this.pathSvc.normalizeRemoteForRclone(remoteName); - } - const baseName = this.pathSvc.normalizeRemoteName( - item?.meta.remote ?? activeRemote?.name ?? '' + item?.meta.remote ?? this.tabSvc.activeRemote()?.name ?? '' ); const features = this.remoteFacadeSvc.featuresSignal(baseName)() as RemoteFeatures; @@ -103,12 +102,8 @@ export class NautilusActionsService { path, isLocal, item: item?.entry, - remoteType: item?.meta.remoteType ?? activeRemote?.type, + remoteType, features, - height: '60vh', - maxHeight: '800px', - width: '60vw', - maxWidth: '400px', }); } @@ -117,9 +112,7 @@ export class NautilusActionsService { } openAboutModal(remote: ExplorerRoot): void { - const normalized = remote.isLocal - ? remote.name - : this.pathSvc.normalizeRemoteForRclone(remote.name); + const normalized = this.pathSvc.normalizeExplorerRoot(remote); this.modalService.openRemoteAbout({ displayName: remote.name, normalizedName: normalized, @@ -138,7 +131,7 @@ export class NautilusActionsService { if (!confirmed) return; try { - const normalized = r.isLocal ? r.name : this.pathSvc.normalizeRemoteForRclone(r.name); + const normalized = this.pathSvc.normalizeExplorerRoot(r); await this.remoteOps.cleanup(normalized, undefined, 'filemanager'); this.notificationService.showInfo( this.translate.instant('nautilus.notifications.trashEmptied') @@ -153,7 +146,7 @@ export class NautilusActionsService { } async openFilePreview(item: FileBrowserItem, activePaneFiles: FileBrowserItem[]): Promise { - const currentRemote = this.tabSvc.nautilusRemote(); + const currentRemote = this.tabSvc.activeRemote(); const actualRemoteName = item.meta.remote ?? currentRemote?.name; if (!actualRemoteName) { this.notificationService.showError(this.translate.instant('nautilus.errors.openFileFailed')); @@ -207,21 +200,24 @@ export class NautilusActionsService { if (changed) this._refresh(); } + getSelectedOrContextItems(): FileBrowserItem[] { + const selection = this.tabSvc.activeSelection(); + const selected = this.tabSvc.activeFiles().filter(f => selection.has(this._itemKey(f))); + if (selected.length > 0) return selected; + const ctx = this.contextMenuItem(); + return ctx ? [ctx] : []; + } + async deleteSelectedItems(): Promise { const remote = this.tabSvc.activeRemote(); if (!remote) return; - const selection = this.tabSvc.selectedItems(); - let itemsToDelete = this.tabSvc.activeFiles().filter(f => selection.has(this._itemKey(f))); - - const ctx = this.contextMenuItem(); - if (ctx && !selection.has(this._itemKey(ctx))) { - itemsToDelete = [ctx]; - } + const itemsToDelete = this.getSelectedOrContextItems(); + if (itemsToDelete.length === 0) return; const refreshNeeded = await this.fileOps.deleteItems(remote, itemsToDelete); if (refreshNeeded) { - this.tabSvc.syncSelection(new Set(), this.tabSvc.activePaneIndex() as 0 | 1); + this.tabSvc.syncSelection(new Set(), this.tabSvc.activePaneIndex()); this._refresh(); } } @@ -230,10 +226,10 @@ export class NautilusActionsService { const remote = this.tabSvc.activeRemote(); if (!remote) return; - const selection = this.tabSvc.selectedItems(); + const selection = this.tabSvc.activeSelection(); const item = - this.contextMenuItem() ?? - this.tabSvc.activeFiles().find(f => selection.has(this._itemKey(f)) && f.entry.IsDir); + this.tabSvc.activeFiles().find(f => selection.has(this._itemKey(f)) && f.entry.IsDir) ?? + this.contextMenuItem(); if (!item) return; const changed = await this.fileOps.removeEmptyDirs(remote, item); @@ -244,14 +240,7 @@ export class NautilusActionsService { const remote = this.tabSvc.activeRemote(); if (!remote) return; - const selection = this.tabSvc.selectedItems(); - let selectedFiles = this.tabSvc.activeFiles().filter(f => selection.has(this._itemKey(f))); - - const ctx = this.contextMenuItem(); - if (ctx && !selection.has(this._itemKey(ctx))) { - selectedFiles = [ctx]; - } - + const selectedFiles = this.getSelectedOrContextItems(); if (selectedFiles.length === 0) return; const changed = await this.fileOps.openArchiveCreateDialog( @@ -266,13 +255,12 @@ export class NautilusActionsService { const item = this.contextMenuItem(); if (!item?.entry.IsDir) return; - let root = this.tabSvc.activeRemote(); - if (!root && item.meta.remote) { - const remoteName = this.pathSvc.normalizeRemoteName(item.meta.remote); - root = - this.nautilusService - .allRemotesLookup() - .find(r => this.pathSvc.normalizeRemoteName(r.name) === remoteName) ?? null; + let root: ExplorerRoot | null = null; + if (item.meta?.remote) { + root = this.nautilusService.lookupRemoteByName(item.meta.remote); + } + if (!root) { + root = this.tabSvc.activeRemote(); } if (root) { @@ -312,11 +300,9 @@ export class NautilusActionsService { }); async copyPublicLink(): Promise { - const item = this.contextMenuItem(); - const remote = this.tabSvc.activeRemote(); - if (!item || !remote) return; + const { item, remoteName } = this._resolveItemRemoteInfo(); + if (!item || !remoteName) return; - const remoteName = this.pathSvc.normalizeRemoteForRclone(item.meta.remote ?? remote.name); this.notificationService.showInfo( this.translate.instant('nautilus.notifications.getPublicLinkStarted') ); @@ -347,11 +333,11 @@ export class NautilusActionsService { const remote = this.tabSvc.activeRemote(); if (!remote) return; - const items = this._getSelectedItemsList(this.tabSvc.activeFiles()); + const items = this.selectionSvc.getSelectedItemsList(this.tabSvc.activeFiles()); if (items.length === 0) return; const existingNames = this.tabSvc.activeFiles().map(f => f.entry.Name); - const ref = await this.notificationService.openInput({ + const ref = await this.notificationService.openInput({ title: this.translate.instant('nautilus.modals.newFolder.title'), label: this.translate.instant('nautilus.modals.newFolder.label'), icon: 'folder', @@ -370,7 +356,7 @@ export class NautilusActionsService { await this.remoteOps.makeDirectory(normalizedRemote, newPath, 'filemanager'); await this.fileOps.performFileOperations(items, remote, newPath, 'move'); - this.tabSvc.syncSelection(new Set(), this.tabSvc.activePaneIndex() as 0 | 1); + this.tabSvc.syncSelection(new Set(), this.tabSvc.activePaneIndex()); this._refresh(); } catch (err) { console.error('Failed to create folder with selected items', err); @@ -387,7 +373,7 @@ export class NautilusActionsService { const remote = this.tabSvc.activeRemote(); if (!remote) return; - const items = this._getSelectedItemsList(this.tabSvc.activeFiles()); + const items = this.selectionSvc.getSelectedItemsList(this.tabSvc.activeFiles()); if (items.length === 0) return; const ref = this.dialog.open(MultiRenameModalComponent, { @@ -398,7 +384,7 @@ export class NautilusActionsService { const changed = await firstValueFrom(ref.afterClosed()); if (changed) { - this.tabSvc.syncSelection(new Set(), this.tabSvc.activePaneIndex() as 0 | 1); + this.tabSvc.syncSelection(new Set(), this.tabSvc.activePaneIndex()); this._refresh(); } } @@ -408,7 +394,7 @@ export class NautilusActionsService { if (remote) { this.tabSvc.refreshPath(remote.name, this.tabSvc.activePath()); } else { - this.tabSvc.refresh(this.tabSvc.activePaneIndex() as 0 | 1); + this.tabSvc.refresh(this.tabSvc.activePaneIndex()); } } @@ -417,14 +403,7 @@ export class NautilusActionsService { } private _resolveBookmarkRemote(bookmark: FileBrowserItem): ExplorerRoot | null { - const remote = - this.nautilusService - .allRemotesLookup() - .find( - r => - this.pathSvc.normalizeRemoteName(r.name) === - this.pathSvc.normalizeRemoteName(bookmark.meta.remote) - ) ?? null; + const remote = this.nautilusService.lookupRemoteByName(bookmark.meta.remote); if (!remote) { this.notificationService.showError( @@ -435,12 +414,4 @@ export class NautilusActionsService { } return remote; } - - private _getSelectedItemsList(currentFiles: FileBrowserItem[]): FileBrowserItem[] { - const selection = - this.tabSvc.activePaneIndex() === 0 - ? this.tabSvc.selectedItems() - : this.tabSvc.selectedItemsRight(); - return currentFiles.filter((item: FileBrowserItem) => selection.has(this._itemKey(item))); - } } diff --git a/src/app/services/ui/nautilus-drag-drop.service.ts b/src/app/services/ui/nautilus-drag-drop.service.ts index 74d9ab3bf..72df4483a 100644 --- a/src/app/services/ui/nautilus-drag-drop.service.ts +++ b/src/app/services/ui/nautilus-drag-drop.service.ts @@ -6,6 +6,7 @@ import { PathService } from 'src/app/services/infrastructure/platform/path.servi import { RemoteFileOperationsService } from 'src/app/services/remote/remote-file-operations.service'; import { ExplorerRoot, FileBrowserItem } from '@app/types'; import { NautilusFileOperationsService } from 'src/app/services/ui/nautilus-file-operations.service'; +import { NautilusService } from 'src/app/services/ui/nautilus.service'; import { getCurrentWindow } from '@tauri-apps/api/window'; export const NAUTILUS_DRAG_MIME_TYPE = 'application/nautilus-files'; @@ -83,6 +84,7 @@ export class NautilusDragDropService { private readonly notifications = inject(NotificationService); private readonly translate = inject(TranslateService); private readonly fileOps = inject(NautilusFileOperationsService); + private readonly nautilusService = inject(NautilusService); private readonly destroyRef = inject(DestroyRef); private readonly _isInternalDragging = signal(false); @@ -202,20 +204,38 @@ export class NautilusDragDropService { this._dragGhostHost.appendChild(this._dragGhostEl); if ('popover' in this._dragGhostEl) { try { - (this._dragGhostEl as any).popover = 'manual'; - (this._dragGhostEl as any).showPopover(); - } catch { - // Popover fallback + const el = this._dragGhostEl as HTMLElement & { + popover?: string; + showPopover?: () => void; + }; + el.popover = 'manual'; + el.showPopover?.(); + } catch (e) { + console.debug('[NautilusDragDrop] Popover API not supported, falling back:', e); } } this._updateDragGhostPosition(point.x + 12, point.y + 12); } + private _moveRafId: number | null = null; + private _lastMovePoint: { x: number; y: number } | null = null; + + private _scheduleMove(point: { x: number; y: number }): void { + this._lastMovePoint = point; + if (this._moveRafId !== null) return; + this._moveRafId = requestAnimationFrame(() => { + this._moveRafId = null; + if (this._lastMovePoint) { + this._onMove(this._lastMovePoint); + } + }); + } + updateInternalPointerDrag(point: { x: number; y: number }): void { if (!this._internalPointerDrag) return; this._internalPointerDrag.lastPoint = point; this._updateDragGhostPosition(point.x + 12, point.y + 12); - this._onMove(point); + this._scheduleMove(point); } private _createDragGhost(items: FileBrowserItem[], svgIcon: SVGElement | null): HTMLElement { @@ -324,9 +344,6 @@ export class NautilusDragDropService { await this._processInternalItemsDrop(this._internalPointerDrag.items, target); } finally { this.cancelInternalPointerDrag(); - if (ctx.activeRemote) { - this._cb.refresh(ctx.activeRemote.name, ctx.activePath); - } } } @@ -337,6 +354,11 @@ export class NautilusDragDropService { } endDrag(): void { + if (this._moveRafId !== null) { + cancelAnimationFrame(this._moveRafId); + this._moveRafId = null; + } + this._lastMovePoint = null; this._isInternalDragging.set(false); this._isExternalDragging.set(false); this._counter = 0; @@ -358,7 +380,7 @@ export class NautilusDragDropService { if (event.dataTransfer?.types.includes('application/x-nautilus-tab')) return; event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; - this._onMove({ x: event.clientX, y: event.clientY }); + this._scheduleMove({ x: event.clientX, y: event.clientY }); } onContainerDragEnter(_event: DragEvent): void { @@ -426,13 +448,7 @@ export class NautilusDragDropService { async dropToBookmark(event: DragEvent, bookmark: FileBrowserItem): Promise { event.stopPropagation(); - const { allRemotesLookup } = this._cb.getContext(); - const targetRemote = - allRemotesLookup.find( - r => - this.pathService.normalizeRemoteName(r.name) === - this.pathService.normalizeRemoteName(bookmark.meta.remote) - ) ?? null; + const targetRemote = this.nautilusService.lookupRemoteByName(bookmark.meta.remote); const fsEntries = event.dataTransfer ? this._snapshotEntries(event.dataTransfer.items) : []; await this._processDrop(event, { remote: targetRemote, path: bookmark.entry.Path }, fsEntries); } @@ -594,14 +610,13 @@ export class NautilusDragDropService { if (items.some(item => item.entry.IsDir && item.entry.Path === target.path)) return; - const sourceParentPath = items[0].entry.Path.substring( - 0, - items[0].entry.Path.lastIndexOf(items[0].entry.Name) - ).replace(/\/$/, ''); + const normalizedTargetRemote = this.pathService.normalizeRemoteName(target.remote.name); + const isSameRemote = items.every( + item => + this.pathService.normalizeRemoteName(item.meta.remote ?? '') === normalizedTargetRemote + ); - const isSameRemote = - this.pathService.normalizeRemoteName(items[0].meta.remote ?? '') === - this.pathService.normalizeRemoteName(target.remote.name); + const sourceParentPath = this.pathService.getParentPath(items[0].entry.Path); if (isSameRemote && sourceParentPath === target.path.replace(/\/$/, '')) return; @@ -761,11 +776,7 @@ export class NautilusDragDropService { const bmPath = sidebarItem.replace('bookmark:', ''); const bm = ctx.bookmarks.find(b => b.entry.Path === bmPath); if (bm) { - const remote = ctx.allRemotesLookup.find( - r => - this.pathService.normalizeRemoteName(r.name) === - this.pathService.normalizeRemoteName(bm.meta.remote ?? '') - ); + const remote = this.nautilusService.lookupRemoteByName(bm.meta.remote ?? ''); if (remote) return { remote, path: bm.entry.Path }; } } @@ -778,11 +789,7 @@ export class NautilusDragDropService { if (folder?.entry.IsDir) { const folderRemote = - ctx.allRemotesLookup.find( - r => - this.pathService.normalizeRemoteName(r.name) === - this.pathService.normalizeRemoteName(folder.meta.remote) - ) ?? pane.remote; + this.nautilusService.lookupRemoteByName(folder.meta.remote) ?? pane.remote; return { remote: folderRemote, path: folder.entry.Path }; } @@ -824,16 +831,22 @@ export class NautilusDragDropService { private _readDirEntries(entry: FileSystemDirectoryEntry): Promise { const reader = entry.createReader(); const all: FileSystemEntry[] = []; - return new Promise((res, rej) => { + return new Promise(res => { const readBatch = (): void => { - reader.readEntries(batch => { - if (!batch.length) { + reader.readEntries( + batch => { + if (!batch.length) { + res(all); + return; + } + all.push(...batch); + readBatch(); + }, + err => { + console.warn(`Failed to read directory entries for ${entry.name}:`, err); res(all); - return; } - all.push(...batch); - readBatch(); - }, rej); + ); }; readBatch(); }); @@ -868,6 +881,6 @@ export class NautilusDragDropService { } private _normalizeRemote(remote: ExplorerRoot): string { - return remote.isLocal ? remote.name : this.pathService.normalizeRemoteForRclone(remote.name); + return this.pathService.normalizeExplorerRoot(remote); } } diff --git a/src/app/services/ui/nautilus-file-operations.service.ts b/src/app/services/ui/nautilus-file-operations.service.ts old mode 100644 new mode 100755 index a470448d0..2c4f88d0b --- a/src/app/services/ui/nautilus-file-operations.service.ts +++ b/src/app/services/ui/nautilus-file-operations.service.ts @@ -125,6 +125,8 @@ export class NautilusFileOperationsService { return hasPaths; } + private _clipboardReader: (() => Promise) | null = null; + private async _readSystemClipboardText(): Promise { try { if (isHeadlessMode()) { @@ -133,9 +135,13 @@ export class NautilusFileOperationsService { } return ''; } - const { readText } = await import('@tauri-apps/plugin-clipboard-manager'); - return await readText(); - } catch { + if (!this._clipboardReader) { + const mod = await import('@tauri-apps/plugin-clipboard-manager'); + this._clipboardReader = mod.readText; + } + return await this._clipboardReader(); + } catch (err) { + console.debug('[NautilusFileOps] Could not read system clipboard:', err); return ''; } } @@ -288,7 +294,7 @@ export class NautilusFileOperationsService { ): Promise { const normalizedRemote = this._normalizeRemote(remote); - const ref = await this.notifications.openInput({ + const ref = await this.notifications.openInput({ title: this.translate.instant('nautilus.modals.rename.title'), label: this.translate.instant('nautilus.modals.rename.label'), icon: 'pen', @@ -365,7 +371,6 @@ export class NautilusFileOperationsService { if (stack.length === 0) return; const entry = stack[stack.length - 1]; - this._undoStack.set(stack.slice(0, -1)); try { if (entry.mode === 'copy') { @@ -376,29 +381,16 @@ export class NautilusFileOperationsService { })); await this.remoteOps.deleteItems(itemsToDelete, 'filemanager'); } else { - const groups = new Map(); - - for (const item of entry.items) { - const dstParentPath = this.pathService.getParentPath(item.path); - const key = `${item.remote}:${dstParentPath}`; - - let group = groups.get(key); - if (!group) { - group = { dstRemote: item.remote, dstPath: dstParentPath, items: [] }; - groups.set(key, group); - } - - group.items.push({ - remote: item.dstRemote, - path: item.dstFullPath, - name: item.name, - isDir: item.isDir, - }); - } + const groups = this._groupTransferItems(entry.items, item => ({ + sourceRemote: item.dstRemote, + sourcePath: item.dstFullPath, + dstRemote: item.remote, + dstPath: this.pathService.getParentPath(item.path), + })); for (const group of groups.values()) { await this.remoteOps.transferItems( - group.items as { remote: string; path: string; name: string; isDir: boolean }[], + group.items, group.dstRemote, group.dstPath, 'move', @@ -407,6 +399,7 @@ export class NautilusFileOperationsService { } } + this._undoStack.set(stack.slice(0, -1)); this._redoStack.update(s => [...s.slice(-(this.MAX_UNDO_STACK - 1)), entry]); this.notifications.showSuccess(this.translate.instant('nautilus.notifications.undoComplete')); } catch (e) { @@ -422,27 +415,26 @@ export class NautilusFileOperationsService { if (stack.length === 0) return; const entry = stack[stack.length - 1]; - this._redoStack.set(stack.slice(0, -1)); - - const transferItems = entry.items.map(item => ({ - remote: item.remote, - path: item.path, - name: item.name, - isDir: item.isDir, - })); try { - const firstItem = entry.items[0]; - const parentPath = this.pathService.getParentPath(firstItem.dstFullPath); + const groups = this._groupTransferItems(entry.items, item => ({ + sourceRemote: item.remote, + sourcePath: item.path, + dstRemote: item.dstRemote, + dstPath: this.pathService.getParentPath(item.dstFullPath), + })); - await this.remoteOps.transferItems( - transferItems, - firstItem.dstRemote, - parentPath, - entry.mode, - 'filemanager' - ); + for (const group of groups.values()) { + await this.remoteOps.transferItems( + group.items, + group.dstRemote, + group.dstPath, + entry.mode, + 'filemanager' + ); + } + this._redoStack.set(stack.slice(0, -1)); this._undoStack.update(s => [...s.slice(-(this.MAX_UNDO_STACK - 1)), entry]); this.notifications.showSuccess(this.translate.instant('nautilus.notifications.redoComplete')); } catch (e) { @@ -453,6 +445,52 @@ export class NautilusFileOperationsService { } } + private _groupTransferItems( + items: UndoEntry['items'], + getDstInfo: (item: UndoEntry['items'][number]) => { + sourceRemote: string; + sourcePath: string; + dstRemote: string; + dstPath: string; + } + ): Map< + string, + { + dstRemote: string; + dstPath: string; + items: { remote: string; path: string; name: string; isDir: boolean }[]; + } + > { + const groups = new Map< + string, + { + dstRemote: string; + dstPath: string; + items: { remote: string; path: string; name: string; isDir: boolean }[]; + } + >(); + + for (const item of items) { + const { sourceRemote, sourcePath, dstRemote, dstPath } = getDstInfo(item); + const key = `${dstRemote}:${dstPath}`; + + let group = groups.get(key); + if (!group) { + group = { dstRemote, dstPath, items: [] }; + groups.set(key, group); + } + + group.items.push({ + remote: sourceRemote, + path: sourcePath, + name: item.name, + isDir: item.isDir, + }); + } + + return groups; + } + async uploadExternalFiles(remote: ExplorerRoot, currentPath: string): Promise { try { const paths = await this.fileSystem.selectFilesForUpload(); @@ -527,7 +565,7 @@ export class NautilusFileOperationsService { ): Promise { const normalizedRemote = this._normalizeRemote(remote); - const ref = await this.notifications.openInput({ + const ref = await this.notifications.openInput({ title: this.translate.instant('nautilus.modals.newFolder.title'), label: this.translate.instant('nautilus.modals.newFolder.label'), icon: 'folder', @@ -542,7 +580,8 @@ export class NautilusFileOperationsService { const newPath = this.pathService.joinPath(currentPath, folderName); await this.remoteOps.makeDirectory(normalizedRemote, newPath, 'filemanager'); return true; - } catch { + } catch (err) { + console.error('[NautilusFileOps] Failed to create folder:', err); this.notifications.showError(this.translate.instant('nautilus.errors.createFolderFailed')); return false; } @@ -551,7 +590,7 @@ export class NautilusFileOperationsService { async openCopyUrlDialog(remote: ExplorerRoot, currentPath: string): Promise { const normalizedRemote = this._normalizeRemote(remote); - const ref = await this.notifications.openInput({ + const ref = await this.notifications.openInput<{ url?: string; filename?: string }>({ title: this.translate.instant('nautilus.modals.copyUrl.title'), icon: 'download', createLabel: this.translate.instant('nautilus.modals.copyUrl.confirm'), @@ -580,9 +619,10 @@ export class NautilusFileOperationsService { const { url, filename } = result; const autoFilename = !filename || filename.trim() === ''; const targetFilename = filename?.trim(); - const targetPath = !autoFilename - ? this.pathService.joinPath(currentPath, targetFilename) - : currentPath; + const targetPath = + !autoFilename && targetFilename + ? this.pathService.joinPath(currentPath, targetFilename) + : currentPath; this.notifications.showInfo(this.translate.instant('nautilus.notifications.copyUrlStarted')); @@ -706,6 +746,6 @@ export class NautilusFileOperationsService { } private _normalizeRemote(remote: ExplorerRoot): string { - return remote.isLocal ? remote.name : this.pathService.normalizeRemoteForRclone(remote.name); + return this.pathService.normalizeExplorerRoot(remote); } } diff --git a/src/app/services/ui/nautilus-selection.service.ts b/src/app/services/ui/nautilus-selection.service.ts index c4838e6c9..ad2fd3505 100755 --- a/src/app/services/ui/nautilus-selection.service.ts +++ b/src/app/services/ui/nautilus-selection.service.ts @@ -1,6 +1,5 @@ import { inject, Injectable } from '@angular/core'; import { NautilusService } from 'src/app/services/ui/nautilus.service'; -import { NautilusActionsService } from './nautilus-actions.service'; import { NautilusTabService } from './nautilus-tab.service'; import { FileBrowserItem, Entry, fileBrowserItemKey } from '@app/types'; @@ -8,7 +7,6 @@ import { FileBrowserItem, Entry, fileBrowserItemKey } from '@app/types'; export class NautilusSelectionService { private readonly tabSvc = inject(NautilusTabService); private readonly nautilusService = inject(NautilusService); - private readonly actions = inject(NautilusActionsService); private lastSelectedIndex: Record<0 | 1, number | null> = { 0: null, 1: null }; @@ -34,10 +32,7 @@ export class NautilusSelectionService { } getSelectedItemsList(currentFiles: FileBrowserItem[]): FileBrowserItem[] { - const selection = - this.tabSvc.activePaneIndex() === 0 - ? this.tabSvc.selectedItems() - : this.tabSvc.selectedItemsRight(); + const selection = this.tabSvc.activeSelection(); return currentFiles.filter((item: FileBrowserItem) => selection.has(this.getItemKey(item))); } @@ -63,8 +58,13 @@ export class NautilusSelectionService { const lastIdx = this.lastSelectedIndex[paneIndex]; if (event.shiftKey && lastIdx !== null && multi) { - const start = Math.min(lastIdx, index); - const end = Math.max(lastIdx, index); + if (event.ctrlKey || event.metaKey) { + currentSel.forEach(k => newSel.add(k)); + } + const safeLastIdx = Math.max(0, Math.min(lastIdx, currentFiles.length - 1)); + const safeIdx = Math.max(0, Math.min(index, currentFiles.length - 1)); + const start = Math.min(safeLastIdx, safeIdx); + const end = Math.max(safeLastIdx, safeIdx); for (let i = start; i <= end; i++) { if (currentFiles[i]) newSel.add(this.getItemKey(currentFiles[i])); } @@ -86,8 +86,6 @@ export class NautilusSelectionService { paneIndex: 0 | 1, currentFiles: FileBrowserItem[] ): void { - this.actions.contextMenuItem.set(item); - if (item) { if (this.tabSvc.activePaneIndex() !== paneIndex) { this.tabSvc.switchPane(paneIndex); diff --git a/src/app/services/ui/nautilus-settings.service.ts b/src/app/services/ui/nautilus-settings.service.ts index f99fd7bce..2ae08ef9a 100644 --- a/src/app/services/ui/nautilus-settings.service.ts +++ b/src/app/services/ui/nautilus-settings.service.ts @@ -26,6 +26,9 @@ export class NautilusSettingsService { private readonly _sortColumn = signal<'name' | 'size' | 'modified'>('name'); private readonly _sortAscending = signal(true); + readonly sortColumn = this._sortColumn.asReadonly(); + readonly sortAscending = this._sortAscending.asReadonly(); + // ── Computeds ─────────────────────────────────────────────────────────────── readonly sortKey = computed( () => `${this._sortColumn()}-${this._sortAscending() ? 'asc' : 'desc'}` diff --git a/src/app/services/ui/nautilus-tab.service.ts b/src/app/services/ui/nautilus-tab.service.ts index 148af411b..2571719e3 100644 --- a/src/app/services/ui/nautilus-tab.service.ts +++ b/src/app/services/ui/nautilus-tab.service.ts @@ -9,6 +9,7 @@ import { PathService } from 'src/app/services/infrastructure/platform/path.servi import { RemoteFileOperationsService } from 'src/app/services/remote/remote-file-operations.service'; import { JobManagementService } from 'src/app/services/operations/job-management.service'; import { NautilusService } from 'src/app/services/ui/nautilus.service'; +import { EventListenersService } from 'src/app/services/infrastructure/system/event-listeners.service'; import { ExplorerRoot, FileBrowserItem, FilePickerConfig, NautilusTabItem } from '@app/types'; import { FileViewerService } from '../ui/file-viewer.service'; @@ -65,15 +66,18 @@ export class NautilusTabService { private readonly pathService = inject(PathService); private readonly localStorage = inject(LocalStorageService); private readonly nautilusService = inject(NautilusService); + private readonly eventListenersService = inject(EventListenersService); private readonly fileViewerSvc = inject(FileViewerService); public readonly listReadGroups: Record<0 | 1, string> = { - 0: `ui/nautilus/list-left-${Date.now().toString(36)}`, - 1: `ui/nautilus/list-right-${Date.now().toString(36)}`, + 0: `ui/nautilus/list-left-${crypto.randomUUID().slice(0, 8)}`, + 1: `ui/nautilus/list-right-${crypto.randomUUID().slice(0, 8)}`, }; /** Callback when the last tab is closed. */ - onCloseOverlay!: () => void; + onCloseOverlay: () => void = () => { + /* empty */ + }; // -- Signals -- readonly pendingPreviewFilePath = signal(null); @@ -112,6 +116,11 @@ export class NautilusTabService { })) ); + readonly activeSelection = computed(() => + this.activePaneIndex() === 0 ? this.selectedItems() : this.selectedItemsRight() + ); + readonly activePaneRef = computed(() => this.getPaneRef(this.activePaneIndex())); + readonly activeRemote = computed(() => this.activePaneIndex() === 0 ? this.nautilusRemote() : this.nautilusRemoteRight() ); @@ -218,7 +227,11 @@ export class NautilusTabService { filteredLocalDrives: ExplorerRoot[], filteredCloudRemotes: ExplorerRoot[] ): Promise { - await this.nautilusService.loadRemoteData(); + if (this.nautilusService.allRemotesLookup().length === 0) { + await this.nautilusService.loadRemoteData(); + } else { + void this.nautilusService.loadRemoteData(); + } const pickerState = this.nautilusService.filePickerState(); let initialRemote: ExplorerRoot | null = null; @@ -346,6 +359,8 @@ export class NautilusTabService { refresh(paneIndex: 0 | 1): void { const ref = this.getPaneRef(paneIndex); + ref.loading.set(true); + ref.error.set(null); ref.refreshTrigger.update(v => v + 1); const activeTab = this.tabs()[this.activeTabIndex()]; @@ -405,10 +420,17 @@ export class NautilusTabService { * Refresh multiple paths at once. */ refreshAffectedPaths(affected: { remote: string; path: string }[]): void { - const unique = new Set(affected.map(a => `${a.remote}||${a.path}`)); - unique.forEach(u => { - const [remote, path] = u.split('||'); - this.refreshPath(remote, path); + const pathsByRemote = new Map>(); + for (const item of affected) { + let set = pathsByRemote.get(item.remote); + if (!set) { + set = new Set(); + pathsByRemote.set(item.remote, set); + } + set.add(item.path); + } + pathsByRemote.forEach((paths, remote) => { + paths.forEach(path => this.refreshPath(remote, path)); }); } @@ -679,6 +701,12 @@ export class NautilusTabService { ); const ref = this.getPaneRef(pIdx); + const isNewLocation = ref.remote()?.name !== remote?.name || ref.path() !== path; + if (isNewLocation) { + ref.rawFiles.set([]); + ref.loading.set(true); + ref.error.set(null); + } ref.remote.set(remote); ref.path.set(path); ref.selection.set(new Set()); @@ -706,12 +734,20 @@ export class NautilusTabService { // -- Private -- private setupBackendEventListener(): void { - this.nautilusService.eventListenersService + this.eventListenersService .listenToJobCacheChanged() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(event => { const { status, remote, source, destination } = event; if ((status === 'Completed' || status === 'Failed' || status === 'Stopped') && remote) { + const cleanRemote = this.pathService.normalizeRemoteName(remote); + const isRelevant = this.tabs().some( + t => + this.pathService.normalizeRemoteName(t.left.remote?.name) === cleanRemote || + this.pathService.normalizeRemoteName(t.right?.remote?.name) === cleanRemote + ); + if (!isRelevant && cleanRemote !== 'local') return; + const affected: { remote: string; path: string }[] = []; const addAffected = (pathStr: string): void => { const parsed = this.pathService.splitFsPath(pathStr); @@ -747,24 +783,28 @@ export class NautilusTabService { private loadFilesForPane(paneIndex: 0 | 1): void { const ref = this.getPaneRef(paneIndex); - const loadParams = computed(() => ({ - remote: ref.remote(), - path: ref.path(), - _trigger: ref.refreshTrigger(), - })); + const loadParams = computed(() => { + const activeTab = this.tabs()[this.activeTabIndex()]; + return { + tabId: activeTab?.id, + remote: ref.remote(), + path: ref.path(), + _trigger: ref.refreshTrigger(), + }; + }); toObservable(loadParams) .pipe( - switchMap(({ remote, path, _trigger }) => { + switchMap(({ tabId, remote, path, _trigger }) => { if (!remote) { ref.rawFiles.set([]); return EMPTY; } // Check if this path was already loaded for this specific pane state - const activeTab = this.tabs()[this.activeTabIndex()]; - if (activeTab) { - const pane = paneIndex === 0 ? activeTab.left : activeTab.right; + const targetTab = this.tabs().find(t => t.id === tabId); + if (targetTab) { + const pane = paneIndex === 0 ? targetTab.left : targetTab.right; if ( pane && pane.remote?.name === remote.name && @@ -789,12 +829,15 @@ export class NautilusTabService { : this.pathService.normalizeRemoteForRclone(remote.name); const readGroup = this.listReadGroups[paneIndex]; - return from(this.stopListReadGroup(readGroup)).pipe( - switchMap(() => - from(this.remoteOps.getRemotePaths(fsName, path, {}, 'filemanager', readGroup)) - ), - map(res => - (res.list || []).map( + return from( + this.remoteOps.getRemotePaths(fsName, path, {}, 'filemanager', readGroup) + ).pipe( + map(res => ({ + tabId, + remote, + path, + _trigger, + files: (res.list || []).map( f => ({ entry: f, @@ -804,13 +847,15 @@ export class NautilusTabService { remoteType: remote.type, }, }) as FileBrowserItem - ) - ), + ), + error: null as string | null, + })), catchError(err => { - const errorMessage = (err?.message ?? err ?? '').toString(); - const isCancelled = /operation cancelled|operation canceled|cancelled|canceled/i.test( - errorMessage - ); + const errorMessage = (err?.message ?? err ?? '').toString().toLowerCase(); + const isCancelled = + errorMessage.includes('cancelled') || + errorMessage.includes('canceled') || + errorMessage.includes('abort'); if (isCancelled) { ref.loading.set(false); @@ -819,35 +864,56 @@ export class NautilusTabService { console.error('Error fetching files:', err); const msg = this.translate.instant('nautilus.errors.loadFailed'); - ref.error.set(err?.message ?? msg); + const errStr = err?.message ?? msg; this.notificationService.showError(msg); - ref.loading.set(false); - return of([]); + return of({ + tabId, + remote, + path, + _trigger, + files: [] as FileBrowserItem[], + error: errStr, + }); }) ); }), takeUntilDestroyed(this.destroyRef) ) - .subscribe(files => { - ref.loading.set(false); - ref.rawFiles.set(files); - - const activeTabId = this.tabs()[this.activeTabIndex()]?.id; - const tab = this.tabs().find(t => t.id === activeTabId); - if (!tab) return; - const pane = paneIndex === 0 ? tab.left : tab.right; - if (pane) { - pane.rawFiles.set(files); - pane.isLoading.set(false); - pane.error.set(ref.error()); - // Save the loaded state! - pane.loadedRemote = pane.remote; - pane.loadedPath = pane.path; - pane.loadedTrigger = ref.refreshTrigger(); + .subscribe(({ tabId, remote, path, _trigger, files, error }) => { + const isCurrentPane = ref.remote()?.name === remote?.name && ref.path() === path; + + if (isCurrentPane) { + ref.loading.set(false); + if (error) { + ref.error.set(error); + } else { + ref.rawFiles.set(files); + ref.error.set(null); + } + } + + // Store into the originating tab & pane + if (tabId !== undefined) { + const tab = this.tabs().find(t => t.id === tabId); + if (tab) { + const pane = paneIndex === 0 ? tab.left : tab.right; + if (pane && pane.remote?.name === remote?.name && pane.path === path) { + pane.isLoading.set(false); + if (error) { + pane.error.set(error); + } else { + pane.rawFiles.set(files); + pane.error.set(null); + } + pane.loadedRemote = pane.remote; + pane.loadedPath = pane.path; + pane.loadedTrigger = _trigger; + } + } } const pending = this.pendingPreviewFilePath(); - if (pending) { + if (pending && isCurrentPane && !error) { const item = files.find(f => f.entry.Path === pending); if (item) { this.pendingPreviewFilePath.set(null); diff --git a/src/app/services/ui/nautilus.service.ts b/src/app/services/ui/nautilus.service.ts index 0b6163d08..53509bf28 100755 --- a/src/app/services/ui/nautilus.service.ts +++ b/src/app/services/ui/nautilus.service.ts @@ -29,8 +29,7 @@ import { ExplorerRoot, } from '@app/types'; import { TauriBaseService } from '../infrastructure/platform/tauri-base.service'; -import { UiStateService } from './state/ui-state.service'; -import { isHeadlessMode, isMobile } from '../infrastructure/platform/api-client.service'; +import { isMobile } from '../infrastructure/platform/api-client.service'; import type { NautilusComponent } from 'src/app/file-browser/nautilus/nautilus.component'; @Injectable({ @@ -45,7 +44,6 @@ export class NautilusService extends TauriBaseService { readonly eventListenersService = inject(EventListenersService); private readonly destroyRef = inject(DestroyRef); private readonly titleService = inject(Title); - private readonly uiState = inject(UiStateService); private readonly _filePickerState = signal<{ isOpen: boolean; options?: FilePickerConfig }>({ isOpen: false, @@ -89,6 +87,8 @@ export class NautilusService extends TauriBaseService { private pickerOverlayRef: OverlayRef | null = null; private pickerComponentRef: ComponentRef | null = null; + private isBrowserOpening = false; + private isPickerOpening = false; private browserOverlayRef: OverlayRef | null = null; private browserComponentRef: ComponentRef | null = null; @@ -132,42 +132,61 @@ export class NautilusService extends TauriBaseService { async loadRemoteData(): Promise { try { - const [remoteNames, drives, configs] = await Promise.all([ - this.remoteManagement.getRemotes(), - this.remoteManagement.getLocalDrives(), - this.remoteManagement.getAllRemoteConfigs().catch(e => { - console.error('[NautilusService] Failed to load remote configs:', e); - return {} as Record; - }), - ]); - - this._localDrives.set( - drives.map(drive => ({ - name: drive.name, - label: drive.label || drive.name, - type: 'hard-drive', - isLocal: true, - showName: drive.show_name, - totalSpace: drive.total_space, - availableSpace: drive.available_space, - fileSystem: drive.file_system, - isRemovable: drive.is_removable, - })) - ); + const loadCloud = (async (): Promise => { + try { + const [remotesRes, configsRes] = await Promise.allSettled([ + this.remoteManagement.getRemotes(), + this.remoteManagement.getAllRemoteConfigs(), + ]); + + const remoteNames = remotesRes.status === 'fulfilled' ? remotesRes.value : []; + const configs = + configsRes.status === 'fulfilled' + ? (configsRes.value as Record) + : {}; + + if (remotesRes.status === 'rejected') { + console.warn('[NautilusService] Failed to load remote names:', remotesRes.reason); + } - this._cloudRemotes.set( - remoteNames.map(name => { - const config = (configs as Record)[ - name - ]; - return { - name, - label: name, - type: config?.type ?? config?.Type ?? 'cloud', - isLocal: false, - }; - }) - ); + this._cloudRemotes.set( + remoteNames.map(name => { + const config = configs[name]; + return { + name, + label: name, + type: config?.type ?? config?.Type ?? 'cloud', + isLocal: false, + }; + }) + ); + } catch (err) { + console.warn('[NautilusService] Error loading cloud remotes:', err); + } + })(); + + const loadDrives = (async (): Promise => { + try { + const drives = await this.remoteManagement.getLocalDrives(); + this._localDrives.set( + drives.map(drive => ({ + name: drive.name, + label: drive.label || drive.name, + type: 'hard-drive', + isLocal: true, + showName: drive.show_name, + totalSpace: drive.total_space, + availableSpace: drive.available_space, + fileSystem: drive.file_system, + isRemovable: drive.is_removable, + })) + ); + } catch (err) { + console.warn('[NautilusService] Failed to load local drives:', err); + } + })(); + + await Promise.allSettled([loadCloud, loadDrives]); } catch (e) { console.error('[NautilusService] Failed to load remote data:', e); } @@ -205,12 +224,10 @@ export class NautilusService extends TauriBaseService { } setWindowTitle(title: string): void { - setTimeout(async () => { - if (this.isTauri) { - await this.getCurrentTauriWindow()?.setTitle(title); - } - this.titleService.setTitle(title); - }, 0); + if (this.isTauri) { + void this.getCurrentTauriWindow()?.setTitle(title); + } + this.titleService.setTitle(title); } getNautilusUrl(remote: string | null, path: string | null): string { @@ -226,8 +243,8 @@ export class NautilusService extends TauriBaseService { } private get isStandaloneEnabled(): boolean { - const opts = this.appSettingsService.options() as Record | null; - return !isHeadlessMode() && !isMobile() && opts?.['general.standalone_dialogs']?.value === true; + const opts = this.appSettingsService.options(); + return this.isTauri && !isMobile() && opts?.['general.standalone_dialogs']?.value === true; } async newNautilusWindow( @@ -273,12 +290,22 @@ export class NautilusService extends TauriBaseService { } async openBrowserOverlay(remote: string | null, path: string | null): Promise { - if (this.browserOverlayRef) return; - - this._isBrowserOverlayOpen.set(true); + if (this.browserOverlayRef) { + if (remote) { + const remoteRoot = this.lookupRemoteByName(remote); + if (path && remoteRoot) { + this.targetPath.set(this.pathService.getFullDisplayPath(remoteRoot, path)); + } else { + this.selectedNautilusRemote.set(remote); + } + } + return; + } + if (this.isBrowserOpening) return; + this.isBrowserOpening = true; if (remote) { - const remoteRoot = this.lookupRemote(remote); + const remoteRoot = this.lookupRemoteByName(remote); if (path && remoteRoot) { this.targetPath.set(this.pathService.getFullDisplayPath(remoteRoot, path)); } else { @@ -286,24 +313,38 @@ export class NautilusService extends TauriBaseService { } } - const { NautilusComponent } = await import('src/app/file-browser/nautilus/nautilus.component'); - const { overlayRef, componentRef } = this.createNautilusOverlay(NautilusComponent, () => - this.closeBrowserOverlay() - ); - this.browserOverlayRef = overlayRef; - this.browserComponentRef = componentRef; + try { + const { NautilusComponent } = + await import('src/app/file-browser/nautilus/nautilus.component'); + const { overlayRef, componentRef } = this.createNautilusOverlay(NautilusComponent, () => + this.closeBrowserOverlay() + ); + this.browserOverlayRef = overlayRef; + this.browserComponentRef = componentRef; + this._isBrowserOverlayOpen.set(true); + } catch (err) { + console.error('[NautilusService] Failed to open browser overlay:', err); + this._isBrowserOverlayOpen.set(false); + } finally { + this.isBrowserOpening = false; + } } closeBrowserOverlay(): void { this._isBrowserOverlayOpen.set(false); - if (!this._isStandaloneWindow()) { - this.pathNav.replaceCurrent(null, null); - } this.animateAndDisposeOverlay(this.browserComponentRef, this.browserOverlayRef); this.browserComponentRef = null; this.browserOverlayRef = null; } + toggleNautilusOverlay(remote: string | null = null, path: string | null = null): void { + if (this._isBrowserOverlayOpen()) { + this.closeBrowserOverlay(); + } else { + void this.newNautilusWindow(remote, path); + } + } + openForRemote(remoteName: string): void { void this.newNautilusWindow(remoteName, null); } @@ -314,12 +355,20 @@ export class NautilusService extends TauriBaseService { } async openFilePicker(options: FilePickerConfig): Promise { - if (this.pickerOverlayRef) return; - this._filePickerState.set({ - isOpen: true, - options: { ...options, requestId: options.requestId ?? crypto.randomUUID() }, - }); - await this.createPickerOverlay(); + if (this.pickerOverlayRef || this.isPickerOpening) return; + this.isPickerOpening = true; + try { + this._filePickerState.set({ + isOpen: true, + options: { ...options, requestId: options.requestId ?? crypto.randomUUID() }, + }); + await this.createPickerOverlay(); + } catch (err) { + console.error('[NautilusService] Failed to open file picker:', err); + this._filePickerState.set({ isOpen: false }); + } finally { + this.isPickerOpening = false; + } } closeFilePicker(result: FileBrowserItem[] | null): void { @@ -330,8 +379,14 @@ export class NautilusService extends TauriBaseService { cancelled: result === null, items, paths: items.map(i => { + const remoteRoot = this.lookupRemote(i.meta.remote); return this.pathService.getFullDisplayPath( - { name: i.meta.remote, isLocal: i.meta.isLocal, label: i.meta.remote, type: '' }, + remoteRoot ?? { + name: i.meta.remote, + isLocal: i.meta.isLocal, + label: i.meta.remote, + type: i.meta.remoteType ?? '', + }, i.entry.Path ); }), @@ -339,9 +394,6 @@ export class NautilusService extends TauriBaseService { }); this._filePickerState.set({ isOpen: false }); - if (!this._isStandaloneWindow()) { - this.pathNav.replaceCurrent(null, null); - } this.animateAndDisposeOverlay(this.pickerComponentRef, this.pickerOverlayRef); this.pickerComponentRef = null; this.pickerOverlayRef = null; @@ -415,19 +467,20 @@ export class NautilusService extends TauriBaseService { /** * Resolve a parsed remote name to its `ExplorerRoot` from the engine-populated * registry (local drives + cloud remotes). Returns `null` if the remote is - * not currently registered — callers should treat that as "no display path - * available" rather than guessing `isLocal` from the name's shape. + * not currently registered. */ - private lookupRemote(remoteName: string): ExplorerRoot | null { + lookupRemoteByName(remoteName: string): ExplorerRoot | null { const all = this.allRemotesLookup(); const byName = all.find(r => r.name === remoteName); if (byName) return byName; - // `parseLocation` returns remote names like `C:` for drive roots and `/` - // for POSIX roots — match those against the registry by normalized name. const normalized = this.pathService.normalizeRemoteName(remoteName); return all.find(r => this.pathService.normalizeRemoteName(r.name) === normalized) ?? null; } + private lookupRemote(remoteName: string): ExplorerRoot | null { + return this.lookupRemoteByName(remoteName); + } + private setupBrowseListener(): void { this.eventListenersService .listenToBrowse() @@ -454,20 +507,25 @@ export class NautilusService extends TauriBaseService { let rawItems = (await this.appSettingsService.getSettingValue(fullKey)) ?? []; if (!Array.isArray(rawItems)) rawItems = []; - const items: FileBrowserItem[] = rawItems.map((item: unknown) => { + const items: FileBrowserItem[] = []; + for (const item of rawItems) { + if (!item || typeof item !== 'object') continue; const rec = item as Record; - if (rec && 'remote' in rec && 'entry' in rec) { - return { - entry: rec['entry'] as FileBrowserItem['entry'], - meta: { - remote: (rec['remote'] as string) || '', - isLocal: false, - remoteType: undefined, - }, - }; + if (rec['entry'] && typeof rec['entry'] === 'object') { + const entry = rec['entry'] as Record; + if (typeof entry['Path'] === 'string' && typeof entry['Name'] === 'string') { + const meta = (rec['meta'] ?? {}) as Record; + items.push({ + entry: entry as unknown as FileBrowserItem['entry'], + meta: { + remote: (meta['remote'] as string) || (rec['remote'] as string) || '', + isLocal: meta['isLocal'] === true, + remoteType: meta['remoteType'] as string | undefined, + }, + }); + } } - return rec as unknown as FileBrowserItem; - }); + } config.signal.set(items.filter(i => i.meta?.remote && i.entry?.Path)); } catch (e) { @@ -494,9 +552,9 @@ export class NautilusService extends TauriBaseService { return this.invokeCommand('unregister_send_to', { remote, path }); } - private createNautilusOverlay( + private createNautilusOverlay( componentClass: typeof NautilusComponent, - onClose: () => void, + onClose: (result?: T) => void, showAnimation = true ): { overlayRef: OverlayRef; componentRef: ComponentRef } { const overlayRef = this.overlay.create({ @@ -510,35 +568,23 @@ export class NautilusService extends TauriBaseService { componentRef.location.nativeElement.classList.add('slide-overlay-enter'); } - outputToObservable(componentRef.instance.closeOverlay).pipe(take(1)).subscribe(onClose); - overlayRef.backdropClick().pipe(take(1)).subscribe(onClose); - - return { overlayRef, componentRef }; - } - - private async createPickerOverlay(): Promise { - const { NautilusComponent } = await import('src/app/file-browser/nautilus/nautilus.component'); - - const overlayRef = this.overlay.create({ - positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(), - scrollStrategy: this.overlay.scrollStrategies.block(), - }); - - const componentRef = overlayRef.attach(new ComponentPortal(NautilusComponent)); - - componentRef.location.nativeElement.classList.add('slide-overlay-enter'); - - // When the picker confirms a selection, it emits the chosen items via closeOverlay outputToObservable(componentRef.instance.closeOverlay) .pipe(take(1)) - .subscribe(items => this.closeFilePicker(items ?? null)); - - // Clicking the backdrop (outside the picker) cancels the selection + .subscribe(res => onClose(res as T)); overlayRef .backdropClick() .pipe(take(1)) - .subscribe(() => this.closeFilePicker(null)); + .subscribe(() => onClose()); + + return { overlayRef, componentRef }; + } + private async createPickerOverlay(): Promise { + const { NautilusComponent } = await import('src/app/file-browser/nautilus/nautilus.component'); + const { overlayRef, componentRef } = this.createNautilusOverlay( + NautilusComponent, + items => this.closeFilePicker(items ?? null) + ); this.pickerOverlayRef = overlayRef; this.pickerComponentRef = componentRef; } @@ -547,9 +593,32 @@ export class NautilusService extends TauriBaseService { componentRef: ComponentRef | null, overlayRef: OverlayRef | null ): void { - componentRef?.location.nativeElement.classList.add('slide-overlay-leave'); - if (overlayRef) { - setTimeout(() => overlayRef.dispose(), 200); + if (!overlayRef) return; + const element = componentRef?.location?.nativeElement as HTMLElement | undefined; + if (element) { + element.classList.add('slide-overlay-leave'); + let timer: ReturnType | null = null; + const onEnd = (): void => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + element.removeEventListener('animationend', onEnd); + if (overlayRef.hasAttached()) { + overlayRef.dispose(); + } + }; + element.addEventListener('animationend', onEnd); + timer = setTimeout(() => { + element.removeEventListener('animationend', onEnd); + if (overlayRef.hasAttached()) { + overlayRef.dispose(); + } + }, 250); + } else { + if (overlayRef.hasAttached()) { + overlayRef.dispose(); + } } } } diff --git a/src/app/services/ui/navigation-dispatcher.service.spec.ts b/src/app/services/ui/navigation-dispatcher.service.spec.ts new file mode 100644 index 000000000..09e8c28e4 --- /dev/null +++ b/src/app/services/ui/navigation-dispatcher.service.spec.ts @@ -0,0 +1,190 @@ +import { TestBed } from '@angular/core/testing'; +import { NavigationDispatcherService } from './navigation-dispatcher.service'; +import { UiStateService } from './state/ui-state.service'; +import { RemoteFacadeService } from '../facade/remote-facade.service'; +import { PathService } from '../infrastructure/platform/path.service'; +import { QuickRunService } from '../flow/quick-run.service'; +import { + JobInfo, + Remote, + ServeListItem, + Automation, + QuickRun, + DEFAULT_JOB_STATS, +} from '@app/types'; +import { vi, describe, beforeEach, it, expect } from 'vitest'; + +describe('NavigationDispatcherService', () => { + let service: NavigationDispatcherService; + let mockUiStateService: { + setMainView: ReturnType; + setTab: ReturnType; + setSelectedRemote: ReturnType; + }; + let mockRemoteFacade: { activeRemotes: ReturnType }; + let mockPathService: { getRemoteNameFromFs: ReturnType }; + let mockQuickRunService: { + quickRuns: ReturnType; + select: ReturnType; + }; + + const mockRemote = { + name: 'test-remote', + type: 'drive', + } as unknown as Remote; + + const mockQuickRun = { + id: 'qr-1', + name: 'Sync Drive', + backendName: 'Local', + remoteName: 'test-remote', + operationType: 'sync', + sourcePaths: ['/local'], + destinationPaths: ['test-remote:/backup'], + status: 'idle', + createdAt: '2026-01-01', + } as unknown as QuickRun; + + beforeEach((): void => { + mockUiStateService = { + setMainView: vi.fn(), + setTab: vi.fn(), + setSelectedRemote: vi.fn(), + }; + mockRemoteFacade = { + activeRemotes: vi.fn().mockReturnValue([mockRemote]), + }; + mockPathService = { + getRemoteNameFromFs: vi.fn(), + }; + mockQuickRunService = { + quickRuns: vi.fn().mockReturnValue([mockQuickRun]), + select: vi.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + NavigationDispatcherService, + { provide: UiStateService, useValue: mockUiStateService }, + { provide: RemoteFacadeService, useValue: mockRemoteFacade }, + { provide: PathService, useValue: mockPathService }, + { provide: QuickRunService, useValue: mockQuickRunService }, + ], + }); + + service = TestBed.inject(NavigationDispatcherService); + }); + + it('should be created', (): void => { + expect(service).toBeTruthy(); + }); + + describe('navigateToJob', () => { + it('should navigate to flow when origin is quickrun', (): void => { + const job: JobInfo = { + jobid: 1, + execute_id: 'exec-1', + job_type: 'sync', + source: '/local', + destination: 'test-remote:/backup', + start_time: '2026-01-01', + status: 'Running', + remote_name: 'test-remote', + stats: DEFAULT_JOB_STATS, + origin: 'quickrun', + profile: 'qr-1', + }; + + service.navigateToJob(job); + + expect(mockUiStateService.setMainView).toHaveBeenCalledWith('flow'); + expect(mockQuickRunService.select).toHaveBeenCalledWith('qr-1'); + }); + + it('should navigate to operations tab for standard job', (): void => { + const job: JobInfo = { + jobid: 1, + execute_id: 'exec-1', + job_type: 'sync', + source: '/local', + destination: 'test-remote:/backup', + start_time: '2026-01-01', + status: 'Running', + remote_name: 'test-remote', + stats: DEFAULT_JOB_STATS, + origin: 'dashboard', + }; + + service.navigateToJob(job); + + expect(mockUiStateService.setMainView).toHaveBeenCalledWith('main_menu'); + expect(mockUiStateService.setTab).toHaveBeenCalledWith('operations'); + expect(mockUiStateService.setSelectedRemote).toHaveBeenCalledWith(mockRemote); + }); + + it('should navigate to mount tab for mount job', (): void => { + const job: JobInfo = { + jobid: 2, + execute_id: 'exec-2', + job_type: 'mount', + source: 'test-remote:', + destination: '/mnt/test', + start_time: '2026-01-01', + status: 'Running', + remote_name: 'test-remote', + stats: DEFAULT_JOB_STATS, + }; + + service.navigateToJob(job); + + expect(mockUiStateService.setTab).toHaveBeenCalledWith('mount'); + }); + }); + + describe('navigateToServe', () => { + it('should navigate to serve tab when remote is found from fs', (): void => { + mockPathService.getRemoteNameFromFs.mockReturnValue('test-remote'); + const serve: ServeListItem = { + id: 'serve-1', + addr: 'localhost:8080', + params: { fs: 'test-remote:path', type: 'http' }, + }; + + service.navigateToServe(serve); + + expect(mockUiStateService.setMainView).toHaveBeenCalledWith('main_menu'); + expect(mockUiStateService.setTab).toHaveBeenCalledWith('serve'); + expect(mockUiStateService.setSelectedRemote).toHaveBeenCalledWith(mockRemote); + }); + }); + + describe('navigateToAutomation', () => { + it('should navigate to operations tab for remote automation', (): void => { + const automation = { + id: 'auto-1', + automationType: 'sync', + remoteName: 'test-remote', + profileName: 'default', + status: 'enabled', + backendName: 'Local', + args: { + srcPaths: ['/src'], + dstPaths: ['/dst'], + remoteName: 'test-remote', + profileName: 'default', + }, + createdAt: '2026-01-01', + runCount: 0, + successCount: 0, + failureCount: 0, + stoppedCount: 0, + } as unknown as Automation; + + service.navigateToAutomation(automation); + + expect(mockUiStateService.setMainView).toHaveBeenCalledWith('main_menu'); + expect(mockUiStateService.setTab).toHaveBeenCalledWith('operations'); + expect(mockUiStateService.setSelectedRemote).toHaveBeenCalledWith(mockRemote); + }); + }); +}); diff --git a/src/app/services/ui/navigation-dispatcher.service.ts b/src/app/services/ui/navigation-dispatcher.service.ts new file mode 100644 index 000000000..c76e7a955 --- /dev/null +++ b/src/app/services/ui/navigation-dispatcher.service.ts @@ -0,0 +1,126 @@ +import { inject, Injectable } from '@angular/core'; +import { Automation, JobInfo, Remote, ServeListItem, AppTab } from '@app/types'; +import { UiStateService } from './state/ui-state.service'; +import { RemoteFacadeService } from '../facade/remote-facade.service'; +import { PathService } from '../infrastructure/platform/path.service'; +import { QuickRunService } from '../flow/quick-run.service'; + +/** + * Dispatches centralized UI navigation requests across main views, tabs, + * remotes, and quick runs from overview panels or global actions. + */ +@Injectable({ + providedIn: 'root', +}) +export class NavigationDispatcherService { + private readonly uiStateService = inject(UiStateService); + private readonly remoteFacade = inject(RemoteFacadeService); + private readonly pathService = inject(PathService); + private readonly quickRunService = inject(QuickRunService); + + /** + * Navigate to the appropriate view and tab for a running or completed job. + */ + navigateToJob(job: JobInfo): void { + if (job.origin === 'quickrun' || job.origin === 'flow') { + this.uiStateService.setMainView('flow'); + const qr = this.quickRunService + .quickRuns() + .find(q => q.id === job.profile || q.name === job.profile); + if (qr) { + this.quickRunService.select(qr.id); + } + return; + } + + const remoteName = job.remote_name; + if (remoteName) { + const remote = this.remoteFacade.activeRemotes().find(r => r.name === remoteName); + if (remote) { + this.uiStateService.setMainView('main_menu'); + if (job.job_type === 'mount') { + this.uiStateService.setTab('mount'); + } else if (job.job_type === 'serve') { + this.uiStateService.setTab('serve'); + } else { + this.uiStateService.setTab('operations'); + } + this.uiStateService.setSelectedRemote(remote); + } + } + } + + /** + * Navigate to the serve tab for the remote hosting the given serve instance. + */ + navigateToServe(serve: ServeListItem): void { + const remoteName = this.pathService.getRemoteNameFromFs(serve.params?.fs); + if (remoteName) { + const remote = this.remoteFacade.activeRemotes().find(r => r.name === remoteName); + if (remote) { + this.uiStateService.setMainView('main_menu'); + this.uiStateService.setTab('serve'); + this.uiStateService.setSelectedRemote(remote); + return; + } + } + + if (serve.profile) { + const qr = this.quickRunService + .quickRuns() + .find(q => q.id === serve.profile || q.name === serve.profile); + if (qr) { + this.uiStateService.setMainView('flow'); + this.quickRunService.select(qr.id); + } + } + } + + /** + * Navigate to the remote or Quick Run corresponding to the given automation. + */ + navigateToAutomation(automation: Automation): void { + const remoteName = automation.remoteName || automation.args?.remoteName; + if (remoteName) { + const remote = this.remoteFacade.activeRemotes().find(r => r.name === remoteName); + if (remote) { + this.uiStateService.setMainView('main_menu'); + this.uiStateService.setTab('operations'); + this.uiStateService.setSelectedRemote(remote); + return; + } + } + + const qr = this.quickRunService + .quickRuns() + .find(q => q.id === automation.profileName || q.name === automation.profileName); + if (qr) { + this.uiStateService.setMainView('flow'); + this.quickRunService.select(qr.id); + } + } + + /** + * Helper to navigate directly to a remote and optionally select a tab. + */ + navigateToRemote(remoteOrName: Remote | string, tab: AppTab = 'general'): void { + const remote = + typeof remoteOrName === 'string' + ? this.remoteFacade.activeRemotes().find(r => r.name === remoteOrName) + : remoteOrName; + + if (remote) { + this.uiStateService.setMainView('main_menu'); + this.uiStateService.setTab(tab); + this.uiStateService.setSelectedRemote(remote); + } + } + + /** + * Helper to navigate directly to Flow and select a Quick Run by ID. + */ + navigateToQuickRun(quickRunId: string): void { + this.uiStateService.setMainView('flow'); + this.quickRunService.select(quickRunId); + } +} diff --git a/src/app/services/ui/notification.service.ts b/src/app/services/ui/notification.service.ts index 440f8de01..be6cc4590 100755 --- a/src/app/services/ui/notification.service.ts +++ b/src/app/services/ui/notification.service.ts @@ -3,40 +3,67 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateService } from '@ngx-translate/core'; import { MatDialog, MatDialogConfig, MatDialogRef } from '@angular/material/dialog'; import { firstValueFrom } from 'rxjs'; -import type { InputModalData } from '../../shared/modals/input-modal/input-modal.component'; +import type { ConfirmModalComponent } from '../../shared/modals/confirm-modal/confirm-modal.component'; +import type { + InputModalData, + InputModalComponent, +} from '../../shared/modals/input-modal/input-modal.component'; import { ConfirmDialogData } from '@app/types'; +import { BackendTranslationService } from '../i18n/backend-translation.service'; + +type NotificationSeverity = 'success' | 'error' | 'info' | 'warning'; -/** - * Centralizes snackbar, modal, and toast notifications. - * Button texts default to their translated `common.*` counterparts. - */ @Injectable({ providedIn: 'root' }) export class NotificationService { private snackBar = inject(MatSnackBar); private translate = inject(TranslateService); private dialog = inject(MatDialog); + private backendTranslation = inject(BackendTranslationService); - showSuccess(message: string, action?: string, duration = 3000): void { - this.snackBar.open(message, action ?? this.translate.instant('common.ok'), { - duration, - }); + /** Default action label per severity. */ + private static readonly DEFAULT_ACTION_KEY = { + success: 'common.ok', + info: 'common.ok', + warning: 'common.close', + error: 'common.close', + } as const; + + /** Default duration per severity (undefined = no auto-dismiss). */ + private static readonly DEFAULT_DURATION_MS: Record = { + success: 3000, + info: 3000, + warning: 4000, + error: undefined, + }; + + showSuccess(message: unknown, action?: string, duration?: number): void { + this.show('success', message, action, duration); } - showError(message: string, action?: string, duration?: number): void { - this.snackBar.open(message, action ?? this.translate.instant('common.close'), { - duration, - }); + showError(message: unknown, action?: string, duration?: number): void { + this.show('error', message, action, duration); } - showInfo(message: string, action?: string, duration = 3000): void { - this.snackBar.open(message, action ?? this.translate.instant('common.ok'), { - duration, - }); + showInfo(message: unknown, action?: string, duration?: number): void { + this.show('info', message, action, duration); + } + + showWarning(message: unknown, action?: string, duration?: number): void { + this.show('warning', message, action, duration); } - showWarning(message: string, action?: string, duration?: number): void { - this.snackBar.open(message, action ?? this.translate.instant('common.ok'), { - duration, + private show( + severity: NotificationSeverity, + message: unknown, + action: string | undefined, + duration: number | undefined + ): void { + const resolvedMessage = this.backendTranslation.translateBackendMessage(message); + const resolvedAction = + action ?? this.translate.instant(NotificationService.DEFAULT_ACTION_KEY[severity]); + const resolvedDuration = duration ?? NotificationService.DEFAULT_DURATION_MS[severity]; + this.snackBar.open(resolvedMessage, resolvedAction, { + duration: resolvedDuration, }); } @@ -63,7 +90,7 @@ export class NotificationService { async openConfirm( data: ConfirmDialogData, config: Partial> = {} - ): Promise> { + ): Promise> { const { ConfirmModalComponent } = await import('../../shared/modals/confirm-modal/confirm-modal.component'); return this.dialog.open(ConfirmModalComponent, { @@ -75,10 +102,10 @@ export class NotificationService { }); } - async openInput( + async openInput( data: InputModalData, config: Partial> = {} - ): Promise> { + ): Promise> { const { InputModalComponent } = await import('../../shared/modals/input-modal/input-modal.component'); return this.dialog.open(InputModalComponent, { diff --git a/src/app/services/ui/state/ui-state.service.ts b/src/app/services/ui/state/ui-state.service.ts index fae8a4fd0..07cdfe165 100644 --- a/src/app/services/ui/state/ui-state.service.ts +++ b/src/app/services/ui/state/ui-state.service.ts @@ -1,10 +1,30 @@ -import { inject, Injectable, signal, effect } from '@angular/core'; +import { inject, Injectable, signal, computed, effect, type Signal } from '@angular/core'; import { platform } from '@tauri-apps/plugin-os'; -import { AppTab, Remote, APP_TABS } from '@app/types'; +import { AppTab, Remote, APP_TABS, MainView, CardDisplayMode } from '@app/types'; import { isHeadlessMode } from 'src/app/services/infrastructure/platform/api-client.service'; -import { PathService } from 'src/app/services/infrastructure/platform/path.service'; import { WindowService } from 'src/app/services/ui/window.service'; import { LocalStorageService } from './local-storage.service'; +import { AppSettingsService } from 'src/app/services/settings/app-settings.service'; + +/** Shape each sidebar-owning component passes to registerMobileSidebar. */ +export interface MobileSidebarRegistration { + /** View identifier so the service knows which registration is topmost. */ + view: MainView; + /** Signal that is `true` when the sidebar drawer uses 'over' mode (mobile). */ + isOver: Signal; + /** Signal that is `true` when the sidebar drawer is open. */ + isOpen: Signal; +} + +/** Configuration and callbacks for active layout editing mode */ +export interface LayoutEditContext { + /** Identifier of the active overview (e.g. 'general', 'mount', 'quick_run') */ + overviewId: string; + /** Optional callback to reset layout to default */ + onReset?: () => void; + /** Whether this overview supports toggling compact/detailed card mode */ + hasViewToggle?: boolean; +} /** * Service for managing UI state with focus on viewport settings @@ -13,26 +33,14 @@ import { LocalStorageService } from './local-storage.service'; providedIn: 'root', }) export class UiStateService { - private pathService = inject(PathService); private windowService = inject(WindowService); private localStorage = inject(LocalStorageService); + private appSettingsService = inject(AppSettingsService); public isMaximized = this.windowService.isMaximized; public readonly platform: string; - private readonly _currentTab = signal( - ((): AppTab => { - const stored = this.localStorage.get('ui.currentTab', 'general'); - const validTabs = APP_TABS; - if (validTabs.includes(stored as AppTab)) { - return stored as AppTab; - } - if (stored === 'sync') { - return 'operations'; - } - return 'general'; - })() - ); + private readonly _currentTab = signal(this.getInitialTab()); public readonly currentTab = this._currentTab.asReadonly(); // JSON Editor mode state @@ -41,13 +49,106 @@ export class UiStateService { ); public readonly showJsonMode = this._showJsonMode.asReadonly(); + // Layout editing state + private readonly _activeEditContext = signal(null); + public readonly activeEditContext = this._activeEditContext.asReadonly(); + public readonly isEditingLayout = computed(() => this._activeEditContext() !== null); + + // Card display mode centrally synchronized with settings + public readonly cardDisplayMode = computed(() => { + const val = this.appSettingsService.options()?.['runtime.dashboard_card_variant'] + ?.value as CardDisplayMode; + return val || 'compact'; + }); + // Selected remote state private readonly _selectedRemote = signal(null); public readonly selectedRemote = this._selectedRemote.asReadonly(); - // Mobile sidebar open state (used to hide bottom tabs when drawer is open) - private readonly _mobileSidebarOpen = signal(false); - public readonly mobileSidebarOpen = this._mobileSidebarOpen.asReadonly(); + // Main view state ('main_menu' | 'nautilus' | 'flow') + private readonly _defaultView = signal('main_menu'); + public readonly defaultView = this._defaultView.asReadonly(); + + private readonly _selectedMainView = signal('main_menu'); + public readonly selectedMainView = this._selectedMainView.asReadonly(); + + // Mobile Sidebar registrations + private readonly _mobileSidebarRegistrations = signal>( + new Map() + ); + + // Overlay signals set lazily to avoid circular dependencies + private _overlaySignals?: { + mainOverlay: Signal; + flowOverlay: Signal; + nautilusOverlay: Signal; + }; + + /** + * Reactive flag consumed by `TabsButtonsComponent` to hide the floating + * mobile tab bar whenever the topmost view's sidebar drawer is open in + * overlay ('over') mode. + * + * The value is computed from the active registrations plus the overlay + * signals injected lazily via `setOverlaySignals()`. + */ + public readonly mobileSidebarOpen = computed(() => { + if ( + this._overlaySignals?.mainOverlay() || + this._overlaySignals?.flowOverlay() || + this._overlaySignals?.nautilusOverlay() + ) { + return true; + } + + const registrations = this._mobileSidebarRegistrations(); + const topView = this._selectedMainView(); + const reg = registrations.get(topView); + if (!reg) return false; + return reg.isOver() && reg.isOpen(); + }); + + setOverlaySignals(signals: { + mainOverlay: Signal; + flowOverlay: Signal; + nautilusOverlay: Signal; + }): void { + this._overlaySignals = signals; + } + + private getInitialTab(): AppTab { + const stored = this.localStorage.get('ui.currentTab', 'general'); + if (APP_TABS.includes(stored as AppTab)) { + return stored as AppTab; + } + if (stored === 'sync') { + return 'operations'; + } + return 'general'; + } + + /** + * Register a sidebar-owning component so the mobile-sidebar computation + * can track its drawer state. Call from the component constructor. + */ + registerMobileSidebar(reg: MobileSidebarRegistration): void { + this._mobileSidebarRegistrations.update(m => { + const next = new Map(m); + next.set(reg.view, reg); + return next; + }); + } + + /** + * Unregister when the component is destroyed. Call from `destroyRef.onDestroy`. + */ + unregisterMobileSidebar(view: MainView): void { + this._mobileSidebarRegistrations.update(m => { + const next = new Map(m); + next.delete(view); + return next; + }); + } // Viewport settings configuration private viewportSettings = { @@ -69,6 +170,14 @@ export class UiStateService { effect(() => { this.applyViewportSettings(this.windowService.isMaximized()); }); + + if (typeof window !== 'undefined') { + window.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key === 'Escape' && this.isEditingLayout()) { + this.endLayoutEdit(); + } + }); + } } private initializePlatform(): string { @@ -83,8 +192,51 @@ export class UiStateService { } } + // === Layout Editing Management === + isEditingOverview(overviewId: string): boolean { + return this._activeEditContext()?.overviewId === overviewId; + } + + toggleLayoutEdit(context: LayoutEditContext): void { + if (this._activeEditContext()?.overviewId === context.overviewId) { + this.endLayoutEdit(); + } else { + this._activeEditContext.set(context); + } + } + + startLayoutEdit(context: LayoutEditContext): void { + this._activeEditContext.set(context); + } + + endLayoutEdit(): void { + this._activeEditContext.set(null); + } + + resetLayout(): void { + this._activeEditContext()?.onReset?.(); + } + + toggleCardDisplayMode(): void { + const next: CardDisplayMode = this.cardDisplayMode() === 'compact' ? 'detailed' : 'compact'; + void this.appSettingsService.saveSetting('runtime', 'dashboard_card_variant', next); + } + + // === Main View Management === + setDefaultView(view: MainView): void { + this.endLayoutEdit(); + this._defaultView.set(view); + this._selectedMainView.set(view); + } + + setMainView(view: MainView): void { + this.endLayoutEdit(); + this._selectedMainView.set(view); + } + // === Tab Management === setTab(tab: AppTab): void { + this.endLayoutEdit(); this._currentTab.set(tab); this.localStorage.set('ui.currentTab', tab); } @@ -112,22 +264,6 @@ export class UiStateService { this._selectedRemote.set(null); } - // === Mobile Sidebar === - setMobileSidebarOpen(open: boolean): void { - this._mobileSidebarOpen.set(open); - } - - extractFilename(path: string): string { - return this.pathService.getFilename(path); - } - - /** - * Join path segments. - */ - joinPath(...segments: string[]): string { - return this.pathService.joinPath(...segments); - } - // === Viewport Management === private applyViewportSettings(isMaximized: boolean): void { diff --git a/src/app/services/ui/validation/validator-registry.service.ts b/src/app/services/ui/validation/validator-registry.service.ts index 54f6d9bdb..209465864 100644 --- a/src/app/services/ui/validation/validator-registry.service.ts +++ b/src/app/services/ui/validation/validator-registry.service.ts @@ -12,6 +12,19 @@ import { REMOTE_NAME_REGEX } from '@app/types'; import { Observable, merge } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +const INTEGER_REGEX = /^-?\d+$/; +const FLOAT_REGEX = /^-?\d+(\.\d+)?$/; +const DURATION_REGEX = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h|d))+$/; +const SIZE_SUFFIX_REGEX = /^\d+(\.\d+)?(b|B|k|K|Ki|M|Mi|G|Gi|T|Ti|P|Pi|E|Ei)?$/; +const TIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?([+-]\d{2}:\d{2}|Z)?$/; +const FILE_MODE_REGEX = /^[0-7]{3,4}$/; +const BW_TIMETABLE_REGEX = /^\d+(\.\d+)?(B|K|M|G|T|P)?$/; +const WIN_ABS_PATH_REGEX = + /^(?:[a-zA-Z]:(?:[\\/].*)?|\\\\[?]?[\\]?[^\\/]+[\\/][^\\/]+|\\\\[a-zA-Z0-9_\-.]+[\\/][^\\/]+.*)$/; +const URL_PATTERN_REGEX = /^https?:\/\/[^\s;]+$/; +const BANDWIDTH_PATTERN_REGEX = + /^(\d+(?:\.\d+)?([KMGkmg]|Mi|mi|Gi|gi|Ki|ki)?(\|\d+(?:\.\d+)?([KMGkmg]|Mi|mi|Gi|gi|Ki|ki)?)*)(:\d+(?:\.\d+)?([KMGkmg]|Mi|mi|Gi|gi|Ki|ki)?(\|\d+(?:\.\d+)?([KMGkmg]|Mi|mi|Gi|gi|Ki|ki)?)*|)?$/; + @Injectable({ providedIn: 'root', }) @@ -19,7 +32,6 @@ export class ValidatorRegistryService { private readonly validators = new Map(); private readonly translate = inject(TranslateService); private readonly backendService = inject(BackendService); - private readonly regexCache = new Map(); constructor() { this.validators.set('crossPlatformPath', this.crossPlatformPathValidator()); @@ -28,15 +40,6 @@ export class ValidatorRegistryService { this.validators.set('password', this.passwordValidator()); } - private getCachedRegex(pattern: string): RegExp { - let compiled = this.regexCache.get(pattern); - if (!compiled) { - compiled = new RegExp(pattern); - this.regexCache.set(pattern, compiled); - } - return compiled; - } - registerValidator(name: string, validator: ValidatorFn): void { this.validators.set(name, validator); } @@ -47,7 +50,8 @@ export class ValidatorRegistryService { arrayValidator(): ValidatorFn { return (control: AbstractControl): ValidationErrors | null => { - if (!control.value || Array.isArray(control.value)) return null; + if (!control.value || Array.isArray(control.value) || typeof control.value === 'string') + return null; return { invalidArray: true }; }; } @@ -57,7 +61,7 @@ export class ValidatorRegistryService { if (!control.value || control.value === '') return null; const value = control.value.toString().trim(); if (defaultValue && value.toLowerCase() === defaultValue.toLowerCase()) return null; - if (!this.getCachedRegex('^-?\\d+$').test(value)) { + if (!INTEGER_REGEX.test(value)) { return { integer: { value, message: this.translate.instant('validators.integer') } }; } return null; @@ -69,7 +73,7 @@ export class ValidatorRegistryService { if (!control.value || control.value === '') return null; const value = control.value.toString().trim(); if (defaultValue && value.toLowerCase() === defaultValue.toLowerCase()) return null; - if (!this.getCachedRegex('^-?\\d+(\\.\\d+)?$').test(value)) { + if (!FLOAT_REGEX.test(value)) { return { float: { value, message: this.translate.instant('validators.float') } }; } return null; @@ -81,7 +85,7 @@ export class ValidatorRegistryService { if (!control.value || control.value === '') return null; const value = control.value.toString().trim(); if (defaultValue && value.toLowerCase() === defaultValue.toLowerCase()) return null; - if (!this.getCachedRegex('^(\\d+(\\.\\d+)?(ns|us|µs|ms|s|m|h|d))+$').test(value)) { + if (!DURATION_REGEX.test(value)) { return { duration: { value, message: this.translate.instant('validators.duration') } }; } return null; @@ -94,9 +98,7 @@ export class ValidatorRegistryService { const value = control.value.toString().trim(); if (defaultValue && value.toLowerCase() === defaultValue.toLowerCase()) return null; if (value.toLowerCase() === 'off') return null; - if ( - !this.getCachedRegex('^\\d+(\\.\\d+)?(b|B|k|K|Ki|M|Mi|G|Gi|T|Ti|P|Pi|E|Ei)?$').test(value) - ) { + if (!SIZE_SUFFIX_REGEX.test(value)) { return { sizeSuffix: { value, message: this.translate.instant('validators.sizeSuffix') } }; } return null; @@ -117,12 +119,7 @@ export class ValidatorRegistryService { if (!control.value || control.value === '') return null; const value = control.value.toString().trim(); if (defaultValue && value.toLowerCase() === defaultValue.toLowerCase()) return null; - if ( - !this.getCachedRegex( - '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}(:\\d{2}(\\.\\d+)?)?([+-]\\d{2}:\\d{2}|Z)?$' - ).test(value) && - isNaN(new Date(value).getTime()) - ) { + if (!TIME_REGEX.test(value) && isNaN(new Date(value).getTime())) { return { time: { value, message: this.translate.instant('validators.time') } }; } return null; @@ -150,11 +147,7 @@ export class ValidatorRegistryService { if (defaultValue && value.toLowerCase() === defaultValue.toLowerCase()) return null; if (value.toLowerCase() === 'off') return null; const hasTimetable = value.includes(',') || value.includes('-') || value.includes(':'); - if ( - !this.getCachedRegex('^\\d+(\\.\\d+)?(B|K|M|G|T|P)?$').test(value) && - !hasTimetable && - value.length > 0 - ) { + if (!BW_TIMETABLE_REGEX.test(value) && !hasTimetable && value.length > 0) { return { bwTimetable: { value, message: this.translate.instant('validators.bwTimetable') }, }; @@ -168,17 +161,18 @@ export class ValidatorRegistryService { if (!control.value || control.value === '') return null; const value = control.value.toString().trim(); if (defaultValue && value.toLowerCase() === defaultValue.toLowerCase()) return null; - if (!this.getCachedRegex('^[0-7]{3,4}$').test(value)) { + if (!FILE_MODE_REGEX.test(value)) { return { fileMode: { value, message: this.translate.instant('validators.fileMode') } }; } return null; }; } - enumValidator(allowedValues: string[]): ValidatorFn { - const lowerValues = allowedValues.map(v => v.toLowerCase()); + enumValidator(allowedValues: unknown[]): ValidatorFn { + const lowerValues = allowedValues.map(v => String(v ?? '').toLowerCase()); return (control: AbstractControl): ValidationErrors | null => { - if (!control.value || control.value === '') return null; + if (control.value === null || control.value === undefined || control.value === '') + return null; const value = control.value.toString().trim().toLowerCase(); if (!lowerValues.includes(value)) { return { @@ -186,7 +180,7 @@ export class ValidatorRegistryService { value, allowedValues, message: this.translate.instant('validators.enum', { - values: allowedValues.join(', '), + values: allowedValues.map(v => String(v)).join(', '), }), }, }; @@ -224,7 +218,7 @@ export class ValidatorRegistryService { } }; - const triggers: Observable[] = []; + const triggers: Observable[] = []; if (autoStartCtrl) triggers.push(autoStartCtrl.valueChanges); if (cronEnabledCtrl) triggers.push(cronEnabledCtrl.valueChanges); if (watchEnabledCtrl) triggers.push(watchEnabledCtrl.valueChanges); @@ -317,9 +311,7 @@ export class ValidatorRegistryService { if (!value) return null; if (this.backendService.isWindows()) { - const winAbs = - /^(?:[a-zA-Z]:(?:[\\/].*)?|\\\\[?]?[\\]?[^\\/]+[\\/][^\\/]+|\\\\[a-zA-Z0-9_\-.]+[\\/][^\\/]+.*)$/; - if (winAbs.test(value)) return null; + if (WIN_ABS_PATH_REGEX.test(value)) return null; } else { if (/^(\/[^\0]*)$/.test(value)) return null; } @@ -329,13 +321,12 @@ export class ValidatorRegistryService { } private urlArrayValidator(): ValidatorFn { - const urlPattern = /^https?:\/\/[^\s;]+$/; return (control: AbstractControl): ValidationErrors | null => { const urls = control.value; if (!Array.isArray(urls) || urls.length === 0) return null; for (const url of urls) { - if (typeof url !== 'string' || !urlPattern.test(url.trim())) { + if (typeof url !== 'string' || !URL_PATTERN_REGEX.test(url.trim())) { return { urlArray: { message: this.translate.instant('validators.urlArray'), invalidUrl: url }, }; @@ -346,11 +337,9 @@ export class ValidatorRegistryService { } private bandwidthValidator(): ValidatorFn { - const bandwidthPattern = - /^(\d+(?:\.\d+)?([KMGkmg]|Mi|mi|Gi|gi|Ki|ki)?(\|\d+(?:\.\d+)?([KMGkmg]|Mi|mi|Gi|gi|Ki|ki)?)*)(:\d+(?:\.\d+)?([KMGkmg]|Mi|mi|Gi|gi|Ki|ki)?(\|\d+(?:\.\d+)?([KMGkmg]|Mi|mi|Gi|gi|Ki|ki)?)*|)?$/; return (control: AbstractControl): ValidationErrors | null => { if (!control.value) return null; - if (!bandwidthPattern.test(control.value)) { + if (!BANDWIDTH_PATTERN_REGEX.test(control.value)) { return { bandwidth: { message: this.translate.instant('validators.bandwidth') } }; } return null; diff --git a/src/app/services/ui/window.service.ts b/src/app/services/ui/window.service.ts index 86918212b..43ba292fb 100644 --- a/src/app/services/ui/window.service.ts +++ b/src/app/services/ui/window.service.ts @@ -1,9 +1,9 @@ -import { effect, inject, Injectable, Injector, signal } from '@angular/core'; +import { DestroyRef, effect, inject, Injectable, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { platform } from '@tauri-apps/plugin-os'; import { Theme } from '@app/types'; import { AppSettingsService } from '../settings/app-settings.service'; import { TauriBaseService } from '../infrastructure/platform/tauri-base.service'; -import { isHeadlessMode } from '../infrastructure/platform/api-client.service'; export type ResizeDirection = 'East' | 'North' | 'NorthEast' | 'NorthWest' | 'South' | 'SouthEast' | 'SouthWest' | 'West'; @@ -14,8 +14,8 @@ export type ResizeDirection = export class WindowService extends TauriBaseService { private readonly _theme = signal('system'); public readonly theme = this._theme.asReadonly(); - appSettingsService = inject(AppSettingsService); - private readonly injector = inject(Injector); + private readonly appSettingsService = inject(AppSettingsService); + private readonly destroyRef = inject(DestroyRef); private readonly systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)'); private readonly _isMaximized = signal(false); @@ -23,35 +23,55 @@ export class WindowService extends TauriBaseService { constructor() { super(); - // Reactively listen to settings changes - this.appSettingsService.selectSetting('runtime.theme').subscribe(setting => { - const theme = (setting?.value as Theme) || 'system'; - this.applyTheme(theme); - this._theme.set(theme); - }); - // Listen for system theme changes - this.systemThemeQuery.addEventListener('change', () => { + this.appSettingsService + .selectSetting('runtime.theme') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(setting => { + const theme = (setting?.value as Theme) || 'system'; + this.applyTheme(theme); + this._theme.set(theme); + }); + + const handleSystemThemeChange = (): void => { if (this._theme() === 'system') { this.applyTheme('system'); } + }; + this.systemThemeQuery.addEventListener('change', handleSystemThemeChange); + this.destroyRef.onDestroy(() => { + this.systemThemeQuery.removeEventListener('change', handleSystemThemeChange); }); - this.initWindowListeners(); - this.initLinuxResizeHandles(); + if (this.isTauri) { + effect(() => { + const isMax = this.isMaximized(); + const container = document.getElementById('linux-resize-handles'); + if (container) { + container.style.display = isMax ? 'none' : 'block'; + } + }); + + this.initWindowListeners(); + this.initLinuxResizeHandles(); + } } private async initWindowListeners(): Promise { - if (this.isTauri) { - this.checkMaximizedState(); - this.listenToEvent('tauri://resize').subscribe(() => { + this.checkMaximizedState(); + this.listenToEvent('tauri://resize') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { this.checkMaximizedState(); }); - } } private initLinuxResizeHandles(): void { - if (!this.isTauri || isHeadlessMode() || platform() !== 'linux') return; + try { + if (platform() !== 'linux') return; + } catch { + return; + } const createHandles = (): void => { if (document.getElementById('linux-resize-handles')) return; @@ -60,6 +80,7 @@ export class WindowService extends TauriBaseService { const container = document.createElement('div'); container.id = 'linux-resize-handles'; + container.style.display = this.isMaximized() ? 'none' : 'block'; const directions: ResizeDirection[] = [ 'North', @@ -95,13 +116,6 @@ export class WindowService extends TauriBaseService { } targetContainer.appendChild(container); - - effect( - () => { - container.style.display = this.isMaximized() ? 'none' : 'block'; - }, - { injector: this.injector } - ); }; if (document.readyState === 'loading') { @@ -124,7 +138,7 @@ export class WindowService extends TauriBaseService { async quitApplication(): Promise { try { - await this.invokeCommand('shutdown_app'); + await this.invokeCommand('request_app_exit'); } catch (error) { console.error('Failed to quit application:', error); } @@ -189,20 +203,10 @@ export class WindowService extends TauriBaseService { async applyTheme(theme: 'light' | 'dark' | 'system'): Promise { try { - let _theme: 'light' | 'dark' = theme as 'light' | 'dark'; - if (theme === 'system') { - try { - _theme = await this.getSystemTheme(); - } catch { - _theme = this.systemThemeQuery.matches ? 'dark' : 'light'; - } - // If systemThemeQuery disagrees (e.g. on Android where backend get_system_theme defaults to dark), use matchMedia - if (this.systemThemeQuery.matches !== (_theme === 'dark')) { - _theme = this.systemThemeQuery.matches ? 'dark' : 'light'; - } - } + const resolvedTheme: 'light' | 'dark' = + theme === 'system' ? (this.systemThemeQuery.matches ? 'dark' : 'light') : theme; - document.documentElement.setAttribute('class', _theme); + document.documentElement.setAttribute('class', resolvedTheme); // On Android, notify native Kotlin bridge to sync status bar / navigation bar icon theme const bridge = ( @@ -214,16 +218,15 @@ export class WindowService extends TauriBaseService { ).__rclone__; if (bridge?.setSystemTheme) { - bridge.setSystemTheme(_theme === 'dark'); + bridge.setSystemTheme(resolvedTheme === 'dark'); } - await this.invokeCommand('set_theme', { theme: _theme }); + await this.invokeCommand('set_theme', { + theme, + systemIsDark: this.systemThemeQuery.matches, + }); } catch (error) { console.error('Failed to apply theme:', error); } } - - getSystemTheme(): Promise<'light' | 'dark'> { - return this.invokeCommand<'light' | 'dark'>('get_system_theme'); - } } diff --git a/src/app/shared/components/app-menu/app-menu.component.html b/src/app/shared/components/app-menu/app-menu.component.html new file mode 100644 index 000000000..f4456e24c --- /dev/null +++ b/src/app/shared/components/app-menu/app-menu.component.html @@ -0,0 +1,167 @@ + + + +
+ +
+ @for (theme of themes; track theme.id) { + + } +
+ + + + + + + + + + + + + + + + + @if (activeWorkspace() !== 'main_menu') { + + } + @if (activeWorkspace() !== 'nautilus') { + + } + @if (activeWorkspace() !== 'flow') { + + } + + + + + + + + + + + + +
+
diff --git a/src/app/shared/components/app-menu/app-menu.component.scss b/src/app/shared/components/app-menu/app-menu.component.scss new file mode 100644 index 000000000..b0bf9a729 --- /dev/null +++ b/src/app/shared/components/app-menu/app-menu.component.scss @@ -0,0 +1,93 @@ +:host { + display: inline-flex; + align-items: center; +} + +.shortcut { + margin-left: auto; + opacity: 0.6; +} + +.menu-icon { + margin-right: 8px; + font-size: 18px; + width: 18px; + height: 18px; +} + +.menu-section-label { + padding: var(--space-xs) var(--space-md); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--dim-color); +} + +.selection-icon { + margin-left: auto; + font-size: 16px; + width: 16px; + height: 16px; + color: var(--accent-color); +} + +.theme-selector { + display: flex; + justify-content: center; + gap: var(--space-md); + padding: var(--space-xs); +} + +.theme-button { + width: 50px; + height: 50px; + border-radius: 50%; + color: var(--window-fg-color); + display: flex; + align-items: flex-end; + position: relative; + justify-content: flex-start; + background: transparent; + border: none; + cursor: pointer; + + &::before { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + box-shadow: 0 0 0 2px var(--border-color); + transition: box-shadow 200ms ease-in-out; + z-index: 0; + } + + .checkmark { + opacity: 1; + background: var(--gnome-light-1); + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + margin: -8px; + color: var(--gnome-blue-3); + position: relative; + z-index: 1; + } +} + +.light::before { + background: var(--gnome-light-1); +} + +.dark::before { + background: var(--gnome-dark-5); +} + +.system::before { + background: linear-gradient(315deg, var(--gnome-dark-5) 49.5%, var(--gnome-light-1) 50.5%); +} + +.selected::before { + box-shadow: 0 0 0 2px var(--accent-color); +} diff --git a/src/app/shared/components/app-menu/app-menu.component.ts b/src/app/shared/components/app-menu/app-menu.component.ts new file mode 100644 index 000000000..54b73ddf6 --- /dev/null +++ b/src/app/shared/components/app-menu/app-menu.component.ts @@ -0,0 +1,178 @@ +import { Component, ChangeDetectionStrategy, inject, computed } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatBadgeModule } from '@angular/material/badge'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatIconModule } from '@angular/material/icon'; +import { CdkMenuModule } from '@angular/cdk/menu'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { ModalService } from 'src/app/services/ui/modal.service'; +import { BackupRestoreUiService } from 'src/app/services/settings/backup-restore-ui.service'; +import { NautilusService } from 'src/app/services/ui/nautilus.service'; +import { WindowService } from 'src/app/services/ui/window.service'; +import { AppUpdaterService } from 'src/app/services/infrastructure/maintenance/app-updater.service'; +import { RcloneUpdateService } from 'src/app/services/infrastructure/maintenance/rclone-update.service'; +import { UiStateService } from 'src/app/services/ui/state/ui-state.service'; +import { AlertService } from 'src/app/services/alerts/alert.service'; +import { FlowOverlayService } from 'src/app/services/ui/flow-overlay.service'; +import { MainUiOverlayService } from 'src/app/services/ui/main-ui-overlay.service'; +import { Theme, MainView } from '@app/types'; + +@Component({ + selector: 'app-menu', + standalone: true, + imports: [ + CdkMenuModule, + MatDividerModule, + MatIconModule, + MatButtonModule, + MatBadgeModule, + TranslatePipe, + ], + templateUrl: './app-menu.component.html', + styleUrl: './app-menu.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AppMenuComponent { + private readonly modalService = inject(ModalService); + private readonly backupRestoreUiService = inject(BackupRestoreUiService); + private readonly nautilusService = inject(NautilusService); + private readonly windowService = inject(WindowService); + private readonly appUpdaterService = inject(AppUpdaterService); + private readonly rcloneUpdateService = inject(RcloneUpdateService); + private readonly translateService = inject(TranslateService); + private readonly flowOverlayService = inject(FlowOverlayService); + private readonly mainUiOverlayService = inject(MainUiOverlayService); + + readonly uiStateService = inject(UiStateService); + readonly alertService = inject(AlertService); + + // Signals for update states + readonly hasUpdates = this.appUpdaterService.hasUpdates; + readonly rcloneUpdateAvailable = this.rcloneUpdateService.hasUpdates; + readonly rcloneRestartRequired = this.rcloneUpdateService.readyToRestart; + readonly readyToRestart = this.appUpdaterService.readyToRestart; + + readonly currentTheme = this.windowService.theme; + + readonly updateTooltip = computed(() => { + const appRestart = this.readyToRestart(); + const rcloneRestart = this.rcloneRestartRequired(); + const appUpdate = this.hasUpdates(); + const rcloneUpdate = this.rcloneUpdateAvailable(); + + if (appRestart || rcloneRestart) { + return this.translateService.instant('titlebar.updates.restart'); + } else if (appUpdate && rcloneUpdate) { + return this.translateService.instant('titlebar.updates.all'); + } else if (appUpdate) { + return this.translateService.instant('titlebar.updates.app'); + } else if (rcloneUpdate) { + return this.translateService.instant('titlebar.updates.rclone'); + } + return ''; + }); + + readonly themes: { id: Theme; label: string; class: string }[] = [ + { id: 'system', label: 'titlebar.menu.system', class: 'system' }, + { id: 'light', label: 'titlebar.menu.light', class: 'light' }, + { id: 'dark', label: 'titlebar.menu.dark', class: 'dark' }, + ]; + + readonly aboutMenuBadge = computed(() => { + const appRestart = this.readyToRestart(); + const rcloneRestart = this.rcloneRestartRequired(); + const appUpdate = this.hasUpdates(); + const rcloneUpdate = this.rcloneUpdateAvailable(); + + if (appRestart || rcloneRestart) return '!'; + if (appUpdate && rcloneUpdate) return '2'; + if (appUpdate || rcloneUpdate) return '!'; + return ''; + }); + + async setTheme(theme: Theme, event?: MouseEvent): Promise { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + await this.windowService.setTheme(theme); + } + + readonly baseWorkspace = this.uiStateService.defaultView; + + readonly activeWorkspace = computed((): MainView => { + if (this.nautilusService.isBrowserOverlayOpen()) return 'nautilus'; + if (this.flowOverlayService.isFlowOverlayOpen()) return 'flow'; + if (this.mainUiOverlayService.isMainUiOverlayOpen()) return 'main_menu'; + return this.uiStateService.selectedMainView(); + }); + + goBackToBaseWorkspace(): void { + this.nautilusService.closeBrowserOverlay(); + this.flowOverlayService.closeFlowOverlay(); + this.mainUiOverlayService.closeMainUiOverlay(); + this.uiStateService.setMainView(this.baseWorkspace()); + } + + openWorkspace(target: MainView): void { + if (target === this.baseWorkspace()) { + this.goBackToBaseWorkspace(); + return; + } + + if (target === 'nautilus') { + this.flowOverlayService.closeFlowOverlay(); + this.mainUiOverlayService.closeMainUiOverlay(); + if (this.baseWorkspace() === 'nautilus') { + this.uiStateService.setMainView('nautilus'); + } else { + void this.nautilusService.openBrowserOverlay(null, null); + } + } else if (target === 'flow') { + this.nautilusService.closeBrowserOverlay(); + this.mainUiOverlayService.closeMainUiOverlay(); + if (this.baseWorkspace() === 'flow') { + this.uiStateService.setMainView('flow'); + } else { + void this.flowOverlayService.openFlowOverlay(); + } + } else if (target === 'main_menu') { + this.nautilusService.closeBrowserOverlay(); + this.flowOverlayService.closeFlowOverlay(); + if (this.baseWorkspace() === 'main_menu') { + this.uiStateService.setMainView('main_menu'); + } else { + void this.mainUiOverlayService.openMainUiOverlay(); + } + } + } + + openPreferencesModal(): void { + this.modalService.openPreferences(); + } + + openRcloneFlagsModal(): void { + this.modalService.openRcloneFlags(); + } + + openKeyboardShortcutsModal(): void { + this.modalService.openKeyboardShortcuts(); + } + + openExportModal(): void { + this.modalService.openExport(); + } + + restoreSettings(): void { + this.backupRestoreUiService.launchRestoreFlow(); + } + + openAboutModal(): void { + this.modalService.openAbout(); + } + + openAlertsModal(): void { + this.modalService.openAlerts(); + } +} diff --git a/src/app/shared/components/installation-options/installation-options.component.html b/src/app/shared/components/installation-options/installation-options.component.html index ea0783f89..4a1326b88 100644 --- a/src/app/shared/components/installation-options/installation-options.component.html +++ b/src/app/shared/components/installation-options/installation-options.component.html @@ -36,7 +36,7 @@

{{ 'shared.installationOptions.modes.' + mode() + '.custom.description' | translate }}

- + {{ 'shared.installationOptions.modes.' + mode() + '.custom.label' | translate }} @@ -69,7 +69,7 @@

{{ 'shared.installationOptions.modes.' + mode() + '.existing.description' | translate }}

- + {{ 'shared.installationOptions.modes.' + mode() + '.existing.label' | translate }} diff --git a/src/app/shared/components/installation-options/installation-options.component.scss b/src/app/shared/components/installation-options/installation-options.component.scss index 3ac9465d5..a97e5ad26 100644 --- a/src/app/shared/components/installation-options/installation-options.component.scss +++ b/src/app/shared/components/installation-options/installation-options.component.scss @@ -14,7 +14,7 @@ flex-direction: column; align-items: center; gap: var(--space-xs); - padding: var(--space-md) var(--space-sm); + padding: var(--space-xs); box-shadow: inset -1px 0 0 var(--card-shade-color), inset 0 -1px 0 var(--card-shade-color); @@ -97,19 +97,3 @@ color: var(--warn-color); } } - -@media (max-width: 480px) { - .options-tabs .option-tab { - padding: var(--space-xs); - - mat-icon { - width: var(--icon-size-xs); - height: var(--icon-size-xs); - font-size: var(--icon-size-xs); - } - - span { - font-size: var(--font-size-xs); - } - } -} diff --git a/src/app/shared/components/json-editor/json-editor.component.html b/src/app/shared/components/json-editor/json-editor.component.html index 47382f330..ba9332e0e 100644 --- a/src/app/shared/components/json-editor/json-editor.component.html +++ b/src/app/shared/components/json-editor/json-editor.component.html @@ -1,8 +1,5 @@ @if (infoBanner(); as banner) { -
- - {{ banner | translate }} -
+ }
@@ -40,15 +37,9 @@
@if (parseError(); as err) { - + } @if (parseWarning(); as warn) { - + } diff --git a/src/app/shared/components/json-editor/json-editor.component.scss b/src/app/shared/components/json-editor/json-editor.component.scss index fd4a5a81c..4f8a67c28 100644 --- a/src/app/shared/components/json-editor/json-editor.component.scss +++ b/src/app/shared/components/json-editor/json-editor.component.scss @@ -5,30 +5,6 @@ min-height: 0; } -.json-info-banner { - display: flex; - align-items: center; - gap: var(--space-sm); - padding: var(--space-sm) var(--space-md); - background: rgba(var(--primary-color-rgb), 0.06); - border: 1px solid rgba(var(--primary-color-rgb), 0.15); - border-radius: var(--radius-md); - color: var(--window-fg-color); - font-size: var(--font-size-xs); - line-height: 1.4; - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); - box-shadow: var(--shadow-gnome); - - mat-icon { - width: 16px; - height: 16px; - font-size: 16px; - flex-shrink: 0; - color: var(--primary-color); - } -} - // ============================================================================ // CHIP ROW // ============================================================================ @@ -38,7 +14,6 @@ flex-wrap: wrap; gap: var(--space-xs); align-items: center; - min-height: 32px; padding: var(--space-xs) var(--space-sm); background: var(--view-bg-color); border-radius: var(--radius-md); @@ -51,46 +26,6 @@ font-style: italic; } -// ============================================================================ -// PARSE ERROR & WARNING BARS -// ============================================================================ - -.parse-error { - display: flex; - align-items: center; - gap: var(--space-xs); - padding: var(--space-xs) var(--space-sm); - background: rgba(var(--warn-color-rgb), 0.08); - color: var(--warn-color); - font-size: var(--font-size-xs); - border-top: 1px solid rgba(var(--warn-color-rgb), 0.25); - - mat-icon { - width: 14px; - height: 14px; - font-size: 14px; - flex-shrink: 0; - } -} - -.parse-warning { - display: flex; - align-items: center; - gap: var(--space-xs); - padding: var(--space-xs) var(--space-sm); - background: rgba(var(--orange-rgb), 0.08); - color: var(--orange); - font-size: var(--font-size-xs); - border-top: 1px solid rgba(var(--orange-rgb), 0.25); - - mat-icon { - width: 14px; - height: 14px; - font-size: 14px; - flex-shrink: 0; - } -} - // ============================================================================ // MOBILE // ============================================================================ diff --git a/src/app/shared/components/json-editor/json-editor.component.ts b/src/app/shared/components/json-editor/json-editor.component.ts index 0d175afc9..a385cd901 100644 --- a/src/app/shared/components/json-editor/json-editor.component.ts +++ b/src/app/shared/components/json-editor/json-editor.component.ts @@ -33,12 +33,11 @@ import { matchesConfigSearch, OPERATION_PATH_MAPPINGS, getTopLevelKeysForProfile, - getControlKey, } from 'src/app/services/remote/utils/remote-config.utils'; import { AppSettingsService } from 'src/app/services/settings/app-settings.service'; -import { staticFlagDefinitions } from '../../../services/remote/flag-definitions'; -import { PathService } from '../../../services/infrastructure/platform/path.service'; +import { PathService, PathGroup } from '../../../services/infrastructure/platform/path.service'; +import { AlertBannerComponent } from '../alert-banner/alert-banner.component'; import { EditorView, keymap, @@ -66,10 +65,6 @@ export const JSON_EDITOR_LOOKUP_TABLE = new InjectionToken char.toUpperCase()); -} - function toSnakeCase(str: string): string { return str.replace(/^--?/, '').replace(/-/g, '_'); } @@ -91,7 +86,8 @@ function hasOptionsGroup(type: string | null): boolean { function buildRcloneCompletionSource( getFieldDefs: () => RcConfigOption[], - getFlagType: () => SharedProfileType | null + getFlagType: () => SharedProfileType | null, + getFieldKey: (f: RcConfigOption) => string = f => f.Name || f.FieldName ) { return (context: CompletionContext): CompletionResult | null => { const tree = syntaxTree(context.state); @@ -100,27 +96,6 @@ function buildRcloneCompletionSource( const flagType = getFlagType(); const isProfile = isProfileType(flagType); - // Check if we are inside the nested options object (_config or mountOpt) - let insideConfig = false; - let parent = nodeBefore.parent; - while (parent) { - if (parent.name === 'Property') { - const propKeyNode = parent.getChild('PropertyName') ?? parent.firstChild; - if (propKeyNode) { - const propKey = context.state - .sliceDoc(propKeyNode.from, propKeyNode.to) - .replace(/^"|"$/g, ''); - if (propKey === '_config' || propKey === 'mountOpt') { - if (parent !== nodeBefore.parent) { - insideConfig = true; - break; - } - } - } - } - parent = parent.parent; - } - const isPropertyName = nodeBefore.name === 'PropertyName' || (nodeBefore.name === 'String' && @@ -139,20 +114,19 @@ function buildRcloneCompletionSource( const to = nodeBefore.name === 'String' ? nodeBefore.to - 1 : context.pos; - if (isProfile && !insideConfig && flagType) { - // Autocomplete top-level properties - let topLevelKeys = getTopLevelKeysForProfile(flagType); - if (flagType === 'serve') { - topLevelKeys = ['fs', 'type', ...fieldDefs.map(f => f.Name)]; - } + if (isProfile && flagType) { + // Autocomplete top-level properties and option names + const topLevelKeys = getTopLevelKeysForProfile(flagType); + const optionKeys = fieldDefs.map(f => getFieldKey(f)); + const allKeys = Array.from(new Set([...topLevelKeys, ...optionKeys])); return { from, to, - options: topLevelKeys.map(k => ({ + options: allKeys.map(k => ({ label: k, type: 'property', - detail: 'Top-Level key', + detail: topLevelKeys.includes(k) ? 'Top-Level key' : 'Option', boost: 2, })), validFor: /^[^"]*$/, @@ -163,7 +137,7 @@ function buildRcloneCompletionSource( from, to, options: fieldDefs.map(f => ({ - label: getControlKey(f, flagType || undefined), + label: getFieldKey(f), type: 'property', detail: f.Type, info: f.Help || undefined, @@ -184,7 +158,7 @@ function buildRcloneCompletionSource( const rawKey = context.state.sliceDoc(keyNode.from, keyNode.to); const keyText = rawKey.replace(/^"|"$/g, ''); - const fieldDef = fieldDefs.find(f => getControlKey(f, flagType || undefined) === keyText); + const fieldDef = fieldDefs.find(f => getFieldKey(f) === keyText); if (!fieldDef?.Examples?.length) return null; const word = context.matchBefore(/"[^"]*/) ?? context.matchBefore(/\w*/); @@ -208,7 +182,7 @@ function buildRcloneCompletionSource( @Component({ selector: 'app-json-editor', - imports: [MatIconModule, TranslatePipe, RcloneOptionTranslatePipe], + imports: [MatIconModule, TranslatePipe, RcloneOptionTranslatePipe, AlertBannerComponent], templateUrl: './json-editor.component.html', styleUrl: './json-editor.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -220,42 +194,32 @@ export class JsonEditorComponent { readonly searchQuery = input(''); readonly keyPrefix = input(''); readonly excludeKeys = input([]); + readonly preferFieldName = input(false); readonly flagType = input(null); readonly currentRemoteName = input(''); readonly existingRemotes = input([]); + private static readonly INFO_BANNERS: Readonly> = { + vfs: 'wizards.remoteConfig.jsonEditorInfo.vfs', + filter: 'wizards.remoteConfig.jsonEditorInfo.filter', + backend: 'wizards.remoteConfig.jsonEditorInfo.backend', + runtimeRemote: 'wizards.remoteConfig.jsonEditorInfo.runtimeRemote', + sync: 'wizards.remoteConfig.jsonEditorInfo.sync', + copy: 'wizards.remoteConfig.jsonEditorInfo.sync', + move: 'wizards.remoteConfig.jsonEditorInfo.sync', + bisync: 'wizards.remoteConfig.jsonEditorInfo.bisync', + check: 'wizards.remoteConfig.jsonEditorInfo.check', + mount: 'wizards.remoteConfig.jsonEditorInfo.mount', + serve: 'wizards.remoteConfig.jsonEditorInfo.serve', + }; + readonly infoBanner = computed(() => { const type = this.flagType(); - if (!type) return null; - switch (type) { - case 'vfs': - return 'wizards.remoteConfig.jsonEditorInfo.vfs'; - case 'filter': - return 'wizards.remoteConfig.jsonEditorInfo.filter'; - case 'backend': - return 'wizards.remoteConfig.jsonEditorInfo.backend'; - case 'runtimeRemote': - return 'wizards.remoteConfig.jsonEditorInfo.runtimeRemote'; - case 'sync': - case 'copy': - case 'move': - return 'wizards.remoteConfig.jsonEditorInfo.sync'; - case 'bisync': - return 'wizards.remoteConfig.jsonEditorInfo.bisync'; - case 'check': - return 'wizards.remoteConfig.jsonEditorInfo.check'; - case 'mount': - return 'wizards.remoteConfig.jsonEditorInfo.mount'; - case 'serve': - return 'wizards.remoteConfig.jsonEditorInfo.serve'; - default: - return null; - } + return type ? (JsonEditorComponent.INFO_BANNERS[type] ?? null) : null; }); private readonly destroyRef = inject(DestroyRef); - private readonly hostEl = inject(ElementRef); private readonly valueMapper = inject(RcloneValueMapperService); private readonly appSettingsService = inject(AppSettingsService); private readonly pathService = inject(PathService); @@ -264,6 +228,10 @@ export class JsonEditorComponent { readonly lookupTable = computed(() => this.sharedLookupTable?.() ?? {}); + /** Resolves the canonical key for a field definition. */ + readonly fieldKey = (f: RcConfigOption): string => + this.preferFieldName() ? f.FieldName || f.Name : f.Name || f.FieldName; + readonly restrictMode = toSignal( this.appSettingsService .selectSetting('general.restrict') @@ -313,14 +281,14 @@ export class JsonEditorComponent { const isSafMount = type === 'mount' && value['mountType'] === 'saf'; const baseDefs = defs.filter(f => { - const key = getControlKey(f, type || undefined); + const key = this.fieldKey(f); if (isSafMount && key === 'mountPoint') return false; return !excluded.has(prefix + key); }); const filteredDefs = query ? baseDefs.filter(f => matchesConfigSearch(f, query)) : baseDefs; return filteredDefs.map(field => { - const controlKey = prefix + getControlKey(field, type || undefined); + const controlKey = prefix + this.fieldKey(field); const currentValue = value[controlKey] ?? null; const isChanged = !this.valueMapper.isDefaultValue(currentValue, field); const isActive = isChanged || explicit.has(controlKey); @@ -343,7 +311,7 @@ export class JsonEditorComponent { return { controlKey, - displayKey: getControlKey(field, type || undefined), + displayKey: this.fieldKey(field), currentValue, displayValue, fullValue: rawDisplay, @@ -356,9 +324,10 @@ export class JsonEditorComponent { constructor() { afterNextRender(() => this.initEditor()); - effect(() => { + effect(onCleanup => { this.formValue(); - this.pushFormToEditor(); + const timer = setTimeout(() => this.pushFormToEditor(), 150); + onCleanup(() => clearTimeout(timer)); }); this.destroyRef.onDestroy(() => { @@ -372,38 +341,26 @@ export class JsonEditorComponent { const completionSource = buildRcloneCompletionSource( () => this.fieldDefs(), - () => this.flagType() + () => this.flagType(), + f => this.fieldKey(f) ); const rcloneLinter = linter(view => { const diagnostics: Diagnostic[] = []; const flagType = this.flagType(); - const validFieldNames = new Set( - this.fieldDefs().map(f => getControlKey(f, flagType || undefined)) - ); + const validFieldNames = new Set(this.fieldDefs().map(f => this.fieldKey(f))); const currentBlock = this.keyPrefix() ? this.keyPrefix().replace('---', '') : ''; const isProfile = isProfileType(flagType); - let topLevelKeys = new Set(); - if (isProfile && flagType) { - if (flagType === 'serve') { - topLevelKeys = new Set(['fs', 'type', ...this.fieldDefs().map(f => f.Name)]); - } else { - topLevelKeys = new Set(getTopLevelKeysForProfile(flagType)); - } - } - const buildCliArgumentDiagnostic = (kText: string, from: number, to: number): Diagnostic => { const matched = this.lookupOption(kText); const suggestion = matched - ? getControlKey(matched.option, flagType || undefined) - : flagType === 'serve' - ? toSnakeCase(kText) - : toCamelCase(kText); + ? matched.option.Name || matched.option.FieldName + : toSnakeCase(kText); return { from, to, - severity: 'error', + severity: 'warning', message: this.translateService.instant('shared.jsonEditor.cliArgumentWithSuggestion', { key: kText, suggestion, @@ -429,27 +386,6 @@ export class JsonEditorComponent { const rawKey = view.state.sliceDoc(node.from, node.to); const keyText = rawKey.replace(/^"|"$/g, ''); - // Check if we are inside the nested options object (_config or mountOpt) - let insideConfig = false; - let parent = node.node.parent; - while (parent) { - if (parent.name === 'Property') { - const propKeyNode = parent.getChild('PropertyName') ?? parent.firstChild; - if (propKeyNode) { - const propKey = view.state - .sliceDoc(propKeyNode.from, propKeyNode.to) - .replace(/^"|"$/g, ''); - if (propKey === '_config' || propKey === 'mountOpt') { - if (parent !== node.node.parent) { - insideConfig = true; - break; - } - } - } - } - parent = parent.parent; - } - const validateOptionKey = (kText: string, nd: { from: number; to: number }): void => { if (kText.startsWith('-')) { diagnostics.push(buildCliArgumentDiagnostic(kText, nd.from, nd.to)); @@ -468,7 +404,7 @@ export class JsonEditorComponent { }); } else { const suggestion = matched - ? getControlKey(matched.option, flagType || undefined) + ? matched.option.Name || matched.option.FieldName : null; const message = suggestion ? this.translateService.instant( @@ -504,12 +440,15 @@ export class JsonEditorComponent { } }; - if (isProfile && !insideConfig) { + if (isProfile) { const mapping = flagType ? OPERATION_PATH_MAPPINGS[flagType] : null; const propertyNode = node.node.parent; const valueNode = propertyNode && propertyNode.name === 'Property' ? propertyNode.lastChild : null; const isArrayValue = valueNode && valueNode.name === 'Array'; + const structuralKeys = new Set( + [mapping?.sourceKey, mapping?.destKey, 'mountType', 'type'].filter(Boolean) + ); if (keyText.startsWith('-')) { diagnostics.push(buildCliArgumentDiagnostic(keyText, node.from, node.to)); @@ -528,16 +467,8 @@ export class JsonEditorComponent { key: keyText, }), }); - } else if (!topLevelKeys.has(keyText)) { - diagnostics.push({ - from: node.from, - to: node.to, - severity: 'warning', - message: this.translateService.instant( - 'wizards.remoteConfig.unknownTopLevelProperty', - { key: keyText } - ), - }); + } else if (!structuralKeys.has(keyText)) { + validateOptionKey(keyText, node); } } else { validateOptionKey(keyText, node); @@ -562,7 +493,7 @@ export class JsonEditorComponent { linter(jsonParseLinter()), rcloneLinter, autocompletion({ override: [completionSource] }), - EditorView.theme({}, { dark: true }), + EditorView.theme({}, { dark: document.documentElement.classList.contains('dark') }), EditorView.updateListener.of(update => { if (!update.docChanged) return; const text = update.state.doc.toString(); @@ -577,24 +508,8 @@ export class JsonEditorComponent { }); } - private checkCliArguments(obj: Record): { key: string; suggestion: string } | null { - const flagType = this.flagType(); - for (const key of Object.keys(obj)) { - if (key.startsWith('-')) { - const matched = this.lookupOption(key); - const suggestion = matched - ? getControlKey(matched.option, flagType || undefined) - : flagType === 'serve' - ? toSnakeCase(key) - : toCamelCase(key); - return { key, suggestion }; - } - } - return null; - } - private validateOptions( - options: Record, + options: Record, validFieldNames: Set, currentBlock: string ): { @@ -606,24 +521,33 @@ export class JsonEditorComponent { const unknown: string[] = []; const wrongBlocks: { key: string; block: string }[] = []; const suggestions: { key: string; suggestion: string }[] = []; - const flagType = this.flagType(); + let cliArg: { key: string; suggestion: string } | undefined; + + const type = this.flagType(); + const isProfile = isProfileType(type); + const structuralKeys = new Set(isProfile && type ? getTopLevelKeysForProfile(type) : []); for (const key of Object.keys(options)) { + if (structuralKeys.has(key)) { + continue; + } + if (key.startsWith('-')) { - const matched = this.lookupOption(key); - const suggestion = matched - ? getControlKey(matched.option, flagType || undefined) - : flagType === 'serve' - ? toSnakeCase(key) - : toCamelCase(key); - return { cliArg: { key, suggestion } }; + if (!cliArg) { + const matched = this.lookupOption(key); + const suggestion = matched + ? matched.option.Name || matched.option.FieldName + : toSnakeCase(key); + cliArg = { key, suggestion }; + } + continue; } if (!validFieldNames.has(key)) { const matched = this.lookupOption(key); if (matched) { if (this.isCompatible(matched.block, currentBlock)) { - const suggestion = getControlKey(matched.option, flagType || undefined); + const suggestion = matched.option.Name || matched.option.FieldName; suggestions.push({ key, suggestion }); } else { wrongBlocks.push({ key, block: matched.block }); @@ -635,6 +559,7 @@ export class JsonEditorComponent { } return { + cliArg, suggestion: suggestions[0], wrongBlock: wrongBlocks[0], unknown, @@ -643,15 +568,11 @@ export class JsonEditorComponent { private applyValidationResult(valRes: ReturnType): boolean { if (valRes.cliArg) { - this.parseError.set({ + this.parseWarning.set({ key: 'shared.jsonEditor.cliArgumentWithSuggestion', params: valRes.cliArg, }); - this.formGroup().setErrors({ cliArgument: true }); - return false; - } - - if (valRes.suggestion) { + } else if (valRes.suggestion) { this.parseWarning.set({ key: 'shared.jsonEditor.camelCaseSuggestionWarning', params: valRes.suggestion, @@ -674,10 +595,11 @@ export class JsonEditorComponent { private syncFormControls( group: FormGroup, - incoming: Record, + incoming: Record, excludeFilter: (key: string) => boolean = () => false ): void { const existingControls = new Set(Object.keys(group.controls)); + const validFieldNames = new Set(this.fieldDefs().map(f => this.fieldKey(f))); const prevCustom = new Set(this.customControlKeys()); const nextCustom = new Set(); @@ -687,13 +609,14 @@ export class JsonEditorComponent { if (!existingControls.has(controlKey)) { group.addControl(controlKey, new FormControl(val), { emitEvent: false }); nextCustom.add(controlKey); - } else if (prevCustom.has(controlKey)) { + } else if (prevCustom.has(controlKey) || !validFieldNames.has(controlKey)) { nextCustom.add(controlKey); } } - for (const key of prevCustom) { - if (!nextCustom.has(key)) { + for (const key of Object.keys(group.controls)) { + if (excludeFilter(key)) continue; + if (!Object.prototype.hasOwnProperty.call(incoming, key) && !validFieldNames.has(key)) { group.removeControl(key, { emitEvent: false }); } } @@ -702,9 +625,9 @@ export class JsonEditorComponent { } private applyEditorChanges(text: string): void { - let parsed: Record; + let parsed: Record; try { - parsed = JSON.parse(text) as Record; + parsed = JSON.parse(text) as Record; } catch { this.parseError.set({ key: 'shared.jsonEditor.parseError' }); this.formGroup().setErrors({ jsonParse: true }); @@ -713,27 +636,18 @@ export class JsonEditorComponent { const type = this.flagType(); const isProfile = isProfileType(type); - const validFieldNames = new Set(this.fieldDefs().map(f => getControlKey(f, type || undefined))); + const validFieldNames = new Set(this.fieldDefs().map(f => this.fieldKey(f))); const currentBlock = this.keyPrefix() ? this.keyPrefix().replace('---', '') : ''; if (isProfile) { - // Validate top level keys (excluding _config/mountOpt, srcFs/dstFs, etc.) - const topLevelKeys = - type === 'serve' - ? new Set(['fs', 'type', ...this.fieldDefs().map(f => f.Name)]) - : type - ? new Set(getTopLevelKeysForProfile(type)) - : new Set(); - - // Check CLI arguments at top level - const cliCheck = this.checkCliArguments(parsed); - if (cliCheck) { - this.parseError.set({ - key: 'shared.jsonEditor.cliArgumentWithSuggestion', - params: cliCheck, - }); - this.formGroup().setErrors({ cliArgument: true }); - return; + // Validate top level keys + const topLevelKeys = type ? new Set(getTopLevelKeysForProfile(type)) : new Set(); + if (type === 'serve') { + topLevelKeys.add('fs'); + topLevelKeys.add('type'); + } + for (const field of this.fieldDefs()) { + topLevelKeys.add(this.fieldKey(field)); } // Check for array values where they are not supported @@ -754,33 +668,29 @@ export class JsonEditorComponent { } } - // Check unknown top level keys - for (const key of Object.keys(parsed)) { - if (!topLevelKeys.has(key)) { - this.parseWarning.set({ - key: 'wizards.remoteConfig.unknownTopLevelProperty', - params: { key }, - }); - this.parseError.set(null); - this.formGroup().setErrors(null); - this.reconcileFormFromEditor(parsed); - return; + // Validate options at top level (including CLI args) + const valRes = this.validateOptions(parsed, validFieldNames, currentBlock); + this.applyValidationResult(valRes); + + // Check unknown top level keys (excluding CLI arguments and valid options) + if (!valRes.cliArg && !valRes.suggestion && !valRes.wrongBlock) { + for (const key of Object.keys(parsed)) { + if (!topLevelKeys.has(key) && !key.startsWith('-')) { + this.parseWarning.set({ + key: 'wizards.remoteConfig.unknownTopLevelProperty', + params: { key }, + }); + this.parseError.set(null); + this.formGroup().setErrors(null); + this.reconcileFormFromEditor(parsed); + return; + } } } - - // Validate options inside nested object (_config or mountOpt) - const nestedKey = type === 'mount' ? 'mountOpt' : '_config'; - const nestedOptions = parsed[nestedKey] || {}; - if (typeof nestedOptions === 'object' && nestedOptions !== null) { - const valRes = this.validateOptions(nestedOptions, validFieldNames, currentBlock); - if (!this.applyValidationResult(valRes)) return; - } else { - this.parseWarning.set(null); - } } else { // Fallback/standard check for flat profiles const valRes = this.validateOptions(parsed, validFieldNames, currentBlock); - if (!this.applyValidationResult(valRes)) return; + this.applyValidationResult(valRes); } this.parseError.set(null); @@ -788,7 +698,7 @@ export class JsonEditorComponent { this.reconcileFormFromEditor(parsed); } - private reconcileFormFromEditor(parsed: Record): void { + private reconcileFormFromEditor(parsed: Record): void { const type = this.flagType(); const fg = this.formGroup(); const currentRemote = this.currentRemoteName(); @@ -832,17 +742,17 @@ export class JsonEditorComponent { }) ); const lastGroup = sourceCtrl.at(sourceCtrl.length - 1) as FormGroup; - const parsed = this.pathService.parseFsString( + const parsedPath = this.pathService.parseFsString( p, 'currentRemote', currentRemote, existing ); if (type === 'mount' || type === 'serve') { - parsed.type = 'currentRemote'; - parsed.remote = ''; + parsedPath.type = 'currentRemote'; + parsedPath.remote = ''; } - lastGroup.patchValue(parsed); + lastGroup.patchValue(parsedPath); } } else { sourceCtrl.push( @@ -854,17 +764,17 @@ export class JsonEditorComponent { ); } } else if (sourceCtrl instanceof FormGroup) { - const parsed = this.pathService.parseFsString( - srcVal || '', + const parsedPath = this.pathService.parseFsString( + String(srcVal || ''), 'currentRemote', currentRemote, existing ); if (type === 'mount' || type === 'serve') { - parsed.type = 'currentRemote'; - parsed.remote = ''; + parsedPath.type = 'currentRemote'; + parsedPath.remote = ''; } - sourceCtrl.patchValue(parsed); + sourceCtrl.patchValue(parsedPath); } } @@ -874,17 +784,17 @@ export class JsonEditorComponent { const dstVal = rcloneParsed[mapping.destKey]; if (destCtrl instanceof FormGroup && dstVal !== undefined) { - const parsed = this.pathService.parseFsString( - dstVal || '', + const parsedPath = this.pathService.parseFsString( + String(dstVal || ''), 'local', currentRemote, existing ); if (type === 'mount') { - parsed.type = 'local'; - parsed.remote = ''; + parsedPath.type = 'local'; + parsedPath.remote = ''; } - destCtrl.patchValue(parsed); + destCtrl.patchValue(parsedPath); } } } @@ -906,7 +816,7 @@ export class JsonEditorComponent { const optionsGroup = fg.get('options') as FormGroup; if (optionsGroup) { // Gather all incoming options (flat + nested) - const incomingOptions: Record = {}; + const incomingOptions: Record = {}; if (type === 'serve') { // Serve is fully flat @@ -929,22 +839,26 @@ export class JsonEditorComponent { } } } else { - const nestedKey = type === 'mount' ? 'mountOpt' : '_config'; - const nestedOptions = rcloneParsed[nestedKey] || {}; - - const flatDefs = type ? staticFlagDefinitions[type] || [] : []; - const flatOptionNames = new Set(flatDefs.map(f => getControlKey(f, type || undefined))); - - // Pull flat options from top-level of rcloneParsed JSON - for (const name of flatOptionNames) { - if (rcloneParsed[name] !== undefined) { - incomingOptions[name] = rcloneParsed[name]; - } - } + // Pull flat options from top level of rcloneParsed + const mapping = type ? OPERATION_PATH_MAPPINGS[type] : null; + const excludeKeys = new Set( + [mapping?.sourceKey, mapping?.destKey, 'mountType', 'type'].filter(Boolean) as string[] + ); - // Pull nested options - if (typeof nestedOptions === 'object' && nestedOptions !== null) { - for (const [k, v] of Object.entries(nestedOptions)) { + for (const [k, v] of Object.entries(rcloneParsed)) { + if (excludeKeys.has(k)) continue; + + // Backward compatibility with old remote_config.ts + if ( + (k === '_config' || k === 'mountOpt' || k === '_filter') && + v && + typeof v === 'object' && + !Array.isArray(v) + ) { + for (const [nk, nv] of Object.entries(v)) { + incomingOptions[nk] = nv; + } + } else { incomingOptions[k] = v; } } @@ -980,11 +894,11 @@ export class JsonEditorComponent { private serializeForm(): string { try { const type = this.flagType(); - const raw = this.formGroup().getRawValue() as Record; + const raw = this.formGroup().getRawValue() as Record; const currentRemote = this.currentRemoteName(); if (isNestedOptionsType(type)) { - let out: Record = {}; + let out: Record = {}; const optionsGroup = this.formGroup().get('options') as FormGroup; if (optionsGroup) { out = this.serializeOptions(optionsGroup.getRawValue(), '', new Set(), false); @@ -993,17 +907,19 @@ export class JsonEditorComponent { } if (isProfileType(type)) { - const rclone: Record = {}; + const rclone: Record = {}; const mapping = type ? OPERATION_PATH_MAPPINGS[type] : null; if (mapping) { // 1. Map source paths to srcFs / path1 / fs if (raw['source']) { const srcPaths = Array.isArray(raw['source']) - ? raw['source'] - .map((s: any) => this.pathService.buildPathString(s, currentRemote)) + ? (raw['source'] as unknown[]) + .map(s => this.pathService.buildPathString(s as PathGroup, currentRemote)) .filter(Boolean) - : [this.pathService.buildPathString(raw['source'], currentRemote)].filter(Boolean); + : [ + this.pathService.buildPathString(raw['source'] as PathGroup, currentRemote), + ].filter(Boolean); rclone[mapping.sourceKey] = mapping.isSourceArray ? srcPaths.length > 1 @@ -1014,60 +930,38 @@ export class JsonEditorComponent { // 2. Map destination paths to dstFs / path2 / mountPoint if (mapping.destKey && raw['dest']) { - const dstPath = this.pathService.buildPathString(raw['dest'], currentRemote); + const dstPath = this.pathService.buildPathString( + raw['dest'] as PathGroup, + currentRemote + ); rclone[mapping.destKey] = dstPath; } } // 3. Map mountType / type if (type === 'mount') { - const val = raw['options']?.['mountType']; - if (val && val.trim() !== '') { + const val = (raw['options'] as Record | undefined)?.['mountType']; + if (typeof val === 'string' && val.trim() !== '') { rclone['mountType'] = val; } } else if (type === 'serve') { - const val = raw['options']?.['type']; - if (val && val.trim() !== '') { + const val = (raw['options'] as Record | undefined)?.['type']; + if (typeof val === 'string' && val.trim() !== '') { rclone['type'] = val; } } - // 4. Map options (flat vs _config/mountOpt) + // 4. Map options (flat at top level) if (raw['options']) { const serialized = this.serializeOptions( - raw['options'], + raw['options'] as Record, '', new Set(['mountType', 'type']), false ); - if (type === 'serve') { - // Serve is fully flat, merge all options directly into rclone - Object.assign(rclone, serialized); - } else { - const flatDefs = type ? staticFlagDefinitions[type] || [] : []; - const flatOptionNames = new Set(flatDefs.map(f => getControlKey(f, type || undefined))); - - const flatOptions: Record = {}; - const nestedOptions: Record = {}; - - for (const [displayKey, finalVal] of Object.entries(serialized)) { - if (flatOptionNames.has(displayKey)) { - flatOptions[displayKey] = finalVal; - } else { - nestedOptions[displayKey] = finalVal; - } - } - - // Merge flat options directly into rclone - Object.assign(rclone, flatOptions); - - // Merge nested options under _config or mountOpt - if (Object.keys(nestedOptions).length > 0) { - const nestedKey = type === 'mount' ? 'mountOpt' : '_config'; - rclone[nestedKey] = nestedOptions; - } - } + // All profiles output options flat at top level + Object.assign(rclone, serialized); } return JSON.stringify(rclone, null, 2); @@ -1104,9 +998,8 @@ export class JsonEditorComponent { patch[controlKey] = val === '••••••••' ? latestRaw[controlKey] : val; } else if (!prefix || controlKey.startsWith(prefix)) { const displayKey = prefix ? controlKey.slice(prefix.length) : controlKey; - const type = this.flagType(); - const field = defs.find(f => getControlKey(f, type || undefined) === displayKey); - patch[controlKey] = field?.Default ?? field?.DefaultStr ?? latestRaw[controlKey] ?? null; + const field = defs.find(f => this.fieldKey(f) === displayKey); + patch[controlKey] = field ? (field.Default ?? field.DefaultStr ?? null) : null; } else { patch[controlKey] = latestRaw[controlKey]; } @@ -1115,12 +1008,12 @@ export class JsonEditorComponent { } private serializeOptions( - rawOptions: Record, + rawOptions: Record, prefix = '', excluded = new Set(), maskSensitive = false - ): Record { - const out: Record = {}; + ): Record { + const out: Record = {}; const defs = this.fieldDefs(); const explicit = this.explicitKeys(); @@ -1129,8 +1022,7 @@ export class JsonEditorComponent { if (excluded.has(controlKey)) continue; const displayKey = prefix ? controlKey.slice(prefix.length) : controlKey; - const type = this.flagType(); - const field = defs.find(f => getControlKey(f, type || undefined) === displayKey); + const field = defs.find(f => this.fieldKey(f) === displayKey); const isExplicit = explicit.has(controlKey); if (field && this.valueMapper.isDefaultValue(val, field) && !isExplicit) continue; diff --git a/src/app/shared/components/number-input/number-input.component.html b/src/app/shared/components/number-input/number-input.component.html index db322f1f0..dcebabc07 100755 --- a/src/app/shared/components/number-input/number-input.component.html +++ b/src/app/shared/components/number-input/number-input.component.html @@ -15,7 +15,7 @@ > - +
- + {{ label() | translate }} +
+ + + {{ 'provision.stages.' + prog.stage | translate }} + + + @if (prog.stage === 'downloading') { + {{ prog.downloadedBytes | formatFileSize }} / + {{ prog.totalBytes ?? 0 | formatFileSize }} + @if (percentage() !== null) { + ({{ percentage() | number: '1.0-0' }}%) + } + } + +
+ +
+} diff --git a/src/app/shared/components/provision-progress/provision-progress.component.scss b/src/app/shared/components/provision-progress/provision-progress.component.scss new file mode 100644 index 000000000..b6124b065 --- /dev/null +++ b/src/app/shared/components/provision-progress/provision-progress.component.scss @@ -0,0 +1,36 @@ +:host { + display: block; +} + +.provision-progress-card { + margin-top: var(--space-md); + padding: 14px 16px; + border-radius: var(--card-border-radius); + background: var(--view-bg-color); + box-shadow: var(--box-shadow); + text-align: left; + display: flex; + flex-direction: column; + gap: var(--space-xs); + + .progress-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; + font-size: var(--font-size-sm); + + .stage-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 500; + color: var(--window-fg-color); + } + + .progress-stats { + color: var(--dim-color); + font-weight: 500; + } + } +} diff --git a/src/app/shared/components/provision-progress/provision-progress.component.ts b/src/app/shared/components/provision-progress/provision-progress.component.ts new file mode 100644 index 000000000..843d4f870 --- /dev/null +++ b/src/app/shared/components/provision-progress/provision-progress.component.ts @@ -0,0 +1,24 @@ +import { Component, input, computed, ChangeDetectionStrategy } from '@angular/core'; +import { DecimalPipe } from '@angular/common'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { TranslatePipe } from '@ngx-translate/core'; +import { FormatFileSizePipe } from '@app/pipes'; +import { ProvisionProgressPayload } from '@app/types'; + +@Component({ + selector: 'app-provision-progress', + imports: [DecimalPipe, MatIconModule, MatProgressBarModule, TranslatePipe, FormatFileSizePipe], + templateUrl: './provision-progress.component.html', + styleUrls: ['./provision-progress.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProvisionProgressComponent { + readonly progress = input(null); + + readonly percentage = computed(() => { + const prog = this.progress(); + if (!prog || !prog.totalBytes || prog.totalBytes <= 0) return null; + return Math.min(100, Math.max(0, (prog.downloadedBytes / prog.totalBytes) * 100)); + }); +} diff --git a/src/app/shared/components/search-container/search-container.component.ts b/src/app/shared/components/search-container/search-container.component.ts index 1e50bcf32..b187309a3 100755 --- a/src/app/shared/components/search-container/search-container.component.ts +++ b/src/app/shared/components/search-container/search-container.component.ts @@ -6,6 +6,7 @@ import { output, viewChild, ChangeDetectionStrategy, + HostListener, } from '@angular/core'; import { FormsModule } from '@angular/forms'; @@ -40,23 +41,37 @@ export class SearchContainerComponent { placeholder = input('shared.search.placeholder'); ariaLabel = input('shared.search.ariaLabel'); searchText = input(''); + enableShortcut = input(true); searchTextChange = output(); + searchToggle = output(); + visibleChange = output(); searchInput = viewChild>('searchInput'); constructor() { effect(() => { if (this.visible()) { - this.focus(); + requestAnimationFrame(() => this.focus()); } }); } + @HostListener('window:keydown', ['$event']) + handleKeyboardEvent(event: KeyboardEvent): void { + if ( + this.enableShortcut() && + (event.ctrlKey || event.metaKey) && + event.key.toLowerCase() === 'f' + ) { + event.preventDefault(); + this.searchToggle.emit(); + this.visibleChange.emit(!this.visible()); + } + } + focus(): void { - setTimeout(() => { - this.searchInput()?.nativeElement?.focus(); - }, 150); + this.searchInput()?.nativeElement?.focus(); } clear(): void { diff --git a/src/app/shared/components/serve-card/serve-card.component.html b/src/app/shared/components/serve-card/serve-card.component.html index c6eefee68..9bbeb1799 100755 --- a/src/app/shared/components/serve-card/serve-card.component.html +++ b/src/app/shared/components/serve-card/serve-card.component.html @@ -17,6 +17,13 @@ @if (serve().profile) { {{ serve().profile }} } + + +
diff --git a/src/app/shared/components/serve-card/serve-card.component.scss b/src/app/shared/components/serve-card/serve-card.component.scss index 4c877239d..323d73619 100644 --- a/src/app/shared/components/serve-card/serve-card.component.scss +++ b/src/app/shared/components/serve-card/serve-card.component.scss @@ -95,6 +95,31 @@ color: var(--accent-color); } +.origin-mini-badge { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--dim-color); + opacity: 0.75; + vertical-align: middle; + transition: opacity var(--transition-fast); + + &:hover { + opacity: 1; + } + + &.quickrun { + color: var(--primary-color); + opacity: 0.9; + } + + .origin-mini-icon { + font-size: 13px; + width: 13px; + height: 13px; + } +} + // ── URL / Address ───────────────────────────────────────────────────────────── .serve-url { diff --git a/src/app/shared/components/serve-card/serve-card.component.ts b/src/app/shared/components/serve-card/serve-card.component.ts index c5152c16e..dbd49fb2b 100755 --- a/src/app/shared/components/serve-card/serve-card.component.ts +++ b/src/app/shared/components/serve-card/serve-card.component.ts @@ -6,6 +6,7 @@ import { MatButtonModule } from '@angular/material/button'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { ServeListItem, TYPE_INFO, DEFAULT_ICON, URL_BASED_PROTOCOLS } from '@app/types'; import { PathService } from 'src/app/services/infrastructure/platform/path.service'; +import { QuickRunService } from 'src/app/services/flow/quick-run.service'; import { CopyToClipboardDirective } from '../../directives/copy-to-clipboard.directive'; @Component({ @@ -25,6 +26,7 @@ import { CopyToClipboardDirective } from '../../directives/copy-to-clipboard.dir export class ServeCardComponent { private readonly translate = inject(TranslateService); private readonly pathService = inject(PathService); + private readonly quickRunService = inject(QuickRunService); serve = input.required(); showRemoteName = input(false); @@ -32,6 +34,24 @@ export class ServeCardComponent { stopServe = output(); cardClick = output(); + readonly isQuickRun = computed(() => { + const profile = this.serve().profile; + if (!profile) return false; + return this.quickRunService + .quickRuns() + .some(qr => qr.operationType === 'serve' && qr.name === profile); + }); + + readonly originLabel = computed(() => { + return this.isQuickRun() + ? this.translate.instant('flow.tabs.quickRun') || 'Quick Run' + : this.translate.instant('navigation.dashboard') || 'Dashboard'; + }); + + readonly originIcon = computed(() => { + return this.isQuickRun() ? 'quick-run' : 'cloud'; + }); + serveIcon = computed(() => { const type = this.serve().params.type.toLowerCase(); return TYPE_INFO[type]?.icon ?? DEFAULT_ICON; diff --git a/src/app/shared/components/setting-control/setting-control.component.html b/src/app/shared/components/setting-control/setting-control.component.html index 2b302bb74..aa5423997 100755 --- a/src/app/shared/components/setting-control/setting-control.component.html +++ b/src/app/shared/components/setting-control/setting-control.component.html @@ -52,10 +52,10 @@
- +
- +
- +
- + @for (itemControl of formArrayControls(); track itemControl) {
- +
- @if (isNumericType()) { - - } @else { - @switch (mergedOption()?.Type) { - @case ('bool') { - -
- - {{ - control()?.value - ? ('shared.settingControl.enabled' | translate) - : ('shared.settingControl.disabled' | translate) - }} - -
- } - @case ('Time') { -
- -
- - {{ 'shared.settingControl.date' | translate }} - - - - - - {{ 'shared.settingControl.time' | translate }} - - - - -
-
- } - @case ('Tristate') { -
- -
- - - {{ - 'shared.settingControl.autoUnset' | translate - }} - {{ - 'shared.settingControl.true' | translate - }} - {{ - 'shared.settingControl.false' | translate - }} - - -
-
- } - @case ('string') { - @if (mergedOption()?.Examples?.length) { - @if (isMultiselectOption()) { - - } @else if (mergedOption()?.Exclusive === false) { - - } @else { - + @switch (controlType()) { + @case ('numeric') { + - } - } - @case ('Bits') { - @if (hasBitsComboExamples()) { - - } @else if (mergedOption()?.Examples?.length) { - - } @else { - - } - } - @case ('Encoding') { - - } - @case ('DumpFlags') { - - } - @case ('SpaceSepList') { - @if (mergedOption()?.Examples?.length) { - - } @else { - - } - } - @case ('stringArray') { - @if (mergedOption()?.Examples?.length) { - - } @else { - - } - } - @case ('CommaSepList') { - @if (mergedOption()?.Examples?.length) { - - } @else { - - } - } - @default { - @if (mergedOption()?.Examples?.length) { - @if (isMultiselectOption()) { - + } + @case ('bool') { + +
+ + {{ + control()?.value + ? ('shared.settingControl.enabled' | translate) + : ('shared.settingControl.disabled' | translate) + }} + +
+ } + @case ('time') { +
+ +
+ + {{ 'shared.settingControl.date' | translate }} + - } @else if (mergedOption()?.Exclusive === false) { - + + + + {{ 'shared.settingControl.time' | translate }} + - } @else { - - } - } @else { - - } - } + + + +
+
+ } + @case ('tristate') { +
+ +
+ + + {{ + 'shared.settingControl.autoUnset' | translate + }} + {{ + 'shared.settingControl.true' | translate + }} + {{ + 'shared.settingControl.false' | translate + }} + + +
+
+ } + @case ('multiSelect') { + + } + @case ('autocomplete') { + + } + @case ('select') { + + } + @case ('array') { + + } + @case ('input') { + } }
diff --git a/src/app/shared/components/setting-control/setting-control.component.scss b/src/app/shared/components/setting-control/setting-control.component.scss index 1f08b38ef..1530f8690 100644 --- a/src/app/shared/components/setting-control/setting-control.component.scss +++ b/src/app/shared/components/setting-control/setting-control.component.scss @@ -61,7 +61,7 @@ a { color: var(--primary-color); text-decoration: none; - border-bottom: 1px solid rgba(var(--primary-color-rgb, 25, 118, 210), 0.3); + border-bottom: 1px solid rgba(var(--primary-color-rgb), 0.3); transition: all 0.2s ease; word-break: break-all; @@ -124,28 +124,6 @@ .setting-control { flex-shrink: 0; - .stepper-container { - display: flex; - align-items: center; - gap: var(--space-xs); - } - - .stepper-input.mat-mdc-form-field { - width: 100px; - flex-grow: 0; - - input { - text-align: center; - } - } - - // Hide native number spinners — custom stepper buttons are used instead - input[type="number"]::-webkit-outer-spin-button, - input[type="number"]::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; - } - .time-split { display: flex; gap: var(--space-md); diff --git a/src/app/shared/components/setting-control/setting-control.component.spec.ts b/src/app/shared/components/setting-control/setting-control.component.spec.ts deleted file mode 100644 index 5ab753017..000000000 --- a/src/app/shared/components/setting-control/setting-control.component.spec.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Component, signal } from '@angular/core'; -import { FormControl, ReactiveFormsModule } from '@angular/forms'; -import { SettingControlComponent } from './setting-control.component'; -import { RcConfigOption } from '@app/types'; -import { TranslateModule } from '@ngx-translate/core'; -import { By } from '@angular/platform-browser'; -import { MatSelect } from '@angular/material/select'; - -@Component({ - imports: [SettingControlComponent, ReactiveFormsModule], - template: ` - - `, -}) -class TestHostComponent { - readonly option = signal(null); - readonly control = new FormControl(null); -} - -describe('SettingControlComponent', () => { - let fixture: ComponentFixture; - let host: TestHostComponent; - - const mockScopeOption: RcConfigOption = { - Name: 'scope', - FieldName: '', - Help: 'Comma separated list of scopes that rclone should use when requesting access from drive.', - Type: 'CommaSepList', - Exclusive: false, - DefaultStr: '', - Examples: [ - { Value: 'drive', Help: 'Full access all files' }, - { Value: 'drive.readonly', Help: 'Read-only access' }, - { Value: 'drive.file', Help: 'Access to files created by rclone only' }, - { Value: 'drive.appfolder', Help: 'Allows read and write access to App folder' }, - ], - }; - - const mockExclusiveOption: RcConfigOption = { - Name: 'type', - FieldName: '', - Help: 'Type of storage', - Type: 'string', - Exclusive: true, - DefaultStr: 'drive', - Examples: [ - { Value: 'drive', Help: 'Google Drive' }, - { Value: 's3', Help: 'Amazon S3' }, - ], - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TestHostComponent, TranslateModule.forRoot()], - }).compileComponents(); - - fixture = TestBed.createComponent(TestHostComponent); - host = fixture.componentInstance; - }); - - it('should render a multi-select dropdown when option is a multiselect type (CommaSepList)', () => { - host.option.set(mockScopeOption); - fixture.detectChanges(); - - const selectDebugEl = fixture.debugElement.query(By.directive(MatSelect)); - expect(selectDebugEl).toBeTruthy(); - const matSelect = selectDebugEl.componentInstance as MatSelect; - expect(matSelect.multiple).toBeTrue(); - }); - - it('should render a single-select dropdown when option is exclusive (Exclusive: true) with Examples', () => { - host.option.set(mockExclusiveOption); - fixture.detectChanges(); - - const selectDebugEl = fixture.debugElement.query(By.directive(MatSelect)); - expect(selectDebugEl).toBeTruthy(); - const matSelect = selectDebugEl.componentInstance as MatSelect; - expect(matSelect.multiple).toBeFalse(); - }); - - it('should parse comma-separated string initial value into an array for multi-select control', () => { - host.option.set(mockScopeOption); - host.control.setValue('drive.file,drive.appfolder'); - fixture.detectChanges(); - - const settingControlEl = fixture.debugElement.query(By.directive(SettingControlComponent)); - const settingControlComp = settingControlEl.componentInstance as SettingControlComponent; - const internalValue = settingControlComp.control()?.value; - - expect(internalValue).toEqual(['drive.file', 'drive.appfolder']); - }); - - it('should emit comma-separated string when multi-select value changes', () => { - host.option.set(mockScopeOption); - fixture.detectChanges(); - - const settingControlEl = fixture.debugElement.query(By.directive(SettingControlComponent)); - const settingControlComp = settingControlEl.componentInstance as SettingControlComponent; - - settingControlComp.control()?.setValue(['drive.readonly', 'drive.file']); - fixture.detectChanges(); - - expect(host.control.value).toBe('drive.readonly,drive.file'); - }); - - it('should correctly determine isMultiselectOption for non-exclusive string options', () => { - host.option.set(mockScopeOption); - fixture.detectChanges(); - - const settingControlEl = fixture.debugElement.query(By.directive(SettingControlComponent)); - const settingControlComp = settingControlEl.componentInstance as SettingControlComponent; - - expect(settingControlComp.isMultiselectOption()).toBeTrue(); - }); - - it('should NOT treat boolean options with Exclusive: false as multiselect and should reset to default boolean value', () => { - const mockBoolOption: RcConfigOption = { - Name: 'shared_files', - FieldName: '', - Help: 'Instructs rclone to work on individual shared files.', - Type: 'bool', - Exclusive: false, - Default: false, - DefaultStr: 'false', - }; - - host.option.set(mockBoolOption); - fixture.detectChanges(); - - const settingControlEl = fixture.debugElement.query(By.directive(SettingControlComponent)); - const settingControlComp = settingControlEl.componentInstance as SettingControlComponent; - - expect(settingControlComp.isMultiselectOption()).toBeFalse(); - expect(settingControlComp.control()?.value).toBeFalse(); - - settingControlComp.control()?.setValue(true); - fixture.detectChanges(); - expect(settingControlComp.isValueChanged()).toBeTrue(); - - settingControlComp.resetToDefault(); - fixture.detectChanges(); - expect(settingControlComp.control()?.value).toBeFalse(); - expect(settingControlComp.isValueChanged()).toBeFalse(); - }); -}); diff --git a/src/app/shared/components/setting-control/setting-control.component.ts b/src/app/shared/components/setting-control/setting-control.component.ts index 57cb26d7f..682bbf157 100755 --- a/src/app/shared/components/setting-control/setting-control.component.ts +++ b/src/app/shared/components/setting-control/setting-control.component.ts @@ -41,7 +41,56 @@ import { RcloneValueMapperService } from 'src/app/services/remote/rclone-value-m import { AppSettingsService } from 'src/app/services/settings/app-settings.service'; import { ValidatorRegistryService } from 'src/app/services/ui/validation/validator-registry.service'; import { RemoteConfigStateService } from 'src/app/services/remote/remote-config-state.service'; -import { isConvertibleType, isMultiselectType } from 'src/app/shared/utils'; +import { + isConvertibleType, + isJsonArrayType, + isMultiselectType, + isArrayType, + isIntType, + isFloatType, +} from 'src/app/shared/utils'; + +const DUMP_FLAGS_FALLBACK: readonly string[] = [ + 'headers', + 'bodies', + 'requests', + 'responses', + 'auth', + 'filters', + 'goroutines', + 'openfiles', + 'mapper', +]; + +const ENCODING_FLAGS: readonly string[] = [ + 'Slash', + 'BackSlash', + 'Del', + 'Ctl', + 'InvalidUtf8', + 'Dot', + 'LeftSpace', + 'RightSpace', + 'LeftCrLfHtVt', + 'RightCrLfHtVt', + 'LeftPeriod', + 'LeftTilde', + 'LtGt', + 'DoubleQuote', + 'SingleQuote', + 'BackQuote', + 'Dollar', + 'Colon', + 'Question', + 'Asterisk', + 'Pipe', + 'Hash', + 'Percent', + 'CrLf', + 'SquareBracket', + 'Semicolon', + 'Exclamation', +].sort(); @Component({ selector: 'app-setting-control', @@ -95,7 +144,9 @@ export class SettingControlComponent implements ControlValueAccessor { return []; } - const optName = opt.Name.replace(/_/g, '-'); + const optName = opt.Name.replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/_/g, '-') + .toLowerCase(); const flags: string[] = []; const providerVal = this.provider(); @@ -122,7 +173,12 @@ export class SettingControlComponent implements ControlValueAccessor { // Signal-backed list of FormArray controls — zoneless CD picks up add/remove. readonly formArrayControls = signal([]); - readonly isValueChanged = signal(false); + readonly isValueChanged = computed(() => { + const ctrl = this.control(); + this.controlValueVersion(); + if (!ctrl) return false; + return !this.valuesEqual(ctrl.value, this.uiDefaultValue()); + }); private readonly controlValueVersion = signal(0); @@ -186,49 +242,8 @@ export class SettingControlComponent implements ControlValueAccessor { max_age: { DefaultStr: '0s', Default: 0 }, }; - static readonly DUMP_FLAGS_FALLBACK = [ - 'headers', - 'bodies', - 'requests', - 'responses', - 'auth', - 'filters', - 'goroutines', - 'openfiles', - 'mapper', - ] as const; - - readonly DUMP_FLAGS_FALLBACK = SettingControlComponent.DUMP_FLAGS_FALLBACK; - - readonly encodingFlags = [ - 'Slash', - 'BackSlash', - 'Del', - 'Ctl', - 'InvalidUtf8', - 'Dot', - 'LeftSpace', - 'RightSpace', - 'LeftCrLfHtVt', - 'RightCrLfHtVt', - 'LeftPeriod', - 'LeftTilde', - 'LtGt', - 'DoubleQuote', - 'SingleQuote', - 'BackQuote', - 'Dollar', - 'Colon', - 'Question', - 'Asterisk', - 'Pipe', - 'Hash', - 'Percent', - 'CrLf', - 'SquareBracket', - 'Semicolon', - 'Exclamation', - ].sort(); + readonly DUMP_FLAGS_FALLBACK = DUMP_FLAGS_FALLBACK; + readonly encodingFlags = ENCODING_FLAGS; // Computed readonly mergedOption = computed( @@ -282,6 +297,64 @@ export class SettingControlComponent implements ControlValueAccessor { return this.translate.instant('shared.settingControl.errors.invalidValue'); }); + readonly controlOptionsList = computed<({ Value?: string; Help?: string } | string)[]>(() => { + const opt = this.mergedOption(); + if (!opt) return []; + if (opt.Examples?.length) return opt.Examples; + if (opt.Type === 'Encoding') return ENCODING_FLAGS as string[]; + if (opt.Type === 'DumpFlags') return DUMP_FLAGS_FALLBACK as string[]; + return []; + }); + + readonly controlType = computed< + | 'numeric' + | 'bool' + | 'time' + | 'tristate' + | 'multiSelect' + | 'autocomplete' + | 'select' + | 'array' + | 'input' + >(() => { + const opt = this.mergedOption(); + if (!opt) return 'input'; + + if (this.isNumericType()) return 'numeric'; + + switch (opt.Type) { + case 'bool': + return 'bool'; + case 'Time': + return 'time'; + case 'Tristate': + return 'tristate'; + } + + const options = this.controlOptionsList(); + const isMulti = this.isMultiselectOption(); + + if (this.hasBitsComboExamples()) { + return 'select'; + } + + if (options.length > 0) { + if (isMulti || opt.Type === 'Encoding' || opt.Type === 'DumpFlags') { + return 'multiSelect'; + } + if (opt.Exclusive === false) { + return 'autocomplete'; + } + return 'select'; + } + + if (isMulti) { + return 'array'; + } + + return 'input'; + }); + readonly selectedLabel = computed(() => { const ctrl = this.control(); const opt = this.mergedOption(); @@ -299,7 +372,7 @@ export class SettingControlComponent implements ControlValueAccessor { if (value === null || value === undefined) return ''; - const examples = this.resolveExamplesList(opt); + const examples = this.controlOptionsList(); for (const e of examples) { const eObj = typeof e === 'object' && e !== null ? (e as { Value?: unknown; Help?: string }) : null; @@ -314,15 +387,6 @@ export class SettingControlComponent implements ControlValueAccessor { return value === '' ? '' : String(value); }); - private resolveExamplesList(opt: RcConfigOption): ({ Value?: string; Help?: string } | string)[] { - if (opt.Examples?.length) return opt.Examples; - if (opt.Type === 'Encoding') return this.encodingFlags; - if (opt.Type === 'DumpFlags') { - return [...SettingControlComponent.DUMP_FLAGS_FALLBACK]; - } - return []; - } - // ControlValueAccessor private onChange: (value: unknown) => void = () => { /* empty */ @@ -376,6 +440,8 @@ export class SettingControlComponent implements ControlValueAccessor { return this.splitToArray(option.DefaultStr, ','); case 'SpaceSepList': return this.splitToArray(option.DefaultStr, /\s+/); + case '[]string': + case 'List': case 'stringArray': return Array.isArray(option.Default) && option.Default.length > 0 ? option.Default.map(v => v ?? '') @@ -402,12 +468,7 @@ export class SettingControlComponent implements ControlValueAccessor { return current === defaultVal; } - if ( - this.isMultiselectOption() || - optType === 'stringArray' || - optType === 'CommaSepList' || - optType === 'SpaceSepList' - ) { + if (this.isMultiselectOption() || isArrayType(optType ?? '')) { const toArray = (v: unknown): string[] => { if (Array.isArray(v)) return v @@ -454,7 +515,7 @@ export class SettingControlComponent implements ControlValueAccessor { defaultArr.forEach(val => ctrl.push(new FormControl(val), { emitEvent: false })); this.syncFormArrayControls(ctrl); this.onChange(this.prepareValueForBackend(ctrl.value)); - this.isValueChanged.set(false); + this.controlValueVersion.update(v => v + 1); this.valueChanged.emit(false); this.commitValue(); return; @@ -497,7 +558,6 @@ export class SettingControlComponent implements ControlValueAccessor { } } this.controlValueVersion.update(v => v + 1); - this.isValueChanged.set(!this.valuesEqual(ctrl.value, this.uiDefaultValue())); } private prepareValueForControl(value: unknown): unknown { @@ -536,7 +596,13 @@ export class SettingControlComponent implements ControlValueAccessor { if (opt.Type === 'bool') return value === true || String(value).toLowerCase() === 'true'; if (opt.Type === 'Tristate') return this.valueMapper.parseTristate(value); - if (opt.Type === 'stringArray') return Array.isArray(value) ? value : []; + if (isJsonArrayType(opt.Type)) { + if (Array.isArray(value)) return value; + if (typeof value === 'string' && value.trim()) { + return this.splitToArray(value, ','); + } + return []; + } if (typeof value === 'number' && opt.Examples?.length) { return opt.ValueStr || opt.DefaultStr || ''; } @@ -566,7 +632,8 @@ export class SettingControlComponent implements ControlValueAccessor { const validators = this.getValidators(opt); const isArray = - !opt.Examples?.length && ['stringArray', 'CommaSepList', 'SpaceSepList'].includes(opt.Type); + !opt.Examples?.length && + (isJsonArrayType(opt.Type) || opt.Type === 'CommaSepList' || opt.Type === 'SpaceSepList'); if (isArray) { const initial = this.getInitialArrayValue(opt); @@ -588,10 +655,8 @@ export class SettingControlComponent implements ControlValueAccessor { this.pendingWriteValue = undefined; } - const ctrl = this.control(); - if (ctrl) { - this.isValueChanged.set(!this.valuesEqual(ctrl.value, this.uiDefaultValue())); - } + // Bump version so isValueChanged computed re-evaluates for the new control. + this.controlValueVersion.update(v => v + 1); } private syncFormArrayControls(ctrl: FormArray): void { @@ -651,31 +716,9 @@ export class SettingControlComponent implements ControlValueAccessor { readonly hasBitsComboExamples = computed(() => this.isBitsWithCombos(this.mergedOption())); - /** - * True for all integer and float types that should use the stepper input - * (app-number-input) instead of falling through to a plain text input. - * - * The @switch in the template only had explicit @case entries for int, - * int64, uint32, and float64 — int32, uint, uint64, float, and float32 - * fell through to @default (standardInput = text field with validation), - * which worked but gave a worse UX than the dedicated stepper. This - * computed lets the template route all numeric types to stepperInput. - */ - private static readonly NUMERIC_TYPES = new Set([ - 'int', - 'int32', - 'int64', - 'uint', - 'uint32', - 'uint64', - 'float', - 'float32', - 'float64', - ]); - readonly isNumericType = computed(() => { const t = this.mergedOption()?.Type; - return !!t && SettingControlComponent.NUMERIC_TYPES.has(t); + return !!t && (isIntType(t) || isFloatType(t)); }); /** @@ -694,10 +737,10 @@ export class SettingControlComponent implements ControlValueAccessor { this.controlSubscriptions.add( ctrl.valueChanges.subscribe(value => { this.controlValueVersion.update(v => v + 1); + if (this.remoteState?.isPopulatingForm()) return; this.onChange(this.prepareValueForBackend(value)); this.onTouched(); const changed = !this.valuesEqual(ctrl.value, this.uiDefaultValue()); - this.isValueChanged.set(changed); this.valueChanged.emit(changed); if (this.mergedOption()?.Type === 'Time') this.updateSplitFromControl(value); }) @@ -754,10 +797,23 @@ export class SettingControlComponent implements ControlValueAccessor { if (!opt) return value; if (Array.isArray(value)) { + if (isJsonArrayType(opt.Type)) { + return value + .map(String) + .map(s => s.trim()) + .filter(Boolean); + } const delimiter = opt.Type === 'SpaceSepList' ? ' ' : ','; return value.join(delimiter); } + if (isJsonArrayType(opt.Type)) { + if (typeof value === 'string' && value.trim()) { + return this.splitToArray(value, ','); + } + if (value === null || value === undefined || value === '') return []; + } + return this.valueMapper.humanToMachine(value, opt.Type); } // Array controls @@ -783,38 +839,35 @@ export class SettingControlComponent implements ControlValueAccessor { const validators: ValidatorFn[] = []; if (opt.Required) validators.push(Validators.required); - const r = this.validatorRegistry; - const vMap: Record ValidatorFn> = { - stringArray: () => r.arrayValidator(), - CommaSepList: () => r.arrayValidator(), - SpaceSepList: () => r.arrayValidator(), - int: () => r.integerValidator(opt.DefaultStr), - int64: () => r.integerValidator(opt.DefaultStr), - int32: () => r.integerValidator(opt.DefaultStr), - uint: () => r.integerValidator(opt.DefaultStr), - uint32: () => r.integerValidator(opt.DefaultStr), - uint64: () => r.integerValidator(opt.DefaultStr), - float: () => r.floatValidator(opt.DefaultStr), - float32: () => r.floatValidator(opt.DefaultStr), - float64: () => r.floatValidator(opt.DefaultStr), - Duration: () => r.durationValidator(opt.DefaultStr), - SizeSuffix: () => r.sizeSuffixValidator(opt.DefaultStr), - BwTimetable: () => r.bwTimetableValidator(opt.DefaultStr), - FileMode: () => r.fileModeValidator(opt.DefaultStr), - Time: () => r.timeValidator(opt.DefaultStr), - Bits: () => - opt.Examples?.length && !this.isBitsWithCombos(opt) - ? r.arrayValidator() - : Validators.nullValidator, - Encoding: () => r.arrayValidator(), - Tristate: () => r.tristateValidator(), - }; - - if (vMap[opt.Type]) validators.push(vMap[opt.Type]()); - const isMultiSelect = this.isMultiselectOption(); - if (opt.Examples && !isMultiSelect && opt.Exclusive !== false) { - validators.push(r.enumValidator(opt.Examples.map(e => e.Value))); + const isSingleSelect = + this.hasBitsComboExamples() || + (!!opt.Examples?.length && !isMultiSelect && opt.Exclusive !== false); + + if (!isSingleSelect) { + const r = this.validatorRegistry; + if (isMultiSelect || isJsonArrayType(opt.Type)) { + validators.push(r.arrayValidator()); + } else { + const vMap: Record ValidatorFn> = { + int: () => r.integerValidator(opt.DefaultStr), + int64: () => r.integerValidator(opt.DefaultStr), + int32: () => r.integerValidator(opt.DefaultStr), + uint: () => r.integerValidator(opt.DefaultStr), + uint32: () => r.integerValidator(opt.DefaultStr), + uint64: () => r.integerValidator(opt.DefaultStr), + float: () => r.floatValidator(opt.DefaultStr), + float32: () => r.floatValidator(opt.DefaultStr), + float64: () => r.floatValidator(opt.DefaultStr), + Duration: () => r.durationValidator(opt.DefaultStr), + SizeSuffix: () => r.sizeSuffixValidator(opt.DefaultStr), + BwTimetable: () => r.bwTimetableValidator(opt.DefaultStr), + FileMode: () => r.fileModeValidator(opt.DefaultStr), + Time: () => r.timeValidator(opt.DefaultStr), + Tristate: () => r.tristateValidator(), + }; + if (vMap[opt.Type]) validators.push(vMap[opt.Type]()); + } } return validators; diff --git a/src/app/shared/constants/backend.constants.ts b/src/app/shared/constants/backend.constants.ts index fef33b013..3bf0791ec 100644 --- a/src/app/shared/constants/backend.constants.ts +++ b/src/app/shared/constants/backend.constants.ts @@ -12,7 +12,6 @@ export const BACKEND_CONSTANTS = { GROUPS: { CONNECTION: 'connection', AUTHENTICATION: 'authentication', - OAUTH: 'oauth', SECURITY: 'security', ADVANCED: 'advanced', }, @@ -22,7 +21,6 @@ export const BACKEND_CONSTANTS = { HOST: 'localhost', IP: '127.0.0.1', PORT: 51900, - OAUTH_PORT: 51901, }, // Status Strings diff --git a/src/app/shared/detail-shared/automation-card/automation-card.component.html b/src/app/shared/detail-shared/automation-card/automation-card.component.html index b23c1b09e..71e7b5ea4 100755 --- a/src/app/shared/detail-shared/automation-card/automation-card.component.html +++ b/src/app/shared/detail-shared/automation-card/automation-card.component.html @@ -12,7 +12,16 @@
- {{ automation().profileName }} +
+ {{ automation().profileName }} + + + +
{{ automation().remoteName }} · {{ 'dashboard.appDetail.' + automation().automationType | translate }} @@ -20,7 +29,12 @@ · @@ -84,7 +98,12 @@
-

{{ automation().profileName }} · {{ automation().backendName }}

+
+

{{ automation().profileName }} · {{ automation().backendName }}

+ + {{ originLabel() }} + +
{{ automation().remoteName }} · {{ 'dashboard.appDetail.' + automation().automationType | translate }} @@ -144,6 +163,14 @@
{{ 'automation.monitoring.seconds' | translate }}
+ @if (automation().watchChangedOnly) { +
+ {{ 'automation.monitoring.syncScope' | translate }} + {{ + 'automation.monitoring.changedOnlyActive' | translate + }} +
+ } }
diff --git a/src/app/shared/detail-shared/automation-card/automation-card.component.scss b/src/app/shared/detail-shared/automation-card/automation-card.component.scss index e8d506cc7..ce1fd9da7 100644 --- a/src/app/shared/detail-shared/automation-card/automation-card.component.scss +++ b/src/app/shared/detail-shared/automation-card/automation-card.component.scss @@ -141,6 +141,44 @@ min-width: 0; } + .automation-title-row { + display: flex; + align-items: center; + gap: var(--space-xs); + min-width: 0; + } + + .origin-mini-badge { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--dim-color); + opacity: 0.75; + vertical-align: middle; + transition: opacity var(--transition-fast); + + &:hover { + opacity: 1; + } + + &.quickrun { + color: var(--primary-color); + opacity: 0.9; + } + + .origin-mini-icon { + font-size: 13px; + width: 13px; + height: 13px; + } + } + + .origin-pill { + font-size: 10px; + padding: 1px 6px; + font-weight: 500; + } + .automation-name { font-weight: 600; font-size: var(--font-size-md); @@ -256,14 +294,27 @@ flex: 1; min-width: 0; - h4 { - margin: 0; - font-size: var(--font-size-lg); - font-weight: 600; - color: var(--window-fg-color); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + .title-heading-row { + display: flex; + align-items: center; + gap: var(--space-xs); + min-width: 0; + + h4 { + margin: 0; + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--window-fg-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .origin-pill { + font-size: 10px; + padding: 1px 6px; + font-weight: 500; + } } } diff --git a/src/app/shared/detail-shared/automation-card/automation-card.component.ts b/src/app/shared/detail-shared/automation-card/automation-card.component.ts index c72353829..5b96d7c58 100755 --- a/src/app/shared/detail-shared/automation-card/automation-card.component.ts +++ b/src/app/shared/detail-shared/automation-card/automation-card.component.ts @@ -53,6 +53,25 @@ export class AutomationCardComponent { toggled = output(); openInFiles = output(); + protected readonly isQuickRun = computed(() => { + const auto = this.automation(); + return auto.args?.source === 'quickrun' || auto.args?.source === 'flow'; + }); + + protected readonly originLabel = computed(() => { + return this.isQuickRun() + ? this.translate.instant('flow.tabs.quickRun') || 'Quick Run' + : this.translate.instant('navigation.dashboard') || 'Dashboard'; + }); + + protected readonly originIcon = computed(() => { + return this.isQuickRun() ? 'quick-run' : 'cloud'; + }); + + protected readonly originBadgeClass = computed(() => { + return this.isQuickRun() ? 'p-primary' : 'p-dim'; + }); + protected readonly automationMeta = computed( () => AUTOMATION_TYPE_META[this.automation().automationType] ?? DEFAULT_META ); diff --git a/src/app/shared/detail-shared/disk-usage-panel/disk-usage-panel.component.ts b/src/app/shared/detail-shared/disk-usage-panel/disk-usage-panel.component.ts index 190098b31..e55733db0 100755 --- a/src/app/shared/detail-shared/disk-usage-panel/disk-usage-panel.component.ts +++ b/src/app/shared/detail-shared/disk-usage-panel/disk-usage-panel.component.ts @@ -1,11 +1,12 @@ -import { Component, input, output, computed, ChangeDetectionStrategy } from '@angular/core'; +import { Component, input, output, computed, inject, ChangeDetectionStrategy } from '@angular/core'; import { NgClass, NgStyle } from '@angular/common'; import { TranslatePipe } from '@ngx-translate/core'; import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; -import { DiskUsage } from '@app/types'; +import { DiskUsage, DiskUsageSeverity } from '@app/types'; import { FormatFileSizePipe } from '@app/pipes'; +import { BackendTranslationService } from 'src/app/services/i18n/backend-translation.service'; @Component({ selector: 'app-disk-usage-panel', @@ -61,7 +62,7 @@ import { FormatFileSizePipe } from '@app/pipes'; {{ 'detailShared.diskUsage.notSupported' | translate }}
} @else if (cfg.error) { - -
- {{ 'detailShared.diskUsage.total' | translate }} - {{ cfg.total ?? 0 | formatFileSize }} -
+ @if (cfg.total) { +
+ + {{ 'detailShared.diskUsage.total' | translate }} + {{ cfg.total | formatFileSize }} +
+ } }
} @@ -121,10 +125,28 @@ import { FormatFileSizePipe } from '@app/pipes'; `, }) export class DiskUsagePanelComponent { + private readonly backendTranslation = inject(BackendTranslationService); + readonly config = input.required(); readonly retry = output(); - readonly usagePercentage = computed(() => this.config().usagePercentage ?? 0); - readonly usagePercentageLabel = computed(() => this.config().usagePercentageLabel ?? '0%'); - readonly usageSeverity = computed(() => this.config().usageSeverity ?? 'healthy'); + readonly usagePercentage = computed(() => { + const cfg = this.config(); + if (cfg.total && cfg.total > 0 && cfg.used !== undefined) { + return Math.min(100, Math.max(0, (cfg.used / cfg.total) * 100)); + } + return 0; + }); + readonly usagePercentageLabel = computed(() => `${Math.round(this.usagePercentage())}%`); + readonly usageSeverity = computed(() => { + const pct = this.usagePercentage(); + if (pct >= 90) return 'critical'; + if (pct >= 80) return 'high'; + if (pct >= 60) return 'warning'; + return 'healthy'; + }); + readonly formattedErrorMessage = computed(() => { + const msg = this.config().errorMessage; + return msg ? this.backendTranslation.translateBackendMessage(msg) : ''; + }); } diff --git a/src/app/shared/detail-shared/operation-control/operation-control.component.ts b/src/app/shared/detail-shared/operation-control/operation-control.component.ts index 4a4934854..69b0ec5e2 100755 --- a/src/app/shared/detail-shared/operation-control/operation-control.component.ts +++ b/src/app/shared/detail-shared/operation-control/operation-control.component.ts @@ -123,9 +123,9 @@ import { TranslatePipe } from '@ngx-translate/core';
}
@@ -279,6 +279,19 @@ export class OperationControlComponent { readonly diskUsage = signal(null); readonly isDiskUsageLoading = signal(false); + readonly diskUsagePercentage = computed(() => { + const usage = this.diskUsage(); + if (!usage || !usage.total || usage.total <= 0) return 0; + return Math.min(100, Math.max(0, (usage.used / usage.total) * 100)); + }); + + readonly diskUsageColor = computed<'primary' | 'accent' | 'warn'>(() => { + const pct = this.diskUsagePercentage(); + if (pct > 90) return 'warn'; + if (pct > 70) return 'accent'; + return 'primary'; + }); + readonly isOperationsType = computed(() => (SYNC_TYPES as string[]).includes(this.config().operationType) ); @@ -289,8 +302,8 @@ export class OperationControlComponent { return ( operationType === 'mount' && isActive && - !!destination && - !destination.includes('Not configured') + pathConfig.hasDestination && + destination.trim().length > 0 ); }); diff --git a/src/app/shared/detail-shared/transfer-activity-panel/active-transfers-table.component.ts b/src/app/shared/detail-shared/transfer-activity-panel/active-transfers-table.component.ts index ff854a97a..789f9812c 100755 --- a/src/app/shared/detail-shared/transfer-activity-panel/active-transfers-table.component.ts +++ b/src/app/shared/detail-shared/transfer-activity-panel/active-transfers-table.component.ts @@ -295,12 +295,14 @@ export class ActiveTransfersTableComponent { } ); - private readonly _preloadEffect = effect(() => { - const remotes = this.remotesList(); - if (remotes.length > 0) { - untracked(() => this.ops.preloadFeatures(this.transfers())); - } - }); + constructor() { + effect(() => { + const remotes = this.remotesList(); + if (remotes.length > 0) { + untracked(() => this.ops.preloadFeatures(this.transfers())); + } + }); + } protected readonly enrichedTransfers = computed(() => { const search = this.searchTerm().toLowerCase().trim(); diff --git a/src/app/shared/detail-shared/transfer-activity-panel/base-transfers-table.component.ts b/src/app/shared/detail-shared/transfer-activity-panel/base-transfers-table.component.ts index 14d77b520..90820b292 100644 --- a/src/app/shared/detail-shared/transfer-activity-panel/base-transfers-table.component.ts +++ b/src/app/shared/detail-shared/transfer-activity-panel/base-transfers-table.component.ts @@ -57,12 +57,14 @@ export abstract class BaseTransfersTableComponent { - const remotes = this.remotesList(); - if (remotes.length > 0) { - untracked(() => this.ops.preloadFeatures(this.transfers())); - } - }); + constructor() { + effect(() => { + const remotes = this.remotesList(); + if (remotes.length > 0) { + untracked(() => this.ops.preloadFeatures(this.transfers())); + } + }); + } /** * Subclass-specific enrichment + filtering pipeline. Must return the full @@ -96,6 +98,13 @@ export abstract class BaseTransfersTableComponent 5 * 1024 * 1024) return 'speed-fast'; + if (speed > 1024 * 1024) return 'speed-medium'; + return 'speed-slow'; + } + /** Whether the given enriched item is currently being resolved. */ isResolving(item: TEnriched): boolean { return this.resolvingIds().has(item.uniqueId) || item.resolveState?.status === 'Running'; diff --git a/src/app/shared/detail-shared/transfer-activity-panel/check-results-table.component.ts b/src/app/shared/detail-shared/transfer-activity-panel/check-results-table.component.ts index 873f82e00..a732465c0 100755 --- a/src/app/shared/detail-shared/transfer-activity-panel/check-results-table.component.ts +++ b/src/app/shared/detail-shared/transfer-activity-panel/check-results-table.component.ts @@ -406,7 +406,7 @@ export class CheckResultsTableComponent extends BaseTransfersTableComponent 0) this.callbacks.clearSelection(); - else this.fileOps.clearClipboard(); + if (this.tabSvc.activeSelection().size > 0) { + this.callbacks.clearSelection(); } else { - if (this.tabSvc.selectedItemsRight().size > 0) this.callbacks.clearSelection(); - else this.fileOps.clearClipboard(); + this.fileOps.clearClipboard(); } return true; } diff --git a/src/app/shared/directives/shortcut-handler.directive.ts b/src/app/shared/directives/shortcut-handler.directive.ts index 9b65a4d86..fb8c9c660 100755 --- a/src/app/shared/directives/shortcut-handler.directive.ts +++ b/src/app/shared/directives/shortcut-handler.directive.ts @@ -11,6 +11,7 @@ import { BackupRestoreUiService } from 'src/app/services/settings/backup-restore import { OnboardingStateService } from 'src/app/services/ui/state/onboarding-state.service'; import { NotificationService } from 'src/app/services/ui/notification.service'; import { ModalService } from 'src/app/services/ui/modal.service'; +import { FlowOverlayService } from 'src/app/services/ui/flow-overlay.service'; @Directive({ selector: '[appShortcutHandler]', @@ -26,6 +27,7 @@ export class ShortcutHandlerDirective { private readonly serveManagementService = inject(ServeManagementService); private readonly nautilusService = inject(NautilusService); private readonly backupRestoreUiService = inject(BackupRestoreUiService); + private readonly flowOverlayService = inject(FlowOverlayService); @HostListener('window:keydown', ['$event']) onKeyDown(event: KeyboardEvent): void { @@ -96,12 +98,6 @@ export class ShortcutHandlerDirective { return true; } - // Navigation shortcuts - if (!ctrlKey && !shiftKey && !altKey && key === 'Escape') { - // Let individual components handle this - return false; - } - // Settings shortcuts if (ctrlKey && !shiftKey && !altKey && key === ',') { this.openPreferences(); @@ -118,6 +114,11 @@ export class ShortcutHandlerDirective { return true; } + if (ctrlKey && !shiftKey && altKey && key.toLowerCase() === 'f') { + this.toggleFlowOverlay(); + return true; + } + return false; } @@ -148,25 +149,9 @@ export class ShortcutHandlerDirective { return false; } - // Block if file viewer is open - if (this.isFileViewerOpen()) { - console.debug('Shortcuts blocked: File viewer is open'); - return true; - } - - // Block if any modal is open - if (this.dialog.openDialogs.length > 0) { - console.debug('Shortcuts blocked: Modal is open'); - return true; - } - - // Block if onboarding is active - if (this.isOnboardingActive()) { - console.debug('Shortcuts blocked: Onboarding is active'); - return true; - } - - return false; + return ( + this.isFileViewerOpen() || this.dialog.openDialogs.length > 0 || this.isOnboardingActive() + ); } /** @@ -192,7 +177,7 @@ export class ShortcutHandlerDirective { } private toggleFileBrowser(): void { - void this.nautilusService.newNautilusWindow(null, null); + this.nautilusService.toggleNautilusOverlay(); } private async forceRefreshMountedRemotes(): Promise { @@ -252,4 +237,8 @@ export class ShortcutHandlerDirective { private openAlerts(): void { this.modalService.openAlerts(); } + + private toggleFlowOverlay(): void { + this.flowOverlayService.toggleFlowOverlay(); + } } diff --git a/src/app/shared/modals/confirm-modal/confirm-modal.component.spec.ts b/src/app/shared/modals/confirm-modal/confirm-modal.component.spec.ts deleted file mode 100644 index 1ffc82090..000000000 --- a/src/app/shared/modals/confirm-modal/confirm-modal.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { ConfirmModalComponent } from './confirm-modal.component'; - -describe('ConfirmModalComponent', () => { - let component: ConfirmModalComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ConfirmModalComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(ConfirmModalComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/shared/modals/input-modal/input-modal.component.html b/src/app/shared/modals/input-modal/input-modal.component.html index 778247648..5adbe8eea 100755 --- a/src/app/shared/modals/input-modal/input-modal.component.html +++ b/src/app/shared/modals/input-modal/input-modal.component.html @@ -8,7 +8,7 @@