diff --git a/crates/ide-host/src/ops/doc/commands.rs b/crates/ide-host/src/ops/doc/commands.rs index ad1019036..d5e082604 100644 --- a/crates/ide-host/src/ops/doc/commands.rs +++ b/crates/ide-host/src/ops/doc/commands.rs @@ -5,7 +5,7 @@ use crate::rpc::Dispatcher; use ide_core::{DocId, IdeError, IdeResult, OutlineItem, Range}; use ide_language::{FoldRange, SyntaxHighlights}; use ide_text::LineEnding; -use ide_workspace::{DocumentInfo, HistoryEntry}; +use ide_workspace::{DocumentEncoding, DocumentInfo, HistoryEntry}; use std::path::PathBuf; /// Runs blocking work (disk I/O, parsing, large copies) on the blocking pool, @@ -29,18 +29,24 @@ pub async fn doc_ui_mode(host: &Host, window: &str, path: PathBuf) -> IdeResult< blocking(move || Ok(project.documents.ui_mode_of(&path))).await } -/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label. +/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding and BOM state. pub async fn doc_set_encoding( host: &Host, window: &str, doc: DocId, encoding: String, reload: bool, -) -> IdeResult { +) -> IdeResult { let project = host.project(window)?; host.blocking(move || project.documents.set_encoding(doc, &encoding, reload)).await } +/// Adds or removes the BOM on a UTF-8 document; the change is written on save. +pub async fn doc_set_bom(host: &Host, window: &str, doc: DocId, has_bom: bool) -> IdeResult { + let project = host.project(window)?; + host.blocking(move || project.documents.set_bom(doc, has_bom)).await +} + pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: LineEnding) -> IdeResult<()> { host.project(window)?.documents.set_line_ending(doc, line_ending) } @@ -175,6 +181,7 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("doc_set_encoding", |c| async move { doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?).await }); + d.add("doc_set_bom", |c| async move { doc_set_bom(&c.host, &c.window, c.arg("doc")?, c.arg("hasBom")?).await }); d.add("doc_set_line_ending", |c| async move { blocking(move || doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)).await }); diff --git a/crates/ide-workspace/src/documents.rs b/crates/ide-workspace/src/documents.rs index e8801bd11..f7ebbf43d 100644 --- a/crates/ide-workspace/src/documents.rs +++ b/crates/ide-workspace/src/documents.rs @@ -21,6 +21,7 @@ pub struct DocumentInfo { pub version: u32, pub language: LanguageId, pub encoding: String, + pub has_bom: bool, pub line_ending: LineEnding, pub read_only: bool, pub dirty: bool, @@ -41,6 +42,14 @@ pub struct DocumentInfo { pub ui_mode: Option, } +/// Encoding and BOM currently applied to an open document. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DocumentEncoding { + pub encoding: String, + pub has_bom: bool, +} + /// Current text and identity of a document, for analyses (outline, folding). #[derive(Clone, Debug)] pub struct TextSnapshot { @@ -326,6 +335,7 @@ impl Documents { version: st.doc.version(), language: st.language.clone(), encoding: st.encoding.clone(), + has_bom: st.has_bom, line_ending: st.doc.line_ending(), read_only: st.read_only, dirty: st.dirty, @@ -822,7 +832,7 @@ impl Documents { /// Changes the document's encoding. `reload`: re-decode the file on disk with /// it (refused while there are unsaved changes); otherwise convert: the text /// is kept and the next save writes it in the new encoding. - pub fn set_encoding(&self, doc: DocId, encoding: &str, reload: bool) -> IdeResult { + pub fn set_encoding(&self, doc: DocId, encoding: &str, reload: bool) -> IdeResult { self.with_doc(doc, |st| { if st.uri.is_some() { return Err(read_only_error(&st.path)); @@ -842,7 +852,7 @@ impl Documents { self.replace_locked(doc, st, &loaded.text)?; // Reloading is not an edit: the buffer matches the file again. self.set_dirty(doc, st, false); - return Ok(loaded.encoding); + return Ok(DocumentEncoding { encoding: st.encoding.clone(), has_bom: st.has_bom }); } if st.read_only { return Err(read_only_error(&st.path)); @@ -856,7 +866,24 @@ impl Documents { st.encoding = encoding.to_owned(); st.has_bom = has_bom; self.set_dirty(doc, st, true); - Ok(st.encoding.clone()) + Ok(DocumentEncoding { encoding: st.encoding.clone(), has_bom: st.has_bom }) + }) + } + + /// 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 { + self.with_doc(doc, |st| { + if st.uri.is_some() || st.read_only { + return Err(read_only_error(&st.path)); + } + if st.encoding != "utf-8" || st.native_to_ascii { + return Err(IdeError::invalid("the BOM can only be changed for UTF-8 documents")); + } + if st.has_bom != has_bom { + st.has_bom = has_bom; + self.set_dirty(doc, st, true); + } + Ok(DocumentEncoding { encoding: st.encoding.clone(), has_bom: st.has_bom }) }) } diff --git a/crates/ide-workspace/src/lib.rs b/crates/ide-workspace/src/lib.rs index a84c52fb7..8118ff351 100644 --- a/crates/ide-workspace/src/lib.rs +++ b/crates/ide-workspace/src/lib.rs @@ -21,7 +21,7 @@ pub mod unity; pub mod window_groups; pub mod workspace; -pub use documents::{DetectLanguage, DocumentInfo, Documents, TextSnapshot}; +pub use documents::{DetectLanguage, DocumentEncoding, DocumentInfo, Documents, TextSnapshot}; pub use editorconfig::{EditorConfig, EditorConfigProps}; pub use file_defaults::FileDefaults; pub use file_moves::{FileMove, FileMoves, UndoOutcome}; diff --git a/crates/ide-workspace/tests/documents.rs b/crates/ide-workspace/tests/documents.rs index 794af1cf6..2eb3d0f12 100644 --- a/crates/ide-workspace/tests/documents.rs +++ b/crates/ide-workspace/tests/documents.rs @@ -486,11 +486,15 @@ fn reload_and_convert_encoding() { let (bytes, _, _) = encoding_rs::GBK.encode("中文内容"); fs::write(&path, &bytes).unwrap(); let info = f.docs.open(&path).unwrap(); - assert_eq!(f.docs.set_encoding(info.id, "gbk", true).unwrap(), "gbk"); + let encoding = f.docs.set_encoding(info.id, "gbk", true).unwrap(); + assert_eq!(encoding.encoding, "gbk"); + assert!(!encoding.has_bom); assert_eq!(f.docs.snapshot(info.id).unwrap().text, "中文内容"); // Convert: same text, written in the new encoding on save. - assert_eq!(f.docs.set_encoding(info.id, "utf-8", false).unwrap(), "utf-8"); + let encoding = f.docs.set_encoding(info.id, "utf-8", false).unwrap(); + assert_eq!(encoding.encoding, "utf-8"); + assert!(!encoding.has_bom); f.docs.save(info.id).unwrap(); assert_eq!(fs::read_to_string(&path).unwrap(), "中文内容"); @@ -501,6 +505,29 @@ fn reload_and_convert_encoding() { assert!(f.docs.set_encoding(info.id, "gbk", true).is_err()); } +#[test] +fn utf8_bom_is_added_and_removed_on_save() { + let f = fixture(); + let path = f.root.join("bom.txt"); + fs::write(&path, "text").unwrap(); + let info = f.docs.open(&path).unwrap(); + assert!(!info.has_bom); + + assert!(f.docs.set_bom(info.id, true).unwrap().has_bom); + assert_eq!(fs::read(&path).unwrap(), b"text", "written on save only"); + f.docs.save(info.id).unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"\xEF\xBB\xBFtext"); + assert_eq!(f.docs.snapshot(info.id).unwrap().text, "text"); + + assert!(!f.docs.set_bom(info.id, false).unwrap().has_bom); + f.docs.save(info.id).unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"text"); + + // Other encodings have no BOM to toggle. + f.docs.set_encoding(info.id, "gbk", false).unwrap(); + assert!(f.docs.set_bom(info.id, true).is_err()); +} + #[cfg(unix)] #[test] fn symlinked_path_maps_to_same_document() { diff --git a/src-tauri/src/commands/doc.rs b/src-tauri/src/commands/doc.rs index c2a3ef905..62f414b52 100644 --- a/src-tauri/src/commands/doc.rs +++ b/src-tauri/src/commands/doc.rs @@ -6,7 +6,7 @@ use ide_core::{ChangeSet, DocId, IdeResult, OutlineItem, Range}; pub use ide_host::ops::doc::*; use ide_language::{FoldRange, SyntaxHighlights}; use ide_text::LineEnding; -use ide_workspace::{DocumentInfo, HistoryEntry}; +use ide_workspace::{DocumentEncoding, DocumentInfo, HistoryEntry}; use std::path::PathBuf; use tauri::{Manager, State, Window}; @@ -87,7 +87,7 @@ pub async fn doc_ui_mode(window: Window, state: State<'_, AppState>, path: PathB ide_host::ops::doc::doc_ui_mode(&state.host, window.label(), path).await } -/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label. +/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding and BOM state. #[tauri::command] #[specta::specta] pub async fn doc_set_encoding( @@ -96,10 +96,21 @@ pub async fn doc_set_encoding( doc: DocId, encoding: String, reload: bool, -) -> IdeResult { +) -> IdeResult { ide_host::ops::doc::doc_set_encoding(&state.host, window.label(), doc, encoding, reload).await } +#[tauri::command] +#[specta::specta] +pub async fn doc_set_bom( + window: Window, + state: State<'_, AppState>, + doc: DocId, + has_bom: bool, +) -> IdeResult { + ide_host::ops::doc::doc_set_bom(&state.host, window.label(), doc, has_bom).await +} + #[tauri::command] #[specta::specta] pub async fn doc_set_line_ending( diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 32e249d45..354cc18ac 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -194,6 +194,7 @@ pub fn builder() -> tauri_specta::Builder { doc::doc_snapshot, doc::doc_ui_mode, doc::doc_set_encoding, + doc::doc_set_bom, doc::doc_set_line_ending, doc::doc_outline, doc::doc_folding, diff --git a/src/bindings.ts b/src/bindings.ts index 1f63a69b4..0eb2b7800 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -298,8 +298,9 @@ export const commands = { * without opening it: previews of closed files. */ docUiMode: (path: string) => typedError(__TAURI_INVOKE("doc_ui_mode", { path })), - /** "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label. */ - docSetEncoding: (doc: DocId, encoding: string, reload: boolean) => typedError(__TAURI_INVOKE("doc_set_encoding", { doc, encoding, reload })), + /** "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding and BOM state. */ + docSetEncoding: (doc: DocId, encoding: string, reload: boolean) => typedError(__TAURI_INVOKE("doc_set_encoding", { doc, encoding, reload })), + docSetBom: (doc: DocId, hasBom: boolean) => typedError(__TAURI_INVOKE("doc_set_bom", { doc, hasBom })), docSetLineEnding: (doc: DocId, lineEnding: LineEnding) => typedError(__TAURI_INVOKE("doc_set_line_ending", { doc, lineEnding })), /** * Structure view: LSP document symbols when available (with tree-sitter @@ -4016,6 +4017,12 @@ export type DockerVolume = { labels?: { [key in string]: string }, }; +/** Encoding and BOM currently applied to an open document. */ +export type DocumentEncoding = { + encoding: string, + hasBom: boolean, +}; + /** What the UI needs to show an opened document. */ export type DocumentInfo = { id: DocId, @@ -4024,6 +4031,7 @@ export type DocumentInfo = { version: number, language: LanguageId, encoding: string, + hasBom: boolean, lineEnding: LineEnding, readOnly: boolean, dirty: boolean, diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts index 4286a448b..456d234a7 100644 --- a/src/editor/core/backend.ts +++ b/src/editor/core/backend.ts @@ -12,6 +12,7 @@ import { isTauri, onIdeEvent, type DocId, + type DocumentEncoding, type DocumentInfo, type FoldRange, type LanguageInfo_Serialize, @@ -47,8 +48,10 @@ export interface DocBackend { syncFromDisk?(): Promise; snapshot(doc: DocId): Promise; setLineEnding(doc: DocId, lineEnding: LineEnding): Promise; - /** "Reload with Encoding" (`reload`) or "Convert to Encoding"; resolves to the new encoding. */ - setEncoding(doc: DocId, encoding: string, reload: boolean): Promise; + /** "Reload with Encoding" (`reload`) or "Convert to Encoding"; resolves to the new encoding and BOM state. */ + setEncoding(doc: DocId, encoding: string, reload: boolean): Promise; + /** Adds or removes the BOM on a UTF-8 document; the change is written on save. */ + setBom(doc: DocId, hasBom: boolean): Promise; outline(doc: DocId): Promise; folding(doc: DocId): Promise; /** Tree-sitter highlighting, for languages without a CodeMirror mode. */ @@ -83,6 +86,7 @@ export const ipcBackend: DocBackend = { snapshot: (doc) => call(commands.docSnapshot(doc)), setLineEnding: async (doc, le) => void (await call(commands.docSetLineEnding(doc, le))), setEncoding: (doc, encoding, reload) => call(commands.docSetEncoding(doc, encoding, reload)), + setBom: (doc, hasBom) => call(commands.docSetBom(doc, hasBom)), outline: (doc) => call(commands.docOutline(doc)), folding: (doc) => call(commands.docFolding(doc)), highlights: (doc) => call(commands.docHighlights(doc)), @@ -108,9 +112,11 @@ interface MemDoc { path: string; text: string; saved: string; + savedBom: boolean; version: number; refs: number; lineEnding: LineEnding; + hasBom: boolean; readOnly: boolean; language: string; uiMode?: string; @@ -377,6 +383,7 @@ export function createMemoryBackend(files: Record = {}): MemoryB const dirtyHandlers = new Set<(e: { doc: DocId; dirty: boolean }) => void>(); let nextId = 1; const store = new Map(Object.entries(files)); + const savedBoms = new Map(); const info = (d: MemDoc): DocumentInfo => ({ id: d.id, @@ -385,9 +392,10 @@ export function createMemoryBackend(files: Record = {}): MemoryB version: d.version, language: d.language, encoding: "UTF-8", + hasBom: d.hasBom, lineEnding: d.lineEnding, readOnly: d.readOnly, - dirty: d.text !== d.saved, + dirty: d.text !== d.saved || d.hasBom !== d.savedBom, ...(d.uri ? { uri: d.uri } : {}), ...(d.uiMode ? { uiMode: d.uiMode } : {}), }); @@ -411,7 +419,7 @@ export function createMemoryBackend(files: Record = {}): MemoryB return d; }; const setDirty = (d: MemDoc, before: boolean) => { - const now = d.text !== d.saved; + const now = d.text !== d.saved || d.hasBom !== d.savedBom; if (now !== before) dirtyHandlers.forEach((h) => h({ doc: d.id, dirty: now })); }; const delay = (v: () => T): Promise => @@ -439,15 +447,18 @@ export function createMemoryBackend(files: Record = {}): MemoryB return info(d); } const text = store.get(path) ?? sampleFor(path); + const hasBom = savedBoms.get(path) ?? false; const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase(); const d: MemDoc = { id: nextId++, path, text, saved: text, + savedBom: hasBom, version: 0, refs: 1, lineEnding: "lf", + hasBom, readOnly: false, language: EXT_LANG[ext] ?? "plaintext", }; @@ -473,9 +484,11 @@ export function createMemoryBackend(files: Record = {}): MemoryB path: uri, text, saved: text, + savedBom: false, version: 0, refs: 1, lineEnding: "lf", + hasBom: false, readOnly: true, language: ext === "class" ? "java" : (EXT_LANG[ext] ?? "plaintext"), uri, @@ -496,7 +509,7 @@ export function createMemoryBackend(files: Record = {}): MemoryB if (d.readOnly) throw new IpcError({ kind: "permissionDenied", message: "read-only" }); if (baseVersion !== d.version) throw new IpcError({ kind: "outOfSync", message: `base ${baseVersion}, document at ${d.version}` }); - const before = d.text !== d.saved; + const before = d.text !== d.saved || d.hasBom !== d.savedBom; d.text = applyChanges(d.text, changes); d.version++; setDirty(d, before); @@ -505,9 +518,11 @@ export function createMemoryBackend(files: Record = {}): MemoryB }, async save(doc) { const d = get(doc); - const before = d.text !== d.saved; + const before = d.text !== d.saved || d.hasBom !== d.savedBom; d.saved = d.text; + d.savedBom = d.hasBom; store.set(d.path, d.text); + savedBoms.set(d.path, d.hasBom); setDirty(d, before); }, async snapshot(doc) { @@ -516,8 +531,16 @@ export function createMemoryBackend(files: Record = {}): MemoryB async setLineEnding(doc, le) { get(doc).lineEnding = le; }, - async setEncoding(_doc, encoding) { - return encoding; + async setEncoding(doc, encoding) { + return { encoding, hasBom: get(doc).hasBom }; + }, + async setBom(doc, hasBom) { + const d = get(doc); + if (d.readOnly) throw new IpcError({ kind: "permissionDenied", message: "read-only" }); + const before = d.text !== d.saved || d.hasBom !== d.savedBom; + d.hasBom = hasBom; + setDirty(d, before); + return { encoding: "utf-8", hasBom: d.hasBom }; }, async outline(doc) { // Brace-based outline good enough for the dev shell. diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 49c2cedff..5a3f5fa20 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -1463,7 +1463,7 @@ export class EditorController implements SlotHost { } try { const encoding = await this.manager.backend.setEncoding(meta.doc, meta.encoding, true); - model.setMeta({ encoding }); + model.setMeta({ encoding: encoding.encoding, hasBom: encoding.hasBom }); } catch (e) { notifications().notify({ level: "error", diff --git a/src/editor/core/model/document.ts b/src/editor/core/model/document.ts index a387a3934..092644a1a 100644 --- a/src/editor/core/model/document.ts +++ b/src/editor/core/model/document.ts @@ -82,6 +82,7 @@ export interface DocMeta { language: string; uiMode: string | null; encoding: string; + hasBom: boolean; lineEnding: LineEnding; readOnly: boolean; dirty: boolean; @@ -248,6 +249,7 @@ export class DocModel { language: info.language, uiMode: uiMode ?? this.desc?.name ?? null, encoding: info.encoding, + hasBom: info.hasBom, lineEnding: info.lineEnding, // Library sources (External Libraries) open read-only, like IDEA's. readOnly: info.readOnly || (!this.virtual && isLibraryPath(info.path)), diff --git a/src/editor/core/view/StatusItems.tsx b/src/editor/core/view/StatusItems.tsx index 7a752626a..3ebbaf2c9 100644 --- a/src/editor/core/view/StatusItems.tsx +++ b/src/editor/core/view/StatusItems.tsx @@ -352,6 +352,7 @@ const ENCODINGS = ENCODING_CHOICES; /** File encoding widget (IDEA: click → choose an encoding, then Reload or Convert). */ export function EncodingStatus() { const meta = useActiveMeta(); + const prefs = useEditorPrefs(); const [open, setOpen] = useState(false); const ref = useRef(null); if (!meta) return null; @@ -360,7 +361,7 @@ export function EncodingStatus() { if (!model) return; void editorController() .manager.backend.setEncoding(meta.doc, encoding, reload) - .then((enc) => model.setMeta({ encoding: enc, ...(reload ? {} : { dirty: true }) })) + .then((enc) => model.setMeta({ encoding: enc.encoding, hasBom: enc.hasBom, ...(reload ? {} : { dirty: true }) })) .catch((e: unknown) => notifications().notify({ level: "error", @@ -370,6 +371,27 @@ export function EncodingStatus() { ); }; const current = meta.encoding.toLowerCase(); + const nativeToAsciiProperties = prefs.encodings.nativeToAscii && meta.path.endsWith(".properties"); + const canToggleBom = current === "utf-8" && !nativeToAsciiProperties; + const label = ENCODINGS.find(([id]) => id === current)?.[1] ?? meta.encoding; + const applyBom = (hasBom: boolean) => { + const controller = editorController(); + const model = controller.manager.get(meta.path); + if (!model) return; + void controller.manager.backend + .setBom(meta.doc, hasBom) + .then(async (enc) => { + model.setMeta({ encoding: enc.encoding, hasBom: enc.hasBom, dirty: true }); + if (controller.autoSave()) await model.save("auto"); + }) + .catch((e: unknown) => + notifications().notify({ + level: "error", + title: tx("Cannot change the UTF-8 BOM"), + body: e instanceof Error ? e.message : String(e), + }), + ); + }; const group = (reload: boolean) => ENCODINGS.filter(([id]) => id !== current).map(([id, label]) => ({ id: `${reload ? "reload" : "convert"}.${id}`, @@ -385,7 +407,7 @@ export function EncodingStatus() { aria-expanded={open} onClick={() => setOpen((v) => !v)} > - {ENCODINGS.find(([id]) => id === current)?.[1] ?? meta.encoding} + {meta.hasBom ? `${label} BOM` : label} applyBom(!meta.hasBom), + }, + ] + : []), { kind: "separator" }, { id: "configure", diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 6a1d1885f..451a35e54 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -117,6 +117,9 @@ "Move the worktree at {path} to {to}?": "Move the worktree at {path} to {to}?", "Cannot move a worktree containing the open project.": "Cannot move a worktree containing the open project.", "Add Environment File": "Add Environment File", + "Add UTF-8 BOM": "Add UTF-8 BOM", + "Remove UTF-8 BOM": "Remove UTF-8 BOM", + "Cannot change the UTF-8 BOM": "Cannot change the UTF-8 BOM", "Convert to cURL and Copy": "Convert to cURL and Copy", "Add Environment to Public File": "Add Environment to Public File", "Add Environment to Private File": "Add Environment to Private File", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 02f378a46..0c39ace4a 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -844,6 +844,9 @@ "Continue Revert": "继续还原", "Contributes": "贡献", "Convert to Encoding": "转换为编码", + "Add UTF-8 BOM": "添加 UTF-8 BOM", + "Remove UTF-8 BOM": "移除 UTF-8 BOM", + "Cannot change the UTF-8 BOM": "无法更改 UTF-8 BOM", "Copied code keeps the editor's syntax colors and font when pasted into documents and mail.": "复制的代码粘贴到文档和邮件中时会保留编辑器的语法颜色和字体。", "Copy": "复制", "Copy / Paste Special": "复制/选择性粘贴",