[terminal] IJPL-212254 Persist terminal tool window tabs

Listen for tool window tab manipulations, title and working directory changes and trigger persistence.
Persistence is done periodically with 300 ms interval to protect from bursting updates.

(cherry picked from commit 5ce6dfb967e04d1886aa5b3240f50f99aa0c46e2)

IJ-CR-214214

GitOrigin-RevId: b4ad99c220dcdb19834a463ca39040e046f16f74
This commit is contained in:
Konstantin Hudyakov
2026-08-04 14:03:37 +00:00
committed by intellij-monorepo-bot
parent 79c6b3f1c8
commit 62f28de625
4 changed files with 181 additions and 9 deletions
@@ -4,10 +4,12 @@ import com.intellij.frontend.FrontendApplicationInfo
import com.intellij.frontend.FrontendType
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.NlsSafe
import com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTab
import com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTabsManager
import com.intellij.terminal.frontend.view.TerminalView
import com.intellij.ui.ExperimentalUI
import com.intellij.ui.content.ContentManager
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.execution.ParametersListUtil
import org.jetbrains.plugins.terminal.TerminalEngine
import org.jetbrains.plugins.terminal.TerminalOptionsProvider
@@ -72,4 +74,9 @@ internal fun TerminalView.getRunningProcessCommandLine(): String? {
val currentBlock = shellIntegration.blocksModel.activeBlock as? TerminalCommandBlock ?: return null
currentBlock.executedCommand ?: return null
}
}
@RequiresEdt
internal fun ContentManager.getTerminalTabs(): List<TerminalToolWindowTab> {
return contentsRecursively.mapNotNull { it.getUserData(TerminalToolWindowTab.KEY) }
}
@@ -0,0 +1,153 @@
package com.intellij.terminal.frontend.toolwindow.impl
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.application.UI
import com.intellij.openapi.application.asContextElement
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.openapi.diagnostic.rethrowControlFlowException
import com.intellij.openapi.project.Project
import com.intellij.platform.util.coroutines.childScope
import com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTab
import com.intellij.ui.content.Content
import com.intellij.ui.content.ContentManager
import com.intellij.ui.content.ContentManagerEvent
import com.intellij.ui.content.ContentManagerListener
import com.intellij.util.concurrency.annotations.RequiresEdt
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import org.jetbrains.plugins.terminal.settings.impl.TerminalSessionPersistedTab
import org.jetbrains.plugins.terminal.settings.impl.TerminalTabsStorage
import kotlin.io.path.pathString
import kotlin.time.Duration.Companion.milliseconds
private val LOG = fileLogger()
/**
* Watches the terminal tab changes in the [contentManager] and updates persisted tabs in [TerminalTabsStorage].
*/
@OptIn(FlowPreview::class)
@RequiresEdt
internal fun installTerminalTabsPersistence(
project: Project,
contentManager: ContentManager,
coroutineScope: CoroutineScope,
) {
val updateRequestsFlow = MutableSharedFlow<Unit>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
coroutineScope.launch(Dispatchers.UI + ModalityState.any().asContextElement() + CoroutineName("persistTerminalTabs")) {
persistTerminalTabs(project, contentManager)
updateRequestsFlow
.sample(300.milliseconds)
.collect {
persistTerminalTabs(project, contentManager)
}
}
listenTerminalTabChangeEvents(contentManager, coroutineScope.childScope("listenTerminalTabChangeEvents")) {
updateRequestsFlow.tryEmit(Unit)
}
}
@RequiresEdt
private fun persistTerminalTabs(project: Project, contentManager: ContentManager) {
try {
val tabs = contentManager.getTerminalTabs().map { computePersistedTab(it) }
TerminalTabsStorage.getInstance(project).updateStoredTabs(tabs)
}
catch (e: Exception) {
rethrowControlFlowException(e)
LOG.error("Error while persisting terminal tabs", e)
}
}
@RequiresEdt
private fun computePersistedTab(tab: TerminalToolWindowTab): TerminalSessionPersistedTab {
val title = tab.view.title
val requestedProcessOptions = tab.processOptions
val processCurDirectory = tab.view.workingDirectoryFlow.value
// Prefer current directory of the running process
val workingDirectory = processCurDirectory?.pathString ?: requestedProcessOptions.workingDirectory
return TerminalSessionPersistedTab(
name = title.userDefinedTitle ?: title.defaultTitle,
isUserDefinedName = title.userDefinedTitle != null,
shellCommand = requestedProcessOptions.shellCommand,
workingDirectory = workingDirectory,
envVariables = requestedProcessOptions.envVariables,
processType = requestedProcessOptions.processType,
)
}
/**
* Calls [onChange] when:
* 1. Terminal tabs are added or removed from the Terminal Tool Window
* 2. TerminalView title changes
* 3. TerminalView working directory changes
*/
@RequiresEdt
private fun listenTerminalTabChangeEvents(
contentManager: ContentManager,
coroutineScope: CoroutineScope,
onChange: () -> Unit,
) {
val contents = mutableListOf<ContentWithListenersScope>()
fun addTerminalViewListeners(content: Content) {
val terminalView = content.getUserData(TerminalToolWindowTab.KEY)?.view ?: return // not a terminal tab
val listenersScope = coroutineScope.childScope(terminalView.toString())
listenersScope.launch {
terminalView.titleStateFlow().collect {
onChange()
}
}
listenersScope.launch {
terminalView.workingDirectoryFlow.collect {
onChange()
}
}
contents.add(ContentWithListenersScope(content, listenersScope))
}
val listener = object : ContentManagerListener {
override fun contentAdded(event: ContentManagerEvent) {
onChange()
addTerminalViewListeners(event.content)
}
override fun contentRemoved(event: ContentManagerEvent) {
onChange()
// Cleanup listeners
val contentToScope = contents.find { it.content == event.content }
if (contentToScope != null) {
contentToScope.listenersScope.cancel()
contents.remove(contentToScope)
}
}
}
for (content in contentManager.contentsRecursively) {
addTerminalViewListeners(content)
}
contentManager.addRecursiveContentManagerListener(listener)
coroutineScope.coroutineContext.job.invokeOnCompletion {
contentManager.removeRecursiveContentManagerListener(listener)
}
}
private data class ContentWithListenersScope(
val content: Content,
val listenersScope: CoroutineScope,
)
@@ -76,7 +76,7 @@ internal fun updateFileNameOnTitleChange(
}
}
private fun TerminalView.titleStateFlow(): Flow<TitleData> {
internal fun TerminalView.titleStateFlow(): Flow<TitleData> {
val terminalView = this
val titleStateFlow: Flow<TitleData> = title.stateFlow(
@@ -34,6 +34,7 @@ import com.intellij.ui.content.ContentFactory
import com.intellij.ui.content.ContentManager
import com.intellij.util.AwaitCancellationAndInvoke
import com.intellij.util.awaitCancellationAndInvoke
import com.intellij.util.concurrency.annotations.RequiresEdt
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
@@ -63,7 +64,7 @@ internal class TerminalToolWindowTabsManagerImpl(
private val coroutineScope: CoroutineScope,
) : TerminalToolWindowTabsManager {
override val tabs: List<TerminalToolWindowTab>
get() = getToolWindow().contentManager.contentsRecursively.mapNotNull { it.getUserData(TerminalToolWindowTab.KEY) }
get() = getToolWindow().contentManager.getTerminalTabs()
private var tabsRestoredDeferred: Deferred<Unit> = CompletableDeferred(Unit)
@@ -280,13 +281,24 @@ internal class TerminalToolWindowTabsManagerImpl(
return toolWindow
}
@RequiresEdt
private fun installTabsPersistence() {
val toolWindow = getToolWindow()
installTerminalTabsPersistence(
project = toolWindow.project,
contentManager = toolWindow.contentManager,
coroutineScope = coroutineScope.childScope("TerminalTabsPersistence")
)
}
internal class Initializer : TerminalToolWindowInitializer {
override fun initialize(toolWindow: ToolWindow) {
val manager = TerminalToolWindowTabsManager.getInstance(toolWindow.project) as TerminalToolWindowTabsManagerImpl
if (shouldUseReworkedTerminal()) {
if (shouldUseReworkedTerminal() && TrustedProjects.isProjectTrusted(manager.project)) {
scheduleTabsRestoring(manager)
}
else manager.installTabsPersistence()
TerminalAgentsAvailabilityService.getInstance(toolWindow.project).prewarm()
@@ -312,12 +324,12 @@ internal class TerminalToolWindowTabsManagerImpl(
}
private fun scheduleTabsRestoring(manager: TerminalToolWindowTabsManagerImpl) {
if (TrustedProjects.isProjectTrusted(manager.project)) {
manager.tabsRestoredDeferred = manager.coroutineScope.async {
val tabs: List<TerminalSessionPersistedTab> = TerminalTabsStorage.getInstance(manager.project).getStoredTabs()
withContext(Dispatchers.EDT + ModalityState.any().asContextElement()) {
restoreTabs(tabs, manager)
}
manager.tabsRestoredDeferred = manager.coroutineScope.async {
val tabs: List<TerminalSessionPersistedTab> = TerminalTabsStorage.getInstance(manager.project).getStoredTabs()
withContext(Dispatchers.EDT + ModalityState.any().asContextElement()) {
restoreTabs(tabs, manager)
// Install tabs persistence after restoring already stored tabs to not override them accidentally with empty content.
manager.installTabsPersistence()
}
}
}