дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-15 06:05:39 +02:00
Порівняти коміти
4
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
54e3b63547 | ||
|
|
0af3bd00e7 | ||
|
|
951e81e065 | ||
|
|
023c0a6ee6 |
No files matched your search
@@ -883,6 +883,7 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
|
||||
pub(crate) struct ProgramTerminal {
|
||||
writer: ide_core::input::InputWriter,
|
||||
killer: Box<dyn portable_pty::ChildKiller + Send + Sync>,
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pid: Option<u32>,
|
||||
/// Set once the program has exited and been reaped.
|
||||
exited: Arc<AtomicBool>,
|
||||
@@ -895,6 +896,7 @@ impl ProgramTerminal {
|
||||
}
|
||||
|
||||
/// The program's process id while it runs.
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub(crate) fn process_id(&self) -> Option<u32> {
|
||||
if self.exited.load(Ordering::Acquire) { None } else { self.pid }
|
||||
}
|
||||
|
||||
@@ -21,6 +21,28 @@ 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)
|
||||
|
||||
@@ -50,7 +50,10 @@ pub async fn doc_set_encoding(
|
||||
reload: bool,
|
||||
) -> IdeResult<DocumentEncoding> {
|
||||
let project = host.project(window)?;
|
||||
if reload && let Some(uri) = project.documents.virtual_uri(doc)? {
|
||||
if reload
|
||||
&& !project.documents.is_untitled(doc)?
|
||||
&& 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 {
|
||||
|
||||
@@ -88,6 +88,9 @@ 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
|
||||
@@ -621,6 +624,9 @@ 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);
|
||||
@@ -703,12 +709,29 @@ 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
|
||||
{
|
||||
current.watch_light_dir(&dir);
|
||||
if let Some(dir) = watch_dir {
|
||||
current.watch_light_dir(dir);
|
||||
}
|
||||
return Ok(current);
|
||||
}
|
||||
self.close_project(window);
|
||||
@@ -727,7 +750,16 @@ 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 = light_watcher(&dir, &sink, &documents, &history)?;
|
||||
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 project_key = project_key(&dir);
|
||||
let (db, http_history, agents) = self.project_services(&dir, &project_key, &settings, &sink);
|
||||
let notebooks = crate::notebook::NotebookService::new(
|
||||
@@ -773,8 +805,11 @@ impl Host {
|
||||
light: true,
|
||||
root: dir,
|
||||
});
|
||||
tracing::info!("window {window} edits {} without a project", file.display());
|
||||
tracing::info!("window {window} edits {} without a project", project.root.display());
|
||||
self.insert_project(window, project.clone());
|
||||
for (key, text, dirty) in &carried {
|
||||
let _ = project.documents.restore_untitled_buffer(key, text, *dirty);
|
||||
}
|
||||
Ok(project)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +218,13 @@ 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
|
||||
})
|
||||
|
||||
@@ -401,6 +401,7 @@ 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"
|
||||
|
||||
@@ -179,3 +179,73 @@ 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;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
; 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
|
||||
@@ -314,7 +314,7 @@ pub(crate) static SPECS: &[Spec] = &[
|
||||
name: "bash",
|
||||
language: tree_sitter_bash::LANGUAGE,
|
||||
outline: outline!("bash"),
|
||||
highlights: None,
|
||||
highlights: Some(Highlights { query: highlights!("bash"), first_wins: false }),
|
||||
fold_nodes: &["do_group", "if_statement", "case_statement", "heredoc_body"],
|
||||
fold_bodies: NONE,
|
||||
imports: NONE,
|
||||
|
||||
@@ -43,11 +43,13 @@ 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; 25] = [
|
||||
pub const ALL: [Self; 26] = [
|
||||
Self::Keyword,
|
||||
Self::String,
|
||||
Self::StringSpecial,
|
||||
@@ -73,6 +75,7 @@ impl SyntaxHighlightKind {
|
||||
Self::Operator,
|
||||
Self::Punctuation,
|
||||
Self::Parameter,
|
||||
Self::Command,
|
||||
];
|
||||
|
||||
/// The kebab-case name plugins use (`doc-comment`, `function-call`, …).
|
||||
@@ -153,6 +156,7 @@ 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)),
|
||||
@@ -408,6 +412,30 @@ 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 [
|
||||
|
||||
@@ -193,6 +193,12 @@ 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 {
|
||||
@@ -529,6 +535,75 @@ 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();
|
||||
@@ -740,6 +815,90 @@ 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(®, &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();
|
||||
@@ -894,9 +1053,12 @@ 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() {
|
||||
if st.uri.is_some() && !is_untitled(&st.path) {
|
||||
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 {
|
||||
@@ -937,6 +1099,42 @@ 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,
|
||||
@@ -967,7 +1165,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.uri.is_some() || st.read_only {
|
||||
if st.read_only || (st.uri.is_some() && !is_untitled(&st.path)) {
|
||||
return Err(read_only_error(&st.path));
|
||||
}
|
||||
if st.encoding != "utf-8" || st.native_to_ascii {
|
||||
|
||||
@@ -1004,3 +1004,104 @@ 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");
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
"--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",
|
||||
|
||||
@@ -332,7 +332,7 @@ a token.
|
||||
|
||||
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
|
||||
|
||||
266 themeable tokens: 125 in the UI theme file, 141 in the editor colour scheme file.
|
||||
267 themeable tokens: 125 in the UI theme file, 142 in the editor colour scheme file.
|
||||
|
||||
### Surfaces (UI theme)
|
||||
|
||||
@@ -429,6 +429,7 @@ 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` |
|
||||
|
||||
@@ -119,6 +119,33 @@ 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)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,31 @@ 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]
|
||||
|
||||
@@ -106,6 +106,7 @@ 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,
|
||||
@@ -198,9 +199,11 @@ 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,
|
||||
|
||||
@@ -217,6 +217,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
}
|
||||
|
||||
let mut file = vec![
|
||||
act("file.newUntitled", "New Untitled File"),
|
||||
sub(
|
||||
"new",
|
||||
"New",
|
||||
|
||||
@@ -170,6 +170,12 @@ 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) {
|
||||
|
||||
+18
-1
@@ -75,6 +75,11 @@ 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,
|
||||
@@ -325,6 +330,11 @@ 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
|
||||
@@ -332,6 +342,11 @@ 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 })),
|
||||
@@ -10182,7 +10197,9 @@ 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";
|
||||
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";
|
||||
|
||||
/** Highlighted ranges of a whole document, packed like LSP semantic tokens. */
|
||||
export type SyntaxHighlights = {
|
||||
|
||||
@@ -532,6 +532,15 @@ 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 =
|
||||
|
||||
@@ -41,6 +41,10 @@ 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>;
|
||||
@@ -79,6 +83,8 @@ 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))),
|
||||
@@ -384,6 +390,14 @@ 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,
|
||||
@@ -504,6 +518,47 @@ 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);
|
||||
|
||||
@@ -593,8 +593,10 @@ describe("Angular templates", () => {
|
||||
moduleStyle: "es",
|
||||
typeScript: true,
|
||||
});
|
||||
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
|
||||
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
|
||||
// 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")));
|
||||
});
|
||||
|
||||
it("is the Angular Template mode", async () => {
|
||||
|
||||
@@ -153,8 +153,10 @@ describe("Astro components", () => {
|
||||
moduleStyle: "es",
|
||||
typeScript: true,
|
||||
});
|
||||
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
|
||||
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
|
||||
// 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")));
|
||||
});
|
||||
|
||||
it("is the Astro mode", async () => {
|
||||
|
||||
@@ -168,8 +168,10 @@ describe("Django template grammar", () => {
|
||||
moduleStyle: "es",
|
||||
typeScript: true,
|
||||
});
|
||||
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
|
||||
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
|
||||
// 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")));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ 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);
|
||||
@@ -60,6 +62,7 @@ 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)),
|
||||
|
||||
@@ -237,8 +237,10 @@ describe("JSP pages", () => {
|
||||
moduleStyle: "es",
|
||||
typeScript: true,
|
||||
});
|
||||
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
|
||||
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
|
||||
// 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")));
|
||||
});
|
||||
|
||||
it("is the JSP mode", async () => {
|
||||
|
||||
@@ -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 } from "./highlight";
|
||||
import { openideaHighlightStyle, parameter, shellCommand } from "./highlight";
|
||||
import { findLanguageDescription, loadSupport } from "./languages";
|
||||
import { setSyntaxHighlights, syntaxHighlightField } from "./syntaxHighlight";
|
||||
|
||||
@@ -46,6 +46,14 @@ 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({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Tree-sitter highlighting (`doc_highlights`) for languages without a
|
||||
* CodeMirror mode (Elixir, ...) or a limited stream mode (Kotlin): backend kinds → Lezer tags, so the colours
|
||||
* CodeMirror mode (Elixir, ...) or a limited stream mode (Kotlin, Shell): 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 } from "./highlight";
|
||||
import { parameter, shellCommand } from "./highlight";
|
||||
import type { SyntaxHighlightKind, SyntaxHighlights } from "@/platform/ipc";
|
||||
|
||||
const TAGS: Record<SyntaxHighlightKind, Tag> = {
|
||||
@@ -38,6 +38,7 @@ const TAGS: Record<SyntaxHighlightKind, Tag> = {
|
||||
tag: t.tagName,
|
||||
operator: t.operator,
|
||||
punctuation: t.punctuation,
|
||||
command: shellCommand,
|
||||
};
|
||||
|
||||
export const setSyntaxHighlights = StateEffect.define<SyntaxHighlights>();
|
||||
|
||||
@@ -11,10 +11,11 @@ import { create } from "zustand";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { isSameOrInside } from "@/platform/paths";
|
||||
import { basename, useProject } from "@/platform/project";
|
||||
import { basename, dirname, useProject } from "@/platform/project";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
import {
|
||||
_emitEditorEvent,
|
||||
_setEditorSwitching,
|
||||
_provideNavigationHistory,
|
||||
_provideOpenEditorPaths,
|
||||
_setActiveEditor,
|
||||
@@ -44,8 +45,10 @@ import {
|
||||
import { useDistractionFree } from "@/workbench/distractionFree";
|
||||
import { restoreMaximizedToolWindow } from "@/workbench/toolwindows/store";
|
||||
import { ask, confirm } from "@/workbench/messages";
|
||||
import { t } from "@/platform/i18n";
|
||||
import { t, tx } 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";
|
||||
@@ -132,9 +135,16 @@ export const virtualInput = (
|
||||
data: { title: vd.title, description: vd.description, decompiled: vd.decompiled } satisfies VirtualInputData,
|
||||
});
|
||||
|
||||
/** Inputs shown in the text editor: files and virtual documents. */
|
||||
/**
|
||||
* 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. */
|
||||
export function isTextInput(input: EditorInput): input is EditorInput & { path: string } {
|
||||
return (input.kind === "file" || input.kind === "virtual") && !!input.path;
|
||||
return (input.kind === "file" || input.kind === "virtual" || input.kind === "untitled") && !!input.path;
|
||||
}
|
||||
|
||||
export const useTabs = create<TabsState>(() => initialTabsState());
|
||||
@@ -159,6 +169,29 @@ 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. */
|
||||
@@ -176,13 +209,22 @@ export function openFilePaths(s: TabsState): string[] {
|
||||
|
||||
/** Editor Tabs › Sort tabs alphabetically: by file name. */
|
||||
function tabSortKey(input: EditorInput): string {
|
||||
return basename(input.path ?? input.key);
|
||||
return basename(untitledTitle(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 {
|
||||
@@ -196,6 +238,7 @@ 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);
|
||||
@@ -208,6 +251,7 @@ 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 {
|
||||
@@ -273,6 +317,7 @@ export class EditorController implements SlotHost {
|
||||
{
|
||||
settings: () => useProject.getState().settings ?? useProject.getState().project?.settings,
|
||||
notify: (n) => notifications().notify(n),
|
||||
saveAs: (model) => this.saveUntitled(model),
|
||||
},
|
||||
scheduler,
|
||||
);
|
||||
@@ -330,14 +375,45 @@ 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();
|
||||
@@ -426,6 +502,7 @@ 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);
|
||||
@@ -524,6 +601,8 @@ 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();
|
||||
@@ -1128,6 +1207,86 @@ 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,
|
||||
@@ -1450,6 +1609,36 @@ 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;
|
||||
@@ -1840,8 +2029,11 @@ export class EditorController implements SlotHost {
|
||||
if (errors.length) throw combinedError(errors);
|
||||
}
|
||||
|
||||
async saveAll(): Promise<void> {
|
||||
const results = await Promise.allSettled([this.manager.saveAll("explicit"), this.saveCustomDocuments()]);
|
||||
async saveAll(opts?: { skipUntitled?: boolean }): Promise<void> {
|
||||
const results = await Promise.allSettled([
|
||||
this.manager.saveAll("explicit", opts?.skipUntitled),
|
||||
this.saveCustomDocuments(),
|
||||
]);
|
||||
const errors = failures(results);
|
||||
if (errors.length) throw combinedError(errors);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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";
|
||||
@@ -42,10 +43,13 @@ 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: () => ctrl.saveAll(),
|
||||
saveAll: (opts) => ctrl.saveAll(opts),
|
||||
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(),
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
useEditorRegistry,
|
||||
type EditorDocContext,
|
||||
} from "@/editor/api";
|
||||
import { isVirtualUri } from "@/platform/virtualDocs";
|
||||
import { isUntitledUri, isVirtualUri } from "@/platform/virtualDocs";
|
||||
import { DocSync, type Scheduler, frameScheduler } from "../sync/DocSync";
|
||||
import type { DocBackend, LanguageInfo } from "../backend";
|
||||
import {
|
||||
@@ -98,6 +98,11 @@ 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;
|
||||
@@ -204,7 +209,7 @@ export function saveCleanupChanges(
|
||||
export class DocModel {
|
||||
readonly doc: DocId;
|
||||
/** File path, or the URI of a virtual (read-only, language-server provided) document. */
|
||||
readonly path: string;
|
||||
path: string;
|
||||
readonly virtual: boolean;
|
||||
readonly sync: DocSync;
|
||||
readonly slots = new Set<EditorSlot>();
|
||||
@@ -234,7 +239,7 @@ export class DocModel {
|
||||
) {
|
||||
this.doc = info.id;
|
||||
this.path = info.path;
|
||||
this.virtual = !!info.uri;
|
||||
this.virtual = !!info.uri && !isUntitledUri(info.path);
|
||||
this.info = languageInfo(info.language);
|
||||
this.editorconfig = info.editorconfig ?? null;
|
||||
// `large`: the backend skips LSP and syntax trees for it; heavy UI features follow.
|
||||
@@ -574,8 +579,7 @@ export class DocModel {
|
||||
const [folds, outline, highlights] = await Promise.allSettled([
|
||||
backend.folding(this.doc),
|
||||
backend.outline(this.doc),
|
||||
// Kotlin's stream mode lacks declaration categories; the grammar supplements it.
|
||||
!this.desc || this.meta.language === "kotlin" ? backend.highlights(this.doc) : null,
|
||||
!this.desc || GRAMMAR_SUPPLEMENTED.has(this.meta.language) ? backend.highlights(this.doc) : null,
|
||||
]);
|
||||
if (this.closed) return;
|
||||
if (counter !== this.changeCounter) {
|
||||
@@ -668,6 +672,14 @@ 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 });
|
||||
@@ -677,6 +689,31 @@ 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);
|
||||
@@ -687,6 +724,11 @@ 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 {
|
||||
@@ -749,6 +791,29 @@ 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);
|
||||
@@ -764,7 +829,11 @@ export class DocumentManager {
|
||||
})
|
||||
.catch(() => {});
|
||||
const [info] = await Promise.all([
|
||||
isVirtualUri(path) ? this.backend.openVirtual(path) : this.backend.open(path),
|
||||
isUntitledUri(path)
|
||||
? this.backend.openUntitled(path)
|
||||
: isVirtualUri(path)
|
||||
? this.backend.openVirtual(path)
|
||||
: this.backend.open(path),
|
||||
catalog,
|
||||
]);
|
||||
const raced = this.byPath.get(path);
|
||||
@@ -829,14 +898,16 @@ export class DocumentManager {
|
||||
}
|
||||
|
||||
/** Saves dirty documents; failures are notified, and an explicit Save All also rejects with them. */
|
||||
async saveAll(reason: "explicit" | "auto") {
|
||||
async saveAll(reason: "explicit" | "auto", skipUntitled = false) {
|
||||
const errors: unknown[] = [];
|
||||
await Promise.all(
|
||||
[...this.byPath.values()].map((m) =>
|
||||
m.save(reason).catch((e) => {
|
||||
errors.push(e);
|
||||
}),
|
||||
),
|
||||
[...this.byPath.values()]
|
||||
.filter((m) => !(skipUntitled && isUntitledUri(m.path)))
|
||||
.map((m) =>
|
||||
m.save(reason).catch((e) => {
|
||||
errors.push(e);
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (errors.length) {
|
||||
const e = errors[0];
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 { isVirtualUri, invalidateVirtualDocument } from "@/platform/virtualDocs";
|
||||
import { isUntitledUri, isVirtualUri, invalidateVirtualDocument, untitledTitle } from "@/platform/virtualDocs";
|
||||
import { editorController, isTextInput, useEditorStatus, useGotoLine, useTabs } from "../controller";
|
||||
import { useDocMeta, useOutline } from "../model/document";
|
||||
import { symbolChain } from "./outline";
|
||||
@@ -76,6 +76,11 @@ 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;
|
||||
|
||||
@@ -262,3 +262,14 @@ 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();
|
||||
});
|
||||
@@ -12,12 +12,17 @@ export function LightEditStatus() {
|
||||
const t = useT();
|
||||
const lightEdit = useProject((s) => s.lightEdit);
|
||||
const path = useEditorRegistry((s) => s.active?.ctx.path);
|
||||
if (!lightEdit || !path || isVirtualUri(path)) return null;
|
||||
if (!lightEdit) return null;
|
||||
const hasRealFile = !!path && !isVirtualUri(path);
|
||||
return (
|
||||
<BarButton
|
||||
emphasis
|
||||
tooltip={t("app.lightEdit.openProjectTooltip", { folder: dirname(path) })}
|
||||
onClick={() => void executeAction(LIGHT_EDIT_OPEN_PROJECT)}
|
||||
tooltip={
|
||||
hasRealFile
|
||||
? t("app.lightEdit.openProjectTooltip", { folder: dirname(path) })
|
||||
: t("app.lightEdit.pickFolderTooltip")
|
||||
}
|
||||
onClick={() => void executeAction(hasRealFile ? LIGHT_EDIT_OPEN_PROJECT : "file.openProject")}
|
||||
>
|
||||
<Icon name="folder-open" size={14} />
|
||||
{t("app.lightEdit.openProject")}
|
||||
|
||||
@@ -279,6 +279,13 @@ 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,
|
||||
@@ -286,10 +293,10 @@ export async function confirmLeave(
|
||||
confirmExit: false,
|
||||
confirmTerminate,
|
||||
processes: confirmTerminate ? await processNames(deps) : [],
|
||||
dirty: dirtyCount(),
|
||||
dirty: dirtyCount() - untitled.size,
|
||||
project,
|
||||
},
|
||||
deps,
|
||||
saveDeps,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
confirmLeaveProject,
|
||||
dirname,
|
||||
openLightEdit,
|
||||
openLightEditUntitled,
|
||||
openProject,
|
||||
provideStartupLaunch,
|
||||
useProject,
|
||||
@@ -26,6 +27,7 @@ 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";
|
||||
@@ -480,6 +482,20 @@ 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",
|
||||
@@ -578,7 +594,9 @@ async function closeThisWindow(): Promise<void> {
|
||||
export function installLightEditAutoClose(closeWindow: () => Promise<void> = closeThisWindow): () => void {
|
||||
const editor = maybeEditorService();
|
||||
const off = editor?.onActiveInputChange?.((input) => {
|
||||
if (input || !useProject.getState().lightEdit || editor?.openTabs?.().length) return;
|
||||
// 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;
|
||||
void closeWindow().catch((e) => console.warn("could not close the window", e));
|
||||
});
|
||||
return () => off?.();
|
||||
|
||||
@@ -198,6 +198,7 @@ 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"]],
|
||||
|
||||
@@ -33,6 +33,9 @@ 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>
|
||||
@@ -45,6 +48,9 @@ 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>
|
||||
@@ -81,6 +87,12 @@ 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"
|
||||
@@ -105,6 +117,12 @@ 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}>
|
||||
|
||||
@@ -364,6 +364,7 @@ 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" }),
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
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;
|
||||
|
||||
@@ -182,7 +182,8 @@ export const vscodeMacos: Record<string, string[]> = {
|
||||
|
||||
// File / editor tabs
|
||||
"file.save": ["Meta+S"],
|
||||
"file.new": ["Meta+N"],
|
||||
"file.newUntitled": ["Meta+N"],
|
||||
"file.new": [],
|
||||
"file.settings": ["Meta+,"],
|
||||
"file.projectStructure": [],
|
||||
"file.openProject": ["Meta+O"],
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"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…",
|
||||
@@ -49,6 +50,7 @@
|
||||
"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}?",
|
||||
@@ -60,8 +62,10 @@
|
||||
"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",
|
||||
@@ -199,6 +203,7 @@
|
||||
"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…",
|
||||
|
||||
@@ -1431,6 +1431,7 @@
|
||||
"{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",
|
||||
|
||||
@@ -783,6 +783,7 @@
|
||||
"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": "无法打开链接",
|
||||
@@ -2502,6 +2503,7 @@
|
||||
"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": "切换到其他应用程序或内置终端时保存文件",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"app.action.cut": "剪切",
|
||||
"app.action.keymapReference": "快捷键参考",
|
||||
"app.action.lightEditOpenProject": "将所在文件夹作为项目打开",
|
||||
"app.action.newFile": "新建无标题文件",
|
||||
"app.action.newWindow": "新建窗口",
|
||||
"app.action.nextWindow": "下一个项目窗口",
|
||||
"app.action.openFile": "打开文件…",
|
||||
@@ -49,6 +50,7 @@
|
||||
"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}?",
|
||||
@@ -60,8 +62,10 @@
|
||||
"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": "取消",
|
||||
@@ -199,6 +203,7 @@
|
||||
"menu.workspace.create": "工作区…",
|
||||
"menu.workspace.open": "打开工作区…",
|
||||
"menu.workspace.manage": "管理工作区…",
|
||||
"menu.file.newUntitled": "新建无标题文件",
|
||||
"menu.file.openInNewWindow": "在新窗口中打开…",
|
||||
"menu.file.openFile": "打开文件…",
|
||||
"menu.remote.connect": "远程开发…",
|
||||
|
||||
@@ -79,6 +79,12 @@ 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 });
|
||||
|
||||
@@ -13,6 +13,18 @@ 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]!;
|
||||
|
||||
@@ -114,6 +114,7 @@ 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;
|
||||
@@ -558,6 +559,7 @@ 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;
|
||||
@@ -856,6 +858,7 @@ 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;
|
||||
@@ -1244,6 +1247,7 @@ 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);
|
||||
|
||||
@@ -16,8 +16,9 @@ const HINTS: Hint[] = [
|
||||
{ label: "Drop files here to open them" },
|
||||
];
|
||||
|
||||
/** LightEdit has no project to navigate: only opening files works there. */
|
||||
/** LightEdit has no project to navigate: only opening and creating 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" },
|
||||
];
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
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";
|
||||
@@ -42,9 +43,10 @@ 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 ?? (path ? basename(path) : "LightEdit")} />
|
||||
<TitleBar lightEdit compact={distractionFree} title={title ?? name} />
|
||||
<main className={styles.lightEdit} data-light-edit>
|
||||
<div className={styles.lightEditIsland}>
|
||||
<EditorArea />
|
||||
|
||||
@@ -644,12 +644,16 @@ 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(): Promise<void>;
|
||||
saveAll(opts?: { skipUntitled?: boolean }): Promise<void>;
|
||||
/** Doc ids of untitled buffers with unsaved changes (they follow the window, not a project). */
|
||||
untitledDirtyDocumentIds?(): number[];
|
||||
/** Includes local edits not yet acknowledged by the backend. */
|
||||
dirtyDocumentIds?(): number[];
|
||||
/** Move keyboard focus to the active editor (used by Escape from tool windows). */
|
||||
|
||||
Reference in new issue
Заблокувати користувача