fix: ensure terminal API object identity and respect grouping mode for plugin-created terminals#17340
Conversation
eneufeld
left a comment
There was a problem hiding this comment.
Looks good to me overall. Some small things to check though.
| const parentTerminal = options.location.parentTerminal; | ||
| if (parentTerminal instanceof TerminalExtImpl) { | ||
| for (const [k, v] of this._terminals) { | ||
| if (v === parentTerminal) { |
There was a problem hiding this comment.
could you check whether this path works correctly? parentTerminal is a proxy (from createAPIObject) but v is the raw object see the obtainTerminal method. This might have been there earlier but we should fix this.
There was a problem hiding this comment.
Good catch! You're right, the === comparison would fail when a plugin passes the API proxy object (which is what plugins receive) since _terminals only stores the raw TerminalExtImpl. Fixed by checking _terminalApiObjects first, then falling back to _terminals.
| this._pseudoTerminals.set(id, new PseudoTerminal(id, this.proxy, options.pty)); | ||
| return this.proxy.$createTerminal(id, { name: options.name }, undefined, true); | ||
| } else { | ||
| return this.proxy.$createTerminal(id, profile.options); |
There was a problem hiding this comment.
do we need to store an instance in the _terminalApiObject map?
There was a problem hiding this comment.
No, this path is fine as-is. $startProfile is an RPC call from the Main side, not the createTerminal path that plugins use directly. It calls this.proxy.$createTerminal(), which later triggers $terminalCreated on the Ext side via RPC, populating _terminals through obtainTerminal. Since no apiObjectWrapper is involved (there is no plugin caller receiving the return value), the raw TerminalExtImpl in _terminals is the API object, and the getApiObject fallback handles it correctly.
| get terminals(): TerminalExtImpl[] { | ||
| return [...this._terminals.values()]; | ||
| get terminals(): theia.Terminal[] { | ||
| return [...this._terminals.keys()].map(id => this.getApiObject(id)!).filter(t => t !== undefined); |
There was a problem hiding this comment.
why the ! after getApiObject? we filter anyway don't we?
There was a problem hiding this comment.
You're right, the ! was unnecessary since the .filter() already handles undefined. Removed the ! and added a type predicate to the filter instead: .filter((t): t is theia.Terminal => t !== undefined).
There was a problem hiding this comment.
should we add tests for this file? what and when is returning the correct instance is quite confusing
| protected registerTerminalListener(): void { | ||
| this.commandHandlerDisposables.push( | ||
| this.terminalFrontendContribution.onDidCreateTerminal(async terminal => { | ||
| if (this.isDebugTerminal(terminal)) { |
There was a problem hiding this comment.
so we now support all terminals and only ignore the ones for tasks? why this change?
There was a problem hiding this comment.
The old code only handled debug terminals (routing them into the manager). The new listener routes all externally-created terminals except tasks and hidden ones. This broadening is intentional: plugin-created terminals have no special kind property, so filtering by kind would not catch them. Task terminals are excluded because they have their own dedicated "Tasks" page (see #17105). Hidden terminals are excluded by design. Debug terminals and regular terminals should both appear in the tree manager when created externally.
| * Set to `true` while {@link createTerminalWidget} is executing, so that external | ||
| * listeners can distinguish internally created terminals from external ones (e.g. from plugins). | ||
| */ | ||
| creatingTerminalInternally = false; |
There was a problem hiding this comment.
if createTerminalWidget ever is called concurrently, this simple boolean flag will fail. we could use a counter instead. not sure this is worth doing .
There was a problem hiding this comment.
Replaced with a counter. It's a small change and makes the code correct under concurrent calls. The getter still returns a boolean so the external API is unchanged.
Resolves GH-16570 - Terminal events now fire with the same Proxy object returned to extensions - This fixes strict equality checks (===) failing in extensions like ms-python - Added apiObjectWrapper parameter to createTerminal for internal registration - Updated terminals getter and activeTerminal to return API objects - Updated $provideTerminalLinks to pass API object to link providers
…nager Part of GH-16570 - Extend terminal creation listener to intercept externally created terminals (e.g. from plugins) and add them to the terminal manager when grouping mode is set to 'tree' - Add creatingTerminalInternally flag to TerminalManagerWidget to distinguish internally vs externally created terminals - Move registerTerminalListener into registerHandlers so it is consistently (un)registered with preference changes
- Remove unnecessary non-null assertion in terminals getter - Fix parentTerminal lookup to check API proxy objects, not just raw terminals - Remove unused isDebugTerminal method - Use counter instead of boolean for creatingTerminalInternally flag - Add terminal-ext.spec.ts with 22 tests for API object identity and events Contributes to GH-16570
What it does
Fixes #16570
The ms-python extension uses strict instance equality (
===) in itsterminalCloseHandlerto check whether a terminal has been closed. However, Theia wraps terminal objects in aProxyviacreateAPIObject, so the object returned to extensions differs from the one fired in terminal events. This causes the equality check to fail, making the "Run Python File" button unresponsive after closing and reopening a terminal.onDidOpenTerminal,onDidCloseTerminal,onDidChangeTerminalState) now fire with the same Proxy-wrapped API object that was returned to extensionsterminalsgetter andactiveTerminalalso return the API objects_terminalApiObjectsmap inTerminalServiceExtImpltracks the Proxy wrappers alongside the raw terminal instancesterminal.grouping.modeis set totree, instead of always appearing as separate tabs in the bottom panelHow to test
Terminal identity fix (ms-python):
Terminal manager tree mode with plugin-created terminals:
terminal.grouping.modetotreein preferencesTask terminals in tree mode:
terminal.grouping.modeset totree, run a task (e.g. via Terminal > Run Task)Integrated terminal in tree mode:
terminal.grouping.modeset totree, create a new terminal via the command palette or Ctrl+`Tabbed mode (regression check):
terminal.grouping.modetotabbedSwitching modes at runtime:
tabbedmode and create some terminalstreemode and verify existing terminals migrate to the managertabbedand verify terminals migrate back to tabsExample python file to test:
Follow-ups
editor/title/runtoolbar button uses a hardcodeddebug-alticon instead of dynamically picking up the primary command's icon from the contributingextension
%debugpy.command.debuginterminal.title%) may appear unresolved in the toolbar dropdownBreaking changes
Attribution
Review checklist
nlsservice (for details, please see the Internationalization/Localization section in the Coding Guidelines)Reminder for reviewers