Files
tty7/src/ui/code_editor.rs
T
l0ng-ai 6b8194bd67 fix(editor): show the file tree the empty editor tells you to use
Opening the code panel with no file draws "Open a file from the file
tree" and hands the tree keyboard focus — but nothing put the tree on
screen. On a fresh tab ⌘⇧E gave you an empty editor across the whole
content area, naming a panel you could neither see nor reach from there;
⌘J did not help, because the code panel had the space.

The branch that reaches for the tree now reveals it first, using the
file_tree_on_screen check that already existed. Verified end to end:
⌘⇧E opens the editor with the tree beside it, and clicking a file in it
loads the file.
2026-08-08 23:22:34 +07:00

1309 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use gpui::prelude::*;
use gpui::{
AnyElement, Context, Entity, Focusable as _, PromptLevel, SharedString, Subscription, Window,
div, px,
};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::input::{Input, InputEvent, InputState, TabSize};
use gpui_component::{
ActiveTheme as _, Icon, IconName, Sizable as _, WindowExt as _, h_flex, v_flex,
};
use crate::ui::app::Tty7App;
use crate::ui::host_ops::{HostOps, MTime, SharedHost, WatchSub};
use crate::ui::i18n::{L10nKey, t, t_fmt};
const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024;
const RELOAD_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200);
pub(crate) struct OpenFile {
pub(crate) path: PathBuf,
pub(crate) input: Entity<InputState>,
pub(crate) dirty: bool,
disk_mtime: Option<MTime>,
edit_seq: u64,
saving: Option<u64>,
save_pending: bool,
save_then_close: bool,
reload_seq: u64,
pub(crate) conflict: bool,
pub(crate) preview: bool,
pub(crate) wrap: bool,
/// The rendered-Markdown pane's own scroll. Per file, so switching away
/// and back lands where you were reading — and so the pane can carry the
/// scrollbar every other scrolling surface in tty7 has. The editor itself
/// gets one from `Input`.
pub(crate) preview_scroll: gpui::ScrollHandle,
_sub: Subscription,
_observe: Subscription,
}
impl OpenFile {
fn label(&self) -> SharedString {
self.path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| self.path.display().to_string())
.into()
}
}
pub(crate) struct TabCode {
pub(crate) visible: bool,
pub(crate) files: Vec<OpenFile>,
pub(crate) active: usize,
pub(crate) roots: Vec<PathBuf>,
pub(crate) expanded: std::collections::HashSet<PathBuf>,
pub(crate) selected: Option<PathBuf>,
}
impl TabCode {
pub(crate) fn new() -> Self {
Self {
visible: false,
files: Vec::new(),
active: 0,
roots: Vec::new(),
expanded: std::collections::HashSet::new(),
selected: None,
}
}
pub(crate) fn active_file(&self) -> Option<&OpenFile> {
self.files.get(self.active)
}
}
pub(crate) struct EditorPanelState {
watch: Option<Arc<WatchSub>>,
watch_host: Option<SharedHost>,
watch_opening: bool,
watch_busy: bool,
watch_dirty: bool,
watched_dirs: HashSet<PathBuf>,
watched_files: HashSet<PathBuf>,
events_tx: smol::channel::Sender<Vec<PathBuf>>,
}
impl EditorPanelState {
pub(crate) fn new(window: &mut Window, cx: &mut Context<Tty7App>) -> Self {
let (tx, rx) = smol::channel::unbounded::<Vec<PathBuf>>();
cx.spawn_in(window, async move |app, cx| {
while let Ok(first) = rx.recv().await {
cx.background_executor().timer(RELOAD_DEBOUNCE).await;
let mut changed: HashSet<PathBuf> = first.into_iter().collect();
while let Ok(more) = rx.try_recv() {
changed.extend(more);
}
let ok = app.update_in(cx, |app, window, cx| {
for path in changed {
if app.editor.watched_files.contains(&path) {
app.editor_handle_external_change(&path, window, cx);
}
}
});
if ok.is_err() {
break;
}
}
})
.detach();
Self {
watch: None,
watch_host: None,
watch_opening: false,
watch_busy: false,
watch_dirty: false,
watched_dirs: HashSet::new(),
watched_files: HashSet::new(),
events_tx: tx,
}
}
}
pub(crate) fn language_for_path(path: &Path) -> &'static str {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
let lowered = name.to_ascii_lowercase();
match lowered.as_str() {
"makefile" | "gnumakefile" => return "make",
"cmakelists.txt" => return "cmake",
_ => {}
}
if lowered.starts_with('.') && (lowered.contains("shrc") || lowered.ends_with("profile")) {
return "bash";
}
}
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
return "text";
};
match ext.to_ascii_lowercase().as_str() {
"rs" => "rust",
"go" => "go",
"py" | "pyi" => "python",
"js" | "mjs" | "cjs" | "jsx" => "javascript",
"ts" | "mts" | "cts" => "typescript",
"tsx" => "tsx",
"json" | "jsonc" => "json",
"toml" => "toml",
"yaml" | "yml" => "yaml",
"html" | "htm" => "html",
"css" => "css",
"md" | "markdown" => "markdown",
"sh" | "bash" | "zsh" => "bash",
"c" | "h" => "c",
"cpp" | "cc" | "cxx" | "hpp" | "hh" => "cpp",
"java" => "java",
"kt" | "kts" => "kotlin",
"lua" => "lua",
"rb" => "ruby",
"php" => "php",
"sql" => "sql",
"swift" => "swift",
"scala" => "scala",
"zig" => "zig",
"proto" => "proto",
"diff" | "patch" => "diff",
"ex" | "exs" => "elixir",
"erb" => "erb",
"ejs" => "ejs",
"svelte" => "svelte",
"astro" => "astro",
"graphql" | "gql" => "graphql",
"cs" => "csharp",
"cmake" => "cmake",
_ => "text",
}
}
fn looks_binary(bytes: &[u8]) -> bool {
bytes.iter().take(8192).any(|b| *b == 0)
}
#[derive(Debug, PartialEq, Eq)]
enum ExternalChange {
Ignore,
Conflict,
Reload,
}
fn classify_external_change(
saving: bool,
dirty: bool,
disk_mtime: Option<MTime>,
observed: Option<MTime>,
) -> ExternalChange {
if saving {
return ExternalChange::Ignore;
}
if observed.is_some() && observed == disk_mtime {
return ExternalChange::Ignore;
}
if dirty {
ExternalChange::Conflict
} else {
ExternalChange::Reload
}
}
#[derive(Debug, PartialEq, Eq)]
struct SaveLanding {
clean: bool,
requeue: bool,
}
fn settle_save(ok: bool, wrote_seq: u64, current_seq: u64, pending: bool) -> SaveLanding {
SaveLanding {
clean: ok && wrote_seq == current_seq,
requeue: ok && pending,
}
}
impl Tty7App {
pub(crate) fn tab_code(&self) -> Option<&TabCode> {
self.tabs.get(self.active)?.code.as_deref()
}
pub(crate) fn tab_code_mut(&mut self) -> Option<&mut TabCode> {
self.tabs.get_mut(self.active)?.code.as_deref_mut()
}
pub(crate) fn tab_code_mut_or_init(&mut self) -> Option<&mut TabCode> {
let tab = self.tabs.get_mut(self.active)?;
Some(tab.code.get_or_insert_with(|| Box::new(TabCode::new())))
}
pub(crate) fn code_panel_visible(&self) -> bool {
self.tab_code().is_some_and(|c| c.visible)
}
fn editor_rebuild_watcher(&mut self, cx: &mut Context<Self>) {
let files: HashSet<PathBuf> = self
.tabs
.iter()
.filter_map(|t| t.code.as_deref())
.flat_map(|c| c.files.iter().map(|f| f.path.clone()))
.collect();
let dirs: HashSet<PathBuf> = files
.iter()
.filter_map(|p| p.parent().map(Path::to_path_buf))
.collect();
self.editor.watched_files = files;
if dirs == self.editor.watched_dirs {
return;
}
self.editor.watched_dirs = dirs;
self.editor_watch_apply(cx);
}
fn editor_watch_apply(&mut self, cx: &mut Context<Self>) {
let want: Vec<PathBuf> = self.editor.watched_dirs.iter().cloned().collect();
let Some(host) = self.active_host(cx) else {
return;
};
if !self
.editor
.watch_host
.as_ref()
.is_some_and(|opened_with| Arc::ptr_eq(opened_with, &host))
{
self.editor.watch = None;
self.editor.watch_host = None;
self.editor.watch_busy = false;
self.editor.watch_dirty = false;
}
if let Some(sub) = self.editor.watch.clone() {
if self.editor.watch_busy {
self.editor.watch_dirty = true;
return;
}
self.editor.watch_busy = true;
HostOps::run(
host,
cx,
move |_| sub.set_dirs(&want),
|app: &mut Self, result: std::io::Result<()>, cx| {
app.editor.watch_busy = false;
if let Err(e) = result {
log::warn!("editor: could not update the watched set: {e}");
}
if std::mem::take(&mut app.editor.watch_dirty) {
app.editor_watch_apply(cx);
}
},
);
return;
}
if self.editor.watch_opening {
return;
}
self.editor.watch_opening = true;
let opened_host = Arc::clone(&host);
let opened_with = self.editor.watched_dirs.clone();
HostOps::run(
host,
cx,
{
let want = want.clone();
move |h| h.watch(&want).map(Arc::new)
},
move |app, result: std::io::Result<Arc<WatchSub>>, cx| {
app.editor.watch_opening = false;
let sub = match result {
Ok(sub) => sub,
Err(e) => {
log::warn!("editor: external-change watcher unavailable: {e}");
return;
}
};
let events = sub.events().clone();
app.editor.watch = Some(sub);
app.editor.watch_host = Some(opened_host);
cx.spawn(async move |app, cx| {
while let Ok(batch) = events.recv().await {
let ok = app.update(cx, |app, _cx| {
let _ = app.editor.events_tx.try_send(batch);
});
if ok.is_err() {
break;
}
}
})
.detach();
if app.editor.watched_dirs != opened_with {
app.editor_watch_apply(cx);
}
},
);
}
pub(crate) fn open_file_in_editor(
&mut self,
path: &Path,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.tabs.get(self.active).is_none() {
return;
}
self.raise_code_overlay();
if self.editor_activate_open(path, window, cx) {
return;
}
let Some(host) = self.active_host(cx) else {
return;
};
let p = path.to_path_buf();
HostOps::run_in(
host,
window,
cx,
move |h| -> Result<(PathBuf, String, Option<MTime>), String> {
let path = h.canonicalize(&p).unwrap_or(p);
let meta = match h.stat(&path) {
Ok(m) => m,
Err(e) => {
return Err(t_fmt(
L10nKey::EditorCantOpen,
&[("path", &path.display().to_string()), ("e", &e.to_string())],
));
}
};
if meta.len > MAX_FILE_BYTES {
return Err(t_fmt(
L10nKey::EditorFileTooLarge,
&[
("path", &path.display().to_string()),
("size", &(meta.len / (1024 * 1024)).to_string()),
],
));
}
let bytes = match h.read_file(&path, MAX_FILE_BYTES) {
Ok(b) => b,
Err(e) => {
return Err(t_fmt(
L10nKey::EditorCantRead,
&[("path", &path.display().to_string()), ("e", &e.to_string())],
));
}
};
if looks_binary(&bytes) {
return Err(t_fmt(
L10nKey::EditorBinaryFile,
&[("path", &path.display().to_string())],
));
}
let text = String::from_utf8(bytes).map_err(|_| {
t_fmt(
L10nKey::EditorNotUtf8,
&[("path", &path.display().to_string())],
)
})?;
Ok((path, text, meta.mtime))
},
move |app, opened, window, cx| match opened {
Ok((path, text, mtime)) => app.editor_install_file(path, text, mtime, window, cx),
Err(message) => window.push_notification(message, cx),
},
);
}
fn editor_activate_open(
&mut self,
path: &Path,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
let Some(code) = self.tab_code_mut() else {
return false;
};
let Some(ix) = code.files.iter().position(|f| f.path == *path) else {
return false;
};
code.visible = true;
let f = code.files.remove(ix);
code.files.insert(0, f);
code.active = 0;
self.focus_editor(window, cx);
cx.notify();
true
}
fn editor_install_file(
&mut self,
path: PathBuf,
text: String,
mtime: Option<MTime>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.editor_activate_open(&path, window, cx) {
return;
}
if self.tabs.get(self.active).is_none() {
return;
}
let language = language_for_path(&path);
let input = cx.new(|cx| {
InputState::new(window, cx)
.code_editor(language)
.multi_line(true)
.tab_size(TabSize {
tab_size: 4,
hard_tabs: false,
})
.line_number(true)
.searchable(true)
.replaceable(true)
.folding(true)
.soft_wrap(false)
.default_value(text)
});
let sub = cx.subscribe_in(&input, window, {
let path = path.clone();
move |this: &mut Tty7App, _input, ev, _window, cx| {
if matches!(ev, InputEvent::Change) {
let Some(f) = this
.tabs
.iter_mut()
.filter_map(|t| t.code.as_deref_mut())
.flat_map(|c| c.files.iter_mut())
.find(|f| f.path == path)
else {
return;
};
f.dirty = true;
f.edit_seq = f.edit_seq.wrapping_add(1);
cx.notify();
}
}
});
let tab = self
.tabs
.get_mut(self.active)
.expect("checked at function entry");
let code = tab.code.get_or_insert_with(|| Box::new(TabCode::new()));
let observe = cx.observe(&input, |_, _, cx| cx.notify());
code.files.insert(
0,
OpenFile {
path,
input,
dirty: false,
disk_mtime: mtime,
edit_seq: 0,
saving: None,
save_pending: false,
save_then_close: false,
reload_seq: 0,
conflict: false,
preview: false,
wrap: false,
preview_scroll: gpui::ScrollHandle::new(),
_sub: sub,
_observe: observe,
},
);
code.active = 0;
code.visible = true;
self.editor_rebuild_watcher(cx);
self.focus_editor(window, cx);
cx.notify();
}
pub(crate) fn toggle_code_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(tab) = self.tabs.get_mut(self.active) else {
return;
};
let buried = tab.overlay_top == crate::ui::app::OverlayTop::Diff
&& tab.diff_overlay.is_some()
&& tab.code.as_ref().is_some_and(|c| c.visible);
tab.overlay_top = crate::ui::app::OverlayTop::Code;
if buried {
self.focus_editor(window, cx);
cx.notify();
return;
}
let Some(tab) = self.tabs.get_mut(self.active) else {
return;
};
let code = tab.code.get_or_insert_with(|| Box::new(TabCode::new()));
if code.visible {
code.visible = false;
self.file_tree.editing = None;
self.focus_active(window, cx);
cx.notify();
return;
}
code.visible = true;
self.file_tree_refresh_roots(window, cx);
if self.tab_code().is_some_and(|c| c.active_file().is_some()) {
self.focus_editor(window, cx);
} else {
// With no file to show, the panel says "Open a file from the file
// tree" and hands the tree the focus — but nothing was putting the
// tree on screen, so ⌘⇧E on a fresh tab opened an empty editor
// pointing at a panel the reader could not see or reach from
// there. Reveal it, then focus it.
if !self.file_tree_on_screen(cx) {
self.set_right_panel_tab(crate::core::config::RightPanelTab::Files, cx);
}
self.file_tree.focus_handle.focus(window, cx);
}
cx.notify();
}
fn raise_code_overlay(&mut self) {
if let Some(tab) = self.tabs.get_mut(self.active) {
tab.overlay_top = crate::ui::app::OverlayTop::Code;
}
}
fn focus_editor(&self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(f) = self.tab_code().and_then(|c| c.active_file()) {
f.input.update(cx, |input, cx| input.focus(window, cx));
}
}
pub(crate) fn editor_has_focus(&self, window: &Window, cx: &Context<Self>) -> bool {
self.code_panel_visible()
&& self
.tab_code()
.and_then(|c| c.active_file())
.is_some_and(|f| {
f.input
.read(cx)
.focus_handle(cx)
.contains_focused(window, cx)
})
}
pub(crate) fn editor_save_active(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(id) = self
.tab_code()
.and_then(|c| c.active_file())
.map(|f| f.input.entity_id())
else {
return;
};
self.editor_save_file(id, false, window, cx);
}
fn editor_save_file(
&mut self,
id: gpui::EntityId,
then_close: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(host) = self.active_host(cx) else {
return;
};
let Some(f) = self.editor_file_mut(id) else {
return;
};
f.save_then_close |= then_close;
if f.saving.is_some() {
f.save_pending = true;
return;
}
let seq = f.edit_seq;
f.saving = Some(seq);
let text = f.input.read(cx).text().to_string();
let target = f.path.clone();
HostOps::run_in(
host,
window,
cx,
move |h| h.write_file(&target, text.as_bytes()).map(|m| m.mtime),
move |app, result: std::io::Result<Option<MTime>>, window, cx| {
let Some(f) = app.editor_file_mut(id) else {
return;
};
f.saving = None;
let landing = settle_save(
result.is_ok(),
seq,
f.edit_seq,
std::mem::take(&mut f.save_pending),
);
match result {
Ok(mtime) => {
f.disk_mtime = mtime;
}
Err(e) => {
// "Save failed" did not say which file, and with more
// than one editor tab open that is the first thing you
// need to know.
let name = f
.path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| f.path.display().to_string());
let context = t_fmt(L10nKey::EditorSaveFailed, &[("name", &name)]);
HostOps::notify_err(window, cx, &context, &e);
}
}
if landing.clean {
f.dirty = false;
f.conflict = false;
}
if landing.requeue {
app.editor_save_file(id, false, window, cx);
cx.notify();
return;
}
let close = app
.editor_file_mut(id)
.is_some_and(|f| std::mem::take(&mut f.save_then_close) && !f.dirty);
if close && let Some((tab_ix, ix)) = app.editor_file_position(id) {
app.editor_remove_file_in(tab_ix, ix, cx);
}
cx.notify();
},
);
cx.notify();
}
fn editor_file_mut(&mut self, id: gpui::EntityId) -> Option<&mut OpenFile> {
self.tabs
.iter_mut()
.filter_map(|t| t.code.as_deref_mut())
.flat_map(|c| c.files.iter_mut())
.find(|f| f.input.entity_id() == id)
}
fn editor_file_position(&self, id: gpui::EntityId) -> Option<(usize, usize)> {
self.tabs.iter().enumerate().find_map(|(tab_ix, t)| {
let code = t.code.as_deref()?;
let ix = code.files.iter().position(|f| f.input.entity_id() == id)?;
Some((tab_ix, ix))
})
}
pub(crate) fn editor_close_file(
&mut self,
ix: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(f) = self.tab_code().and_then(|c| c.files.get(ix)) else {
return;
};
if !f.dirty {
self.editor_remove_file(ix, cx);
return;
}
let name = f.label();
let answer = window.prompt(
PromptLevel::Warning,
&t_fmt(L10nKey::EditorUnsavedChanges, &[("name", &name)]),
None,
// Cancel sits between Save and Discard on purpose. The platform
// renders the first button as the default and lays the rest out
// beside it, so Discard was landing directly next to the key that
// Return presses. Apple separates them for exactly this reason.
// Three answers, so the shared helper does not fit: Save keeps
// index 0 (rightmost, Return), Cancel takes Escape, and Discard
// sits on the far left where nothing lands by reflex.
&[
gpui::PromptButton::ok(t(L10nKey::Save)),
gpui::PromptButton::cancel(t(L10nKey::Cancel)),
gpui::PromptButton::ok(t(L10nKey::EditorDiscard)),
],
cx,
);
let id = f.input.entity_id();
cx.spawn_in(window, async move |app, cx| {
let Ok(choice) = answer.await else { return };
let _ = app.update_in(cx, |app, window, cx| match choice {
0 => app.editor_save_file(id, true, window, cx),
2 => {
if let Some((tab_ix, ix)) = app.editor_file_position(id) {
app.editor_remove_file_in(tab_ix, ix, cx);
}
}
_ => {}
});
})
.detach();
}
pub(crate) fn editor_close_active_if_focused(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
if !self.editor_has_focus(window, cx) {
return false;
}
let Some(code) = self.tab_code_mut() else {
return false;
};
if code.files.is_empty() {
code.visible = false;
cx.notify();
return true;
}
let active = code.active;
self.editor_close_file(active, window, cx);
true
}
fn editor_remove_file(&mut self, ix: usize, cx: &mut Context<Self>) {
self.editor_remove_file_in(self.active, ix, cx);
}
fn editor_remove_file_in(&mut self, tab_ix: usize, ix: usize, cx: &mut Context<Self>) {
let Some(code) = self
.tabs
.get_mut(tab_ix)
.and_then(|t| t.code.as_deref_mut())
else {
return;
};
if ix >= code.files.len() {
return;
}
code.files.remove(ix);
if code.active >= ix && code.active > 0 {
code.active -= 1;
}
self.editor_rebuild_watcher(cx);
cx.notify();
}
pub(crate) fn editor_handle_external_change(
&mut self,
path: &Path,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(host) = self.active_host(cx) else {
return;
};
let p = path.to_path_buf();
let landed = p.clone();
HostOps::run_in(
host,
window,
cx,
move |h| h.stat(&p).ok().and_then(|m| m.mtime),
move |app, mtime, window, cx| {
app.editor_apply_external_change(&landed, mtime, window, cx)
},
);
}
fn editor_apply_external_change(
&mut self,
path: &Path,
mtime: Option<MTime>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let mut reload: Vec<(usize, usize)> = Vec::new();
let mut changed = false;
for (tab_ix, tab) in self.tabs.iter_mut().enumerate() {
let Some(code) = tab.code.as_deref_mut() else {
continue;
};
for (ix, f) in code.files.iter_mut().enumerate() {
if f.path != *path {
continue;
}
match classify_external_change(f.saving.is_some(), f.dirty, f.disk_mtime, mtime) {
ExternalChange::Ignore => {}
ExternalChange::Conflict => {
f.conflict = true;
changed = true;
}
ExternalChange::Reload => reload.push((tab_ix, ix)),
}
}
}
for (tab_ix, ix) in reload {
self.editor_reload_from_disk(tab_ix, ix, window, cx);
}
if changed {
cx.notify();
}
}
pub(crate) fn editor_reload_from_disk(
&mut self,
tab_ix: usize,
ix: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(f) = self
.tabs
.get_mut(tab_ix)
.and_then(|t| t.code.as_deref_mut())
.and_then(|c| c.files.get_mut(ix))
else {
return;
};
let target = f.path.clone();
let id = f.input.entity_id();
f.reload_seq = f.reload_seq.wrapping_add(1);
let seq = f.reload_seq;
let Some(host) = self.active_host(cx) else {
return;
};
HostOps::run_in(
host,
window,
cx,
move |h| {
let bytes = h.read_file(&target, MAX_FILE_BYTES)?;
let text = String::from_utf8(bytes).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidData, "not valid UTF-8")
})?;
let mtime = h.stat(&target).ok().and_then(|m| m.mtime);
Ok((text, mtime))
},
move |app, result: std::io::Result<(String, Option<MTime>)>, window, cx| {
let Some(f) = app.editor_file_mut(id) else {
return;
};
if f.reload_seq != seq {
return;
}
let Ok((text, mtime)) = result else {
f.dirty = true;
f.conflict = false;
cx.notify();
return;
};
f.disk_mtime = mtime;
f.dirty = false;
f.conflict = false;
f.edit_seq = f.edit_seq.wrapping_add(1);
let input = f.input.clone();
input.update(cx, |input, cx| input.set_value(text, window, cx));
cx.notify();
},
);
}
}
impl Tty7App {
pub(crate) fn render_code_overlay(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
if !self.code_panel_visible() {
return None;
}
let body = match self.tab_code().and_then(|c| c.active_file()) {
None => self.render_editor_empty(cx).into_any_element(),
Some(f) if f.preview => {
let markdown = f.input.read(cx).text().to_string();
let scroll = f.preview_scroll.clone();
// The bar's wrapper takes its height from `flex_1`, so it needs
// a column with a definite height to grow inside — hand it one
// rather than dropping it straight into the overlay, or the
// pane sizes to its content and there is nothing left to
// scroll.
v_flex()
.size_full()
.child(crate::ui::scrollbar::with_vertical_scrollbar(
"editor-md-preview-scrollbar",
div()
.id("editor-md-preview")
.size_full()
.overflow_y_scroll()
.track_scroll(&scroll)
.px_4()
.py_3()
.child(gpui_component::text::TextView::markdown(
"editor-md-preview-body",
markdown,
)),
&scroll,
))
.into_any_element()
}
Some(f) => {
let input = f.input.clone();
Input::new(&input)
.appearance(false)
.font_family(cx.theme().mono_font_family.clone())
.text_size(cx.theme().mono_font_size)
.size_full()
.into_any_element()
}
};
let conflict_banner = self
.tab_code()
.and_then(|c| c.active_file())
.filter(|f| f.conflict)
.map(|_| self.render_editor_conflict_banner(cx));
let editor_col = v_flex()
.flex_1()
.min_w_0()
.h_full()
.child(self.render_editor_header(window, cx))
.when_some(conflict_banner, |this, b| this.child(b))
.child(div().flex_1().min_h_0().child(body));
Some(
v_flex()
.id("code-panel")
.absolute()
.inset_0()
.occlude()
.bg(cx.theme().background)
.on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| {
if ev.keystroke.key == "escape" {
this.toggle_code_panel(window, cx);
}
}))
.child(h_flex().flex_1().min_h_0().w_full().child(editor_col))
.child(self.render_code_status_bar(window, cx))
.into_any_element(),
)
}
fn render_editor_header(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> gpui::Stateful<gpui::Div> {
let active = self.tab_code().and_then(|c| c.active_file());
let name = active.map(|f| f.label());
let dirty = active.is_some_and(|f| f.dirty);
let lead = if self.left_panel_open(cx) {
crate::ui::app::CONTENT_INSET
} else {
crate::ui::app::TITLE_BAR_LEAD
};
crate::ui::app::title_bar_drag(h_flex().id("editor-header"), "editor-header", window, cx)
.flex_none()
.h(px(crate::ui::app::TITLE_BAR_HEIGHT))
.items_center()
.gap_1p5()
.pl(px(lead))
.pr(px(crate::ui::app::tile_trailing_inset()))
.border_b_1()
.border_color(cx.theme().border)
.child(
div()
.flex_1()
.min_w_0()
.text_ellipsis()
.text_sm()
.when(name.is_none(), |d| {
d.text_color(cx.theme().muted_foreground)
})
.child(
name.unwrap_or_else(|| SharedString::from(t(L10nKey::EditorNoFileOpen))),
),
)
.when(dirty, |d| {
d.child(
div()
.flex_none()
.size(px(6.))
.rounded_full()
.bg(cx.theme().warning),
)
})
.child(
div().occlude().flex_shrink_0().child(
crate::ui::tab_strip::chrome_tile_sized(
Button::new("editor-panel-close").icon(Icon::new(IconName::Close)),
crate::ui::app::TILE_SIZE,
crate::ui::app::TILE_GLYPH_LINE,
false,
cx,
)
.rounded_lg()
.tooltip(t(L10nKey::EditorBackToTerminal))
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_code_panel(window, cx);
})),
),
)
}
fn render_code_status_bar(&self, _window: &Window, cx: &mut Context<Self>) -> gpui::Div {
let code = self.tab_code();
let muted = cx.theme().muted_foreground;
let path_text: Option<SharedString> = code.map(|c| {
let repo = c
.roots
.first()
.and_then(|r| r.file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
match c.active_file() {
Some(f) => {
let rel = c
.roots
.iter()
.find_map(|r| f.path.strip_prefix(r).ok())
.map(|p| p.display().to_string())
.unwrap_or_else(|| f.label().to_string());
format!("{repo} {rel}").into()
}
None => repo.into(),
}
});
let active = code.and_then(|c| c.active_file());
let cursor: Option<SharedString> = active.map(|f| {
let pos = f.input.read(cx).cursor_position();
t_fmt(
L10nKey::EditorLnCol,
&[
("line", &(pos.line + 1).to_string()),
("column", &(pos.character + 1).to_string()),
],
)
.into()
});
let wrap: Option<bool> = active.map(|f| f.wrap);
let is_markdown = active.is_some_and(|f| language_for_path(&f.path) == "markdown");
let preview = active.is_some_and(|f| f.preview);
h_flex()
.flex_none()
.w_full()
.h(px(26.))
.items_center()
.gap_3()
.px_3()
.border_t_1()
.border_color(cx.theme().border)
.text_xs()
.text_color(muted)
.when_some(path_text, |this, t| {
this.child(div().min_w_0().text_ellipsis().child(t))
})
.child(div().flex_1())
.when(is_markdown, |this| {
this.child(
Button::new("status-md-preview")
.label(if preview {
t(L10nKey::EditorEdit)
} else {
t(L10nKey::EditorPreview)
})
.custom(crate::ui::tab_strip::chrome_tile_variant(cx))
.xsmall()
.on_click(cx.listener(|this, _, _w, cx| {
if let Some(code) = this.tab_code_mut() {
let ix = code.active;
if let Some(f) = code.files.get_mut(ix) {
f.preview = !f.preview;
cx.notify();
}
}
})),
)
})
.when_some(wrap, |this, wrap| {
this.child(
Button::new("status-wrap")
.label(if wrap {
t(L10nKey::EditorWrapOn)
} else {
t(L10nKey::EditorWrapOff)
})
.custom(crate::ui::tab_strip::chrome_tile_variant(cx))
.xsmall()
.on_click(cx.listener(|this, _, window, cx| {
let Some(code) = this.tab_code_mut() else {
return;
};
let ix = code.active;
if let Some(f) = code.files.get_mut(ix) {
f.wrap = !f.wrap;
let wrap = f.wrap;
f.input.clone().update(cx, |st, cx| {
st.set_soft_wrap(wrap, window, cx);
});
}
})),
)
})
.when_some(cursor, |this, t| this.child(div().child(t)))
}
fn render_editor_empty(&self, cx: &Context<Self>) -> gpui::Div {
v_flex()
.size_full()
.items_center()
.justify_center()
.gap_2()
.child(
Icon::new(IconName::File)
.large()
.text_color(cx.theme().muted_foreground),
)
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(crate::ui::i18n::t(
crate::ui::i18n::L10nKey::OpenFileFromTree,
)),
)
}
fn render_editor_conflict_banner(&self, cx: &mut Context<Self>) -> AnyElement {
let tab_ix = self.active;
let ix = self.tab_code().map(|c| c.active).unwrap_or(0);
h_flex()
.flex_none()
.w_full()
.items_center()
.gap_2()
.px_2()
.py_1()
.bg(cx.theme().warning.opacity(0.15))
.border_b_1()
.border_color(cx.theme().border)
.text_sm()
.child(div().flex_1().child(crate::ui::i18n::t(
crate::ui::i18n::L10nKey::FileChangedOnDisk,
)))
.child(
Button::new("editor-conflict-reload")
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Reload))
.small()
.on_click(cx.listener(move |this, _, window, cx| {
this.editor_reload_from_disk(tab_ix, ix, window, cx);
})),
)
.child(
Button::new("editor-conflict-keep")
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::KeepMine))
.ghost()
.small()
.on_click(cx.listener(move |this, _, _w, cx| {
if let Some(f) = this.tab_code_mut().and_then(|c| c.files.get_mut(ix)) {
f.conflict = false;
cx.notify();
}
})),
)
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn language_map_covers_common_extensions() {
for (path, lang) in [
("a/b/main.rs", "rust"),
("x.tsx", "tsx"),
("x.jsx", "javascript"),
("x.yml", "yaml"),
("Makefile", "make"),
("CMakeLists.txt", "cmake"),
(".zshrc", "bash"),
("notes.md", "markdown"),
("query.SQL", "sql"),
("unknown.xyz", "text"),
("no_ext", "text"),
] {
assert_eq!(language_for_path(Path::new(path)), lang, "path {path}");
}
}
#[test]
fn binary_sniff_flags_nul_bytes_only() {
assert!(looks_binary(b"\x7fELF\x00\x01"));
assert!(!looks_binary("plain text\nwith lines".as_bytes()));
assert!(!looks_binary("中文 UTF-8 内容".as_bytes()));
}
fn t(secs: i64, nanos: u32) -> Option<MTime> {
Some(MTime { secs, nanos })
}
#[test]
fn external_changes_are_told_apart_from_our_own_saves() {
let ours = t(100, 0);
assert_eq!(
classify_external_change(false, false, ours, ours),
ExternalChange::Ignore
);
assert_eq!(
classify_external_change(false, false, ours, t(101, 0)),
ExternalChange::Reload
);
assert_eq!(
classify_external_change(false, true, ours, t(101, 0)),
ExternalChange::Conflict
);
assert_eq!(
classify_external_change(false, false, t(100, 0), t(100, 1)),
ExternalChange::Reload
);
assert_eq!(
classify_external_change(true, false, ours, t(101, 0)),
ExternalChange::Ignore
);
assert_eq!(
classify_external_change(false, false, None, None),
ExternalChange::Reload
);
}
#[test]
fn a_landed_save_only_cleans_a_buffer_that_did_not_move() {
assert_eq!(
settle_save(true, 7, 7, false),
SaveLanding {
clean: true,
requeue: false
}
);
assert_eq!(
settle_save(true, 7, 9, false),
SaveLanding {
clean: false,
requeue: false
}
);
assert_eq!(
settle_save(true, 7, 9, true),
SaveLanding {
clean: false,
requeue: true
}
);
assert_eq!(
settle_save(false, 7, 7, true),
SaveLanding {
clean: false,
requeue: false
}
);
}
}