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:
Jokin
2026-07-14 00:02:42 +02:00
co-authored by Claude Fable 5
parent d80d5c23dc
commit c7271fb7cf
23 changed files with 223 additions and 57 deletions
+3 -3
View File
@@ -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' }}
+3 -1
View File
@@ -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
View File
@@ -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({
+20 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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" */
+4 -4
View File
@@ -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. */
+6
View File
@@ -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';
+4 -3
View File
@@ -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;