Editor: open scripts in an external editor

Stage-10 editor-UX follow-up, second half of in-editor script authoring:

- "✏ Edit" button on the Script inspector section (enabled once a
  source is assigned) and double-click on any script in the Project
  panel open the .rhai in the user's editor. Non-script assets
  double-click through xdg-open.
- Resolution order: the new External Editor preference command (spawned
  detached as `<command> <file>`, flags allowed), else $VISUAL/$EDITOR
  in a new Terminal-panel tab (TUI editors work in-editor via the
  existing PTY widget), else xdg-open.
- New `editor.external_editor` settings section (ExternalEditorPrefs)
  registered by EditorState, persisted with the preferences file, with
  a rich row in the Preferences window; registration covered by a unit
  test. Editor crate now depends on serde directly for its own
  sections.

Saved edits flow back through the file watcher's live reload, including
into a playing scene. GUI piece — needs an eye-check before promotion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Homer
2026-07-10 20:41:37 +02:00
parent 4f1e9a48d7
commit d08bee1361
5 changed files with 212 additions and 13 deletions
+3
View File
@@ -25,6 +25,9 @@ egui_dock.workspace = true
# Editor preferences file I/O reads/writes the same RON shape `Settings`
# exports; the engine already pulls `ron` in, the editor now does too.
ron.workspace = true
# Editor-owned settings sections (e.g. the External Editor preference) derive
# their own Serialize/Deserialize for the Settings store.
serde.workspace = true
# PTY-backed terminal panel (Stage 10): run interactive/TUI programs — shells,
# REPLs, and AI-agent CLIs like `claude` — inside the editor. `portable-pty`
+169 -13
View File
@@ -52,7 +52,7 @@ use crate::command::{Command, CommandStack};
use crate::commands::{RenameCmd, SetFieldCmd, SetUiPanelCmd};
use crate::extension::EditorExtensions;
use crate::gizmo::{self, Axis3, GizmoHandle, GizmoMode, PlaneAxis};
use crate::state::{EditorState, PlayState};
use crate::state::{EditorState, ExternalEditorPrefs, PlayState, EXTERNAL_EDITOR_SECTION};
use oxide_engine::math::{Mat4, Vec4};
use oxide_engine::reflect::FieldInfo;
@@ -1463,6 +1463,8 @@ impl Shell {
// section editor lands.
if name == crate::bindings::SETTINGS_SECTION {
ui.collapsing("Input Bindings", |ui| self.input_bindings_page(ui));
} else if name == EXTERNAL_EDITOR_SECTION {
ui.collapsing("External Editor", |ui| self.external_editor_page(ui));
} else {
let ron = self.state.settings.section_ron(name);
ui.collapsing(name, |ui| match ron {
@@ -1486,6 +1488,37 @@ impl Shell {
self.show_preferences = open;
}
/// The External Editor preferences page: the command used when opening a
/// script from the editor. Edits mark the preferences dirty so the host
/// persists them like a binding remap.
fn external_editor_page(&mut self, ui: &mut egui::Ui) {
let Some(prefs) = self
.state
.settings
.get_mut::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION)
else {
ui.weak("(section unavailable)");
return;
};
ui.horizontal(|ui| {
ui.label("Open scripts with");
if ui
.add(
egui::TextEdit::singleline(&mut prefs.command)
.hint_text("auto — $VISUAL/$EDITOR, else xdg-open"),
)
.changed()
{
self.bindings_dirty = true;
}
});
ui.weak(
"Launched as `<command> <file>` (the command may carry flags, e.g. `code -g`). \
Leave empty for auto: $VISUAL/$EDITOR runs in a built-in Terminal tab, \
otherwise the file opens via xdg-open.",
);
}
/// Renders the input-bindings preferences page: every registered editor
/// action with its current bindings, a Change/Add/Clear control per
/// slot, a per-action "Restore defaults" button, and a global
@@ -3371,11 +3404,18 @@ impl<'a> ShellTabViewer<'a> {
edits.push((comp.name, row.info.name, new_ron));
}
}
// Stage-10 UX: author a script without leaving the editor. The
// assignment goes through `edits` → SetFieldCmd like any field
// change, so it is undoable (the file itself stays — harmless).
// Stage-10 UX: author and open scripts without leaving the
// editor. The assignment goes through `edits` → SetFieldCmd
// like any field change, so it is undoable (the created file
// itself stays — harmless).
if comp.name == "Script" {
if let Some(ron) = self.new_script_row(ui) {
let source = comp
.fields
.iter()
.find(|r| r.info.name == "source")
.and_then(|r| ron::from_str::<Option<AssetUid>>(&r.value).ok())
.flatten();
if let Some(ron) = self.new_script_row(ui, source) {
edits.push((comp.name, "source", ron));
}
}
@@ -3424,14 +3464,16 @@ impl<'a> ShellTabViewer<'a> {
}
}
/// The "New Script" row at the bottom of a `Script` component's section:
/// a name field + create button that writes a `.rhai` template into
/// The script-tools row at the bottom of a `Script` component's section:
/// a name field + "New Script" button that writes a `.rhai` template into
/// `assets/scripts/`, registers it in the asset database, and returns the
/// RON for the component's `source` field — the caller routes it through
/// the normal edit path so the assignment is undoable (undo detaches the
/// script; the created file stays, which is harmless). Disabled until a
/// project is open. Errors go to the log, i.e. the Console panel.
fn new_script_row(&mut self, ui: &mut egui::Ui) -> Option<String> {
/// script; the created file stays, which is harmless) — plus an "Edit"
/// button opening the currently assigned script (`source`) in the user's
/// editor. Disabled until a project is open. Errors go to the log, i.e.
/// the Console panel.
fn new_script_row(&mut self, ui: &mut egui::Ui, source: Option<AssetUid>) -> Option<String> {
let mut created = None;
ui.horizontal(|ui| {
let open = self.state.asset_db.is_some();
@@ -3469,6 +3511,18 @@ impl<'a> ShellTabViewer<'a> {
Err(err) => log::warn!("could not create script: {err}"),
}
}
let edit = ui
.add_enabled(source.is_some(), egui::Button::new("✏ Edit").small())
.on_hover_text(
"Open the assigned script in your editor (External Editor \
preference, else $VISUAL/$EDITOR in a Terminal tab, else \
xdg-open). Saved edits live-reload.",
);
if edit.clicked() {
if let Some(uid) = source {
self.open_script_in_editor(uid);
}
}
});
if !open {
ui.label(egui::RichText::new("(open a project to create scripts)").weak());
@@ -3477,6 +3531,59 @@ impl<'a> ShellTabViewer<'a> {
created
}
/// Opens the script asset `uid` in the user's editor. Resolution order:
///
/// 1. the **External Editor** preference command, spawned detached as
/// `<command> <file>`;
/// 2. `$VISUAL` / `$EDITOR`, run in a new **Terminal-panel tab** (so TUI
/// editors like vim/nano work in-editor);
/// 3. `xdg-open` (the desktop's default handler).
///
/// Whichever way, saved edits flow back through the file watcher's live
/// reload — including into a playing scene.
fn open_script_in_editor(&mut self, uid: AssetUid) {
let Some(abs) = self
.state
.asset_db
.as_ref()
.and_then(|db| db.absolute_path(uid))
else {
log::warn!("script asset {uid:?} has no file to open");
return;
};
let configured = self
.state
.settings
.get::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION)
.map(|p| p.command.trim().to_owned())
.unwrap_or_default();
if !configured.is_empty() {
spawn_detached(&configured, &abs);
return;
}
let not_blank = |v: String| (!v.trim().is_empty()).then_some(v);
let terminal_editor = std::env::var("VISUAL")
.ok()
.and_then(not_blank)
.or_else(|| std::env::var("EDITOR").ok().and_then(not_blank));
match terminal_editor {
Some(editor) => {
let leaf = abs
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "script".to_owned());
// $EDITOR may carry flags — whitespace-split it like the
// configured command, then append the file path.
let mut parts = editor.split_whitespace().map(str::to_owned);
let program = parts.next().unwrap_or_else(|| "vi".to_owned());
let owned: Vec<String> = parts.chain([abs.display().to_string()]).collect();
let args: Vec<&str> = owned.iter().map(String::as_str).collect();
self.launch_terminal(&format!("edit {leaf}"), &program, &args);
}
None => spawn_detached("xdg-open", &abs),
}
}
/// Renders one reflected field as a typed widget chosen from its
/// `type_name`, returning the field's new RON if the user changed it.
/// Unknown types fall back to an editable RON text box, so the inspector is
@@ -3790,10 +3897,29 @@ impl<'a> ShellTabViewer<'a> {
}
}
});
match &self.state.asset_db {
let open_asset = match &self.state.asset_db {
Some(db) => asset_browser(ui, db),
None => {
ui.weak("(asset database unavailable)");
None
}
};
// Double-click opens the asset: scripts through the external-editor
// flow (preference / $VISUAL/$EDITOR terminal tab), everything else
// via the desktop's default handler.
if let Some(uid) = open_asset {
let (kind, abs) = {
let db = self
.state
.asset_db
.as_ref()
.expect("open_asset came from it");
(db.entry(uid).map(|e| e.kind), db.absolute_path(uid))
};
if kind == Some(AssetKind::Script) {
self.open_script_in_editor(uid);
} else if let Some(abs) = abs {
spawn_detached("xdg-open", &abs);
}
}
@@ -4383,6 +4509,29 @@ impl<'a> ShellTabViewer<'a> {
// ---- helpers -----------------------------------------------------------
/// Spawns `command` (whitespace-split, so it may carry flags) with `file`
/// appended as the final argument, detached from the editor; a reaper thread
/// waits on the child so it never lingers as a zombie. Failures are logged
/// (→ the Console), never fatal.
fn spawn_detached(command: &str, file: &std::path::Path) {
let mut parts = command.split_whitespace();
let Some(program) = parts.next() else {
return;
};
match std::process::Command::new(program)
.args(parts)
.arg(file)
.spawn()
{
Ok(mut child) => {
std::thread::spawn(move || {
let _ = child.wait();
});
}
Err(err) => log::warn!("could not launch {command}: {err}"),
}
}
/// Returns whether the rendered control changed any axis this frame.
/// Projects a world-space point through `view_proj` to screen pixels
/// inside `tab_rect` (egui logical points). Returns `None` when the point
@@ -4861,7 +5010,8 @@ fn build_terminal_job(
job
}
fn asset_browser(ui: &mut egui::Ui, db: &AssetDatabase) {
fn asset_browser(ui: &mut egui::Ui, db: &AssetDatabase) -> Option<AssetUid> {
let mut open = None;
for kind in AssetKind::TYPED {
let mut entries: Vec<&AssetEntry> = db.entries_of_kind(kind).collect();
entries.sort_by(|a, b| a.path.cmp(&b.path));
@@ -4875,10 +5025,16 @@ fn asset_browser(ui: &mut egui::Ui, db: &AssetDatabase) {
for entry in entries {
// Show the leaf name; the full relative path on hover.
let leaf = entry.path.rsplit('/').next().unwrap_or(&entry.path);
ui.label(leaf).on_hover_text(&entry.path);
let resp = ui
.add(egui::Label::new(leaf).sense(egui::Sense::click()))
.on_hover_text(format!("{}\n(double-click to open)", entry.path));
if resp.double_clicked() {
open = Some(entry.uid);
}
}
});
}
open
}
/// A short label for a widget in the tree / property header: its id if set,
+28
View File
@@ -209,6 +209,20 @@ impl Default for UiDoc {
}
}
/// The settings section holding [`ExternalEditorPrefs`].
pub const EXTERNAL_EDITOR_SECTION: &str = "editor.external_editor";
/// Preferences for opening a script (or other text asset) in an editor —
/// registered as the [`EXTERNAL_EDITOR_SECTION`] settings section, editable in
/// Preferences, persisted to `~/.config/oxide/editor.ron` like the bindings.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct ExternalEditorPrefs {
/// Command to launch, invoked as `<command> <file>` (whitespace-split; may
/// carry its own flags, e.g. `"code -g"`). **Empty (the default) = auto**:
/// run `$VISUAL`/`$EDITOR` in an editor Terminal tab, else `xdg-open`.
pub command: String,
}
impl EditorState {
/// A blank state with an empty scene, no open project, and the editor's
/// default action bindings registered (`F` toggle, WASD/QE move, Shift
@@ -224,6 +238,7 @@ impl EditorState {
bindings::register_defaults(&mut actions);
let mut settings = Settings::new();
settings.register::<ActionOverrides>(bindings::SETTINGS_SECTION);
settings.register::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION);
let mut registry = TypeRegistry::new();
register_builtin_types(&mut registry);
// Seed a small, generally-useful set of named layers (besides the
@@ -590,4 +605,17 @@ mod tests {
.get::<oxide_physics::RigidBody>(restored)
.is_some());
}
#[test]
fn external_editor_section_is_registered_and_defaults_to_auto() {
let state = EditorState::new();
let prefs = state
.settings
.get::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION)
.expect("external-editor settings section must be registered");
assert!(
prefs.command.is_empty(),
"default is empty = auto ($VISUAL/$EDITOR terminal tab, else xdg-open)"
);
}
}