first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,22 +1,22 @@
|
||||
//! Module containing the definition of [`ActiveFileModel`],
|
||||
//! which tracks the currently focused file across an entire PaneGroup.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use galaxyui::{Entity, ModelContext};
|
||||
|
||||
use super::buffer_location::LocalOrRemotePath;
|
||||
|
||||
/// Events emitted by the ActiveFileModel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ActiveFileEvent {
|
||||
/// A new file became focused.
|
||||
ActiveFileChanged { file_info: PathBuf },
|
||||
ActiveFileChanged { location: LocalOrRemotePath },
|
||||
}
|
||||
|
||||
/// Model that tracks the currently focused file.
|
||||
#[derive(Default)]
|
||||
pub struct ActiveFileModel {
|
||||
/// The currently focused file, if any.
|
||||
active_file: Option<PathBuf>,
|
||||
active_file: Option<LocalOrRemotePath>,
|
||||
}
|
||||
|
||||
impl Entity for ActiveFileModel {
|
||||
@@ -29,16 +29,20 @@ impl ActiveFileModel {
|
||||
}
|
||||
|
||||
/// Get the currently active file, if any.
|
||||
pub fn active_file(&self) -> Option<&PathBuf> {
|
||||
pub fn active_file(&self) -> Option<&LocalOrRemotePath> {
|
||||
self.active_file.as_ref()
|
||||
}
|
||||
|
||||
/// Set the currently active file.
|
||||
pub fn active_file_changed(&mut self, path: PathBuf, ctx: &mut ModelContext<Self>) {
|
||||
pub fn active_file_changed(
|
||||
&mut self,
|
||||
location: LocalOrRemotePath,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Only emit event if the active file changed.
|
||||
if self.active_file.as_ref() != Some(&path) {
|
||||
self.active_file = Some(path.clone());
|
||||
ctx.emit(ActiveFileEvent::ActiveFileChanged { file_info: path });
|
||||
if self.active_file.as_ref() != Some(&location) {
|
||||
self.active_file = Some(location.clone());
|
||||
ctx.emit(ActiveFileEvent::ActiveFileChanged { location });
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
use warp_util::content_version::ContentVersion;
|
||||
// Re-export from warp_util so existing app-level imports continue to work.
|
||||
pub use warp_util::local_or_remote_path::LocalOrRemotePath;
|
||||
|
||||
/// Tracks sync state between client and server for a single remote buffer.
|
||||
///
|
||||
/// Uses a version vector with two components:
|
||||
/// - `server_version`: bumped by the server when the file changes on disk.
|
||||
/// - `client_version`: bumped by the client when the user edits the buffer.
|
||||
///
|
||||
/// Conflict detection:
|
||||
/// - Server pushes `{S_new, C_expected}`. Client checks `C_expected == local client_version`.
|
||||
/// Match → accept. Mismatch → conflict.
|
||||
/// - Client sends `{S_expected, C_new}`. Server checks `S_expected == local server_version`.
|
||||
/// Match → accept. Mismatch → reject (server pushes its current state).
|
||||
///
|
||||
/// Both fields use `ContentVersion` internally. At the wire boundary (proto
|
||||
/// encode/decode), convert via `ContentVersion::as_u64()` and
|
||||
/// `ContentVersion::from_raw()`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SyncClock {
|
||||
/// Last version acknowledged from the server (file-watcher side).
|
||||
pub server_version: ContentVersion,
|
||||
/// Last version acknowledged from the client (user-edit side).
|
||||
pub client_version: ContentVersion,
|
||||
}
|
||||
|
||||
impl SyncClock {
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
server_version: ContentVersion::from_raw(0),
|
||||
client_version: ContentVersion::from_raw(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct a `SyncClock` from wire values (proto deserialization).
|
||||
pub fn from_wire(server_version: u64, client_version: u64) -> Self {
|
||||
Self {
|
||||
server_version: ContentVersion::from_raw(server_version as usize),
|
||||
client_version: ContentVersion::from_raw(client_version as usize),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bump the server version after a file-watcher change.
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub fn bump_server(&mut self) -> ContentVersion {
|
||||
self.server_version = ContentVersion::new();
|
||||
self.server_version
|
||||
}
|
||||
|
||||
/// Check whether a server push's expected client version matches our local state.
|
||||
pub fn server_push_matches(&self, expected_client_version: ContentVersion) -> bool {
|
||||
self.client_version == expected_client_version
|
||||
}
|
||||
|
||||
/// Check whether a client edit's expected server version matches our local state.
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub fn client_edit_matches(&self, expected_server_version: ContentVersion) -> bool {
|
||||
self.server_version == expected_server_version
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "buffer_location_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,845 @@
|
||||
use lsp::LspManagerModel;
|
||||
use remote_server::proto::TextEdit;
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use repo_metadata::watcher::DirectoryWatcher;
|
||||
use repo_metadata::RepoMetadataModel;
|
||||
use warp_files::FileModel;
|
||||
use warp_util::content_version::ContentVersion;
|
||||
use warp_util::host_id::HostId;
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
use warpui::{App, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::code::global_buffer_model::{CharOffsetEdit, GlobalBufferModel, GlobalBufferModelEvent};
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
|
||||
// ── Test setup ────────────────────────────────────────────────────
|
||||
|
||||
/// Minimum singletons required by `GlobalBufferModel::new`.
|
||||
fn init_app(app: &mut App) {
|
||||
initialize_settings_for_tests(app);
|
||||
app.add_singleton_model(|_| LspManagerModel::new());
|
||||
app.add_singleton_model(DirectoryWatcher::new);
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
app.add_singleton_model(FileModel::new);
|
||||
}
|
||||
|
||||
/// Returns the `GlobalBufferModel` singleton handle.
|
||||
fn gbm(app: &App) -> ModelHandle<GlobalBufferModel> {
|
||||
GlobalBufferModel::handle(app)
|
||||
}
|
||||
|
||||
/// Reads the text content of a buffer tracked by `GlobalBufferModel`.
|
||||
fn content(app: &App, file_id: warp_util::file::FileId) -> String {
|
||||
let handle = gbm(app);
|
||||
app.read(|ctx| {
|
||||
handle
|
||||
.as_ref(ctx)
|
||||
.content_for_file(file_id, ctx)
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the server_version from the ServerLocal sync clock.
|
||||
fn server_version(app: &App, file_id: warp_util::file::FileId) -> ContentVersion {
|
||||
let handle = gbm(app);
|
||||
app.read(|ctx| {
|
||||
handle
|
||||
.as_ref(ctx)
|
||||
.sync_clock_for_server_local(file_id)
|
||||
.unwrap()
|
||||
.server_version
|
||||
})
|
||||
}
|
||||
|
||||
/// Helper: creates a proto `TextEdit` for use with `apply_client_edit`.
|
||||
/// `start` and `end` are 1-indexed character offsets (matching `CharOffset`).
|
||||
fn text_edit(start: u64, end: u64, text: &str) -> TextEdit {
|
||||
TextEdit {
|
||||
start_offset: start,
|
||||
end_offset: end,
|
||||
text: text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: creates a `CharOffsetEdit` for use with `handle_buffer_updated_push`.
|
||||
/// `start` and `end` are 1-indexed character offsets (matching `CharOffset`).
|
||||
fn char_edit(start: usize, end: usize, text: &str) -> CharOffsetEdit {
|
||||
CharOffsetEdit {
|
||||
start: string_offset::CharOffset::from(start),
|
||||
end: string_offset::CharOffset::from(end),
|
||||
text: text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_host_id() -> HostId {
|
||||
HostId::new("test-host".to_string())
|
||||
}
|
||||
|
||||
fn test_path() -> StandardizedPath {
|
||||
StandardizedPath::try_new("/test/file.txt").unwrap()
|
||||
}
|
||||
|
||||
// ── Flow 1: Open server-local buffer ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn open_server_local_creates_buffer_and_is_server_local() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let file_id = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
let state = gbm.open_server_local("/tmp/test_open.txt".into(), ctx);
|
||||
state.file_id
|
||||
});
|
||||
|
||||
let handle = gbm(&app);
|
||||
app.read(|ctx| {
|
||||
assert!(handle.as_ref(ctx).is_server_local(file_id));
|
||||
assert!(handle
|
||||
.as_ref(ctx)
|
||||
.sync_clock_for_server_local(file_id)
|
||||
.is_some());
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// ── Flow 2: Client edit via apply_client_edit ─────────────────────
|
||||
|
||||
#[test]
|
||||
fn apply_client_edit_accepted_when_version_matches() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
// Open a server-local buffer and manually populate it with content
|
||||
// (simulating what FileModel::FileLoaded would do).
|
||||
// Keep _buffer_state alive so the WeakModelHandle in GlobalBufferModel
|
||||
// can be upgraded (the ModelHandle<Buffer> is the only strong reference).
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
let state = gbm.open_server_local("/tmp/test_edit.txt".into(), ctx);
|
||||
// Manually populate content (bypassing async file load).
|
||||
gbm.populate_buffer_with_read_content(
|
||||
state.file_id,
|
||||
"hello\nworld",
|
||||
ContentVersion::new(),
|
||||
ContentVersion::new(),
|
||||
true, // is_initial_load
|
||||
ctx,
|
||||
);
|
||||
state
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
// Read the server_version before the edit.
|
||||
let sv = server_version(&app, file_id);
|
||||
|
||||
// Apply a client edit: insert " there" after "hello" (1-indexed offset 6).
|
||||
let accepted = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.apply_client_edit(
|
||||
file_id,
|
||||
&[text_edit(6, 6, " there")],
|
||||
sv, // expected_server_version matches
|
||||
ContentVersion::new(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
assert!(accepted);
|
||||
assert_eq!(content(&app, file_id), "hello there\nworld");
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_client_edit_rejected_when_version_stale() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
let state = gbm.open_server_local("/tmp/test_reject.txt".into(), ctx);
|
||||
gbm.populate_buffer_with_read_content(
|
||||
state.file_id,
|
||||
"original",
|
||||
ContentVersion::new(),
|
||||
ContentVersion::new(),
|
||||
true,
|
||||
ctx,
|
||||
);
|
||||
state
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
// Use a stale server_version (not the current one).
|
||||
let stale_sv = ContentVersion::from_raw(99999);
|
||||
|
||||
let accepted = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.apply_client_edit(
|
||||
file_id,
|
||||
&[text_edit(9, 9, " edit")],
|
||||
stale_sv,
|
||||
ContentVersion::new(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
assert!(!accepted);
|
||||
assert_eq!(content(&app, file_id), "original");
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_client_edit_replaces_range() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
let state = gbm.open_server_local("/tmp/test_replace.txt".into(), ctx);
|
||||
gbm.populate_buffer_with_read_content(
|
||||
state.file_id,
|
||||
"hello world",
|
||||
ContentVersion::new(),
|
||||
ContentVersion::new(),
|
||||
true,
|
||||
ctx,
|
||||
);
|
||||
state
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
let sv = server_version(&app, file_id);
|
||||
|
||||
let accepted = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
// Replace "world" (1-indexed char offset 7..12) with "rust".
|
||||
gbm.apply_client_edit(
|
||||
file_id,
|
||||
&[text_edit(7, 12, "rust")],
|
||||
sv,
|
||||
ContentVersion::new(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
assert!(accepted);
|
||||
assert_eq!(content(&app, file_id), "hello rust");
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_client_edit_across_lines() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
let state = gbm.open_server_local("/tmp/test_multiline.txt".into(), ctx);
|
||||
gbm.populate_buffer_with_read_content(
|
||||
state.file_id,
|
||||
"line1\nline2\nline3",
|
||||
ContentVersion::new(),
|
||||
ContentVersion::new(),
|
||||
true,
|
||||
ctx,
|
||||
);
|
||||
state
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
let sv = server_version(&app, file_id);
|
||||
|
||||
let accepted = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
// Delete from after "line1" (1-indexed offset 6) to start of "line3" (1-indexed offset 13).
|
||||
// "line1\nline2\n" = 12 chars, so "line3" starts at offset 13.
|
||||
gbm.apply_client_edit(
|
||||
file_id,
|
||||
&[text_edit(6, 13, "\n")],
|
||||
sv,
|
||||
ContentVersion::new(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
assert!(accepted);
|
||||
assert_eq!(content(&app, file_id), "line1\nline3");
|
||||
})
|
||||
}
|
||||
|
||||
// ── Flow 3: Server push via handle_buffer_updated_push ────────────
|
||||
|
||||
#[test]
|
||||
fn handle_buffer_updated_push_accepted_when_version_matches() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let host_id = test_host_id();
|
||||
let path = test_path();
|
||||
|
||||
// Seed a remote buffer (client_version starts at 0).
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.seed_remote_buffer_for_test(
|
||||
host_id.clone(),
|
||||
path.clone(),
|
||||
"hello\nworld",
|
||||
42, // server_version
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
// Push an edit with expected_client_version = 0 (matches initial).
|
||||
// 1-indexed: offset 6 = after "hello".
|
||||
gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.handle_buffer_updated_push(
|
||||
&host_id,
|
||||
path.as_str(),
|
||||
43, // new_server_version
|
||||
0, // expected_client_version (matches the seeded 0)
|
||||
&[char_edit(6, 6, " there")],
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
assert_eq!(content(&app, file_id), "hello there\nworld");
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_buffer_updated_push_conflict_when_client_version_stale() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let host_id = test_host_id();
|
||||
let path = test_path();
|
||||
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
// Seed with client_version = 0.
|
||||
gbm.seed_remote_buffer_for_test(host_id.clone(), path.clone(), "original", 42, ctx)
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
// Collect events.
|
||||
let (event_tx, event_rx) = async_channel::unbounded::<bool>();
|
||||
let gbm_handle = gbm(&app);
|
||||
app.update(|ctx| {
|
||||
ctx.subscribe_to_model(&gbm_handle, move |_, event, _| {
|
||||
if matches!(event, GlobalBufferModelEvent::RemoteBufferConflict { .. }) {
|
||||
let _ = event_tx.try_send(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Push with expected_client_version = 999 (does not match 0).
|
||||
gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.handle_buffer_updated_push(
|
||||
&host_id,
|
||||
path.as_str(),
|
||||
43,
|
||||
999, // stale expected_client_version
|
||||
&[char_edit(1, 9, "replaced")],
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
// Content should be unchanged.
|
||||
assert_eq!(content(&app, file_id), "original");
|
||||
|
||||
// Should have emitted a RemoteBufferConflict event.
|
||||
assert!(event_rx.try_recv().is_ok());
|
||||
})
|
||||
}
|
||||
|
||||
// ── Flow 4: Close / deallocate ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn remove_deallocates_buffer() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
let state = gbm.open_server_local("/tmp/test_remove.txt".into(), ctx);
|
||||
gbm.populate_buffer_with_read_content(
|
||||
state.file_id,
|
||||
"content",
|
||||
ContentVersion::new(),
|
||||
ContentVersion::new(),
|
||||
true,
|
||||
ctx,
|
||||
);
|
||||
state
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
// Verify it exists.
|
||||
assert_eq!(content(&app, file_id), "content");
|
||||
|
||||
// Remove it.
|
||||
gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.remove(file_id, ctx);
|
||||
});
|
||||
|
||||
// content_for_file should now return None (empty string via unwrap_or_default).
|
||||
assert_eq!(content(&app, file_id), "");
|
||||
})
|
||||
}
|
||||
|
||||
// ── Version tracking ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn apply_client_edit_updates_sync_clock() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
let state = gbm.open_server_local("/tmp/test_clock.txt".into(), ctx);
|
||||
gbm.populate_buffer_with_read_content(
|
||||
state.file_id,
|
||||
"hello",
|
||||
ContentVersion::new(),
|
||||
ContentVersion::new(),
|
||||
true,
|
||||
ctx,
|
||||
);
|
||||
state
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
let sv = server_version(&app, file_id);
|
||||
let new_cv = ContentVersion::new();
|
||||
|
||||
let accepted = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.apply_client_edit(file_id, &[text_edit(6, 6, " world")], sv, new_cv, ctx)
|
||||
});
|
||||
assert!(accepted);
|
||||
|
||||
// client_version should be updated; server_version unchanged.
|
||||
let handle = gbm(&app);
|
||||
app.read(|ctx| {
|
||||
let clock = handle
|
||||
.as_ref(ctx)
|
||||
.sync_clock_for_server_local(file_id)
|
||||
.unwrap();
|
||||
assert_eq!(clock.client_version, new_cv);
|
||||
assert_eq!(clock.server_version, sv);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_push_updates_sync_clock() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let host_id = test_host_id();
|
||||
let path = test_path();
|
||||
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.seed_remote_buffer_for_test(host_id.clone(), path.clone(), "hello", 42, ctx)
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.handle_buffer_updated_push(
|
||||
&host_id,
|
||||
path.as_str(),
|
||||
43,
|
||||
0,
|
||||
&[char_edit(6, 6, " world")],
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
// server_version should be updated; client_version unchanged.
|
||||
let handle = gbm(&app);
|
||||
app.read(|ctx| {
|
||||
let clock = handle
|
||||
.as_ref(ctx)
|
||||
.sync_clock_for_remote_test(file_id)
|
||||
.unwrap();
|
||||
assert_eq!(clock.server_version, ContentVersion::from_raw(43));
|
||||
assert_eq!(clock.client_version, ContentVersion::from_raw(0));
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// ── Round-trip: sequential operations ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sequential_client_edits_accepted() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
let state = gbm.open_server_local("/tmp/test_seq_client.txt".into(), ctx);
|
||||
gbm.populate_buffer_with_read_content(
|
||||
state.file_id,
|
||||
"abc",
|
||||
ContentVersion::new(),
|
||||
ContentVersion::new(),
|
||||
true,
|
||||
ctx,
|
||||
);
|
||||
state
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
let sv = server_version(&app, file_id);
|
||||
let cv1 = ContentVersion::new();
|
||||
|
||||
// First edit: append "d" (1-indexed offset 4 = after "abc").
|
||||
let accepted = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.apply_client_edit(file_id, &[text_edit(4, 4, "d")], sv, cv1, ctx)
|
||||
});
|
||||
assert!(accepted);
|
||||
assert_eq!(content(&app, file_id), "abcd");
|
||||
|
||||
// Second edit: append "e" (1-indexed offset 5 = after "abcd").
|
||||
let cv2 = ContentVersion::new();
|
||||
let accepted = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.apply_client_edit(file_id, &[text_edit(5, 5, "e")], sv, cv2, ctx)
|
||||
});
|
||||
assert!(accepted);
|
||||
assert_eq!(content(&app, file_id), "abcde");
|
||||
|
||||
// Final clock state.
|
||||
let handle = gbm(&app);
|
||||
app.read(|ctx| {
|
||||
let clock = handle
|
||||
.as_ref(ctx)
|
||||
.sync_clock_for_server_local(file_id)
|
||||
.unwrap();
|
||||
assert_eq!(clock.client_version, cv2);
|
||||
assert_eq!(clock.server_version, sv);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_server_pushes_accepted() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let host_id = test_host_id();
|
||||
let path = test_path();
|
||||
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.seed_remote_buffer_for_test(host_id.clone(), path.clone(), "ab", 10, ctx)
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
// First push: append "c" (1-indexed offset 3 = after "ab").
|
||||
gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.handle_buffer_updated_push(
|
||||
&host_id,
|
||||
path.as_str(),
|
||||
11,
|
||||
0,
|
||||
&[char_edit(3, 3, "c")],
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
assert_eq!(content(&app, file_id), "abc");
|
||||
|
||||
// Second push: append "d" (1-indexed offset 4 = after "abc").
|
||||
gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.handle_buffer_updated_push(
|
||||
&host_id,
|
||||
path.as_str(),
|
||||
12,
|
||||
0,
|
||||
&[char_edit(4, 4, "d")],
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
assert_eq!(content(&app, file_id), "abcd");
|
||||
|
||||
// Final clock state.
|
||||
let handle = gbm(&app);
|
||||
app.read(|ctx| {
|
||||
let clock = handle
|
||||
.as_ref(ctx)
|
||||
.sync_clock_for_remote_test(file_id)
|
||||
.unwrap();
|
||||
assert_eq!(clock.server_version, ContentVersion::from_raw(12));
|
||||
assert_eq!(clock.client_version, ContentVersion::from_raw(0));
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// ── Conflict resolution ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolve_conflict_updates_content_and_clock() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
let state = gbm.open_server_local("/tmp/test_resolve.txt".into(), ctx);
|
||||
gbm.populate_buffer_with_read_content(
|
||||
state.file_id,
|
||||
"original",
|
||||
ContentVersion::new(),
|
||||
ContentVersion::new(),
|
||||
true,
|
||||
ctx,
|
||||
);
|
||||
state
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
let acked_sv = ContentVersion::new();
|
||||
|
||||
let client_cv = ContentVersion::new();
|
||||
|
||||
// resolve_conflict may fail on the disk-save portion in tests;
|
||||
// the in-memory content and clock update are not gated on save success.
|
||||
let _ = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.resolve_conflict(file_id, acked_sv, client_cv, "resolved content", ctx)
|
||||
});
|
||||
|
||||
assert_eq!(content(&app, file_id), "resolved content");
|
||||
|
||||
let handle = gbm(&app);
|
||||
app.read(|ctx| {
|
||||
let clock = handle
|
||||
.as_ref(ctx)
|
||||
.sync_clock_for_server_local(file_id)
|
||||
.unwrap();
|
||||
assert_eq!(clock.server_version, acked_sv);
|
||||
assert_eq!(clock.client_version, client_cv);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// ── Echo loop prevention ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn server_push_does_not_echo_back_as_client_edit() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let host_id = test_host_id();
|
||||
let path = test_path();
|
||||
|
||||
// Track whether any user-originated ContentChanged fires on the buffer.
|
||||
let (user_edit_tx, user_edit_rx) = async_channel::unbounded::<bool>();
|
||||
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
let state =
|
||||
gbm.seed_remote_buffer_for_test(host_id.clone(), path.clone(), "hello", 42, ctx);
|
||||
|
||||
// Subscribe to buffer events, mirroring what open_remote_buffer does.
|
||||
// If a user-originated ContentChanged fires, it means the echo loop
|
||||
// guard (origin.from_user()) failed.
|
||||
let tx = user_edit_tx.clone();
|
||||
ctx.subscribe_to_model(&state.buffer, move |_me, _, event, _ctx| {
|
||||
use warp_editor::content::buffer::BufferEvent;
|
||||
if let BufferEvent::ContentChanged { origin, .. } = event {
|
||||
if origin.from_user() {
|
||||
let _ = tx.try_send(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
state
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
// Apply a server push. insert_at_char_offset_ranges emits
|
||||
// ContentChanged with SystemEdit origin, so the subscription
|
||||
// above should NOT fire (origin.from_user() == false).
|
||||
gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.handle_buffer_updated_push(
|
||||
&host_id,
|
||||
path.as_str(),
|
||||
43,
|
||||
0,
|
||||
&[char_edit(6, 6, " world")],
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
// Content should be updated.
|
||||
assert_eq!(content(&app, file_id), "hello world");
|
||||
|
||||
// No user-originated ContentChanged should have fired.
|
||||
assert!(
|
||||
user_edit_rx.try_recv().is_err(),
|
||||
"Server push should not trigger a user-originated ContentChanged"
|
||||
);
|
||||
|
||||
// client_version should remain at 0.
|
||||
let handle = gbm(&app);
|
||||
app.read(|ctx| {
|
||||
let clock = handle
|
||||
.as_ref(ctx)
|
||||
.sync_clock_for_remote_test(file_id)
|
||||
.unwrap();
|
||||
assert_eq!(clock.client_version, ContentVersion::from_raw(0));
|
||||
assert_eq!(clock.server_version, ContentVersion::from_raw(43));
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// ── Batched edits ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn apply_client_edit_multiple_edits_in_batch() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
let state = gbm.open_server_local("/tmp/test_batch_client.txt".into(), ctx);
|
||||
gbm.populate_buffer_with_read_content(
|
||||
state.file_id,
|
||||
"aaa bbb ccc",
|
||||
ContentVersion::new(),
|
||||
ContentVersion::new(),
|
||||
true,
|
||||
ctx,
|
||||
);
|
||||
state
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
let sv = server_version(&app, file_id);
|
||||
|
||||
let accepted = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.apply_client_edit(
|
||||
file_id,
|
||||
&[text_edit(1, 4, "xxx"), text_edit(9, 12, "zzz")],
|
||||
sv,
|
||||
ContentVersion::new(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
assert!(accepted);
|
||||
assert_eq!(content(&app, file_id), "xxx bbb zzz");
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_client_edit_sequential_insertions() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
// Simulate a file with content "def fib(n):\n pass"
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
let state = gbm.open_server_local("/tmp/test_seq_insert.txt".into(), ctx);
|
||||
gbm.populate_buffer_with_read_content(
|
||||
state.file_id,
|
||||
"def fib(n):\n pass",
|
||||
ContentVersion::new(),
|
||||
ContentVersion::new(),
|
||||
true,
|
||||
ctx,
|
||||
);
|
||||
state
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
let sv = server_version(&app, file_id);
|
||||
|
||||
// Simulate rapid typing of "hello" at position 13 (after '\n', start of line 2).
|
||||
// Each edit's offset is in sequential coordinates (post-previous-edit).
|
||||
let accepted = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.apply_client_edit(
|
||||
file_id,
|
||||
&[
|
||||
text_edit(13, 13, "h"),
|
||||
text_edit(14, 14, "e"),
|
||||
text_edit(15, 15, "l"),
|
||||
text_edit(16, 16, "l"),
|
||||
text_edit(17, 17, "o"),
|
||||
],
|
||||
sv,
|
||||
ContentVersion::new(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
assert!(accepted);
|
||||
assert_eq!(content(&app, file_id), "def fib(n):\nhello pass");
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_client_edit_insertion_then_edit_at_shifted_offset() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
let state = gbm.open_server_local("/tmp/test_mixed_batch.txt".into(), ctx);
|
||||
gbm.populate_buffer_with_read_content(
|
||||
state.file_id,
|
||||
"aaa bbb",
|
||||
ContentVersion::new(),
|
||||
ContentVersion::new(),
|
||||
true,
|
||||
ctx,
|
||||
);
|
||||
state
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
let sv = server_version(&app, file_id);
|
||||
|
||||
// Edit 0: insert "xx" at position 4 → "aaaxx bbb" (net +2 chars)
|
||||
// Edit 1: replace positions 6..9 in post-edit-0 state (" bb") with "ZZ"
|
||||
// In original coords this would be 4..7, but the client sends
|
||||
// sequential coords: 6..9.
|
||||
let accepted = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.apply_client_edit(
|
||||
file_id,
|
||||
&[text_edit(4, 4, "xx"), text_edit(6, 9, "ZZ")],
|
||||
sv,
|
||||
ContentVersion::new(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
assert!(accepted);
|
||||
assert_eq!(content(&app, file_id), "aaaxxZZb");
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_buffer_updated_push_multiple_edits_in_batch() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let host_id = test_host_id();
|
||||
let path = test_path();
|
||||
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.seed_remote_buffer_for_test(host_id.clone(), path.clone(), "aaa bbb ccc", 1, ctx)
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.handle_buffer_updated_push(
|
||||
&host_id,
|
||||
path.as_str(),
|
||||
2,
|
||||
0,
|
||||
&[char_edit(1, 4, "xxx"), char_edit(9, 12, "zzz")],
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
assert_eq!(content(&app, file_id), "xxx bbb zzz");
|
||||
})
|
||||
}
|
||||
@@ -1,12 +1,29 @@
|
||||
use std::cell::RefCell;
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use warp_editor::render::element::VerticalExpansionBehavior;
|
||||
use warpui::elements::{
|
||||
Border, ChildView, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex,
|
||||
MainAxisAlignment, MainAxisSize, ParentElement, Radius, Shrinkable, Text,
|
||||
};
|
||||
use warpui::keymap::Keystroke;
|
||||
use warpui::text_layout::ClipConfig;
|
||||
use warpui::units::Pixels;
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View,
|
||||
ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::code::editor::comments::{EditorCommentsModel, PendingCommentEvent};
|
||||
use crate::code::editor::line::EditorLineLocation;
|
||||
use crate::code_review::comments::{CommentId, CommentOrigin};
|
||||
use crate::editor::InteractionState;
|
||||
use crate::notebooks::editor::{
|
||||
model::NotebooksEditorModel,
|
||||
rich_text_styles,
|
||||
view::{EditorViewEvent, RichTextEditorConfig, RichTextEditorView},
|
||||
};
|
||||
use crate::notebooks::editor::model::NotebooksEditorModel;
|
||||
use crate::notebooks::editor::rich_text_styles;
|
||||
use crate::notebooks::editor::view::{EditorViewEvent, RichTextEditorConfig, RichTextEditorView};
|
||||
use crate::notebooks::link::{NotebookLinks, SessionSource};
|
||||
use crate::settings::FontSettings;
|
||||
use crate::ui_components::blended_colors;
|
||||
@@ -14,22 +31,6 @@ use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::{
|
||||
ActionButton, ButtonSize, DangerNakedTheme, KeystrokeSource, NakedTheme, PrimaryTheme,
|
||||
};
|
||||
use galaxy_core::ui::{appearance::Appearance, theme::Fill};
|
||||
use galaxy_editor::render::element::VerticalExpansionBehavior;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Border, ChildView, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
Flex, MainAxisAlignment, MainAxisSize, ParentElement, Radius, Shrinkable, Text,
|
||||
},
|
||||
keymap::Keystroke,
|
||||
text_layout::ClipConfig,
|
||||
units::Pixels,
|
||||
AppContext, Element, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View,
|
||||
ViewContext, ViewHandle,
|
||||
};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use std::cell::RefCell;
|
||||
|
||||
/// Default width of the comment editor, in pixels.
|
||||
pub(crate) const DEFAULT_COMMENT_MAX_WIDTH: f32 = 750.0;
|
||||
@@ -165,7 +166,10 @@ impl CommentEditor {
|
||||
let save_button = ctx.add_typed_action_view(|ctx| {
|
||||
ActionButton::new("Comment", PrimaryTheme)
|
||||
.with_keybinding(
|
||||
KeystrokeSource::Fixed(Keystroke::parse("cmdorctrl-enter").unwrap_or_default()),
|
||||
KeystrokeSource::Fixed(
|
||||
Keystroke::parse(crate::code_review::CODE_REVIEW_SUBMIT_KEYSTROKE)
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
ctx,
|
||||
)
|
||||
.on_click(|ctx| {
|
||||
@@ -423,8 +427,8 @@ impl View for CommentEditor {
|
||||
)
|
||||
.with_child(
|
||||
Container::new(footer_row)
|
||||
.with_vertical_padding(8.)
|
||||
.with_horizontal_padding(8.)
|
||||
.with_vertical_padding(4.)
|
||||
.with_horizontal_padding(4.)
|
||||
.with_border(Border::top(1.).with_border_fill(border_color))
|
||||
.finish(),
|
||||
)
|
||||
|
||||
@@ -1,31 +1,26 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxyui::{
|
||||
platform::WindowStyle, presenter::ChildView, App, Element, Entity, TypedActionView, View,
|
||||
ViewHandle, WindowId,
|
||||
};
|
||||
|
||||
use super::{create_editable_comment_markdown_editor, create_readonly_comment_markdown_editor};
|
||||
use crate::notebooks::editor::view::RichTextEditorView;
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use repo_metadata::RepoMetadataModel;
|
||||
use warpui::platform::WindowStyle;
|
||||
use warpui::presenter::ChildView;
|
||||
use warpui::{App, Element, Entity, TypedActionView, View, ViewHandle, WindowId};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
auth::AuthStateProvider,
|
||||
cloud_object::model::persistence::CloudModel,
|
||||
notebooks::{
|
||||
editor::keys::NotebookKeybindings,
|
||||
link::{NotebookLinks, SessionSource},
|
||||
},
|
||||
search::files::model::FileSearchModel,
|
||||
server::server_api::{team::MockTeamClient, workspace::MockWorkspaceClient},
|
||||
settings_view::keybindings::KeybindingChangedNotifier,
|
||||
terminal::keys::TerminalKeybindings,
|
||||
test_util::settings::initialize_settings_for_tests,
|
||||
workspace::ActiveSession,
|
||||
GlobalResourceHandles, GlobalResourceHandlesProvider, UserWorkspaces,
|
||||
};
|
||||
use super::{create_editable_comment_markdown_editor, create_readonly_comment_markdown_editor};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::notebooks::editor::keys::NotebookKeybindings;
|
||||
use crate::notebooks::editor::view::RichTextEditorView;
|
||||
use crate::notebooks::link::{NotebookLinks, SessionSource};
|
||||
use crate::search::files::model::FileSearchModel;
|
||||
use crate::server::server_api::team::MockTeamClient;
|
||||
use crate::server::server_api::workspace::MockWorkspaceClient;
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
use crate::terminal::keys::TerminalKeybindings;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::workspace::ActiveSession;
|
||||
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider, UserWorkspaces};
|
||||
|
||||
struct TestView {
|
||||
editor: ViewHandle<RichTextEditorView>,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))]
|
||||
// Adding this file level gate as some of the code around editability is not used in WASM yet.
|
||||
|
||||
use std::{collections::HashMap, ops::Range, rc::Rc, sync::Arc};
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Range;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::stream::AbortHandle;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
@@ -16,14 +19,16 @@ use pathfinder_color::ColorU;
|
||||
use rangemap::RangeMap;
|
||||
use similar::{ChangeTag, DiffOp, TextDiff};
|
||||
use string_offset::CharOffset;
|
||||
use galaxy_core::ui::theme::{AnsiColorIdentifier, Fill};
|
||||
use galaxy_editor::content::edit::TemporaryBlock;
|
||||
use galaxy_editor::content::version::BufferVersion;
|
||||
use galaxy_editor::multiline::{AnyMultilineString, MultilineStr, MultilineString, LF};
|
||||
use galaxy_editor::render::model::{Decoration, LineCount, LineDecoration};
|
||||
|
||||
use super::super::DiffResult;
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
code::editor::{line::EditorLineLocation, line_iterator::LineIterator},
|
||||
};
|
||||
use galaxy_core::ui::theme::AnsiColorIdentifier;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::code::editor::line::EditorLineLocation;
|
||||
use crate::code::editor::line_iterator::LineIterator;
|
||||
|
||||
const OVERLAY_ALPHA: u8 = 56;
|
||||
const INLINE_OVERLAY_ALPHA: u8 = 71;
|
||||
|
||||
@@ -4,9 +4,8 @@ use galaxy_editor::multiline::{MultilineStr, MultilineString};
|
||||
use rangemap::RangeMap;
|
||||
use unindent::Unindent as _;
|
||||
|
||||
use crate::code::editor::diff::ChangeType;
|
||||
|
||||
use super::DiffModel;
|
||||
use crate::code::editor::diff::ChangeType;
|
||||
|
||||
#[test]
|
||||
fn test_diff_generation() {
|
||||
@@ -76,7 +75,6 @@ fn test_diff_generation() {
|
||||
|
||||
#[test]
|
||||
fn test_reverse_action() {
|
||||
use galaxyui::App;
|
||||
App::test((), |_| async move {
|
||||
let mut diff_model = DiffModel::new();
|
||||
diff_model.set_base(MultilineString::apply(
|
||||
@@ -109,7 +107,6 @@ fn test_reverse_action() {
|
||||
|
||||
#[test]
|
||||
fn test_reverse_action_replaced_newlines() {
|
||||
use galaxyui::App;
|
||||
App::test((), |_| async move {
|
||||
let mut diff_model = DiffModel::new();
|
||||
let base_text = r"
|
||||
@@ -192,7 +189,6 @@ fn test_reverse_action_replaced_newlines() {
|
||||
|
||||
#[test]
|
||||
fn test_reverse_action_replaced_text() {
|
||||
use galaxyui::App;
|
||||
App::test((), |_| async move {
|
||||
let mut diff_model = DiffModel::new();
|
||||
let base_text = r"
|
||||
@@ -269,7 +265,6 @@ fn test_reverse_action_replaced_text() {
|
||||
|
||||
#[test]
|
||||
fn test_reverse_action_deleted_lines() {
|
||||
use galaxyui::App;
|
||||
App::test((), |_| async move {
|
||||
let mut diff_model = DiffModel::new();
|
||||
let base_text = r"
|
||||
@@ -353,7 +348,6 @@ fn test_reverse_action_deleted_lines() {
|
||||
|
||||
#[test]
|
||||
fn test_diff_count_before_line() {
|
||||
use galaxyui::App;
|
||||
App::test((), |_| async move {
|
||||
let mut diff_model = DiffModel::new();
|
||||
diff_model.set_base(
|
||||
@@ -373,7 +367,6 @@ fn test_diff_count_before_line() {
|
||||
|
||||
#[test]
|
||||
fn test_unified_diff() {
|
||||
use galaxyui::App;
|
||||
App::test((), |_| async move {
|
||||
let diff = DiffModel::retrieve_unified_diff_internal(
|
||||
MultilineStr::try_new("Hello World\nThis is the second line.\nThis is the third.")
|
||||
@@ -395,7 +388,6 @@ fn test_unified_diff() {
|
||||
/// to produce duplicate deletion and insertion hunks for what is logically a replacement.
|
||||
#[test]
|
||||
fn test_coalesce_replacements() {
|
||||
use galaxyui::App;
|
||||
App::test((), |_| async move {
|
||||
let mut diff_model = DiffModel::new();
|
||||
let base_text = r"
|
||||
|
||||
@@ -1,46 +1,37 @@
|
||||
mod gutter_button;
|
||||
use std::ops::Range;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use gutter_button::{AddAsContextButton, CommentButton, RevertHunkButton};
|
||||
|
||||
use std::{
|
||||
ops::Range,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
use parking_lot::Mutex;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxy_editor::editor::EditorView;
|
||||
use galaxy_editor::render::element::lens_element::RichTextElementLens;
|
||||
use galaxy_editor::render::element::{RenderableBlock, RichTextElement, VerticalExpansionBehavior};
|
||||
use galaxy_editor::render::model::{
|
||||
gutter_expansion_button_types, BlockLocation, ExpansionType, LineCount, RenderState,
|
||||
};
|
||||
|
||||
use galaxy_core::ui::{
|
||||
appearance::Appearance,
|
||||
theme::{color::internal_colors, Fill},
|
||||
};
|
||||
use galaxy_editor::{
|
||||
editor::EditorView,
|
||||
render::{
|
||||
element::{
|
||||
lens_element::RichTextElementLens, RenderableBlock, RichTextElement,
|
||||
VerticalExpansionBehavior,
|
||||
},
|
||||
model::{
|
||||
gutter_expansion_button_types, BlockLocation, ExpansionType, LineCount, RenderState,
|
||||
},
|
||||
},
|
||||
use galaxyui::elements::new_scrollable::{NewScrollableElement, ScrollableAxis};
|
||||
use galaxyui::elements::{
|
||||
Align, Axis, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, Empty, F32Ext, Flex,
|
||||
Hoverable, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
|
||||
ParentOffsetBounds, Point, Radius, ScrollData, Stack, Text, ZIndex,
|
||||
};
|
||||
use galaxyui::event::DispatchedEvent;
|
||||
use galaxyui::fonts::FamilyId;
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
use galaxyui::units::{IntoPixels, Pixels};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
new_scrollable::{NewScrollableElement, ScrollableAxis},
|
||||
Align, Axis, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, Empty, F32Ext,
|
||||
Flex, MainAxisSize, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds,
|
||||
Point, Radius, ScrollData, Stack, Text, ZIndex,
|
||||
},
|
||||
event::DispatchedEvent,
|
||||
fonts::FamilyId,
|
||||
ui_components::components::UiComponent,
|
||||
units::{IntoPixels, Pixels},
|
||||
AfterLayoutContext, AppContext, ClipBounds, Element, Event, EventContext, LayoutContext,
|
||||
ModelHandle, PaintContext, SingletonEntity, SizeConstraint,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::{
|
||||
rect::RectF,
|
||||
vector::{vec2f, Vector2F},
|
||||
@@ -49,15 +40,10 @@ use pathfinder_geometry::{
|
||||
use super::diff::{DiffHunkDisplay, DiffStatus};
|
||||
use super::model::DiffNavigationState;
|
||||
use crate::code::editor::element::gutter_button::GutterButton;
|
||||
use crate::{
|
||||
code::editor::{
|
||||
line::EditorLineLocation,
|
||||
view::{CodeEditorViewAction, SavedComment},
|
||||
},
|
||||
view_components::action_button::{ActionButtonTheme, SecondaryTheme},
|
||||
};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::elements::{Hoverable, MouseStateHandle};
|
||||
use crate::code::editor::line::EditorLineLocation;
|
||||
use crate::code::editor::view::{CodeEditorViewAction, SavedComment};
|
||||
use crate::settings::CodeEditorLineNumberMode;
|
||||
use crate::view_components::action_button::{ActionButtonTheme, SecondaryTheme};
|
||||
|
||||
pub const GUTTER_WIDTH: f32 = 94.;
|
||||
const VERTICAL_DIFF_HUNK_INDICATOR_WIDTH: f32 = 3.;
|
||||
@@ -351,7 +337,7 @@ impl GutterRange {
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum GutterHoverTarget {
|
||||
// The entire line covered by the gutter is cosidered the hover target.
|
||||
// The entire line covered by the gutter is considered the hover target.
|
||||
Line,
|
||||
// Only the gutter element itself is considered the hover target.
|
||||
GutterElement,
|
||||
@@ -367,6 +353,28 @@ pub struct LineNumberConfig {
|
||||
pub text_color: ColorU,
|
||||
pub highlight_text_color: ColorU,
|
||||
pub starting_line_number: Option<usize>,
|
||||
pub mode: CodeEditorLineNumberMode,
|
||||
pub active_line_number: Option<LineCount>,
|
||||
pub active_cursor_is_visible: bool,
|
||||
}
|
||||
impl LineNumberConfig {
|
||||
pub fn absolute_line_number(&self, line_count: LineCount) -> usize {
|
||||
line_count.as_usize() + self.starting_line_number.unwrap_or(1)
|
||||
}
|
||||
|
||||
pub fn display_line_number(&self, line_count: LineCount) -> usize {
|
||||
if self.mode == CodeEditorLineNumberMode::Relative {
|
||||
if let Some(active_line_number) = self.active_line_number {
|
||||
if active_line_number != line_count {
|
||||
return active_line_number
|
||||
.as_usize()
|
||||
.abs_diff(line_count.as_usize());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.absolute_line_number(line_count)
|
||||
}
|
||||
}
|
||||
|
||||
struct CommentBox {
|
||||
@@ -567,6 +575,21 @@ impl<V: EditorView> EditorWrapper<V> {
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn should_display_relative_line_number(&self) -> bool {
|
||||
let Some(line_number_config) = &self.line_number_config else {
|
||||
return false;
|
||||
};
|
||||
if line_number_config.mode != CodeEditorLineNumberMode::Relative
|
||||
|| line_number_config.active_line_number.is_none()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Relative numbers follow the cursor: only show them when a cursor is
|
||||
// actually drawn (editor focused and editable).
|
||||
line_number_config.active_cursor_is_visible
|
||||
}
|
||||
|
||||
/// Returning **no** gutter means the gutter shouldn't be rendered at all.
|
||||
/// Returning an **empty** gutter means the gutter should be rendered with no contents.
|
||||
fn gutter_elements(&self, app: &AppContext) -> Option<Vec<GutterElement>> {
|
||||
@@ -602,8 +625,11 @@ impl<V: EditorView> EditorWrapper<V> {
|
||||
let diff_hunk = self.diff_status.diff_hunk(line_count, appearance);
|
||||
let is_removal = matches!(diff_hunk, Some(DiffHunkDisplay::Remove(_)));
|
||||
|
||||
let current_line =
|
||||
line_count.as_usize() + line_number_config.starting_line_number.unwrap_or(1);
|
||||
let current_line = if self.should_display_relative_line_number() {
|
||||
line_number_config.display_line_number(line_count)
|
||||
} else {
|
||||
line_number_config.absolute_line_number(line_count)
|
||||
};
|
||||
|
||||
// If the block is temporary, don't render line number.
|
||||
// Currently, all temporary blocks are removal hunks, either from a deleted section,
|
||||
@@ -1662,3 +1688,7 @@ impl<V: EditorView> NewScrollableElement for EditorWrapper<V> {
|
||||
ScrollableAxis::Both
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "element_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
use crate::view_components::action_button::{
|
||||
ActionButtonTheme, DisabledSecondaryTheme, SecondaryTheme,
|
||||
};
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::color::contrast::MinimumAllowedContrast;
|
||||
use galaxy_core::ui::color::ContrastingColor;
|
||||
@@ -9,6 +6,10 @@ use galaxy_core::ui::theme::Fill;
|
||||
use galaxy_core::ui::Icon;
|
||||
use galaxyui::elements::MouseState;
|
||||
|
||||
use crate::view_components::action_button::{
|
||||
ActionButtonTheme, DisabledSecondaryTheme, SecondaryTheme,
|
||||
};
|
||||
|
||||
/// A button rendered within the gutter of the editor.
|
||||
pub(super) trait GutterButton {
|
||||
/// The icon color for the gutter.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
use super::*;
|
||||
fn config(
|
||||
mode: CodeEditorLineNumberMode,
|
||||
starting_line_number: Option<usize>,
|
||||
active_line_number: Option<LineCount>,
|
||||
) -> LineNumberConfig {
|
||||
LineNumberConfig {
|
||||
font_family: FamilyId(0),
|
||||
font_size: 0.,
|
||||
text_color: ColorU::transparent_black(),
|
||||
highlight_text_color: ColorU::transparent_black(),
|
||||
starting_line_number,
|
||||
mode,
|
||||
active_line_number,
|
||||
active_cursor_is_visible: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_line_numbers_default_to_one_based_values() {
|
||||
let config = config(CodeEditorLineNumberMode::Absolute, None, None);
|
||||
|
||||
assert_eq!(config.absolute_line_number(LineCount::from(0)), 1);
|
||||
assert_eq!(config.absolute_line_number(LineCount::from(4)), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_line_numbers_honor_starting_line_number() {
|
||||
let config = config(CodeEditorLineNumberMode::Absolute, Some(10), None);
|
||||
|
||||
assert_eq!(config.absolute_line_number(LineCount::from(0)), 10);
|
||||
assert_eq!(config.absolute_line_number(LineCount::from(4)), 14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_line_numbers_show_absolute_value_on_active_line() {
|
||||
let config = config(
|
||||
CodeEditorLineNumberMode::Relative,
|
||||
None,
|
||||
Some(LineCount::from(4)),
|
||||
);
|
||||
assert_eq!(config.display_line_number(LineCount::from(4)), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_line_numbers_show_distance_above_and_below_active_line() {
|
||||
let config = config(
|
||||
CodeEditorLineNumberMode::Relative,
|
||||
None,
|
||||
Some(LineCount::from(5)),
|
||||
);
|
||||
assert_eq!(config.display_line_number(LineCount::from(2)), 3);
|
||||
assert_eq!(config.display_line_number(LineCount::from(8)), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_line_numbers_fall_back_to_absolute_without_active_line() {
|
||||
let config = config(CodeEditorLineNumberMode::Relative, None, None);
|
||||
assert_eq!(config.display_line_number(LineCount::from(4)), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_line_numbers_use_starting_line_number_for_active_line_only() {
|
||||
let config = config(
|
||||
CodeEditorLineNumberMode::Relative,
|
||||
Some(10),
|
||||
Some(LineCount::from(4)),
|
||||
);
|
||||
assert_eq!(config.display_line_number(LineCount::from(4)), 14);
|
||||
assert_eq!(config.display_line_number(LineCount::from(1)), 3);
|
||||
}
|
||||
@@ -10,10 +10,10 @@ use galaxy_editor::render::layout::TextLayout;
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
use serde_yaml::Mapping;
|
||||
use uuid::Uuid;
|
||||
|
||||
use galaxy_editor::render::model::viewport::ViewportItem;
|
||||
use galaxy_editor::render::model::{
|
||||
viewport::ViewportItem, BlockSpacing, EmbeddedItem, EmbeddedItemHTMLRepresentation,
|
||||
EmbeddedItemRichFormat, LaidOutEmbeddedItem, RenderState,
|
||||
BlockSpacing, EmbeddedItem, EmbeddedItemHTMLRepresentation, EmbeddedItemRichFormat,
|
||||
LaidOutEmbeddedItem, RenderState,
|
||||
};
|
||||
use galaxyui::event::DispatchedEvent;
|
||||
use galaxyui::units::Pixels;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use serde_yaml::{Mapping, Value};
|
||||
use warp_editor::content::markdown::MarkdownStyle;
|
||||
use warpui::{EntityId, WindowId};
|
||||
|
||||
use super::{
|
||||
comment_embedded_item_conversion, EmbeddedCommentSpace, EmbeddedItem as _,
|
||||
COMMENT_ID_MAPPING_KEY, ENTITY_ID_MAPPING_KEY, WINDOW_ID_MAPPING_KEY,
|
||||
};
|
||||
use crate::code_review::comments::CommentId;
|
||||
use galaxy_editor::content::markdown::MarkdownStyle;
|
||||
use galaxyui::{EntityId, WindowId};
|
||||
use serde_yaml::{Mapping, Value};
|
||||
|
||||
#[test]
|
||||
fn test_comment_embedded_item_conversion_valid_input() {
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))]
|
||||
// Adding this file level gate as some of the code around editability is not used in WASM yet.
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_editor::editor::NavigationKey;
|
||||
use warp_editor::search::{SearchEvent, Searcher};
|
||||
pub use warpui::accessibility::{AccessibilityContent, WarpA11yRole};
|
||||
use warpui::elements::{
|
||||
Align, Border, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius,
|
||||
CrossAxisAlignment, DropShadow, Element, Flex, Hoverable, MainAxisAlignment, MouseStateHandle,
|
||||
OffsetPositioning, ParentAnchor, ParentOffsetBounds, Radius, Rect, SavePosition, Shrinkable,
|
||||
Text,
|
||||
};
|
||||
pub use warpui::elements::{ParentElement as _, Stack};
|
||||
pub use warpui::geometry::vector::vec2f;
|
||||
use warpui::keymap::EditableBinding;
|
||||
use warpui::presenter::ChildView;
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
pub use warpui::AppContext;
|
||||
use warpui::{
|
||||
Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::editor::{
|
||||
EditorView, Event as EditorEvent, InteractionState, PropagateAndNoOpNavigationKeys,
|
||||
SingleLineEditorOptions, TextOptions,
|
||||
};
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::send_telemetry_from_ctx;
|
||||
use crate::server::telemetry::{FindOption, TelemetryEvent};
|
||||
use crate::settings::AppEditorSettings;
|
||||
use crate::themes::theme::Fill;
|
||||
use crate::ui_components::{blended_colors, icons::Icon};
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::{ActionButton, DisabledSecondaryTheme, SecondaryTheme};
|
||||
use crate::view_components::find::FindDirection;
|
||||
use crate::{features::FeatureFlag, settings::AppEditorSettings};
|
||||
use galaxy_editor::editor::NavigationKey;
|
||||
use galaxy_editor::search::{SearchEvent, Searcher};
|
||||
use galaxyui::elements::MainAxisAlignment;
|
||||
use galaxyui::elements::{ChildAnchor, OffsetPositioning, Radius, SavePosition, Shrinkable};
|
||||
use galaxyui::keymap::EditableBinding;
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
pub use galaxyui::{
|
||||
accessibility::{AccessibilityContent, GalaxyA11yRole},
|
||||
elements::{ParentElement as _, Stack},
|
||||
geometry::vector::vec2f,
|
||||
AppContext,
|
||||
};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Align, Border, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
DropShadow, Element, Flex, Hoverable, MouseStateHandle, ParentAnchor, ParentOffsetBounds,
|
||||
Rect, Text,
|
||||
},
|
||||
Entity, SingletonEntity, TypedActionView, View,
|
||||
};
|
||||
use galaxyui::{presenter::ChildView, ViewContext, ViewHandle};
|
||||
use galaxyui::{FocusContext, ModelHandle};
|
||||
use pathfinder_color::ColorU;
|
||||
|
||||
pub const FIND_BAR_WIDTH: f32 = 500.;
|
||||
const ICON_PADDING: f32 = 4.;
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))]
|
||||
|
||||
use warpui::elements::{
|
||||
Align, Border, ChildView, ConstrainedBox, Container, CornerRadius, DropShadow, Flex,
|
||||
ParentElement, Radius, Text,
|
||||
};
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::code::editor::find::view::{FIND_BAR_PADDING, FIND_EDITOR_BORDER_RADIUS};
|
||||
use crate::editor::{
|
||||
EditorView, Event as EditorEvent, InteractionState, PropagateAndNoOpNavigationKeys,
|
||||
SingleLineEditorOptions, TextOptions,
|
||||
};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Align, Border, ChildView, ConstrainedBox, Container, CornerRadius, DropShadow, Flex,
|
||||
ParentElement, Radius, Text,
|
||||
},
|
||||
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
const GOTO_LINE_WIDTH: f32 = 300.;
|
||||
const GOTO_LINE_LABEL_FONT_SIZE: f32 = 12.;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use galaxy_editor::render::model::{LineCount, RenderLineLocation};
|
||||
use std::ops::Range;
|
||||
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EditorLineLocation {
|
||||
Collapsed {
|
||||
|
||||
@@ -15,8 +15,7 @@ pub mod scroll;
|
||||
pub mod view;
|
||||
|
||||
pub use comment_editor::{CommentEditor, CommentEditorEvent};
|
||||
pub use comments::EditorCommentsModel;
|
||||
pub use comments::EditorReviewComment;
|
||||
pub use comments::{EditorCommentsModel, EditorReviewComment};
|
||||
pub(crate) use diff::{add_color, remove_color};
|
||||
pub use element::GutterHoverTarget;
|
||||
pub use nav_bar::NavBarBehavior;
|
||||
|
||||
+228
-76
@@ -1,22 +1,6 @@
|
||||
#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))]
|
||||
// Adding this file level gate as some of the code around editability is not used in WASM yet.
|
||||
|
||||
use crate::code::editor::line_iterator::LineIterator;
|
||||
use crate::code_review::CodeReviewTelemetryEvent;
|
||||
use galaxy_core::platform::SessionPlatform;
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxy_editor::content::anchor::Anchor;
|
||||
use galaxy_editor::content::edit::EditDelta;
|
||||
use galaxy_editor::content::find::{SearchConfig, SearchResults};
|
||||
use galaxy_editor::content::selection_model::BufferSelectionModel;
|
||||
use galaxy_editor::content::version::BufferVersion;
|
||||
use galaxy_editor::multiline::{AnyMultilineString, MultilineString, LF};
|
||||
use galaxy_editor::render::model::{AutoScrollMode, LineCount, StyleUpdateAction};
|
||||
use galaxy_editor::selection::TextDirection;
|
||||
use galaxyui::units::{IntoPixels, Pixels};
|
||||
use num_traits::SaturatingSub;
|
||||
use rangemap::{RangeMap, RangeSet};
|
||||
use std::future::Future;
|
||||
use std::ops::Range;
|
||||
use std::path::Path;
|
||||
@@ -24,42 +8,12 @@ use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::{cmp, mem};
|
||||
|
||||
use crate::util::link_detection::get_word_range_at_offset;
|
||||
use crate::{
|
||||
appearance::Appearance, editor::InteractionState, notebooks::editor::model::word_unit,
|
||||
themes::theme::AnsiColorIdentifier,
|
||||
};
|
||||
|
||||
use ai::diff_validation::DiffDelta;
|
||||
use galaxy_core::semantic_selection::SemanticSelection;
|
||||
use galaxy_editor::content::buffer::{ShouldAutoscroll, VimInsertPoint};
|
||||
use galaxy_editor::{
|
||||
content::{
|
||||
buffer::{
|
||||
AutoScrollBehavior, Buffer, BufferEditAction, BufferEvent, BufferSelectAction,
|
||||
EditOrigin, InitialBufferState, SelectionOffsets, ToBufferCharOffset, ToBufferPoint,
|
||||
},
|
||||
hidden_lines_model::HiddenLinesModel,
|
||||
text::{BufferBlockStyle, IndentBehavior, IndentUnit},
|
||||
},
|
||||
decoration::DecorationLayer,
|
||||
editor::TextDecoration,
|
||||
model::{CoreEditorModel, PlainTextEditorModel},
|
||||
render::model::{
|
||||
BlockItem, Decoration, LineDecoration, RenderEvent, RenderLineLocation, RenderState,
|
||||
RichTextStyles, UpdateDecorationAfterLayout, WidthSetting,
|
||||
},
|
||||
selection::{SelectionMode, SelectionModel, TextUnit},
|
||||
};
|
||||
use galaxyui::elements::{
|
||||
AnchorPair, OffsetPositioning, OffsetType, PositionedElementOffsetBounds, PositioningAxis,
|
||||
XAxisAnchor, YAxisAnchor,
|
||||
};
|
||||
use galaxyui::text::{point::Point, TextBuffer};
|
||||
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
use itertools::Itertools;
|
||||
use languages::{language_by_filename, language_by_name, Language};
|
||||
use languages::{language_by_filename, language_by_local_filename, language_by_name, Language};
|
||||
use line_ending::LineEnding;
|
||||
use num_traits::SaturatingSub;
|
||||
use rangemap::{RangeMap, RangeSet};
|
||||
use string_offset::CharOffset;
|
||||
use syntax_tree::{ColorMap, DecorationStateEvent, SyntaxTreeState};
|
||||
use vec1::{vec1, Vec1};
|
||||
@@ -73,6 +27,41 @@ use vim::{
|
||||
vim_a_quote, vim_a_word, vim_find_char_on_line, vim_find_matching_bracket, vim_inner_block,
|
||||
vim_inner_paragraph, vim_inner_quote, vim_inner_word, vim_word_iterator_from_offset,
|
||||
};
|
||||
use galaxy_core::platform::SessionPlatform;
|
||||
use galaxy_core::semantic_selection::SemanticSelection;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxy_editor::content::anchor::Anchor;
|
||||
use galaxy_editor::content::buffer::{
|
||||
AutoScrollBehavior, Buffer, BufferEditAction, BufferEvent, BufferSelectAction, EditOrigin,
|
||||
InitialBufferState, SelectionOffsets, ShouldAutoscroll, ToBufferCharOffset, ToBufferPoint,
|
||||
VimInsertPoint,
|
||||
};
|
||||
use galaxy_editor::content::edit::EditDelta;
|
||||
use galaxy_editor::content::find::{SearchConfig, SearchResults};
|
||||
use galaxy_editor::content::hidden_lines_model::HiddenLinesModel;
|
||||
use galaxy_editor::content::selection_model::BufferSelectionModel;
|
||||
use galaxy_editor::content::text::{BufferBlockStyle, IndentBehavior, IndentUnit};
|
||||
use galaxy_editor::content::version::BufferVersion;
|
||||
use galaxy_editor::decoration::DecorationLayer;
|
||||
use galaxy_editor::editor::TextDecoration;
|
||||
use galaxy_editor::model::{CoreEditorModel, PlainTextEditorModel};
|
||||
use galaxy_editor::multiline::{AnyMultilineString, MultilineString, LF};
|
||||
use galaxy_editor::render::model::{
|
||||
AutoScrollMode, BlockItem, BlockSpacings, BrokenLinkStyle, CheckBoxStyle, ColumnUnit,
|
||||
Decoration, HorizontalRuleStyle, InlineCodeStyle, LineCount, LineDecoration, ParagraphStyles,
|
||||
RenderEvent, RenderLineLocation, RenderState, RichTextStyles, StyleUpdateAction, TableStyle,
|
||||
UpdateDecorationAfterLayout, WidthSetting,
|
||||
};
|
||||
use galaxy_editor::selection::{SelectionMode, SelectionModel, TextDirection, TextUnit};
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
use galaxyui::elements::{
|
||||
AnchorPair, OffsetPositioning, OffsetType, PositionedElementOffsetBounds, PositioningAxis,
|
||||
XAxisAnchor, YAxisAnchor,
|
||||
};
|
||||
use galaxyui::text::point::Point;
|
||||
use galaxyui::text::TextBuffer;
|
||||
use galaxyui::units::{IntoPixels, Pixels};
|
||||
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::super::DiffResult;
|
||||
use super::comments::{EditorCommentsModel, PendingComment, PendingCommentEvent};
|
||||
@@ -80,7 +69,13 @@ use super::diff::{
|
||||
add_inline_overlay_color, DiffModel, DiffModelEvent, DiffStatus, RenderableDiffHunk,
|
||||
};
|
||||
use super::line::EditorLineLocation;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::code::editor::line_iterator::LineIterator;
|
||||
use crate::code_review::comments::{CommentId, CommentOrigin, LineDiffContent};
|
||||
use crate::editor::InteractionState;
|
||||
use crate::notebooks::editor::model::word_unit;
|
||||
use crate::themes::theme::AnsiColorIdentifier;
|
||||
use crate::util::link_detection::get_word_range_at_offset;
|
||||
|
||||
/// An opaque handle to a stable line in the editor content, suitable for scroll
|
||||
/// position preservation. Contains an internal anchor that tracks through
|
||||
@@ -261,7 +256,7 @@ impl DelayRendering {
|
||||
model.render_state.update(ctx, move |render_state, _| {
|
||||
let should_autoscroll = self.should_autoscroll;
|
||||
for (delta, content_version) in self.edits {
|
||||
render_state.add_pending_edit(delta.clone(), content_version);
|
||||
render_state.add_pending_edit(delta, content_version);
|
||||
}
|
||||
match should_autoscroll {
|
||||
ShouldAutoscroll::Yes => render_state.request_autoscroll(),
|
||||
@@ -338,7 +333,73 @@ impl CodeEditorModel {
|
||||
content.update(ctx, |buffer, _| {
|
||||
buffer.set_session_platform(session_platform);
|
||||
});
|
||||
ctx.subscribe_to_model(&content, |me, event, ctx| {
|
||||
|
||||
Self::from_content(
|
||||
content,
|
||||
true, // show_current_line_highlights
|
||||
lazy_layout, // lazy_layout_enabled
|
||||
false, // lazy_layout_initialized
|
||||
ctx,
|
||||
|hidden_lines, ctx| {
|
||||
ctx.add_model(|ctx| {
|
||||
RenderState::new(text_styles, lazy_layout, Some(hidden_lines.clone()), ctx)
|
||||
.with_width_setting(WidthSetting::InfiniteWidth)
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Constructs a `CodeEditorModel` in TUI char-cell mode.
|
||||
///
|
||||
/// Identical to `new` but creates the `RenderState` with
|
||||
/// [`LayoutMode::CharCell`] so all soft-wrap positions use monospace
|
||||
/// character-count arithmetic rather than font-aware pixel layout.
|
||||
/// `TuiEditorModel` (in `warp_tui`) is a type alias for this type;
|
||||
/// constructing via this method is what gives the TUI editor all of
|
||||
/// `CodeEditorModel`'s features (vim, syntax, diff, hidden lines) for free
|
||||
/// while sharing no GUI-rendering infrastructure.
|
||||
///
|
||||
/// Like `new`, this reads syntax-highlight colors from the `Appearance`
|
||||
/// singleton, so callers must register `Appearance` (a real one for the
|
||||
/// runtime, `Appearance::mock()` for tests) before constructing the model.
|
||||
pub fn new_tui(terminal_width: u16, ctx: &mut ModelContext<Self>) -> Self {
|
||||
let content = ctx.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
|
||||
|
||||
Self::from_content(
|
||||
content,
|
||||
false, // show_current_line_highlights: no GPU rendering in TUI
|
||||
false, // lazy_layout_enabled: no lazy layout in TUI
|
||||
true, // lazy_layout_initialized: no lazy layout in TUI
|
||||
ctx,
|
||||
|_hidden_lines, ctx| {
|
||||
// CharCell layout never consults `RichTextStyles`, so pass a stub.
|
||||
ctx.add_model(|ctx| {
|
||||
RenderState::new_tui(terminal_width, Self::tui_stub_text_styles(), ctx)
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Shared construction for [`Self::new`] and [`Self::new_tui`]. The two modes
|
||||
/// differ only in how the backing `content` buffer and the `RenderState` are
|
||||
/// built (GUI pixel layout vs. TUI char-cell layout) plus a few flags; all
|
||||
/// other sub-models (selection, syntax tree, diff, hidden lines, comments)
|
||||
/// and event subscriptions are identical and wired up here.
|
||||
///
|
||||
/// `build_render_state` receives the freshly-created `hidden_lines` handle so
|
||||
/// the GUI path can attach it; the TUI path ignores it.
|
||||
fn from_content(
|
||||
content: ModelHandle<Buffer>,
|
||||
show_current_line_highlights: bool,
|
||||
lazy_layout_enabled: bool,
|
||||
lazy_layout_initialized: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
build_render_state: impl FnOnce(
|
||||
&ModelHandle<HiddenLinesModel>,
|
||||
&mut ModelContext<Self>,
|
||||
) -> ModelHandle<RenderState>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(&content, |me, _, event, ctx| {
|
||||
me.handle_content_model_event(event, ctx);
|
||||
});
|
||||
|
||||
@@ -349,23 +410,20 @@ impl CodeEditorModel {
|
||||
let buffer_handle = content.downgrade();
|
||||
let syntax_tree =
|
||||
ctx.add_model(|_ctx| SyntaxTreeState::new(buffer_handle, buffer_version, color_map));
|
||||
ctx.subscribe_to_model(&syntax_tree, |me, event, ctx| {
|
||||
ctx.subscribe_to_model(&syntax_tree, |me, _, event, ctx| {
|
||||
me.handle_syntax_tree_model_event(event, ctx);
|
||||
});
|
||||
|
||||
let diff = ctx.add_model(|_ctx| DiffModel::new());
|
||||
ctx.subscribe_to_model(&diff, |me, event, ctx| {
|
||||
ctx.subscribe_to_model(&diff, |me, _, event, ctx| {
|
||||
me.handle_diff_model_event(event, ctx);
|
||||
});
|
||||
|
||||
let hidden_lines =
|
||||
ctx.add_model(|_| HiddenLinesModel::new(content.clone(), selection_model.clone()));
|
||||
|
||||
let render_state = ctx.add_model(|ctx| {
|
||||
RenderState::new(text_styles, lazy_layout, Some(hidden_lines.clone()), ctx)
|
||||
.with_width_setting(WidthSetting::InfiniteWidth)
|
||||
});
|
||||
ctx.subscribe_to_model(&render_state, |me, event, ctx| {
|
||||
let render_state = build_render_state(&hidden_lines, ctx);
|
||||
ctx.subscribe_to_model(&render_state, |me, _, event, ctx| {
|
||||
me.handle_render_state_model_event(event, ctx);
|
||||
});
|
||||
let selection = ctx.add_model(|ctx| {
|
||||
@@ -394,17 +452,96 @@ impl CodeEditorModel {
|
||||
hidden_lines,
|
||||
diff_navigation_state: DiffNavigationState::Collapsed,
|
||||
interaction_state: InteractionState::Editable,
|
||||
show_current_line_highlights: true,
|
||||
show_current_line_highlights,
|
||||
delay_rendering: None,
|
||||
vim_visual_tails: vec![],
|
||||
hovered_symbol_range: None,
|
||||
hide_lines_outside_of_active_diff: None,
|
||||
lazy_layout_enabled: lazy_layout,
|
||||
lazy_layout_initialized: false,
|
||||
lazy_layout_enabled,
|
||||
lazy_layout_initialized,
|
||||
pending_syntax_tree_bootstrap: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A minimal [`RichTextStyles`] for the TUI char-cell editor.
|
||||
///
|
||||
/// `RenderState::new_tui` stores styles only for API compatibility and never
|
||||
/// uses them for char-cell layout, so these values are placeholders. This
|
||||
/// lives here (the caller of `RenderState::new_tui`) rather than in the core
|
||||
/// editor crate so the editor API doesn't carry a TUI-specific stub.
|
||||
fn tui_stub_text_styles() -> RichTextStyles {
|
||||
use warpui::elements::{Border, Fill};
|
||||
use warpui::fonts::{FamilyId, Weight};
|
||||
|
||||
const TRANSPARENT: warpui::color::ColorU = warpui::color::ColorU {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 0,
|
||||
};
|
||||
let paragraph = |fixed_width_tab_size| ParagraphStyles {
|
||||
font_family: FamilyId(0),
|
||||
font_size: 10.,
|
||||
font_weight: Weight::Normal,
|
||||
line_height_ratio: 1.,
|
||||
text_color: TRANSPARENT,
|
||||
baseline_ratio: 0.7,
|
||||
fixed_width_tab_size,
|
||||
};
|
||||
RichTextStyles {
|
||||
base_text: paragraph(None),
|
||||
code_text: paragraph(Some(4)),
|
||||
code_background: Fill::None,
|
||||
embedding_background: Fill::None,
|
||||
embedding_text: paragraph(None),
|
||||
code_border: Border::new(0.),
|
||||
placeholder_color: TRANSPARENT,
|
||||
selection_fill: Fill::None,
|
||||
cursor_fill: Fill::None,
|
||||
inline_code_style: InlineCodeStyle {
|
||||
font_family: FamilyId(0),
|
||||
background: TRANSPARENT,
|
||||
font_color: TRANSPARENT,
|
||||
},
|
||||
check_box_style: CheckBoxStyle {
|
||||
border_width: 0.,
|
||||
border_color: TRANSPARENT,
|
||||
icon_path: "",
|
||||
background: TRANSPARENT,
|
||||
hover_background: TRANSPARENT,
|
||||
},
|
||||
horizontal_rule_style: HorizontalRuleStyle {
|
||||
rule_height: 0.,
|
||||
color: TRANSPARENT,
|
||||
},
|
||||
broken_link_style: BrokenLinkStyle {
|
||||
icon_path: "",
|
||||
icon_color: TRANSPARENT,
|
||||
},
|
||||
block_spacings: BlockSpacings::default(),
|
||||
minimum_paragraph_height: None,
|
||||
show_placeholder_text_on_empty_block: false,
|
||||
cursor_width: 0.,
|
||||
highlight_urls: false,
|
||||
table_style: TableStyle {
|
||||
border_color: TRANSPARENT,
|
||||
header_background: TRANSPARENT,
|
||||
cell_background: TRANSPARENT,
|
||||
alternate_row_background: None,
|
||||
text_color: TRANSPARENT,
|
||||
header_text_color: TRANSPARENT,
|
||||
scrollbar_nonactive_thumb_color: TRANSPARENT,
|
||||
scrollbar_active_thumb_color: TRANSPARENT,
|
||||
font_family: FamilyId(0),
|
||||
font_size: 10.,
|
||||
cell_padding: 0.,
|
||||
outer_border: false,
|
||||
column_dividers: false,
|
||||
row_dividers: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn should_defer_syntax_tree_parsing(&self) -> bool {
|
||||
self.lazy_layout_enabled && !self.lazy_layout_initialized
|
||||
}
|
||||
@@ -1151,7 +1288,11 @@ impl CodeEditorModel {
|
||||
}
|
||||
|
||||
/// Set the language of the syntax map based on the file path.
|
||||
pub fn set_language_with_path(&mut self, path: &Path, ctx: &mut ModelContext<Self>) {
|
||||
pub fn set_language_with_path(
|
||||
&mut self,
|
||||
path: &StandardizedPath,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let language = language_by_filename(path);
|
||||
|
||||
if let Some(language) = language {
|
||||
@@ -1159,6 +1300,15 @@ impl CodeEditorModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the language of the syntax map based on the local filesystem path.
|
||||
pub fn set_language_with_local_path(&mut self, path: &Path, ctx: &mut ModelContext<Self>) {
|
||||
let language = language_by_local_filename(path);
|
||||
|
||||
if let Some(language) = language {
|
||||
self.set_language(language, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_language_with_name(&mut self, name: &str, ctx: &mut ModelContext<Self>) {
|
||||
let language = language_by_name(name);
|
||||
if let Some(language) = language {
|
||||
@@ -1581,8 +1731,6 @@ impl CodeEditorModel {
|
||||
fn update_cursor_line_highlights(&self, ctx: &mut ModelContext<CodeEditorModel>) {
|
||||
let selection_model = self.selection_model.as_ref(ctx);
|
||||
|
||||
let overlay = Appearance::as_ref(ctx).theme().surface_2();
|
||||
|
||||
let highlight_line = if self.diff_nav_is_active() {
|
||||
// We don't show current line highlights during diff navigation so we don't need
|
||||
// to update the `RenderState`. This lets us keep the line decorations we set
|
||||
@@ -1591,6 +1739,7 @@ impl CodeEditorModel {
|
||||
} else if selection_model.all_single_cursors() && self.show_current_line_highlights {
|
||||
// When diff is not expanded, the only source of line decoration is highlights
|
||||
// from the active cursor, e.g. the current line highlight.
|
||||
let overlay = Appearance::as_ref(ctx).theme().surface_2();
|
||||
Some(
|
||||
selection_model
|
||||
.selected_lines(ctx)
|
||||
@@ -2302,7 +2451,7 @@ impl CodeEditorModel {
|
||||
self.vim_set_selections_preserving_goal_xs(new_selections, AutoScrollBehavior::None, ctx);
|
||||
}
|
||||
|
||||
/// Horziontal cursor movement for vim in the code editor.
|
||||
/// Horizontal cursor movement for vim in the code editor.
|
||||
/// Separate from the model's `move_left` and `move_right` functions to allow for stopping at
|
||||
/// line boundaries and vim-specific selection logic.
|
||||
pub fn vim_move_horizontal_by_offset(
|
||||
@@ -2428,7 +2577,7 @@ impl CodeEditorModel {
|
||||
if let Some(existing) = self.selection().as_ref(ctx).goal_xs.as_ref() {
|
||||
existing
|
||||
.iter()
|
||||
.map(|px| px.as_f32().round() as u32)
|
||||
.map(|col| col.as_pixels().as_f32().round() as u32)
|
||||
.collect()
|
||||
} else {
|
||||
current_selections
|
||||
@@ -2469,10 +2618,11 @@ impl CodeEditorModel {
|
||||
if let Ok(new_selections) = Vec1::try_from_vec(new_selections_vec) {
|
||||
self.vim_set_selections(new_selections, AutoScrollBehavior::Selection, ctx);
|
||||
|
||||
// Update goal_xs to the desired columns (stored as pixels for consistency with SelectionModel)
|
||||
// Update goal_xs to the desired columns (stored as ColumnUnit::Pixels for
|
||||
// consistency with the GUI SelectionModel pixel path)
|
||||
let goal_pixels: Vec<_> = goal_cols
|
||||
.into_iter()
|
||||
.map(|c| (c as usize).into_pixels())
|
||||
.map(|c| ColumnUnit::Pixels((c as usize).into_pixels()))
|
||||
.collect();
|
||||
self.selection().update(ctx, |selection, _| {
|
||||
selection.goal_xs = Vec1::try_from_vec(goal_pixels).ok();
|
||||
@@ -3642,11 +3792,19 @@ impl CoreEditorModel for CodeEditorModel {
|
||||
buffer_version: BufferVersion,
|
||||
ctx: &mut ModelContext<Self::T>,
|
||||
) {
|
||||
// Synchronously convert hidden range anchors into offsets for the given version. This allows the render model
|
||||
// to accurately hide line ranges based on the corresponding incoming buffer state.
|
||||
// Synchronously convert hidden range anchors into offsets for the given version. This allows
|
||||
// the render model to accurately hide line ranges based on the corresponding incoming buffer state.
|
||||
self.hidden_lines.update(ctx, |hidden_lines_model, ctx| {
|
||||
hidden_lines_model.materialize_hidden_range_offsets(buffer_version, ctx);
|
||||
});
|
||||
// In TUI char-cell mode the async font-shaping pipeline is bypassed entirely (the
|
||||
// LayoutAction::BufferEdit arm is a no-op for CharCell). We must therefore refresh the
|
||||
// char-cell line index synchronously here so that offset_to_softwrap_point, max_line,
|
||||
// and all cursor-positioning queries see up-to-date data in the same frame.
|
||||
if let Some(char_cell) = self.render_state.as_ref(ctx).char_cell() {
|
||||
let text = self.content.as_ref(ctx).text().into_string();
|
||||
char_cell.update_text(&text);
|
||||
}
|
||||
}
|
||||
|
||||
fn content(&self) -> &ModelHandle<Buffer> {
|
||||
@@ -3817,9 +3975,6 @@ impl CoreEditorModel for CodeEditorModel {
|
||||
|
||||
impl CodeEditorModel {
|
||||
pub fn open_comment_line(&mut self, line: &EditorLineLocation, ctx: &mut ModelContext<Self>) {
|
||||
// Telemetry: comment editor opened for a new inline review comment.
|
||||
send_telemetry_from_ctx!(CodeReviewTelemetryEvent::CommentEditorOpened, ctx);
|
||||
|
||||
self.comments.update(ctx, |comments, ctx| {
|
||||
comments.pending_comment = PendingComment::Open { line: line.clone() };
|
||||
ctx.emit(PendingCommentEvent::NewPendingComment(line.clone()));
|
||||
@@ -3834,9 +3989,6 @@ impl CodeEditorModel {
|
||||
origin: &CommentOrigin,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Telemetry: comment editor opened for editing an existing inline review comment.
|
||||
send_telemetry_from_ctx!(CodeReviewTelemetryEvent::CommentEditorOpened, ctx);
|
||||
|
||||
self.comments.update(ctx, |comments, ctx| {
|
||||
comments.pending_comment = PendingComment::Open { line: line.clone() };
|
||||
ctx.emit(PendingCommentEvent::ReopenPendingComment {
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
use futures::channel::oneshot;
|
||||
use galaxy_editor::content::buffer::{InitialBufferState, SelectionOffsets};
|
||||
use galaxy_editor::multiline::MultilineString;
|
||||
use galaxy_util::content_version::ContentVersion;
|
||||
use galaxyui::App;
|
||||
use std::path::Path;
|
||||
|
||||
use futures::channel::oneshot;
|
||||
use vec1::vec1;
|
||||
|
||||
use crate::{
|
||||
code::editor::line::EditorLineLocation, code::editor::view::code_text_styles,
|
||||
settings::FontSettings, test_util::settings::initialize_settings_for_tests,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::code::editor::line::EditorLineLocation;
|
||||
use crate::code::editor::view::code_text_styles;
|
||||
use crate::settings::FontSettings;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
|
||||
fn initialize_deps(app: &mut App) {
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
@@ -24,7 +20,7 @@ fn mock_model(app: &mut App, text: &str, version: ContentVersion) -> ModelHandle
|
||||
let mut model = CodeEditorModel::new(styles, None, false, None, ctx);
|
||||
let state = InitialBufferState::plain_text(text).with_version(version);
|
||||
model.reset_content(state, ctx);
|
||||
model.set_language_with_path(Path::new("test.rs"), ctx);
|
||||
model.set_language_with_local_path(Path::new("/test.rs"), ctx);
|
||||
model
|
||||
})
|
||||
}
|
||||
@@ -40,7 +36,7 @@ fn mock_model_with_diff(
|
||||
let mut model = CodeEditorModel::new(styles, None, false, None, ctx);
|
||||
let state = InitialBufferState::plain_text(current_text).with_version(version);
|
||||
model.reset_content(state, ctx);
|
||||
model.set_language_with_path(Path::new("test.rs"), ctx);
|
||||
model.set_language_with_local_path(Path::new("/test.rs"), ctx);
|
||||
|
||||
// Set up diff model with base text
|
||||
model.diff().update(ctx, |diff, _| {
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))]
|
||||
// Adding this file level gate as some of the code around editability is not used in WASM yet.
|
||||
|
||||
use galaxy_core::ui::{appearance::Appearance, theme::Fill};
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxy_editor::model::CoreEditorModel;
|
||||
use galaxyui::elements::{
|
||||
Align, Border, ConstrainedBox, Container, CrossAxisAlignment, Flex, MouseStateHandle,
|
||||
ParentElement, Shrinkable,
|
||||
};
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use galaxyui::units::IntoPixels;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Align, Border, ConstrainedBox, Container, CrossAxisAlignment, Flex, MouseStateHandle,
|
||||
ParentElement, Shrinkable,
|
||||
},
|
||||
presenter::ChildView,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{UiComponent, UiComponentStyles},
|
||||
},
|
||||
units::IntoPixels,
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
editor::InteractionState,
|
||||
ui_components::icons::Icon,
|
||||
view_components::action_button::{ActionButton, ButtonSize, NakedTheme},
|
||||
view_components::find::FIND_BAR_PADDING,
|
||||
};
|
||||
|
||||
use super::model::{CodeEditorModel, CodeEditorModelEvent};
|
||||
use crate::editor::InteractionState;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme};
|
||||
use crate::view_components::find::FIND_BAR_PADDING;
|
||||
|
||||
const NAV_BAR_HEIGHT: f32 = 40.;
|
||||
const NAV_BAR_ICON_SIZE: f32 = 16.;
|
||||
|
||||
+140
-79
@@ -1,88 +1,85 @@
|
||||
#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))]
|
||||
// Adding this file level gate as some of the code around editability is not used in WASM yet.
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt::Debug;
|
||||
use std::ops::Range;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::code::editor::{
|
||||
comment_editor::{CommentEditor, CommentEditorEvent},
|
||||
comments::PendingComment,
|
||||
diff::DiffStatus,
|
||||
element::{
|
||||
AddAsContextButton, CommentButton, EditorWrapper, EditorWrapperStateHandle,
|
||||
GutterHoverTarget, GutterRange, InnerEditor, LineNumberConfig, RevertHunkButton,
|
||||
},
|
||||
find::view::{CodeEditorFind as Find, Event as FindViewEvent},
|
||||
goto_line::view::{Event as GoToLineEvent, GoToLineView},
|
||||
line::EditorLineLocation,
|
||||
model::{CodeEditorModel, CodeEditorModelEvent, HoverableLink, LineBound, StableEditorLine},
|
||||
nav_bar::{NavBar, NavBarBehavior, NavBarEvent},
|
||||
scroll::{ScrollPosition, ScrollTrigger, ScrollWheelBehavior},
|
||||
};
|
||||
use crate::code::{
|
||||
editor::EditorReviewComment, DiffResult, NoopCommentEditorProvider,
|
||||
NoopFindReferencesCardProvider, ShowCommentEditorProvider, ShowFindReferencesCardProvider,
|
||||
};
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
code_review::comments::{CommentId, CommentOrigin},
|
||||
editor::InteractionState,
|
||||
features::FeatureFlag,
|
||||
notebooks::editor::rich_text_styles,
|
||||
settings::{AppEditorSettings, FontSettings},
|
||||
view_components::find::FindDirection,
|
||||
};
|
||||
use ai::diff_validation::DiffDelta;
|
||||
use galaxy_core::platform::SessionPlatform;
|
||||
use galaxy_editor::{
|
||||
content::{
|
||||
buffer::{
|
||||
Buffer, BufferEditAction, EditOrigin, InitialBufferState, ToBufferCharOffset as _,
|
||||
ToBufferPoint,
|
||||
},
|
||||
text::IndentUnit,
|
||||
version::BufferVersion,
|
||||
},
|
||||
model::{CoreEditorModel, PlainTextEditorModel},
|
||||
multiline::AnyMultilineString,
|
||||
render::{
|
||||
element::{
|
||||
lens_element::RichTextElementLens, DisplayOptions, DisplayStateHandle, RichTextElement,
|
||||
VerticalExpansionBehavior,
|
||||
},
|
||||
model::{
|
||||
AutoScrollMode, BlockSpacing, Decoration, ExpansionType, LineCount, ParagraphStyles,
|
||||
RichTextStyles, CODE_EDITOR_HIDDEN_SECTION_EXPANSION_LINES,
|
||||
},
|
||||
},
|
||||
search::{SearchEvent, Searcher, MATCH_FILL, SELECTED_MATCH_FILL},
|
||||
};
|
||||
use galaxy_util::content_version::ContentVersion;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
new_scrollable::{
|
||||
AxisConfiguration, DualAxisConfig, NewScrollableElement, ScrollableAppearance,
|
||||
},
|
||||
ChildAnchor, ChildView, Dismiss, Fill, Flex, Margin, MouseStateHandle, NewScrollable,
|
||||
OffsetPositioning, Padding, ParentAnchor, ParentElement, ParentOffsetBounds,
|
||||
ScrollStateHandle, Shrinkable, Stack,
|
||||
},
|
||||
event::ModifiersState,
|
||||
keymap::Keystroke,
|
||||
platform::Cursor,
|
||||
prelude::RectF,
|
||||
text::point::Point,
|
||||
units::Pixels,
|
||||
AppContext, BlurContext, Element, Entity, FocusContext, ModelHandle, SingletonEntity, View,
|
||||
ViewContext, ViewHandle, WeakViewHandle, WindowId,
|
||||
};
|
||||
use lazy_static::lazy_static;
|
||||
use num_traits::SaturatingSub;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use std::fmt::Debug;
|
||||
use std::rc::Rc;
|
||||
use std::{collections::HashMap, ops::Range};
|
||||
use std::{collections::HashSet, path::Path};
|
||||
use settings::Setting as _;
|
||||
use string_offset::CharOffset;
|
||||
use vec1::{vec1, Vec1};
|
||||
use vim::vim::{Direction, InsertPosition, VimMode, VimModel, VimState, VimSubscriber};
|
||||
use galaxy_core::platform::SessionPlatform;
|
||||
use galaxy_editor::content::buffer::{
|
||||
Buffer, BufferEditAction, EditOrigin, InitialBufferState, ToBufferCharOffset as _,
|
||||
ToBufferPoint,
|
||||
};
|
||||
use galaxy_editor::content::text::IndentUnit;
|
||||
use galaxy_editor::content::version::BufferVersion;
|
||||
use galaxy_editor::model::{CoreEditorModel, PlainTextEditorModel};
|
||||
use galaxy_editor::multiline::AnyMultilineString;
|
||||
use galaxy_editor::render::element::lens_element::RichTextElementLens;
|
||||
use galaxy_editor::render::element::{
|
||||
DisplayOptions, DisplayStateHandle, RichTextElement, VerticalExpansionBehavior,
|
||||
};
|
||||
use galaxy_editor::render::model::{
|
||||
AutoScrollMode, BlockSpacing, Decoration, ExpansionType, LineCount, ParagraphStyles,
|
||||
RichTextStyles, CODE_EDITOR_HIDDEN_SECTION_EXPANSION_LINES,
|
||||
};
|
||||
use galaxy_editor::search::{SearchEvent, Searcher, MATCH_FILL, SELECTED_MATCH_FILL};
|
||||
use galaxy_util::content_version::ContentVersion;
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
use galaxyui::elements::new_scrollable::{
|
||||
AxisConfiguration, DualAxisConfig, NewScrollableElement, ScrollableAppearance,
|
||||
};
|
||||
use galaxyui::elements::{
|
||||
ChildAnchor, ChildView, Dismiss, Fill, Flex, Margin, MouseStateHandle, NewScrollable,
|
||||
OffsetPositioning, Padding, ParentAnchor, ParentElement, ParentOffsetBounds, ScrollStateHandle,
|
||||
Shrinkable, Stack,
|
||||
};
|
||||
use galaxyui::event::ModifiersState;
|
||||
use galaxyui::keymap::Keystroke;
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::prelude::RectF;
|
||||
use galaxyui::text::point::Point;
|
||||
use galaxyui::units::Pixels;
|
||||
use galaxyui::{
|
||||
AppContext, BlurContext, CursorInfo, Element, Entity, FocusContext, ModelHandle,
|
||||
SingletonEntity, View, ViewContext, ViewHandle, WeakViewHandle, WindowId,
|
||||
};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::code::editor::comment_editor::{CommentEditor, CommentEditorEvent};
|
||||
use crate::code::editor::comments::PendingComment;
|
||||
use crate::code::editor::diff::DiffStatus;
|
||||
use crate::code::editor::element::{
|
||||
AddAsContextButton, CommentButton, EditorWrapper, EditorWrapperStateHandle, GutterHoverTarget,
|
||||
GutterRange, InnerEditor, LineNumberConfig, RevertHunkButton,
|
||||
};
|
||||
use crate::code::editor::find::view::{CodeEditorFind as Find, Event as FindViewEvent};
|
||||
use crate::code::editor::goto_line::view::{Event as GoToLineEvent, GoToLineView};
|
||||
use crate::code::editor::line::EditorLineLocation;
|
||||
use crate::code::editor::model::{
|
||||
CodeEditorModel, CodeEditorModelEvent, HoverableLink, LineBound, StableEditorLine,
|
||||
};
|
||||
use crate::code::editor::nav_bar::{NavBar, NavBarBehavior, NavBarEvent};
|
||||
use crate::code::editor::scroll::{ScrollPosition, ScrollTrigger, ScrollWheelBehavior};
|
||||
use crate::code::editor::EditorReviewComment;
|
||||
use crate::code::{
|
||||
DiffResult, NoopCommentEditorProvider, NoopFindReferencesCardProvider,
|
||||
ShowCommentEditorProvider, ShowFindReferencesCardProvider,
|
||||
};
|
||||
use crate::code_review::comments::{CommentId, CommentOrigin};
|
||||
use crate::editor::InteractionState;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::notebooks::editor::rich_text_styles;
|
||||
use crate::settings::{AppEditorSettings, CodeEditorLineNumberMode, FontSettings};
|
||||
use crate::view_components::find::FindDirection;
|
||||
|
||||
mod actions;
|
||||
pub use actions::init;
|
||||
@@ -123,6 +120,8 @@ pub enum CodeEditorEvent {
|
||||
},
|
||||
/// Emitted when a diff hunk is reverted
|
||||
DiffReverted,
|
||||
/// Emitted when the inline comment editor is opened.
|
||||
CommentEditorOpened,
|
||||
HiddenSectionExpanded,
|
||||
/// Emitted when a comment is saved. This gets propagated up so that it
|
||||
/// can be augmented with the file and repo paths and saved to the comment model.
|
||||
@@ -318,6 +317,10 @@ impl CodeEditorView {
|
||||
ctx.subscribe_to_model(&font_settings_handle, |me, _, _, ctx| {
|
||||
me.handle_appearance_or_font_change(ctx);
|
||||
});
|
||||
let app_editor_settings_handle = AppEditorSettings::handle(ctx);
|
||||
ctx.subscribe_to_model(&app_editor_settings_handle, |_, _, _, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let model = ctx.add_model(|ctx| {
|
||||
CodeEditorModel::new(
|
||||
@@ -1215,18 +1218,32 @@ impl CodeEditorView {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let theme = appearance.theme();
|
||||
if self.display_options.show_line_numbers {
|
||||
let editor_settings = AppEditorSettings::as_ref(ctx);
|
||||
Some(LineNumberConfig {
|
||||
font_family: appearance.monospace_font_family(),
|
||||
font_size: appearance.monospace_font_size(),
|
||||
text_color: theme.sub_text_color(theme.background()).into(),
|
||||
highlight_text_color: theme.main_text_color(theme.background()).into(),
|
||||
starting_line_number: self.display_options.starting_line_number,
|
||||
mode: *editor_settings.code_editor_line_number_mode.value(),
|
||||
active_line_number: self.active_cursor_line_for_line_numbers(ctx),
|
||||
active_cursor_is_visible: self.is_focused(ctx) && self.is_editable(ctx),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn active_cursor_line_for_line_numbers(&self, ctx: &AppContext) -> Option<LineCount> {
|
||||
let model = self.model.as_ref(ctx);
|
||||
let selection = *model.selections(ctx).first();
|
||||
let buffer = model.content().as_ref(ctx);
|
||||
let point = selection.head.to_buffer_point(buffer);
|
||||
// `LineCount`s used by render blocks are zero-based, while buffer points report rows using
|
||||
// the editor's one-based convention.
|
||||
Some(LineCount::from(point.row.saturating_sub(1) as usize))
|
||||
}
|
||||
|
||||
fn run_find(&mut self, query: &str, ctx: &mut ViewContext<Self>) {
|
||||
self.searcher.update(ctx, |searcher, ctx| {
|
||||
searcher.set_query(query.to_string(), ctx);
|
||||
@@ -1252,6 +1269,14 @@ impl CodeEditorView {
|
||||
self.reset_for_editing_change();
|
||||
self.vim_maybe_enforce_cursor_line_cap(ctx);
|
||||
ctx.emit(CodeEditorEvent::SelectionChanged);
|
||||
if *AppEditorSettings::as_ref(ctx)
|
||||
.code_editor_line_number_mode
|
||||
.value()
|
||||
== CodeEditorLineNumberMode::Relative
|
||||
{
|
||||
// Repaint relative line-number gutters when the cursor origin changes.
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
CodeEditorModelEvent::ContentChanged { origin } => {
|
||||
if origin.from_user() {
|
||||
@@ -1426,12 +1451,18 @@ impl CodeEditorView {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_language_with_path(&mut self, path: &Path, ctx: &mut ViewContext<Self>) {
|
||||
pub fn set_language_with_path(&mut self, path: &StandardizedPath, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.set_language_with_path(path, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_language_with_local_path(&mut self, path: &Path, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.set_language_with_local_path(path, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_language_with_name(&mut self, name: &str, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.set_language_with_name(name, ctx);
|
||||
@@ -2040,7 +2071,7 @@ impl CodeEditorView {
|
||||
}
|
||||
}
|
||||
|
||||
// If the character is opening autcomplete symbol, we want to autocomplete it with a closing symbol.
|
||||
// If the character is opening autocomplete symbol, we want to autocomplete it with a closing symbol.
|
||||
if let Some(close) = AUTOCOMPLETE_SYMBOLS.get(&first_char) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.autocomplete_symbol(first_char, *close, ctx);
|
||||
@@ -2126,6 +2157,7 @@ impl CodeEditorView {
|
||||
self.model.update(ctx, |editor_model, ctx| {
|
||||
editor_model.reopen_comment_line(id, location, comment_text, origin, ctx);
|
||||
});
|
||||
ctx.emit(CodeEditorEvent::CommentEditorOpened);
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
@@ -2356,6 +2388,18 @@ impl View for CodeEditorView {
|
||||
}
|
||||
}
|
||||
|
||||
fn active_cursor_position(&self, ctx: &ViewContext<Self>) -> Option<CursorInfo> {
|
||||
let render_state = self.model.as_ref(ctx).render_state().as_ref(ctx);
|
||||
let cursor_id = render_state.saved_positions().cursor_id();
|
||||
let font_size = render_state.styles().base_text.font_size;
|
||||
|
||||
ctx.element_position_by_id(cursor_id.as_str())
|
||||
.map(|position| CursorInfo {
|
||||
position,
|
||||
font_size,
|
||||
})
|
||||
}
|
||||
|
||||
fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext<Self>) {
|
||||
if blur_ctx.is_self_blurred() {
|
||||
ctx.notify();
|
||||
@@ -2370,8 +2414,14 @@ impl View for CodeEditorView {
|
||||
}
|
||||
if let Some(vim_mode) = self.vim_mode(app) {
|
||||
context.set.insert("Vim");
|
||||
if vim_mode == VimMode::Normal {
|
||||
context.set.insert("VimNormalMode");
|
||||
match vim_mode {
|
||||
VimMode::Normal => {
|
||||
context.set.insert("VimNormalMode");
|
||||
}
|
||||
VimMode::Visual(_) => {
|
||||
context.set.insert("VimVisualMode");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if self.find_bar.is_some() {
|
||||
@@ -2422,6 +2472,17 @@ impl CodeEditorView {
|
||||
};
|
||||
self.handle_goto_line_event(&event, ctx);
|
||||
}
|
||||
|
||||
pub fn displayed_line_number_for_test(
|
||||
&self,
|
||||
one_based_line_number: usize,
|
||||
ctx: &AppContext,
|
||||
) -> Option<usize> {
|
||||
let line_number_config = self.line_number_config(ctx)?;
|
||||
let line_count = LineCount::from(one_based_line_number.checked_sub(1)?);
|
||||
|
||||
Some(line_number_config.display_line_number(line_count))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,42 +1,36 @@
|
||||
#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))]
|
||||
// Adding this file level gate as some of the code around editability is not used in WASM yet.
|
||||
|
||||
use crate::code::editor::{
|
||||
line::EditorLineLocation,
|
||||
model::CodeEditorModel,
|
||||
view::{CodeEditorEvent, CodeEditorView, VimMode},
|
||||
};
|
||||
use crate::{
|
||||
cmd_or_ctrl_shift, code_review::comments::CommentId,
|
||||
code_review::telemetry_event::CodeReviewTelemetryEvent, editor::InteractionState,
|
||||
features::FeatureFlag, notebooks::editor::model::word_unit, send_telemetry_from_ctx,
|
||||
util::bindings::CustomAction,
|
||||
};
|
||||
use galaxy_editor::{
|
||||
content::version::BufferVersion,
|
||||
editor::{EmbeddedItemModel, RunnableCommandModel, TextDecoration},
|
||||
model::{CoreEditorModel, PlainTextEditorModel},
|
||||
render::{
|
||||
element::RichTextAction,
|
||||
model::{ExpansionType, LineCount, Location},
|
||||
},
|
||||
selection::{TextDirection, TextUnit},
|
||||
};
|
||||
use galaxy_util::user_input::UserInput;
|
||||
use galaxyui::{
|
||||
actions::StandardAction,
|
||||
elements::Axis,
|
||||
event::ModifiersState,
|
||||
keymap::{EditableBinding, FixedBinding, Keystroke, PerPlatformKeystroke},
|
||||
units::Pixels,
|
||||
AppContext, TypedActionView, ViewContext, WeakViewHandle,
|
||||
};
|
||||
use lazy_static::lazy_static;
|
||||
use rangemap::RangeSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt::Debug;
|
||||
use std::ops::Range;
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use rangemap::RangeSet;
|
||||
use string_offset::CharOffset;
|
||||
use galaxy_editor::content::version::BufferVersion;
|
||||
use galaxy_editor::editor::{EmbeddedItemModel, RunnableCommandModel, TextDecoration};
|
||||
use galaxy_editor::model::{CoreEditorModel, PlainTextEditorModel};
|
||||
use galaxy_editor::render::element::RichTextAction;
|
||||
use galaxy_editor::render::model::{ExpansionType, LineCount, Location};
|
||||
use galaxy_editor::selection::{TextDirection, TextUnit};
|
||||
use galaxy_util::user_input::UserInput;
|
||||
use galaxyui::actions::StandardAction;
|
||||
use galaxyui::elements::Axis;
|
||||
use galaxyui::event::ModifiersState;
|
||||
use galaxyui::keymap::{EditableBinding, FixedBinding, Keystroke, PerPlatformKeystroke};
|
||||
use galaxyui::units::Pixels;
|
||||
use galaxyui::{AppContext, TypedActionView, ViewContext, WeakViewHandle};
|
||||
|
||||
use crate::cmd_or_ctrl_shift;
|
||||
use crate::code::editor::line::EditorLineLocation;
|
||||
use crate::code::editor::model::CodeEditorModel;
|
||||
use crate::code::editor::view::{CodeEditorEvent, CodeEditorView, VimMode};
|
||||
use crate::code_review::comments::CommentId;
|
||||
use crate::editor::InteractionState;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::notebooks::editor::model::word_unit;
|
||||
use crate::util::bindings::CustomAction;
|
||||
|
||||
/// Limit the keybindings that conflict with the Agent Mode embedded editor.
|
||||
const NON_EDITABLE_KEYMAP_CONTEXT: &str = "NonEditableKeymapContext";
|
||||
@@ -508,8 +502,24 @@ pub fn init(app: &mut AppContext) {
|
||||
.with_context_predicate(text_entry.clone())
|
||||
.with_key_binding("cmdorctrl-/"),
|
||||
EditableBinding::new("editor_view:delete", "Delete", CodeEditorViewAction::Delete)
|
||||
.with_context_predicate(text_entry.clone())
|
||||
.with_context_predicate(
|
||||
text_entry.clone() & !id!("VimNormalMode") & !id!("VimVisualMode"),
|
||||
)
|
||||
.with_key_binding("ctrl-d"),
|
||||
EditableBinding::new(
|
||||
"editor_view:vim_scroll_half_page_down",
|
||||
"Scroll down half a page (vim)",
|
||||
CodeEditorViewAction::ScrollHalfPageDown,
|
||||
)
|
||||
.with_context_predicate(text_entry.clone() & (id!("VimNormalMode") | id!("VimVisualMode")))
|
||||
.with_key_binding("ctrl-d"),
|
||||
EditableBinding::new(
|
||||
"editor_view:vim_scroll_half_page_up",
|
||||
"Scroll up half a page (vim)",
|
||||
CodeEditorViewAction::ScrollHalfPageUp,
|
||||
)
|
||||
.with_context_predicate(text_entry.clone() & (id!("VimNormalMode") | id!("VimVisualMode")))
|
||||
.with_key_binding("ctrl-u"),
|
||||
EditableBinding::new(
|
||||
"editor_view:cut_word_left",
|
||||
"Cut word left",
|
||||
@@ -616,6 +626,8 @@ pub enum CodeEditorViewAction {
|
||||
ToggleComment,
|
||||
ScrollVertical(Pixels),
|
||||
ScrollHorizontal(Pixels),
|
||||
ScrollHalfPageDown,
|
||||
ScrollHalfPageUp,
|
||||
SelectUp,
|
||||
SelectDown,
|
||||
SelectLeft,
|
||||
@@ -753,6 +765,8 @@ impl CodeEditorViewAction {
|
||||
Self::WindowsCtrlC => true,
|
||||
Self::ScrollVertical(_)
|
||||
| Self::ScrollHorizontal(_)
|
||||
| Self::ScrollHalfPageDown
|
||||
| Self::ScrollHalfPageUp
|
||||
| Self::SelectUp
|
||||
| Self::SelectDown
|
||||
| Self::SelectLeft
|
||||
@@ -867,7 +881,7 @@ impl TypedActionView for CodeEditorView {
|
||||
match self.vim_mode(ctx) {
|
||||
Some(VimMode::Visual(_)) => {
|
||||
// In Vim Visual mode, if we get a ToggleComment request via the keyboard
|
||||
// shorcut (cmd+/), simulate `gc` to the VimModel so that we correctly
|
||||
// shortcut (cmd+/), simulate `gc` to the VimModel so that we correctly
|
||||
// calculate the current visual selections, apply the toggle, and exit to
|
||||
// normal mode.
|
||||
self.vim_user_insert("gc", ctx);
|
||||
@@ -889,6 +903,12 @@ impl TypedActionView for CodeEditorView {
|
||||
render_state.scroll_horizontal(*delta, ctx);
|
||||
})
|
||||
}),
|
||||
ScrollHalfPageDown => {
|
||||
self.vim_keystroke(&Keystroke::parse("ctrl-d").expect("ctrl-d parses"), ctx)
|
||||
}
|
||||
ScrollHalfPageUp => {
|
||||
self.vim_keystroke(&Keystroke::parse("ctrl-u").expect("ctrl-u parses"), ctx)
|
||||
}
|
||||
SelectUp => self.model.update(ctx, |model, ctx| {
|
||||
model.select_up(ctx);
|
||||
}),
|
||||
@@ -1067,8 +1087,6 @@ impl TypedActionView for CodeEditorView {
|
||||
}
|
||||
RevertDiffHunk { line_range } => {
|
||||
if FeatureFlag::RevertDiffHunk.is_enabled() {
|
||||
send_telemetry_from_ctx!(CodeReviewTelemetryEvent::RevertHunkClicked, ctx);
|
||||
|
||||
// Convert line range to diff hunk index and revert it
|
||||
let hunk_index = self
|
||||
.model
|
||||
@@ -1093,6 +1111,7 @@ impl TypedActionView for CodeEditorView {
|
||||
self.model.update(ctx, |model: &mut CodeEditorModel, ctx| {
|
||||
model.open_comment_line(line_info, ctx);
|
||||
});
|
||||
ctx.emit(CodeEditorEvent::CommentEditorOpened);
|
||||
|
||||
ctx.focus(&self.active_comment_editor);
|
||||
ctx.notify();
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_editor::render::element::VerticalExpansionBehavior;
|
||||
use galaxyui::{
|
||||
elements::{new_scrollable::ScrollableAppearance, ScrollbarWidth},
|
||||
platform::WindowStyle,
|
||||
App, TypedActionView, ViewHandle, WindowId,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
cloud_object::model::persistence::CloudModel,
|
||||
editor::InteractionState,
|
||||
notebooks::editor::keys::NotebookKeybindings,
|
||||
server::server_api::{team::MockTeamClient, workspace::MockWorkspaceClient},
|
||||
settings_view::keybindings::KeybindingChangedNotifier,
|
||||
test_util::settings::initialize_settings_for_tests,
|
||||
vim_registers::VimRegisters,
|
||||
workspace::{sync_inputs::SyncedInputState, ActiveSession},
|
||||
workspaces::user_workspaces::UserWorkspaces,
|
||||
AuthStateProvider,
|
||||
};
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_editor::render::element::VerticalExpansionBehavior;
|
||||
use galaxy_util::user_input::UserInput;
|
||||
use galaxyui::elements::new_scrollable::ScrollableAppearance;
|
||||
use galaxyui::elements::ScrollbarWidth;
|
||||
use galaxyui::platform::WindowStyle;
|
||||
use galaxyui::{App, TypedActionView, ViewHandle, WindowId};
|
||||
|
||||
use super::{CodeEditorRenderOptions, CodeEditorView, CodeEditorViewAction};
|
||||
use galaxy_util::user_input::UserInput;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::editor::InteractionState;
|
||||
use crate::notebooks::editor::keys::NotebookKeybindings;
|
||||
use crate::server::server_api::team::MockTeamClient;
|
||||
use crate::server::server_api::workspace::MockWorkspaceClient;
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::vim_registers::VimRegisters;
|
||||
use crate::workspace::sync_inputs::SyncedInputState;
|
||||
use crate::workspace::ActiveSession;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::AuthStateProvider;
|
||||
|
||||
fn initialize_editor(app: &mut App) -> (WindowId, ViewHandle<CodeEditorView>) {
|
||||
initialize_settings_for_tests(app);
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
use super::{CodeEditorEvent, CodeEditorView};
|
||||
use crate::code::editor::{
|
||||
find::view::Event as FindViewEvent,
|
||||
model::{CaseTransform, CodeEditorModel, LineBound},
|
||||
};
|
||||
use crate::{
|
||||
view_components::find::FindDirection,
|
||||
vim_registers::{RegisterContent, VimRegisters},
|
||||
};
|
||||
use galaxy_editor::{
|
||||
content::buffer::{
|
||||
AutoScrollBehavior, BufferEditAction, EditOrigin, SelectionOffsets,
|
||||
ToBufferCharOffset as _, VimInsertPoint,
|
||||
},
|
||||
model::{CoreEditorModel, PlainTextEditorModel},
|
||||
selection::{TextDirection, TextUnit},
|
||||
};
|
||||
use galaxyui::{text::point::Point, SingletonEntity, ViewContext};
|
||||
use vim::vim::{
|
||||
BracketChar, CharacterMotion, Direction, FindCharMotion, FirstNonWhitespaceMotion,
|
||||
InsertPosition, LineMotion, ModeTransition, MotionType, TextObjectType, VimHandler, VimMode,
|
||||
VimMotion, VimOperand, VimOperator, VimTextObject, WordMotion,
|
||||
};
|
||||
use galaxy_editor::content::buffer::{
|
||||
AutoScrollBehavior, BufferEditAction, EditOrigin, SelectionOffsets, ToBufferCharOffset as _,
|
||||
VimInsertPoint,
|
||||
};
|
||||
use galaxy_editor::model::{CoreEditorModel, PlainTextEditorModel};
|
||||
use galaxy_editor::render::model::AutoScrollMode;
|
||||
use galaxy_editor::selection::{TextDirection, TextUnit};
|
||||
use galaxyui::text::point::Point;
|
||||
use galaxyui::units::IntoPixels;
|
||||
use galaxyui::{SingletonEntity, ViewContext};
|
||||
|
||||
use super::{CodeEditorEvent, CodeEditorView};
|
||||
use crate::code::editor::find::view::Event as FindViewEvent;
|
||||
use crate::code::editor::model::{CaseTransform, CodeEditorModel, LineBound};
|
||||
use crate::view_components::find::FindDirection;
|
||||
use crate::vim_registers::{RegisterContent, VimRegisters};
|
||||
|
||||
impl VimHandler for CodeEditorView {
|
||||
fn insert_char(&mut self, c: char, ctx: &mut ViewContext<Self>) {
|
||||
@@ -921,6 +919,62 @@ impl VimHandler for CodeEditorView {
|
||||
fn show_hover(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.emit(CodeEditorEvent::VimShowHover);
|
||||
}
|
||||
|
||||
fn center_cursor_vertically(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let cursor_offset = self
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.buffer_selection_model()
|
||||
.as_ref(ctx)
|
||||
.first_selection_head();
|
||||
self.model
|
||||
.as_ref(ctx)
|
||||
.render_state()
|
||||
.clone()
|
||||
.update(ctx, |render_state, _ctx| {
|
||||
render_state.request_autoscroll_to(AutoScrollMode::PositionOffsetInViewportCenter(
|
||||
cursor_offset,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
fn scroll_half_page_down(&mut self, count: u32, ctx: &mut ViewContext<Self>) {
|
||||
self.scroll_half_page(count, TextDirection::Forwards, ctx);
|
||||
}
|
||||
|
||||
fn scroll_half_page_up(&mut self, count: u32, ctx: &mut ViewContext<Self>) {
|
||||
self.scroll_half_page(count, TextDirection::Backwards, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
impl CodeEditorView {
|
||||
/// Implements `<C-d>` and `<C-u>`. Without a count, scrolls by half the
|
||||
/// viewport; with a count > 1, scrolls by that many lines (matching vim's
|
||||
/// `n<C-d>` / `n<C-u>` behavior).
|
||||
fn scroll_half_page(
|
||||
&mut self,
|
||||
count: u32,
|
||||
direction: TextDirection,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let model = self.model.as_ref(ctx);
|
||||
let lines = if count > 1 {
|
||||
count as usize
|
||||
} else {
|
||||
(model.lines_in_viewport(ctx) / 2).max(1)
|
||||
};
|
||||
let signed_lines = match direction {
|
||||
TextDirection::Forwards => -(lines as f32),
|
||||
TextDirection::Backwards => lines as f32,
|
||||
};
|
||||
let scroll_pixels = (signed_lines * model.line_height(ctx)).into_pixels();
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.vim_move_vertical_by_offset(lines as u32, direction, false, ctx);
|
||||
model.render_state().update(ctx, |render_state, ctx| {
|
||||
render_state.scroll(scroll_pixels, ctx);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`str::trim_end_matches`] except that it only trims up to a single instance.
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::code::editor::view::CodeEditorRenderOptions;
|
||||
use crate::notebooks::editor::keys::NotebookKeybindings;
|
||||
use crate::workspace::ActiveSession;
|
||||
use crate::{
|
||||
code::editor::view::{CodeEditorView, CodeEditorViewAction},
|
||||
server::server_api::{team::MockTeamClient, workspace::MockWorkspaceClient},
|
||||
settings::AppEditorSettings,
|
||||
settings_view::keybindings::KeybindingChangedNotifier,
|
||||
test_util::settings::initialize_settings_for_tests,
|
||||
vim_registers::VimRegisters,
|
||||
workspace::sync_inputs::SyncedInputState,
|
||||
workspaces::user_workspaces::UserWorkspaces,
|
||||
};
|
||||
use galaxy_core::{features::FeatureFlag, settings::Setting, ui::appearance::Appearance};
|
||||
use galaxy_editor::model::CoreEditorModel;
|
||||
use galaxy_editor::{
|
||||
content::buffer::{InitialBufferState, ToBufferCharOffset, ToBufferPoint},
|
||||
render::element::VerticalExpansionBehavior,
|
||||
};
|
||||
use galaxy_util::user_input::UserInput;
|
||||
use galaxyui::text::point::Point;
|
||||
use galaxyui::{
|
||||
keymap::Keystroke, platform::WindowStyle, App, SingletonEntity, TypedActionView, UpdateModel,
|
||||
ViewHandle,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use unindent::Unindent;
|
||||
use vim::vim::{MotionType, VimMode};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::settings::Setting;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_editor::content::buffer::{InitialBufferState, ToBufferCharOffset, ToBufferPoint};
|
||||
use galaxy_editor::model::CoreEditorModel;
|
||||
use galaxy_editor::render::element::VerticalExpansionBehavior;
|
||||
use galaxy_editor::render::model::viewport::SizeInfo;
|
||||
use galaxy_util::user_input::UserInput;
|
||||
use galaxyui::keymap::Keystroke;
|
||||
use galaxyui::platform::WindowStyle;
|
||||
use galaxyui::text::point::Point;
|
||||
use galaxyui::units::IntoPixels;
|
||||
use galaxyui::{App, SingletonEntity, TypedActionView, UpdateModel, ViewHandle};
|
||||
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::code::editor::view::{CodeEditorRenderOptions, CodeEditorView, CodeEditorViewAction};
|
||||
use crate::notebooks::editor::keys::NotebookKeybindings;
|
||||
use crate::server::server_api::team::MockTeamClient;
|
||||
use crate::server::server_api::workspace::MockWorkspaceClient;
|
||||
use crate::settings::AppEditorSettings;
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::vim_registers::VimRegisters;
|
||||
use crate::workspace::sync_inputs::SyncedInputState;
|
||||
use crate::workspace::ActiveSession;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
// Await render/layout completion for a CodeEditorView in tests.
|
||||
async fn layout_editor_view(app: &mut App, editor: &ViewHandle<CodeEditorView>) {
|
||||
@@ -143,6 +145,37 @@ fn set_cursor_position(editor: &ViewHandle<CodeEditorView>, row: usize, col: usi
|
||||
});
|
||||
}
|
||||
|
||||
/// Set the viewport to exactly `lines` rows tall. Returns the line height in pixels.
|
||||
fn set_viewport_lines(editor: &ViewHandle<CodeEditorView>, lines: usize, app: &mut App) -> f32 {
|
||||
let (line_height, render_state) = editor.read(app, |view, ctx| {
|
||||
let model = view.model.as_ref(ctx);
|
||||
(model.line_height(ctx), model.render_state().clone())
|
||||
});
|
||||
render_state.update(app, |render_state, ctx| {
|
||||
render_state.set_viewport_size(
|
||||
SizeInfo {
|
||||
viewport_size: Vector2F::new(800.0, line_height * lines as f32),
|
||||
needs_layout: false,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
line_height
|
||||
}
|
||||
|
||||
/// Read the current vertical scroll position.
|
||||
fn scroll_top(editor: &ViewHandle<CodeEditorView>, app: &App) -> f32 {
|
||||
editor.read(app, |view, ctx| {
|
||||
view.model
|
||||
.as_ref(ctx)
|
||||
.render_state()
|
||||
.as_ref(ctx)
|
||||
.viewport()
|
||||
.scroll_top()
|
||||
.as_f32()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_code_editor_vim_basic_mode_switching() {
|
||||
let _feature_flag_guard = FeatureFlag::VimCodeEditor.override_enabled(true);
|
||||
@@ -1436,3 +1469,266 @@ fn test_vim_visual_linewise_delete_first_line_does_not_panic() {
|
||||
assert_eq!(buffer_text(&editor, &app), "bbb\nccc");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vim_zz_in_normal_mode_preserves_cursor_and_mode() {
|
||||
let _feature_flag_guard = FeatureFlag::VimCodeEditor.override_enabled(true);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_code_editor_app(&mut app);
|
||||
let editor = add_code_editor(
|
||||
"line 1
|
||||
line 2
|
||||
line 3
|
||||
line 4
|
||||
line 5",
|
||||
&mut app,
|
||||
);
|
||||
|
||||
layout_editor_view(&mut app, &editor).await;
|
||||
|
||||
// Place cursor on line 3, then center it. zz scrolls but should not move
|
||||
// the cursor or change the mode.
|
||||
set_cursor_position(&editor, 3, 0, &mut app);
|
||||
assert_eq!(cursor_position(&editor, &app), (3, 0));
|
||||
assert_eq!(vim_mode(&editor, &app), Some(VimMode::Normal));
|
||||
|
||||
vim_user_insert(&editor, "zz", &mut app);
|
||||
|
||||
assert_eq!(cursor_position(&editor, &app), (3, 0));
|
||||
assert_eq!(vim_mode(&editor, &app), Some(VimMode::Normal));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vim_zz_in_visual_mode_preserves_cursor_and_mode() {
|
||||
let _feature_flag_guard = FeatureFlag::VimCodeEditor.override_enabled(true);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_code_editor_app(&mut app);
|
||||
let editor = add_code_editor(
|
||||
"line 1
|
||||
line 2
|
||||
line 3
|
||||
line 4
|
||||
line 5",
|
||||
&mut app,
|
||||
);
|
||||
|
||||
layout_editor_view(&mut app, &editor).await;
|
||||
|
||||
set_cursor_position(&editor, 3, 0, &mut app);
|
||||
vim_user_insert(&editor, "v", &mut app);
|
||||
assert_eq!(
|
||||
vim_mode(&editor, &app),
|
||||
Some(VimMode::Visual(MotionType::Charwise))
|
||||
);
|
||||
|
||||
vim_user_insert(&editor, "zz", &mut app);
|
||||
|
||||
assert_eq!(cursor_position(&editor, &app), (3, 0));
|
||||
assert_eq!(
|
||||
vim_mode(&editor, &app),
|
||||
Some(VimMode::Visual(MotionType::Charwise))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vim_z_followed_by_non_z_clears_pending() {
|
||||
let _feature_flag_guard = FeatureFlag::VimCodeEditor.override_enabled(true);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_code_editor_app(&mut app);
|
||||
let editor = add_code_editor(
|
||||
"line 1
|
||||
line 2
|
||||
line 3",
|
||||
&mut app,
|
||||
);
|
||||
|
||||
layout_editor_view(&mut app, &editor).await;
|
||||
|
||||
// `z` followed by an unrecognized char should clear the pending action.
|
||||
// After that, a subsequent `j` should move the cursor down one line as normal.
|
||||
set_cursor_position(&editor, 1, 0, &mut app);
|
||||
vim_user_insert(&editor, "z", &mut app);
|
||||
vim_user_insert(&editor, "x", &mut app);
|
||||
assert_eq!(cursor_position(&editor, &app), (1, 0));
|
||||
|
||||
vim_user_insert(&editor, "j", &mut app);
|
||||
assert_eq!(cursor_position(&editor, &app), (2, 0));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vim_ctrl_d_scrolls_half_page_down() {
|
||||
let _feature_flag_guard = FeatureFlag::VimCodeEditor.override_enabled(true);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_code_editor_app(&mut app);
|
||||
let buffer: String = (1..=200).map(|i| format!("line {}\n", i)).collect();
|
||||
let editor = add_code_editor(buffer.as_str(), &mut app);
|
||||
|
||||
layout_editor_view(&mut app, &editor).await;
|
||||
|
||||
// 20 visible lines → half page = 10 lines.
|
||||
let line_height = set_viewport_lines(&editor, 20, &mut app);
|
||||
let half_page = 10;
|
||||
|
||||
set_cursor_position(&editor, 1, 0, &mut app);
|
||||
let (start_row, _) = cursor_position(&editor, &app);
|
||||
let start_scroll = scroll_top(&editor, &app);
|
||||
|
||||
editor.update(&mut app, |view, ctx| {
|
||||
view.vim_keystroke(&Keystroke::parse("ctrl-d").unwrap(), ctx);
|
||||
});
|
||||
|
||||
let (after_row, _) = cursor_position(&editor, &app);
|
||||
let after_scroll = scroll_top(&editor, &app);
|
||||
assert_eq!(after_row, start_row + half_page);
|
||||
assert!(
|
||||
(after_scroll - start_scroll - half_page as f32 * line_height).abs() < 0.5,
|
||||
"scroll_top should advance by half_page * line_height \
|
||||
(start={start_scroll}, after={after_scroll}, line_height={line_height})",
|
||||
);
|
||||
assert_eq!(vim_mode(&editor, &app), Some(VimMode::Normal));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vim_ctrl_u_scrolls_half_page_up() {
|
||||
let _feature_flag_guard = FeatureFlag::VimCodeEditor.override_enabled(true);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_code_editor_app(&mut app);
|
||||
let buffer: String = (1..=200).map(|i| format!("line {}\n", i)).collect();
|
||||
let editor = add_code_editor(buffer.as_str(), &mut app);
|
||||
|
||||
layout_editor_view(&mut app, &editor).await;
|
||||
|
||||
// 20 visible lines → half page = 10 lines.
|
||||
let line_height = set_viewport_lines(&editor, 20, &mut app);
|
||||
let half_page = 10;
|
||||
|
||||
// Start near the bottom and scroll down so we have room to scroll up.
|
||||
set_cursor_position(&editor, 100, 0, &mut app);
|
||||
let render_state = editor.read(&app, |view, ctx| {
|
||||
view.model.as_ref(ctx).render_state().clone()
|
||||
});
|
||||
render_state.update(&mut app, |render_state, ctx| {
|
||||
render_state.scroll(-(50.0 * line_height).into_pixels(), ctx);
|
||||
});
|
||||
|
||||
let (start_row, _) = cursor_position(&editor, &app);
|
||||
let start_scroll = scroll_top(&editor, &app);
|
||||
|
||||
editor.update(&mut app, |view, ctx| {
|
||||
view.vim_keystroke(&Keystroke::parse("ctrl-u").unwrap(), ctx);
|
||||
});
|
||||
|
||||
let (after_row, _) = cursor_position(&editor, &app);
|
||||
let after_scroll = scroll_top(&editor, &app);
|
||||
assert_eq!(after_row, start_row - half_page);
|
||||
assert!(
|
||||
(start_scroll - after_scroll - half_page as f32 * line_height).abs() < 0.5,
|
||||
"scroll_top should retreat by half_page * line_height \
|
||||
(start={start_scroll}, after={after_scroll}, line_height={line_height})",
|
||||
);
|
||||
assert_eq!(vim_mode(&editor, &app), Some(VimMode::Normal));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vim_ctrl_d_with_count_scrolls_n_lines() {
|
||||
let _feature_flag_guard = FeatureFlag::VimCodeEditor.override_enabled(true);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_code_editor_app(&mut app);
|
||||
let buffer: String = (1..=200).map(|i| format!("line {}\n", i)).collect();
|
||||
let editor = add_code_editor(buffer.as_str(), &mut app);
|
||||
|
||||
layout_editor_view(&mut app, &editor).await;
|
||||
|
||||
// Viewport half page would be 10, but `5<C-d>` should scroll by 5 lines,
|
||||
// not 5 * half_page.
|
||||
set_viewport_lines(&editor, 20, &mut app);
|
||||
|
||||
set_cursor_position(&editor, 1, 0, &mut app);
|
||||
let (start_row, _) = cursor_position(&editor, &app);
|
||||
|
||||
vim_user_insert(&editor, "5", &mut app);
|
||||
editor.update(&mut app, |view, ctx| {
|
||||
view.vim_keystroke(&Keystroke::parse("ctrl-d").unwrap(), ctx);
|
||||
});
|
||||
|
||||
let (after_row, _) = cursor_position(&editor, &app);
|
||||
assert_eq!(after_row, start_row + 5);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vim_ctrl_d_consumes_pending_count() {
|
||||
let _feature_flag_guard = FeatureFlag::VimCodeEditor.override_enabled(true);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_code_editor_app(&mut app);
|
||||
// Use a buffer big enough that scrolling won't max the cursor at the bottom.
|
||||
let buffer: String = (1..=200)
|
||||
.map(|i| format!("line {}\n", i))
|
||||
.collect::<String>();
|
||||
let editor = add_code_editor(buffer.as_str(), &mut app);
|
||||
|
||||
layout_editor_view(&mut app, &editor).await;
|
||||
|
||||
// After `2<C-d>`, the pending count of 2 must be consumed by ctrl-d. A
|
||||
// following `j` should move the cursor down exactly 1 line, not 2.
|
||||
set_cursor_position(&editor, 1, 0, &mut app);
|
||||
vim_user_insert(&editor, "2", &mut app);
|
||||
editor.update(&mut app, |view, ctx| {
|
||||
view.vim_keystroke(&Keystroke::parse("ctrl-d").unwrap(), ctx);
|
||||
});
|
||||
let (after_scroll_row, _) = cursor_position(&editor, &app);
|
||||
vim_user_insert(&editor, "j", &mut app);
|
||||
let (after_j_row, _) = cursor_position(&editor, &app);
|
||||
assert_eq!(
|
||||
after_j_row,
|
||||
after_scroll_row + 1,
|
||||
"j after `2<C-d>` should move down 1, not 2 (after_scroll_row={}, after_j_row={})",
|
||||
after_scroll_row,
|
||||
after_j_row
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vim_ctrl_d_clears_pending_operator() {
|
||||
let _feature_flag_guard = FeatureFlag::VimCodeEditor.override_enabled(true);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_code_editor_app(&mut app);
|
||||
let editor = add_code_editor(
|
||||
"alpha bravo charlie
|
||||
delta echo foxtrot
|
||||
golf hotel india",
|
||||
&mut app,
|
||||
);
|
||||
|
||||
layout_editor_view(&mut app, &editor).await;
|
||||
|
||||
// After `d<C-d>`, the pending `d` operator must be cleared. A following
|
||||
// `w` should move forward by word, not delete a word.
|
||||
set_cursor_position(&editor, 1, 0, &mut app);
|
||||
let original = buffer_text(&editor, &app);
|
||||
vim_user_insert(&editor, "d", &mut app);
|
||||
editor.update(&mut app, |view, ctx| {
|
||||
view.vim_keystroke(&Keystroke::parse("ctrl-d").unwrap(), ctx);
|
||||
});
|
||||
vim_user_insert(&editor, "w", &mut app);
|
||||
assert_eq!(
|
||||
buffer_text(&editor, &app),
|
||||
original,
|
||||
"w after `d<C-d>` should not delete (pending d should be cleared)"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
use std::{
|
||||
collections::{hash_map::Entry, HashMap},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::ai::skills::SkillOpenOrigin;
|
||||
use ai::skills::SkillReference;
|
||||
use galaxy_util::path::LineAndColumnArg;
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity, ViewHandle, WindowId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
ai::agent::AIAgentActionId,
|
||||
code_review::code_review_view::CodeReviewView,
|
||||
pane_group::{PaneGroup, PaneId},
|
||||
workspace::PaneViewLocator,
|
||||
};
|
||||
|
||||
use super::buffer_location::LocalOrRemotePath;
|
||||
use super::view::CodeView;
|
||||
use crate::ai::agent::AIAgentActionId;
|
||||
use crate::ai::skills::SkillOpenOrigin;
|
||||
use crate::code_review::code_review_view::CodeReviewView;
|
||||
use crate::pane_group::{PaneGroup, PaneId};
|
||||
use crate::workspace::PaneViewLocator;
|
||||
|
||||
pub struct CodeEditorSummary<'a> {
|
||||
pub unsaved_changes: Vec<&'a CodeEditorStatus>,
|
||||
@@ -116,16 +113,18 @@ pub enum CodeSource {
|
||||
},
|
||||
/// Opened from an active AI agent conversation.
|
||||
AIAction { id: AIAgentActionId },
|
||||
/// Opened from project rules (GALAXY.md) file.
|
||||
ProjectRules { path: PathBuf },
|
||||
/// Opened from file tree.
|
||||
FileTree { path: PathBuf },
|
||||
/// Opened from project rules (WARP.md) file.
|
||||
ProjectRules { location: LocalOrRemotePath },
|
||||
/// Opened from file tree (local or remote).
|
||||
FileTree { location: LocalOrRemotePath },
|
||||
/// Opened from command palette file search (local or remote).
|
||||
CommandPalette { location: LocalOrRemotePath },
|
||||
/// Opened from macOS Finder via "Open With".
|
||||
Finder { path: PathBuf },
|
||||
/// Opened from a skill.
|
||||
Skill {
|
||||
reference: SkillReference,
|
||||
path: PathBuf,
|
||||
location: LocalOrRemotePath,
|
||||
origin: SkillOpenOrigin,
|
||||
},
|
||||
}
|
||||
@@ -140,6 +139,7 @@ impl CodeSource {
|
||||
| Self::AIAction { .. }
|
||||
| Self::ProjectRules { .. }
|
||||
| Self::FileTree { .. }
|
||||
| Self::CommandPalette { .. }
|
||||
| Self::Finder { .. }
|
||||
| Self::Skill { .. } => None,
|
||||
}
|
||||
@@ -148,11 +148,44 @@ impl CodeSource {
|
||||
pub fn path(&self) -> Option<PathBuf> {
|
||||
match self {
|
||||
Self::New { .. } | Self::AIAction { .. } => None,
|
||||
Self::Link { path, .. }
|
||||
| Self::ProjectRules { path }
|
||||
| Self::FileTree { path }
|
||||
| Self::Finder { path }
|
||||
| Self::Skill { path, .. } => Some(path.clone()),
|
||||
Self::FileTree { location, .. } | Self::CommandPalette { location, .. } => {
|
||||
match location {
|
||||
LocalOrRemotePath::Local(path) => Some(path.clone()),
|
||||
LocalOrRemotePath::Remote(_) => None,
|
||||
}
|
||||
}
|
||||
Self::Link { path, .. } | Self::Finder { path } => Some(path.clone()),
|
||||
Self::ProjectRules { location } | Self::Skill { location, .. } => {
|
||||
location.to_local_path().map(Path::to_path_buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the `LocalOrRemotePath` for file tree sources.
|
||||
pub fn file_location(&self) -> Option<&LocalOrRemotePath> {
|
||||
match self {
|
||||
Self::FileTree { location } | Self::CommandPalette { location } => Some(location),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the `LocalOrRemotePath` for any source that has a backing file.
|
||||
///
|
||||
/// Unlike `path()` (which only returns local paths) and `file_location()`
|
||||
/// (which only covers `FileTree`), this covers every variant that maps to
|
||||
/// a file — local or remote.
|
||||
pub fn location(&self) -> Option<LocalOrRemotePath> {
|
||||
match self {
|
||||
Self::New { .. } | Self::AIAction { .. } => None,
|
||||
Self::FileTree { location } | Self::CommandPalette { location } => {
|
||||
Some(location.clone())
|
||||
}
|
||||
Self::Link { path, .. } | Self::Finder { path } => {
|
||||
Some(LocalOrRemotePath::Local(path.clone()))
|
||||
}
|
||||
Self::ProjectRules { location } | Self::Skill { location, .. } => {
|
||||
Some(location.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +219,14 @@ impl CodeSource {
|
||||
Self::Link { .. } => "link",
|
||||
Self::AIAction { .. } => "ai_action",
|
||||
Self::ProjectRules { .. } => "project_rules",
|
||||
Self::FileTree {
|
||||
location: LocalOrRemotePath::Remote(_),
|
||||
} => "remote_file_tree",
|
||||
Self::FileTree { .. } => "file_tree",
|
||||
Self::CommandPalette {
|
||||
location: LocalOrRemotePath::Remote(_),
|
||||
} => "remote_command_palette",
|
||||
Self::CommandPalette { .. } => "command_palette",
|
||||
Self::Finder { .. } => "finder",
|
||||
Self::Skill { .. } => "skill",
|
||||
}
|
||||
@@ -197,7 +237,23 @@ impl CodeSource {
|
||||
/// `AIAction` is ephemeral (tied to a live conversation) and should not
|
||||
/// be restored.
|
||||
pub fn is_restorable(&self) -> bool {
|
||||
!matches!(self, Self::AIAction { .. })
|
||||
!matches!(
|
||||
self,
|
||||
Self::AIAction { .. }
|
||||
| Self::FileTree {
|
||||
location: LocalOrRemotePath::Remote(_),
|
||||
}
|
||||
| Self::CommandPalette {
|
||||
location: LocalOrRemotePath::Remote(_),
|
||||
}
|
||||
| Self::ProjectRules {
|
||||
location: LocalOrRemotePath::Remote(_),
|
||||
}
|
||||
| Self::Skill {
|
||||
location: LocalOrRemotePath::Remote(_),
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,17 +305,19 @@ impl CodeManager {
|
||||
pub fn deregister_pane(&mut self, source: &CodeSource) {
|
||||
self.source_to_pane_data.remove(&source.omit_line_col());
|
||||
}
|
||||
/// Returns the locator for a code pane that already has `path` open in the given pane group.
|
||||
pub fn get_locator_for_path_in_tab(
|
||||
|
||||
/// Returns the locator for a code pane that already has the given `LocalOrRemotePath`
|
||||
/// open in the given pane group. Works for both local and remote files.
|
||||
pub fn get_locator_for_location_in_tab(
|
||||
&self,
|
||||
pane_group_id: EntityId,
|
||||
path: &Path,
|
||||
location: &LocalOrRemotePath,
|
||||
) -> Option<PaneViewLocator> {
|
||||
self.source_to_pane_data
|
||||
.iter()
|
||||
.find(|(source, data)| {
|
||||
data.locator.pane_group_id == pane_group_id
|
||||
&& source.path().is_some_and(|p| p.as_path() == path)
|
||||
&& source.location().as_ref() == Some(location)
|
||||
})
|
||||
.map(|(_, data)| data.locator)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
#[path = "snapshot/iterator.rs"]
|
||||
mod iterator;
|
||||
|
||||
use std::{cmp::Ordering, ops::AddAssign, path::Path, sync::Arc};
|
||||
use std::cmp::Ordering;
|
||||
use std::ops::AddAssign;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use sum_tree::{Edit, KeyedItem, SeekBias, SumTree};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Iterator implementations for FileTreeSnapshot.
|
||||
|
||||
use std::{path::Path, sync::Arc};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use sum_tree::{Cursor, SeekBias};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Tests for the file tree snapshot module.
|
||||
|
||||
use std::{path::Path, sync::Arc};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use sum_tree::Item;
|
||||
|
||||
|
||||
+197
-128
@@ -1,3 +1,8 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ops::Range;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use editing::sort_entries_for_file_tree;
|
||||
use galaxy_util::path::LineAndColumnArg;
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
@@ -9,69 +14,58 @@ use repo_metadata::file_tree_store::{
|
||||
FileTreeDirectoryEntryState, FileTreeEntryState, FileTreeFileMetadata,
|
||||
};
|
||||
use repo_metadata::local_model::IndexedRepoState;
|
||||
use repo_metadata::FileTreeEntry;
|
||||
use repo_metadata::RepoMetadataModel;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ops::Range;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use repo_metadata::{FileTreeEntry, RepoMetadataModel};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxy_core::{send_telemetry_from_ctx, HostId};
|
||||
use galaxyui::clipboard::ClipboardContent;
|
||||
use galaxyui::elements::{
|
||||
AcceptedByDropTarget, Align, Clipped, ConstrainedBox, Container, Dismiss, Draggable,
|
||||
DraggableState, Empty, FormattedTextElement, MainAxisAlignment, Percentage, Rect, SavePosition,
|
||||
Scrollable, Shrinkable,
|
||||
AcceptedByDropTarget, Align, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container,
|
||||
CrossAxisAlignment, Dismiss, Draggable, DraggableState, Empty, Flex, FormattedTextElement,
|
||||
Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
|
||||
ParentElement, ParentOffsetBounds, Percentage, Rect, SavePosition, ScrollStateHandle,
|
||||
Scrollable, ScrollableElement, ScrollbarWidth, Shrinkable, Stack, Text, UniformList,
|
||||
UniformListState,
|
||||
};
|
||||
use galaxyui::fonts::Style;
|
||||
use galaxyui::fonts::{Properties, Style, Weight};
|
||||
use galaxyui::keymap::FixedBinding;
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::text_layout::TextAlignment;
|
||||
use galaxyui::{clipboard::ClipboardContent, id, ViewContext, WeakViewHandle};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
ChildAnchor, ChildView, CrossAxisAlignment, Flex, Hoverable, MainAxisSize,
|
||||
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds,
|
||||
ScrollStateHandle, ScrollableElement, ScrollbarWidth, Stack, Text, UniformList,
|
||||
UniformListState,
|
||||
},
|
||||
fonts::{Properties, Weight},
|
||||
AppContext, Element, Entity, EventContext, SingletonEntity as _, TypedActionView, View,
|
||||
ViewHandle,
|
||||
id, AppContext, BlurContext, Element, Entity, EventContext, ModelHandle, SingletonEntity as _,
|
||||
TypedActionView, View, ViewContext, ViewHandle, WeakViewHandle,
|
||||
};
|
||||
use galaxyui::{BlurContext, ModelHandle};
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::code::active_file::{ActiveFileEvent, ActiveFileModel};
|
||||
use crate::code::buffer_location::LocalOrRemotePath;
|
||||
use crate::coding_panel_enablement_state::CodingPanelEnablementState;
|
||||
use crate::editor::{EditorOptions, EditorView, TextOptions};
|
||||
use crate::menu::{Menu, MenuItem, MenuItemFields};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::server::telemetry::CodePanelsFileOpenEntrypoint;
|
||||
use crate::server::telemetry::TelemetryEvent;
|
||||
use crate::terminal::input::InputDropTargetData;
|
||||
use crate::terminal::view::{TerminalDropTargetData, TerminalView};
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::ui_components::item_highlight::{ImageOrIcon, ItemHighlightState};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::util::file::external_editor::EditorSettings;
|
||||
use crate::util::openable_file_type::{is_file_content_binary, EditorLayout, FileTarget};
|
||||
use crate::util::openable_file_type::{
|
||||
is_file_content_binary, is_markdown_file, EditorLayout, FileTarget,
|
||||
};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::util::openable_file_type::{
|
||||
resolve_file_target_to_open_in_warp, resolve_file_target_with_editor_choice,
|
||||
};
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
menu::{Menu, MenuItem, MenuItemFields},
|
||||
server::telemetry::TelemetryEvent,
|
||||
ui_components::icons::Icon,
|
||||
view_components::DismissibleToast,
|
||||
workspace::ToastStack,
|
||||
};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::theme::{color::internal_colors, Fill};
|
||||
use galaxy_core::HostId;
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
|
||||
mod editing;
|
||||
mod render;
|
||||
|
||||
use crate::settings::{CodeSettings, CodeSettingsChangedEvent};
|
||||
|
||||
const REMOTE_TEXT: &str = "The Project Explorer requires access to your local workspace, which isn’t supported in remote sessions.";
|
||||
const DISABLED_TEXT: &str = "The Project Explorer requires access to your local workspace. Open a new session or navigate to an active session to view.";
|
||||
const WSL_TEXT: &str = "The Project Explorer doesn't currently work in WSL.";
|
||||
@@ -294,6 +288,8 @@ pub struct FileTreeView {
|
||||
/// the target is selected by the user or when the target root stops
|
||||
/// being displayed.
|
||||
pending_focus_target: Option<PendingFocusTarget>,
|
||||
/// Whether to show hidden files (dotfiles) in the file tree.
|
||||
show_hidden_files: bool,
|
||||
}
|
||||
|
||||
/// Directory the file tree wants to focus once its entry becomes available.
|
||||
@@ -355,6 +351,8 @@ impl FileTreeView {
|
||||
if is_active {
|
||||
self.subscribe_to_repository_metadata(ctx);
|
||||
self.subscribe_to_active_file_model(ctx);
|
||||
self.subscribe_to_code_settings(ctx);
|
||||
self.show_hidden_files = *CodeSettings::as_ref(ctx).show_hidden_files;
|
||||
|
||||
// Catch up on any repository/file changes that happened while inactive.
|
||||
// Skip remote-backed roots — their data comes from server pushes,
|
||||
@@ -388,6 +386,7 @@ impl FileTreeView {
|
||||
} else {
|
||||
ctx.unsubscribe_to_model(&self.repository_metadata_model);
|
||||
self.unsubscribe_from_active_file_model(ctx);
|
||||
self.unsubscribe_from_code_settings(ctx);
|
||||
let repository_metadata_model = self.repository_metadata_model.clone();
|
||||
let paths: Vec<_> = self.registered_lazy_loaded_paths.drain().collect();
|
||||
repository_metadata_model.update(ctx, move |model: &mut RepoMetadataModel, ctx| {
|
||||
@@ -502,8 +501,7 @@ impl FileTreeView {
|
||||
event: &repo_metadata::RepoMetadataEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
use repo_metadata::RepoMetadataEvent;
|
||||
use repo_metadata::RepositoryIdentifier;
|
||||
use repo_metadata::{RepoMetadataEvent, RepositoryIdentifier};
|
||||
match event {
|
||||
RepoMetadataEvent::RepositoryUpdated {
|
||||
id: RepositoryIdentifier::Local(std_path),
|
||||
@@ -535,6 +533,7 @@ impl FileTreeView {
|
||||
}
|
||||
RepoMetadataEvent::FileTreeEntryUpdated {
|
||||
id: RepositoryIdentifier::Local(std_path),
|
||||
..
|
||||
} => {
|
||||
// Find root directories whose backing model entry matches this path.
|
||||
let root_paths: Vec<StandardizedPath> = self
|
||||
@@ -549,13 +548,15 @@ impl FileTreeView {
|
||||
if !root_paths.is_empty() {
|
||||
let id = RepositoryIdentifier::Local(std_path.clone());
|
||||
if let Some(state) = RepoMetadataModel::as_ref(ctx).get_repository(&id, ctx) {
|
||||
for root_path in root_paths {
|
||||
if let Some(root_dir) = self.root_directories.get_mut(&root_path) {
|
||||
for root_path in &root_paths {
|
||||
if let Some(root_dir) = self.root_directories.get_mut(root_path) {
|
||||
root_dir.entry = state.entry.clone();
|
||||
}
|
||||
}
|
||||
|
||||
self.rebuild_flattened_items();
|
||||
for root_path in &root_paths {
|
||||
self.rebuild_flattened_items_for_root(root_path);
|
||||
}
|
||||
self.apply_pending_focus_target();
|
||||
ctx.notify();
|
||||
}
|
||||
@@ -593,6 +594,7 @@ impl FileTreeView {
|
||||
}
|
||||
RepoMetadataEvent::FileTreeEntryUpdated {
|
||||
id: RepositoryIdentifier::Remote(remote_id),
|
||||
..
|
||||
} => {
|
||||
let repo_path = remote_id.path.clone();
|
||||
let id = RepositoryIdentifier::Remote(remote_id.clone());
|
||||
@@ -600,7 +602,11 @@ impl FileTreeView {
|
||||
if let Some(root_dir) = self.root_directories.get_mut(&repo_path) {
|
||||
root_dir.entry = state.entry.clone();
|
||||
}
|
||||
self.rebuild_flattened_items();
|
||||
// Only rebuild the affected remote root instead of all roots.
|
||||
// Remote servers stream frequent incremental updates; a full
|
||||
// rebuild would cause unrelated local roots to re-render on
|
||||
// every remote filesystem change, leading to visible flicker.
|
||||
self.rebuild_flattened_items_for_root(&repo_path);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
@@ -610,11 +616,15 @@ impl FileTreeView {
|
||||
let repo_path = &remote_id.path;
|
||||
self.displayed_directories.retain(|p| p != repo_path);
|
||||
self.root_directories.remove(repo_path);
|
||||
self.rebuild_flattened_items();
|
||||
// The removed root is already gone from root_directories, so
|
||||
// this is effectively a no-op rebuild that avoids touching
|
||||
// the remaining roots' flattened items.
|
||||
self.rebuild_flattened_items_for_root(repo_path);
|
||||
ctx.notify();
|
||||
}
|
||||
RepoMetadataEvent::FileTreeUpdated { .. }
|
||||
| RepoMetadataEvent::RepositoryRemoved { .. }
|
||||
| RepoMetadataEvent::StandingQueryResultsUpdated { .. }
|
||||
| RepoMetadataEvent::UpdatingRepositoryFailed { .. }
|
||||
| RepoMetadataEvent::IncrementalUpdateReady { .. } => {}
|
||||
}
|
||||
@@ -640,6 +650,20 @@ impl FileTreeView {
|
||||
ctx.unsubscribe_to_model(active_file_model);
|
||||
}
|
||||
|
||||
fn subscribe_to_code_settings(&self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.subscribe_to_model(&CodeSettings::handle(ctx), |me, _, event, ctx| {
|
||||
if let CodeSettingsChangedEvent::ShowHiddenFiles { .. } = event {
|
||||
me.show_hidden_files = *CodeSettings::as_ref(ctx).show_hidden_files;
|
||||
me.rebuild_flattened_items();
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn unsubscribe_from_code_settings(&self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.unsubscribe_to_model(&CodeSettings::handle(ctx));
|
||||
}
|
||||
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let context_menu = ctx.add_typed_action_view(|_| {
|
||||
Menu::new()
|
||||
@@ -701,6 +725,7 @@ impl FileTreeView {
|
||||
#[cfg(feature = "local_fs")]
|
||||
registered_lazy_loaded_paths: HashSet::new(),
|
||||
pending_focus_target: None,
|
||||
show_hidden_files: *CodeSettings::as_ref(ctx).show_hidden_files,
|
||||
};
|
||||
|
||||
picker
|
||||
@@ -730,9 +755,17 @@ impl FileTreeView {
|
||||
fn handle_code_event(&mut self, event: &ActiveFileEvent, ctx: &mut ViewContext<Self>) {
|
||||
// When a file is focused, scroll to show it in the file tree
|
||||
match event {
|
||||
ActiveFileEvent::ActiveFileChanged { file_info } => {
|
||||
let Ok(file_std) = StandardizedPath::try_from_local(file_info) else {
|
||||
return;
|
||||
ActiveFileEvent::ActiveFileChanged { location } => {
|
||||
let file_std = match location {
|
||||
crate::code::buffer_location::LocalOrRemotePath::Local(path) => {
|
||||
match StandardizedPath::try_from_local(path) {
|
||||
Ok(std_path) => std_path,
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
crate::code::buffer_location::LocalOrRemotePath::Remote(remote) => {
|
||||
remote.path.clone()
|
||||
}
|
||||
};
|
||||
// Prefer the currently-selected item's root if the file lives under it;
|
||||
// otherwise fall back to the deepest matching root directory.
|
||||
@@ -1273,8 +1306,9 @@ impl FileTreeView {
|
||||
remote_host_id: None,
|
||||
});
|
||||
let root_local = root_path.to_local_path_lossy();
|
||||
if let Some(repo_root) =
|
||||
DetectedRepositories::as_ref(ctx).get_root_for_path(&root_local)
|
||||
if let Some(repo_root) = DetectedRepositories::as_ref(ctx)
|
||||
.get_root_for_path(&LocalOrRemotePath::Local(root_local))
|
||||
.and_then(|r| PathBuf::try_from(r).ok())
|
||||
{
|
||||
let repo_entry = {
|
||||
let repo_metadata = RepoMetadataModel::as_ref(ctx);
|
||||
@@ -1288,6 +1322,12 @@ impl FileTreeView {
|
||||
{
|
||||
Some(state.entry.clone())
|
||||
}
|
||||
Some(IndexedRepoState::Pending(_)) => {
|
||||
// Repo is being (re-)indexed. Keep whatever entry
|
||||
// we already have so the tree doesn't flash to a
|
||||
// loading state during the transition.
|
||||
continue;
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
@@ -1397,14 +1437,6 @@ impl FileTreeView {
|
||||
.update(ctx, |model: &mut RepoMetadataModel, ctx| {
|
||||
model.load_directory(&backing_root, &dir_path, ctx)
|
||||
});
|
||||
if matches!(
|
||||
load_result,
|
||||
Err(repo_metadata::RepoMetadataError::BuildTree(
|
||||
repo_metadata::BuildTreeError::ExceededMaxFileLimit,
|
||||
))
|
||||
) {
|
||||
Self::show_exceeded_file_limit_toast(ctx);
|
||||
}
|
||||
if let Err(error) = load_result {
|
||||
log::warn!("Failed to load directory {dir_path}: {error}");
|
||||
}
|
||||
@@ -1538,14 +1570,6 @@ impl FileTreeView {
|
||||
.update(ctx, |model: &mut RepoMetadataModel, ctx| {
|
||||
model.index_lazy_loaded_path(path, ctx)
|
||||
});
|
||||
if matches!(
|
||||
index_result,
|
||||
Err(repo_metadata::RepoMetadataError::BuildTree(
|
||||
repo_metadata::BuildTreeError::ExceededMaxFileLimit,
|
||||
))
|
||||
) {
|
||||
Self::show_exceeded_file_limit_toast(ctx);
|
||||
}
|
||||
if let Err(error) = &index_result {
|
||||
log::warn!("Failed to index lazy-loaded path {path}: {error}");
|
||||
}
|
||||
@@ -1555,14 +1579,21 @@ impl FileTreeView {
|
||||
}
|
||||
|
||||
let id = repo_metadata::RepositoryIdentifier::local(path.clone());
|
||||
let entry = RepoMetadataModel::as_ref(ctx)
|
||||
.get_repository(&id, ctx)
|
||||
.map(|state| state.entry.clone());
|
||||
let repo_state = RepoMetadataModel::as_ref(ctx).repository_state(&id, ctx);
|
||||
if let Some(root_dir) = self.root_directories.get_mut(path) {
|
||||
root_dir.entry = match entry {
|
||||
Some(entry) => entry,
|
||||
None => Self::create_empty_entry(path),
|
||||
};
|
||||
match repo_state {
|
||||
Some(IndexedRepoState::Indexed(state)) => {
|
||||
root_dir.entry = state.entry.clone();
|
||||
}
|
||||
Some(IndexedRepoState::Pending(_)) => {
|
||||
// Repo is being (re-)indexed. Keep whatever entry we already
|
||||
// have so the tree doesn't flash back to a loading state
|
||||
// during the Pending → Indexed transition.
|
||||
}
|
||||
Some(IndexedRepoState::Failed(_)) | None => {
|
||||
root_dir.entry = Self::create_empty_entry(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1570,43 +1601,53 @@ impl FileTreeView {
|
||||
FileTreeEntry::new_for_directory(Arc::new(path.clone()))
|
||||
}
|
||||
|
||||
fn show_exceeded_file_limit_toast(ctx: &mut ViewContext<Self>) {
|
||||
let window_id = ctx.window_id();
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
let toast = DismissibleToast::error(String::from(
|
||||
"Folder has too many files to display in the file explorer.",
|
||||
))
|
||||
.with_object_id("file_tree_exceeded_file_limit".to_string());
|
||||
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
|
||||
});
|
||||
/// Rebuilds the flattened items list for a single root directory only,
|
||||
/// leaving all other roots untouched. Use this when only one root's
|
||||
/// backing data has changed (e.g. a metadata update) to avoid
|
||||
/// unnecessarily re-flattening — and re-rendering — unrelated roots.
|
||||
fn rebuild_flattened_items_for_root(&mut self, target_root: &StandardizedPath) {
|
||||
self.rebuild_flatten_items_impl(None, None, Some(target_root));
|
||||
}
|
||||
|
||||
/// Rebuilds the flattened items list from the current entry tree, optionally removing an item.
|
||||
fn rebuild_flattened_items(&mut self) {
|
||||
self.rebuild_flatten_items_and_select_path(None, None);
|
||||
self.rebuild_flatten_items_impl(None, None, None);
|
||||
}
|
||||
|
||||
fn rebuild_flattened_items_without(&mut self, path_to_remove: &StandardizedPath) -> bool {
|
||||
self.rebuild_flatten_items_and_select_path(None, Some(path_to_remove))
|
||||
self.rebuild_flatten_items_impl(None, Some(path_to_remove), None)
|
||||
}
|
||||
|
||||
/// Rebuilds the flattened items list from the current entry tree
|
||||
/// If `id_to_select` is `Some`, the item identified by that FileTreeIdentifier will be selected.
|
||||
/// If `path_to_remove` is `Some`, the item identified by `path_to_remove` will be removed
|
||||
/// upon rebuilding.
|
||||
/// Core implementation for rebuilding the flattened items list.
|
||||
///
|
||||
/// When `target_root` is `Some`, only that root is re-flattened; all
|
||||
/// other roots keep their existing items. When `None`, every displayed
|
||||
/// root is rebuilt.
|
||||
///
|
||||
/// If `id_to_select` is `Some`, the item identified by that
|
||||
/// `FileTreeIdentifier` will be selected. If `path_to_remove` is
|
||||
/// `Some`, the item at that path will be excluded from the result.
|
||||
///
|
||||
/// Returns `true` if an item was removed.
|
||||
fn rebuild_flatten_items_and_select_path(
|
||||
fn rebuild_flatten_items_impl(
|
||||
&mut self,
|
||||
id_to_select: Option<&FileTreeIdentifier>,
|
||||
path_to_remove: Option<&StandardizedPath>,
|
||||
target_root: Option<&StandardizedPath>,
|
||||
) -> bool {
|
||||
let mut any_item_removed = false;
|
||||
|
||||
// Clone the ID to preserve so we don't hold a borrow on self.selected_item
|
||||
let id_to_preserve = id_to_select.cloned().or_else(|| self.selected_item.clone());
|
||||
|
||||
// Process all displayed directories
|
||||
// Process displayed directories, optionally filtering to a single root.
|
||||
for root_path in self.displayed_directories.clone() {
|
||||
if let Some(target) = target_root {
|
||||
if root_path != *target {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let Some(root_dir) = self.root_directories.get(&root_path) else {
|
||||
continue;
|
||||
};
|
||||
@@ -1634,13 +1675,19 @@ impl FileTreeView {
|
||||
root_dir.items = items;
|
||||
}
|
||||
|
||||
// If we found the selection in this root, update selected_item
|
||||
if let (Some(index), Some(id)) = (new_index, id_to_preserve.as_ref()) {
|
||||
// If we found the selection in this root, update selected_item.
|
||||
// If the selection was expected but not found (e.g. filtered out as hidden),
|
||||
// clear selected_item to avoid stale references.
|
||||
if let Some(id) = id_to_preserve.as_ref() {
|
||||
if id.root == root_path {
|
||||
self.selected_item = Some(FileTreeIdentifier {
|
||||
root: root_path,
|
||||
index,
|
||||
});
|
||||
if let Some(index) = new_index {
|
||||
self.selected_item = Some(FileTreeIdentifier {
|
||||
root: root_path,
|
||||
index,
|
||||
});
|
||||
} else if selected_item_path.is_some() {
|
||||
self.selected_item = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1669,6 +1716,17 @@ impl FileTreeView {
|
||||
return (None, true);
|
||||
}
|
||||
|
||||
// Filter hidden files/directories when show_hidden_files is disabled.
|
||||
// Only filter descendants (depth > 0), not the root entry itself,
|
||||
// so that hidden workspace directories (e.g. ~/.config) are still shown.
|
||||
if !self.show_hidden_files && depth > 0 {
|
||||
if let Some(name) = current_path.file_name() {
|
||||
if name.starts_with('.') {
|
||||
return (selected_item_index, removed_item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if path_of_selected_item == Some(current_path) {
|
||||
selected_item_index = Some(items.len());
|
||||
}
|
||||
@@ -1935,7 +1993,6 @@ impl FileTreeView {
|
||||
let is_selected = self.selected_item.as_ref() == Some(id);
|
||||
let is_expanded = self.is_item_expanded(&id.root, item);
|
||||
let render_state = item.to_render_state(is_expanded, appearance);
|
||||
let is_remote_file = root_dir.is_remote() && matches!(item, FileTreeItem::File { .. });
|
||||
|
||||
let item_display_name = render_state.display_name.clone();
|
||||
let item_position_id = format!("file_tree_item:{item_display_name}");
|
||||
@@ -1954,34 +2011,14 @@ impl FileTreeView {
|
||||
let id_for_context = id.clone();
|
||||
let id_for_drop = id.clone();
|
||||
let id_for_drag = id.clone();
|
||||
let ui_builder = appearance.ui_builder();
|
||||
let hoverable = Hoverable::new(render_state.mouse_state.clone(), move |mouse_state| {
|
||||
let item_highlight_state = ItemHighlightState::new(is_selected, mouse_state);
|
||||
let element = Self::render_item_with_hover(
|
||||
Self::render_item_with_hover(
|
||||
render_state,
|
||||
appearance,
|
||||
item_highlight_state,
|
||||
editor_view,
|
||||
);
|
||||
|
||||
if is_remote_file && mouse_state.is_hovered() {
|
||||
let tooltip = ui_builder
|
||||
.tool_tip("Opening files is unavailable for remote sessions".to_string())
|
||||
.build()
|
||||
.finish();
|
||||
let offset = OffsetPositioning::offset_from_parent(
|
||||
Vector2F::new(0., 4.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::BottomLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
);
|
||||
Stack::new()
|
||||
.with_child(element)
|
||||
.with_positioned_overlay_child(tooltip, offset)
|
||||
.finish()
|
||||
} else {
|
||||
element
|
||||
}
|
||||
)
|
||||
})
|
||||
.on_click(
|
||||
move |event_ctx: &mut EventContext, _app_ctx: &AppContext, _position| {
|
||||
@@ -2004,12 +2041,7 @@ impl FileTreeView {
|
||||
});
|
||||
},
|
||||
)
|
||||
// Remote files can't be opened in the editor, so use the default cursor.
|
||||
.with_cursor(if is_remote_file {
|
||||
Cursor::Arrow
|
||||
} else {
|
||||
Cursor::PointingHand
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish();
|
||||
|
||||
let draggable = Draggable::new(draggable_state, hoverable)
|
||||
@@ -2208,7 +2240,7 @@ impl FileTreeView {
|
||||
);
|
||||
|
||||
ctx.emit(FileTreeEvent::OpenFile {
|
||||
path: path.to_path_buf(),
|
||||
path: LocalOrRemotePath::Local(path.to_path_buf()),
|
||||
target,
|
||||
line_col: None,
|
||||
});
|
||||
@@ -2230,8 +2262,38 @@ impl FileTreeView {
|
||||
|
||||
match item {
|
||||
FileTreeItem::File { metadata, .. } => {
|
||||
// Remote file trees don't support opening files in the editor.
|
||||
if !is_remote {
|
||||
if is_remote {
|
||||
// Emit a remote open event if we have a host ID.
|
||||
if let Some(host_id) = &root_dir.remote_host_id {
|
||||
let remote_path = warp_util::remote_path::RemotePath::new(
|
||||
host_id.clone(),
|
||||
(*metadata.path).clone(),
|
||||
);
|
||||
let path_str = metadata.path.as_str();
|
||||
let target = if is_markdown_file(Path::new(path_str)) {
|
||||
#[cfg(feature = "local_fs")]
|
||||
{
|
||||
let prefer_md = *EditorSettings::as_ref(ctx).prefer_markdown_viewer;
|
||||
if prefer_md {
|
||||
FileTarget::MarkdownViewer(EditorLayout::SplitPane)
|
||||
} else {
|
||||
FileTarget::CodeEditor(EditorLayout::SplitPane)
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
{
|
||||
FileTarget::CodeEditor(EditorLayout::SplitPane)
|
||||
}
|
||||
} else {
|
||||
FileTarget::CodeEditor(EditorLayout::SplitPane)
|
||||
};
|
||||
ctx.emit(FileTreeEvent::OpenFile {
|
||||
path: LocalOrRemotePath::Remote(remote_path),
|
||||
target,
|
||||
line_col: None,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let path = metadata.path.to_local_path_lossy();
|
||||
self.open_file(&path, None, ctx);
|
||||
}
|
||||
@@ -2855,7 +2917,7 @@ pub enum FileTreeEvent {
|
||||
AttachAsContext { path: PathBuf },
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
OpenFile {
|
||||
path: PathBuf,
|
||||
path: LocalOrRemotePath,
|
||||
target: FileTarget,
|
||||
line_col: Option<LineAndColumnArg>,
|
||||
},
|
||||
@@ -2892,6 +2954,13 @@ impl View for FileTreeView {
|
||||
return self.render_error_state(DISABLED_TEXT.to_string(), app);
|
||||
}
|
||||
|
||||
if matches!(
|
||||
self.enablement,
|
||||
CodingPanelEnablementState::PendingRemoteSession
|
||||
) {
|
||||
return self.render_loading_state(app);
|
||||
}
|
||||
|
||||
if self.displayed_directories.is_empty() {
|
||||
if let CodingPanelEnablementState::RemoteSession { has_remote_server } = self.enablement
|
||||
{
|
||||
|
||||
@@ -1,37 +1,34 @@
|
||||
//! Module for utlities related to editing items in the file tree.
|
||||
//! Module for utilities related to editing items in the file tree.
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "editing_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
use galaxyui::{elements::MouseStateHandle, ViewContext};
|
||||
use repo_metadata::file_tree_store::FileTreeEntryState;
|
||||
use repo_metadata::{FileMetadata, FileTreeEntry};
|
||||
use std::cmp::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
use repo_metadata::file_tree_store::FileTreeEntryState;
|
||||
use repo_metadata::{FileMetadata, FileTreeEntry};
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
use galaxyui::elements::MouseStateHandle;
|
||||
use galaxyui::ViewContext;
|
||||
|
||||
use super::{FileTreeIdentifier, FileTreeItem, FileTreeView};
|
||||
use crate::{
|
||||
code::file_tree::{
|
||||
view::{PendingEdit, PendingEditKind},
|
||||
FileTreeEvent,
|
||||
},
|
||||
send_telemetry_from_ctx,
|
||||
server::telemetry::TelemetryEvent,
|
||||
};
|
||||
use crate::code::file_tree::view::{PendingEdit, PendingEditKind};
|
||||
use crate::code::file_tree::FileTreeEvent;
|
||||
use crate::send_telemetry_from_ctx;
|
||||
use crate::server::telemetry::TelemetryEvent;
|
||||
|
||||
/// Custom ordering function for items in the file tree.
|
||||
///
|
||||
/// Directories are ordered first, sorted alphabetically.
|
||||
/// Files are ordered second, sorted alphabetically.
|
||||
/// Directories are ordered first, sorted by natural (numeric-aware) order.
|
||||
/// Files are ordered second, sorted by natural (numeric-aware) order.
|
||||
/// Within each group, dotfiles (entries starting with a dot) are ordered first.
|
||||
pub(super) fn sort_entries_for_file_tree(
|
||||
entry_1: &StandardizedPath,
|
||||
entry_2: &StandardizedPath,
|
||||
entry_map: &FileTreeEntry,
|
||||
) -> Ordering {
|
||||
use std::cmp::Ordering;
|
||||
|
||||
// Entries missing from the map sort before present entries, and compare
|
||||
// equal to each other. Using the same `Ordering` on both sides would
|
||||
@@ -70,7 +67,7 @@ pub(super) fn sort_entries_for_file_tree(
|
||||
match (starts_with_dot_1, starts_with_dot_2) {
|
||||
(true, false) => Ordering::Less,
|
||||
(false, true) => Ordering::Greater,
|
||||
_ => name_1.cmp(name_2),
|
||||
_ => alphanumeric_sort::compare_str(name_1, name_2),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +237,7 @@ impl FileTreeView {
|
||||
});
|
||||
|
||||
// Rebuild and select the renamed item using its FileTreeIdentifier
|
||||
self.rebuild_flatten_items_and_select_path(Some(&file_tree_id), None);
|
||||
self.rebuild_flatten_items_impl(Some(&file_tree_id), None, None);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,3 +72,35 @@ fn sort_entries_for_file_tree_sorts_without_panicking_on_missing_children() {
|
||||
|
||||
paths.sort_by(|a, b| sort_entries_for_file_tree(a, b, &entry));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_entries_for_file_tree_uses_natural_order_for_numbered_files() {
|
||||
let root = std_path("/repo");
|
||||
let mut entry = FileTreeEntry::new_for_directory(Arc::new(root.clone()));
|
||||
for name in ["L1", "L2", "L3", "L10", "L11", "L12"] {
|
||||
entry.insert_child_state(&root, file_state(&format!("/repo/{name}.tsx")));
|
||||
}
|
||||
|
||||
let mut paths = [
|
||||
std_path("/repo/L10.tsx"),
|
||||
std_path("/repo/L2.tsx"),
|
||||
std_path("/repo/L1.tsx"),
|
||||
std_path("/repo/L12.tsx"),
|
||||
std_path("/repo/L3.tsx"),
|
||||
std_path("/repo/L11.tsx"),
|
||||
];
|
||||
paths.sort_by(|a, b| sort_entries_for_file_tree(a, b, &entry));
|
||||
|
||||
let sorted: Vec<&str> = paths.iter().map(|p| p.as_str()).collect();
|
||||
assert_eq!(
|
||||
sorted,
|
||||
[
|
||||
"/repo/L1.tsx",
|
||||
"/repo/L2.tsx",
|
||||
"/repo/L3.tsx",
|
||||
"/repo/L10.tsx",
|
||||
"/repo/L11.tsx",
|
||||
"/repo/L12.tsx",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use galaxyui::elements::{DraggableState, MouseStateHandle};
|
||||
|
||||
use super::FileTreeItem;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::code::icon_from_file_path;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::ui_components::item_highlight::ImageOrIcon;
|
||||
use crate::{appearance::Appearance, ui_components::icons::Icon};
|
||||
|
||||
impl FileTreeItem {
|
||||
pub(super) fn to_render_state(
|
||||
|
||||
@@ -8,10 +8,16 @@ use repo_metadata::local_model::IndexedRepoState;
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use repo_metadata::watcher::DirectoryWatcher;
|
||||
use repo_metadata::RepoMetadataModel;
|
||||
use settings::Setting;
|
||||
use virtual_fs::{Stub, VirtualFS};
|
||||
use galaxyui::platform::WindowStyle;
|
||||
use galaxyui::{App, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::FileTreeView;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::server::server_api::{team::MockTeamClient, workspace::MockWorkspaceClient};
|
||||
use crate::server::server_api::team::MockTeamClient;
|
||||
use crate::server::server_api::workspace::MockWorkspaceClient;
|
||||
use crate::settings::CodeSettings;
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::vim_registers::VimRegisters;
|
||||
@@ -19,8 +25,6 @@ use crate::workspace::sync_inputs::SyncedInputState;
|
||||
use crate::workspace::ToastStack;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
use super::FileTreeView;
|
||||
|
||||
fn std_path(path: &std::path::Path) -> galaxy_util::standardized_path::StandardizedPath {
|
||||
galaxy_util::standardized_path::StandardizedPath::try_from_local(path).unwrap()
|
||||
}
|
||||
@@ -112,6 +116,147 @@ fn build_repo_state_with_unloaded_directory(repo_root: &std::path::Path) -> File
|
||||
FileTreeState::new(root, vec![], None)
|
||||
}
|
||||
|
||||
fn flattened_paths(
|
||||
view: &FileTreeView,
|
||||
root: &std::path::Path,
|
||||
) -> Vec<warp_util::standardized_path::StandardizedPath> {
|
||||
view.root_directories
|
||||
.get(&std_path(root))
|
||||
.expect("root directory is tracked")
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.path().clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn set_show_hidden_files(app: &mut App, show_hidden_files: bool) {
|
||||
CodeSettings::handle(app).update(app, |settings, ctx| {
|
||||
Setting::set_value(&mut settings.show_hidden_files, show_hidden_files, ctx)
|
||||
.expect("show hidden files setting updates");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_files_are_filtered_until_setting_is_enabled() {
|
||||
VirtualFS::test("file_tree_hidden_files_setting", |dirs, mut vfs| {
|
||||
vfs.mkdir("tree/.config").with_files(vec![
|
||||
Stub::FileWithContent("tree/.env", "SECRET=value\n"),
|
||||
Stub::FileWithContent("tree/.config/settings.toml", ""),
|
||||
Stub::FileWithContent("tree/visible.txt", "content\n"),
|
||||
]);
|
||||
let tree = dirs.tests().join("tree");
|
||||
let hidden_file = tree.join(".env");
|
||||
let hidden_dir = tree.join(".config");
|
||||
let visible_file = tree.join("visible.txt");
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let _ = initialize_app(&mut app);
|
||||
set_show_hidden_files(&mut app, false);
|
||||
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
|
||||
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_is_active(true, ctx);
|
||||
view.set_root_directories(vec![tree.clone()], ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
let paths = flattened_paths(view, &tree);
|
||||
assert!(paths.contains(&std_path(&tree)));
|
||||
assert!(paths.contains(&std_path(&visible_file)));
|
||||
assert!(!paths.contains(&std_path(&hidden_file)));
|
||||
assert!(!paths.contains(&std_path(&hidden_dir)));
|
||||
});
|
||||
|
||||
set_show_hidden_files(&mut app, true);
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
let paths = flattened_paths(view, &tree);
|
||||
assert!(paths.contains(&std_path(&hidden_file)));
|
||||
assert!(paths.contains(&std_path(&hidden_dir)));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_root_directory_is_not_filtered() {
|
||||
VirtualFS::test("file_tree_hidden_root_directory", |dirs, mut vfs| {
|
||||
vfs.mkdir(".config").with_files(vec![
|
||||
Stub::FileWithContent(".config/settings.toml", ""),
|
||||
Stub::FileWithContent(".config/.secret", ""),
|
||||
]);
|
||||
let hidden_root = dirs.tests().join(".config");
|
||||
let visible_file = hidden_root.join("settings.toml");
|
||||
let hidden_file = hidden_root.join(".secret");
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let _ = initialize_app(&mut app);
|
||||
set_show_hidden_files(&mut app, false);
|
||||
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
|
||||
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_is_active(true, ctx);
|
||||
view.set_root_directories(vec![hidden_root.clone()], ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
let paths = flattened_paths(view, &hidden_root);
|
||||
assert!(paths.contains(&std_path(&hidden_root)));
|
||||
assert!(paths.contains(&std_path(&visible_file)));
|
||||
assert!(!paths.contains(&std_path(&hidden_file)));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_hidden_file_is_cleared_when_filtered() {
|
||||
VirtualFS::test(
|
||||
"file_tree_selected_hidden_file_filtered",
|
||||
|dirs, mut vfs| {
|
||||
vfs.mkdir("tree").with_files(vec![
|
||||
Stub::FileWithContent("tree/.env", "SECRET=value\n"),
|
||||
Stub::FileWithContent("tree/visible.txt", "content\n"),
|
||||
]);
|
||||
let tree = dirs.tests().join("tree");
|
||||
let hidden_file = tree.join(".env");
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let _ = initialize_app(&mut app);
|
||||
set_show_hidden_files(&mut app, true);
|
||||
|
||||
let (_, file_tree_view) =
|
||||
app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_is_active(true, ctx);
|
||||
view.set_root_directories(vec![tree.clone()], ctx);
|
||||
|
||||
let root_dir = view.root_directories.get(&std_path(&tree)).unwrap();
|
||||
let (index, _) = root_dir
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, item)| item.path() == &std_path(&hidden_file))
|
||||
.expect("hidden file is visible");
|
||||
let id = super::FileTreeIdentifier {
|
||||
root: std_path(&tree),
|
||||
index,
|
||||
};
|
||||
view.select_id(&id, ctx);
|
||||
});
|
||||
|
||||
set_show_hidden_files(&mut app, false);
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
let paths = flattened_paths(view, &tree);
|
||||
assert!(!paths.contains(&std_path(&hidden_file)));
|
||||
assert!(view.selected_item.is_none());
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_transition_unregisters_lazy_loaded_path() {
|
||||
VirtualFS::test("file_tree_repo_transition", |dirs, mut vfs| {
|
||||
@@ -389,7 +534,7 @@ fn pending_repository_root_does_not_register_lazy_loaded_path() {
|
||||
);
|
||||
assert!(matches!(
|
||||
model.repository_state(&id, ctx),
|
||||
Some(IndexedRepoState::Pending)
|
||||
Some(IndexedRepoState::Pending(_))
|
||||
));
|
||||
});
|
||||
|
||||
@@ -416,7 +561,7 @@ fn pending_repository_root_does_not_register_lazy_loaded_path() {
|
||||
);
|
||||
assert!(matches!(
|
||||
model.repository_state(&id, ctx),
|
||||
Some(IndexedRepoState::Pending)
|
||||
Some(IndexedRepoState::Pending(_))
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,39 +3,36 @@
|
||||
//! This module provides a hover card that shows all references to a symbol
|
||||
//! as a flat list with file info, line numbers, and syntax-highlighted code snippets.
|
||||
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use galaxy_core::ui::{
|
||||
appearance::Appearance, icons::Icon as GalaxyIcon, theme::color::internal_colors,
|
||||
};
|
||||
use galaxy_files::FileModel;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ClippedScrollable,
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Fill, Flex, Hoverable,
|
||||
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds,
|
||||
Radius, ScrollbarWidth, Shrinkable, Stack, Text,
|
||||
},
|
||||
keymap::FixedBinding,
|
||||
platform::Cursor,
|
||||
prelude::Align,
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
use lsp::ReferenceLocation;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use string_offset::CharOffset;
|
||||
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::icons::Icon as WarpIcon;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_editor::content::buffer::InitialBufferState;
|
||||
use galaxy_editor::render::element::VerticalExpansionBehavior;
|
||||
use galaxy_files::FileModel;
|
||||
use galaxyui::elements::{
|
||||
Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox,
|
||||
Container, CornerRadius, CrossAxisAlignment, Fill, Flex, Hoverable, MouseStateHandle,
|
||||
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, ScrollbarWidth,
|
||||
Shrinkable, Stack, Text,
|
||||
};
|
||||
use galaxyui::keymap::FixedBinding;
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::prelude::Align;
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
use galaxyui::{
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use super::{
|
||||
editor::view::{CodeEditorRenderOptions, CodeEditorView},
|
||||
global_buffer_model::GlobalBufferModel,
|
||||
};
|
||||
use super::editor::view::{CodeEditorRenderOptions, CodeEditorView};
|
||||
use super::global_buffer_model::GlobalBufferModel;
|
||||
use crate::editor::InteractionState;
|
||||
use galaxy_editor::{
|
||||
content::buffer::InitialBufferState, render::element::VerticalExpansionBehavior,
|
||||
};
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
|
||||
/// Maximum height for the find references card.
|
||||
pub const FIND_REFERENCES_CARD_MAX_HEIGHT: f32 = 300.;
|
||||
@@ -135,7 +132,7 @@ impl ReferenceEntryWithUi {
|
||||
let content = trimmed.to_string();
|
||||
let file_path = self.entry.file_path.clone();
|
||||
self.editor_view.update(ctx, |view, ctx| {
|
||||
view.set_language_with_path(&file_path, ctx);
|
||||
view.set_language_with_local_path(&file_path, ctx);
|
||||
let state = InitialBufferState::plain_text(&content);
|
||||
view.reset(state, ctx);
|
||||
});
|
||||
@@ -346,7 +343,7 @@ impl FindReferencesView {
|
||||
view.set_interaction_state(InteractionState::Disabled, ctx);
|
||||
|
||||
// Set up syntax highlighting based on file extension
|
||||
view.set_language_with_path(&file_path, ctx);
|
||||
view.set_language_with_local_path(&file_path, ctx);
|
||||
|
||||
// Reset with the reference line content
|
||||
let state = InitialBufferState::plain_text(&content);
|
||||
|
||||
+18
-19
@@ -7,35 +7,35 @@ use lsp::{
|
||||
LanguageId, LanguageServerId, LspManagerModel, LspManagerModelEvent, LspServerModel,
|
||||
LspState as LspModelState,
|
||||
};
|
||||
|
||||
use crate::code::lsp_telemetry::{LspControlActionType, LspEnablementSource, LspTelemetryEvent};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_core::ui::theme::{Fill as ThemeFill, GalaxyTheme};
|
||||
use galaxy_core::ui::{appearance::Appearance, Icon};
|
||||
use galaxy_core::ui::theme::{AnsiColorIdentifier, Fill as ThemeFill, WarpTheme};
|
||||
use galaxy_core::ui::Icon;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
||||
use galaxyui::elements::{
|
||||
ChildAnchor, ChildView, Dismiss, Empty, Hoverable, MainAxisSize, MouseStateHandle,
|
||||
ParentAnchor, ParentOffsetBounds, Rect, Shrinkable,
|
||||
Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
Dismiss, Empty, Fill, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
|
||||
OffsetPositioning, Padding, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Rect,
|
||||
Shrinkable, Stack,
|
||||
};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Fill, Flex,
|
||||
MainAxisAlignment, OffsetPositioning, Padding, ParentElement, Radius, Stack,
|
||||
},
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, View, WeakModelHandle,
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle, WeakModelHandle,
|
||||
};
|
||||
use galaxyui::{TypedActionView, ViewContext, ViewHandle};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use galaxy_core::ui::theme::AnsiColorIdentifier;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::ai::persisted_workspace::PersistedWorkspaceEvent;
|
||||
use crate::ai::persisted_workspace::{
|
||||
LSPEnablementResultForFile, LspRepoStatus, PersistedWorkspace,
|
||||
};
|
||||
use crate::code::lsp_telemetry::{LspControlActionType, LspEnablementSource, LspTelemetryEvent};
|
||||
use crate::settings::AISettings;
|
||||
use crate::ui_components::blended_colors;
|
||||
#[cfg(feature = "local_fs")]
|
||||
@@ -43,8 +43,6 @@ use crate::user_config::is_tab_config_toml;
|
||||
use crate::view_components::action_button::{
|
||||
ActionButton, ButtonSize, NakedTheme, PaneHeaderTheme,
|
||||
};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
|
||||
const FOOTER_HEIGHT: f32 = 24.;
|
||||
/// Margin around the LSP icon container
|
||||
@@ -699,7 +697,8 @@ impl CodeFooterView {
|
||||
|
||||
let repo_root = DetectedRepositories::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.get_root_for_path(file_path)
|
||||
.get_root_for_path(&LocalOrRemotePath::Local(file_path.to_path_buf()))
|
||||
.and_then(|r| r.to_local_path().map(std::path::Path::to_path_buf))
|
||||
.or_else(|| file_path.parent().map(|p| p.to_path_buf()));
|
||||
|
||||
let Some(repo_root) = repo_root else {
|
||||
|
||||
+1344
-74
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,323 @@
|
||||
use lsp::LspManagerModel;
|
||||
use remote_server::proto::TextEdit;
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use repo_metadata::watcher::DirectoryWatcher;
|
||||
use repo_metadata::RepoMetadataModel;
|
||||
use warp_files::FileModel;
|
||||
use warp_util::content_version::ContentVersion;
|
||||
use warp_util::host_id::HostId;
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
use warpui::{App, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::{BufferSource, CharOffsetEdit, GlobalBufferModel, PendingEditBatch};
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
|
||||
// ── Test-only helpers on GlobalBufferModel ────────────────────────
|
||||
// These live here (child module) rather than in global_buffer_model.rs
|
||||
// to keep test infrastructure out of the production source file.
|
||||
//
|
||||
// Note: `seed_remote_buffer_for_test` and `sync_clock_for_remote_test`
|
||||
// are `pub(crate)` in global_buffer_model.rs because they're shared
|
||||
// with `buffer_location_tests`.
|
||||
|
||||
impl GlobalBufferModel {
|
||||
/// Returns whether a pending edit batch exists for a Remote buffer.
|
||||
fn has_pending_batch_for_test(&self, file_id: warp_util::file::FileId) -> bool {
|
||||
self.buffers.get(&file_id).is_some_and(|state| {
|
||||
matches!(&state.source, BufferSource::Remote { pending_batch, .. } if pending_batch.is_some())
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the number of edits in the pending batch, or 0 if none.
|
||||
fn pending_batch_edit_count_for_test(&self, file_id: warp_util::file::FileId) -> usize {
|
||||
self.buffers
|
||||
.get(&file_id)
|
||||
.and_then(|state| match &state.source {
|
||||
BufferSource::Remote { pending_batch, .. } => {
|
||||
pending_batch.as_ref().map(|b| b.edits.len())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Inserts a fake pending batch so tests can verify discard/flush
|
||||
/// behavior without needing a real `RemoteServerClient` or the
|
||||
/// `ContentChanged` subscription path.
|
||||
fn insert_pending_batch_for_test(
|
||||
&mut self,
|
||||
file_id: warp_util::file::FileId,
|
||||
expected_server_version: u64,
|
||||
edits: Vec<remote_server::proto::TextEdit>,
|
||||
client_version: ContentVersion,
|
||||
) {
|
||||
let Some(state) = self.buffers.get_mut(&file_id) else {
|
||||
return;
|
||||
};
|
||||
if let BufferSource::Remote {
|
||||
pending_batch,
|
||||
sync_clock,
|
||||
..
|
||||
} = &mut state.source
|
||||
{
|
||||
if let Some(clock) = sync_clock.as_mut() {
|
||||
clock.client_version = client_version;
|
||||
}
|
||||
*pending_batch = Some(PendingEditBatch {
|
||||
expected_server_version,
|
||||
edits,
|
||||
latest_client_version: client_version,
|
||||
debounce_timer: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test setup ────────────────────────────────────────────────────
|
||||
|
||||
fn init_app(app: &mut App) {
|
||||
initialize_settings_for_tests(app);
|
||||
app.add_singleton_model(|_| LspManagerModel::new());
|
||||
app.add_singleton_model(DirectoryWatcher::new);
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
app.add_singleton_model(FileModel::new);
|
||||
}
|
||||
|
||||
fn gbm(app: &App) -> ModelHandle<GlobalBufferModel> {
|
||||
GlobalBufferModel::handle(app)
|
||||
}
|
||||
|
||||
fn content(app: &App, file_id: warp_util::file::FileId) -> String {
|
||||
let handle = gbm(app);
|
||||
app.read(|ctx| {
|
||||
handle
|
||||
.as_ref(ctx)
|
||||
.content_for_file(file_id, ctx)
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
fn text_edit(start: u64, end: u64, text: &str) -> TextEdit {
|
||||
TextEdit {
|
||||
start_offset: start,
|
||||
end_offset: end,
|
||||
text: text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn char_edit(start: usize, end: usize, text: &str) -> CharOffsetEdit {
|
||||
CharOffsetEdit {
|
||||
start: string_offset::CharOffset::from(start),
|
||||
end: string_offset::CharOffset::from(end),
|
||||
text: text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_host_id() -> HostId {
|
||||
HostId::new("test-host".to_string())
|
||||
}
|
||||
|
||||
fn test_path() -> StandardizedPath {
|
||||
StandardizedPath::try_new("/test/file.txt").unwrap()
|
||||
}
|
||||
|
||||
// ── Pending edit batch: discard on server push ───────────────────
|
||||
|
||||
#[test]
|
||||
fn pending_batch_discarded_on_server_push_with_conflict() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let host_id = test_host_id();
|
||||
let path = test_path();
|
||||
|
||||
// Seed a remote buffer at server_version=1, client_version=0.
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.seed_remote_buffer_for_test(host_id.clone(), path.clone(), "hello", 1, ctx)
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
// Simulate client edits that haven't been flushed yet.
|
||||
let client_cv = ContentVersion::new();
|
||||
gbm(&app).update(&mut app, |gbm, _ctx| {
|
||||
gbm.insert_pending_batch_for_test(
|
||||
file_id,
|
||||
1, // expected_server_version
|
||||
vec![text_edit(6, 6, " world")],
|
||||
client_cv,
|
||||
);
|
||||
});
|
||||
|
||||
// Verify the batch exists.
|
||||
let handle = gbm(&app);
|
||||
app.read(|ctx| {
|
||||
assert!(handle.as_ref(ctx).has_pending_batch_for_test(file_id));
|
||||
assert_eq!(
|
||||
handle
|
||||
.as_ref(ctx)
|
||||
.pending_batch_edit_count_for_test(file_id),
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
// Server push arrives. Since client_cv != 0, this triggers a conflict
|
||||
// and the batch should be discarded.
|
||||
gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.handle_buffer_updated_push(
|
||||
&host_id,
|
||||
path.as_str(),
|
||||
2, // new_server_version
|
||||
0, // expected_client_version (server doesn't know about our edits)
|
||||
&[char_edit(6, 6, " push")],
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
// Batch should be discarded.
|
||||
let handle = gbm(&app);
|
||||
app.read(|ctx| {
|
||||
assert!(
|
||||
!handle.as_ref(ctx).has_pending_batch_for_test(file_id),
|
||||
"Pending batch should be discarded on server push"
|
||||
);
|
||||
});
|
||||
|
||||
// Content should be unchanged (conflict path, push not applied).
|
||||
assert_eq!(content(&app, file_id), "hello");
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_batch_discarded_on_conflict_detected() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let host_id = test_host_id();
|
||||
let path = test_path();
|
||||
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.seed_remote_buffer_for_test(host_id.clone(), path.clone(), "hello", 1, ctx)
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
// Insert a pending batch.
|
||||
let client_cv = ContentVersion::new();
|
||||
gbm(&app).update(&mut app, |gbm, _ctx| {
|
||||
gbm.insert_pending_batch_for_test(
|
||||
file_id,
|
||||
1,
|
||||
vec![text_edit(6, 6, " edit")],
|
||||
client_cv,
|
||||
);
|
||||
});
|
||||
|
||||
let handle = gbm(&app);
|
||||
app.read(|ctx| {
|
||||
assert!(handle.as_ref(ctx).has_pending_batch_for_test(file_id));
|
||||
});
|
||||
|
||||
// BufferConflictDetected arrives.
|
||||
gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.handle_buffer_conflict_detected(&host_id, path.as_str(), ctx);
|
||||
});
|
||||
|
||||
// Batch should be discarded.
|
||||
let handle = gbm(&app);
|
||||
app.read(|ctx| {
|
||||
assert!(
|
||||
!handle.as_ref(ctx).has_pending_batch_for_test(file_id),
|
||||
"Pending batch should be discarded on conflict detected"
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_push_accepted_without_pending_batch() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let host_id = test_host_id();
|
||||
let path = test_path();
|
||||
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.seed_remote_buffer_for_test(host_id.clone(), path.clone(), "hello", 1, ctx)
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
// No pending batch — clean push should be accepted.
|
||||
let handle = gbm(&app);
|
||||
app.read(|ctx| {
|
||||
assert!(!handle.as_ref(ctx).has_pending_batch_for_test(file_id));
|
||||
});
|
||||
|
||||
gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.handle_buffer_updated_push(
|
||||
&host_id,
|
||||
path.as_str(),
|
||||
2,
|
||||
0, // matches client_version=0
|
||||
&[char_edit(6, 6, " world")],
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
assert_eq!(content(&app, file_id), "hello world");
|
||||
|
||||
// Clock should be updated.
|
||||
let handle = gbm(&app);
|
||||
app.read(|ctx| {
|
||||
let clock = handle
|
||||
.as_ref(ctx)
|
||||
.sync_clock_for_remote_test(file_id)
|
||||
.unwrap();
|
||||
assert_eq!(clock.server_version, ContentVersion::from_raw(2));
|
||||
assert_eq!(clock.client_version, ContentVersion::from_raw(0));
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_batch_bumps_client_version_immediately() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
app.add_singleton_model(GlobalBufferModel::new);
|
||||
|
||||
let host_id = test_host_id();
|
||||
let path = test_path();
|
||||
|
||||
let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| {
|
||||
gbm.seed_remote_buffer_for_test(host_id.clone(), path.clone(), "hello", 1, ctx)
|
||||
});
|
||||
let file_id = _buffer_state.file_id;
|
||||
|
||||
// Insert a batch — this simulates what the ContentChanged handler does:
|
||||
// sync_clock.client_version is bumped immediately.
|
||||
let client_cv = ContentVersion::new();
|
||||
gbm(&app).update(&mut app, |gbm, _ctx| {
|
||||
gbm.insert_pending_batch_for_test(
|
||||
file_id,
|
||||
1,
|
||||
vec![text_edit(6, 6, " edit")],
|
||||
client_cv,
|
||||
);
|
||||
});
|
||||
|
||||
// The sync clock's client_version should already reflect the edit,
|
||||
// even though the batch hasn't been flushed.
|
||||
let handle = gbm(&app);
|
||||
app.read(|ctx| {
|
||||
let clock = handle
|
||||
.as_ref(ctx)
|
||||
.sync_clock_for_remote_test(file_id)
|
||||
.unwrap();
|
||||
assert_eq!(clock.client_version, client_cv);
|
||||
// server_version unchanged
|
||||
assert_eq!(clock.server_version, ContentVersion::from_raw(1));
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
use std::path::Path;
|
||||
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::{
|
||||
assets::asset_cache::AssetSource,
|
||||
elements::{CacheOption, Icon, Image},
|
||||
Element,
|
||||
};
|
||||
use galaxyui::assets::asset_cache::AssetSource;
|
||||
use galaxyui::elements::{CacheOption, Icon, Image};
|
||||
use galaxyui::Element;
|
||||
|
||||
/// Returns a special icon for the given file path, if any.
|
||||
pub fn icon_from_file_path(path: &str, appearance: &Appearance) -> Option<Box<dyn Element>> {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::blocklist::inline_action::code_diff_view::DiffSessionType;
|
||||
use ai::diff_validation::DiffType;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxy_files::{FileModel, FileModelEvent};
|
||||
@@ -14,12 +12,13 @@ use galaxyui::elements::ChildView;
|
||||
use galaxyui::SingletonEntity;
|
||||
use galaxyui::{AppContext, Element, Entity, TypedActionView, View, ViewContext, ViewHandle};
|
||||
|
||||
use super::diff_viewer::DiffViewer;
|
||||
use super::diff_viewer::DisplayMode;
|
||||
use super::diff_viewer::{DiffViewer, DisplayMode};
|
||||
use super::editor::scroll::{ScrollPosition, ScrollTrigger};
|
||||
use super::editor::view::{CodeEditorEvent, CodeEditorView};
|
||||
use super::editor::NavBarBehavior;
|
||||
use super::DiffResult;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::blocklist::inline_action::code_diff_view::DiffSessionType;
|
||||
use crate::editor::InteractionState;
|
||||
|
||||
pub enum InlineDiffViewEvent {
|
||||
@@ -128,9 +127,15 @@ impl InlineDiffView {
|
||||
let file_id = match session_type {
|
||||
DiffSessionType::Local => {
|
||||
let Some(local_path) = file_path.to_local_path() else {
|
||||
log::error!(
|
||||
"Failed to convert StandardizedPath to local path: {file_path}; \
|
||||
diff will be read-only",
|
||||
crate::safe_error!(
|
||||
safe: (
|
||||
"Failed to convert StandardizedPath to local path; diff will be \
|
||||
read-only"
|
||||
),
|
||||
full: (
|
||||
"Failed to convert StandardizedPath to local path: {file_path}; diff \
|
||||
will be read-only"
|
||||
)
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1,34 +1,29 @@
|
||||
use galaxy_core::ui::{
|
||||
appearance::Appearance,
|
||||
theme::{color::internal_colors, GalaxyTheme},
|
||||
};
|
||||
use galaxy_editor::{
|
||||
content::buffer::InitialBufferState,
|
||||
render::{element::VerticalExpansionBehavior, model::Decoration},
|
||||
};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
|
||||
CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, HighlightedHyperlink,
|
||||
Hoverable, MouseStateHandle, ParentElement, Radius, Rect, ScrollbarWidth,
|
||||
},
|
||||
AppContext, Element, SingletonEntity, ViewContext,
|
||||
};
|
||||
use lsp::{HoverContents, LspServerLogLevel, MarkupKind};
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use num_traits::SaturatingSub;
|
||||
use string_offset::CharOffset;
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_core::ui::theme::WarpTheme;
|
||||
use galaxy_editor::content::buffer::InitialBufferState;
|
||||
use galaxy_editor::render::element::VerticalExpansionBehavior;
|
||||
use galaxy_editor::render::model::Decoration;
|
||||
use galaxyui::elements::{
|
||||
Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
|
||||
CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, HighlightedHyperlink, Hoverable,
|
||||
MouseStateHandle, ParentElement, Radius, Rect, ScrollbarWidth,
|
||||
};
|
||||
use galaxyui::{AppContext, Element, SingletonEntity, ViewContext};
|
||||
|
||||
use super::editor::view::{CodeEditorRenderOptions, CodeEditorView};
|
||||
use super::lsp_telemetry::LspTelemetryEvent;
|
||||
use crate::code::local_code_editor::{
|
||||
HoverContentSegment, LocalCodeEditorView, LspHoverState, HOVER_TOOLTIP_MAX_HEIGHT,
|
||||
HOVER_TOOLTIP_MAX_WIDTH,
|
||||
};
|
||||
use crate::editor::InteractionState;
|
||||
|
||||
use super::editor::view::{CodeEditorRenderOptions, CodeEditorView};
|
||||
use super::lsp_telemetry::LspTelemetryEvent;
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
|
||||
/// A processed diagnostic with its converted offset range.
|
||||
/// Stored on LocalCodeEditorView and used for both decoration and hover display.
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -107,15 +107,11 @@ fn has_terminal_for_workspace(root: &Path, app: &AppContext) -> bool {
|
||||
for window_id in app.window_ids() {
|
||||
if let Some(terminals) = app.views_of_type::<TerminalView>(window_id) {
|
||||
for terminal in terminals {
|
||||
let Some(pwd) = terminal.as_ref(app).pwd_if_local(app) else {
|
||||
let Some(pwd) = terminal.as_ref(app).canonical_session_pwd_if_local(app) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(cwd) = PathBuf::from(pwd).canonicalize() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if cwd.starts_with(root) {
|
||||
if pwd.as_path().starts_with(root) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+260
-122
@@ -9,71 +9,66 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use ai::diff_validation::DiffType;
|
||||
use futures::stream::AbortHandle;
|
||||
use galaxy_core::{features::FeatureFlag, ui::appearance::Appearance};
|
||||
use galaxy_editor::{
|
||||
content::{buffer::InitialBufferState, text::IndentUnit},
|
||||
render::model::{Decoration, LineCount},
|
||||
};
|
||||
use galaxy_util::{
|
||||
content_version::ContentVersion,
|
||||
file::{FileId, FileLoadError, FileSaveError},
|
||||
path::to_relative_path,
|
||||
};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ConstrainedBox, Container,
|
||||
CornerRadius, CrossAxisAlignment, DropShadow, Flex, Hoverable, MainAxisAlignment,
|
||||
MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
|
||||
ParentOffsetBounds, Radius, Rect, Shrinkable, Stack, Text,
|
||||
},
|
||||
keymap::{macros::*, FixedBinding},
|
||||
text::point::Point,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
WindowId,
|
||||
};
|
||||
use galaxyui::{platform::SaveFilePickerConfiguration, ModelHandle};
|
||||
use lsp::types::FileLocation;
|
||||
use lsp::{
|
||||
types::FileLocation, LanguageId, LanguageServerId, LspEvent, LspManagerModel,
|
||||
LspManagerModelEvent, LspServerModel, ReferenceLocation,
|
||||
LanguageId, LanguageServerId, LspEvent, LspManagerModel, LspManagerModelEvent, LspServerModel,
|
||||
ReferenceLocation,
|
||||
};
|
||||
use lsp_types::FormattingOptions;
|
||||
use markdown_parser::FormattedText;
|
||||
use num_traits::SaturatingSub;
|
||||
use pathfinder_geometry::{rect::RectF, vector::Vector2F};
|
||||
use string_offset::CharOffset;
|
||||
use vec1::Vec1;
|
||||
|
||||
use crate::menu::{Event, Menu, MenuItem, MenuItemFields};
|
||||
|
||||
use crate::{
|
||||
code::{
|
||||
editor::model::HoverableLink,
|
||||
footer::{CodeFooterView, CodeFooterViewEvent},
|
||||
global_buffer_model::{BufferState, GlobalBufferModel},
|
||||
SaveOutcome, ShowFindReferencesCardProvider,
|
||||
},
|
||||
debounce::debounce,
|
||||
settings::AISettings,
|
||||
terminal::TerminalView,
|
||||
util::sync::Condition,
|
||||
};
|
||||
use crate::{
|
||||
code::{editor::EditorReviewComment, global_buffer_model::GlobalBufferModelEvent},
|
||||
code_review::comments::CommentId,
|
||||
};
|
||||
use ai::diff_validation::DiffType;
|
||||
use galaxy_core::ui::icons::Icon;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use remote_server::manager::RemoteServerManager;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use string_offset::CharOffset;
|
||||
use vec1::Vec1;
|
||||
use vim::vim::{MotionType, VimMode};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::r#async::debounce;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::icons::Icon;
|
||||
use galaxy_editor::content::buffer::InitialBufferState;
|
||||
use galaxy_editor::content::text::IndentUnit;
|
||||
use galaxy_editor::render::model::{Decoration, LineCount};
|
||||
use galaxy_util::content_version::ContentVersion;
|
||||
use galaxy_util::file::{FileId, FileLoadError, FileSaveError};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
||||
use galaxy_util::path::to_relative_path;
|
||||
use galaxy_util::sync::Condition;
|
||||
use galaxyui::elements::{
|
||||
Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ConstrainedBox, Container,
|
||||
CornerRadius, CrossAxisAlignment, DropShadow, Flex, Hoverable, MainAxisAlignment, MainAxisSize,
|
||||
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius,
|
||||
Rect, Shrinkable, Stack, Text,
|
||||
};
|
||||
use galaxyui::keymap::macros::*;
|
||||
use galaxyui::keymap::FixedBinding;
|
||||
use galaxyui::platform::SaveFilePickerConfiguration;
|
||||
use galaxyui::text::point::Point;
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use galaxyui::{
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle, WindowId,
|
||||
};
|
||||
|
||||
use crate::ai::persisted_workspace::{PersistedWorkspace, PersistedWorkspaceEvent};
|
||||
use crate::code::buffer_location::LocalOrRemotePath as BufferFileLocation;
|
||||
use crate::code::editor::model::HoverableLink;
|
||||
use crate::code::editor::EditorReviewComment;
|
||||
use crate::code::footer::{CodeFooterView, CodeFooterViewEvent};
|
||||
use crate::code::global_buffer_model::{BufferState, GlobalBufferModel, GlobalBufferModelEvent};
|
||||
use crate::code::{SaveOutcome, ShowFindReferencesCardProvider};
|
||||
use crate::code_review::comments::CommentId;
|
||||
use crate::menu::{Event, Menu, MenuItem, MenuItemFields};
|
||||
use crate::settings::{AISettings, CodeSettings};
|
||||
use crate::terminal::TerminalView;
|
||||
use crate::workspace::WorkspaceAction;
|
||||
|
||||
const DROP_SHADOW_COLOR: ColorU = ColorU {
|
||||
@@ -85,20 +80,17 @@ const DROP_SHADOW_COLOR: ColorU = ColorU {
|
||||
|
||||
const HOVER_DEBOUNCE_PERIOD: Duration = Duration::from_millis(500);
|
||||
|
||||
use super::code_actions::{CodeActionsState, CODE_ACTIONS_DEBOUNCE_PERIOD};
|
||||
use super::completion::{CompletionState, COMPLETION_DEBOUNCE_PERIOD};
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
|
||||
use super::diff_viewer::DiffViewer;
|
||||
use super::editor::{
|
||||
scroll::{ScrollPosition, ScrollTrigger},
|
||||
view::{CodeEditorEvent, CodeEditorView},
|
||||
};
|
||||
use super::editor::scroll::{ScrollPosition, ScrollTrigger};
|
||||
use super::editor::view::{CodeEditorEvent, CodeEditorView};
|
||||
use super::find_references_view::{FindReferencesView, FindReferencesViewEvent};
|
||||
use super::language_server_extension::ProcessedDiagnostic;
|
||||
use super::lsp_telemetry::LspTelemetryEvent;
|
||||
use super::rename::RenameState;
|
||||
use super::signature_help::SignatureHelpState;
|
||||
use super::ImmediateSaveError;
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
|
||||
type SaveCallback =
|
||||
Box<dyn FnOnce(SaveOutcome, &mut ViewContext<LocalCodeEditorView>) + Send + Sync + 'static>;
|
||||
@@ -189,9 +181,9 @@ pub enum LocalCodeEditorEvent {
|
||||
|
||||
/// Metadata about a file that is opened in the code view.
|
||||
#[derive(Debug, Clone)]
|
||||
enum LoadedFileMetadata {
|
||||
/// Normal file with both FileId and path (for files that are actually opened)
|
||||
LocalFile { id: FileId, path: PathBuf },
|
||||
struct LoadedFileMetadata {
|
||||
id: FileId,
|
||||
location: BufferFileLocation,
|
||||
}
|
||||
|
||||
pub use super::diff_viewer::DisplayMode;
|
||||
@@ -310,6 +302,9 @@ pub struct LocalCodeEditorView {
|
||||
was_edited: bool,
|
||||
/// Content version of the base file state.
|
||||
base_content_version: Option<ContentVersion>,
|
||||
/// Set to `true` when a `RemoteBufferConflict` event fires for this
|
||||
/// editor's buffer. Cleared when the user discards or overwrites.
|
||||
has_remote_conflict: bool,
|
||||
conflict_banner_mouse_states: ConflictResolutionBannerMouseStates,
|
||||
/// Default directory to use for save dialogs when creating new files
|
||||
default_directory: Option<PathBuf>,
|
||||
@@ -565,6 +560,7 @@ impl LocalCodeEditorView {
|
||||
selection_as_context_tooltip: None,
|
||||
was_edited: false,
|
||||
base_content_version: None,
|
||||
has_remote_conflict: false,
|
||||
conflict_banner_mouse_states: Default::default(),
|
||||
default_directory: None,
|
||||
lsp_server: None,
|
||||
@@ -1083,6 +1079,14 @@ impl LocalCodeEditorView {
|
||||
}
|
||||
|
||||
fn format_and_save(&mut self, file_id: FileId, ctx: &mut ViewContext<Self>) {
|
||||
// Respect the user's format-on-save setting. When disabled, save without
|
||||
// requesting LSP document formatting; all other LSP features (hover,
|
||||
// go-to-definition, references, diagnostics) are unaffected.
|
||||
if !*CodeSettings::as_ref(ctx).format_on_save {
|
||||
self.perform_save(file_id, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(lsp_server) = &self.lsp_server else {
|
||||
self.perform_save(file_id, ctx);
|
||||
return;
|
||||
@@ -1256,9 +1260,13 @@ impl LocalCodeEditorView {
|
||||
GlobalBufferModel::as_ref(ctx).buffer_loaded(file_id)
|
||||
}
|
||||
|
||||
/// Construct a new local editor view with a shared buffer.
|
||||
/// Construct a new editor view with a shared buffer backed by the given location.
|
||||
///
|
||||
/// For local files, sets the language from the file path and wires up LSP.
|
||||
/// For remote files, sets the language from the extension and skips
|
||||
/// local-only wiring (LSP, footer).
|
||||
pub fn new_with_global_buffer<T>(
|
||||
path: &Path,
|
||||
location: BufferFileLocation,
|
||||
editor_constructor: T,
|
||||
enable_diff_nav_by_default: bool,
|
||||
display_mode: Option<DisplayMode>,
|
||||
@@ -1268,24 +1276,35 @@ impl LocalCodeEditorView {
|
||||
T: FnOnce(BufferState, &mut ViewContext<Self>) -> ViewHandle<CodeEditorView>,
|
||||
{
|
||||
let buffer_state = GlobalBufferModel::handle(ctx)
|
||||
.update(ctx, |model, ctx| model.open(path.to_path_buf(), ctx));
|
||||
.update(ctx, |model, ctx| model.open(location.clone(), ctx));
|
||||
let file_id = buffer_state.file_id;
|
||||
let editor = editor_constructor(buffer_state, ctx);
|
||||
|
||||
editor.update(ctx, |editor, ctx| {
|
||||
editor.set_language_with_path(path, ctx);
|
||||
// Rebuild layout and bootstrap syntax highlighting for the editor with existing buffer content.
|
||||
editor.model.update(ctx, |model, ctx| {
|
||||
model.rebuild_layout_with_syntax_highlighting(ctx)
|
||||
});
|
||||
});
|
||||
match &location {
|
||||
BufferFileLocation::Local(path) => {
|
||||
editor.update(ctx, |editor, ctx| {
|
||||
editor.set_language_with_local_path(path, ctx);
|
||||
editor.model.update(ctx, |model, ctx| {
|
||||
model.rebuild_layout_with_syntax_highlighting(ctx)
|
||||
});
|
||||
});
|
||||
}
|
||||
BufferFileLocation::Remote(remote_path) => {
|
||||
editor.update(ctx, |editor, ctx| {
|
||||
editor.set_language_with_path(&remote_path.path, ctx);
|
||||
editor.model.update(ctx, |model, ctx| {
|
||||
model.rebuild_layout_with_syntax_highlighting(ctx)
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut local_editor =
|
||||
Self::new(editor, None, enable_diff_nav_by_default, display_mode, ctx);
|
||||
|
||||
local_editor.metadata = Some(LoadedFileMetadata::LocalFile {
|
||||
local_editor.metadata = Some(LoadedFileMetadata {
|
||||
id: file_id,
|
||||
path: path.to_path_buf(),
|
||||
location,
|
||||
});
|
||||
|
||||
Self::subscribe_to_global_buffer_events(file_id, ctx);
|
||||
@@ -1453,7 +1472,6 @@ impl LocalCodeEditorView {
|
||||
/// 5. Starting the LSP server via PersistedWorkspace
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn enable_lsp_for_path(path: &Path, ctx: &mut ViewContext<Self>) {
|
||||
use crate::ai::persisted_workspace::LspTask;
|
||||
|
||||
// Get the language ID from the file path
|
||||
let Some(language_id) = LanguageId::from_path(path) else {
|
||||
@@ -1472,7 +1490,10 @@ impl LocalCodeEditorView {
|
||||
{
|
||||
Some(workspace_root.to_path_buf())
|
||||
} else {
|
||||
match DetectedRepositories::as_ref(ctx).get_root_for_path(path) {
|
||||
match DetectedRepositories::as_ref(ctx)
|
||||
.get_root_for_path(&LocalOrRemotePath::Local(path.to_path_buf()))
|
||||
.and_then(|r| PathBuf::try_from(r).ok())
|
||||
{
|
||||
Some(root) => Some(root),
|
||||
None => path.parent().map(|s| s.to_path_buf()), // If we can't find root, treat the parent as the root.
|
||||
}
|
||||
@@ -1495,7 +1516,6 @@ impl LocalCodeEditorView {
|
||||
/// and emits events that are handled by handle_persisted_workspace_event.
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn install_and_enable_lsp_for_path(path: &Path, ctx: &mut ViewContext<Self>) {
|
||||
use crate::ai::persisted_workspace::LspTask;
|
||||
|
||||
let Some(language_id) = LanguageId::from_path(path) else {
|
||||
log::warn!("Install and enable lsp for path should only work for supported file paths");
|
||||
@@ -1510,7 +1530,10 @@ impl LocalCodeEditorView {
|
||||
{
|
||||
Some(workspace_root.to_path_buf())
|
||||
} else {
|
||||
match DetectedRepositories::as_ref(ctx).get_root_for_path(&path) {
|
||||
match DetectedRepositories::as_ref(ctx)
|
||||
.get_root_for_path(&LocalOrRemotePath::Local(path.to_path_buf()))
|
||||
.and_then(|r| PathBuf::try_from(r).ok())
|
||||
{
|
||||
Some(root) => Some(root),
|
||||
None => path.parent().map(|s| s.to_path_buf()),
|
||||
}
|
||||
@@ -1580,19 +1603,24 @@ impl LocalCodeEditorView {
|
||||
if event.file_id() != file_id {
|
||||
return;
|
||||
}
|
||||
me.update_diff_hunk_gutter_buttons(ctx);
|
||||
match event {
|
||||
GlobalBufferModelEvent::BufferLoaded {
|
||||
content_version, ..
|
||||
} => {
|
||||
// For a reopen (discard), base_content_version is already
|
||||
// set from the initial load. Accept the new version and
|
||||
// clear any conflict flag.
|
||||
me.has_remote_conflict = false;
|
||||
if me.base_content_version.is_some() {
|
||||
return;
|
||||
me.base_content_version = Some(*content_version);
|
||||
ctx.notify();
|
||||
} else {
|
||||
me.base_content_version = Some(*content_version);
|
||||
me.subscribe_to_lsp_manager_updates(ctx);
|
||||
me.try_connect_lsp_server(ctx);
|
||||
me.on_file_loaded(ctx);
|
||||
ctx.emit(LocalCodeEditorEvent::FileLoaded);
|
||||
}
|
||||
me.base_content_version = Some(*content_version);
|
||||
me.subscribe_to_lsp_manager_updates(ctx);
|
||||
me.try_connect_lsp_server(ctx);
|
||||
me.on_file_loaded(ctx);
|
||||
ctx.emit(LocalCodeEditorEvent::FileLoaded);
|
||||
}
|
||||
GlobalBufferModelEvent::FailedToLoad { error, .. } => {
|
||||
me.is_new_file = true;
|
||||
@@ -1612,7 +1640,11 @@ impl LocalCodeEditorView {
|
||||
me.base_content_version = Some(*content_version);
|
||||
}
|
||||
}
|
||||
GlobalBufferModelEvent::FileSaved { .. } => {
|
||||
GlobalBufferModelEvent::FileSaved {
|
||||
content_version, ..
|
||||
} => {
|
||||
me.base_content_version = Some(*content_version);
|
||||
me.has_remote_conflict = false;
|
||||
ctx.emit(LocalCodeEditorEvent::FileSaved);
|
||||
}
|
||||
GlobalBufferModelEvent::FailedToSave { error, .. } => {
|
||||
@@ -1621,21 +1653,53 @@ impl LocalCodeEditorView {
|
||||
error: error.clone(),
|
||||
});
|
||||
}
|
||||
GlobalBufferModelEvent::RemoteBufferConflict { .. } => {
|
||||
me.has_remote_conflict = true;
|
||||
ctx.notify();
|
||||
}
|
||||
GlobalBufferModelEvent::ServerLocalBufferUpdated { .. } => {
|
||||
// Not relevant for local code editors.
|
||||
}
|
||||
}
|
||||
|
||||
me.update_diff_hunk_gutter_buttons(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn has_version_conflicts(&self, app: &AppContext) -> bool {
|
||||
// Remote buffers use SyncClock for conflict detection.
|
||||
// The flag is set by the RemoteBufferConflict event handler.
|
||||
if matches!(self.file_location(), Some(BufferFileLocation::Remote(_))) {
|
||||
return self.has_remote_conflict;
|
||||
}
|
||||
let Some(file_id) = self.file_id() else {
|
||||
return false;
|
||||
};
|
||||
self.has_unsaved_changes(app)
|
||||
&& self.base_content_version != GlobalBufferModel::as_ref(app).base_version(file_id)
|
||||
}
|
||||
/// Save the file to the local file system.
|
||||
|
||||
/// Returns `true` when this editor is backed by a remote file whose
|
||||
/// host no longer has any connected session. Derived on-the-fly from
|
||||
/// `RemoteServerManager` so it is always in sync with actual
|
||||
/// connection state.
|
||||
pub fn is_remote_disconnected(&self, app: &AppContext) -> bool {
|
||||
let Some(BufferFileLocation::Remote(remote_path)) = self.file_location() else {
|
||||
return false;
|
||||
};
|
||||
RemoteServerManager::as_ref(app)
|
||||
.client_for_host(&remote_path.host_id)
|
||||
.is_none()
|
||||
}
|
||||
|
||||
/// Save the file to the local file system (or remotely via the remote server).
|
||||
/// This will only return an error immediately if there is a failure in the sync part of the call.
|
||||
/// Other errors could be returned asynchronously via the FileModelEvent::FailedToSave event.
|
||||
pub fn save_local(&mut self, ctx: &mut ViewContext<Self>) -> Result<(), ImmediateSaveError> {
|
||||
if self.is_remote_disconnected(ctx) {
|
||||
return Err(ImmediateSaveError::RemoteDisconnected);
|
||||
}
|
||||
|
||||
let Some(file_id) = self.file_id() else {
|
||||
return Err(ImmediateSaveError::NoFileId);
|
||||
};
|
||||
@@ -1682,15 +1746,15 @@ impl LocalCodeEditorView {
|
||||
.update(ctx, |model, ctx| model.register(path.clone(), buffer, ctx));
|
||||
|
||||
let file_id = buffer_state.file_id;
|
||||
me.metadata = Some(LoadedFileMetadata::LocalFile {
|
||||
me.metadata = Some(LoadedFileMetadata {
|
||||
id: file_id,
|
||||
path: path.clone(),
|
||||
location: BufferFileLocation::Local(path.clone()),
|
||||
});
|
||||
|
||||
me.set_new_file(false);
|
||||
|
||||
me.editor.update(ctx, |editor, ctx| {
|
||||
editor.set_language_with_path(&path, ctx);
|
||||
editor.set_language_with_local_path(&path, ctx);
|
||||
});
|
||||
|
||||
let content = me.editor.as_ref(ctx).text(ctx).into_string();
|
||||
@@ -1749,15 +1813,18 @@ impl LocalCodeEditorView {
|
||||
}
|
||||
|
||||
pub fn file_id(&self) -> Option<FileId> {
|
||||
self.metadata.as_ref().map(|metadata| match metadata {
|
||||
LoadedFileMetadata::LocalFile { id, .. } => *id,
|
||||
})
|
||||
self.metadata.as_ref().map(|m| m.id)
|
||||
}
|
||||
|
||||
/// Returns the unified file location (local or remote).
|
||||
pub fn file_location(&self) -> Option<&BufferFileLocation> {
|
||||
self.metadata.as_ref().map(|m| &m.location)
|
||||
}
|
||||
|
||||
/// Returns the local path if this editor is backed by a local file.
|
||||
/// Returns `None` for remote files. Used by LSP and other local-only code paths.
|
||||
pub fn file_path(&self) -> Option<&Path> {
|
||||
self.metadata.as_ref().map(|metadata| match metadata {
|
||||
LoadedFileMetadata::LocalFile { path, .. } => path.as_path(),
|
||||
})
|
||||
self.file_location().and_then(|loc| loc.to_local_path())
|
||||
}
|
||||
|
||||
/// Update this editor's file identity after a `GlobalBufferModel::rename`.
|
||||
@@ -1771,13 +1838,13 @@ impl LocalCodeEditorView {
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let file_id = buffer_state.file_id;
|
||||
self.metadata = Some(LoadedFileMetadata::LocalFile {
|
||||
self.metadata = Some(LoadedFileMetadata {
|
||||
id: file_id,
|
||||
path: new_path.to_path_buf(),
|
||||
location: BufferFileLocation::Local(new_path.to_path_buf()),
|
||||
});
|
||||
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.set_language_with_path(new_path, ctx);
|
||||
editor.set_language_with_local_path(new_path, ctx);
|
||||
});
|
||||
|
||||
// Re-subscribe to GlobalBufferModel events for the new file_id.
|
||||
@@ -2184,30 +2251,45 @@ impl View for LocalCodeEditorView {
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn galaxyui::Element> {
|
||||
// Rendering the version conflict banner.
|
||||
let base: Box<dyn Element> = if self.has_version_conflicts(app) {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let banner = render_unsaved_changes_banner(
|
||||
appearance,
|
||||
self.conflict_banner_mouse_states
|
||||
.discard_mouse_state
|
||||
.clone(),
|
||||
self.conflict_banner_mouse_states
|
||||
.overwrite_mouse_state
|
||||
.clone(),
|
||||
);
|
||||
let mut col = Flex::column().with_child(banner);
|
||||
// Rendering the remote disconnection banner or version conflict banner.
|
||||
// Only show the disconnection banner if the file was successfully loaded;
|
||||
// if it never loaded, the error/loading state handles that.
|
||||
let base: Box<dyn Element> =
|
||||
if self.base_content_version.is_some() && self.is_remote_disconnected(app) {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let banner = render_remote_disconnected_banner(appearance);
|
||||
let mut col = Flex::column().with_child(banner);
|
||||
|
||||
let editor_view = ChildView::new(&self.editor).finish();
|
||||
if self.editor.as_ref(app).needs_vertical_constraint() {
|
||||
col.add_child(Shrinkable::new(1., editor_view).finish());
|
||||
let editor_view = ChildView::new(&self.editor).finish();
|
||||
if self.editor.as_ref(app).needs_vertical_constraint() {
|
||||
col.add_child(Shrinkable::new(1., editor_view).finish());
|
||||
} else {
|
||||
col.add_child(editor_view);
|
||||
}
|
||||
col.finish()
|
||||
} else if self.has_version_conflicts(app) {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let banner = render_unsaved_changes_banner(
|
||||
appearance,
|
||||
self.conflict_banner_mouse_states
|
||||
.discard_mouse_state
|
||||
.clone(),
|
||||
self.conflict_banner_mouse_states
|
||||
.overwrite_mouse_state
|
||||
.clone(),
|
||||
);
|
||||
let mut col = Flex::column().with_child(banner);
|
||||
|
||||
let editor_view = ChildView::new(&self.editor).finish();
|
||||
if self.editor.as_ref(app).needs_vertical_constraint() {
|
||||
col.add_child(Shrinkable::new(1., editor_view).finish());
|
||||
} else {
|
||||
col.add_child(editor_view);
|
||||
}
|
||||
col.finish()
|
||||
} else {
|
||||
col.add_child(editor_view);
|
||||
}
|
||||
col.finish()
|
||||
} else {
|
||||
ChildView::new(&self.editor).finish()
|
||||
};
|
||||
ChildView::new(&self.editor).finish()
|
||||
};
|
||||
|
||||
let base_with_handler =
|
||||
Hoverable::new(self.context_menu_state.mouse_state.clone(), |_| base)
|
||||
@@ -2344,6 +2426,17 @@ impl TypedActionView for LocalCodeEditorView {
|
||||
if let Some(path) = self.file_path().map(Path::to_path_buf) {
|
||||
self.base_content_version = Some(self.editor().as_ref(ctx).version(ctx));
|
||||
ctx.emit(LocalCodeEditorEvent::DiscardUnsavedChanges { path });
|
||||
} else if self.has_remote_conflict {
|
||||
// Remote file: re-open the buffer from the server to get
|
||||
// the latest on-disk content. The BufferLoaded event will
|
||||
// clear has_remote_conflict and update base_content_version.
|
||||
// If the re-open fails, has_remote_conflict stays true and
|
||||
// the banner remains visible so the user can retry.
|
||||
if let Some(file_id) = self.file_id() {
|
||||
GlobalBufferModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
model.reopen_remote_buffer(file_id, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
LocalCodeEditorAction::NavigateToTarget(location) => {
|
||||
@@ -2506,6 +2599,51 @@ pub fn render_unsaved_changes_banner(
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders a banner indicating that the remote SSH session is disconnected
|
||||
/// and save / auto-reload are unavailable.
|
||||
pub fn render_remote_disconnected_banner(appearance: &Appearance) -> Box<dyn Element> {
|
||||
let row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::Warning
|
||||
.to_warpui_icon(appearance.theme().active_ui_text_color())
|
||||
.finish(),
|
||||
)
|
||||
.with_height(16.)
|
||||
.with_width(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Text::new(
|
||||
"Remote host disconnected. You will not be able to see updates and save changes.",
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(appearance.theme().active_ui_text_color().into())
|
||||
.soft_wrap(true)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Container::new(row)
|
||||
.with_background(appearance.theme().text_selection_as_context_color())
|
||||
.with_padding_top(8.)
|
||||
.with_padding_bottom(8.)
|
||||
.with_padding_left(12.)
|
||||
.with_padding_right(12.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders a small yellow circle with tooltip indicating unsaved changes
|
||||
pub fn render_unsaved_circle_with_tooltip(
|
||||
mouse_state: MouseStateHandle,
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use galaxy_editor::{content::buffer::InitialBufferState, render::model::LineCount};
|
||||
use galaxy_util::file::{FileLoadError, FileSaveError};
|
||||
use galaxyui::{
|
||||
elements::MouseStateHandle, AppContext, Element, Entity, TypedActionView, View, ViewContext,
|
||||
ViewHandle, WindowId,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use ai::diff_validation::DiffType;
|
||||
|
||||
use super::editor::view::CodeEditorView;
|
||||
use super::ImmediateSaveError;
|
||||
use crate::terminal::TerminalView;
|
||||
use crate::{code::editor::EditorReviewComment, code_review::comments::CommentId};
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_editor::content::buffer::InitialBufferState;
|
||||
use galaxy_editor::render::model::LineCount;
|
||||
use galaxy_util::file::{FileLoadError, FileSaveError};
|
||||
use galaxyui::elements::MouseStateHandle;
|
||||
use galaxyui::{
|
||||
AppContext, Element, Entity, TypedActionView, View, ViewContext, ViewHandle, WindowId,
|
||||
};
|
||||
|
||||
pub use super::diff_viewer::DisplayMode;
|
||||
use super::editor::view::CodeEditorView;
|
||||
use super::ImmediateSaveError;
|
||||
use crate::code::buffer_location::LocalOrRemotePath as BufferFileLocation;
|
||||
use crate::code::editor::EditorReviewComment;
|
||||
use crate::code_review::comments::CommentId;
|
||||
use crate::terminal::TerminalView;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LocalCodeEditorEvent {
|
||||
@@ -97,7 +95,9 @@ impl LocalCodeEditorView {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn file_path(&self) -> Option<&Path> {
|
||||
/// Returns the unified file location (local or remote).
|
||||
/// The WASM stub has no backing file, so this always returns `None`.
|
||||
pub fn file_location(&self) -> Option<&BufferFileLocation> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
+8
-4
@@ -1,11 +1,12 @@
|
||||
use galaxy_util::file::FileSaveError;
|
||||
use galaxyui::elements::DropTargetData;
|
||||
use galaxyui::AppContext;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use std::any::Any;
|
||||
use std::fmt::Debug;
|
||||
use std::ops::AddAssign;
|
||||
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use galaxy_util::file::FileSaveError;
|
||||
use galaxyui::elements::DropTargetData;
|
||||
use galaxyui::AppContext;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod code_actions;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
@@ -23,6 +24,7 @@ pub mod rename;
|
||||
pub mod signature_help;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub use local_code_editor::ShowFindReferencesCard;
|
||||
pub mod buffer_location;
|
||||
pub mod diff_viewer;
|
||||
pub mod editor;
|
||||
pub mod editor_management;
|
||||
@@ -43,6 +45,8 @@ pub enum ImmediateSaveError {
|
||||
FailedToSave(#[from] FileSaveError),
|
||||
#[error("There is no file tab currently selected")]
|
||||
NoActiveFileTab,
|
||||
#[error("Remote session disconnected")]
|
||||
RemoteDisconnected,
|
||||
}
|
||||
|
||||
/// Trait to determine whether we should show the comment editor based on state held
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
//! Module containing the definition of [`OpenedFilesModel`],
|
||||
//! which tracks files that have been opened, organized by repository.
|
||||
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
use instant::Instant;
|
||||
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
||||
|
||||
/// Tracks opened files within a single repository.
|
||||
/// Keys are repo-relative file paths (e.g. `src/main.rs`).
|
||||
#[derive(Default, Clone)]
|
||||
pub struct OpenedFilesInRepo(HashMap<PathBuf, Instant>);
|
||||
pub struct OpenedFilesInRepo(HashMap<String, Instant>);
|
||||
|
||||
impl OpenedFilesInRepo {
|
||||
pub fn get(&self, file_path: &PathBuf) -> Option<&Instant> {
|
||||
self.0.get(file_path)
|
||||
pub fn get(&self, relative_path: &str) -> Option<&Instant> {
|
||||
self.0.get(relative_path)
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&PathBuf, &Instant)> {
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&String, &Instant)> {
|
||||
self.0.iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// Model that tracks files that have been opened, organized by repository.
|
||||
/// Maps repository paths to files and when they were last opened.
|
||||
/// Maps repository root locations (local or remote) to their opened files.
|
||||
#[derive(Default)]
|
||||
pub struct OpenedFilesModel {
|
||||
opened_files: HashMap<PathBuf, OpenedFilesInRepo>,
|
||||
opened_files: HashMap<LocalOrRemotePath, OpenedFilesInRepo>,
|
||||
}
|
||||
|
||||
impl Entity for OpenedFilesModel {
|
||||
@@ -38,31 +42,35 @@ impl OpenedFilesModel {
|
||||
}
|
||||
|
||||
/// Get all opened files for a specific repository.
|
||||
pub fn opened_files_for_repo(&self, repo_path: &PathBuf) -> Option<&OpenedFilesInRepo> {
|
||||
self.opened_files.get(repo_path)
|
||||
pub fn opened_files_for_repo(
|
||||
&self,
|
||||
repo_root: &LocalOrRemotePath,
|
||||
) -> Option<&OpenedFilesInRepo> {
|
||||
self.opened_files.get(repo_root)
|
||||
}
|
||||
|
||||
/// Record that a file has been opened in a repository. If the `file_path` is not within the `repo_path`,
|
||||
/// then the file is not recorded.
|
||||
/// Record that a file has been opened in a repository.
|
||||
///
|
||||
/// `repo_root` is the repository root location (local or remote).
|
||||
/// `file_location` is the absolute file location. If it is not within
|
||||
/// `repo_root`, the file is not recorded.
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub fn file_opened(
|
||||
&mut self,
|
||||
repo_path: PathBuf,
|
||||
file_path: PathBuf,
|
||||
repo_root: LocalOrRemotePath,
|
||||
file_location: &LocalOrRemotePath,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let opened_at = Instant::now();
|
||||
|
||||
// Convert absolute file path to relative path from repo root
|
||||
let Ok(relative_file_path) = file_path.strip_prefix(&repo_path) else {
|
||||
let Some(relative_path) = repo_root.strip_repo_prefix(file_location) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let opened_at = Instant::now();
|
||||
self.opened_files
|
||||
.entry(repo_path.clone())
|
||||
.entry(repo_root)
|
||||
.or_default()
|
||||
.0
|
||||
.insert(relative_file_path.into(), opened_at);
|
||||
.insert(relative_path, opened_at);
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
+303
-183
@@ -1,84 +1,77 @@
|
||||
use crate::code::editor::scroll::ScrollPosition;
|
||||
use crate::code::editor::view::CodeEditorRenderOptions;
|
||||
use crate::code::editor_management::CodeEditorStatus;
|
||||
use crate::code::global_buffer_model::GlobalBufferModel;
|
||||
use crate::code::local_code_editor::ShowFindReferencesCard;
|
||||
use crate::code::{ImmediateSaveError, SaveOutcome, SaveStatus};
|
||||
use crate::editor::InteractionState;
|
||||
use crate::input::Vector2F;
|
||||
use crate::pane_group::focus_state::PaneFocusHandle;
|
||||
use crate::pane_group::pane::view::header::components::{
|
||||
render_pane_header_buttons, render_pane_header_title_text, render_three_column_header,
|
||||
CenteredHeaderEdgeWidth,
|
||||
};
|
||||
use crate::pane_group::pane::view::header::render_pane_header_draggable;
|
||||
use crate::pane_group::{CodePane, PaneConfigurationEvent, PaneDragDropLocation};
|
||||
use crate::quit_warning::UnsavedStateSummary;
|
||||
use crate::server::telemetry::CodeContextDestination;
|
||||
use crate::terminal::cli_agent::{
|
||||
build_selection_line_range_prompt, build_selection_substring_prompt,
|
||||
};
|
||||
use crate::terminal::view::CliAgentRouting;
|
||||
use crate::workspace::util::get_context_target_terminal_view;
|
||||
use crate::workspace::TabBarDropTargetData;
|
||||
use crate::{code::EditorTabBarDropTargetData, pane_group::pane::ActionOrigin};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use lsp::LspManagerModel;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use galaxy_core::channel::{Channel, ChannelState};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::icons::ICON_DIMENSIONS;
|
||||
use galaxy_editor::render::element::VerticalExpansionBehavior;
|
||||
use galaxy_util::path::LineAndColumnArg;
|
||||
use galaxyui::elements::Rect;
|
||||
use galaxyui::fonts::Style;
|
||||
use galaxyui::text::point::Point;
|
||||
use galaxyui::text_layout::ClipConfig;
|
||||
use lsp::LspManagerModel;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxyui::clipboard::ClipboardContent;
|
||||
use galaxyui::elements::{
|
||||
AcceptedByDropTarget, Align, Border, ChildAnchor, ChildView, Clipped, ConstrainedBox,
|
||||
Container, CornerRadius, CrossAxisAlignment, Draggable, DraggableState, DropTarget, Empty,
|
||||
Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
|
||||
OffsetPositioning, Padding, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Rect,
|
||||
SavePosition, Shrinkable, Stack, Text,
|
||||
};
|
||||
use galaxyui::fonts::{Properties, Style, Weight};
|
||||
use galaxyui::keymap::EditableBinding;
|
||||
use galaxyui::text::point::Point;
|
||||
use galaxyui::text_layout::ClipConfig;
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
AcceptedByDropTarget, Align, Border, ChildAnchor, ChildView, Clipped, ConstrainedBox,
|
||||
Container, CornerRadius, CrossAxisAlignment, Draggable, DraggableState, DropTarget, Empty,
|
||||
Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
|
||||
OffsetPositioning, Padding, ParentAnchor, ParentElement, ParentOffsetBounds, Radius,
|
||||
SavePosition, Shrinkable, Stack, Text,
|
||||
},
|
||||
fonts::{Properties, Weight},
|
||||
id,
|
||||
keymap::EditableBinding,
|
||||
ui_components::{button::ButtonVariant, components::UiComponent},
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle, WindowId,
|
||||
id, AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View,
|
||||
ViewContext, ViewHandle, WindowId,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
menu::{MenuItem, MenuItemFields},
|
||||
notebooks::file::{is_markdown_file, MarkdownDisplayMode},
|
||||
search::{files::icon::icon_from_file_path, ItemHighlightState},
|
||||
tab::TAB_BAR_BORDER_HEIGHT,
|
||||
ui_components::{blended_colors, buttons::icon_button},
|
||||
view_components::{DismissibleToast, MarkdownToggleEvent, MarkdownToggleView},
|
||||
workspace::{ActiveSession, ToastStack, WorkspaceAction},
|
||||
use super::buffer_location::LocalOrRemotePath;
|
||||
use super::diff_viewer::DiffViewer;
|
||||
use super::editor::view::{CodeEditorEvent, CodeEditorView};
|
||||
use super::editor_management::{CodeManager, CodeSource};
|
||||
use super::local_code_editor::{LocalCodeEditorEvent, LocalCodeEditorView};
|
||||
use crate::code::editor::scroll::ScrollPosition;
|
||||
use crate::code::editor::view::CodeEditorRenderOptions;
|
||||
use crate::code::editor_management::CodeEditorStatus;
|
||||
use crate::code::global_buffer_model::GlobalBufferModel;
|
||||
use crate::code::local_code_editor::ShowFindReferencesCard;
|
||||
use crate::code::{EditorTabBarDropTargetData, ImmediateSaveError, SaveOutcome, SaveStatus};
|
||||
use crate::editor::InteractionState;
|
||||
use crate::input::Vector2F;
|
||||
use crate::menu::{MenuItem, MenuItemFields};
|
||||
use crate::notebooks::file::{is_markdown_file, MarkdownDisplayMode};
|
||||
use crate::pane_group::focus_state::PaneFocusHandle;
|
||||
use crate::pane_group::pane::view::header::components::{
|
||||
render_pane_header_buttons, render_pane_header_title_text, render_three_column_header,
|
||||
CenteredHeaderEdgeWidth,
|
||||
};
|
||||
|
||||
use crate::pane_group::pane::view::header::render_pane_header_draggable;
|
||||
use crate::pane_group::pane::{view, ActionOrigin, PaneHeaderAction};
|
||||
use crate::pane_group::{
|
||||
pane::{view, PaneHeaderAction},
|
||||
BackingView, PaneConfiguration, PaneEvent,
|
||||
BackingView, CodePane, PaneConfiguration, PaneConfigurationEvent, PaneDragDropLocation,
|
||||
PaneEvent, TabBarAxis,
|
||||
};
|
||||
|
||||
use super::{
|
||||
diff_viewer::DiffViewer,
|
||||
editor::view::{CodeEditorEvent, CodeEditorView},
|
||||
editor_management::{CodeManager, CodeSource},
|
||||
local_code_editor::{LocalCodeEditorEvent, LocalCodeEditorView},
|
||||
use crate::quit_warning::UnsavedStateSummary;
|
||||
use crate::search::files::icon::icon_from_file_path;
|
||||
use crate::search::ItemHighlightState;
|
||||
use crate::server::telemetry::CodeContextDestination;
|
||||
use crate::tab::TAB_BAR_BORDER_HEIGHT;
|
||||
use crate::terminal::cli_agent::{
|
||||
build_selection_line_range_prompt, build_selection_substring_prompt,
|
||||
};
|
||||
|
||||
use crate::terminal::view::CliAgentRouting;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::buttons::icon_button;
|
||||
use crate::util::path::{display_name_with_host, display_path_with_host};
|
||||
use crate::view_components::{DismissibleToast, MarkdownToggleEvent, MarkdownToggleView};
|
||||
use crate::workspace::util::get_context_target_terminal_view;
|
||||
use crate::workspace::{ActiveSession, TabBarDropTargetData, ToastStack, WorkspaceAction};
|
||||
use crate::{send_telemetry_from_ctx, TelemetryEvent};
|
||||
|
||||
type SaveCallback =
|
||||
@@ -162,6 +155,11 @@ pub enum CodeViewAction {
|
||||
ToggleMaximized,
|
||||
#[cfg(feature = "local_fs")]
|
||||
CopyFilePath,
|
||||
/// Open the active code tab's file in the platform's file manager
|
||||
/// (Finder on macOS, Explorer on Windows). No-op when the active tab has
|
||||
/// no resolvable local path.
|
||||
#[cfg(feature = "local_fs")]
|
||||
RevealInFinder,
|
||||
#[cfg(feature = "local_fs")]
|
||||
RenderMarkdown,
|
||||
DragOverIndex {
|
||||
@@ -181,11 +179,11 @@ pub enum CodeViewAction {
|
||||
pub enum CodeViewEvent {
|
||||
Pane(PaneEvent),
|
||||
TabChanged {
|
||||
file_path: Option<PathBuf>,
|
||||
location: Option<LocalOrRemotePath>,
|
||||
tab_index: usize,
|
||||
},
|
||||
FileOpened {
|
||||
file_path: PathBuf,
|
||||
location: LocalOrRemotePath,
|
||||
tab_index: usize,
|
||||
},
|
||||
RunTabConfigSkill {
|
||||
@@ -207,7 +205,7 @@ struct TabDataMouseStateHandles {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TabData {
|
||||
path: Option<PathBuf>,
|
||||
location: Option<LocalOrRemotePath>,
|
||||
editor_view: ViewHandle<LocalCodeEditorView>,
|
||||
mouse_state_handles: TabDataMouseStateHandles,
|
||||
preview: bool,
|
||||
@@ -221,8 +219,17 @@ pub enum PendingSaveIntent {
|
||||
}
|
||||
|
||||
impl TabData {
|
||||
pub fn path(&self) -> Option<PathBuf> {
|
||||
self.path.clone()
|
||||
/// Returns the file location (local or remote), if any.
|
||||
pub fn location(&self) -> Option<&LocalOrRemotePath> {
|
||||
self.location.as_ref()
|
||||
}
|
||||
|
||||
/// Returns the local filesystem path, if this tab is backed by a local file.
|
||||
/// Returns `None` for remote files and untitled tabs.
|
||||
pub fn local_path(&self) -> Option<PathBuf> {
|
||||
self.location
|
||||
.as_ref()
|
||||
.and_then(|loc| loc.to_local_path().map(Path::to_path_buf))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,9 +266,9 @@ impl CodeView {
|
||||
line_col: Option<LineAndColumnArg>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let path = source.path();
|
||||
let location = source.location();
|
||||
let mut view = Self::new_internal(source, ctx);
|
||||
view.open_or_focus_existing(path, line_col, ctx);
|
||||
view.open_or_focus_existing(location, line_col, ctx);
|
||||
#[cfg(feature = "local_fs")]
|
||||
{
|
||||
view.update_markdown_mode_segmented_control(ctx);
|
||||
@@ -271,15 +278,11 @@ impl CodeView {
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn update_markdown_mode_segmented_control(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let path = self
|
||||
.local_path(ctx)
|
||||
.or_else(|| {
|
||||
self.tab_at(self.active_tab_index)
|
||||
.and_then(|t| t.path.clone())
|
||||
})
|
||||
.or_else(|| self.source.path());
|
||||
|
||||
let is_markdown = path.as_ref().map(is_markdown_file).unwrap_or(false);
|
||||
let is_markdown = self
|
||||
.tab_at(self.active_tab_index)
|
||||
.and_then(|t| t.location.as_ref())
|
||||
.map(|loc| is_markdown_file(std::path::Path::new(&loc.display_path())))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_markdown {
|
||||
self.markdown_mode_segmented_control = None;
|
||||
@@ -317,7 +320,8 @@ impl CodeView {
|
||||
) -> Self {
|
||||
let mut view = Self::new_internal(source, ctx);
|
||||
for tab_snapshot in tabs {
|
||||
let tab_data = view.build_tab_data(tab_snapshot.path.clone(), false, ctx);
|
||||
let location = tab_snapshot.path.clone().map(LocalOrRemotePath::Local);
|
||||
let tab_data = view.build_tab_data(location, false, ctx);
|
||||
view.tab_group.push(tab_data);
|
||||
}
|
||||
let clamped_index = if view.tab_group.is_empty() {
|
||||
@@ -361,14 +365,20 @@ impl CodeView {
|
||||
}
|
||||
}
|
||||
|
||||
fn construct_shared_buffer_editor_from_path(
|
||||
/// Construct an editor backed by the global shared buffer for the given location.
|
||||
///
|
||||
/// For local files, additional features are wired up (selection-as-context,
|
||||
/// find-references, footer). Remote files skip these because LSP and
|
||||
/// related tooling run on the local machine.
|
||||
fn construct_editor_for_location(
|
||||
&mut self,
|
||||
path: &Path,
|
||||
location: LocalOrRemotePath,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> ViewHandle<LocalCodeEditorView> {
|
||||
let is_local = matches!(location, LocalOrRemotePath::Local(_));
|
||||
ctx.add_typed_action_view(|ctx| {
|
||||
let mut editor = LocalCodeEditorView::new_with_global_buffer(
|
||||
path,
|
||||
location,
|
||||
|buffer_state, ctx| {
|
||||
ctx.add_typed_action_view(|ctx| {
|
||||
CodeEditorView::new(
|
||||
@@ -389,20 +399,23 @@ impl CodeView {
|
||||
None,
|
||||
ctx,
|
||||
);
|
||||
if FeatureFlag::HoaCodeReview.is_enabled() {
|
||||
editor =
|
||||
editor.with_selection_as_context(Box::new(get_context_target_terminal_view));
|
||||
if is_local {
|
||||
if FeatureFlag::HoaCodeReview.is_enabled() {
|
||||
editor = editor
|
||||
.with_selection_as_context(Box::new(get_context_target_terminal_view));
|
||||
}
|
||||
let mut editor = editor.with_find_references_provider(
|
||||
ShowFindReferencesCard {
|
||||
editor_window_id: ctx.window_id(),
|
||||
parent_scrollable_position_id: None,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
editor.add_footer(ctx);
|
||||
editor
|
||||
} else {
|
||||
editor
|
||||
}
|
||||
let mut editor = editor.with_find_references_provider(
|
||||
ShowFindReferencesCard {
|
||||
editor_window_id: ctx.window_id(),
|
||||
parent_scrollable_position_id: None,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
editor.add_footer(ctx);
|
||||
editor
|
||||
})
|
||||
}
|
||||
|
||||
@@ -444,16 +457,16 @@ impl CodeView {
|
||||
|
||||
fn build_tab_data(
|
||||
&mut self,
|
||||
path: Option<PathBuf>,
|
||||
location: Option<LocalOrRemotePath>,
|
||||
preview: bool,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> TabData {
|
||||
// Opt out of shared buffer if we are creating a new file.
|
||||
// TODO(kevin): Once the file is saved, we should convert that into a shared buffer.
|
||||
let code_editor = if let Some(path) = path.as_ref() {
|
||||
self.construct_shared_buffer_editor_from_path(path, ctx)
|
||||
} else {
|
||||
self.construct_new_file_editor(ctx)
|
||||
let (code_editor, tab_location) = match location {
|
||||
Some(loc) => {
|
||||
let editor = self.construct_editor_for_location(loc.clone(), ctx);
|
||||
(editor, Some(loc))
|
||||
}
|
||||
None => (self.construct_new_file_editor(ctx), None),
|
||||
};
|
||||
|
||||
let editor = code_editor.as_ref(ctx).editor().clone();
|
||||
@@ -470,7 +483,7 @@ impl CodeView {
|
||||
});
|
||||
|
||||
// For new files (CodeSource::New), mark the editor as a new file and set default directory
|
||||
if path.is_none() && matches!(self.source, CodeSource::New { .. }) {
|
||||
if tab_location.is_none() && matches!(self.source, CodeSource::New { .. }) {
|
||||
let default_directory = self.source.default_directory().cloned();
|
||||
code_editor.update(ctx, |local_editor, _ctx| {
|
||||
local_editor.set_new_file(true);
|
||||
@@ -518,7 +531,7 @@ impl CodeView {
|
||||
);
|
||||
}
|
||||
LocalCodeEditorEvent::FileSaved => {
|
||||
me.sync_active_tab_path(ctx);
|
||||
me.sync_active_tab_location(ctx);
|
||||
me.set_title_after_content_update(ctx);
|
||||
CodeView::display_save_success(ctx.window_id(), ctx);
|
||||
ctx.notify();
|
||||
@@ -568,7 +581,11 @@ impl CodeView {
|
||||
column_num: Some(*column),
|
||||
};
|
||||
|
||||
me.open_or_focus_existing(Some(path.to_path_buf()), Some(line_col), ctx);
|
||||
me.open_or_focus_existing(
|
||||
Some(LocalOrRemotePath::Local(path.to_path_buf())),
|
||||
Some(line_col),
|
||||
ctx,
|
||||
);
|
||||
if let Some(editor) = me.tab_at(me.active_tab_index()).map(|tab| &tab.editor_view) {
|
||||
editor.update(ctx, |editor, ctx| {
|
||||
editor.cursor_at(Point::new(line_1based as u32, *column as u32), ctx);
|
||||
@@ -593,7 +610,7 @@ impl CodeView {
|
||||
});
|
||||
|
||||
TabData {
|
||||
path,
|
||||
location: tab_location,
|
||||
editor_view: code_editor,
|
||||
mouse_state_handles: Default::default(),
|
||||
preview,
|
||||
@@ -658,7 +675,7 @@ impl CodeView {
|
||||
if let Some(existing_index) = self
|
||||
.tab_group
|
||||
.iter()
|
||||
.position(|tab| tab.path == Some(path.clone()))
|
||||
.position(|tab| tab.location.as_ref() == Some(&LocalOrRemotePath::Local(path.clone())))
|
||||
{
|
||||
self.set_active_tab_index(existing_index, ctx);
|
||||
self.promote_if_preview(ctx);
|
||||
@@ -667,7 +684,8 @@ impl CodeView {
|
||||
|
||||
// Find the existing preview tab (if any) and replace it with a new GlobalBuffer-backed editor
|
||||
if let Some((preview_index, _)) = self.preview_tab() {
|
||||
let new_tab = self.build_tab_data(Some(path.clone()), true, ctx);
|
||||
let new_tab =
|
||||
self.build_tab_data(Some(LocalOrRemotePath::Local(path.clone())), true, ctx);
|
||||
self.tab_group[preview_index] = new_tab;
|
||||
|
||||
GlobalBufferModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
@@ -679,14 +697,14 @@ impl CodeView {
|
||||
}
|
||||
|
||||
// Create a new preview tab
|
||||
let new_tab = self.build_tab_data(Some(path.clone()), true, ctx);
|
||||
let new_tab = self.build_tab_data(Some(LocalOrRemotePath::Local(path.clone())), true, ctx);
|
||||
|
||||
self.tab_group.push(new_tab);
|
||||
let active_tab_index = self.tab_group.len() - 1;
|
||||
self.set_active_tab_index(active_tab_index, ctx);
|
||||
|
||||
ctx.emit(CodeViewEvent::FileOpened {
|
||||
file_path: path,
|
||||
location: LocalOrRemotePath::Local(path),
|
||||
tab_index: self.active_tab_index,
|
||||
});
|
||||
}
|
||||
@@ -705,30 +723,31 @@ impl CodeView {
|
||||
|
||||
pub fn open_or_focus_existing(
|
||||
&mut self,
|
||||
path: Option<PathBuf>,
|
||||
location: Option<LocalOrRemotePath>,
|
||||
line_col: Option<LineAndColumnArg>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// If the tab already exists, focus it (and optionally jump) without re-opening from disk.
|
||||
if let Some(existing_index) = self.focus_existing_tab_if_present(&path, ctx) {
|
||||
if let Some(existing_index) = self.focus_existing_tab_if_present(location.as_ref(), ctx) {
|
||||
if let Some(line_col) = line_col {
|
||||
self.jump_to_line_col_in_tab(existing_index, line_col, ctx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
self.open_new_tab_for_path(path, line_col, ctx);
|
||||
self.open_new_tab(location, line_col, ctx);
|
||||
}
|
||||
|
||||
fn focus_existing_tab_if_present(
|
||||
&mut self,
|
||||
path: &Option<PathBuf>,
|
||||
location: Option<&LocalOrRemotePath>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Option<usize> {
|
||||
let location = location?;
|
||||
let existing_index = self
|
||||
.tab_group
|
||||
.iter()
|
||||
.position(|tab| tab.path.as_ref() == path.as_ref())?;
|
||||
.position(|tab| tab.location.as_ref() == Some(location))?;
|
||||
self.set_active_tab_index(existing_index, ctx);
|
||||
Some(existing_index)
|
||||
}
|
||||
@@ -757,19 +776,19 @@ impl CodeView {
|
||||
});
|
||||
}
|
||||
|
||||
fn open_new_tab_for_path(
|
||||
fn open_new_tab(
|
||||
&mut self,
|
||||
path: Option<PathBuf>,
|
||||
location: Option<LocalOrRemotePath>,
|
||||
line_col: Option<LineAndColumnArg>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let new_tab = self.build_tab_data(path.clone(), false, ctx);
|
||||
let new_tab = self.build_tab_data(location.clone(), false, ctx);
|
||||
self.tab_group.push(new_tab);
|
||||
let active_tab_index = self.tab_group.len() - 1;
|
||||
|
||||
if let (Some(file_path), Some(tab)) = (path, self.tab_group.get(active_tab_index)) {
|
||||
if let (Some(loc), Some(tab)) = (&location, self.tab_group.get(active_tab_index)) {
|
||||
ctx.emit(CodeViewEvent::FileOpened {
|
||||
file_path: file_path.clone(),
|
||||
location: loc.clone(),
|
||||
tab_index: active_tab_index,
|
||||
});
|
||||
|
||||
@@ -794,15 +813,16 @@ impl CodeView {
|
||||
|
||||
/// Set the title of the pane, which is the file path.
|
||||
fn set_title(&self, _unsaved_changes: bool, ctx: &mut ViewContext<Self>) {
|
||||
let file = self.local_path(ctx);
|
||||
let file_location = self
|
||||
.tab_at(self.active_tab_index)
|
||||
.and_then(|t| t.editor_view.as_ref(ctx).file_location().cloned());
|
||||
let is_new = self
|
||||
.tab_at(self.active_tab_index)
|
||||
.is_some_and(|t| t.editor_view.as_ref(ctx).is_new_file());
|
||||
|
||||
let title = if let Some(file) = file {
|
||||
file.display().to_string()
|
||||
} else {
|
||||
"Untitled".to_string()
|
||||
let title = match &file_location {
|
||||
Some(location) => display_path_with_host(location, false, ctx),
|
||||
None => "Untitled".to_string(),
|
||||
};
|
||||
|
||||
self.pane_configuration.update(ctx, |pane_config, ctx| {
|
||||
@@ -816,6 +836,7 @@ impl CodeView {
|
||||
pane_config.set_title(title, ctx);
|
||||
pane_config.set_title_secondary(secondary, ctx);
|
||||
ctx.emit(PaneConfigurationEvent::TitleUpdated);
|
||||
ctx.emit(PaneConfigurationEvent::HeaderContentChanged);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -842,6 +863,14 @@ impl CodeView {
|
||||
// If there's no file ID, this is a new file - trigger Save As
|
||||
self.save_as(index, callback, ctx)
|
||||
}
|
||||
Err(ImmediateSaveError::RemoteDisconnected) => {
|
||||
log::warn!("Cannot save: remote session disconnected");
|
||||
CodeView::display_remote_disconnected_save_failure(ctx.window_id(), ctx);
|
||||
if let Some(callback) = callback {
|
||||
callback(SaveOutcome::Failed, self, ctx);
|
||||
}
|
||||
SaveStatus::Failed(ImmediateSaveError::RemoteDisconnected)
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Failed to save file. {err:?}");
|
||||
CodeView::display_save_failure(ctx.window_id(), ctx);
|
||||
@@ -906,6 +935,15 @@ impl CodeView {
|
||||
});
|
||||
}
|
||||
|
||||
fn display_remote_disconnected_save_failure(window_id: WindowId, ctx: &mut ViewContext<Self>) {
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
let toast =
|
||||
DismissibleToast::error(String::from("Cannot save — remote session disconnected."))
|
||||
.with_object_id("failed_to_save_file_remote_disconnected".to_string());
|
||||
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn display_save_success(window_id: WindowId, ctx: &mut ViewContext<Self>) {
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
let toast = DismissibleToast::success(String::from("File saved."))
|
||||
@@ -952,16 +990,11 @@ impl CodeView {
|
||||
self.set_title(self.contains_unsaved_changes(ctx), ctx);
|
||||
}
|
||||
|
||||
/// Update the TabData path for the active tab to match the LocalCodeEditor metadata.
|
||||
/// This is needed after save_as operations to keep the paths in sync.
|
||||
fn sync_active_tab_path(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
/// Update the TabData location for the active tab to match the LocalCodeEditor metadata.
|
||||
/// This is needed after save operations to keep local and remote locations in sync.
|
||||
fn sync_active_tab_location(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if let Some(tab) = self.tab_group.get_mut(self.active_tab_index) {
|
||||
let new_path = tab
|
||||
.editor_view
|
||||
.as_ref(ctx)
|
||||
.file_path()
|
||||
.map(|p| p.to_path_buf());
|
||||
tab.path = new_path;
|
||||
tab.location = tab.editor_view.as_ref(ctx).file_location().cloned();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1104,10 +1137,10 @@ impl CodeView {
|
||||
) {
|
||||
if let Some(tab) = self.tab_at(index) {
|
||||
let file_name = tab
|
||||
.path
|
||||
.location
|
||||
.as_ref()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|name| name.to_string_lossy().to_string());
|
||||
.map(|loc| display_name_with_host(loc, ctx))
|
||||
.filter(|n| !n.is_empty());
|
||||
let summary = UnsavedStateSummary::for_editor_tab(
|
||||
file_name,
|
||||
vec![CodeEditorStatus::new(Self::has_unsaved_changes(tab, ctx))],
|
||||
@@ -1163,7 +1196,7 @@ impl CodeView {
|
||||
index: usize,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Option<CodePane> {
|
||||
self.tab_at(index).and_then(|t| t.path()).map(|path| {
|
||||
self.tab_at(index).and_then(|t| t.local_path()).map(|path| {
|
||||
let source = CodeSource::Link {
|
||||
path,
|
||||
range_start: None,
|
||||
@@ -1256,9 +1289,9 @@ impl CodeView {
|
||||
self.active_tab_index = index;
|
||||
self.update_tab_bar_state(ctx);
|
||||
|
||||
let file_path = self.tab_at(index).and_then(|tab| tab.path());
|
||||
let location = self.tab_at(index).and_then(|tab| tab.location.clone());
|
||||
ctx.emit(CodeViewEvent::TabChanged {
|
||||
file_path,
|
||||
location,
|
||||
tab_index: index,
|
||||
});
|
||||
|
||||
@@ -1275,7 +1308,7 @@ impl CodeView {
|
||||
pub fn close_tabs_with_path(&mut self, file_path: &Path, ctx: &mut ViewContext<Self>) {
|
||||
let mut indices_to_remove = Vec::new();
|
||||
for (tab_idx, tab) in self.tab_group.iter().enumerate() {
|
||||
if tab.path.as_ref().is_some_and(|path| path == file_path) {
|
||||
if tab.local_path().is_some_and(|path| path == file_path) {
|
||||
indices_to_remove.push(tab_idx);
|
||||
}
|
||||
}
|
||||
@@ -1294,8 +1327,8 @@ impl CodeView {
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
for tab in self.tab_group.iter_mut() {
|
||||
if tab.path.as_ref().is_some_and(|path| path == old_path) {
|
||||
tab.path = Some(new_path.to_path_buf());
|
||||
if tab.local_path().is_some_and(|path| path == old_path) {
|
||||
tab.location = Some(LocalOrRemotePath::Local(new_path.to_path_buf()));
|
||||
tab.editor_view.update(ctx, |editor, ctx| {
|
||||
let was_unsaved = editor.has_unsaved_changes(ctx);
|
||||
|
||||
@@ -1453,6 +1486,7 @@ impl CodeView {
|
||||
is_hovered: bool,
|
||||
has_unsaved_changes: bool,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let text_color = if is_active {
|
||||
@@ -1466,9 +1500,10 @@ impl CodeView {
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween);
|
||||
|
||||
let file_name = tab_data
|
||||
.path
|
||||
.location
|
||||
.as_ref()
|
||||
.and_then(|p| p.file_name().map(|f| f.to_string_lossy().to_string()))
|
||||
.map(|loc| display_name_with_host(loc, app))
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
let language_icon =
|
||||
icon_from_file_path(&file_name, appearance, ItemHighlightState::Default);
|
||||
@@ -1503,6 +1538,7 @@ impl CodeView {
|
||||
)
|
||||
.with_color(text_color)
|
||||
.with_style(style)
|
||||
.with_clip(ClipConfig::start())
|
||||
.finish();
|
||||
row.add_child(
|
||||
Shrinkable::new(
|
||||
@@ -1578,7 +1614,7 @@ impl CodeView {
|
||||
origin: ActionOrigin::EditorTab(index),
|
||||
drag_location: PaneDragDropLocation::TabBar(data.tab_bar_location),
|
||||
drag_position,
|
||||
precomputed_tab_hover_index: None,
|
||||
tab_bar_axis: Some(TabBarAxis::Horizontal),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
@@ -1683,6 +1719,7 @@ impl CodeView {
|
||||
tab_handle.is_hovered(),
|
||||
Self::has_unsaved_changes(tab_data, app),
|
||||
appearance,
|
||||
app,
|
||||
))
|
||||
.with_horizontal_margin(TAB_HORIZONTAL_MARGIN)
|
||||
.with_padding(Padding::uniform(TAB_PADDING))
|
||||
@@ -1725,7 +1762,7 @@ impl CodeView {
|
||||
.tab_draggable_state
|
||||
.is_dragging()
|
||||
{
|
||||
if let Some(path) = tab_data.path.clone() {
|
||||
if let Some(path) = tab_data.local_path() {
|
||||
let tooltip = appearance
|
||||
.ui_builder()
|
||||
.tool_tip(Self::relative_path(path, self.window_id, app))
|
||||
@@ -1841,9 +1878,12 @@ impl CodeView {
|
||||
let title = self
|
||||
.tab_group
|
||||
.first()
|
||||
.and_then(|tab| tab.path.as_ref())
|
||||
.and_then(|path| path.file_name())
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.and_then(|tab| {
|
||||
tab.location
|
||||
.as_ref()
|
||||
.map(|loc| display_name_with_host(loc, app))
|
||||
.filter(|n| !n.is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
@@ -1884,18 +1924,37 @@ impl CodeView {
|
||||
let tab = self.tab_group.first();
|
||||
let tab_handle = tab.map(|tab| tab.mouse_state_handles.tab_handle.clone());
|
||||
|
||||
// Check unsaved changes for the active tab.
|
||||
let has_unsaved = tab.is_some_and(|tab| Self::has_unsaved_changes(tab, app));
|
||||
|
||||
// Build the center title element, with a hover tooltip showing the full path.
|
||||
let title_element: Box<dyn Element> = match tab_handle {
|
||||
Some(handle) => Hoverable::new(handle, |hover_state| {
|
||||
let title_text =
|
||||
render_pane_header_title_text(title.clone(), appearance, ClipConfig::start());
|
||||
|
||||
let mut title_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Min);
|
||||
if has_unsaved {
|
||||
let dot_color = appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background());
|
||||
title_row.add_child(
|
||||
Container::new(render_unsaved_changes_icon(dot_color.into()))
|
||||
.with_margin_right(4.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
title_row.add_child(title_text);
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(title_text);
|
||||
stack.add_child(title_row.finish());
|
||||
if hover_state.is_hovered() {
|
||||
let tooltip_relative_path = tab
|
||||
.and_then(|tab| tab.path.clone())
|
||||
.map(|p| Self::relative_path(p, self.window_id, app));
|
||||
if let Some(ref path) = tooltip_relative_path {
|
||||
let tooltip_path = tab
|
||||
.and_then(|tab| tab.location())
|
||||
.map(|loc| loc.display_path());
|
||||
if let Some(ref path) = tooltip_path {
|
||||
let tooltip = appearance
|
||||
.ui_builder()
|
||||
.tool_tip(path.clone())
|
||||
@@ -1915,7 +1974,27 @@ impl CodeView {
|
||||
stack.finish()
|
||||
})
|
||||
.finish(),
|
||||
None => render_pane_header_title_text(title, appearance, ClipConfig::start()),
|
||||
None => {
|
||||
let title_text =
|
||||
render_pane_header_title_text(title, appearance, ClipConfig::start());
|
||||
if has_unsaved {
|
||||
let dot_color = appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background());
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Min);
|
||||
row.add_child(
|
||||
Container::new(render_unsaved_changes_icon(dot_color.into()))
|
||||
.with_margin_right(4.)
|
||||
.finish(),
|
||||
);
|
||||
row.add_child(title_text);
|
||||
row.finish()
|
||||
} else {
|
||||
title_text
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render_three_column_header(
|
||||
@@ -1952,15 +2031,45 @@ impl CodeView {
|
||||
];
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
if let Some(path) = self.local_path(ctx) {
|
||||
items.extend([
|
||||
MenuItem::Separator,
|
||||
MenuItemFields::new("Copy file path")
|
||||
.with_on_select_action(CodeViewAction::CopyFilePath)
|
||||
.into_item(),
|
||||
]);
|
||||
{
|
||||
let active_location = self
|
||||
.tab_at(self.active_tab_index)
|
||||
.and_then(|t| t.location.as_ref());
|
||||
let local_path = self.local_path(ctx);
|
||||
|
||||
if is_markdown_file(&path) {
|
||||
if active_location.is_some() {
|
||||
items.push(MenuItem::Separator);
|
||||
items.push(
|
||||
MenuItemFields::new("Copy file path")
|
||||
.with_on_select_action(CodeViewAction::CopyFilePath)
|
||||
.into_item(),
|
||||
);
|
||||
}
|
||||
|
||||
if local_path.is_some() {
|
||||
let reveal_label = if cfg!(target_os = "macos") {
|
||||
"Reveal in Finder"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Reveal in Explorer"
|
||||
} else {
|
||||
"Reveal in file manager"
|
||||
};
|
||||
items.push(
|
||||
MenuItemFields::new(reveal_label)
|
||||
.with_on_select_action(CodeViewAction::RevealInFinder)
|
||||
.into_item(),
|
||||
);
|
||||
}
|
||||
|
||||
let is_md = local_path
|
||||
.as_ref()
|
||||
.map(is_markdown_file)
|
||||
.unwrap_or_else(|| {
|
||||
active_location
|
||||
.map(|loc| is_markdown_file(std::path::Path::new(&loc.display_path())))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if is_md {
|
||||
items.push(
|
||||
MenuItemFields::new("View Markdown preview")
|
||||
.with_on_select_action(CodeViewAction::RenderMarkdown)
|
||||
@@ -1974,19 +2083,18 @@ impl CodeView {
|
||||
|
||||
/// Merges tabs from another `CodeView`, avoiding duplicates and updating the active tab index.
|
||||
pub fn merge_tabs(&mut self, source_code_view: &CodeView, ctx: &mut ViewContext<Self>) {
|
||||
let existing_paths_to_idx: HashMap<String, usize> = self
|
||||
let existing_locations_to_idx: HashMap<&LocalOrRemotePath, usize> = self
|
||||
.tab_group
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, tab)| tab.path().map(|p| (p.to_string_lossy().to_string(), idx)))
|
||||
.filter_map(|(idx, tab)| tab.location.as_ref().map(|loc| (loc, idx)))
|
||||
.collect();
|
||||
let mut active_tab_index = self.active_tab_index();
|
||||
let mut to_extend: Vec<TabData> = Vec::new();
|
||||
|
||||
for (i, tab_data) in source_code_view.tab_group.iter().enumerate() {
|
||||
if let Some(path) = tab_data.path() {
|
||||
if let Some(&index) = existing_paths_to_idx.get(&path.to_string_lossy().to_string())
|
||||
{
|
||||
if let Some(loc) = tab_data.location.as_ref() {
|
||||
if let Some(&index) = existing_locations_to_idx.get(loc) {
|
||||
// If the tab already exists in the tab group and is the active tab in the source CodeView,
|
||||
// update the active tab index to point to it.
|
||||
if i == source_code_view.active_tab_index() {
|
||||
@@ -2000,7 +2108,7 @@ impl CodeView {
|
||||
to_extend.push(new_data);
|
||||
// If the newly added tab is the active tab in the source CodeView, update the active tab index to point to it.
|
||||
if i == source_code_view.active_tab_index() {
|
||||
active_tab_index = existing_paths_to_idx.len() + to_extend.len() - 1;
|
||||
active_tab_index = self.tab_group.len() + to_extend.len() - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2114,19 +2222,31 @@ impl TypedActionView for CodeView {
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
CodeViewAction::CopyFilePath => {
|
||||
if let Some(path) = self.local_path(ctx) {
|
||||
if let Some(location) = self
|
||||
.tab_at(self.active_tab_index)
|
||||
.and_then(|t| t.location.as_ref())
|
||||
{
|
||||
ctx.clipboard()
|
||||
.write(ClipboardContent::plain_text(path.display().to_string()));
|
||||
.write(ClipboardContent::plain_text(location.display_path()));
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "local_fs")]
|
||||
CodeViewAction::RevealInFinder => {
|
||||
if let Some(path) = self.local_path(ctx) {
|
||||
ctx.open_file_path_in_explorer(&path);
|
||||
} else {
|
||||
log::warn!(
|
||||
"Reveal in Finder requested, but the active code tab has no local file path"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "local_fs")]
|
||||
CodeViewAction::RenderMarkdown => {
|
||||
let path = self.local_path(ctx).or_else(|| {
|
||||
self.tab_at(self.active_tab_index)
|
||||
.and_then(|t| t.path.clone())
|
||||
});
|
||||
let lor_path = self
|
||||
.tab_at(self.active_tab_index)
|
||||
.and_then(|t| t.location.clone());
|
||||
|
||||
if let Some(path) = path {
|
||||
if let Some(lor_path) = lor_path {
|
||||
let source = self.source.clone();
|
||||
if self.active_tab_has_unsaved_changes(ctx) {
|
||||
self.save_local(
|
||||
@@ -2134,7 +2254,7 @@ impl TypedActionView for CodeView {
|
||||
Some(Box::new(move |outcome, _me, ctx| {
|
||||
if outcome != SaveOutcome::Canceled {
|
||||
ctx.emit(CodeViewEvent::Pane(PaneEvent::ReplaceWithFilePane {
|
||||
path: path.clone(),
|
||||
path: lor_path.clone(),
|
||||
source: Some(source.clone()),
|
||||
}));
|
||||
}
|
||||
@@ -2143,7 +2263,7 @@ impl TypedActionView for CodeView {
|
||||
);
|
||||
} else {
|
||||
ctx.emit(CodeViewEvent::Pane(PaneEvent::ReplaceWithFilePane {
|
||||
path,
|
||||
path: lor_path,
|
||||
source: Some(source),
|
||||
}));
|
||||
}
|
||||
|
||||
+24
-15
@@ -1,18 +1,18 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use ai::diff_validation::DiffDelta;
|
||||
use galaxy_util::path::LineAndColumnArg;
|
||||
use galaxyui::elements::{DraggableState, Empty, MouseStateHandle};
|
||||
use galaxyui::{
|
||||
elements::{DraggableState, Empty, MouseStateHandle},
|
||||
AppContext, Element, Entity, ModelHandle, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use super::{editor_management::CodeSource, local_code_editor::LocalCodeEditorView};
|
||||
use crate::pane_group::{
|
||||
focus_state::PaneFocusHandle,
|
||||
pane::view::{HeaderContent, HeaderRenderContext},
|
||||
BackingView, CodePane, PaneConfiguration, PaneEvent,
|
||||
};
|
||||
use ai::diff_validation::DiffDelta;
|
||||
use super::buffer_location::LocalOrRemotePath;
|
||||
use super::editor_management::CodeSource;
|
||||
use super::local_code_editor::LocalCodeEditorView;
|
||||
use crate::pane_group::focus_state::PaneFocusHandle;
|
||||
use crate::pane_group::pane::view::{HeaderContent, HeaderRenderContext};
|
||||
use crate::pane_group::{BackingView, CodePane, PaneConfiguration, PaneEvent};
|
||||
|
||||
// Keybinding constants - exported so AI document view can reuse
|
||||
pub const SAVE_FILE_BINDING_NAME: &str = "code_view:save";
|
||||
@@ -40,11 +40,11 @@ pub enum CodeViewAction {
|
||||
pub enum CodeViewEvent {
|
||||
Pane(PaneEvent),
|
||||
TabChanged {
|
||||
file_path: Option<PathBuf>,
|
||||
location: Option<LocalOrRemotePath>,
|
||||
tab_index: usize,
|
||||
},
|
||||
FileOpened {
|
||||
file_path: PathBuf,
|
||||
location: LocalOrRemotePath,
|
||||
tab_index: usize,
|
||||
},
|
||||
RunTabConfigSkill {
|
||||
@@ -82,15 +82,24 @@ struct TabDataMouseStateHandles {
|
||||
#[allow(unused)]
|
||||
#[derive(Clone)]
|
||||
pub struct TabData {
|
||||
path: Option<PathBuf>,
|
||||
location: Option<LocalOrRemotePath>,
|
||||
editor_view: ViewHandle<LocalCodeEditorView>,
|
||||
mouse_state_handles: TabDataMouseStateHandles,
|
||||
drag_position: Option<TabBarDragPosition>,
|
||||
}
|
||||
|
||||
impl TabData {
|
||||
pub fn path(&self) -> Option<PathBuf> {
|
||||
self.path.clone()
|
||||
/// Returns the file location (local or remote), if any.
|
||||
pub fn location(&self) -> Option<&LocalOrRemotePath> {
|
||||
self.location.as_ref()
|
||||
}
|
||||
|
||||
/// Returns the local filesystem path, if this tab is backed by a local file.
|
||||
/// Returns `None` for remote files and untitled tabs.
|
||||
pub fn local_path(&self) -> Option<PathBuf> {
|
||||
self.location
|
||||
.as_ref()
|
||||
.and_then(|loc| PathBuf::try_from(loc.clone()).ok())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,11 +140,11 @@ impl CodeView {
|
||||
|
||||
pub fn open_or_focus_existing(
|
||||
&mut self,
|
||||
path: Option<PathBuf>,
|
||||
location: Option<LocalOrRemotePath>,
|
||||
line_col: Option<LineAndColumnArg>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let Some(path) = path {
|
||||
if let Some(path) = location.and_then(|loc| PathBuf::try_from(loc).ok()) {
|
||||
self.open_local(None, path, line_col, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user