From 0f75ff8cefa3c831db1b76acb2feed8c6e37a26c Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Mon, 25 May 2026 10:04:01 -0700 Subject: [PATCH 01/14] Add setting to govern `TextEditor::isModified` behavior --- src/config-schema.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/config-schema.js b/src/config-schema.js index 4ee6161080..2c7740f3bf 100644 --- a/src/config-schema.js +++ b/src/config-schema.js @@ -663,6 +663,20 @@ const configSchema = { default: true, description: 'Add multiple cursors when pressing the Ctrl key (Command key on macOS) and clicking the editor.' + }, + // This setting relates to the behavior of `text-buffer` specifically + // (not treating a buffer as modified if its backing file is deleted and + // the file was not already considered to be modified at time of + // deletion). + // + // For other types of pane item, nothing else has changed; + // `shouldPromptToSave` is the source of truth. That's why this setting + // exists in the `editor` namespace rather than the `core` namespace. + promptWhenAbandoningDeletedFile: { + type: 'boolean', + title: 'Experimental: Prompt When Abandoning Deleted File', + default: true, + description: "Prompt before closing a buffer whose file on disk has been deleted. If disabled, you will still be prompted to save a deleted file on closing if the file had uncommitted changes in the buffer at time of deletion." } } } From 2d3689edbd7a0a2dd72a9628ab6dc9fb195b4560 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Mon, 25 May 2026 10:05:22 -0700 Subject: [PATCH 02/14] =?UTF-8?q?Change=20`TextEditor::isModified`=20to=20?= =?UTF-8?q?check=20config=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …before deciding how to behave. --- src/text-editor.js | 48 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/src/text-editor.js b/src/text-editor.js index 60102eaea1..21e7a51cb9 100644 --- a/src/text-editor.js +++ b/src/text-editor.js @@ -1031,8 +1031,8 @@ module.exports = class TextEditor { return this.getBuffer().onDidChangeModified(callback); } - // Extended: Calls your `callback` when the buffer's underlying file changes on - // disk at a moment when the result of {::isModified} is true. + // Extended: Calls your `callback` when the buffer's underlying file changes + // on disk at a moment when the result of {::isModified} is true. // // * `callback` {Function} // @@ -1041,6 +1041,12 @@ module.exports = class TextEditor { return this.getBuffer().onDidConflict(callback); } + // Extended: Calls your `callback` when the buffer's underlying file is + // deleted on disk. + onDidDelete(callback) { + return this.getBuffer().onDidDelete(callback); + } + // Extended: Calls your `callback` before text has been inserted. // // * `callback` {Function} @@ -1433,23 +1439,28 @@ module.exports = class TextEditor { } } - // Essential: Returns the {String} path of this editor's text buffer. + // Essential: Returns the {String} path of this editor's text buffer, or + // `undefined` if the buffer is not associated with a file on disk. getPath() { return this.buffer.getPath(); } + // Extended: Returns the file name of this editor's text buffer, or + // `undefined` if the buffer is not associated with a file on disk. getFileName() { const fullPath = this.getPath(); if (fullPath) return path.basename(fullPath); } + // Extended: Returns the parent directory of this editor's text buffer, or + // `undefined` if the buffer is not associated with a file on disk. getDirectoryPath() { const fullPath = this.getPath(); if (fullPath) return path.dirname(fullPath); } - // Extended: Returns the {String} character set encoding of this editor's text - // buffer. + // Extended: Returns the {String} character set encoding of this editor's + // text buffer. getEncoding() { return this.buffer.getEncoding(); } @@ -1457,13 +1468,22 @@ module.exports = class TextEditor { // Extended: Set the character set encoding to use in this editor's text // buffer. // - // * `encoding` The {String} character set encoding name such as 'utf8' + // * `encoding` The {String} character set encoding name, such as `utf8`. setEncoding(encoding) { this.buffer.setEncoding(encoding); } // Essential: Returns {Boolean} `true` if this editor has been modified. isModified() { + let promptWhenAbandoningDeletedFile = atom.config.get('editor.promptWhenAbandoningDeletedFile', { + scope: this.getRootScopeDescriptor() + }); + // When `promptWhenAbandoningDeletedFile` is `true` (as is the default), + // replicate the legacy `isModified` behavior where buffers with no backing + // file _always_ return `true`. + if (promptWhenAbandoningDeletedFile && this.isDeleted()) { + return true; + } return this.buffer.isModified(); } @@ -1475,10 +1495,20 @@ module.exports = class TextEditor { // edit it in Pulsar, but before you're able to save those changes. It can // also happen if you switch branches in version control while a certain // buffer has uncommitted changes. - isInConflict () { + isInConflict() { return this.buffer.isInConflict(); } + // Essential: Returns {Boolean} `true` if this editor's backing file was + // deleted. + // + // Will return `false` if the editor's buffer _never_ was associated with a + // file on disk, or if the buffer was re-saved to the same location or a + // different one. + isDeleted() { + return this.buffer.isDeleted(); + } + // Essential: Returns {Boolean} `true` if this editor has no content. isEmpty() { return this.buffer.isEmpty(); @@ -1514,6 +1544,10 @@ module.exports = class TextEditor { ) { return this.buffer.isInConflict(); } else { + // Don't prompt when the user closes an editor if there are other editors + // in the window that belong to that buffer. In theory, this ensures + // we'll prompt only once for a given file when closing a window, but may + // need investigation in practice to be sure. return this.isModified() && !this.buffer.hasMultipleEditors(); } } From 1b77a3673e7c3ee2f4f8cfceb0bfde1317331694 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Mon, 25 May 2026 10:06:31 -0700 Subject: [PATCH 03/14] Use a more accurate error message when a conflicted file is deleted --- src/pane.js | 118 ++++++++++++++++++++++++++++------------------------ 1 file changed, 64 insertions(+), 54 deletions(-) diff --git a/src/pane.js b/src/pane.js index 1881576aa0..819acf53aa 100644 --- a/src/pane.js +++ b/src/pane.js @@ -49,8 +49,10 @@ module.exports = class Pane { state.activeItem = items[activeItemIndex]; if (!state.activeItem && activeItemURI) { state.activeItem = state.items.find( - item => - typeof item.getURI === 'function' && item.getURI() === activeItemURI + item => { + let itemURI = item.getURI?.() ?? item.getUri?.(); + return itemURI === activeItemURI; + } ); } @@ -485,7 +487,7 @@ module.exports = class Pane { itemStackIndices.length !== this.items.length || itemStackIndices.includes(-1) ) { - itemStackIndices = this.items.map((item, i) => i); + itemStackIndices = this.items.map((_, i) => i); } for (let itemIndex of itemStackIndices) { @@ -652,10 +654,9 @@ module.exports = class Pane { options = { index: options }; } - const index = - options.index != null ? options.index : this.getActiveItemIndex() + 1; - const moved = options.moved != null ? options.moved : false; - const pending = options.pending != null ? options.pending : false; + const index = options.index ?? this.getActiveItemIndex() + 1; + const moved = options.moved ?? false; + const pending = options.pending ?? false; if (!item || typeof item !== 'object') { throw new Error( @@ -663,7 +664,7 @@ module.exports = class Pane { ); } - if (typeof item.isDestroyed === 'function' && item.isDestroyed()) { + if (item.isDestroyed?.()) { throw new Error( `Adding a pane item with URI '${typeof item.getURI === 'function' && item.getURI()}' that has already been destroyed` @@ -695,7 +696,7 @@ module.exports = class Pane { this.emitter.emit('did-add-item', { item, index, moved }); if (!moved) { - if (this.container) this.container.didAddPaneItem(item, this, index); + this.container?.didAddPaneItem(item, this, index); } if (replacingPendingItem) this.destroyItem(lastPendingItem); @@ -838,17 +839,18 @@ module.exports = class Pane { const index = this.items.indexOf(item); if (index === -1) return false; + // Don't allow deletion of permanent dock items unless `force` is `true`. if ( !force && - typeof item.isPermanentDockItem === 'function' && - item.isPermanentDockItem() && + item.isPermanentDockItem?.() && (!this.container || this.container.getLocation() !== 'center') ) { return false; } - // In the case where there are no `onWillDestroyPaneItem` listeners, preserve the old behavior - // where `Pane.destroyItem` and callers such as `Pane.close` take effect synchronously. + // In the case where there are no `onWillDestroyPaneItem` listeners, + // preserve the old behavior where `Pane.destroyItem` and callers such as + // `Pane.close` take effect synchronously. if (this.emitter.listenerCountForEventName('will-destroy-item') > 0) { await this.emitter.emitAsync('will-destroy-item', { item, index }); } @@ -872,8 +874,7 @@ module.exports = class Pane { if ( !force && - typeof item.shouldPromptToSave === 'function' && - item.shouldPromptToSave() + item.shouldPromptToSave?.() ) { if (!(await this.promptToSaveItem(item))) return false; } @@ -900,8 +901,8 @@ module.exports = class Pane { // save. The user must decide whether to cancel the attempted save… or force // it and overwrite what's on disk. // - // Resolves with boolean `true` when a save can proceed… or rejects with an - // error when the save is aborted. + // Returns a {Promise} that resolves with {Boolean} `true` when a save can + // proceed… or rejects with an error when the save is aborted. promptOnConflict(item) { return new Promise((resolve, reject) => { // Don't prompt if the user hasn't opted into it. @@ -914,18 +915,29 @@ module.exports = class Pane { } // Figure out how to describe the buffer in the dialog. const uri = item.getURI?.() ?? item.getUri?.() ?? null; - const title = - (typeof item.getTitle === 'function' && item.getTitle()) || uri; + const title = item.getTitle?.() || uri; + + let message, detail; + let firstButton = item.isDeleted() ? 'Save' : 'Overwrite'; + if (item.isDeleted()) { + // The message is a bit different when the file no longer exists on + // disk. + message = `'${title}' was deleted on disk. Do you still want to save this file?`; + detail = 'The contents of the buffer are stale because of pending changes.'; + } else { + message = `'${title}' has changed on disk. Do you want to overwrite this file with your changes?`; + detail = 'The contents of the buffer may be stale.' + } this.applicationDelegate.confirm({ - message: `'${title}' has changed on disk. Do you want to overwrite this file with your changes?`, - detail: 'The contents of the buffer may be stale.', + message, + detail, // TODO: Individual pane items may have additional strategies to // contribute (e.g., conflict resolution view). Implement a way for // them to contribute buttons to this dialog — and to handle them in // the callback below. - buttons: ['Overwrite', 'Cancel'] + buttons: [firstButton, 'Cancel'] }, (response) => { switch (response) { case 0: @@ -937,26 +949,31 @@ module.exports = class Pane { }); } + // Decide whether to show a dialog to the user inviting them to save the + // given pane item. + // + // In order to trigger prompt-to-save, a pane item must implement the + // `shouldPromptToSave` method and have it return `true` under the proper + // circumstances. It must also implement `getURI` and have it return a URI + // that represents the pane item. + // + // Returns a {Promise} that resolves with a {Boolean}. If `true`, the item + // should proceed with destruction; if `false`, the user has cancelled the + // closing of the pane item. promptToSaveItem(item, options = {}) { - return new Promise((resolve, reject) => { - if ( - typeof item.shouldPromptToSave !== 'function' || - !item.shouldPromptToSave(options) - ) { + return new Promise((resolve, _reject) => { + if (!item.shouldPromptToSave?.(options)) { return resolve(true); } - let uri; - if (typeof item.getURI === 'function') { - uri = item.getURI(); - } else if (typeof item.getUri === 'function') { - uri = item.getUri(); - } else { + // A pane item must have a URI so that we have a "title of last resort" + // to show in the dialog. + let uri = item.getURI?.() ?? item.getUri?.() ?? null; + if (!uri) { return resolve(true); } - const title = - (typeof item.getTitle === 'function' && item.getTitle()) || uri; + let title = item.getTitle?.() || uri; const saveDialog = (saveButtonText, saveFn, message) => { this.applicationDelegate.confirm( @@ -973,6 +990,10 @@ module.exports = class Pane { if (error instanceof SaveCancelledError) { resolve(false); } else if (error) { + // For whatever reason, the save failed. In the event that + // the failure is due to the location not existing (network + // share?) or permissions being wrong… we offer the user + // the ability to choose a new destination. saveDialog( 'Save as', this.saveItemAs, @@ -996,7 +1017,7 @@ module.exports = class Pane { saveDialog( 'Save', this.saveItem, - `'${title}' has changes, do you want to save them?` + `'${title}' has changes; do you want to save them?` ); }); } @@ -1030,12 +1051,7 @@ module.exports = class Pane { saveItem(item, nextAction) { if (!item) return Promise.resolve(); - let itemURI; - if (typeof item.getURI === 'function') { - itemURI = item.getURI(); - } else if (typeof item.getUri === 'function') { - itemURI = item.getUri(); - } + let itemURI = item.getURI?.() ?? item.getUri?.(); if (itemURI != null) { if (typeof item.save === 'function') { @@ -1095,10 +1111,7 @@ module.exports = class Pane { if (!item) return; if (typeof item.saveAs !== 'function') return; - const saveOptions = - typeof item.getSaveDialogOptions === 'function' - ? item.getSaveDialogOptions() - : {}; + const saveOptions = item.getSaveDialogOptions?.() ?? {}; const itemPath = item.getPath(); if (itemPath && !saveOptions.defaultPath) @@ -1141,7 +1154,7 @@ module.exports = class Pane { // Public: Save all items. saveItems() { for (let item of this.getItems()) { - if (typeof item.isModified === 'function' && item.isModified()) { + if (item.isModified?.()) { this.saveItem(item); } } @@ -1153,11 +1166,9 @@ module.exports = class Pane { // * `uri` {String} containing a URI. itemForURI(uri) { return this.items.find(item => { - if (typeof item.getURI === 'function') { - return item.getURI() === uri; - } else if (typeof item.getUri === 'function') { - return item.getUri() === uri; - } + let itemUri = item.getURI?.() ?? item.getUri?.(); + if (!itemUri) return false; + return uri === itemUri; }); } @@ -1440,8 +1451,7 @@ module.exports = class Pane { } handleSaveError(error, item) { - const itemPath = - error.path || (typeof item.getPath === 'function' && item.getPath()); + const itemPath = error.path || item.getPath?.(); const addWarningWithPath = (message, options) => { if (itemPath) message = `${message} '${itemPath}'`; this.notificationManager.addWarning(message, options); From bb01cca5d8dab81979ff3f4f6e092d2c0fa7e6e3 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Mon, 25 May 2026 10:21:10 -0700 Subject: [PATCH 04/14] =?UTF-8?q?[tabs]=20Refactor=20`tab-view.js`?= =?UTF-8?q?=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …and add support for `deleted` and `conflicted` states. By default, this does nothing cosmetic; UI themes must be updated to hook into these new class names. --- packages/tabs/lib/tab-view.js | 251 +++++++++++++++++++--------------- 1 file changed, 138 insertions(+), 113 deletions(-) diff --git a/packages/tabs/lib/tab-view.js b/packages/tabs/lib/tab-view.js index 177e42bbf7..4f528b23a5 100644 --- a/packages/tabs/lib/tab-view.js +++ b/packages/tabs/lib/tab-view.js @@ -5,6 +5,10 @@ const getIconServices = require('./get-icon-services'); const layout = require('./layout'); class TabView { + isModified = false; + isDeleted = false; + isConflicted = false; + constructor({item, pane, didClickCloseIcon, tabs, location}) { this.item = item; this.pane = pane; @@ -55,66 +59,78 @@ class TabView { } handleEvents() { - const titleChangedHandler = () => { - return this.updateTitle(); - }; - - this.subscriptions.add(this.pane.onDidDestroy(() => this.destroy())); - this.subscriptions.add(this.pane.onItemDidTerminatePendingState(item => { - if (item === this.item) { return this.clearPending(); } - }) + this.subscriptions.add( + // Destroy a tab when its pane item is destroyed. + this.pane.onDidDestroy(() => this.destroy()), + // Take a tab out of "pending" state when the same happens for the pane + // item. + this.pane.onItemDidTerminatePendingState(item => { + if (item === this.item) this.clearPending(); + }), + // Update whether icons are shown when the user changes the associated + // setting. + atom.config.observe('tabs.showIcons', () => { + this.updateIconVisibility(); + }), + // Update whether VCS status colors are used when the user changes the + // associated setting. + atom.config.observe('tabs.enableVcsColoring', isEnabled => { + if (isEnabled && (this.path != null)) { + this.setupVcsStatus(); + } else { + this.unsetVcsStatus(); + } + }) ); + const titleChangedHandler = () => this.updateTitle(); + + // Subscribe to title changes on this pane item. if (typeof this.item.onDidChangeTitle === 'function') { const onDidChangeTitleDisposable = this.item.onDidChangeTitle(titleChangedHandler); - if (Disposable.isDisposable(onDidChangeTitleDisposable)) { - this.subscriptions.add(onDidChangeTitleDisposable); - } else { - console.warn("::onDidChangeTitle does not return a valid Disposable!", this.item); - } + this.addItemDisposable(onDidChangeTitleDisposable, 'onDidChangeTitle'); } else if (typeof this.item.on === 'function') { - //TODO Remove once old events are no longer supported + // TODO: Remove once old events are no longer supported. this.item.on('title-changed', titleChangedHandler); - this.subscriptions.add({dispose: () => { - return (typeof this.item.off === 'function' ? this.item.off('title-changed', titleChangedHandler) : undefined); - } - }); + this.subscriptions.add( + new Disposable(() => { + this.item.off?.('title-changed', titleChangedHandler) + }) + ); } - const pathChangedHandler = path1 => { - this.path = path1; + const pathChangedHandler = newPath => { + this.path = newPath; this.updateDataAttributes(); this.updateTitle(); this.updateTooltip(); - return this.updateIcon(); + this.updateIcon(); }; + // Subscribe to path changes on this pane item. if (typeof this.item.onDidChangePath === 'function') { const onDidChangePathDisposable = this.item.onDidChangePath(pathChangedHandler); - if (Disposable.isDisposable(onDidChangePathDisposable)) { - this.subscriptions.add(onDidChangePathDisposable); - } else { - console.warn("::onDidChangePath does not return a valid Disposable!", this.item); - } + this.addItemDisposable(onDidChangePathDisposable, 'onDidChangePath'); } else if (typeof this.item.on === 'function') { - //TODO Remove once old events are no longer supported + // TODO: Remove once old events are no longer supported. this.item.on('path-changed', pathChangedHandler); - this.subscriptions.add({dispose: () => { - return (typeof this.item.off === 'function' ? this.item.off('path-changed', pathChangedHandler) : undefined); - } - }); + this.subscriptions.add( + new Disposable(() => { + this.item.off?.('path-changed', pathChangedHandler); + }) + ); } - const iconChangedHandler = () => { - return this.updateIcon(); - }; - - this.subscriptions.add(getIconServices().onDidChange(() => this.updateIcon())); + const iconChangedHandler = () => this.updateIcon(); + this.subscriptions.add( + getIconServices().onDidChange(iconChangedHandler) + ); + // Subscribe to icon changes on this pane item. if (typeof this.item.onDidChangeIcon === 'function') { - const onDidChangeIconDisposable = typeof this.item.onDidChangeIcon === 'function' ? this.item.onDidChangeIcon(() => { + const onDidChangeIconDisposable = this.item.onDidChangeIcon(() => { return this.updateIcon(); - }) : undefined; + }); if (Disposable.isDisposable(onDidChangeIconDisposable)) { this.subscriptions.add(onDidChangeIconDisposable); } else { @@ -123,68 +139,70 @@ class TabView { } else if (typeof this.item.on === 'function') { //TODO Remove once old events are no longer supported this.item.on('icon-changed', iconChangedHandler); - this.subscriptions.add({dispose: () => { - return (typeof this.item.off === 'function' ? this.item.off('icon-changed', iconChangedHandler) : undefined); - } - }); + this.subscriptions.add( + new Disposable(() => { + this.item.off?.('icon-changed', iconChangedHandler); + }) + ); } - const modifiedHandler = () => { - return this.updateModifiedStatus(); - }; + const modifiedHandler = () => this.updateModifiedStatus(); + // Subscribe to changes in "modified" status on this pane item. if (typeof this.item.onDidChangeModified === 'function') { const onDidChangeModifiedDisposable = this.item.onDidChangeModified(modifiedHandler); - if (Disposable.isDisposable(onDidChangeModifiedDisposable)) { - this.subscriptions.add(onDidChangeModifiedDisposable); - } else { - console.warn("::onDidChangeModified does not return a valid Disposable!", this.item); - } + this.addItemDisposable(onDidChangeModifiedDisposable, 'onDidChangeModified'); } else if (typeof this.item.on === 'function') { - //TODO Remove once old events are no longer supported + // TODO: Remove once old events are no longer supported. this.item.on('modified-status-changed', modifiedHandler); - this.subscriptions.add({dispose: () => { - return (typeof this.item.off === 'function' ? this.item.off('modified-status-changed', modifiedHandler) : undefined); - } - }); + this.subscriptions.add( + new Disposable(() => { + this.item.off?.('modified-status-changed', modifiedHandler) + }) + ); } + // Subscribe to changes in "conflicted" status on this pane item. if (typeof this.item.onDidConflict === 'function') { const onDidConflictDisposable = this.item.onDidConflict(() => { this.updateConflictedStatus(); }); - if (Disposable.isDisposable(onDidConflictDisposable)) { - this.subscriptions.add(onDidConflictDisposable); - } else { - console.warn("::onDidConflict does not return a valid Disposable!", this.item); - } + this.addItemDisposable(onDidConflictDisposable, 'onDidConflict'); } + // Subscribe to changes in "deleted" status on this pane item. + if (typeof this.item.onDidDelete === 'function') { + let onDidDeleteDisposable = this.item.onDidDelete(() => { + this.terminatePendingState(); + this.updateDeletedStatus(); + }); + this.addItemDisposable(onDidDeleteDisposable, 'onDidDelete'); + } + + // Subscribe to "save" events on this pane item, since those correlate with + // changes in several pane item states. if (typeof this.item.onDidSave === 'function') { const onDidSaveDisposable = this.item.onDidSave(event => { this.terminatePendingState(); this.updateConflictedStatus(); + this.updateDeletedStatus(); if (event.path !== this.path) { this.path = event.path; - if (atom.config.get('tabs.enableVcsColoring')) { return this.setupVcsStatus(); } + if (atom.config.get('tabs.enableVcsColoring')) { + this.setupVcsStatus(); + } } }); - - if (Disposable.isDisposable(onDidSaveDisposable)) { - this.subscriptions.add(onDidSaveDisposable); - } else { - console.warn("::onDidSave does not return a valid Disposable!", this.item); - } + this.addItemDisposable(onDidSaveDisposable, 'onDidSave'); } - this.subscriptions.add(atom.config.observe('tabs.showIcons', () => { - return this.updateIconVisibility(); - }) - ); + } - return this.subscriptions.add(atom.config.observe('tabs.enableVcsColoring', isEnabled => { - if (isEnabled && (this.path != null)) { return this.setupVcsStatus(); } else { return this.unsetVcsStatus(); } - }) - ); + addItemDisposable (disposable, methodName) { + if (!Disposable.isDisposable(disposable)) { + console.warn(`::${methodName} does not return a valid Disposable!`, this.item); + return; + } + this.subscriptions.add(disposable); } setupTooltip() { @@ -198,11 +216,12 @@ class TabView { return this.element.dispatchEvent(new CustomEvent('mouseenter', {bubbles: true})); }; - this.mouseEnterSubscription = { dispose: () => { - this.element.removeEventListener('mouseenter', onMouseEnter); - return this.mouseEnterSubscription = null; - } - }; + this.mouseEnterSubscription = { + dispose: () => { + this.element.removeEventListener('mouseenter', onMouseEnter); + return this.mouseEnterSubscription = null; + } + }; return this.element.addEventListener('mouseenter', onMouseEnter); } @@ -328,72 +347,78 @@ class TabView { } updateConflictedStatus () { - if (this.item.isInConflict?.()) { - this.element.classList.add('conflicted'); - this.isConflicted = true; - } else { - if (this.isConflicted) { - this.element.classList.remove('conflicted'); - } - this.isConflicted = false; - } + this.isConflicted = this.item.isInConflict?.() ?? false; + this.toggleTabClass('conflicted', this.isConflicted); return this.isConflicted; } updateModifiedStatus() { - if (typeof this.item.isModified === 'function' ? this.item.isModified() : undefined) { - if (!this.isModified) { this.element.classList.add('modified'); } - return this.isModified = true; - } else { - if (this.isModified) { this.element.classList.remove('modified'); } - return this.isModified = false; - } + this.isModified = this.item.isModified?.() ?? false; + this.toggleTabClass('modified', this.isModified); + return this.isModified; + } + + updateDeletedStatus () { + this.isDeleted = this.item.isDeleted?.() ?? false; + this.toggleTabClass('deleted', this.isDeleted); + return this.isDeleted; } setupVcsStatus() { if (this.path == null) { return; } return this.repoForPath(this.path).then(repo => { this.subscribeToRepo(repo); - return this.updateVcsStatus(repo); + this.updateVcsStatus(repo); }); } + toggleTabClass (className, condition) { + if (condition) { + this.element.classList.add(className); + } else { + this.element.classList.remove(className); + } + } + // Subscribe to the project's repo for changes to the VCS status of the file. subscribeToRepo(repo) { - if (repo == null) { return; } + if (repo == null) return; // Remove previous repo subscriptions. - if (this.repoSubscriptions != null) { - this.repoSubscriptions.dispose(); - } + this.repoSubscriptions?.dispose(); this.repoSubscriptions = new CompositeDisposable(); - this.repoSubscriptions.add(repo.onDidChangeStatus(event => { - if (event.path === this.path) { return this.updateVcsStatus(repo, event.pathStatus); } - }) - ); - return this.repoSubscriptions.add(repo.onDidChangeStatuses(() => { - return this.updateVcsStatus(repo); - }) + this.repoSubscriptions.add( + repo.onDidChangeStatus(event => { + if (event.path === this.path) { + return this.updateVcsStatus(repo, event.pathStatus); + } + }), + + repo.onDidChangeStatuses(() => { + return this.updateVcsStatus(repo); + }) ); } repoForPath() { for (let dir of atom.project.getDirectories()) { - if (dir.contains(this.path)) { return atom.project.repositoryForDirectory(dir); } + if (dir.contains(this.path)) { + return atom.project.repositoryForDirectory(dir); + } } return Promise.resolve(null); } // Update the VCS status property of this tab using the repo. updateVcsStatus(repo, status) { - if (repo == null) { return; } + if (repo == null) return; let newStatus = null; if (repo.isPathIgnored(this.path)) { newStatus = 'ignored'; } else { - if (status == null) { status = repo.getCachedPathStatus(this.path); } + status ??= repo.getCachedPathStatus(this.path); if (repo.isStatusModified(status)) { newStatus = 'modified'; } else if (repo.isStatusNew(status)) { @@ -410,7 +435,7 @@ class TabView { updateVcsColoring() { this.itemTitle.classList.remove('status-ignored', 'status-modified', 'status-added'); if (this.status && atom.config.get('tabs.enableVcsColoring')) { - return this.itemTitle.classList.add(`status-${this.status}`); + this.itemTitle.classList.add(`status-${this.status}`); } } @@ -419,7 +444,7 @@ class TabView { this.repoSubscriptions.dispose(); } delete this.status; - return this.updateVcsColoring(); + this.updateVcsColoring(); } } From 628a7bfb5fb225e66a21e9e388310ffc3d3431b0 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Sat, 30 May 2026 16:31:03 -0700 Subject: [PATCH 05/14] Bump `@pulsar-edit/text-buffer` to `15.0.0` --- package.json | 2 +- yarn.lock | 30 +++++++----------------------- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index bef6184b8f..b240f4f966 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@pulsar-edit/pathwatcher": "^9.0.2", "@pulsar-edit/scandal": "^4.0.0", "@pulsar-edit/superstring": "^3.0.5", - "@pulsar-edit/text-buffer": "^14.0.4", + "@pulsar-edit/text-buffer": "^15.0.0", "about": "file:packages/about", "archive-view": "file:packages/archive-view", "async": "3.2.6", diff --git a/yarn.lock b/yarn.lock index 199e62470d..f6c7f17269 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1726,7 +1726,7 @@ unbzip2-stream "^1.4.3" yauzl "^3.2.0" -"@pulsar-edit/pathwatcher@9.0.3", "@pulsar-edit/pathwatcher@^9.0.2": +"@pulsar-edit/pathwatcher@9.0.3", "@pulsar-edit/pathwatcher@^9.0.2", "@pulsar-edit/pathwatcher@^9.0.3": version "9.0.3" resolved "https://registry.yarnpkg.com/@pulsar-edit/pathwatcher/-/pathwatcher-9.0.3.tgz#13dcbdbf4115904bfdf76509580612cf09e8043c" integrity sha512-S8QwhsUf/x617g6FEuPixdu1c3Mt4kzZsGaTrbogkaCJuY1mMAEkqkp6zEp4MM+UfWFruRszAOkdZYbI5gWW9Q== @@ -1758,22 +1758,20 @@ dependencies: node-addon-api "^8.5.0" -"@pulsar-edit/text-buffer@^14.0.4": - version "14.0.4" - resolved "https://registry.yarnpkg.com/@pulsar-edit/text-buffer/-/text-buffer-14.0.4.tgz#9bf03445ab8da000c9c986255654acf04433e6bb" - integrity sha512-KiC/uA7zdnoI2pI3AyoYYXAdL5h6LyHURHl/URxkViQPjGeFXr3vRr6cb9OxsS1NRM4fVFtEdHFG8T7aafDdUQ== +"@pulsar-edit/text-buffer@^15.0.0": + version "15.0.0" + resolved "https://registry.yarnpkg.com/@pulsar-edit/text-buffer/-/text-buffer-15.0.0.tgz#5c72fde1caa8d9aee71a4f98252550b32934401d" + integrity sha512-Ut7VbD+8NIUMlf79NFjUzhTBV5e/Ty2jLGWlxtOYM0RRuey4zFv3RedIFjbgWY+qQoRBbmigLmyvzewIMVrONA== dependencies: - "@pulsar-edit/pathwatcher" "^9.0.2" + "@pulsar-edit/pathwatcher" "^9.0.3" "@pulsar-edit/superstring" "^3.0.4" delegato "^1.0.0" diff "^2.2.1" - emissary "^1.0.0" event-kit "^2.4.0" fs-admin "^0.19.0" fs-plus "^3.0.0" grim "^2.0.2" mkdirp "^0.5.1" - serializable "^1.0.3" underscore-plus "^1.0.0" winattr "^3.0.0" @@ -4242,7 +4240,7 @@ electron@30.5.1: "@types/node" "^20.9.0" extract-zip "^2.0.1" -emissary@^1.0.0, emissary@^1.2.0, emissary@^1.3.2, emissary@^1.3.3: +emissary@^1.2.0, emissary@^1.3.2, emissary@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/emissary/-/emissary-1.3.3.tgz#a618d92d682b232d31111dc3625a5df661799606" integrity sha512-pD6FWNBSlEOzSJDCTcSGVLgNnGw5fnCvvGMdQ/TN43efeXZ/QTq8+hZoK3OOEXPRNjMmSJmeOnEJh+bWT5O8rQ== @@ -5231,11 +5229,6 @@ get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@ hasown "^2.0.2" math-intrinsics "^1.1.0" -get-parameter-names@~0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/get-parameter-names/-/get-parameter-names-0.2.0.tgz#a2163ad092e350d94bee2958974fcece1bc53c99" - integrity sha512-QgxVvmXNqxCDYMwK8zwM5B0AMoLUGQ9MBw202kELskDTDEIveOwP6zR38F3XLA3v+rsuv6+DtCXjW0AEPE9+4Q== - get-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" @@ -8900,15 +8893,6 @@ semver@^7.0.0, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semve resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== -serializable@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/serializable/-/serializable-1.0.3.tgz#0a5a8b6b7777cb24544df11a6f889a6d2b3e1189" - integrity sha512-x4C87GbC+fSbj1NlmZrKW4tDN+sZodzTZNxELEH4iwXzg4xirHBQOIOpPatmksXkes07ZebIRpo6+UVS0rvwsw== - dependencies: - get-parameter-names "~0.2.0" - mixto "1.x" - underscore-plus "1.x" - serialize-error@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/serialize-error/-/serialize-error-7.0.1.tgz#f1360b0447f61ffb483ec4157c737fab7d778e18" From 7b23eaf5a3166e27b95db7eae3c88d6bbd7b14f4 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Sat, 30 May 2026 17:35:52 -0700 Subject: [PATCH 06/14] =?UTF-8?q?Style=20the=20tabs=20on=20built-in=20UI?= =?UTF-8?q?=20themes=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …to reflect the “deleted” status of a file. --- packages/atom-dark-ui/styles/tabs.less | 12 +++++++- packages/atom-light-ui/styles/tabs.less | 8 +++++ packages/one-dark-ui/styles/tabs.less | 30 +++++++++++++++---- .../styles/ui-variables-custom.less | 4 ++- packages/one-light-ui/styles/tabs.less | 28 +++++++++++++---- .../styles/ui-variables-custom.less | 4 ++- 6 files changed, 73 insertions(+), 13 deletions(-) diff --git a/packages/atom-dark-ui/styles/tabs.less b/packages/atom-dark-ui/styles/tabs.less index a3e534882d..9aa8f145c1 100644 --- a/packages/atom-dark-ui/styles/tabs.less +++ b/packages/atom-dark-ui/styles/tabs.less @@ -25,9 +25,9 @@ max-width: @tab-max-width; height: @tab-height; line-height: @tab-height; + color: @text-color; padding: 0; margin: 0 20px 0 5px; - color: @text-color; transition: color .1s ease-in; border: none; @@ -91,6 +91,14 @@ padding-top: @tab-top-padding; padding-right: 10px; } + + &.deleted { + &, & .title { + color: @text-color-error; + text-decoration: line-through; + text-decoration-color: @text-color-error; + } + } } .tab.active { @@ -132,9 +140,11 @@ .placeholder { height: (@tab-height + @tab-top-padding + @tab-bottom-border-height); pointer-events: none; + &:before { margin-left: -9px; // center between tabs } + &:after { top: (@tab-height + @tab-top-padding + @tab-bottom-border-height - 2px); margin-left: -10px; // center between tabs diff --git a/packages/atom-light-ui/styles/tabs.less b/packages/atom-light-ui/styles/tabs.less index 687b1ba744..73153adebf 100644 --- a/packages/atom-light-ui/styles/tabs.less +++ b/packages/atom-light-ui/styles/tabs.less @@ -93,6 +93,14 @@ padding-top: (@tab-top-padding + 1px); padding-right: 10px; } + + &.deleted { + &, & .title { + color: @text-color-error; + text-decoration: line-through; + text-decoration-color: @text-color-error; + } + } } .tab.active { diff --git a/packages/one-dark-ui/styles/tabs.less b/packages/one-dark-ui/styles/tabs.less index b595f46024..b26ad41e7a 100644 --- a/packages/one-dark-ui/styles/tabs.less +++ b/packages/one-dark-ui/styles/tabs.less @@ -27,6 +27,7 @@ // Tab ---------------------- .tab { + --tab-color: @tab-text-color; position: relative; top: 0; padding: 0; @@ -34,12 +35,12 @@ height: inherit; font-size: inherit; line-height: @ui-tab-height; - color: @tab-text-color; + color: var(--tab-color); background-color: @tab-background-color; box-shadow: inherit; border-left: @tab-border; &.active { - color: @tab-text-color-active; + --tab-color: @tab-text-color-active; background-color: @tab-background-color-active; box-shadow: none; } @@ -60,11 +61,16 @@ .title { text-align: center; margin: 0 @title-padding; + color: var(--tab-color); } // VCS coloring ---------------------- - &:not(.active) .status-added { color: @tab-inactive-status-added; } - &:not(.active) .status-modified { color: @tab-inactive-status-modified; } + &:not(.active):has(.status-added) { + --tab-color: @tab-inactive-status-added; + } + &:not(.active):has(.status-modified) { + --tab-color: @tab-inactive-status-modified; + } // Icons ---------------------- @@ -116,6 +122,19 @@ } } + // Deleted state ---------------------- + + .tab.deleted { + --tab-color: @text-color-error; + text-decoration: line-through; + text-decoration-color: var(--tab-color); + + &:not(.active) { + --tab-color: @tab-inactive-status-deleted; + } + } + + // Modified icon ---------------------- .tab.modified { @@ -249,7 +268,8 @@ atom-dock .tab-bar .tab::before { &[data-type$="TimecopView"], &[data-type$="StyleguideView"], &[data-type="MarkdownPreviewView"] { - color: @tab-text-color-editor; + --tab-color: @tab-text-color-editor; + color: var(--tab-color); background-color: @tab-background-color-editor; // Match syntax background color } } diff --git a/packages/one-dark-ui/styles/ui-variables-custom.less b/packages/one-dark-ui/styles/ui-variables-custom.less index 62a5bdc51b..69b9c20dd6 100644 --- a/packages/one-dark-ui/styles/ui-variables-custom.less +++ b/packages/one-dark-ui/styles/ui-variables-custom.less @@ -105,10 +105,12 @@ @tab-text-color: @text-color-subtle; @tab-text-color-active: @text-color-highlight; -@tab-text-color-editor: contrast(@ui-syntax-color, darken(@ui-syntax-color, 50%), @text-color-highlight ); +@tab-text-color-editor: contrast(@ui-syntax-color, darken(@ui-syntax-color, 50%), @text-color-highlight); +@tab-text-color-editor-deleted: @syntax-color-removed; @tab-background-color-editor: @ui-syntax-color; @tab-inactive-status-added: fade(@text-color-success, 55%); @tab-inactive-status-modified: fade(@text-color-warning, 55%); +@tab-inactive-status-deleted: fade(@text-color-error, 55%); @tooltip-background-color: @accent-bg-color; diff --git a/packages/one-light-ui/styles/tabs.less b/packages/one-light-ui/styles/tabs.less index b595f46024..fbc02885b0 100644 --- a/packages/one-light-ui/styles/tabs.less +++ b/packages/one-light-ui/styles/tabs.less @@ -27,6 +27,7 @@ // Tab ---------------------- .tab { + --tab-color: @tab-text-color; position: relative; top: 0; padding: 0; @@ -34,12 +35,12 @@ height: inherit; font-size: inherit; line-height: @ui-tab-height; - color: @tab-text-color; + color: var(--tab-color); background-color: @tab-background-color; box-shadow: inherit; border-left: @tab-border; &.active { - color: @tab-text-color-active; + --tab-color: @tab-text-color-active; background-color: @tab-background-color-active; box-shadow: none; } @@ -60,11 +61,16 @@ .title { text-align: center; margin: 0 @title-padding; + color: var(--tab-color); } // VCS coloring ---------------------- - &:not(.active) .status-added { color: @tab-inactive-status-added; } - &:not(.active) .status-modified { color: @tab-inactive-status-modified; } + &:not(.active):has(.status-added) { + --tab-color: @tab-inactive-status-added; + } + &:not(.active):has(.status-modified) { + --tab-color: @tab-inactive-status-modified; + } // Icons ---------------------- @@ -116,6 +122,18 @@ } } + // Deleted state ---------------------- + + .tab.deleted { + --tab-color: @text-color-error; + text-decoration: line-through; + text-decoration-color: var(--tab-color); + + &:not(.active) { + --tab-color: @tab-inactive-status-deleted; + } + } + // Modified icon ---------------------- .tab.modified { @@ -249,7 +267,7 @@ atom-dock .tab-bar .tab::before { &[data-type$="TimecopView"], &[data-type$="StyleguideView"], &[data-type="MarkdownPreviewView"] { - color: @tab-text-color-editor; + --tab-color: @tab-text-color-editor; background-color: @tab-background-color-editor; // Match syntax background color } } diff --git a/packages/one-light-ui/styles/ui-variables-custom.less b/packages/one-light-ui/styles/ui-variables-custom.less index c685bfc105..117a81c668 100644 --- a/packages/one-light-ui/styles/ui-variables-custom.less +++ b/packages/one-light-ui/styles/ui-variables-custom.less @@ -88,10 +88,12 @@ @tab-text-color: @text-color-subtle; @tab-text-color-active: @text-color-highlight; -@tab-text-color-editor: contrast(@ui-syntax-color, lighten(@ui-syntax-color, 70%), @text-color-highlight ); +@tab-text-color-editor: contrast(@ui-syntax-color, lighten(@ui-syntax-color, 70%), @text-color-highlight); +@tab-text-color-editor-deleted: @syntax-color-removed; @tab-background-color-editor: @ui-syntax-color; @tab-inactive-status-added: fade(@text-color-success, 77%); @tab-inactive-status-modified: fade(@text-color-warning, 77%); +@tab-inactive-status-deleted: fade(@text-color-error, 55%); @tooltip-background-color: @accent-bg-color; @tooltip-text-color: @accent-bg-text-color; From c1aba5a6607aed3bb555f11d16873887f0a0743b Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Sat, 30 May 2026 17:36:20 -0700 Subject: [PATCH 07/14] Fix interpretation of `isDestroyed` on pane items --- src/pane.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/pane.js b/src/pane.js index 819acf53aa..bced730f48 100644 --- a/src/pane.js +++ b/src/pane.js @@ -664,7 +664,7 @@ module.exports = class Pane { ); } - if (item.isDestroyed?.()) { + if (this.paneItemIsDestroyed(item)) { throw new Error( `Adding a pane item with URI '${typeof item.getURI === 'function' && item.getURI()}' that has already been destroyed` @@ -704,6 +704,15 @@ module.exports = class Pane { return item; } + paneItemIsDestroyed (item) { + if (typeof item.isDestroyed === 'boolean') { + return item.isDestroyed; + } else if (typeof item.isDestroyed === 'function') { + return item.isDestroyed(); + } + return false; + } + setPendingItem(item) { if (this.pendingItem !== item) { const mostRecentPendingItem = this.pendingItem; From 14969b3519ae38eab608bed19cc415bbf9ff68eb Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Sat, 30 May 2026 18:37:01 -0700 Subject: [PATCH 08/14] [tabs] Add specs for `TabView` --- packages/tabs/spec/tabs-spec.js | 157 ++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/packages/tabs/spec/tabs-spec.js b/packages/tabs/spec/tabs-spec.js index 3f95ad533f..6c8b399e9e 100644 --- a/packages/tabs/spec/tabs-spec.js +++ b/packages/tabs/spec/tabs-spec.js @@ -1,4 +1,5 @@ const _ = require('underscore-plus'); +const { Emitter } = require('atom'); const path = require('path'); const temp = require('temp'); const TabBarView = require('../lib/tab-bar-view'); @@ -6,6 +7,90 @@ const layout = require('../lib/layout'); const main = require('../lib/main'); let {triggerMouseEvent, triggerClickEvent, buildDragEvents, buildDragEnterLeaveEvents, buildWheelEvent, buildWheelPlusShiftEvent} = require("./event-helpers.js"); +class SamplePaneItem { + _isModified = false; + _isConflicted = false; + _isDeleted = false; + constructor(slug) { + this._slug = slug; + this.element = document.createElement('div'); + this.emitter = new Emitter(); + } + getTitle() { return "Anything"; } + getPath() { return `foo://${this._slug ?? 'bar'}`; } + onDidChangeTitle(cb) { + return this.emitter.on('did-change-title', cb); + } + onDidChangeIcon(cb) { + return this.emitter.on('did-change-icon', cb); + } + onDidChangeModified(cb) { + return this.emitter.on('did-change-modified', cb); + } + onDidSave(cb) { + return this.emitter.on('did-save', cb); + } + onDidChangePath(cb) { + return this.emitter.on('did-change-path', cb); + } + onDidDelete (cb) { + return this.emitter.on('did-change-deleted', cb); + } + onDidConflict (cb) { + return this.emitter.on('did-conflict', cb); + } + + save() { + // Mimic what saving would do for a typical pane item -- clear any + // "deleted" state and then trigger a save event. + this._isDeleted = false; + this._isConflicted = false; + this._trigger('did-save', { path: this.getPath() }); + } + + isModified () { + return this._isModified; + } + + isInConflict () { + return this._isConflicted; + } + + isDeleted() { + return this._isDeleted; + } + + _setIsModified (isModified) { + let wasModified = this._isModified; + this._isModified = isModified; + if (isModified !== wasModified) { + this._trigger('did-change-modified', isModified); + } + } + + _setIsConflicted (isConflicted) { + let wasConflicted = this._isConflicted; + this._isConflicted = isConflicted; + if (isConflicted && !wasConflicted) { + this._trigger('did-conflict', isConflicted); + } + } + + _setIsDeleted (isDeleted) { + let wasDeleted = this._isDeleted; + this._isDeleted = isDeleted; + if (isDeleted && !wasDeleted) { + console.log('Triggering deletion change!'); + this._trigger('did-change-deleted', isDeleted); + } + } + + _trigger (eventName, ...args) { + this.emitter.emit(eventName, ...args); + } +} + + describe("Tabs package main", () => { let centerElement = null; @@ -96,6 +181,7 @@ describe("TabBarView", () => { } beforeEach(() => { + atom.config.set('tabs.enableItemStatusColoring', true); deserializerDisposable = atom.deserializers.add(TestView); item1 = new TestView('Item 1', undefined, "squirrel", "sample.js"); item2 = new TestView('Item 2'); @@ -219,6 +305,7 @@ describe("TabBarView", () => { describe("when a new item is added to the pane", () => { it("adds the 'modified' class to the new tab if the item is initially modified", () => { let editor2 = null; + let paneItem = null; waitsForPromise(() => { if (atom.workspace.createItemForURI != null) { @@ -232,9 +319,79 @@ describe("TabBarView", () => { editor2.insertText('x'); pane.activateItem(editor2); expect(tabBar.tabForItem(editor2).element).toHaveClass('modified'); + if (paneItem) { + pane.removeItem(paneItem); + } }); }); + it("adds a 'deleted' class if the item signals that it is entering the 'deleted' state", () => { + paneItem = new SamplePaneItem('test-1'); + console.log('Adding to pane:', pane); + pane.addItem(paneItem); + + let tabBar = new TabBarView(pane, 'center'); + + let tab = tabBar.tabs.find(t => t.item === paneItem); + expect(!!tab).toBe(true); + + expect(tab.element).not.toHaveClass('deleted'); + + paneItem._setIsDeleted(true); + + expect(tab.element).toHaveClass('deleted'); + + // Toggling this setting should immediately remove/restore the + // associated class name. + atom.config.set('tabs.enableItemStatusColoring', false); + expect(tab.element).not.toHaveClass('deleted'); + + atom.config.set('tabs.enableItemStatusColoring', true); + expect(tab.element).toHaveClass('deleted'); + + paneItem.save(); + expect(tab.element).not.toHaveClass('deleted'); + + // When this setting is `false`, no status-related class names should be + // added to the tab. + atom.config.set('tabs.enableItemStatusColoring', false); + paneItem._setIsDeleted(true); + expect(tab.element).not.toHaveClass('deleted'); + }); + + it("adds a 'conflicted' class if the item signals that it is entering the 'conflicted' state", () => { + paneItem = new SamplePaneItem('test-1'); + pane.addItem(paneItem); + + let tabBar = new TabBarView(pane, 'center'); + + let tab = tabBar.tabs.find(t => t.item === paneItem); + expect(!!tab).toBe(true); + + expect(tab.element).not.toHaveClass('conflicted'); + + paneItem._setIsConflicted(true); + + expect(tab.element).toHaveClass('conflicted'); + + // Toggling this setting should immediately remove/restore the + // associated class name. + atom.config.set('tabs.enableItemStatusColoring', false); + expect(tab.element).not.toHaveClass('conflicted'); + + atom.config.set('tabs.enableItemStatusColoring', true); + expect(tab.element).toHaveClass('conflicted'); + + paneItem.save(); + expect(tab.element).not.toHaveClass('conflicted'); + + // When this setting is `false`, no status-related class names should be + // added to the tab. + atom.config.set('tabs.enableItemStatusColoring', false); + paneItem._setIsConflicted(true); + expect(tab.element).not.toHaveClass('conflicted'); + }); + describe("when addNewTabsAtEnd is set to true in package settings", () => { it("adds a tab for the new item at the end of the tab bar", () => { atom.config.set("tabs.addNewTabsAtEnd", true); From d7da43cac87a0bc75e49b6be0fcc8f2d57ef5a70 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Sat, 30 May 2026 18:39:14 -0700 Subject: [PATCH 09/14] =?UTF-8?q?[tabs]=20Make=20the=20new=20status=20colo?= =?UTF-8?q?ring=20governed=20by=20a=20setting=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …named `tabs.enableItemStatusColoring`. --- packages/tabs/lib/tab-view.js | 31 +++++++++++++++++++++++-------- packages/tabs/package.json | 6 ++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/packages/tabs/lib/tab-view.js b/packages/tabs/lib/tab-view.js index 4f528b23a5..79e28cb864 100644 --- a/packages/tabs/lib/tab-view.js +++ b/packages/tabs/lib/tab-view.js @@ -80,7 +80,9 @@ class TabView { } else { this.unsetVcsStatus(); } - }) + }), + + atom.config.observe('tabs.enableItemStatusColoring', this.setBufferStatusColoring.bind(this)) ); const titleChangedHandler = () => this.updateTitle(); @@ -197,6 +199,18 @@ class TabView { } } + setBufferStatusColoring (isEnabled) { + console.log('Setting buffer status coloring to', isEnabled); + this.useBufferStatusColoring = isEnabled; + if (!isEnabled) { + this.toggleTabClass('conflicted', false); + this.toggleTabClass('deleted', false); + } else { + this.toggleTabClass('conflicted', this.isConflicted); + this.toggleTabClass('deleted', this.isDeleted); + } + } + addItemDisposable (disposable, methodName) { if (!Disposable.isDisposable(disposable)) { console.warn(`::${methodName} does not return a valid Disposable!`, this.item); @@ -348,22 +362,23 @@ class TabView { updateConflictedStatus () { this.isConflicted = this.item.isInConflict?.() ?? false; - this.toggleTabClass('conflicted', this.isConflicted); + this.toggleTabClass('conflicted', this.useBufferStatusColoring && this.isConflicted); return this.isConflicted; } + updateDeletedStatus () { + this.isDeleted = this.item.isDeleted?.() ?? false; + console.log('using buffer status coloring?', this.useBufferStatusColoring); + this.toggleTabClass('deleted', this.useBufferStatusColoring && this.isDeleted); + return this.isDeleted; + } + updateModifiedStatus() { this.isModified = this.item.isModified?.() ?? false; this.toggleTabClass('modified', this.isModified); return this.isModified; } - updateDeletedStatus () { - this.isDeleted = this.item.isDeleted?.() ?? false; - this.toggleTabClass('deleted', this.isDeleted); - return this.isDeleted; - } - setupVcsStatus() { if (this.path == null) { return; } return this.repoForPath(this.path).then(repo => { diff --git a/packages/tabs/package.json b/packages/tabs/package.json index e4b22ec4f1..9c52b18817 100644 --- a/packages/tabs/package.json +++ b/packages/tabs/package.json @@ -60,6 +60,12 @@ "default": false, "description": "Color file names in tabs based on VCS status, similar to how file names are colored in the tree view." }, + "enableItemStatusColoring": { + "type": "boolean", + "title": "Enable Item Status Coloring", + "default": true, + "description": "Color tab names based on internal item status. “Deleted” status means the item once had a file on disk but it was deleted. “Conflicted” status means the file on disk changed while the item had unsaved changes. (Not all themes add styles for these states.)" + }, "addNewTabsAtEnd": { "type": "boolean", "default": false, From 12ad10ddf36149940c28c83aa7205a0de35b2743 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Sat, 30 May 2026 19:41:18 -0700 Subject: [PATCH 10/14] [tabs] Remove logging --- packages/tabs/lib/tab-view.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/tabs/lib/tab-view.js b/packages/tabs/lib/tab-view.js index 79e28cb864..39e1f09654 100644 --- a/packages/tabs/lib/tab-view.js +++ b/packages/tabs/lib/tab-view.js @@ -200,7 +200,6 @@ class TabView { } setBufferStatusColoring (isEnabled) { - console.log('Setting buffer status coloring to', isEnabled); this.useBufferStatusColoring = isEnabled; if (!isEnabled) { this.toggleTabClass('conflicted', false); @@ -368,7 +367,6 @@ class TabView { updateDeletedStatus () { this.isDeleted = this.item.isDeleted?.() ?? false; - console.log('using buffer status coloring?', this.useBufferStatusColoring); this.toggleTabClass('deleted', this.useBufferStatusColoring && this.isDeleted); return this.isDeleted; } From db327b82312abd3353f61585247ef096cc1f4344 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Sat, 30 May 2026 20:22:09 -0700 Subject: [PATCH 11/14] Be paranoid about calling certain methods on pane items --- src/pane.js | 46 +++++++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/src/pane.js b/src/pane.js index bced730f48..20bc67d0ba 100644 --- a/src/pane.js +++ b/src/pane.js @@ -16,7 +16,27 @@ class SaveConflictedError extends Error { name = 'SaveConflictedError'; } - +// Handles values that could be booleans, functions, or undefined. +// +// Pane items implement several `isX` methods — `isDestroyed`, `isDeleted`, +// `isModified`, et cetera. But all of these are optional. We could just do +// `item.isDestroyed?.()` — except that, in at least one high-profile case, a +// package has implemented defined `isDestroyed` _incorrectly_ as a boolean +// instead of a function! And calling `item.isDestroyed?.()` on a boolean +// throws an error. +// +// So this is our way of handling such scenarios without re-introducing lots of +// boilerplate. We use this for some `isX` methods, but not all. (Newer methods +// don't get this treatment because of almost zero chance of misimplementation +// in the wild.) +function interpret(booleanOrFunction) { + if (typeof booleanOrFunction === 'boolean') { + return booleanOrFunction; + } else if (typeof booleanOrFunction === 'function') { + return booleanOrFunction(); + } + return false; +} // Extended: A container for presenting content in the center of the workspace. // Panes can contain multiple items, one of which is *active* at a given time. @@ -664,7 +684,7 @@ module.exports = class Pane { ); } - if (this.paneItemIsDestroyed(item)) { + if (interpret(item.isDestroyed)) { throw new Error( `Adding a pane item with URI '${typeof item.getURI === 'function' && item.getURI()}' that has already been destroyed` @@ -704,15 +724,6 @@ module.exports = class Pane { return item; } - paneItemIsDestroyed (item) { - if (typeof item.isDestroyed === 'boolean') { - return item.isDestroyed; - } else if (typeof item.isDestroyed === 'function') { - return item.isDestroyed(); - } - return false; - } - setPendingItem(item) { if (this.pendingItem !== item) { const mostRecentPendingItem = this.pendingItem; @@ -851,15 +862,15 @@ module.exports = class Pane { // Don't allow deletion of permanent dock items unless `force` is `true`. if ( !force && - item.isPermanentDockItem?.() && + interpret(item.isPermanentDockItem) && (!this.container || this.container.getLocation() !== 'center') ) { return false; } // In the case where there are no `onWillDestroyPaneItem` listeners, - // preserve the old behavior where `Pane.destroyItem` and callers such as - // `Pane.close` take effect synchronously. + // preserve the old behavior where `Pane::destroyItem` and callers such as + // `Pane::close` take effect synchronously. if (this.emitter.listenerCountForEventName('will-destroy-item') > 0) { await this.emitter.emitAsync('will-destroy-item', { item, index }); } @@ -927,8 +938,9 @@ module.exports = class Pane { const title = item.getTitle?.() || uri; let message, detail; - let firstButton = item.isDeleted() ? 'Save' : 'Overwrite'; - if (item.isDeleted()) { + let isDeleted = interpret(item.isDeleted); + let firstButton = isDeleted ? 'Save' : 'Overwrite'; + if (isDeleted) { // The message is a bit different when the file no longer exists on // disk. message = `'${title}' was deleted on disk. Do you still want to save this file?`; @@ -1163,7 +1175,7 @@ module.exports = class Pane { // Public: Save all items. saveItems() { for (let item of this.getItems()) { - if (item.isModified?.()) { + if (interpret(item.isModified)) { this.saveItem(item); } } From 73d7fa778f3be20fe2dedaf7a6b3a8b32b77661a Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Sat, 30 May 2026 22:58:12 -0700 Subject: [PATCH 12/14] =?UTF-8?q?Whoops=20=E2=80=94=20fix=20binding=20issu?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pane.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/pane.js b/src/pane.js index 20bc67d0ba..27fdf834a0 100644 --- a/src/pane.js +++ b/src/pane.js @@ -29,11 +29,12 @@ class SaveConflictedError extends Error { // boilerplate. We use this for some `isX` methods, but not all. (Newer methods // don't get this treatment because of almost zero chance of misimplementation // in the wild.) -function interpret(booleanOrFunction) { +function interpret(obj, booleanOrFunctionName) { + let booleanOrFunction = obj[booleanOrFunctionName]; if (typeof booleanOrFunction === 'boolean') { return booleanOrFunction; } else if (typeof booleanOrFunction === 'function') { - return booleanOrFunction(); + return obj[booleanOrFunctionName](); } return false; } @@ -684,7 +685,7 @@ module.exports = class Pane { ); } - if (interpret(item.isDestroyed)) { + if (interpret(item, 'isDestroyed')) { throw new Error( `Adding a pane item with URI '${typeof item.getURI === 'function' && item.getURI()}' that has already been destroyed` @@ -862,7 +863,7 @@ module.exports = class Pane { // Don't allow deletion of permanent dock items unless `force` is `true`. if ( !force && - interpret(item.isPermanentDockItem) && + interpret(item, 'isPermanentDockItem') && (!this.container || this.container.getLocation() !== 'center') ) { return false; @@ -938,7 +939,7 @@ module.exports = class Pane { const title = item.getTitle?.() || uri; let message, detail; - let isDeleted = interpret(item.isDeleted); + let isDeleted = interpret(item, 'isDeleted'); let firstButton = isDeleted ? 'Save' : 'Overwrite'; if (isDeleted) { // The message is a bit different when the file no longer exists on @@ -1175,7 +1176,7 @@ module.exports = class Pane { // Public: Save all items. saveItems() { for (let item of this.getItems()) { - if (interpret(item.isModified)) { + if (interpret(item, 'isModified')) { this.saveItem(item); } } From 18706dc76c58cd33a18db62eeeac25a03565f9a4 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Sat, 30 May 2026 23:25:02 -0700 Subject: [PATCH 13/14] Stop trying to be fancy --- src/pane.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pane.js b/src/pane.js index 27fdf834a0..11406d0caa 100644 --- a/src/pane.js +++ b/src/pane.js @@ -31,9 +31,7 @@ class SaveConflictedError extends Error { // in the wild.) function interpret(obj, booleanOrFunctionName) { let booleanOrFunction = obj[booleanOrFunctionName]; - if (typeof booleanOrFunction === 'boolean') { - return booleanOrFunction; - } else if (typeof booleanOrFunction === 'function') { + if (typeof booleanOrFunction === 'function') { return obj[booleanOrFunctionName](); } return false; From dd43d3ffdb7ff9e2b870d8a731ff18303c23ebb6 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Sat, 30 May 2026 23:44:16 -0700 Subject: [PATCH 14/14] Fix pane specs (again) --- src/pane.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pane.js b/src/pane.js index 11406d0caa..a4dda357bd 100644 --- a/src/pane.js +++ b/src/pane.js @@ -986,13 +986,13 @@ module.exports = class Pane { return resolve(true); } - // A pane item must have a URI so that we have a "title of last resort" - // to show in the dialog. - let uri = item.getURI?.() ?? item.getUri?.() ?? null; - if (!uri) { + // If the item has no way of providing us a URI, then it probably wasn't + // designed to represent a resource that can be saved. + if (typeof item.getURI !== 'function' && typeof item.getUri !== 'function') { return resolve(true); } + let uri = item.getURI?.() ?? item.getUri?.(); let title = item.getTitle?.() || uri; const saveDialog = (saveButtonText, saveFn, message) => {