Skip to content

feat: edit files in local workspaces from the code viewer (refs #402) - #404

Merged
yuga-hashimoto merged 1 commit into
mainfrom
feat/402-edit-local-workspace-files
Oct 25, 2026
Merged

yuga-hashimoto merged 1 commit into
mainfrom
feat/402-edit-local-workspace-files

Conversation

@yuga-hashimoto

Copy link
Copy Markdown
Owner

Summary

Adds in-app file editing for local workspaces — the part of #402 that can be done today. Refs #402; it does not close it, since dev-server management and the in-app-browser report are still open.

  • The code viewer gets an Edit action (pencil) on runtimes whose workspace is a folder on the device: OpenCode (local), Claude Code, Codex, Antigravity. Save writes through the host path of the guest folder (WorkspaceFolders.hostDirectory).
  • Remote workspaces stay read-only: a remote OpenCode server has no write endpoint, so RuntimeCapabilities.fileEditing is false there and the edit button is not shown.
  • Unsaved-changes guard on the toolbar back arrow, the system back gesture, and "stop editing"; discarding asks first, and back is ignored while a save is in flight.

Dotfiles / .env (checked first)

No local backend hides them, so nothing needed fixing there:

  • Claude Code / Codex / Antigravity list via ClaudeWorkspaceFiles.list, which returns every child and only flags dotfiles ignored (already covered by ClaudeWorkspaceFilesTest; a new test also edits one).
  • Local OpenCode lists through the server's /file, which excludes only .git / .DS_Store; OpenCodeApiClient.files and the explorer's Files tab do no filtering and just show the ignored chip. (The server-side part is from OpenCode's source and was not exercised against a live runtime here.)

Safety rules for writes (WorkspaceFileWriter, plain Kotlin)

  • Containment: root and target are canonicalised before comparing, so .. and symlinks (file or directory) cannot reach outside the workspace root. The editing root is the selected workspace, not the folder the explorer happens to be browsing, and / is never an editing root.
  • Text only: refuses binary (NUL), non-UTF-8 (would corrupt on round trip), and files over 256 KB; refuses read-only files, directories and missing files (nothing is ever created).
  • Atomic write: temp file in the same directory → fsync → ATOMIC_MOVE over the target; temp is always removed on failure; the original is untouched if anything fails; permission bits (e.g. +x) are preserved; a symlink inside the workspace is written through, not replaced.
  • No lost agent edits: the editor starts from the file's exact on-disk text (readFileForEditing, not the server's trimmed viewer copy) and the save is refused with CHANGED_ON_DISK if the file no longer matches that.
  • Errors are typed (FileEditFailure) and shown as localized messages; 15 new strings in all 8 locale files. IME autocorrect/auto-capitalisation are off in the editor.

Test plan

  • ./gradlew spotlessCheck :app:testGithubDebugUnitTest :app:lintGithubDebug detekt — green: 1478 unit tests, 0 failures (8 skipped = pre-existing live OpenCodeV2LiveTest), lint 0 errors. Note the root detekt task reports NO-SOURCE in this repo, so it checks nothing.
  • WorkspaceFileWriterTest (new, ~30 cases): containment incl. ../absolute/symlink-out/symlinked dir, symlink-in-workspace, binary, invalid UTF-8, size cap (existing + new content), NUL, read-only, conflict detection, atomic replace leaves no temp / keeps original on failure, exec bit, BOM/CRLF round trip
  • ClaudeWorkspaceFilesTest, LocalRuntimeTargetFileEditingTest, CodeViewerViewModelTest (edit flow, refused save keeps the draft, ignored input while saving, editTargetOf), RemoteRuntimeTargetCapabilitiesTest (remote never offers editing)
  • i18n key parity: same check as .github/workflows/i18n-check.yml (ported from GNU grep -P), 15/15 file_edit_* keys in all 7 translations
  • New instrumented CodeViewerEditingInstrumentedTest (real CodeViewerScreen + CodeViewerViewModel): edit action hidden without an editing root, save writes the typed text, back with unsaved edits asks first and keeps them unless discarded, back without edits leaves — 4/4 on an API 36 emulator, two runs in a row
  • Not tried by hand against a real local runtime: Japanese IME composition, CRLF files in the editor field, typing latency near the 256 KB cap, rotation mid-edit

Known limitations / follow-ups (from review, non-blocking)

  • After CHANGED_ON_DISK the session can't save (copy → close → reopen is the only way; the message says so). A "reload from disk" / "overwrite" choice would be a nice follow-up, as would reloading the viewer after a discarded conflicted edit.
  • Conflict detection compares then renames, so it is not atomic against a write landing in that tiny window; and a process kill between temp creation and rename leaves a hidden .andcode-edit-*.tmp in the workspace.
  • The Koin-built RuntimeRegistry in AppModule constructs LocalRuntimeTarget without files, so its capabilities.fileEditing is false. Only app.runtimeRegistry (which the nav graph reads) has it; nothing reads it from the Koin one today.
  • Draft lives in the view model only (lost on process death); cursor/selection reset on rotation.
  • Dev-server management and the in-app-browser report from Improve Local Dev Server and File Management #402 are not addressed here.

Review

repo-reviewer is an OpenCode agent definition (.opencode/agents/repo-reviewer.md) and is not available as a Claude Code agent type here, so the review was run by a general-purpose subagent instructed to follow that file exactly, read-only. A first pass (APPROVE) led to hardening commits folded into this one (stale-text comparison in the editor, back ignored while saving, autocorrect off, logging the failure cause, more tests); the review was then re-run from scratch on the final HEAD.

Pre-PR review

判定: APPROVE

ブロッカー

なし

提案(非ブロッキング)

  • app/src/main/java/com/yugahashimoto/andcode/di/AppModule.kt:181 — Koin 側の RuntimeRegistry は LocalRuntimeTarget(get(), messages = get()) を作っており、files を渡していない。AndCodeApplication.kt:468 で組み立てる app.runtimeRegistry(ClaudeWorkspaceFiles 注入済み)とは別インスタンスで、Koin 側の capabilities.fileEditing は false になる。今のところ fileEditing を読むのは WorkspaceNavGraph.kt:238 だけで、そこは app.runtimeRegistry.target(...) から取るため実害はない。後から Koin 側のレジストリ経由で capabilities を読むと、編集ボタンが黙って消える。コメントを足すか、files 付きの target を共有しておくと安全。
  • app/src/main/java/com/yugahashimoto/andcode/runtime/local/WorkspaceFileWriter.kt:45-47,121-128 — 競合検出は「内容比較 → temp 作成 → rename」の順で、比較と rename の間は原子的ではない。この間にエージェントが書き込むと、その変更を上書きする。窓は狭く、致命的ではない。クラス KDoc(21行目)は symlink すり替えしか触れていないので、この限界も一言書いておくとよい。また、temp 作成後〜rename 前にプロセスが kill されると .andcode-edit-*.tmp が残る。ワークスペース内なので git status に未追跡ファイルとして出る。起動時などで古い temp を掃除する案もある。
  • app/src/main/java/com/yugahashimoto/andcode/feature/workspace/CodeViewerViewModel.kt:144 付近 — CHANGED_ON_DISK を受けてユーザーが破棄したあとも、ビューアは編集開始前の content を表示したままで、ディスク上の最新内容とずれる。stopEditing 後(少なくとも競合後)に load() を呼ぶと整合する。
  • app/src/main/java/com/yugahashimoto/andcode/runtime/local/WorkspaceFileWriter.kt:42 — content.toByteArray(UTF_8) は、ドラフト内の孤立サロゲートを黙って ? に置換する。FileEditPolicy.refusalFor はエンコード後のバイト列を見るので検出できない。IME 経由ではほぼ起きないが、CharsetEncoder(REPORT)でエンコードすれば保存前に NOT_TEXT として弾ける。
  • app/src/main/java/com/yugahashimoto/andcode/feature/workspace/CodeViewerScreen.kt:362 — CodeEditor の field は remember なので、画面回転でカーソル位置と選択が失われる(テキストは VM に残る)。ドラフトは VM のみで保持するため、プロセス終了では未保存の編集が消える。現状の仕様として許容範囲。

チェック済み項目

  • 差分全体(28ファイル、+1943/-93)を git diff origin/main...HEAD で全て確認した。git fetch origin main も実施済み。
  • i18n:
    • 新規15キー(file_edit_*)が values/ と ar/es/fr/ja/pt-rBR/ru/zh-rCN の全8ファイルにあることを、キー名の抽出で確認した。i18n-check.yml の判定ロジックとも整合する。
    • file_edit_error_too_large の %1$d は全ロケールで保持されている。Kotlin 側は Long を渡しており、%d で問題ない。
    • Kotlin に直書きのユーザー向け文言はない。UNSAVED_MARK は記号のみ。
    • VM は CodeViewerMessages インタフェース+ AndroidCodeViewerMessages(app) で、LocalRuntimeMessages と同じ作法。ログ(Log.w)と例外メッセージは英語。
  • 前回レビュー後の変更7点を確認した。
    • (1) CodeEditor は field.text 基準で変更検知しており、readOnly 中は VM とのズレも生じない。
    • (2) pendingDiscard は remember。保存不可の理由(VM が復元されない)をコメントしている。
    • (3) 保存中は BackHandler(enabled = hasUnsavedChanges || isSaving) で戻るを吸収し、leaveScreen も無視、Close ボタンは enabled = !isSaving。VM 側も updateDraft / stopEditing / 二重 save を無視する。
    • (4) KeyboardOptions(capitalization = None, autoCorrectEnabled = false) は妥当。
    • (5) Log.w は failureMessage に集約。CancellationException は failureMessage に入る前に再スローしている。isReturnDefaultValues = true(build.gradle.kts:208)なので JVM テストでも動く。
    • (6) LocalRuntimeTargetFileEditingTest(capabilities、保存、files = null で UnsupportedOperationException)と VM の「保存中は無視」テストを確認した。
    • (7) CodeViewerEditingInstrumentedTest は文字列を R.string の id で引くため、端末ロケールに依存しない。
  • セキュリティ:
    • WorkspaceFileWriter.resolveInside はルートと対象を canonical 化してから root + separator の前方一致で比較する。..、symlink、絶対パス、同一プレフィックスの兄弟ディレクトリ、ルート自身を拒否し、/ ルートは VM 側でも除外している。
    • 書き込み先は selectedWorkspace().path で縛られ、閲覧中ディレクトリは境界にならない(editTargetOf)。.. を含む経路はレキシカルに拒否される。
    • 既存ファイルの置換のみで、新規作成はしない。バイナリ、非 UTF-8、NUL、256KB超(読み込み前に length() で判定)、読み取り専用を拒否する。BOM、CRLF、非ASCII の byte 一致往復はテストで確認した。
  • 原子性: 同一ディレクトリに temp を作り、fd.sync() のあと ATOMIC_MOVE で置換する。finally で temp を削除し、パーミッションは best-effort でコピーする。minSdk 26 なので java.nio.file を使える。
  • 配線と互換性:
    • CodeViewerViewModel の構築箇所は WorkspaceNavGraph.kt のみ(Koin 側の構築なし)。
    • LocalRuntimeTarget.capabilities は getter で fileEditing = files != null。AndCodeApplication の ClaudeWorkspaceFiles 引数は ClaudeCodeTarget / AntigravityTarget と同一。
    • 4つのローカル target はすべて fileEditing = true、リモートは既定の false で、テストで固定されている。
    • OpenCodeBackend の既定実装は unsupported(...)。
    • ビューアに渡すパスの意味(node.path は currentPath 基準、変更一覧は workspace.path + displayPath)が editTargetOf の前提と一致する。
  • テスト: WorkspaceFileWriterTest(約30ケース)、ClaudeWorkspaceFilesTest(サンドボックスパス、rootfs プロジェクト、ルート外拒否)、CodeViewerViewModelTest(編集開始、保存、拒否、競合、保存中の無視、editTargetOf)は、挙動変更を網羅している。
  • ビルド系(spotlessCheck / unitTest 1478件 / lint / detekt、実機 instrumented 4件)は依頼文の報告どおりと受け取った。本レビューではビルドもテストも実行していない。
  • ワークツリー規約: ブランチ feat/402-edit-local-workspace-files は origin/main 基準で、未コミット変更なし。秘密情報の混入なし。

🤖 Generated with Claude Code

Adds an Edit action to the code viewer for runtimes whose workspace is a
folder on the device (OpenCode local, Claude Code, Codex, Antigravity).
Remote OpenCode servers have no write endpoint, so they stay read-only
(RuntimeCapabilities.fileEditing).

Writes go through the host path of the guest folder via WorkspaceFileWriter
(plain Kotlin, unit tested):
- root and target are canonicalised, so `..` and symlinks cannot leave the
  selected workspace; `/` is never an editing root
- only existing UTF-8 text up to 256 KB; binary, non-UTF-8, read-only
  files and directories are refused
- temp file + fsync + atomic move; the original survives any failure and
  permission bits are preserved
- the editor starts from the exact on-disk text and the save is refused if
  the file changed meanwhile, so an agent's concurrent edit is not lost

The viewer guards unsaved changes on back, system back and stop-editing, and
shows localized errors (15 new strings in all 8 locale files).

Refs #402 (file-editing part only).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@yuga-hashimoto
yuga-hashimoto merged commit 414c780 into main Oct 25, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant