Repository navigation
feat: edit files in local workspaces from the code viewer (refs #402) - #404
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
WorkspaceFolders.hostDirectory).RuntimeCapabilities.fileEditingis false there and the edit button is not shown.Dotfiles /
.env(checked first)No local backend hides them, so nothing needed fixing there:
ClaudeWorkspaceFiles.list, which returns every child and only flags dotfilesignored(already covered byClaudeWorkspaceFilesTest; a new test also edits one)./file, which excludes only.git/.DS_Store;OpenCodeApiClient.filesand the explorer's Files tab do no filtering and just show theignoredchip. (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)..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.fsync→ATOMIC_MOVEover 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.readFileForEditing, not the server's trimmed viewer copy) and the save is refused withCHANGED_ON_DISKif the file no longer matches that.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 liveOpenCodeV2LiveTest), lint 0 errors. Note the rootdetekttask reportsNO-SOURCEin 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 tripClaudeWorkspaceFilesTest,LocalRuntimeTargetFileEditingTest,CodeViewerViewModelTest(edit flow, refused save keeps the draft, ignored input while saving,editTargetOf),RemoteRuntimeTargetCapabilitiesTest(remote never offers editing).github/workflows/i18n-check.yml(ported from GNUgrep -P), 15/15file_edit_*keys in all 7 translationsCodeViewerEditingInstrumentedTest(realCodeViewerScreen+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 rowKnown limitations / follow-ups (from review, non-blocking)
CHANGED_ON_DISKthe 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..andcode-edit-*.tmpin the workspace.RuntimeRegistryinAppModuleconstructsLocalRuntimeTargetwithoutfiles, so itscapabilities.fileEditingis false. Onlyapp.runtimeRegistry(which the nav graph reads) has it; nothing reads it from the Koin one today.Review
repo-revieweris 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
ブロッカー
なし
提案(非ブロッキング)
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 を共有しておくと安全。.andcode-edit-*.tmpが残る。ワークスペース内なので git status に未追跡ファイルとして出る。起動時などで古い temp を掃除する案もある。CHANGED_ON_DISKを受けてユーザーが破棄したあとも、ビューアは編集開始前のcontentを表示したままで、ディスク上の最新内容とずれる。stopEditing後(少なくとも競合後)にload()を呼ぶと整合する。content.toByteArray(UTF_8)は、ドラフト内の孤立サロゲートを黙って?に置換する。FileEditPolicy.refusalForはエンコード後のバイト列を見るので検出できない。IME 経由ではほぼ起きないが、CharsetEncoder(REPORT)でエンコードすれば保存前にNOT_TEXTとして弾ける。CodeEditorのfieldはrememberなので、画面回転でカーソル位置と選択が失われる(テキストは VM に残る)。ドラフトは VM のみで保持するため、プロセス終了では未保存の編集が消える。現状の仕様として許容範囲。チェック済み項目
git diff origin/main...HEADで全て確認した。git fetch origin mainも実施済み。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で問題ない。UNSAVED_MARKは記号のみ。CodeViewerMessagesインタフェース+AndroidCodeViewerMessages(app)で、LocalRuntimeMessagesと同じ作法。ログ(Log.w)と例外メッセージは英語。CodeEditorはfield.text基準で変更検知しており、readOnly中は VM とのズレも生じない。pendingDiscardはremember。保存不可の理由(VM が復元されない)をコメントしている。BackHandler(enabled = hasUnsavedChanges || isSaving)で戻るを吸収し、leaveScreenも無視、Close ボタンはenabled = !isSaving。VM 側もupdateDraft/stopEditing/ 二重saveを無視する。KeyboardOptions(capitalization = None, autoCorrectEnabled = false)は妥当。Log.wはfailureMessageに集約。CancellationExceptionはfailureMessageに入る前に再スローしている。isReturnDefaultValues = true(build.gradle.kts:208)なので JVM テストでも動く。LocalRuntimeTargetFileEditingTest(capabilities、保存、files = nullでUnsupportedOperationException)と VM の「保存中は無視」テストを確認した。CodeViewerEditingInstrumentedTestは文字列をR.stringの id で引くため、端末ロケールに依存しない。WorkspaceFileWriter.resolveInsideはルートと対象を canonical 化してからroot + separatorの前方一致で比較する。..、symlink、絶対パス、同一プレフィックスの兄弟ディレクトリ、ルート自身を拒否し、/ルートは VM 側でも除外している。selectedWorkspace().pathで縛られ、閲覧中ディレクトリは境界にならない(editTargetOf)。..を含む経路はレキシカルに拒否される。length()で判定)、読み取り専用を拒否する。BOM、CRLF、非ASCII の byte 一致往復はテストで確認した。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 と同一。fileEditing = true、リモートは既定の false で、テストで固定されている。OpenCodeBackendの既定実装はunsupported(...)。node.pathはcurrentPath基準、変更一覧はworkspace.path+displayPath)がeditTargetOfの前提と一致する。WorkspaceFileWriterTest(約30ケース)、ClaudeWorkspaceFilesTest(サンドボックスパス、rootfs プロジェクト、ルート外拒否)、CodeViewerViewModelTest(編集開始、保存、拒否、競合、保存中の無視、editTargetOf)は、挙動変更を網羅している。feat/402-edit-local-workspace-filesは origin/main 基準で、未コミット変更なし。秘密情報の混入なし。🤖 Generated with Claude Code