Skip to content

Refactor: introduce RemoteFs abstraction (LocalRemoteFs only) for ipc/file.ts #124

@parsakhaz

Description

@parsakhaz

Why

`main/src/ipc/file.ts` currently imports `fs/promises` directly and uses `path.join` in ~20 places. This causes two problems:

  1. Cross-platform path bug: On Windows, `path.join` produces backslash-separated paths. The existing WSL code path already has subtle issues here, and any future cross-system file backend (SSH, Docker, Codespaces) would inherit this latent bug.
  2. No seam for future remote backends: Every file handler hard-codes `fs.readFile`, `fs.writeFile`, etc. There's no place to add an `if (remote) { ... }` branch without touching every handler individually.

This issue introduces a single, narrow `RemoteFs` interface and migrates every file handler to use it. The interface starts with only one implementation (`LocalRemoteFs`) that wraps the same `fs/promises` calls used today. This is a pure refactor — zero behavior change.

The goal is not to add abstraction for its own sake. It's to give us one place to slot in remote file backends later, and to fix the `path.join` bug along the way.

Scope

New files

  • `main/src/services/fs/RemoteFs.ts` — interface
  • `main/src/services/fs/LocalRemoteFs.ts` — implementation

Interface

```typescript
export interface RemoteDirent {
name: string;
isDirectory: boolean;
isFile: boolean;
size: number;
mtime: Date;
}

export interface RemoteStat {
isDirectory: boolean;
isFile: boolean;
size: number;
mtime: Date;
}

export interface RemoteFs {
readFile(p: string): Promise;
readFileBuffer(p: string): Promise;
writeFile(p: string, data: string | Buffer): Promise;
mkdir(p: string, opts?: { recursive?: boolean }): Promise;
exists(p: string): Promise;
stat(p: string): Promise;
readdir(p: string): Promise<RemoteDirent[]>;
unlink(p: string): Promise;
rm(p: string, opts: { recursive: boolean }): Promise;
rename(from: string, to: string): Promise;
search(root: string, pattern: string, opts?: { ignoreCase?: boolean; maxResults?: number }): Promise<string[]>;
}
```

`LocalRemoteFs` wraps `fs/promises` for everything and `node-glob` for `search`. No new logic — it's a thin pass-through.

Files to migrate

  • `main/src/ipc/file.ts` — every handler pulls `remoteFs` from `sessionManager.getProjectContext` and uses it instead of direct `fs.*` calls. Specific call sites to migrate (line numbers from current main):
    • Line 102: `fs.readFile` → `remoteFs.readFile`
    • Line 138: `fs.readFile` (binary) → `remoteFs.readFileBuffer`
    • Line 171: `fs.access` → `remoteFs.exists`
    • Line 219: `fs.mkdir` → `remoteFs.mkdir`
    • Line 222: `fs.writeFile` → `remoteFs.writeFile`
    • Line 237: `fs.access` → `remoteFs.exists`
    • Line 294: `fs.writeFile` → `remoteFs.writeFile`
    • Line 565: `fs.readdir` → `remoteFs.readdir`
    • Line 576: `fs.stat` → `remoteFs.stat`
    • Line 641: `fs.access` → `remoteFs.exists`
    • Line 647: `fs.stat` → `remoteFs.stat`
    • Line 651: `fs.rm` → `remoteFs.rm`
    • Line 654: `fs.unlink` → `remoteFs.unlink`
    • Line 712: `fs.access` → `remoteFs.exists`
    • Line 757: `glob()` → `remoteFs.search`
    • Line 787: `fs.stat` → `remoteFs.stat`
    • Line 797: `fs.stat` → `remoteFs.stat`
    • Line 860: `fs.access` → `remoteFs.exists`
    • Line 869: `fs.readFile` → `remoteFs.readFile`
    • Line 906: `fs.mkdir` → `remoteFs.mkdir`
    • Line 909: `fs.writeFile` → `remoteFs.writeFile`
  • `main/src/services/sessionManager.ts` — `getOrCreateContext`, `getProjectContext`, `getProjectContextByProjectId` return shape gains `remoteFs: RemoteFs` (always populated, currently always `LocalRemoteFs`)
  • `main/src/services/worktreeManager.ts` — `initializeProject` accepts `remoteFs: RemoteFs` parameter; the one direct `mkdir` call is replaced with `remoteFs.mkdir(...)`
  • All `path.join` calls in `ipc/file.ts` are replaced with `pathResolver.join` (which already handles WSL correctly today). `path.basename` and `path.dirname` stay — those are safe for both local and remote paths.

Audit task

After migration, `grep -n 'from .fs/promises.|require..fs/promises.' main/src/ipc/file.ts` should return zero matches. The only remaining `path.*` usages should be `path.basename`, `path.dirname`, `path.extname`, or `path.relative` — never `path.join` for paths constructed from session/worktree context.

Validation

Automated

  • `pnpm typecheck` — clean
  • `pnpm lint` — clean
  • No new `any` introduced

Manual regression smoke (REQUIRED)

Test on a real local project:

  • File tree: open editor view, navigate directories, see file listings with sizes and timestamps
  • Open file: click a file in the tree, content loads in the editor
  • Edit and save file: modify a file, save, reopen — changes persist
  • Create new file via the file tree
  • Delete file: delete a single file, then delete a directory recursively
  • Rename file: works
  • File search: open the search panel, type a query, see results
  • Binary file read: open an image file in the editor (uses `readFileBuffer`)
  • Permission errors: try to read a file with no read permission, see a clean error (not a crash)
  • Large file: open a several-MB file, see it load
  • WSL projects (if applicable): file operations on WSL paths still work
  • Worktree creation: create a new session, verify the worktree directory is created

Out of Scope

Success Criteria

  • `RemoteFs` interface and `LocalRemoteFs` implementation exist
  • Every direct `fs.` call in `ipc/file.ts` is replaced with `remoteFs.`
  • Every `path.join` in `ipc/file.ts` is replaced with `pathResolver.join`
  • `worktreeManager.initializeProject` uses `remoteFs.mkdir`
  • `sessionManager.getOrCreateContext` returns `remoteFs` in its result
  • Manual regression smoke passes for every file feature listed above
  • Zero typecheck or lint errors
  • Diff is purely a refactor — no behavior changes

Dependencies

Why This Is a Prerequisite for Remote SSH

The Remote SSH effort needs to add a `SftpRemoteFs implements RemoteFs` that routes file operations through SFTP and `find` over SSH. With this issue landed, that addition is purely additive — drop in a new class, branch in `getOrCreateContext` based on `project.ssh_enabled`, no other code in `ipc/file.ts` or `worktreeManager.ts` needs to change.

Doing this introduction as part of the SSH PR would couple a refactor (path bug fix, FS abstraction) with a feature (remote support) and make regressions impossible to bisect.

The Remote SSH plan is at `tmp/ready-plans/2026-04-10-remote-ssh-support.md` in the `remote-ssh-like-vs-code` branch.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions