Windows compatibility pass (beta target)
External editor via cmd/start + exe picker; Permissions hidden for Windows-local (chmod only flips read-only there); Explorer/Recycle Bin wording; Ctrl vs Cmd shortcut labels; separator-aware path joins + drive-root detection; drives list in the panel source picker; icon.ico + colored tray icon; icacls ACL on connections.json; README Windows (beta) section with upstream known issues and a smoke-test checklist. Runtime paths pending first boot on a real Windows machine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,7 @@
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="toggleWrap()"
|
||||
title="Wrap long lines — hides line numbers (⌥Z)"
|
||||
[title]="'Wrap long lines — hides line numbers (' + altLabel + 'Z)'"
|
||||
class="rounded-md px-2 py-1 text-[11px] electrobun-webkit-app-region-no-drag hover:bg-accent"
|
||||
[class.bg-accent]="wrap()"
|
||||
[class.text-foreground]="wrap()"
|
||||
@@ -38,7 +38,7 @@
|
||||
</button>
|
||||
<button
|
||||
(click)="openFind()"
|
||||
title="Find (⌘F)"
|
||||
[title]="'Find (' + modLabel + 'F)'"
|
||||
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaSearch" size="0.85rem" />
|
||||
@@ -142,7 +142,7 @@
|
||||
</div>
|
||||
|
||||
<footer class="flex shrink-0 items-center gap-2 border-t border-border px-4 py-2">
|
||||
<span class="text-[11px] text-muted-foreground">⌘S save · ⌘F find · ⌘W close</span>
|
||||
<span class="text-[11px] text-muted-foreground">{{ modLabel }}S save · {{ modLabel }}F find · {{ modLabel }}W close</span>
|
||||
<span class="text-[11px] text-muted-foreground">·</span>
|
||||
<span class="text-[11px] tabular-nums text-muted-foreground">
|
||||
{{ lineCount() }} {{ lineCount() === 1 ? 'line' : 'lines' }}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME } from '../platform';
|
||||
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '../platform';
|
||||
import { RpcService } from '../rpc.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { ToastService } from '../toast.service';
|
||||
@@ -40,6 +40,8 @@ const DRAFT_DEBOUNCE_MS = 400;
|
||||
export class EditorWindow {
|
||||
readonly ctx = EDITOR_CONTEXT;
|
||||
readonly isMacChrome = IS_MAC_CHROME;
|
||||
readonly modLabel = MOD_LABEL;
|
||||
readonly altLabel = ALT_LABEL;
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal<string | null>(null);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { FileEntry } from '@shared/types';
|
||||
import { DialogsService } from './dialogs.service';
|
||||
import { joinPath } from './format';
|
||||
import { type PanelState, type Side } from './panels.service';
|
||||
import { IS_WINDOWS } from './platform';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { ToastService } from './toast.service';
|
||||
@@ -157,6 +158,8 @@ export class FileActionsService {
|
||||
}
|
||||
|
||||
chmod(panel: PanelState): void {
|
||||
// Windows local chmod only flips the read-only bit — don't pretend
|
||||
if (IS_WINDOWS && panel.sessionId() === 'local') return;
|
||||
const targets = this.targets(panel);
|
||||
if (!targets.length) return;
|
||||
this.dialogs.chmod.set({
|
||||
|
||||
@@ -63,13 +63,15 @@
|
||||
>
|
||||
<ng-icon name="mynaFilePlus" size="var(--action-icon)" />
|
||||
</button>
|
||||
<button
|
||||
(click)="run('chmod')"
|
||||
title="Permissions"
|
||||
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaLock" size="var(--action-icon)" />
|
||||
</button>
|
||||
@if (chmodAvailable()) {
|
||||
<button
|
||||
(click)="run('chmod')"
|
||||
title="Permissions"
|
||||
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaLock" size="var(--action-icon)" />
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
(click)="run('delete')"
|
||||
title="Delete (F8 / Del)"
|
||||
@@ -95,6 +97,17 @@
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Windows drive roots (empty list elsewhere) -->
|
||||
@for (drive of drives(); track drive) {
|
||||
<button
|
||||
(click)="pickDrive(drive)"
|
||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent"
|
||||
>
|
||||
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||
<span class="flex-1 font-mono">{{ drive }}</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (connections.grouped().loose.length || connections.grouped().stacks.length) {
|
||||
<div class="my-1 border-t border-border"></div>
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { FileActionsService } from '../file-actions.service';
|
||||
import { fileGlyph, fileIconClass, fileKind } from '../file-icons';
|
||||
import { formatDate, formatMode, formatSize } from '../format';
|
||||
import { PanelsService, type Side, type SortKey } from '../panels.service';
|
||||
import { IS_WINDOWS } from '../platform';
|
||||
import { RpcService } from '../rpc.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { TransfersService } from '../transfers.service';
|
||||
@@ -56,6 +57,10 @@ export class FilePanel {
|
||||
|
||||
readonly pathDraft = signal('');
|
||||
readonly menuOpen = signal(false);
|
||||
/** Windows drive roots for the source picker; always empty elsewhere. */
|
||||
readonly drives = signal<string[]>([]);
|
||||
/** chmod on Windows local files only flips the read-only bit — hide it. */
|
||||
readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote()));
|
||||
|
||||
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
|
||||
private lastClickedIndex = 0;
|
||||
@@ -79,7 +84,18 @@ export class FilePanel {
|
||||
void this.rpc.request.showColumnsMenu({ visible: this.settings.columns() });
|
||||
}
|
||||
|
||||
pickDrive(drive: string): void {
|
||||
this.menuOpen.set(false);
|
||||
this.activate();
|
||||
void this.panel().load(drive);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
if (IS_WINDOWS && this.rpc.available) {
|
||||
void this.rpc.request.listDrives({}).then((res) => {
|
||||
if (res.ok) this.drives.set(res.drives);
|
||||
});
|
||||
}
|
||||
// keep the editable path bar in sync with the actual cwd
|
||||
effect(() => this.pathDraft.set(this.panel().cwd()));
|
||||
// focus follows the active side (Tab switches panels, dialogs refocus on close)
|
||||
|
||||
+11
-2
@@ -42,9 +42,18 @@ export function formatEta(seconds: number): string {
|
||||
return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : mmss;
|
||||
}
|
||||
|
||||
/** POSIX-style join for UI-composed paths (both panels use "/" on macOS + remote). */
|
||||
/**
|
||||
* Separator-aware join for UI-composed paths: remote and POSIX-local dirs use
|
||||
* "/", a Windows-local dir ("C:\Users\x") keeps "\" so paths stay consistent.
|
||||
*/
|
||||
export function joinPath(dir: string, name: string): string {
|
||||
return dir.endsWith('/') ? dir + name : `${dir}/${name}`;
|
||||
const sep = dir.includes('\\') && !dir.includes('/') ? '\\' : '/';
|
||||
return dir.endsWith(sep) ? dir + name : `${dir}${sep}${name}`;
|
||||
}
|
||||
|
||||
/** "/" (POSIX) or a drive root like "C:\" — nothing above to navigate to. */
|
||||
export function isRootPath(p: string): boolean {
|
||||
return p === '/' || /^[A-Za-z]:[\\/]?$/.test(p);
|
||||
}
|
||||
|
||||
/** 0o755 -> "rwxr-xr-x" */
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, computed, inject, signal, type WritableSignal } from '@angu
|
||||
import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
|
||||
import { fileKind } from './file-icons';
|
||||
import { FocusService } from './focus.service';
|
||||
import { formatMode } from './format';
|
||||
import { formatMode, isRootPath, joinPath } from './format';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { ToastService } from './toast.service';
|
||||
|
||||
@@ -73,8 +73,8 @@ export class PanelState {
|
||||
return cmp * dir;
|
||||
});
|
||||
const cwd = this.cwd();
|
||||
if (cwd && cwd !== '/') {
|
||||
list.unshift({ name: '..', path: `${cwd}/..`, type: 'dir', size: 0, mtime: 0, mode: 0 });
|
||||
if (cwd && !isRootPath(cwd)) {
|
||||
list.unshift({ name: '..', path: joinPath(cwd, '..'), type: 'dir', size: 0, mtime: 0, mode: 0 });
|
||||
}
|
||||
return list;
|
||||
});
|
||||
@@ -144,7 +144,7 @@ export class PanelState {
|
||||
|
||||
up(): void {
|
||||
if (!this.cwd()) return;
|
||||
void this.load(`${this.cwd()}/..`);
|
||||
void this.load(joinPath(this.cwd(), '..'));
|
||||
}
|
||||
|
||||
/** Update the filter and park the cursor on the first real match. */
|
||||
|
||||
@@ -18,6 +18,12 @@ export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?
|
||||
/** True when the window uses macOS hiddenInset chrome (header fused with titlebar). */
|
||||
export const IS_MAC_CHROME = PLATFORM === 'darwin';
|
||||
|
||||
export const IS_WINDOWS = PLATFORM === 'win32';
|
||||
|
||||
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
|
||||
export const MOD_LABEL = IS_MAC_CHROME ? '⌘' : 'Ctrl+';
|
||||
export const ALT_LABEL = IS_MAC_CHROME ? '⌥' : 'Alt+';
|
||||
|
||||
export const APP_MODE: 'main' | 'editor' | 'manager' =
|
||||
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NgIcon } from '@ng-icons/core';
|
||||
import { BRANDING } from '@shared/branding';
|
||||
import type { AccentTone } from '@shared/types';
|
||||
import { PanelsService } from '../panels.service';
|
||||
import { IS_MAC_CHROME } from '../platform';
|
||||
import { IS_MAC_CHROME, MOD_LABEL } from '../platform';
|
||||
import { RpcService } from '../rpc.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
|
||||
@@ -78,7 +78,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
@if (panels.workspaces().length > 1) {
|
||||
<button
|
||||
(click)="close($event, ws.id)"
|
||||
title="Close tab (⌘W)"
|
||||
[title]="'Close tab (' + modLabel + 'W)'"
|
||||
class="mr-1 grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.7rem" />
|
||||
@@ -94,7 +94,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
</div>
|
||||
<button
|
||||
(click)="panels.newWorkspace()"
|
||||
title="New tab (⌘T)"
|
||||
[title]="'New tab (' + modLabel + 'T)'"
|
||||
class="ml-0.5 grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaPlus" size=".8rem" />
|
||||
@@ -359,6 +359,7 @@ export class TabBar {
|
||||
private readonly rpc = inject(RpcService);
|
||||
readonly settingsOpen = signal(false);
|
||||
readonly isMacChrome = IS_MAC_CHROME;
|
||||
readonly modLabel = MOD_LABEL;
|
||||
readonly appName = BRANDING.appName;
|
||||
readonly buildLabel = BRANDING.buildLabel;
|
||||
readonly themes = THEMES;
|
||||
|
||||
Reference in New Issue
Block a user