mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
# Conflicts: # README.md # README.zh-CN.md # crates/tty7-cli/src/cli.rs # crates/tty7-cli/src/server.rs # crates/tty7-core/src/core/config.rs # crates/tty7-core/src/core/git/status.rs # crates/tty7-core/src/daemon/install/wsl.rs # crates/tty7-core/src/daemon/protocol.rs # crates/tty7-core/src/daemon/spawn.rs # crates/tty7-core/src/daemon/ssh/mod.rs # src/terminal/completion.rs # src/terminal/remote.rs # src/ui/app.rs # src/ui/i18n/en.rs # src/ui/i18n/ja.rs # src/ui/i18n/zh.rs # src/ui/tree_sync.rs
1550 lines
54 KiB
Rust
1550 lines
54 KiB
Rust
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, Position, TabSize};
|
||
use gpui_component::menu::ContextMenuExt as _;
|
||
use gpui_component::{
|
||
ActiveTheme as _, Icon, IconName, Sizable as _, WindowExt as _, h_flex, v_flex,
|
||
};
|
||
|
||
use crate::ui::app::Tty7App;
|
||
use crate::ui::document_column::DocumentChrome;
|
||
use crate::ui::host_ops::{HostId, 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,
|
||
/// The machine `path` lives on, held rather than looked up. Saves,
|
||
/// reloads and duplicate detection all key on its id — an SFTP file and a
|
||
/// local file can share the string `/etc/hosts` without being the same
|
||
/// file — and saving goes straight through this handle, so a buffer stays
|
||
/// saveable however the window's own machine has changed underneath it.
|
||
pub(crate) host: SharedHost,
|
||
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 {
|
||
/// Where to put the cursor once a particular file is on screen, for a
|
||
/// `file.rs:120:3` that has to load first. Carried rather than applied at
|
||
/// the call site because opening is asynchronous: the click is long over
|
||
/// by the time there is a buffer to put a cursor in.
|
||
pending_cursor: Option<(PathBuf, u32, u32)>,
|
||
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 {
|
||
pending_cursor: None,
|
||
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",
|
||
}
|
||
}
|
||
|
||
/// How many frames a jump-to-line may wait for the editor to be laid out.
|
||
///
|
||
/// `InputState::scroll_to` gives up when the buffer has never been painted,
|
||
/// and that is exactly the state a file that just opened is in: the cursor
|
||
/// lands on the right line and the view stays at the top of the file, which
|
||
/// is the one thing a `:120` link exists to avoid. Three frames is the same
|
||
/// bounded-retry shape `prefill::select_all_when_drawn` uses against the same
|
||
/// class of problem.
|
||
const CURSOR_SCROLL_ATTEMPTS: u8 = 3;
|
||
|
||
/// Puts the cursor at `position`, re-trying on later frames until the scroll
|
||
/// that follows it can actually be computed.
|
||
///
|
||
/// Applied immediately as well as on the retry: the cursor itself lands with
|
||
/// no layout, so the position is right even for a pane that never paints.
|
||
fn place_cursor(
|
||
input: Entity<InputState>,
|
||
position: Position,
|
||
left: u8,
|
||
window: &mut Window,
|
||
cx: &mut gpui::App,
|
||
) {
|
||
input.update(cx, |state, cx| {
|
||
state.set_cursor_position(position, window, cx);
|
||
});
|
||
// A target near the top of the file scrolls nowhere and is already done;
|
||
// so is one that has landed. Either way this stops.
|
||
if left <= 1 || input.read(cx).scroll_offset().y != px(0.) {
|
||
return;
|
||
}
|
||
window.on_next_frame(move |window, cx| {
|
||
place_cursor(input, position, left - 1, window, cx);
|
||
});
|
||
// Registering a callback does not by itself ask for a frame, and an
|
||
// overlay that has finished drawing has no other reason to produce one.
|
||
window.refresh();
|
||
}
|
||
|
||
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>) {
|
||
// Only files on the host the watch itself runs on. A path from
|
||
// another machine — an SFTP file, say — does not exist under that
|
||
// watcher's feet, and would either miss or, worse, match a local file
|
||
// that happens to share its name.
|
||
//
|
||
// A host that cannot watch therefore gets no external-change
|
||
// detection at all: an SFTP buffer will not notice the file changing
|
||
// underneath it, and saving overwrites whatever is there. Catching
|
||
// that at save time needs a "keep mine" that survives to the next
|
||
// save, which the conflict banner does not have yet.
|
||
let watch_host = self.spawn_host(cx);
|
||
let files: HashSet<PathBuf> = self
|
||
.tabs
|
||
.iter()
|
||
.filter_map(|t| t.code.as_deref())
|
||
.flat_map(|c| c.files.iter())
|
||
.filter(|f| f.host.id() == watch_host)
|
||
.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);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
/// Shows what a file link in the grid pointed at.
|
||
///
|
||
/// A file opens in the editor, on the line the link named; the tree
|
||
/// follows along so "what does it say?" and "where does it live?" are
|
||
/// answered by the same click. A directory has no contents to show, so it
|
||
/// is only ever the tree.
|
||
pub(crate) fn open_linked_file(
|
||
&mut self,
|
||
path: &Path,
|
||
line: Option<u32>,
|
||
column: Option<u32>,
|
||
is_dir: bool,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
if is_dir {
|
||
self.file_tree_show(path, window, cx);
|
||
return;
|
||
}
|
||
self.open_file_in_editor_at(path, line, column, window, cx);
|
||
self.file_tree_reveal_path(path, cx);
|
||
}
|
||
|
||
/// [`Self::open_file_in_editor`], landing the cursor on a line the caller
|
||
/// already knows — what a `src/main.rs:120:3` in the grid was pointing at.
|
||
pub(crate) fn open_file_in_editor_at(
|
||
&mut self,
|
||
path: &Path,
|
||
line: Option<u32>,
|
||
column: Option<u32>,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
self.editor.pending_cursor =
|
||
line.map(|line| (path.to_path_buf(), line, column.unwrap_or(1)));
|
||
self.open_file_in_editor(path, window, cx);
|
||
}
|
||
|
||
/// Moves the cursor to the position a link asked for, if the file it asked
|
||
/// about is the one that just opened. Anything else — a different file
|
||
/// opened in between, a file that never arrived — drops the request rather
|
||
/// than throwing the cursor somewhere it was never meant to go.
|
||
fn apply_pending_cursor(
|
||
&mut self,
|
||
host: HostId,
|
||
requested: &Path,
|
||
opened: &Path,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
// Peeked before it is taken: another file opening in the meantime must
|
||
// not swallow a request that was never about it.
|
||
if self
|
||
.editor
|
||
.pending_cursor
|
||
.as_ref()
|
||
.is_none_or(|(wanted, ..)| wanted != requested)
|
||
{
|
||
return;
|
||
}
|
||
let Some((_, line, column)) = self.editor.pending_cursor.take() else {
|
||
return;
|
||
};
|
||
let Some(file) = self.tab_code().and_then(|c| {
|
||
c.files
|
||
.iter()
|
||
.find(|f| f.host.id() == host && f.path == *opened)
|
||
}) else {
|
||
return;
|
||
};
|
||
let input = file.input.clone();
|
||
// The grid counts from one and `Position` counts from zero, and a
|
||
// compiler that says "line 1" means the first line either way.
|
||
let position = Position {
|
||
line: line.saturating_sub(1),
|
||
character: column.saturating_sub(1),
|
||
};
|
||
place_cursor(input, position, CURSOR_SCROLL_ATTEMPTS, window, cx);
|
||
}
|
||
|
||
pub(crate) fn open_file_in_editor(
|
||
&mut self,
|
||
path: &Path,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
let Some(host) = self.active_host(cx) else {
|
||
return;
|
||
};
|
||
self.editor_open_on_host(host, path, window, cx);
|
||
}
|
||
|
||
/// [`Self::open_file_in_editor`] against an explicit host — the SFTP
|
||
/// browser's files live on a host that is never the active one.
|
||
pub(crate) fn editor_open_on_host(
|
||
&mut self,
|
||
host: SharedHost,
|
||
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(host.id(), path, window, cx) {
|
||
return;
|
||
}
|
||
let host_id = host.id();
|
||
let p = path.to_path_buf();
|
||
let requested = p.clone();
|
||
HostOps::run_in(
|
||
host.clone(),
|
||
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", &crate::ui::host_ops::explain_io(&e)),
|
||
],
|
||
));
|
||
}
|
||
};
|
||
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", &crate::ui::host_ops::explain_io(&e)),
|
||
],
|
||
));
|
||
}
|
||
};
|
||
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(host, path.clone(), text, mtime, window, cx);
|
||
// Against `requested`, not `path`: the host canonicalised
|
||
// it on the way through, and a link that named a symlink
|
||
// would otherwise lose the line it asked for.
|
||
app.apply_pending_cursor(host_id, &requested, &path, window, cx);
|
||
}
|
||
Err(message) => window.push_notification(message, cx),
|
||
},
|
||
);
|
||
}
|
||
|
||
fn editor_activate_open(
|
||
&mut self,
|
||
host: HostId,
|
||
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.host.id() == host && 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);
|
||
self.apply_pending_cursor(host, path, path, window, cx);
|
||
cx.notify();
|
||
true
|
||
}
|
||
|
||
fn editor_install_file(
|
||
&mut self,
|
||
host: SharedHost,
|
||
path: PathBuf,
|
||
text: String,
|
||
mtime: Option<MTime>,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
let host_id = host.id();
|
||
if self.editor_activate_open(host_id, &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.host.id() == host_id && 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,
|
||
host,
|
||
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>,
|
||
) {
|
||
// The file's own host, not the active one: the buffer keeps pointing
|
||
// at the machine it was read from, however the focus has moved since.
|
||
let Some(host) = self.editor_file_mut(id).map(|f| f.host.clone()) 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();
|
||
let host_id = host.id();
|
||
let saved_in = target.parent().map(std::path::Path::to_path_buf);
|
||
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),
|
||
);
|
||
let wrote = result.is_ok();
|
||
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;
|
||
}
|
||
// A save is a working-tree edit the `.git` watch cannot see,
|
||
// and the file tree only sees it while it happens to be showing
|
||
// that directory.
|
||
if wrote && let Some(dir) = &saved_in {
|
||
app.scm_invalidate_cwd(host_id, dir, cx);
|
||
}
|
||
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 host_id = host.id();
|
||
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(host_id, &landed, mtime, window, cx)
|
||
},
|
||
);
|
||
}
|
||
|
||
fn editor_apply_external_change(
|
||
&mut self,
|
||
host: HostId,
|
||
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.host.id() != host || 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 host = f.host.clone();
|
||
let id = f.input.entity_id();
|
||
f.reload_seq = f.reload_seq.wrapping_add(1);
|
||
let seq = f.reload_seq;
|
||
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,
|
||
chrome: DocumentChrome,
|
||
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 header = chrome
|
||
.renders_own_header()
|
||
.then(|| self.render_editor_header(chrome, window, cx));
|
||
let editor_col = v_flex()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.h_full()
|
||
.children(header)
|
||
.when_some(conflict_banner, |this, b| this.child(b))
|
||
.child(div().flex_1().min_h_0().child(body));
|
||
|
||
// The panel's own paint is the same either way; only the box is not.
|
||
// Filling the workspace means stopping the window's translucency and
|
||
// repainting the theme image the root's copy now sits under; docking
|
||
// means sitting in the same plane as the right panel, which the column
|
||
// wrapper has already painted.
|
||
let shell = v_flex().id("code-panel");
|
||
let shell = match chrome {
|
||
DocumentChrome::Fill => shell
|
||
.absolute()
|
||
.inset_0()
|
||
.occlude()
|
||
// Opaque on purpose: this overlay covers the whole workspace
|
||
// (everything but the detail panel) and an open file must never
|
||
// let the window translucency / backdrop material show through
|
||
// it. The preset's gradient fill is preserved, just with
|
||
// alpha 1 — the same paint the settings overlay uses. The
|
||
// theme background image is repainted on top of it, since the
|
||
// root's copy now sits below this fill.
|
||
.bg(crate::ui::theme::overlay_background(cx))
|
||
.children(crate::ui::app::overlay_surface_layers(cx)),
|
||
DocumentChrome::Dock | DocumentChrome::DockHoisted => shell.size_full().min_w_0(),
|
||
};
|
||
Some(
|
||
shell
|
||
.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(),
|
||
)
|
||
}
|
||
|
||
/// The editor header alone, for the strip above a docked column.
|
||
pub(crate) fn render_editor_header_only(
|
||
&self,
|
||
chrome: DocumentChrome,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) -> gpui::AnyElement {
|
||
self.render_editor_header(chrome, window, cx)
|
||
.into_any_element()
|
||
}
|
||
|
||
fn render_editor_header(
|
||
&self,
|
||
chrome: DocumentChrome,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) -> impl IntoElement + use<> {
|
||
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);
|
||
// `TITLE_BAR_LEAD` is the room macOS's traffic lights need. Only a
|
||
// header that starts at the left edge of the window has them to clear,
|
||
// and a docked column never does.
|
||
let lead = if self.left_panel_open(cx) || chrome.is_dock() {
|
||
crate::ui::app::CONTENT_INSET
|
||
} else {
|
||
crate::ui::app::TITLE_BAR_LEAD
|
||
};
|
||
let row = h_flex().id("editor-header");
|
||
let row = if chrome.header_is_title_strip() {
|
||
crate::ui::app::title_bar_drag(row, "editor-header", window, cx)
|
||
} else {
|
||
row
|
||
};
|
||
let menu_app = cx.entity().downgrade();
|
||
row.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);
|
||
})),
|
||
),
|
||
)
|
||
.context_menu(move |menu, _window, cx| {
|
||
Tty7App::document_header_menu(menu, &menu_app, cx)
|
||
})
|
||
}
|
||
|
||
fn render_code_status_bar(&self, _window: &Window, cx: &mut Context<Self>) -> gpui::Div {
|
||
// The roots below belong to this window's own machine. A file read
|
||
// over SFTP is on another one, where they mean nothing, so it shows
|
||
// its own full path rather than borrowing the local repo's name.
|
||
let tree_host = self.spawn_host(cx);
|
||
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) if f.host.id() != tree_host => f.path.display().to_string().into(),
|
||
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
|
||
}
|
||
);
|
||
}
|
||
}
|