Порівняти коміти

..
Автор SHA1 Повідомлення Дата
l0ng-ai 7beee38e29 fix(debugger): silence the unix-only runInTerminal pid on Windows
ProgramTerminal's pid and process_id are read only by the unix process-group
cleanup, so Windows clippy failed with dead-code warnings.
2026-11-15 04:11:20 +08:00
52 змінених файлів з 52 додано та 1288 видалено

No files matched your search

-22
Переглянути файл
@@ -21,28 +21,6 @@ pub fn close(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
Ok(())
}
/// Opens an editable untitled buffer (a new-file tab); it reaches the disk
/// through [`save_as`], which turns it into a regular file document. `name`
/// re-references an already minted buffer instead of creating one.
pub async fn open_untitled(host: &Host, window: &str, name: Option<String>) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
host.blocking(move || project.documents.open_untitled(name.as_deref())).await
}
/// Save As for an untitled buffer ([`Documents::save_as`]); LightEdit windows
/// start watching the saved file's folder.
pub async fn save_as(host: &Host, window: &str, doc: DocId, path: PathBuf) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
host.blocking(move || {
let info = project.documents.save_as(doc, &path)?;
if let Some(dir) = path.parent().filter(|_| project.light) {
project.watch_light_dir(dir);
}
Ok(info)
})
.await
}
/// Applies a CodeMirror change set; returns the new version (`outOfSync` when `base_version` is stale).
pub fn edit(host: &Host, window: &str, doc: DocId, base_version: u32, changes: ChangeSet) -> IdeResult<u32> {
host.project(window)?.documents.edit(doc, base_version, changes)
+1 -4
Переглянути файл
@@ -50,10 +50,7 @@ pub async fn doc_set_encoding(
reload: bool,
) -> IdeResult<DocumentEncoding> {
let project = host.project(window)?;
if reload
&& !project.documents.is_untitled(doc)?
&& let Some(uri) = project.documents.virtual_uri(doc)?
{
if reload && let Some(uri) = project.documents.virtual_uri(doc)? {
let vd = if uri.starts_with("git-revision:") {
crate::ops::vcs::revision_virtual_document(host, window, &uri, Some(&encoding)).await?
} else {
+3 -38
Переглянути файл
@@ -88,9 +88,6 @@ impl Host {
let root = dunce::canonicalize(root)?;
let opening = self.opening(window);
let _opening = opening.lock();
// Untitled buffers belong to the window, not a project: they follow it
// into the project's fresh document registry.
let carried = self.project(window).ok().map(|prev| prev.documents.untitled_buffers()).unwrap_or_default();
// Reject a damaged manifest before replacing the currently open project.
let is_workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
// The window reloaded (or asked again) for the project it already has: keep
@@ -624,9 +621,6 @@ impl Host {
}));
}
self.insert_project(window, project.clone());
for (key, text, dirty) in &carried {
let _ = project.documents.restore_untitled_buffer(key, text, *dirty);
}
project.lsp.prewarm_java_configuration();
if let Some(bridge) = &project.kotlin_bridge {
bridge.start(&self.runtime);
@@ -709,29 +703,12 @@ impl Host {
return Err(IdeError::invalid(format!("{} is a folder", file.display())));
}
let dir = file.parent().map(Path::to_path_buf).ok_or_else(|| IdeError::invalid("no parent folder"))?;
let watch = dir.clone();
self.open_light_in(window, dir, Some(&watch))
}
/// LightEdit without a file (a welcome window's File › New File): rooted
/// at the user's home folder, watching nothing until a buffer is saved
/// into a real folder ([`Project::watch_light_dir`]).
pub fn open_light_empty(&self, window: &str) -> IdeResult<Arc<Project>> {
let dir = dirs::home_dir().unwrap_or_else(std::env::temp_dir);
self.open_light_in(window, dir, None)
}
fn open_light_in(&self, window: &str, dir: PathBuf, watch_dir: Option<&Path>) -> IdeResult<Arc<Project>> {
let opening = self.opening(window);
let _opening = opening.lock();
// Untitled buffers belong to the window: they follow it out of a project.
let carried = self.project(window).ok().map(|prev| prev.documents.untitled_buffers()).unwrap_or_default();
if let Ok(current) = self.project(window)
&& current.light
{
if let Some(dir) = watch_dir {
current.watch_light_dir(dir);
}
current.watch_light_dir(&dir);
return Ok(current);
}
self.close_project(window);
@@ -750,16 +727,7 @@ impl Host {
let syntax = Arc::new(SyntaxCache::new(self.syntax()));
documents.add_observer(Arc::new(SyntaxObserver(syntax.clone())));
documents.add_observer(Arc::new(HistoryObserver(history.clone())));
let watcher = match watch_dir {
Some(dir) => light_watcher(dir, &sink, &documents, &history)?,
// A quiet private folder stands in until the first save brings a
// real folder in.
None => {
let idle = self.local_data_dir.join("light-edit");
std::fs::create_dir_all(&idle)?;
light_watcher(&idle, &sink, &documents, &history)?
}
};
let watcher = light_watcher(&dir, &sink, &documents, &history)?;
let project_key = project_key(&dir);
let (db, http_history, agents) = self.project_services(&dir, &project_key, &settings, &sink);
let notebooks = crate::notebook::NotebookService::new(
@@ -805,11 +773,8 @@ impl Host {
light: true,
root: dir,
});
tracing::info!("window {window} edits {} without a project", project.root.display());
tracing::info!("window {window} edits {} without a project", file.display());
self.insert_project(window, project.clone());
for (key, text, dirty) in &carried {
let _ = project.documents.restore_untitled_buffer(key, text, *dirty);
}
Ok(project)
}
}
-7
Переглянути файл
@@ -218,13 +218,6 @@ impl Dispatcher {
})
.add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await })
.add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await })
.add("doc_open_untitled", |c| async move {
ops::doc::open_untitled(&c.host, &c.window, c.arg::<Option<String>>("name")?).await
})
.add(
"doc_save_as",
|c| async move { ops::doc::save_as(&c.host, &c.window, c.arg("doc")?, c.arg("path")?).await },
)
.add("doc_close", |c| async move {
ops::doc::blocking(move || ops::doc::close(&c.host, &c.window, c.arg("doc")?)).await
})
-1
Переглянути файл
@@ -401,7 +401,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "web_unresolved_references"
| "project_rename"
| "light_edit_open"
| "light_edit_open_untitled"
| "workspace_create"
| "workspace_discover_projects"
| "workspace_update"
-70
Переглянути файл
@@ -179,73 +179,3 @@ async fn light_notebooks_open_without_interpreter_detection_or_execution() {
assert!(!bin.join("python.probed").exists());
host.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn untitled_buffers_reach_the_disk_through_save_as() {
let tmp = tempfile::tempdir().unwrap();
let tmp_root = dunce::canonicalize(tmp.path()).unwrap();
let dir = tmp_root.join("notes");
let host = host(&tmp_root.join("app"));
// A window with no file (a welcome screen's File › New File): rooted at the
// home folder, no watcher yet, doc commands work.
let empty = host.open_light_empty("main").unwrap();
assert!(empty.light);
let untitled = ops::doc::open_untitled(&host, "main", None).await.unwrap();
assert_eq!(untitled.path, std::path::PathBuf::from("untitled:Untitled-1"));
assert_eq!(untitled.language.as_str(), "plaintext");
let version = ops::doc::edit(&host, "main", untitled.id, untitled.version, untitled_edit("# notes\n")).unwrap();
assert!(ops::doc::save(&host, "main", untitled.id).await.is_ok(), "saving nowhere is a no-op");
assert!(host.project("main").unwrap().documents.is_dirty(untitled.id).unwrap());
// Save As: the buffer lands on the disk and turns into a file document.
let target = dir.join("deep/notes.md");
let saved = ops::doc::save_as(&host, "main", untitled.id, target.clone()).await.unwrap();
assert_eq!(saved.path, target);
assert_eq!(saved.language.as_str(), "markdown");
assert!(!saved.dirty);
// The platform's default line separator applies to new buffers.
assert_eq!(std::fs::read_to_string(&target).unwrap().replace("\r\n", "\n"), "# notes\n");
ops::doc::edit(&host, "main", untitled.id, version, untitled_edit("x\n")).unwrap();
ops::doc::save(&host, "main", untitled.id).await.unwrap();
assert_eq!(std::fs::read_to_string(&target).unwrap().replace("\r\n", "\n"), "x\n# notes\n");
// The saved file's folder is followed for external changes now.
host.shutdown().await;
}
fn untitled_edit(insert: &str) -> ChangeSet {
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: insert.into() }] }
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn untitled_buffers_follow_the_window_into_a_project() {
let tmp = tempfile::tempdir().unwrap();
let tmp_root = dunce::canonicalize(tmp.path()).unwrap();
let host = host(&tmp_root.join("app"));
// A LightEdit window with an edited untitled buffer (and a clean one).
host.open_light_empty("main").unwrap();
let draft = ops::doc::open_untitled(&host, "main", None).await.unwrap();
ops::doc::edit(&host, "main", draft.id, draft.version, untitled_edit("{\"draft\": true}")).unwrap();
let clean = ops::doc::open_untitled(&host, "main", None).await.unwrap();
// The window switches to a project: the buffers follow, contents and dirty flags kept.
let project_root = tmp_root.join("proj");
std::fs::create_dir_all(&project_root).unwrap();
let project = host.open_project("main", &project_root).unwrap();
assert!(!project.light);
let carried =
ops::doc::open_untitled(&host, "main", Some(draft.path.to_string_lossy().into_owned())).await.unwrap();
assert_eq!(carried.path, draft.path, "same key, fresh registry: a new doc id carries the content");
let snap = ops::doc::snapshot(&host, "main", carried.id).await.unwrap();
assert_eq!(snap.text, "{\"draft\": true}");
assert!(host.project("main").unwrap().documents.is_dirty(carried.id).unwrap());
assert_eq!(snap.language.as_str(), "plaintext");
let clean_carried =
ops::doc::open_untitled(&host, "main", Some(clean.path.to_string_lossy().into_owned())).await.unwrap();
assert!(!host.project("main").unwrap().documents.is_dirty(clean_carried.id).unwrap());
host.shutdown().await;
}
-56
Переглянути файл
@@ -1,56 +0,0 @@
; Shell scripts: drawn over the stream mode, which knows only fixed word lists.
; The last matching pattern wins.
[
(string)
(raw_string)
(ansi_c_string)
(translated_string)
(heredoc_body)
(heredoc_start)
] @string
[
(command_substitution)
(process_substitution)
(expansion)
(simple_expansion)
] @embedded
(comment) @comment
[
"case"
"do"
"done"
"elif"
"else"
"esac"
"fi"
"for"
"function"
"if"
"in"
"select"
"then"
"until"
"while"
"declare"
"export"
"local"
"readonly"
"typeset"
"unset"
"unsetenv"
] @keyword
(command_name) @function.command
(function_definition name: (word) @function.definition)
(variable_name) @variable
(special_variable_name) @variable
[
(number)
(file_descriptor)
] @number
+1 -1
Переглянути файл
@@ -314,7 +314,7 @@ pub(crate) static SPECS: &[Spec] = &[
name: "bash",
language: tree_sitter_bash::LANGUAGE,
outline: outline!("bash"),
highlights: Some(Highlights { query: highlights!("bash"), first_wins: false }),
highlights: None,
fold_nodes: &["do_group", "if_statement", "case_statement", "heredoc_body"],
fold_bodies: NONE,
imports: NONE,
+1 -29
Переглянути файл
@@ -43,13 +43,11 @@ pub enum SyntaxHighlightKind {
Operator,
Punctuation,
Parameter,
/// A shell command name (`pgrep`, a script's own function).
Command,
}
impl SyntaxHighlightKind {
/// Every kind, in `as u32` order: the legend for [`SyntaxHighlights::data`].
pub const ALL: [Self; 26] = [
pub const ALL: [Self; 25] = [
Self::Keyword,
Self::String,
Self::StringSpecial,
@@ -75,7 +73,6 @@ impl SyntaxHighlightKind {
Self::Operator,
Self::Punctuation,
Self::Parameter,
Self::Command,
];
/// The kebab-case name plugins use (`doc-comment`, `function-call`, …).
@@ -156,7 +153,6 @@ const CAPTURES: &[(&str, Paint)] = {
("function.builtin", Kind(K::Builtin)),
("function.macro", Kind(K::Macro)),
("function.definition", Kind(K::Function)),
("function.command", Kind(K::Command)),
("function", Kind(K::FunctionCall)),
("method", Kind(K::FunctionCall)),
("constructor", Kind(K::Type)),
@@ -412,30 +408,6 @@ mod tests {
}
}
#[test]
fn shell_scripts_highlight_declarations_commands_and_function_names() {
use SyntaxHighlightKind as K;
let src = "this::check_process() {\n local process\n process=\"${1:?}\"\n if [ -x \"$(command -v pgrep)\" ]; then\n if pgrep -f -q \"${process}\"; then\n functions::exit 1 \"'${process}' is running\"\n fi\n fi\n}\n# done\n";
let got = spans("shellscript", src);
for (text, expected) in [
("this::check_process", K::Function),
("local", K::Keyword),
("if", K::Keyword),
("command", K::Command),
("pgrep", K::Command),
("functions::exit", K::Command),
("process", K::Variable),
("# done", K::Comment),
] {
assert!(
got.iter().any(|(word, kind)| word == text && *kind == expected),
"missing {text}: {expected:?} in {got:?}"
);
}
// An expansion inside a string is not string-coloured.
assert!(!got.iter().any(|(word, kind)| word.contains("${process}") && *kind == K::String), "{got:?}");
}
#[test]
fn non_kotlin_parameters_keep_variable_highlighting() {
for (lang, src) in [
+2 -200
Переглянути файл
@@ -193,12 +193,6 @@ fn read_only_error(path: &Path) -> IdeError {
IdeError::new(ErrorKind::PermissionDenied, format!("{} is read-only", path.display()))
}
/// The synthetic key of an unsaved new-file buffer (`untitled:Untitled-1`);
/// minted by [`Documents::open_untitled`], replaced by a real path on save.
fn is_untitled(path: &Path) -> bool {
path.to_str().is_some_and(|p| p.starts_with("untitled:"))
}
impl Documents {
pub fn new(sink: SharedSink, detect_language: Box<DetectLanguage>) -> Self {
Self {
@@ -535,75 +529,6 @@ impl Documents {
Ok(info)
}
/// Opens (or re-references) an editable buffer without a file behind it:
/// a new-file tab ("Untitled-1"). Keyed by an `untitled:` URI like virtual
/// documents, so it never touches the disk until [`Documents::save_as`]
/// gives it a path (which also re-detects the language). Encoding, BOM and
/// line ending come from the file defaults. `name` re-references an
/// existing buffer (`Some("untitled:Untitled-1")`); `None` mints the
/// first free one.
pub fn open_untitled(&self, name: Option<&str>) -> IdeResult<DocumentInfo> {
let _operation = self.operations.lock();
let key = name.map(PathBuf::from).unwrap_or_else(|| self.next_untitled_key());
if let Some(info) = self.reopen(&key) {
return Ok(info);
}
let defaults = self.file_defaults();
let mut doc = Document::new("");
doc.set_line_ending(defaults.line_ending);
let state = DocState {
path: key.clone(),
doc,
language: LanguageId::plain_text(),
ui_mode: None,
encoding: defaults.encoding.clone(),
has_bom: defaults.encoding == "utf-8" && defaults.utf8_bom,
native_to_ascii: false,
read_only: false,
large: false,
editorconfig: None,
observed: true,
binary: false,
dirty: false,
can_reload_encoding: false,
encoding_chosen: false,
disk_mtime: 0.0,
disk_len: 0,
closed: false,
uri: Some(key.to_string_lossy().into_owned()),
};
let slot: Slot = Arc::new(Mutex::new(state));
let st = slot.lock();
let id = {
let mut reg = self.registry.lock();
if reg.by_key.contains_key(&key) {
// Lost a race with a concurrent open of the same untitled buffer.
drop(reg);
drop(st);
return self.reopen(&key).ok_or_else(|| IdeError::internal("document vanished while opening"));
}
let id = DocId::next();
reg.by_key.insert(key.clone(), id);
reg.docs.insert(id, RegEntry { slot: slot.clone(), refs: 1, keys: vec![key] });
id
};
let info = Self::info(id, &st);
for o in self.observers() {
o.opened(&info);
}
Ok(info)
}
/// The first free `untitled:Untitled-N` key.
fn next_untitled_key(&self) -> PathBuf {
let reg = self.registry.lock();
let mut n = 1;
while reg.by_key.contains_key(&PathBuf::from(format!("untitled:Untitled-{n}"))) {
n += 1;
}
PathBuf::from(format!("untitled:Untitled-{n}"))
}
fn reopen(&self, path: &Path) -> Option<DocumentInfo> {
let slot = {
let mut reg = self.registry.lock();
@@ -815,90 +740,6 @@ impl Documents {
})
}
/// Save As for an untitled buffer: writes it to `target` (creating missing
/// parent folders) and turns it into a regular file document — the buffer
/// keeps its identity (undo history, version), the registry is re-keyed and
/// the language is re-detected from the new path. The target path must not
/// be open as another document.
pub fn save_as(&self, doc: DocId, target: &Path) -> IdeResult<DocumentInfo> {
let _operation = self.operations.lock();
// The caller picks a real file path; an `untitled:` key is never one
// (and must not be resolved against the working directory either).
if is_untitled(target) {
return Err(IdeError::invalid("cannot save over an untitled buffer"));
}
let target = absolute(target);
{
let reg = self.registry.lock();
if let Some(other) = Self::lookup(&reg, &target)
&& other != doc
{
return Err(IdeError::invalid(format!("{} is already open", target.display())));
}
}
self.with_doc(doc, |st| {
if st.uri.is_none() || !is_untitled(&st.path) {
return Err(IdeError::invalid("only an untitled document can be saved to a new path"));
}
let text = st.doc.text_for_save();
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
let mtime = if st.native_to_ascii {
ide_vfs::save_properties_text(&target, &text)?
} else {
ide_vfs::save_text(&target, &text, &st.encoding, st.has_bom)?
};
// Re-key the registry before anyone opens the new path in parallel.
let keys = keys_for(&target);
{
let mut reg = self.registry.lock();
let old_keys = match reg.docs.get_mut(&doc) {
Some(entry) => std::mem::take(&mut entry.keys),
None => return Err(IdeError::not_found("document closed while saving")),
};
for k in &old_keys {
if reg.by_key.get(k) == Some(&doc) {
reg.by_key.remove(k);
}
}
for k in &keys {
reg.by_key.insert(k.clone(), doc);
}
if let Some(entry) = reg.docs.get_mut(&doc) {
entry.keys = keys;
}
}
let old_path = std::mem::replace(&mut st.path, target.clone());
st.uri = None;
let defaults = self.file_defaults();
let first_line = st.doc.text();
let first_line = first_line.lines().next();
let language = (self.detect_language)(&st.path, first_line);
let ui_mode = (self.detect_ui_mode)(&st.path, &language);
let language_changed = language != st.language;
st.language = language.clone();
st.ui_mode = ui_mode;
st.editorconfig = if defaults.editorconfig { self.editorconfig.resolve(&st.path) } else { None };
st.native_to_ascii = defaults.native_to_ascii_for(&st.path);
st.disk_mtime = mtime;
st.disk_len = disk_len(&st.path);
self.set_dirty(doc, st, false);
let info = Self::info(doc, st);
if st.observed {
for o in self.observers() {
o.closed(doc, &old_path);
o.opened(&info);
o.saved(doc, &st.path, &st.doc.text());
}
}
if language_changed {
self.sink.emit(IdeEvent::DocLanguage { doc, language, ui_mode: st.ui_mode.clone() });
}
Ok(info)
})
}
/// Saves every dirty document; reports the first failure after trying all.
pub fn save_all(&self) -> IdeResult<()> {
let ids: Vec<DocId> = self.registry.lock().docs.keys().copied().collect();
@@ -1053,12 +894,9 @@ impl Documents {
/// is kept and the next save writes it in the new encoding.
pub fn set_encoding(&self, doc: DocId, encoding: &str, reload: bool) -> IdeResult<DocumentEncoding> {
self.with_doc(doc, |st| {
if st.uri.is_some() && !is_untitled(&st.path) {
if st.uri.is_some() {
return Err(read_only_error(&st.path));
}
if reload && st.uri.is_some() {
return Err(IdeError::invalid("save the file before reloading it with another encoding"));
}
ide_vfs::encode("", encoding, false)?;
if reload {
if st.dirty {
@@ -1099,42 +937,6 @@ impl Documents {
self.with_doc(doc, |st| Ok(st.uri.clone()))
}
/// Whether this is an untitled (new-file) buffer: editable, no file behind
/// it until [`Documents::save_as`].
pub fn is_untitled(&self, doc: DocId) -> IdeResult<bool> {
self.with_doc(doc, |st| Ok(is_untitled(&st.path)))
}
/// The open untitled buffers: `(key, text, dirty)` — they belong to the
/// window rather than a project, and [`Documents::restore_untitled_buffer`]
/// carries them into a project's fresh document registry.
pub fn untitled_buffers(&self) -> Vec<(String, String, bool)> {
let slots: Vec<Slot> = self.registry.lock().docs.values().map(|e| e.slot.clone()).collect();
let mut out = Vec::new();
for slot in slots {
let st = slot.lock();
if !st.closed && is_untitled(&st.path) {
out.push((st.path.to_string_lossy().into_owned(), st.doc.text(), st.dirty));
}
}
out
}
/// Recreates an untitled buffer carried over from the window's previous
/// project: same key, same text, same dirty flag.
pub fn restore_untitled_buffer(&self, key: &str, text: &str, dirty: bool) -> IdeResult<()> {
let info = self.open_untitled(Some(key))?;
if info.text != text {
self.replace_all(info.id, text)?;
}
// `replace_all` marks the buffer dirty; the carried flag is authoritative.
self.with_doc(info.id, |st| {
self.set_dirty(info.id, st, dirty);
Ok(())
})?;
Ok(())
}
/// Re-decoded content of a virtual document ("Reload with Encoding"):
/// replaces the text and the encoding label, keeping the document
/// read-only and clean. The caller re-reads the origin (archive entry,
@@ -1165,7 +967,7 @@ impl Documents {
/// Adds or removes the BOM on a UTF-8 document; the change is written on save.
pub fn set_bom(&self, doc: DocId, has_bom: bool) -> IdeResult<DocumentEncoding> {
self.with_doc(doc, |st| {
if st.read_only || (st.uri.is_some() && !is_untitled(&st.path)) {
if st.uri.is_some() || st.read_only {
return Err(read_only_error(&st.path));
}
if st.encoding != "utf-8" || st.native_to_ascii {
-101
Переглянути файл
@@ -1004,104 +1004,3 @@ fn properties_ascii_noop_and_charset_conversion() {
assert_eq!(f.docs.snapshot(doc.id).unwrap().text, "a=世界😀");
assert!(!f.docs.is_dirty(doc.id).unwrap());
}
#[test]
fn untitled_buffers_are_editable_and_saved_to_a_chosen_path() {
let f = fixture();
let mut defaults = f.docs.file_defaults();
defaults.line_ending = LineEnding::Lf;
f.docs.set_file_defaults(defaults);
let info = f.docs.open_untitled(None).unwrap();
assert_eq!(info.path, PathBuf::from("untitled:Untitled-1"));
assert_eq!(info.uri.as_deref(), Some("untitled:Untitled-1"));
assert_eq!(info.language, LanguageId::plain_text());
assert!(!info.read_only && !info.dirty && !info.large);
// Editing works; nothing is written anywhere.
let version = f.docs.edit(info.id, info.version, change(0, 0, "fn main() {}\n")).unwrap();
assert!(f.docs.is_dirty(info.id).unwrap());
f.docs.save(info.id).unwrap();
assert!(f.docs.is_dirty(info.id).unwrap(), "an untitled buffer has no file to save to");
// Save As: the buffer reaches the disk and becomes a regular file document
// (same id, re-keyed, language re-detected, missing parents created).
let target = f.root.join("new/nested/main.rs");
let saved = f.docs.save_as(info.id, &target).unwrap();
assert_eq!(saved.id, info.id);
assert_eq!(saved.path, target);
assert_eq!(saved.uri, None);
assert_eq!(saved.language, LanguageId::new("rust"));
assert!(!saved.dirty);
assert_eq!(fs::read_to_string(&target).unwrap(), "fn main() {}\n");
assert_eq!(f.docs.by_path(&target), Some(info.id));
assert_eq!(f.docs.by_path(Path::new("untitled:Untitled-1")), None);
f.docs.edit(info.id, version, change(0, 0, "// hi\n")).unwrap();
f.docs.save(info.id).unwrap();
assert!(!f.docs.is_dirty(info.id).unwrap());
assert_eq!(fs::read_to_string(&target).unwrap(), "// hi\nfn main() {}\n");
// Re-opening the file references the same buffer (split editors share it).
let again = f.docs.open(&target).unwrap();
assert_eq!(again.id, info.id);
f.docs.close(again.id);
assert!(f.docs.snapshot(info.id).is_ok());
f.docs.close(info.id);
assert!(f.docs.snapshot(info.id).is_err());
}
#[test]
fn save_as_rejects_paths_opened_elsewhere_and_reuses_free_names() {
let f = fixture();
let first = f.root.join("taken.txt");
fs::write(&first, "taken").unwrap();
let taken = f.docs.open(&first).unwrap();
let a = f.docs.open_untitled(None).unwrap();
let b = f.docs.open_untitled(None).unwrap();
assert_eq!(a.path, PathBuf::from("untitled:Untitled-1"));
assert_eq!(b.path, PathBuf::from("untitled:Untitled-2"));
f.docs.edit(a.id, a.version, change(0, 0, "draft")).unwrap();
let err = f.docs.save_as(a.id, &first).unwrap_err();
assert_eq!(err.kind, ErrorKind::InvalidInput);
assert!(f.docs.is_dirty(a.id).unwrap(), "nothing was written");
assert_eq!(fs::read_to_string(&first).unwrap(), "taken");
let err = f.docs.save_as(a.id, Path::new("untitled:Untitled-2")).unwrap_err();
assert_eq!(err.kind, ErrorKind::InvalidInput);
// The smallest free name is minted again after a buffer closes.
f.docs.close(a.id);
let c = f.docs.open_untitled(None).unwrap();
assert_eq!(c.path, PathBuf::from("untitled:Untitled-1"));
// Non-untitled documents cannot be re-keyed.
let err = f.docs.save_as(taken.id, &f.root.join("other.txt")).unwrap_err();
assert_eq!(err.kind, ErrorKind::InvalidInput);
}
#[test]
fn untitled_buffers_take_file_defaults_and_refuse_encoding_reloads() {
let f = fixture();
let mut defaults = f.docs.file_defaults();
defaults.line_ending = LineEnding::CrLf;
defaults.encoding = "utf-8".into();
defaults.utf8_bom = true;
f.docs.set_file_defaults(defaults);
let info = f.docs.open_untitled(None).unwrap();
assert_eq!(info.line_ending, LineEnding::CrLf);
assert!(info.has_bom);
assert!(!info.can_reload_encoding, "there is no file to re-read yet");
// Converting the encoding of the unsaved text works; re-decoding does not.
let encoding = f.docs.set_encoding(info.id, "utf-16le", false).unwrap();
assert_eq!(encoding.encoding, "utf-16le");
let err = f.docs.set_encoding(info.id, "utf-8", true).unwrap_err();
assert_eq!(err.kind, ErrorKind::InvalidInput);
// Disk syncs never touch an untitled buffer.
f.docs.edit(info.id, info.version, change(0, 0, "draft")).unwrap();
f.docs.sync_after_disk_change();
assert!(f.docs.is_dirty(info.id).unwrap());
assert_eq!(f.docs.snapshot(info.id).unwrap().text, "draft");
}
-1
Переглянути файл
@@ -9,7 +9,6 @@
"--syn-type": "#8fd4c9",
"--syn-function": "#f2d07a",
"--syn-function-call": "#d9dfe8",
"--syn-shell-command": "#e8a46a",
"--syn-comment": "#6b7a8f",
"--syn-comment-style": "normal",
"--syn-doc-comment": "#74889c",
+1 -2
Переглянути файл
@@ -332,7 +332,7 @@ a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
267 themeable tokens: 125 in the UI theme file, 142 in the editor colour scheme file.
266 themeable tokens: 125 in the UI theme file, 141 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -429,7 +429,6 @@ a token.
| `--syn-type` | `#bcbec4` | `#000000` |
| `--syn-function` | `#56a8f5` | `#00627a` |
| `--syn-function-call` | `#bcbec4` | `#080808` |
| `--syn-shell-command` | `#c57633` | `#0073bf` |
| `--syn-comment` | `#7a7e85` | `#8c8c8c` |
| `--syn-comment-style` | `normal` | `italic` |
| `--syn-doc-comment` | `#5f826b` | `#8c8c8c` |
-27
Переглянути файл
@@ -119,33 +119,6 @@ pub async fn light_edit_open(window: Window, path: PathBuf) -> IdeResult<Setting
Ok(project.settings.effective())
}
/// LightEdit without a file (File › New File from a welcome window); returns
/// the effective (global) settings.
#[tauri::command]
#[specta::specta]
pub async fn light_edit_open_untitled(window: Window) -> IdeResult<Settings> {
let app = window.app_handle().clone();
let label = window.label().to_owned();
let log_label = label.clone();
let result = async {
if app.state::<AppState>().project(&label).is_ok_and(|p| !p.light) {
crate::windows::close_auxiliary_windows(&app, &label);
crate::window_groups::detach(&app, &label)?;
crate::window_groups::persist(&app).await?;
}
tauri::async_runtime::spawn_blocking(move || app.state::<AppState>().open_light_empty(&label))
.await
.map_err(|e| IdeError::internal(e.to_string()))?
};
match result.await {
Ok(project) => Ok(project.settings.effective()),
Err(e) => {
tracing::warn!("light_edit_open_untitled ({log_label}): {e}");
Err(e)
}
}
}
fn project_info(project: &crate::state::Project) -> ProjectInfo {
ide_host::ops::project::info(project)
}
-25
Переглянути файл
@@ -26,31 +26,6 @@ pub async fn doc_close(window: Window, state: State<'_, AppState>, doc: DocId) -
blocking(move || ide_host::ops::doc::close(&host, &window, doc)).await
}
/// Opens an editable untitled buffer (a new-file tab); it reaches the disk
/// through `doc_save_as`, which turns it into a regular file document.
#[tauri::command]
#[specta::specta]
pub async fn doc_open_untitled(
window: Window,
state: State<'_, AppState>,
name: Option<String>,
) -> IdeResult<DocumentInfo> {
ide_host::ops::doc::open_untitled(&state.host, window.label(), name).await
}
/// Save As for an untitled buffer: writes it to `path` and re-keys the buffer
/// (undo history and version are kept; the language is re-detected).
#[tauri::command]
#[specta::specta]
pub async fn doc_save_as(
window: Window,
state: State<'_, AppState>,
doc: DocId,
path: PathBuf,
) -> IdeResult<DocumentInfo> {
ide_host::ops::doc::save_as(&state.host, window.label(), doc, path).await
}
/// Applies a CodeMirror change set; returns the new version. Rejects with
/// `outOfSync` when `base_version` is stale; the UI then calls `doc_snapshot`.
#[tauri::command]
-3
Переглянути файл
@@ -106,7 +106,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
window_group::window_group_reorder,
window_group::window_group_restore,
app::light_edit_open,
app::light_edit_open_untitled,
app::project_current,
app::close_project,
app::languages,
@@ -199,11 +198,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
fonts::system_font_families,
doc::doc_open,
doc::doc_open_virtual,
doc::doc_open_untitled,
doc::doc_close,
doc::doc_edit,
doc::doc_save,
doc::doc_save_as,
doc::doc_save_all,
doc::doc_sync_from_disk,
doc::doc_snapshot,
-1
Переглянути файл
@@ -217,7 +217,6 @@ pub fn spec(os: Os) -> Vec<Entry> {
}
let mut file = vec![
act("file.newUntitled", "New Untitled File"),
sub(
"new",
"New",
-6
Переглянути файл
@@ -170,12 +170,6 @@ impl AppState {
self.host.open_light(window, file)
}
/// LightEdit without a file (File › New File from a welcome window).
pub fn open_light_empty(&self, window: &str) -> IdeResult<Arc<Project>> {
self.remote_dev.disconnect(window);
self.host.open_light_empty(window)
}
/// Called on the event-loop thread (window destruction, the `close_project`
/// command), so it must not wait for the project's workers to shut down.
pub fn close_project(&self, window: &str) {
+1 -18
Переглянути файл
@@ -75,11 +75,6 @@ export const commands = {
* a window already in LightEdit keeps its files. Returns the effective (global) settings.
*/
lightEditOpen: (path: string) => typedError<Settings_Serialize, IdeError>(__TAURI_INVOKE("light_edit_open", { path })),
/**
* LightEdit without a file (File › New File from a welcome window); returns
* the effective (global) settings.
*/
lightEditOpenUntitled: () => typedError<Settings_Serialize, IdeError>(__TAURI_INVOKE("light_edit_open_untitled")),
/** The project open in this window (a settings window: its parent's), if any. */
projectCurrent: () => __TAURI_INVOKE<{
trusted: boolean,
@@ -330,11 +325,6 @@ export const commands = {
* editor buffer, so syntax features and language-server requests work in it.
*/
docOpenVirtual: (uri: string) => typedError<DocumentInfo, IdeError>(__TAURI_INVOKE("doc_open_virtual", { uri })),
/**
* Opens an editable untitled buffer (a new-file tab); it reaches the disk
* through `doc_save_as`, which turns it into a regular file document.
*/
docOpenUntitled: (name: string | null) => typedError<DocumentInfo, IdeError>(__TAURI_INVOKE("doc_open_untitled", { name })),
docClose: (doc: DocId) => typedError<null, IdeError>(__TAURI_INVOKE("doc_close", { doc })),
/**
* Applies a CodeMirror change set; returns the new version. Rejects with
@@ -342,11 +332,6 @@ export const commands = {
*/
docEdit: (doc: DocId, baseVersion: number, changes: ChangeSet) => typedError<number, IdeError>(__TAURI_INVOKE("doc_edit", { doc, baseVersion, changes })),
docSave: (doc: DocId) => typedError<null, IdeError>(__TAURI_INVOKE("doc_save", { doc })),
/**
* Save As for an untitled buffer: writes it to `path` and re-keys the buffer
* (undo history and version are kept; the language is re-detected).
*/
docSaveAs: (doc: DocId, path: string) => typedError<DocumentInfo, IdeError>(__TAURI_INVOKE("doc_save_as", { doc, path })),
docSaveAll: () => typedError<null, IdeError>(__TAURI_INVOKE("doc_save_all")),
docSyncFromDisk: () => typedError<null, IdeError>(__TAURI_INVOKE("doc_sync_from_disk")),
docSnapshot: (doc: DocId) => typedError<DocumentInfo, IdeError>(__TAURI_INVOKE("doc_snapshot", { doc })),
@@ -10197,9 +10182,7 @@ export type SyntaxErrors = {
};
/** Highlight classes sent to the UI, which maps each onto a Lezer tag. */
export type SyntaxHighlightKind = "keyword" | "string" | "stringSpecial" | "escape" | "regexp" | "atom" | "number" | "constant" | "comment" | "docComment" | "function" | "functionCall" | "builtin" | "type" | "namespace" | "variable" | "selfVariable" | "property" | "attribute" | "macro" | "label" | "tag" | "operator" | "punctuation" | "parameter" |
/** A shell command name (`pgrep`, a script's own function). */
"command";
export type SyntaxHighlightKind = "keyword" | "string" | "stringSpecial" | "escape" | "regexp" | "atom" | "number" | "constant" | "comment" | "docComment" | "function" | "functionCall" | "builtin" | "type" | "namespace" | "variable" | "selfVariable" | "property" | "attribute" | "macro" | "label" | "tag" | "operator" | "punctuation" | "parameter";
/** Highlighted ranges of a whole document, packed like LSP semantic tokens. */
export type SyntaxHighlights = {
-9
Переглянути файл
@@ -532,15 +532,6 @@ export function _provideNavigationHistory(provider: () => NavigationHistorySnaps
navigationHistoryProvider = provider;
}
/** A project↔file-editor switch is clearing and re-filling the tabs. @internal editor/core only. */
let editorSwitchingFlag = false;
export function _setEditorSwitching(value: boolean) {
editorSwitchingFlag = value;
}
export function editorSwitching(): boolean {
return editorSwitchingFlag;
}
// --- editor events ------------------------------------------------------------
export type EditorEvent =
-55
Переглянути файл
@@ -41,10 +41,6 @@ export interface DocBackend {
open(path: string): Promise<DocumentInfo>;
/** Opens a read-only language-server document (library or disk class contents). */
openVirtual(uri: string): Promise<DocumentInfo>;
/** Opens (or re-references) an editable untitled buffer (`untitled:Untitled-N`); `null` mints one. */
openUntitled(name: string | null): Promise<DocumentInfo>;
/** Save As for an untitled buffer: writes it to `path` and re-keys the document. */
saveAs(doc: DocId, path: string): Promise<DocumentInfo>;
close(doc: DocId): Promise<void>;
edit(doc: DocId, baseVersion: number, changes: TextChange[]): Promise<number>;
save(doc: DocId): Promise<void>;
@@ -83,8 +79,6 @@ export interface DocBackend {
export const ipcBackend: DocBackend = {
open: (path) => call(commands.docOpen(path)),
openVirtual: (uri) => call(commands.docOpenVirtual(uri)),
openUntitled: (name) => call(commands.docOpenUntitled(name)),
saveAs: (doc, path) => call(commands.docSaveAs(doc, path)),
close: async (doc) => void (await call(commands.docClose(doc))),
edit: (doc, baseVersion, changes) => call(commands.docEdit(doc, baseVersion, { changes })),
save: async (doc) => void (await call(commands.docSave(doc))),
@@ -390,14 +384,6 @@ export function createMemoryBackend(files: Record<string, string> = {}): MemoryB
let nextId = 1;
const store = new Map(Object.entries(files));
const savedBoms = new Map<string, boolean>();
const memUntitledCount = () => {
let max = 0;
for (const key of byPath.keys()) {
const m = /^untitled:Untitled-(\d+)$/.exec(key);
if (m) max = Math.max(max, Number(m[1]));
}
return max;
};
const info = (d: MemDoc): DocumentInfo => ({
id: d.id,
@@ -518,47 +504,6 @@ export function createMemoryBackend(files: Record<string, string> = {}): MemoryB
docs.delete(doc);
byPath.delete(d.path);
},
async openUntitled(name) {
const key = name ?? `untitled:Untitled-${memUntitledCount() + 1}`;
const existing = byPath.get(key);
if (existing !== undefined) {
const d = get(existing);
d.refs++;
return info(d);
}
const d: MemDoc = {
id: nextId++,
path: key,
text: "",
saved: "",
savedBom: false,
version: 0,
refs: 1,
lineEnding: "lf",
hasBom: false,
readOnly: false,
language: "plaintext",
uri: key,
};
docs.set(d.id, d);
byPath.set(key, d.id);
return info(d);
},
async saveAs(doc, path) {
const d = get(doc);
const before = d.text !== d.saved || d.hasBom !== d.savedBom;
d.saved = d.text;
d.savedBom = d.hasBom;
store.set(path, d.text);
savedBoms.set(path, d.hasBom);
byPath.delete(d.path);
byPath.set(path, d.id);
d.path = path;
d.uri = undefined;
d.language = EXT_LANG[path.slice(path.lastIndexOf(".") + 1).toLowerCase()] ?? "plaintext";
setDirty(d, before);
return info(d);
},
edit(doc, baseVersion, changes) {
return delay(() => {
const d = get(doc);
+2 -4
Переглянути файл
@@ -593,10 +593,8 @@ describe("Angular templates", () => {
moduleStyle: "es",
typeScript: true,
});
// Generated files are checked in with LF; a Windows checkout may hold CRLF.
const lf = (s: string) => s.replace(/\r\n/g, "\n");
expect(lf(parser)).toBe(lf(readFileSync(resolve(dir, "parser.ts"), "utf8")));
expect(lf(terms)).toBe(lf(readFileSync(resolve(dir, "parser.terms.ts"), "utf8")));
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
});
it("is the Angular Template mode", async () => {
+2 -4
Переглянути файл
@@ -153,10 +153,8 @@ describe("Astro components", () => {
moduleStyle: "es",
typeScript: true,
});
// Generated files are checked in with LF; a Windows checkout may hold CRLF.
const lf = (s: string) => s.replace(/\r\n/g, "\n");
expect(lf(parser)).toBe(lf(readFileSync(resolve(dir, "parser.ts"), "utf8")));
expect(lf(terms)).toBe(lf(readFileSync(resolve(dir, "parser.terms.ts"), "utf8")));
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
});
it("is the Astro mode", async () => {
+2 -4
Переглянути файл
@@ -168,10 +168,8 @@ describe("Django template grammar", () => {
moduleStyle: "es",
typeScript: true,
});
// Generated files are checked in with LF; a Windows checkout may hold CRLF.
const lf = (s: string) => s.replace(/\r\n/g, "\n");
expect(lf(parser)).toBe(lf(readFileSync(resolve(dir, "parser.ts"), "utf8")));
expect(lf(terms)).toBe(lf(readFileSync(resolve(dir, "parser.terms.ts"), "utf8")));
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
});
});
-3
Переглянути файл
@@ -9,8 +9,6 @@ import { Tag, tags as t } from "@lezer/highlight";
/** Log warnings: the UI warning colour, since no syntax token reads as one in every scheme. */
export const logWarning = Tag.define("logWarning");
export const parameter = Tag.define(t.variableName);
/** A shell command name: its own colour in JetBrains schemes, else a function call. */
export const shellCommand = Tag.define("shellCommand");
/** Inside doc comments, as IntelliJ colours them: `@param` / `{@link`, a `@param` name, HTML markup. */
export const docCommentTag = Tag.define(t.docComment);
export const docCommentTagValue = Tag.define(t.docComment);
@@ -62,7 +60,6 @@ export const openideaHighlightStyle = HighlightStyle.define([
token([t.typeName, t.className, t.namespace, t.definition(t.typeName)], "type"),
token([t.standard(t.typeName), t.standard(t.variableName), t.standard(t.function(t.variableName))], "builtin"),
token([t.function(t.variableName), t.function(t.propertyName)], "function-call"),
token(shellCommand, "shell-command", { color: "function-call" }),
token(
[
t.function(t.definition(t.variableName)),
+2 -4
Переглянути файл
@@ -237,10 +237,8 @@ describe("JSP pages", () => {
moduleStyle: "es",
typeScript: true,
});
// Generated files are checked in with LF; a Windows checkout may hold CRLF.
const lf = (s: string) => s.replace(/\r\n/g, "\n");
expect(lf(parser)).toBe(lf(readFileSync(resolve(dir, "parser.ts"), "utf8")));
expect(lf(terms)).toBe(lf(readFileSync(resolve(dir, "parser.terms.ts"), "utf8")));
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
});
it("is the JSP mode", async () => {
+1 -9
Переглянути файл
@@ -3,7 +3,7 @@ import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { highlightingFor, syntaxHighlighting } from "@codemirror/language";
import { tags as t } from "@lezer/highlight";
import { openideaHighlightStyle, parameter, shellCommand } from "./highlight";
import { openideaHighlightStyle, parameter } from "./highlight";
import { findLanguageDescription, loadSupport } from "./languages";
import { setSyntaxHighlights, syntaxHighlightField } from "./syntaxHighlight";
@@ -46,14 +46,6 @@ describe("syntaxHighlightField", () => {
expect(marks(state).map((m) => m.text)).toEqual(["def", "hi", ":ok"]);
});
it("gives shell command names their own class, apart from function calls", () => {
const state = create().update({ effects: setSyntaxHighlights.of({ kinds: ["command"], data: [0, 3, 0] }) }).state;
const command = highlightingFor(state, [shellCommand]);
expect(command).toBeTruthy();
expect(command).not.toBe(highlightingFor(state, [t.function(t.variableName)]));
expect(marks(state)).toEqual([{ text: "def", cls: command }]);
});
it("lets Kotlin declaration roles override the stream lexer's generic definition style", async () => {
const support = await loadSupport(findLanguageDescription("Kotlin", "demo.kt")!);
const view = new EditorView({
+2 -3
Переглянути файл
@@ -1,6 +1,6 @@
/**
* Tree-sitter highlighting (`doc_highlights`) for languages without a
* CodeMirror mode (Elixir, ...) or a limited stream mode (Kotlin, Shell): backend kinds → Lezer tags, so the colours
* CodeMirror mode (Elixir, ...) or a limited stream mode (Kotlin): backend kinds → Lezer tags, so the colours
* come from the same highlight style and Color Scheme settings as Lezer
* highlighting. Refreshed with folding; in between, marks are mapped through
* the changes.
@@ -9,7 +9,7 @@ import { Prec, StateEffect, StateField, type EditorState } from "@codemirror/sta
import { Decoration, EditorView, type DecorationSet } from "@codemirror/view";
import { highlightingFor } from "@codemirror/language";
import { tags as t, type Tag } from "@lezer/highlight";
import { parameter, shellCommand } from "./highlight";
import { parameter } from "./highlight";
import type { SyntaxHighlightKind, SyntaxHighlights } from "@/platform/ipc";
const TAGS: Record<SyntaxHighlightKind, Tag> = {
@@ -38,7 +38,6 @@ const TAGS: Record<SyntaxHighlightKind, Tag> = {
tag: t.tagName,
operator: t.operator,
punctuation: t.punctuation,
command: shellCommand,
};
export const setSyntaxHighlights = StateEffect.define<SyntaxHighlights>();
+7 -199
Переглянути файл
@@ -11,11 +11,10 @@ import { create } from "zustand";
import { setContext } from "@/platform/context";
import { shortcutLabel } from "@/platform/keymap";
import { isSameOrInside } from "@/platform/paths";
import { basename, dirname, useProject } from "@/platform/project";
import { basename, useProject } from "@/platform/project";
import { editorPrefs } from "@/platform/editorSettings";
import {
_emitEditorEvent,
_setEditorSwitching,
_provideNavigationHistory,
_provideOpenEditorPaths,
_setActiveEditor,
@@ -45,10 +44,8 @@ import {
import { useDistractionFree } from "@/workbench/distractionFree";
import { restoreMaximizedToolWindow } from "@/workbench/toolwindows/store";
import { ask, confirm } from "@/workbench/messages";
import { t, tx } from "@/platform/i18n";
import { t } from "@/platform/i18n";
import { call, commands } from "@/platform/ipc";
import { save as saveFileDialog, type DialogFilter as SaveDialogFilter } from "@/platform/dialog";
import { isUntitledUri, untitledTitle } from "@/platform/virtualDocs";
import { isRemoteWindow } from "@/platform/remote";
import { fileIconName, showContextMenu, type MenuItem } from "@/ui";
import { canRevealInFileManager, fileManagerName } from "@/platform/fileManager";
@@ -135,16 +132,9 @@ export const virtualInput = (
data: { title: vd.title, description: vd.description, decompiled: vd.decompiled } satisfies VirtualInputData,
});
/**
* An editable new-file buffer (File › New File). Its `path` is the minted
* `untitled:` URI, which identifies its document model; Save asks for a real
* path and turns the tab into a file one.
*/
export const untitledInput = (uri: string): EditorInput => ({ kind: "untitled", key: uri, path: uri });
/** Inputs shown in the text editor: files, virtual documents and untitled buffers. */
/** Inputs shown in the text editor: files and virtual documents. */
export function isTextInput(input: EditorInput): input is EditorInput & { path: string } {
return (input.kind === "file" || input.kind === "virtual" || input.kind === "untitled") && !!input.path;
return (input.kind === "file" || input.kind === "virtual") && !!input.path;
}
export const useTabs = create<TabsState>(() => initialTabsState());
@@ -169,29 +159,6 @@ export const useEditorStatus = create<{ status: EditorStatus | null }>(() => ({
export const useTabErrors = create<Record<string, string>>(() => ({}));
/** Per-tab find bar state (open / replace mode), like IDEA's per-editor search session. */
export const useFindBars = create<Record<string, { replace: boolean; seq: number } | undefined>>(() => ({}));
/** Text file entry shown first in the save dialog's type dropdown. */
const TXT_FILTER = { name: "Text files (*.txt)", extensions: ["txt"] };
/**
* The save dialog's type dropdown: every registered language (its name and
* extensions), plus plain text and an all-files escape hatch. Picking a type
* makes the OS append that extension; the backend re-detects from the path.
*/
let cachedLanguageFilters: SaveDialogFilter[] | null = null;
async function languageFilters(): Promise<SaveDialogFilter[]> {
cachedLanguageFilters ??= await commands
.languages()
.catch(() => [] as { name: string; extensions: string[] }[])
.then((languages) => [
TXT_FILTER,
...languages
.map((l) => ({ name: `${l.name} (${l.extensions.map((e) => `*.${e}`).join(", ")})`, extensions: l.extensions }))
.sort((a, b) => a.name.localeCompare(b.name)),
{ name: "All files (*.*)", extensions: ["*"] },
]);
return cachedLanguageFilters ?? [];
}
export const useGotoLine = create<{ open: boolean }>(() => ({ open: false }));
/** Paths of open file tabs, all panes in visual order, unique. */
@@ -209,22 +176,13 @@ export function openFilePaths(s: TabsState): string[] {
/** Editor Tabs › Sort tabs alphabetically: by file name. */
function tabSortKey(input: EditorInput): string {
return basename(untitledTitle(input.path ?? input.key));
return basename(input.path ?? input.key);
}
function isTextTab(tab: Tab | null | undefined): tab is Tab & { input: { path: string } } {
return !!tab && isTextInput(tab.input);
}
/** Keys of untitled buffers whose last tab the transition `before` → `after` closes. */
function closedUntitledKeys(before: TabsState, after: TabsState): string[] {
const open = openKeys(after);
const out = new Set<string>();
for (const pane of Object.values(before.panes))
for (const tab of pane.tabs) if (tab.input.kind === "untitled" && !open.has(tab.input.key)) out.add(tab.input.key);
return [...out];
}
type EditorKinds = ReturnType<typeof useContributions.getState>["editorKinds"];
interface OpenRequest {
@@ -238,7 +196,6 @@ interface OpenRequest {
/** Tab title of an editor input: the file name, or what its editor kind calls it. */
export function tabTitle(input: EditorInput, kinds: EditorKinds): string {
if (input.kind === "file") return basename(input.path ?? input.key);
if (input.kind === "untitled") return untitledTitle(input.path ?? input.key);
if (input.kind === "virtual")
return (input.data as VirtualInputData | undefined)?.title ?? basename(input.path ?? input.key);
const kind = kinds.get(input.kind);
@@ -251,7 +208,6 @@ export function tabTitle(input: EditorInput, kinds: EditorKinds): string {
export function tabIcon(input: EditorInput, kinds: EditorKinds): string {
if (input.kind === "file") return fileIconName(basename(input.path ?? ""), input.path ?? null);
if (input.kind === "untitled") return fileIconName(untitledTitle(input.path ?? ""));
if (input.kind === "virtual") return fileIconName(tabTitle(input, kinds));
const kind = kinds.get(input.kind);
try {
@@ -317,7 +273,6 @@ export class EditorController implements SlotHost {
{
settings: () => useProject.getState().settings ?? useProject.getState().project?.settings,
notify: (n) => notifications().notify(n),
saveAs: (model) => this.saveUntitled(model),
},
scheduler,
);
@@ -375,45 +330,14 @@ export class EditorController implements SlotHost {
this.sessionRoot = null;
const generation = ++this.projectGeneration;
const documents = customDocuments(useTabs.getState(), useContributions.getState().editorKinds);
// Untitled buffers belong to the window, not a project: their tabs
// survive the switch (the backend carries them into the new
// registry) and their slots rebuild against the carried documents.
const carriedUntitled: Tab[] = [];
let carriedActive: string | null = null;
const cs = useTabs.getState();
for (const pane of Object.values(cs.panes)) {
for (const tab of pane.tabs) {
if (isUntitledUri(tab.input.path ?? "")) {
carriedUntitled.push(tab);
if (pane.active === tab.id) carriedActive = tab.id;
}
}
}
// The guard decided already. Disposal must never prompt or autosave
// custom documents after the backend has replaced their project.
this.replacingProject = true;
_setEditorSwitching(true);
try {
useTabs.setState(initialTabsState(), true);
if (carriedUntitled.length) {
let st = useTabs.getState();
const firstPane = paneIds(st)[0]!;
for (const tab of carriedUntitled) {
st = openTab(st, firstPane, tab.input, { activate: false }).state;
}
if (carriedActive) {
const carriedInPane = st.panes[firstPane]!.tabs.find(
(t) => t.input.key === carriedUntitled.find((c) => c.id === carriedActive)!.input.key,
);
if (carriedInPane) st = activateTab(st, firstPane, carriedInPane.id);
}
useTabs.setState(st, true);
}
} finally {
this.replacingProject = false;
_setEditorSwitching(false);
}
for (const tab of carriedUntitled) void this.ensureSlot(tab.id);
this.closeSession = null;
this.closeCommitted = false;
this.preparedDocuments.clear();
@@ -502,7 +426,6 @@ export class EditorController implements SlotHost {
const live = new Set<string>();
for (const p of Object.values(next.panes)) for (const t of p.tabs) live.add(t.id);
for (const id of this.selectionRequests.keys()) if (!live.has(id)) this.selectionRequests.delete(id);
for (const [id, slot] of this.slots) {
if (!live.has(id)) {
this.slots.delete(id);
@@ -601,8 +524,6 @@ export class EditorController implements SlotHost {
for (const slot of this.slots.values()) {
if (slot.disposed) continue;
const path = slot.model.path;
// Untitled buffers have no file to restore a caret for (and are not saved as tabs).
if (isUntitledUri(path)) continue;
// The last focused slot of a document wins over other split copies.
if (files[path] && slot.model.lastFocused !== slot && !this.restoredViews.has(path)) continue;
files[path] = slot.viewState();
@@ -1207,86 +1128,6 @@ export class EditorController implements SlotHost {
return this.admitInput(input, o, this.openingGeneration(o));
}
/** File › New File: an editable untitled buffer; Save asks for a path. */
async openUntitled(o: OpenOptions = {}): Promise<void> {
const generation = this.openingGeneration(o);
await this.projectTransition;
this.checkOpening(generation);
const info = await this.manager.backend.openUntitled(null);
this.checkOpening(generation);
this.manager.adopt(info);
await this.admitInput(untitledInput(info.path), o, generation);
}
/**
* ManagerHost.saveAs: an untitled buffer's explicit save asks for a path
* (Save As). The backend re-keys the buffer; the model is re-pointed and
* every tab showing it becomes a file tab.
*/
private async saveUntitled(model: import("./model/document").DocModel): Promise<boolean> {
const path = await saveFileDialog({
title: tx("Save As"),
defaultPath: this.saveAsSuggestion(model),
filters: await languageFilters(),
});
if (!path) return false;
const untitled = model.path;
try {
const info = await this.manager.backend.saveAs(model.doc, path);
this.manager.retarget(model, info);
this.swapUntitledTabs(untitled, info.path);
return true;
} catch (e) {
notifications().notify({
level: "error",
title: "Cannot save file",
body: e instanceof Error ? e.message : String(e),
});
return false;
}
}
/**
* Where the save dialog opens for a new buffer: the project root, or, in a
* LightEdit window, the folder of its most recently open real file. The file
* type comes from the dialog's own type dropdown (see `languageFilters`).
*/
private saveAsSuggestion(model: import("./model/document").DocModel): string {
const root = useProject.getState().project?.root;
const dir =
root ??
(() => {
const s = useTabs.getState();
for (const pane of Object.values(s.panes)) {
for (const id of pane.mru) {
const tab = pane.tabs.find((t) => t.id === id);
if (tab?.input.kind === "file" && tab.input.path) return dirname(tab.input.path);
}
}
return null;
})();
const name = untitledTitle(model.path);
return dir ? `${dir}/${name}` : name;
}
/** Save As turned an untitled buffer into `path`: retarget every tab showing it. */
private swapUntitledTabs(oldKey: string, path: string) {
const s = useTabs.getState();
let changed = false;
const panes: TabsState["panes"] = {};
for (const [paneId, pane] of Object.entries(s.panes)) {
let paneChanged = false;
const tabs = pane.tabs.map((tab) => {
if (tab.input.key !== oldKey) return tab;
paneChanged = true;
return { ...tab, input: fileInput(path) };
});
panes[paneId] = paneChanged ? { ...pane, tabs } : pane;
changed ||= paneChanged;
}
if (changed) useTabs.setState({ ...s, panes }, true);
}
private async admitInput(
input: EditorInput,
o: OpenOptions,
@@ -1609,36 +1450,6 @@ export class EditorController implements SlotHost {
}
authorized.add(d.input.key);
}
// Dirty untitled buffers: autosave never writes them in place, so
// closing their last tab asks (Save opens the Save As dialog).
const untitledAuthorized = new Set<string>();
for (;;) {
const current = useTabs.getState();
const key = closedUntitledKeys(current, currentRemoval(current)).find((k) => !untitledAuthorized.has(k));
if (!key) break;
const model = this.manager.get(key);
if (!model || model.closed || !model.getMeta().dirty) {
untitledAuthorized.add(key);
continue;
}
const decision = await ask({
title: t("app.unsaved.title"),
body: t("app.unsaved.fileBody", { name: untitledTitle(key) }),
buttons: [
{ id: "discard", label: t("app.unsaved.discard") },
{ id: "save", label: t("app.unsaved.saveFile"), primary: true },
],
icon: "warning",
});
if (decision.button === "save") {
await model.save("explicit");
// Still dirty: the Save As dialog was cancelled or the save failed.
if (model.getMeta().dirty) return false;
} else if (decision.button !== "discard") {
return false;
}
untitledAuthorized.add(key);
}
// Again after the discards: a tab may have become busy while they ran.
if (!(await this.confirmBusyClose(currentRemoval, confirmedBusy))) return false;
if (generation !== this.projectGeneration) return false;
@@ -2029,11 +1840,8 @@ export class EditorController implements SlotHost {
if (errors.length) throw combinedError(errors);
}
async saveAll(opts?: { skipUntitled?: boolean }): Promise<void> {
const results = await Promise.allSettled([
this.manager.saveAll("explicit", opts?.skipUntitled),
this.saveCustomDocuments(),
]);
async saveAll(): Promise<void> {
const results = await Promise.allSettled([this.manager.saveAll("explicit"), this.saveCustomDocuments()]);
const errors = failures(results);
if (errors.length) throw combinedError(errors);
}
+1 -5
Переглянути файл
@@ -11,7 +11,6 @@ import {
registerStatusItem,
} from "@/workbench/contributions";
import { _provideCodePreview, _provideDocumentAttacher, editorsFor } from "@/editor/api";
import { isUntitledUri } from "@/platform/virtualDocs";
import { formatDocument } from "@/editor/lsp/format";
import { editorController } from "./controller";
import { attachDocument } from "./model/attachment";
@@ -43,13 +42,10 @@ export function activate(): void | (() => void) {
provideEditorService({
openFile: (path, options) => ctrl.openFile(path, options),
openInput: (input, options) => ctrl.openInput(input, options),
openUntitled: (options) => ctrl.openUntitled(options),
activePath: () => ctrl.activePath(),
activeCursor: () => ctrl.activeCursor(),
saveAll: (opts) => ctrl.saveAll(opts),
saveAll: () => ctrl.saveAll(),
dirtyDocumentIds: () => [...ctrl.manager.byId.values()].filter((m) => m.getMeta().dirty).map((m) => m.doc),
untitledDirtyDocumentIds: () =>
[...ctrl.manager.byId.values()].filter((m) => isUntitledUri(m.path) && m.getMeta().dirty).map((m) => m.doc),
focus: () => ctrl.focus(),
openPaths: () => ctrl.openPaths(),
openTabs: () => ctrl.openTabs(),
+12 -83
Переглянути файл
@@ -26,7 +26,7 @@ import {
useEditorRegistry,
type EditorDocContext,
} from "@/editor/api";
import { isUntitledUri, isVirtualUri } from "@/platform/virtualDocs";
import { isVirtualUri } from "@/platform/virtualDocs";
import { DocSync, type Scheduler, frameScheduler } from "../sync/DocSync";
import type { DocBackend, LanguageInfo } from "../backend";
import {
@@ -98,11 +98,6 @@ export const useDocMeta = create<Record<string, DocMeta>>(() => ({}));
export const useOutline = create<Record<string, OutlineItem[]>>(() => ({}));
const REFRESH_DELAY = 450;
/**
* Languages whose stream mode works from word lists, so the grammar's highlights are drawn
* over it: Kotlin declaration roles, Shell commands, function names and `local`.
*/
const GRAMMAR_SUPPLEMENTED = new Set(["kotlin", "shellscript"]);
function bannerFor(info: DocumentInfo): Banner {
// Virtual documents (library classes) get their own notice from the editor area.
if (info.uri) return null;
@@ -209,7 +204,7 @@ export function saveCleanupChanges(
export class DocModel {
readonly doc: DocId;
/** File path, or the URI of a virtual (read-only, language-server provided) document. */
path: string;
readonly path: string;
readonly virtual: boolean;
readonly sync: DocSync;
readonly slots = new Set<EditorSlot>();
@@ -239,7 +234,7 @@ export class DocModel {
) {
this.doc = info.id;
this.path = info.path;
this.virtual = !!info.uri && !isUntitledUri(info.path);
this.virtual = !!info.uri;
this.info = languageInfo(info.language);
this.editorconfig = info.editorconfig ?? null;
// `large`: the backend skips LSP and syntax trees for it; heavy UI features follow.
@@ -579,7 +574,8 @@ export class DocModel {
const [folds, outline, highlights] = await Promise.allSettled([
backend.folding(this.doc),
backend.outline(this.doc),
!this.desc || GRAMMAR_SUPPLEMENTED.has(this.meta.language) ? backend.highlights(this.doc) : null,
// Kotlin's stream mode lacks declaration categories; the grammar supplements it.
!this.desc || this.meta.language === "kotlin" ? backend.highlights(this.doc) : null,
]);
if (this.closed) return;
if (counter !== this.changeCounter) {
@@ -672,14 +668,6 @@ export class DocModel {
}
await this.sync.whenIdle();
if (this.closed) return;
if (isUntitledUri(this.path)) {
// An untitled buffer has no path to write to: autosave is a no-op and
// an explicit save asks for one (Save As re-points the buffer, or stays
// dirty on Cancel).
if (reason === "auto") return;
if (await this.manager.saveAs(this)) this.modified.clear();
return;
}
await this.manager.backend.save(this.doc);
this.modified.clear();
this.setMeta({ dirty: false });
@@ -689,31 +677,6 @@ export class DocModel {
return this.saving;
}
/** Save As turned this untitled buffer into the file `info` points at. */
retarget(info: DocumentInfo) {
const old = this.path;
this.path = info.path;
this.info = languageInfo(info.language);
this.editorconfig = info.editorconfig ?? null;
this.meta = {
...this.meta,
path: info.path,
language: info.language,
encoding: info.encoding,
hasBom: info.hasBom,
lineEnding: info.lineEnding,
canReloadEncoding: info.canReloadEncoding,
dirty: false,
};
useDocMeta.setState((s) => {
const next = { ...s };
delete next[old];
next[info.path] = this.meta;
return next;
}, true);
_emitEditorEvent({ type: "docSaved", doc: this.doc, path: this.path });
}
dispose() {
this.closed = true;
clearTimeout(this.refreshTimer);
@@ -724,11 +687,6 @@ export class DocModel {
export interface ManagerHost {
settings(): Settings | null | undefined;
notify(n: { level: "info" | "warning" | "error"; title: string; body?: string }): void;
/**
* An untitled buffer's explicit save: asks for a path, writes the buffer
* there and re-points the model. `false` = cancelled or failed.
*/
saveAs?(model: DocModel): Promise<boolean>;
}
export class DocumentManager {
@@ -791,29 +749,6 @@ export class DocumentManager {
return this.byPath.get(path);
}
/** Registers a backend document opened outside `acquire` (a minted untitled buffer). */
adopt(info: DocumentInfo): DocModel {
const model = new DocModel(this, info);
void model.loadLanguage();
this.byPath.set(model.path, model);
this.byId.set(model.doc, model);
_emitEditorEvent({ type: "docOpened", doc: model.doc, path: model.path, language: info.language });
return model;
}
/** An untitled buffer's explicit save (Save As); `false` = cancelled or failed. */
saveAs(model: DocModel): Promise<boolean> {
return this.host.saveAs?.(model) ?? Promise.resolve(false);
}
/** Save As re-pointed `model` from its untitled URI to a real file path. */
retarget(model: DocModel, info: DocumentInfo) {
if (model.path === info.path) return;
this.byPath.delete(model.path);
this.byPath.set(info.path, model);
model.retarget(info);
}
/** Opens (or returns the already open) document model for `path`. */
acquire(path: string): Promise<DocModel> {
const existing = this.byPath.get(path);
@@ -829,11 +764,7 @@ export class DocumentManager {
})
.catch(() => {});
const [info] = await Promise.all([
isUntitledUri(path)
? this.backend.openUntitled(path)
: isVirtualUri(path)
? this.backend.openVirtual(path)
: this.backend.open(path),
isVirtualUri(path) ? this.backend.openVirtual(path) : this.backend.open(path),
catalog,
]);
const raced = this.byPath.get(path);
@@ -898,16 +829,14 @@ export class DocumentManager {
}
/** Saves dirty documents; failures are notified, and an explicit Save All also rejects with them. */
async saveAll(reason: "explicit" | "auto", skipUntitled = false) {
async saveAll(reason: "explicit" | "auto") {
const errors: unknown[] = [];
await Promise.all(
[...this.byPath.values()]
.filter((m) => !(skipUntitled && isUntitledUri(m.path)))
.map((m) =>
m.save(reason).catch((e) => {
errors.push(e);
}),
),
[...this.byPath.values()].map((m) =>
m.save(reason).catch((e) => {
errors.push(e);
}),
),
);
if (errors.length) {
const e = errors[0];
-171
Переглянути файл
@@ -1,171 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/platform/dialog", () => ({ save: vi.fn(async () => null) }));
const ask = vi.fn(async () => ({ button: "cancel" }));
vi.mock("@/workbench/messages", () => ({
ask: (...args: unknown[]) => ask(...(args as [])),
confirm: vi.fn(async () => false),
}));
const { EditorController, isTextInput, tabTitle, untitledInput, useTabs } = await import("./controller");
const { createMemoryBackend } = await import("./backend");
type MemoryBackend = Awaited<ReturnType<typeof createMemoryBackend>>;
const { initialTabsState } = await import("./model/tabs");
const { serializeSession } = await import("./model/session");
const { useDocMeta } = await import("./model/document");
const { save } = await import("@/platform/dialog");
const editorKinds = await import("@/workbench/contributions");
let ctrl: InstanceType<typeof EditorController> | null = null;
let backend: MemoryBackend;
beforeEach(() => {
backend = createMemoryBackend({ "/p/main.rs": "fn main() {}\n" });
ctrl = new EditorController(backend);
ctrl.start();
});
afterEach(() => {
ctrl?.stop();
ctrl = null;
useTabs.setState(initialTabsState(), true);
vi.clearAllMocks();
});
const activeTab = () => {
const s = useTabs.getState();
const pane = s.panes[s.activePane]!;
return pane.tabs.find((t) => t.id === pane.active)!;
};
const editActive = async (insert: string) => {
const slot = ctrl!.slotFor(activeTab().id)!;
slot.dispatchLocal({ changes: { from: 0, insert } });
const model = slot.model;
// The dirty flag arrives with the backend's acknowledgement.
await vi.waitFor(() => expect(model.getMeta().dirty).toBe(true));
return model;
};
describe("untitled editor input", () => {
it("opens editable, pathless buffers with incrementing names", async () => {
await ctrl!.openUntitled({ focus: false });
const tab = activeTab();
expect(tab.input).toEqual({ kind: "untitled", key: "untitled:Untitled-1", path: "untitled:Untitled-1" });
expect(isTextInput(tab.input)).toBe(true);
expect(tabTitle(tab.input, editorKinds.useContributions.getState().editorKinds)).toBe("Untitled-1");
const slot = ctrl!.slotFor(tab.id)!;
expect(slot.model.virtual).toBe(false);
expect(slot.model.ctx()).toMatchObject({ path: "untitled:Untitled-1", language: "plaintext" });
expect(useDocMeta.getState()["untitled:Untitled-1"]).toMatchObject({ dirty: false, readOnly: false });
await editActive("draft");
expect(slot.model.getMeta().dirty).toBe(true);
await ctrl!.openUntitled({ focus: false });
expect(activeTab().input.path).toBe("untitled:Untitled-2");
expect(untitledInput("untitled:Untitled-9").key).toBe("untitled:Untitled-9");
});
it("autosave never writes an untitled buffer; explicit save asks for a path and retargets tab and model", async () => {
await ctrl!.openUntitled({ focus: false });
const model = await editActive("draft");
vi.mocked(save).mockResolvedValue("/p/new/dir/hello.rs");
await model.save("auto");
expect(save).not.toHaveBeenCalled();
expect(model.getMeta().dirty).toBe(true);
await model.save("explicit");
// No content guessing: the bare name plus the type dropdown (filters).
expect(save).toHaveBeenCalledWith(
expect.objectContaining({ defaultPath: "Untitled-1", filters: expect.any(Array) }),
);
expect(activeTab().input).toMatchObject({ kind: "file", path: "/p/new/dir/hello.rs" });
expect(model.path).toBe("/p/new/dir/hello.rs");
expect(model.getMeta().dirty).toBe(false);
expect(useDocMeta.getState()["/p/new/dir/hello.rs"]).toBeDefined();
expect(useDocMeta.getState()["untitled:Untitled-1"]).toBeUndefined();
// Save cleanup adds the final newline.
expect(backend.files.get("/p/new/dir/hello.rs")).toBe("draft\n");
// Saved as .rs: the buffer is a regular file document again.
expect(model.virtual).toBe(false);
expect((await backend.snapshot(model.doc)).uri).toBeUndefined();
// The session keeps the saved file but never an unsaved untitled buffer.
await ctrl!.openUntitled({ focus: false });
await editActive("second");
const session = serializeSession(useTabs.getState(), {});
expect(JSON.stringify(session)).toContain("/p/new/dir/hello.rs");
expect(JSON.stringify(session)).not.toContain("untitled:");
});
it("offers the registered languages as save-as types", async () => {
await ctrl!.openUntitled({ focus: false });
const slot = ctrl!.slotFor(activeTab().id)!;
slot.dispatchLocal({ changes: { from: 0, insert: "{}" } });
vi.mocked(save).mockResolvedValue("/p/anywhere/x.md");
await slot.model.save("explicit");
const call = vi.mocked(save).mock.calls.at(-1)![0]!;
const types = (call.filters ?? []).map((f: { name: string }) => f.name);
// Plain text first (the default), registered languages in between, all-files last.
expect(types[0]).toBe("Text files (*.txt)");
expect(types.at(-1)).toBe("All files (*.*)");
// The name carries no extension: the picked type decides it.
expect(call.defaultPath).toBe("Untitled-1");
// The name carries no extension: the picked type decides it.
expect(call.defaultPath).toBe("Untitled-1");
});
it("keeps the buffer open and dirty when the save dialog is cancelled", async () => {
await ctrl!.openUntitled({ focus: false });
const model = await editActive("draft");
vi.mocked(save).mockResolvedValue(null);
await model.save("explicit");
expect(activeTab().input.kind).toBe("untitled");
expect(model.getMeta().dirty).toBe(true);
expect([...backend.files.keys()]).toEqual(["/p/main.rs"]);
});
it("asks about a dirty untitled buffer when its last tab closes", async () => {
await ctrl!.openUntitled({ focus: false });
const tab = activeTab();
await editActive("draft");
const paneId = useTabs.getState().activePane;
// Cancel keeps the tab.
ask.mockResolvedValue({ button: "cancel" });
expect(await ctrl!.closeTab(paneId, tab.id)).toBe(false);
expect(activeTab().id).toBe(tab.id);
// Save closes it through the Save As dialog.
ask.mockResolvedValue({ button: "save" });
vi.mocked(save).mockResolvedValue("/p/kept.txt");
expect(await ctrl!.closeTab(paneId, tab.id)).toBe(true);
expect(Object.values(useTabs.getState().panes).flatMap((p) => p.tabs)).toHaveLength(0);
expect(backend.files.get("/p/kept.txt")).toBe("draft\n");
});
it("closes an untitled buffer without saving when the user declines", async () => {
await ctrl!.openUntitled({ focus: false });
const tab = activeTab();
await editActive("draft");
const paneId = useTabs.getState().activePane;
ask.mockResolvedValue({ button: "discard" });
expect(await ctrl!.closeTab(paneId, tab.id)).toBe(true);
expect(Object.values(useTabs.getState().panes).flatMap((p) => p.tabs)).toHaveLength(0);
expect([...backend.files.keys()]).toEqual(["/p/main.rs"]);
});
it("reuses the buffer for a re-opened untitled name and shares it across tabs", async () => {
const info = await backend.openUntitled(null);
const twice = await backend.openUntitled("untitled:Untitled-1");
expect(twice.id).toBe(info.id);
await ctrl!.openInput(untitledInput(info.path), { focus: false });
await ctrl!.openInput(untitledInput(info.path), { focus: false, side: true });
const tabs = Object.values(useTabs.getState().panes).flatMap((p) => p.tabs);
expect(tabs).toHaveLength(2);
expect(ctrl!.slotFor(tabs[0]!.id)!.model).toBe(ctrl!.slotFor(tabs[1]!.id)!.model);
});
});
+1 -6
Переглянути файл
@@ -10,7 +10,7 @@ import { BarButton, Icon, Menu, badgeKind, fileIconName } from "@/ui";
import { notifications, useProjectViewLead } from "@/workbench/contributions";
import { useDistractionFree } from "@/workbench/distractionFree";
import { ENCODING_CHOICES, useEditorPrefs } from "@/platform/editorSettings";
import { isUntitledUri, isVirtualUri, invalidateVirtualDocument, untitledTitle } from "@/platform/virtualDocs";
import { isVirtualUri, invalidateVirtualDocument } from "@/platform/virtualDocs";
import { editorController, isTextInput, useEditorStatus, useGotoLine, useTabs } from "../controller";
import { useDocMeta, useOutline } from "../model/document";
import { symbolChain } from "./outline";
@@ -76,11 +76,6 @@ function projectCrumb(project: { root: string; name: string }): Crumb {
/** The project › dirs › file part of the navigation path (IDEA navigation bar). */
function pathCrumbs(project: { root: string; name: string } | null, path: string, dir = false): Crumb[] {
// A new-file buffer has no folder yet: only its name.
if (isUntitledUri(path)) {
const label = untitledTitle(path);
return [{ label, icon: fileIconName(label), title: label }];
}
const target = dir ? path : dirname(path);
const segments: Crumb[] = [];
if (project && target !== project.root && !target.startsWith(project.root + "/")) project = null;
-11
Переглянути файл
@@ -262,14 +262,3 @@ it("shows the project › dirs › file path above the editor", async () => {
hidden.unmount();
useProject.setState({ project: null, settings: null });
});
it("shows only the name of a new-file buffer", () => {
useProject.setState({
project: { root: "/workspace", name: "Workspace", settings: {}, trusted: true, isGit: false },
settings: { appearance: { navigationBar: "top" } } as unknown as Settings,
});
useEditorStatus.setState({ status: { ...caret(0, 0), path: "untitled:Untitled-1" } });
const view = render(<Breadcrumbs className="nav" />);
expect(screen.getByRole("navigation", { name: "Breadcrumbs" }).textContent).toBe("Untitled-1");
view.unmount();
});
+3 -8
Переглянути файл
@@ -12,17 +12,12 @@ export function LightEditStatus() {
const t = useT();
const lightEdit = useProject((s) => s.lightEdit);
const path = useEditorRegistry((s) => s.active?.ctx.path);
if (!lightEdit) return null;
const hasRealFile = !!path && !isVirtualUri(path);
if (!lightEdit || !path || isVirtualUri(path)) return null;
return (
<BarButton
emphasis
tooltip={
hasRealFile
? t("app.lightEdit.openProjectTooltip", { folder: dirname(path) })
: t("app.lightEdit.pickFolderTooltip")
}
onClick={() => void executeAction(hasRealFile ? LIGHT_EDIT_OPEN_PROJECT : "file.openProject")}
tooltip={t("app.lightEdit.openProjectTooltip", { folder: dirname(path) })}
onClick={() => void executeAction(LIGHT_EDIT_OPEN_PROJECT)}
>
<Icon name="folder-open" size={14} />
{t("app.lightEdit.openProject")}
+2 -9
Переглянути файл
@@ -279,13 +279,6 @@ export async function confirmLeave(
): Promise<boolean> {
if (!(await closeDocumentWindows())) return false;
const confirmTerminate = workflowSettings().run.confirmTerminate;
// Untitled buffers follow the window across projects: they are neither
// counted nor saved by this guard (the window keeps them open).
const untitled = new Set(maybeEditorService()?.untitledDirtyDocumentIds?.() ?? []);
const saveDeps: CloseDeps =
untitled.size > 0
? { ...deps, saveAll: () => Promise.resolve(maybeEditorService()?.saveAll({ skipUntitled: true })) }
: deps;
return decideClose(
{
quitting: false,
@@ -293,10 +286,10 @@ export async function confirmLeave(
confirmExit: false,
confirmTerminate,
processes: confirmTerminate ? await processNames(deps) : [],
dirty: dirtyCount() - untitled.size,
dirty: dirtyCount(),
project,
},
saveDeps,
deps,
);
}
+1 -19
Переглянути файл
@@ -15,7 +15,6 @@ import {
confirmLeaveProject,
dirname,
openLightEdit,
openLightEditUntitled,
openProject,
provideStartupLaunch,
useProject,
@@ -27,7 +26,6 @@ import { holdSplash } from "@/platform/splash";
import { editorService, maybeEditorService, notifications } from "@/workbench/contributions";
import { chooseProjectWindow } from "@/workbench/projectWindow";
import { cycleWindowTab, useWindowGroup } from "@/platform/windowGroups";
import { editorSwitching } from "@/editor/api";
import { showWorkspaceDialog } from "./WorkspaceDialog";
export const LAUNCH_EVENT = "openidea://launch";
@@ -482,20 +480,6 @@ export function windowActions(): Action[] {
await openFiles(await pickFiles(t("app.window.pickFileTitle")));
},
},
{
id: "file.newUntitled",
title: t("app.action.newFile"),
category: t("app.category.file"),
icon: "plus",
keywords: ["new", "create", "untitled", "file", "light edit"],
run: async () => {
if (desktopOnly()) return;
const s = useProject.getState();
// A welcome window becomes a LightEdit one first (its buffers need a backend project).
if (!s.project && !s.lightEdit) await openLightEditUntitled();
await editorService().openUntitled?.({ focus: true });
},
},
{
// Files and folders dropped on the Welcome screen.
id: "file.openPaths",
@@ -594,9 +578,7 @@ async function closeThisWindow(): Promise<void> {
export function installLightEditAutoClose(closeWindow: () => Promise<void> = closeThisWindow): () => void {
const editor = maybeEditorService();
const off = editor?.onActiveInputChange?.((input) => {
// A mode switch clears and re-fills the tabs in place: while it runs, the
// momentarily empty editor must not read as "the user closed everything".
if (input || !useProject.getState().lightEdit || editor?.openTabs?.().length || editorSwitching()) return;
if (input || !useProject.getState().lightEdit || editor?.openTabs?.().length) return;
void closeWindow().catch((e) => console.warn("could not close the window", e));
});
return () => off?.();
-1
Переглянути файл
@@ -198,7 +198,6 @@ export const ICLS_ATTRIBUTES: Record<string, Array<[Part, string]>> = {
"PY.DOC_COMMENT_TAG": [["FOREGROUND", "--syn-python-docstring-tag"]],
OPENIDEA_pyDocstringKeyword: [["FOREGROUND", "--syn-python-docstring-keyword"]],
"PY.BUILTIN_NAME": [["FOREGROUND", "--syn-predefined"]],
"BASH.EXTERNAL_COMMAND": [["FOREGROUND", "--syn-shell-command"]],
MARKDOWN_HEADER_LEVEL_1: [["FOREGROUND", "--syn-markup-heading"]],
MARKDOWN_CODE_SPAN: [["FOREGROUND", "--syn-markup-code"]],
MARKDOWN_LINK_TEXT: [["FOREGROUND", "--syn-link"]],
-18
Переглянути файл
@@ -33,9 +33,6 @@ function HeaderButtons() {
const openKey = shortcutLabel("file.openProject");
return (
<div className={styles.headerButtons}>
<Button disabled={opening} onClick={() => void executeAction("file.newUntitled")}>
<T>New File</T>
</Button>
{canCreate && (
<Button disabled={opening} onClick={() => void executeAction("file.newProject")}>
<T>New Project</T>
@@ -48,9 +45,6 @@ function HeaderButtons() {
>
<T>Open</T>
</Button>
<Button disabled={opening} onClick={() => void executeAction("file.openFile")}>
<T>Open File</T>
</Button>
{canClone && (
<Button onClick={() => void executeAction("vcs.clone")}>
<T>Clone Repository</T>
@@ -87,12 +81,6 @@ function Landing() {
<T>You can also drop a folder onto this window.</T>
</p>
<div className={styles.landingActions}>
<button type="button" className={styles.landingAction} onClick={() => void executeAction("file.newUntitled")}>
<span className={styles.landingIcon}>
<Icon name="plus" size={20} />
</span>
<T>New File</T>
</button>
{canCreate && (
<button
type="button"
@@ -117,12 +105,6 @@ function Landing() {
</span>
<T>Open</T>
</button>
<button type="button" className={styles.landingAction} onClick={() => void executeAction("file.openFile")}>
<span className={styles.landingIcon}>
<Icon name="file" size={20} />
</span>
<T>Open File</T>
</button>
{canClone && (
<button type="button" className={styles.landingAction} onClick={() => void executeAction("vcs.clone")}>
<span className={styles.landingIcon}>
-1
Переглянути файл
@@ -364,7 +364,6 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [
italicOnly: true,
defaultItalic: false,
},
syn("shellCommand", "Command", ["Shell Script"], "shell-command"),
syn("mdHeading", "Heading", ["Markdown"], "markup-heading", { defaultBold: true }),
syn("mdCode", "Code span", ["Markdown"], "markup-code"),
syn("mdLink", "Link", ["Markdown"], "link", { defaultEffect: "underline" }),
-3
Переглянути файл
@@ -7,13 +7,10 @@
import {
open as pluginOpen,
save as pluginSave,
type DialogFilter,
type OpenDialogOptions,
type OpenDialogReturn,
type SaveDialogOptions,
} from "@tauri-apps/plugin-dialog";
export type { SaveDialogOptions, DialogFilter };
import { call, commands } from "@/platform/ipc";
let needsGtk: boolean | undefined;
+1 -2
Переглянути файл
@@ -182,8 +182,7 @@ export const vscodeMacos: Record<string, string[]> = {
// File / editor tabs
"file.save": ["Meta+S"],
"file.newUntitled": ["Meta+N"],
"file.new": [],
"file.new": ["Meta+N"],
"file.settings": ["Meta+,"],
"file.projectStructure": [],
"file.openProject": ["Meta+O"],
-5
Переглянути файл
@@ -19,7 +19,6 @@
"app.action.cut": "Cut",
"app.action.keymapReference": "Keymap Reference",
"app.action.lightEditOpenProject": "Open Containing Folder as Project",
"app.action.newFile": "New Untitled File",
"app.action.newWindow": "New Window",
"app.action.nextWindow": "Next Project Window",
"app.action.openFile": "Open File…",
@@ -50,7 +49,6 @@
"app.keymap.title": "Keymap Reference",
"app.lightEdit.openProject": "Open as Project",
"app.lightEdit.openProjectTooltip": "Open {folder} as a project",
"app.lightEdit.pickFolderTooltip": "Pick a folder to open as a project",
"app.log.failed": "Cannot show the log",
"app.project.opening": "Opening {name}…",
"app.terminateProcesses.bodyMany": "Do you want to terminate these {count} processes: {names}?",
@@ -62,10 +60,8 @@
"app.title.default": "OpenIDEA",
"app.unsaved.body": "{count, plural, one {# file has} other {# files have}} unsaved changes in {project}. Save before closing?",
"app.unsaved.discard": "Don't Save",
"app.unsaved.fileBody": "{name} has unsaved changes. Save it before closing?",
"app.unsaved.save": "Save All",
"app.unsaved.saveFailed": "Could not save all files",
"app.unsaved.saveFile": "Save",
"app.unsaved.title": "Unsaved Changes",
"app.update.available": "OpenIDEA {version} is available",
"app.update.cancel": "Cancel",
@@ -203,7 +199,6 @@
"menu.workspace.create": "Workspace…",
"menu.workspace.open": "Open Workspace…",
"menu.workspace.manage": "Manage Workspace…",
"menu.file.newUntitled": "New Untitled File",
"menu.file.openInNewWindow": "Open in New Window…",
"menu.file.openFile": "Open File…",
"menu.remote.connect": "Remote Development…",
-1
Переглянути файл
@@ -1431,7 +1431,6 @@
"{program} {found} is installed, but this project needs {min} or later.": "{program} {found} is installed, but this project needs {min} or later.",
"{program} is not installed.": "{program} is not installed.",
"Create a project, open a folder from disk or clone a repository from version control.": "Create a project, open a folder from disk or clone a repository from version control.",
"New File": "New File",
"New Project": "New Project",
"{path} already exists and is not empty": "{path} already exists and is not empty",
"Example": "Example",
-2
Переглянути файл
@@ -783,7 +783,6 @@
"Cannot generate the DDL": "无法生成 DDL",
"Cannot generate the SQL script": "无法生成 SQL 脚本",
"Cannot get the DDL of {name}": "无法获取 {name} 的 DDL",
"Cannot save file": "无法保存文件",
"Cannot open file": "无法打开文件",
"Cannot open library class": "无法打开库类",
"Cannot open link": "无法打开链接",
@@ -2503,7 +2502,6 @@
"Sample plugin: inserts today's date and counts words in the active file.": "示例插件:插入今天的日期,并统计当前文件的字数。",
"Save": "保存",
"Save All": "全部保存",
"Save As": "另存为",
"Save Files": "保存文件",
"Save files if the IDE is idle for": "IDE 空闲以下时长时保存文件",
"Save files when switching to a different application or a built-in terminal": "切换到其他应用程序或内置终端时保存文件",
-5
Переглянути файл
@@ -19,7 +19,6 @@
"app.action.cut": "剪切",
"app.action.keymapReference": "快捷键参考",
"app.action.lightEditOpenProject": "将所在文件夹作为项目打开",
"app.action.newFile": "新建无标题文件",
"app.action.newWindow": "新建窗口",
"app.action.nextWindow": "下一个项目窗口",
"app.action.openFile": "打开文件…",
@@ -50,7 +49,6 @@
"app.keymap.title": "快捷键参考",
"app.lightEdit.openProject": "作为项目打开",
"app.lightEdit.openProjectTooltip": "将 {folder} 作为项目打开",
"app.lightEdit.pickFolderTooltip": "选择一个文件夹作为工程打开",
"app.log.failed": "无法显示日志",
"app.project.opening": "正在打开 {name}…",
"app.terminateProcesses.bodyMany": "是否要终止这 {count} 个进程:{names}?",
@@ -62,10 +60,8 @@
"app.title.default": "OpenIDEA",
"app.unsaved.body": "{project} 中有 {count} 个文件尚未保存。关闭前是否保存?",
"app.unsaved.discard": "不保存",
"app.unsaved.fileBody": "{name} 尚未保存。关闭前是否保存?",
"app.unsaved.save": "全部保存",
"app.unsaved.saveFailed": "无法保存所有文件",
"app.unsaved.saveFile": "保存",
"app.unsaved.title": "未保存的更改",
"app.update.available": "OpenIDEA {version} 可用",
"app.update.cancel": "取消",
@@ -203,7 +199,6 @@
"menu.workspace.create": "工作区…",
"menu.workspace.open": "打开工作区…",
"menu.workspace.manage": "管理工作区…",
"menu.file.newUntitled": "新建无标题文件",
"menu.file.openInNewWindow": "在新窗口中打开…",
"menu.file.openFile": "打开文件…",
"menu.remote.connect": "远程开发…",
-6
Переглянути файл
@@ -79,12 +79,6 @@ export async function openLightEdit(path: string): Promise<void> {
useProject.setState({ project: null, settings, lightEdit: true });
}
/** Switches this window to LightEdit without a file (File › New File from the welcome screen). */
export async function openLightEditUntitled(): Promise<void> {
const settings = await call(commands.lightEditOpenUntitled());
useProject.setState({ project: null, settings, lightEdit: true });
}
export async function closeProject() {
await commands.closeProject();
useProject.setState({ project: null, lightEdit: false });
-12
Переглянути файл
@@ -13,18 +13,6 @@ export function isVirtualUri(s: string | null | undefined): boolean {
return !!m && (m[1]!.toLowerCase() !== "file" || isClassFile(s!));
}
/** Scheme of an unsaved new-file buffer (File › New File), keyed like a virtual document. */
export const UNTITLED_SCHEME = "untitled:";
export function isUntitledUri(s: string | null | undefined): boolean {
return !!s && s.startsWith(UNTITLED_SCHEME);
}
/** Display name of an untitled buffer's URI (`untitled:Untitled-1` → `Untitled-1`); passthrough otherwise. */
export function untitledTitle(uri: string): string {
return isUntitledUri(uri) ? uri.slice(UNTITLED_SCHEME.length) : uri;
}
/** Last path segment of a URI (without query / fragment), e.g. `SpringApplication.class`. */
export function virtualName(uri: string): string {
const rest = uri.replace(/^[^:]+:(\/\/)?/, "").split(/[?#]/)[0]!;
-4
Переглянути файл
@@ -114,7 +114,6 @@ html {
--syn-type: #bcbec4;
--syn-function: #56a8f5;
--syn-function-call: #bcbec4;
--syn-shell-command: #c57633;
--syn-comment: #7a7e85;
--syn-comment-style: normal;
--syn-doc-comment: #5f826b;
@@ -559,7 +558,6 @@ html {
--syn-type: #000000;
--syn-function: #00627a;
--syn-function-call: #080808;
--syn-shell-command: #0073bf;
--syn-comment: #8c8c8c;
--syn-comment-style: italic;
--syn-doc-comment: #8c8c8c;
@@ -858,7 +856,6 @@ html {
--syn-type: #e3e4e8;
--syn-function: #7cc0ff;
--syn-function-call: #e3e4e8;
--syn-shell-command: #c57633;
--syn-comment: #a5a9b0;
--syn-doc-comment: #8fb89c;
--syn-doc-comment-tag: #8fcca3;
@@ -1247,7 +1244,6 @@ html {
--syn-type: var(--palette-type);
--syn-function: var(--palette-blue);
--syn-function-call: var(--palette-blue);
--syn-shell-command: var(--syn-function-call);
--syn-comment: var(--palette-comment);
--syn-doc-comment: var(--palette-comment);
--syn-doc-comment-tag: var(--palette-keyword);
+1 -2
Переглянути файл
@@ -16,9 +16,8 @@ const HINTS: Hint[] = [
{ label: "Drop files here to open them" },
];
/** LightEdit has no project to navigate: only opening and creating files works there. */
/** LightEdit has no project to navigate: only opening files works there. */
const LIGHT_EDIT_HINTS: Hint[] = [
{ label: "New File", action: "file.newUntitled" },
{ label: "Open File", action: "file.openFile" },
{ label: "Drop files here to open them" },
];
+1 -3
Переглянути файл
@@ -7,7 +7,6 @@
import { useEditorRegistry } from "@/editor/api";
import { Breadcrumbs } from "@/editor/core";
import { basename, useProject } from "@/platform/project";
import { isUntitledUri, untitledTitle } from "@/platform/virtualDocs";
import { useT } from "@/platform/i18n";
import { ProgressBar } from "@/ui";
import projectMark from "../../src-tauri/icons/source/openidea-mark.svg";
@@ -43,10 +42,9 @@ function LightEditFrame({ title }: { title?: string }) {
const path = useEditorRegistry((s) => s.active?.ctx.path);
const distractionFree = useDistractionFree((s) => s.active);
const statusBar = useAppearanceFlag("showStatusBar");
const name = path ? (isUntitledUri(path) ? untitledTitle(path) : basename(path)) : "LightEdit";
return (
<>
<TitleBar lightEdit compact={distractionFree} title={title ?? name} />
<TitleBar lightEdit compact={distractionFree} title={title ?? (path ? basename(path) : "LightEdit")} />
<main className={styles.lightEdit} data-light-edit>
<div className={styles.lightEditIsland}>
<EditorArea />
+1 -5
Переглянути файл
@@ -644,16 +644,12 @@ export const EDITOR_TAB_MIME = "application/x-openidea-editor-tab";
export interface EditorService {
openFile(path: string, options?: OpenOptions): Promise<void>;
openInput(input: EditorInput, options?: OpenOptions): Promise<void>;
/** File › New File: an editable untitled buffer; Save asks for a path. */
openUntitled?(options?: OpenOptions): Promise<void>;
/** Path of the focused text editor, if any. */
activePath(): string | null;
/** Doc id + cursor of the focused text editor. */
activeCursor(): { doc: number; path: string; line: number; col: number; offset: number } | null;
/** Save all dirty documents; rejects with the failures once every save was tried. */
saveAll(opts?: { skipUntitled?: boolean }): Promise<void>;
/** Doc ids of untitled buffers with unsaved changes (they follow the window, not a project). */
untitledDirtyDocumentIds?(): number[];
saveAll(): Promise<void>;
/** Includes local edits not yet acknowledged by the backend. */
dirtyDocumentIds?(): number[];
/** Move keyboard focus to the active editor (used by Escape from tool windows). */