diff --git a/.idea/modules.xml b/.idea/modules.xml
index 8f018c48fbd9..4066899c77f4 100644
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -1614,6 +1614,7 @@
+
diff --git a/BUILD.bazel b/BUILD.bazel
index f542074caf0d..47ce414952e5 100644
--- a/BUILD.bazel
+++ b/BUILD.bazel
@@ -628,6 +628,8 @@ jvm_library(
"//xml/dom-tests:dom-tests_test_lib",
"//plugins/IntelliLang/tests",
"//plugins/IntelliLang/tests:tests_test_lib",
+ "//platform/recentFiles/tests",
+ "//platform/recentFiles/tests:tests_test_lib",
"//xml/tests",
"//xml/tests:tests_test_lib",
"//jps/model-serialization:model-serialization-tests",
diff --git a/build/bazel-generated-file-list.txt b/build/bazel-generated-file-list.txt
index 106f23b8dcf5..44fb8e4c795b 100644
--- a/build/bazel-generated-file-list.txt
+++ b/build/bazel-generated-file-list.txt
@@ -819,6 +819,7 @@ platform/recentFiles/backend
platform/recentFiles/frontend
platform/recentFiles/plugin
platform/recentFiles/shared
+platform/recentFiles/tests
platform/refactoring
platform/remote-core
platform/remote-driver/client
diff --git a/intellij.idea.community.main.tests.iml b/intellij.idea.community.main.tests.iml
index 4a05cabe74a6..dfd86a0cbbf3 100644
--- a/intellij.idea.community.main.tests.iml
+++ b/intellij.idea.community.main.tests.iml
@@ -14,6 +14,7 @@
+
diff --git a/platform/recentFiles/tests/BUILD.bazel b/platform/recentFiles/tests/BUILD.bazel
new file mode 100644
index 000000000000..4e863f6044e7
--- /dev/null
+++ b/platform/recentFiles/tests/BUILD.bazel
@@ -0,0 +1,88 @@
+load("@community//build:tests-options.bzl", "jps_test")
+load("@rules_jvm//:jvm.bzl", "jvm_library")
+
+### auto-generated section `build intellij.platform.recentFiles.tests` start
+
+jvm_library(
+ name = "tests",
+ srcs = glob(
+ [],
+ allow_empty = True,
+ ),
+ module_name = "intellij.platform.recentFiles.tests",
+ visibility = ["//visibility:public"],
+ runtime_deps = [
+ # do not sort,
+ "@lib//:kotlin-stdlib",
+ "//platform/recentFiles/plugin",
+ ],
+)
+
+jvm_library(
+ name = "tests_test_lib",
+ testonly = True,
+ srcs = glob(
+ [
+ "testSrc/**/*.kt",
+ "testSrc/**/*.java",
+ "testSrc/**/*.form",
+ ],
+ allow_empty = True,
+ ),
+ associates = [
+ "//platform/recentFiles/backend",
+ "//platform/recentFiles/backend:backend_test_lib",
+ ],
+ visibility = ["//visibility:public"],
+ runtime_deps = [
+ # do not sort,
+ ":tests",
+ "//platform/recentFiles/plugin:plugin_test_lib",
+ "//platform/platform-impl:ide-impl_test_lib",
+ "//platform/platform-impl/rpc:rpc_test_lib",
+ ],
+ deps = [
+ # do not sort,
+ "@lib//:kotlin-stdlib",
+ "//platform/recentFiles/frontend",
+ "//platform/recentFiles/frontend:frontend_test_lib",
+ "//platform/recentFiles/shared:recentFiles",
+ "//platform/recentFiles/shared:recentFiles_test_lib",
+ "//platform/testFramework/junit5",
+ "//platform/testFramework/junit5:junit5_test_lib",
+ "//platform/testFramework",
+ "//platform/testFramework:testFramework_test_lib",
+ "//libraries/junit5",
+ "//libraries/junit5:junit5_test_lib",
+ "//libraries/kotlinx/coroutines/core",
+ "//libraries/kotlinx/coroutines/core:core_test_lib",
+ "//platform/core-api:core",
+ "//platform/core-api:core_test_lib",
+ "//platform/util/concurrency",
+ "//platform/util/concurrency:concurrency_test_lib",
+ "//platform/platform-impl:ide-impl",
+ "//platform/platform-impl/rpc",
+ "//platform/kernel/rpc.backend",
+ "//platform/kernel/rpc.backend:rpc.backend_test_lib",
+ "//platform/util:util-ui",
+ "//platform/util:util-ui_test_lib",
+ ],
+)
+### auto-generated section `build intellij.platform.recentFiles.tests` end
+
+### auto-generated section `iml intellij.platform.recentFiles.tests` start
+exports_files(
+ [
+ "intellij.platform.recentFiles.tests.iml",
+ ],
+ visibility = ["//visibility:public"],
+)
+### auto-generated section `iml intellij.platform.recentFiles.tests` end
+
+### auto-generated section `test intellij.platform.recentFiles.tests` start
+
+jps_test(
+ name = "tests_test",
+ runtime_deps = [":tests_test_lib"],
+)
+### auto-generated section `test intellij.platform.recentFiles.tests` end
diff --git a/platform/recentFiles/tests/intellij.platform.recentFiles.tests.iml b/platform/recentFiles/tests/intellij.platform.recentFiles.tests.iml
new file mode 100644
index 000000000000..4ad9565d0363
--- /dev/null
+++ b/platform/recentFiles/tests/intellij.platform.recentFiles.tests.iml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/platform/recentFiles/tests/testSrc/com/intellij/platform/recentFiles/backend/RecentFilesDistributedModelTest.kt b/platform/recentFiles/tests/testSrc/com/intellij/platform/recentFiles/backend/RecentFilesDistributedModelTest.kt
new file mode 100644
index 000000000000..05388e01f0f8
--- /dev/null
+++ b/platform/recentFiles/tests/testSrc/com/intellij/platform/recentFiles/backend/RecentFilesDistributedModelTest.kt
@@ -0,0 +1,174 @@
+// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.intellij.platform.recentFiles.backend
+
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.application.EDT
+import com.intellij.openapi.fileEditor.impl.IdeDocumentHistoryImpl
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.vfs.VirtualFile
+import com.intellij.platform.recentFiles.frontend.RecentFilesExcluder
+import com.intellij.platform.recentFiles.frontend.model.FrontendRecentFilesModel
+import com.intellij.platform.recentFiles.shared.FileChangeKind
+import com.intellij.platform.recentFiles.shared.RecentFileKind
+import com.intellij.testFramework.LightVirtualFile
+import com.intellij.testFramework.junit5.TestApplication
+import com.intellij.testFramework.junit5.TestDisposable
+import com.intellij.testFramework.junit5.fixture.projectFixture
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.fail
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+@TestApplication
+class RecentFilesDistributedModelTest {
+ private val projectFixture = projectFixture()
+ private val project by projectFixture
+
+ @TestDisposable
+ lateinit var disposable: Disposable
+
+ @BeforeEach
+ fun setUp() {
+ RecentFilesExcluder.EP_NAME.point.registerExtension(TestDiffRecentFilesExcluder(), disposable)
+ }
+
+ @Test
+ fun `regular file open and close are synchronized between frontend and backend models`() = runBlocking {
+ withModelSynchronization {
+ val file = LightVirtualFile("regular.txt", "content")
+ val anchorFile1 = LightVirtualFile("anchor1.txt", "content")
+ val anchorFile2 = LightVirtualFile("anchor2.txt", "content")
+ val openedFiles = listOf(file, anchorFile1, anchorFile2)
+ val frontendModel = FrontendRecentFilesModel.getInstance(project)
+
+ frontendModel.applyFrontendChanges(RecentFileKind.RECENTLY_OPENED, openedFiles, FileChangeKind.ADDED)
+ frontendModel.applyFrontendChanges(RecentFileKind.RECENTLY_OPENED_UNPINNED, openedFiles, FileChangeKind.ADDED)
+
+ waitUntilFileIsPresent(file, RecentFileKind.RECENTLY_OPENED)
+ waitUntilFileIsPresent(file, RecentFileKind.RECENTLY_OPENED_UNPINNED)
+
+ frontendModel.applyFrontendChanges(RecentFileKind.RECENTLY_OPENED_UNPINNED, listOf(file), FileChangeKind.REMOVED)
+
+ waitUntilFileIsAbsent(file, RecentFileKind.RECENTLY_OPENED_UNPINNED)
+ assertBothModelsContain(file, RecentFileKind.RECENTLY_OPENED)
+ }
+ }
+
+ @Test
+ fun `diff file open and close are synchronized between frontend and backend models`() = runBlocking {
+ withModelSynchronization {
+ val file = TestDiffVirtualFile("diff.txt")
+ val frontendModel = FrontendRecentFilesModel.getInstance(project)
+
+ frontendModel.applyFrontendChanges(RecentFileKind.RECENTLY_OPENED, listOf(file), FileChangeKind.ADDED)
+ frontendModel.applyFrontendChanges(RecentFileKind.RECENTLY_OPENED_UNPINNED, listOf(file), FileChangeKind.ADDED)
+
+ waitUntilFileIsPresent(file, RecentFileKind.RECENTLY_OPENED)
+ waitUntilFileIsPresent(file, RecentFileKind.RECENTLY_OPENED_UNPINNED)
+
+ file.isIncludedInHistory = false
+ frontendModel.applyFrontendChanges(RecentFileKind.RECENTLY_OPENED_UNPINNED, listOf(file), FileChangeKind.REMOVED)
+ frontendModel.applyFrontendChanges(RecentFileKind.RECENTLY_OPENED, listOf(file), FileChangeKind.REMOVED)
+
+ waitUntilFileIsAbsent(file, RecentFileKind.RECENTLY_OPENED)
+ waitUntilFileIsAbsent(file, RecentFileKind.RECENTLY_OPENED_UNPINNED)
+ }
+ }
+
+ private suspend fun withModelSynchronization(action: suspend CoroutineScope.() -> Unit) = coroutineScope {
+ val subscriptions = startModelSynchronization()
+ try {
+ action()
+ }
+ finally {
+ subscriptions.forEach(Job::cancel)
+ }
+ }
+
+ private fun CoroutineScope.startModelSynchronization(): List {
+ val backendModel = BackendRecentFilesModel.getInstance(project)
+ val frontendModel = FrontendRecentFilesModel.getInstance(project)
+ return RecentFileKind.entries.flatMap { kind ->
+ listOf(
+ launch { backendModel.subscribeToBackendRecentFilesUpdates(kind) },
+ launch { frontendModel.subscribeToBackendRecentFilesUpdates(kind) }
+ )
+ }
+ }
+
+ private suspend fun waitUntilFileIsPresent(file: VirtualFile, kind: RecentFileKind) {
+ waitUntil("Expected ${file.name} to appear in $kind") {
+ frontendFiles(kind).contains(file) && backendFiles(kind).contains(file)
+ }
+ }
+
+ private suspend fun waitUntilFileIsAbsent(file: VirtualFile, kind: RecentFileKind) {
+ var frontendFiles = emptyList()
+ var backendFiles = emptyList()
+ waitUntil({ "Expected ${file.name} to disappear from $kind, frontend=${frontendFiles.map { it.name }}, backend=${backendFiles.map { it.name }}" }) {
+ frontendFiles = frontendFiles(kind)
+ backendFiles = backendFiles(kind)
+ !frontendFiles.contains(file) && !backendFiles.contains(file)
+ }
+ }
+
+ private suspend fun assertBothModelsContain(file: VirtualFile, kind: RecentFileKind) {
+ val frontendFiles = frontendFiles(kind)
+ val backendFiles = backendFiles(kind)
+ assertTrue(frontendFiles.contains(file), "Expected frontend $kind to contain ${file.name}, got ${frontendFiles.map { it.name }}")
+ assertTrue(backendFiles.contains(file), "Expected backend $kind to contain ${file.name}, got ${backendFiles.map { it.name }}")
+ }
+
+ private suspend fun waitUntil(message: String, condition: suspend () -> Boolean) {
+ waitUntil({ message }, condition)
+ }
+
+ private suspend fun waitUntil(message: () -> String, condition: suspend () -> Boolean) {
+ try {
+ withTimeout(5.seconds) {
+ while (!condition()) {
+ delay(50.milliseconds)
+ }
+ }
+ }
+ catch (_: TimeoutCancellationException) {
+ fail(message())
+ }
+ }
+
+ private suspend fun frontendFiles(kind: RecentFileKind): List {
+ return withContext(Dispatchers.EDT) {
+ FrontendRecentFilesModel.getInstance(project).getRecentFiles(kind).mapNotNull { it.virtualFile }
+ }
+ }
+
+ private fun backendFiles(kind: RecentFileKind): List {
+ return BackendRecentFilesModel.getInstance(project).getFilesByKind(kind)
+ }
+
+ private class TestDiffVirtualFile(name: String) : LightVirtualFile(name, "diff"), IdeDocumentHistoryImpl.OptionallyIncluded {
+ var isIncludedInHistory: Boolean = true
+
+ override fun isIncludedInDocumentHistory(project: Project): Boolean {
+ return isIncludedInHistory
+ }
+ }
+
+ private class TestDiffRecentFilesExcluder : RecentFilesExcluder {
+ override fun isExcludedFromRecentlyOpened(project: Project, file: VirtualFile): Boolean {
+ return file is TestDiffVirtualFile && !file.isIncludedInDocumentHistory(project)
+ }
+ }
+}