From e76924e91361a0a9e34b8df67dac9ec0cd8e1424 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:32:06 +0200 Subject: [PATCH 001/155] feat(rpc): add the chat contract between the frontend and the backend --- .gitignore | 3 ++ .../kotlin/dev/lain/claudejb/rpc/ChatApi.kt | 31 +++++++++++++++++++ .../kotlin/dev/lain/claudejb/rpc/ChatDtos.kt | 27 ++++++++++++++++ .../dev/lain/claudejb/rpc/FrontendChannel.kt | 14 +++++++++ 4 files changed, 75 insertions(+) create mode 100644 shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatApi.kt create mode 100644 shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatDtos.kt create mode 100644 shared/src/main/kotlin/dev/lain/claudejb/rpc/FrontendChannel.kt diff --git a/.gitignore b/.gitignore index 43df8a93..7756cbfd 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ # Source and resources # ------------------------------------------------------------------------------------------ !/src/** +!/shared/** +!/frontend/** +!/backend/** !/bin/** !/gradle/** !/config/** diff --git a/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatApi.kt b/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatApi.kt new file mode 100644 index 00000000..2c19ca82 --- /dev/null +++ b/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatApi.kt @@ -0,0 +1,31 @@ +package dev.lain.claudejb.rpc + +import com.intellij.platform.project.ProjectId +import com.intellij.platform.rpc.RemoteApiProviderService +import fleet.rpc.RemoteApi +import fleet.rpc.Rpc +import fleet.rpc.remoteApiDescriptor +import kotlinx.coroutines.flow.Flow + +@Rpc +interface ChatApi : RemoteApi { + suspend fun chats(projectId: ProjectId): List + + suspend fun events(projectId: ProjectId): Flow + + suspend fun newChat(projectId: ProjectId): ChatRef + + suspend fun select(projectId: ProjectId, chatId: ChatId) + + suspend fun close(projectId: ProjectId, chatId: ChatId) + + suspend fun post(projectId: ProjectId, chatId: ChatId, json: String) + + suspend fun pushes(projectId: ProjectId, chatId: ChatId): Flow + + suspend fun ready(projectId: ProjectId, chatId: ChatId) + + companion object { + suspend fun getInstance(): ChatApi = RemoteApiProviderService.resolve(remoteApiDescriptor()) + } +} diff --git a/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatDtos.kt b/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatDtos.kt new file mode 100644 index 00000000..0dbbfd92 --- /dev/null +++ b/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatDtos.kt @@ -0,0 +1,27 @@ +package dev.lain.claudejb.rpc + +import kotlinx.serialization.Serializable + +@Serializable +data class ChatId(val value: String) + +@Serializable +data class ChatRef(val id: ChatId, val title: String) + +@Serializable +data class PagePush(val method: String, val json: String) + +@Serializable +sealed interface ChatEvent { + @Serializable + data class Opened(val chat: ChatRef, val select: Boolean) : ChatEvent + + @Serializable + data class Closed(val id: ChatId) : ChatEvent + + @Serializable + data class Selected(val id: ChatId) : ChatEvent + + @Serializable + data class Renamed(val chat: ChatRef) : ChatEvent +} diff --git a/shared/src/main/kotlin/dev/lain/claudejb/rpc/FrontendChannel.kt b/shared/src/main/kotlin/dev/lain/claudejb/rpc/FrontendChannel.kt new file mode 100644 index 00000000..338f81c6 --- /dev/null +++ b/shared/src/main/kotlin/dev/lain/claudejb/rpc/FrontendChannel.kt @@ -0,0 +1,14 @@ +package dev.lain.claudejb.rpc + +object FrontendChannel { + const val PREFIX = "frontend." + const val COPY = "frontend.copy" + const val BROWSE = "frontend.browse" + const val VIBE = "frontend.vibe" + const val FOCUS = "frontend.focus" + const val ATTACH_IMAGE = "attachImageData" + + val clientMessages: Set = setOf("pasteClipboard", "pasteClipboardImage") + + fun isFrontendPush(method: String): Boolean = method.startsWith(PREFIX) +} From 4f11ce006ce65552e2f484eebb77e69f45aaeefa Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:35:43 +0200 Subject: [PATCH 002/155] test: add the benchmark harness for 6.5.0 --- .../chat/page-streaming.bench.test.js | 91 +++++++++++++++++++ .../lain/claudejb/bench/AgentScanBenchTest.kt | 43 +++++++++ .../dev/lain/claudejb/bench/BenchClock.kt | 23 +++++ .../dev/lain/claudejb/bench/BenchReport.kt | 23 +++++ .../dev/lain/claudejb/bench/BenchText.kt | 20 ++++ .../lain/claudejb/bench/DiffUnifiedBench.kt | 39 ++++++++ .../lain/claudejb/bench/LiveOutputBench.kt | 64 +++++++++++++ .../dev/lain/claudejb/bench/McpCharsBench.kt | 91 +++++++++++++++++++ .../claudejb/bench/PageAssemblyBenchTest.kt | 25 +++++ .../lain/claudejb/bench/RestoreBenchTest.kt | 36 ++++++++ .../lain/claudejb/bench/SessionLeakBench.kt | 49 ++++++++++ .../claudejb/bench/StdoutParseBenchTest.kt | 54 +++++++++++ .../lain/claudejb/bench/TranscriptFixture.kt | 46 ++++++++++ 13 files changed, 604 insertions(+) create mode 100644 src/test/frontend/chat/page-streaming.bench.test.js create mode 100644 src/test/kotlin/dev/lain/claudejb/bench/AgentScanBenchTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/bench/BenchClock.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/bench/BenchReport.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/bench/BenchText.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/bench/DiffUnifiedBench.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/bench/LiveOutputBench.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/bench/McpCharsBench.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/bench/PageAssemblyBenchTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/bench/RestoreBenchTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/bench/SessionLeakBench.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/bench/StdoutParseBenchTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/bench/TranscriptFixture.kt diff --git a/src/test/frontend/chat/page-streaming.bench.test.js b/src/test/frontend/chat/page-streaming.bench.test.js new file mode 100644 index 00000000..fb30afdb --- /dev/null +++ b/src/test/frontend/chat/page-streaming.bench.test.js @@ -0,0 +1,91 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { loadFrontend } = require('../helpers/load'); + +const MESSAGE_CHARS = 50 * 1024; +const DELTA_CHARS = 128; +const WARM_UP_CHARS = 8 * 1024; +const RUNS = 3; +const SEED = 42; +const RESULTS = path.resolve(__dirname, '../../../../build/bench/results.txt'); +const WORDS = ['stream', 'render', 'token', 'delta', 'markdown', 'row', 'update', 'transcript', 'page']; + +function seeded(seed) { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function words(random, count) { + return Array.from({ length: count }, () => WORDS[Math.floor(random() * WORDS.length)]).join(' '); +} + +function codeBlock() { + const body = WORDS.slice(0, 6).map((word, i) => `const ${word} = ${i};`); + return ['```js', ...body, '```'].join('\n'); +} + +function list(random) { + return Array.from({ length: 4 }, () => `- **${words(random, 2)}** ${words(random, 8)}`).join('\n'); +} + +function block(random, index) { + if (index % 5 === 3) return codeBlock(); + if (index % 5 === 4) return list(random); + return words(random, 60); +} + +function message(chars) { + const random = seeded(SEED); + const blocks = []; + let length = 0; + while (length < chars) { + const next = block(random, blocks.length); + blocks.push(next); + length += next.length + 2; + } + return blocks.join('\n\n').slice(0, chars); +} + +function entry(text, state) { + return { id: 1, order: 0, speaker: 'ASSISTANT', text, state, elapsed: 0 }; +} + +function stream(text) { + const win = loadFrontend(['app-transcript.js']); + const start = performance.now(); + for (let end = DELTA_CHARS; end < text.length; end += DELTA_CHARS) { + win.cc.batch([entry(text.slice(0, end), 'RUNNING')]); + } + win.cc.batch([entry(text, 'FINISHED')]); + const elapsed = performance.now() - start; + expect(win.document.querySelectorAll('.msg.assistant').length).toBe(1); + return elapsed; +} + +function median(samples) { + const sorted = [...samples].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +function record(name, value, unit) { + const line = `BENCH ${name} ${value.toFixed(2)} ${unit}`; + console.log(line); + fs.mkdirSync(path.dirname(RESULTS), { recursive: true }); + fs.appendFileSync(RESULTS, line + '\n'); +} + +describe('bench — an assistant message streamed into its transcript row', () => { + it('streams 50 KB in 128-character deltas, one per 30 ms tick', () => { + stream(message(WARM_UP_CHARS)); + const text = message(MESSAGE_CHARS); + const samples = Array.from({ length: RUNS }, () => stream(text)); + record('page_streaming', median(samples), 'ms'); + }, 600000); +}); diff --git a/src/test/kotlin/dev/lain/claudejb/bench/AgentScanBenchTest.kt b/src/test/kotlin/dev/lain/claudejb/bench/AgentScanBenchTest.kt new file mode 100644 index 00000000..dc2e00dc --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/bench/AgentScanBenchTest.kt @@ -0,0 +1,43 @@ +package dev.lain.claudejb.bench + +import dev.lain.claudejb.model.session.agents.AgentMeta +import dev.lain.claudejb.model.session.agents.AgentRegistry +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path + +@Tag("bench") +class AgentScanBenchTest { + + @TempDir + lateinit var dir: Path + + @Test + fun `a ten megabyte agent transcript is scanned by a fresh registry`() { + Files.writeString( + dir.resolve("${AgentMeta.FILE_PREFIX}$AGENT${AgentMeta.META_SUFFIX}"), + """{"agentType":"general-purpose","description":"Bench agent","spawnDepth":1}""", + ) + TranscriptFixture.write(dir.resolve(AgentMeta.transcriptFile(AGENT)), TARGET_BYTES, SEED) + var admitted = emptyList() + val median = BenchClock.medianMillis(WARMUPS, RUNS) { + val registry = AgentRegistry(subagentsDir = { dir }, now = { CLOCK }, runStartedAtMillis = CLOCK) + registry.preAdmit(listOf(AGENT)) + admitted = registry.scan() + } + assertEquals(listOf(AGENT), admitted) + BenchReport.record("agent_scan_10mb", median, "ms") + } + + private companion object { + const val AGENT = "bench" + const val TARGET_BYTES = 10L * 1024 * 1024 + const val SEED = 10 + const val CLOCK = 1_000_000_000L + const val WARMUPS = 2 + const val RUNS = 5 + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/bench/BenchClock.kt b/src/test/kotlin/dev/lain/claudejb/bench/BenchClock.kt new file mode 100644 index 00000000..9e2a2bb6 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/bench/BenchClock.kt @@ -0,0 +1,23 @@ +package dev.lain.claudejb.bench + +object BenchClock { + + fun millis(block: () -> Unit): Double { + val start = System.nanoTime() + block() + return (System.nanoTime() - start) / NANOS_PER_MILLI + } + + fun medianMillis(warmups: Int, runs: Int, block: () -> Unit): Double { + repeat(warmups) { block() } + return median(List(runs) { millis(block) }) + } + + fun median(samples: List): Double { + val sorted = samples.sorted() + val mid = sorted.size / 2 + return if (sorted.size % 2 == 1) sorted[mid] else (sorted[mid - 1] + sorted[mid]) / 2 + } + + private const val NANOS_PER_MILLI = 1_000_000.0 +} diff --git a/src/test/kotlin/dev/lain/claudejb/bench/BenchReport.kt b/src/test/kotlin/dev/lain/claudejb/bench/BenchReport.kt new file mode 100644 index 00000000..7a1d4a97 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/bench/BenchReport.kt @@ -0,0 +1,23 @@ +package dev.lain.claudejb.bench + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import java.util.Locale + +object BenchReport { + + private val RESULTS: Path = Path.of("build", "bench", "results.txt") + + fun record(name: String, value: Double, unit: String) = + emit("BENCH $name ${String.format(Locale.ROOT, "%.2f", value)} $unit") + + fun record(name: String, value: Long, unit: String) = emit("BENCH $name $value $unit") + + @Synchronized + private fun emit(line: String) { + println(line) + Files.createDirectories(RESULTS.parent) + Files.writeString(RESULTS, line + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/bench/BenchText.kt b/src/test/kotlin/dev/lain/claudejb/bench/BenchText.kt new file mode 100644 index 00000000..d44cec3e --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/bench/BenchText.kt @@ -0,0 +1,20 @@ +package dev.lain.claudejb.bench + +import kotlin.random.Random + +object BenchText { + + private val WORDS = listOf( + "session", "restore", "agent", "transcript", "entry", "record", + "parse", "tool", "output", "line", "file", "project", + ) + + fun word(random: Random): String = WORDS[random.nextInt(WORDS.size)] + + fun phrase(random: Random, words: Int, breakEvery: Int = Int.MAX_VALUE): String = buildString { + repeat(words) { index -> + if (index > 0) append(if (index % breakEvery == 0) "\\n" else " ") + append(word(random)) + } + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/bench/DiffUnifiedBench.kt b/src/test/kotlin/dev/lain/claudejb/bench/DiffUnifiedBench.kt new file mode 100644 index 00000000..3e0126fe --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/bench/DiffUnifiedBench.kt @@ -0,0 +1,39 @@ +package dev.lain.claudejb.bench + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import dev.lain.claudejb.model.diff.DiffPresenter +import kotlin.random.Random + +class DiffUnifiedBench : BasePlatformTestCase() { + + fun `test a unified diff of a one megabyte file with one line in a hundred changed`() { + val (current, proposed) = pair() + val median = BenchClock.medianMillis(WARMUPS, RUNS) { + assertTrue(DiffPresenter.unifiedDiff(current, proposed).isNotEmpty()) + } + BenchReport.record("diff_1mb", median, "ms") + } + + private fun pair(): Pair { + val random = Random(SEED) + val current = ArrayList() + var size = 0 + while (size < TARGET_CHARS) { + val line = " val value${current.size} = compute(${random.nextInt(BOUND)}, \"${BenchText.word(random)}\")" + current += line + size += line.length + 1 + } + val proposed = + current.map { line -> if (random.nextInt(CHANGE_ONE_IN) == 0) "$line.also { log(it) }" else line } + return current.joinToString("\n") to proposed.joinToString("\n") + } + + private companion object { + const val TARGET_CHARS = 1_000_000 + const val CHANGE_ONE_IN = 100 + const val BOUND = 10_000 + const val SEED = 1 + const val WARMUPS = 3 + const val RUNS = 10 + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/bench/LiveOutputBench.kt b/src/test/kotlin/dev/lain/claudejb/bench/LiveOutputBench.kt new file mode 100644 index 00000000..2e0f9ec2 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/bench/LiveOutputBench.kt @@ -0,0 +1,64 @@ +package dev.lain.claudejb.bench + +import com.intellij.testFramework.PlatformTestUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import dev.lain.claudejb.controller.mcp.ToolOutput +import dev.lain.claudejb.controller.session.ChatSessionManager +import dev.lain.claudejb.controller.session.ClaudeSession +import dev.lain.claudejb.model.session.transcript.Speaker +import dev.lain.claudejb.model.session.transcript.ToolState +import dev.lain.claudejb.model.session.transcript.TranscriptEntry +import dev.lain.claudejb.model.session.transcript.TranscriptModel + +class LiveOutputBench : BasePlatformTestCase() { + + private val manager get() = ChatSessionManager.getInstance(project) + + private var updates = 0 + + override fun tearDown() { + try { + manager.all().forEach { runCatching { manager.remove(it) } } + } finally { + super.tearDown() + } + } + + fun `test ten thousand live output lines reach a session's transcript`() { + val session = manager.create() + session.transcript.addListener( + object : TranscriptModel.Listener { + override fun onUpdated(entry: TranscriptEntry) { + updates++ + } + }, + ) + PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue() + repeat(WARMUPS) { push(session, "toolu_warm_$it") } + val samples = List(RUNS) { push(session, "toolu_bench_$it") } + assertTrue(updates > 0) + BenchReport.record("live_output_10k", BenchClock.median(samples), "ms") + BenchReport.record("live_output_10k_edt_updates", updates.toLong(), "updates") + } + + private fun push(session: ClaudeSession, toolUseId: String): Double { + session.transcript.add( + Speaker.TOOL, + "Bash", + meta = "Bash", + toolUseId = toolUseId, + toolState = ToolState.RUNNING, + ) + updates = 0 + return BenchClock.millis { + repeat(LINES) { ToolOutput.line(project, toolUseId, "line $it of the build output") } + PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue() + } + } + + private companion object { + const val LINES = 10_000 + const val WARMUPS = 2 + const val RUNS = 5 + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/bench/McpCharsBench.kt b/src/test/kotlin/dev/lain/claudejb/bench/McpCharsBench.kt new file mode 100644 index 00000000..bbd90c32 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/bench/McpCharsBench.kt @@ -0,0 +1,91 @@ +package dev.lain.claudejb.bench + +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import dev.lain.claudejb.controller.mcp.IdeMcpService +import dev.lain.claudejb.mcp.McpClient +import dev.lain.claudejb.model.session.launch.IdeServer +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import java.nio.file.Files +import java.nio.file.Path + +class McpCharsBench : BasePlatformTestCase() { + + private lateinit var dir: Path + + override fun runInDispatchThread(): Boolean = false + + override fun setUp() { + super.setUp() + dir = Files.createDirectories(Path.of(project.basePath!!).resolve(DIR)) + FILES.forEachIndexed { index, name -> + val file = dir.resolve(name) + Files.writeString(file, source(index)) + assertNotNull(LocalFileSystem.getInstance().refreshAndFindFileByNioFile(file)) + } + } + + override fun tearDown() { + try { + dir.toFile().deleteRecursively() + } finally { + super.tearDown() + } + } + + fun `test the characters the fixed calls return`() { + val paths = FILES.map { dir.resolve(it).toString() } + connect(IdeServer.CODE).use { code -> + record("mcp_chars_read_file", paths.sumOf { chars(code, "read_file", buildJsonObject { put("path", it) }) }) + record( + "mcp_chars_search_text", + chars( + code, + "search_text", + buildJsonObject { + put("query", QUERY) + put("path", DIR) + }, + ), + ) + record("mcp_chars_project_problems", chars(code, "project_problems", buildJsonObject {})) + record( + "mcp_chars_batch", + chars(code, "read_file", buildJsonObject { putJsonArray("paths") { paths.forEach { add(it) } } }), + ) + } + connect(IdeServer.OPS).use { ops -> + record("mcp_chars_services", chars(ops, "services", buildJsonObject {})) + } + } + + private fun connect(server: IdeServer): McpClient { + val service = IdeMcpService.getInstance(project) + service.expectConnections(1) + val client = McpClient.connect(Path.of(service.sockets().getValue(server))) + client.initialize() + return client + } + + private fun chars(client: McpClient, tool: String, args: JsonObject): Long = + runCatching { client.run(tool, args) }.getOrElse { it.message.orEmpty() }.length.toLong() + + private fun record(name: String, chars: Long) = BenchReport.record(name, chars, "chars") + + private fun source(index: Int): String = buildString { + appendLine("package bench") + appendLine() + repeat(FUNCTIONS) { appendLine("fun task${index}_$it(value: Int): Int = value * ${it + 1}") } + } + + private companion object { + const val DIR = "benchsrc" + const val QUERY = "fun task1_" + const val FUNCTIONS = 40 + val FILES = listOf("Alpha.kt", "Beta.kt", "Gamma.kt") + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/bench/PageAssemblyBenchTest.kt b/src/test/kotlin/dev/lain/claudejb/bench/PageAssemblyBenchTest.kt new file mode 100644 index 00000000..b499b629 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/bench/PageAssemblyBenchTest.kt @@ -0,0 +1,25 @@ +package dev.lain.claudejb.bench + +import dev.lain.claudejb.view.jcef.PageAssembly +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test + +@Tag("bench") +class PageAssemblyBenchTest { + + @Test + fun `the chat page is assembled from its resources`() { + val first = BenchClock.millis { PageAssembly.build() } + val second = BenchClock.millis { PageAssembly.build() } + val median = BenchClock.medianMillis(WARMUPS, RUNS) { assertTrue(PageAssembly.build().html.isNotEmpty()) } + BenchReport.record("page_assembly_first_call", first, "ms") + BenchReport.record("page_assembly_second_call", second, "ms") + BenchReport.record("page_assembly", median, "ms") + } + + private companion object { + const val WARMUPS = 5 + const val RUNS = 20 + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/bench/RestoreBenchTest.kt b/src/test/kotlin/dev/lain/claudejb/bench/RestoreBenchTest.kt new file mode 100644 index 00000000..5162565a --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/bench/RestoreBenchTest.kt @@ -0,0 +1,36 @@ +package dev.lain.claudejb.bench + +import dev.lain.claudejb.model.session.history.SessionTranscriptReader +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path + +@Tag("bench") +class RestoreBenchTest { + + @TempDir + lateinit var dir: Path + + @Test + fun `a twenty megabyte session is read back to the capped entry list`() { + val file = dir.resolve("session.jsonl") + TranscriptFixture.write(file, TARGET_BYTES, SEED) + var entries = 0 + val median = BenchClock.medianMillis(WARMUPS, RUNS) { + val lines = Files.readAllLines(file) + entries = SessionTranscriptReader.parseEntries(lines, SessionTranscriptReader.DEFAULT_RESTORE_CAP).size + } + assertTrue(entries in 1..SessionTranscriptReader.DEFAULT_RESTORE_CAP) + BenchReport.record("restore_20mb", median, "ms") + } + + private companion object { + const val TARGET_BYTES = 20L * 1024 * 1024 + const val SEED = 20 + const val WARMUPS = 2 + const val RUNS = 5 + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/bench/SessionLeakBench.kt b/src/test/kotlin/dev/lain/claudejb/bench/SessionLeakBench.kt new file mode 100644 index 00000000..ca4eb4dc --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/bench/SessionLeakBench.kt @@ -0,0 +1,49 @@ +package dev.lain.claudejb.bench + +import com.intellij.testFramework.PlatformTestUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import dev.lain.claudejb.controller.session.ChatSessionManager +import dev.lain.claudejb.controller.session.ClaudeSession +import java.lang.ref.WeakReference + +class SessionLeakBench : BasePlatformTestCase() { + + private val manager get() = ChatSessionManager.getInstance(project) + + override fun tearDown() { + try { + manager.all().forEach { runCatching { manager.remove(it) } } + } finally { + super.tearDown() + } + } + + fun `test sessions opened and closed through the manager are released`() { + val refs = openAndClose() + BenchReport.record("session_leak", reachableAfterGc(refs).toLong(), "sessions") + } + + private fun openAndClose(): List> { + val refs = List(SESSIONS) { WeakReference(manager.create()) } + PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue() + refs.forEach { ref -> ref.get()?.let(manager::remove) } + PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue() + return refs + } + + private fun reachableAfterGc(refs: List>): Int { + repeat(GC_ROUNDS) { + if (refs.all { it.get() == null }) return 0 + System.gc() + PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue() + Thread.sleep(GC_PAUSE_MS) + } + return refs.count { it.get() != null } + } + + private companion object { + const val SESSIONS = 10 + const val GC_ROUNDS = 10 + const val GC_PAUSE_MS = 50L + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/bench/StdoutParseBenchTest.kt b/src/test/kotlin/dev/lain/claudejb/bench/StdoutParseBenchTest.kt new file mode 100644 index 00000000..ab26ca9e --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/bench/StdoutParseBenchTest.kt @@ -0,0 +1,54 @@ +package dev.lain.claudejb.bench + +import dev.lain.claudejb.controller.process.ClaudeProcess +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Path +import kotlin.random.Random + +@Tag("bench") +class StdoutParseBenchTest { + + @TempDir + lateinit var dir: Path + + @Test + fun `a five megabyte stdout line arrives in eight kilobyte chunks`() { + val chunks = line().chunked(CHUNK_CHARS) + var events = 0 + val median = BenchClock.medianMillis(WARMUPS, RUNS) { + val process = + ClaudeProcess(File("claude"), dir.toFile(), emptyList(), onEvent = { events++ }, onTerminated = {}) + val consume = consumerOf(process) + chunks.forEach(consume) + } + assertTrue(events > 0) + BenchReport.record("stdout_parse", median, "ms") + } + + private fun consumerOf(process: ClaudeProcess): (String) -> Unit { + val method = ClaudeProcess::class.java.getDeclaredMethod("consumeStdout", String::class.java) + method.isAccessible = true + return { chunk -> method.invoke(process, chunk) } + } + + private fun line(): String { + val random = Random(SEED) + val text = buildString(LINE_CHARS) { + while (length < LINE_CHARS) append(BenchText.word(random)).append(' ') + } + return "{\"type\":\"assistant\",\"session_id\":\"bench\",\"message\":{\"id\":\"msg_bench\"," + + "\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"$text\"}]}}\n" + } + + private companion object { + const val LINE_CHARS = 5 * 1024 * 1024 + const val CHUNK_CHARS = 8 * 1024 + const val SEED = 5 + const val WARMUPS = 2 + const val RUNS = 5 + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/bench/TranscriptFixture.kt b/src/test/kotlin/dev/lain/claudejb/bench/TranscriptFixture.kt new file mode 100644 index 00000000..6ca522ab --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/bench/TranscriptFixture.kt @@ -0,0 +1,46 @@ +package dev.lain.claudejb.bench + +import java.nio.file.Files +import java.nio.file.Path +import kotlin.random.Random + +object TranscriptFixture { + + fun write(file: Path, targetBytes: Long, seed: Int) { + val random = Random(seed) + var written = 0L + var turn = 0 + Files.newBufferedWriter(file).use { out -> + while (written < targetBytes) { + recordsOf(turn++, random).forEach { line -> + out.write(line) + out.write("\n") + written += line.length + 1 + } + } + } + } + + private fun recordsOf(turn: Int, random: Random): List { + val id = "toolu_bench_$turn" + val prompt = BenchText.phrase(random, PROMPT_WORDS) + val reply = BenchText.phrase(random, REPLY_WORDS, LINE_WORDS) + val output = BenchText.phrase(random, OUTPUT_WORDS, LINE_WORDS) + val input = "{\"command\":\"ls -la src/module$turn\",\"description\":\"List the module\"}" + return listOf( + record("user", "\"$prompt\""), + record("assistant", "[{\"type\":\"text\",\"text\":\"$reply\"}]"), + record("assistant", "[{\"type\":\"tool_use\",\"id\":\"$id\",\"name\":\"Bash\",\"input\":$input}]"), + record("user", "[{\"type\":\"tool_result\",\"tool_use_id\":\"$id\",\"content\":\"$output\"}]"), + ) + } + + private fun record(type: String, content: String): String = + "{\"type\":\"$type\",\"timestamp\":\"$STAMP\",\"message\":{\"role\":\"$type\",\"content\":$content}}" + + private const val STAMP = "2026-01-01T00:00:00.000Z" + private const val PROMPT_WORDS = 40 + private const val REPLY_WORDS = 300 + private const val OUTPUT_WORDS = 200 + private const val LINE_WORDS = 12 +} From 810328f16a9fe387a5ae07e12ea69a1b323d0e36 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:39:10 +0200 Subject: [PATCH 003/155] build(deps): update DOMPurify to 3.4.16 --- THIRD-PARTY-NOTICES.md | 10 +++++----- src/main/resources/jcef/purify.min.js | 10 +++++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 66487bd3..4d246f85 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -36,7 +36,7 @@ carries the vendored web assets), the two kotlinx.serialization jars listed belo `searchableOptions` jar. Everything with a third-party license in that list has an entry here. Last verified: 2026-08-11, against the upstream `LICENSE` files at the pinned tags -(`markedjs/marked@v12.0.0`, `cure53/DOMPurify@3.4.13`, `highlightjs/highlight.js@11.11.2`, +(`markedjs/marked@v12.0.0`, `cure53/DOMPurify@3.4.16`, `highlightjs/highlight.js@11.11.2`, `Kotlin/kotlinx.serialization@v1.7.3`). --- @@ -62,9 +62,9 @@ subset build as generated (see its entry). — a single line that names neither MarkedJS nor Gruber and states a date range that does not appear in the license. `LICENSE.md` at `v12.0.0` is the grant and is what is reproduced above. -### DOMPurify — 3.4.13 +### DOMPurify — 3.4.16 - **License:** `MPL-2.0 OR Apache-2.0` — dual-licensed, as upstream's own `package.json` states it - verbatim at this tag. At `3.4.13` the two grants live in two files: `LICENSE` carries the bare + verbatim at this tag. At `3.4.16` the two grants live in two files: `LICENSE` carries the bare Apache-2.0 text and `LICENSE-MPL` carries the MPL-2.0 text. - **License chosen by this project: Apache-2.0.** A dual `OR` license is a choice the redistributor must make and record; leaving it unstated is an @@ -82,14 +82,14 @@ subset build as generated (see its entry). - **Note — this entry is the exception to the "read the `LICENSE`, not the banner" rule, and it is worth stating why.** Up to and including `3.0.11`, DOMPurify's `LICENSE` opened with a header naming the author (*"DOMPurify / Copyright 2023 Dr.-Ing. Mario Heiderich, Cure53"*) followed by the - dual-license statement, and that named individual was the notice to preserve. At `3.4.13` that + dual-license statement, and that named individual was the notice to preserve. At `3.4.16` that header is **gone**: `LICENSE` is the unmodified Apache-2.0 boilerplate, whose only copyright line is the appendix's unfilled `Copyright {yyyy} {name of copyright owner}` placeholder. So the sole copyright notice upstream still asserts is the banner inside `purify.min.js` — *"(c) Cure53 and other contributors"* — which the vendored file carries intact, as Apache-2.0 §4(c) requires. Both forms are reproduced above rather than picking one, because dropping the named form would discard a notice that upstream did assert, and it costs nothing to keep. -- **Verified:** the DOMPurify repository publishes **no `NOTICE` file** at tag `3.4.13`, so having +- **Verified:** the DOMPurify repository publishes **no `NOTICE` file** at tag `3.4.16`, so having chosen Apache-2.0 there is nothing further to propagate under Apache-2.0 §4(d). ### highlight.js — 11.11.2 diff --git a/src/main/resources/jcef/purify.min.js b/src/main/resources/jcef/purify.min.js index 86efd858..00742c47 100644 --- a/src/main/resources/jcef/purify.min.js +++ b/src/main/resources/jcef/purify.min.js @@ -1,3 +1,7 @@ -/*! @license DOMPurify 3.4.13 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.13/LICENSE */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,function(){"use strict";function e(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,o=Array(t);n2?n-2:0),r=2;r1?t-1:0),o=1;o1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:T;if(o&&o(e,null),!b(t))return e;let i=t.length;for(;i--;){let o=t[i];if("string"==typeof o){const e=n(o);e!==o&&(r(t)||(t[i]=e),o=e)}e[o]=!0}return e}function z(e){for(let t=0;t/g),J=c(/\${[\w\W]*/g),Q=c(/^data-[\-\w.\u00B7-\uFFFF]+$/),ee=c(/^aria-[\-\w]+$/),te=c(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),ne=c(/^(?:\w+script|data):/i),oe=c(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),re=c(/^html$/i),ie=c(/^[a-z][.\w]*(-[.\w]+)+$/i),ae=c(/<[/\w!]/g),le=c(/<[/\w]/g),ce=c(/<\/no(script|embed|frames)/i),se=c(/\/>/i),ue=1,fe=3,pe=7,me=8,de=9,he=11,ge=function(){return"undefined"==typeof window?null:window},ye=function(e,t,n,o){return R(e,t)&&b(e[t])?M(o.base?P(o.base):{},e[t],o.transform):n};var be=function e(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:ge();const o=t=>e(t);if(o.version="3.4.13",o.removed=[],!t||!t.document||t.document.nodeType!==de||!t.Element)return o.isSupported=!1,o;let r=t.document;const i=r,a=i.currentScript;t.DocumentFragment;const u=t.HTMLTemplateElement,f=t.Node,p=t.Element,k=t.NodeFilter,L=t.NamedNodeMap;void 0===L&&(t.NamedNodeMap||t.MozNamedAttrMap),t.HTMLFormElement;const z=t.DOMParser,be=t.trustedTypes,Te=p.prototype,Se=U(Te,"cloneNode"),Ee=U(Te,"remove"),Ae=U(Te,"nextSibling"),Ne=U(Te,"childNodes"),_e=U(Te,"parentNode"),we=U(Te,"shadowRoot"),Oe=U(Te,"attributes"),ve=f&&f.prototype?U(f.prototype,"nodeType"):null,De=f&&f.prototype?U(f.prototype,"nodeName"):null,Re=f&&f.prototype?U(f.prototype,"ownerDocument"):null;if("function"==typeof u){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let Ce,Ie,xe="",ke=!1,Le=0;const Me=function(){if(Le>0)throw x('A configured TRUSTED_TYPES_POLICY callback (createHTML or createScriptURL) must not call DOMPurify.sanitize, as that causes infinite recursion. Do not pass a policy whose callbacks wrap DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted Types" section of the README.')},ze=function(e){Me(),Le++;try{return Ce.createHTML(e)}finally{Le--}},Pe=function(){return ke||(Ie=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";t&&t.hasAttribute(o)&&(n=t.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}}(be,a),ke=!0),Ie},Ue=r,Fe=Ue.implementation,He=Ue.createNodeIterator,je=Ue.createDocumentFragment,Be=Ue.getElementsByTagName,Ge=i.importNode;let We={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]};o.isSupported="function"==typeof n&&"function"==typeof _e&&Fe&&void 0!==Fe.createHTMLDocument;const Ye=V,qe=Z,Xe=J,$e=Q,Ke=ee,Ve=ne,Ze=oe,Je=ie;let Qe=te,et=null;const tt=M({},[...F,...H,...j,...G,...Y]);let nt=null;const ot=M({},[...q,...X,...$,...K]);let rt=Object.seal(s(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),it=null,at=null;const lt=Object.seal(s(null,{tagCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeCheck:{writable:!0,configurable:!1,enumerable:!0,value:null}}));let ct=!0,st=!0,ut=!1,ft=!0,pt=!1,mt=!0,dt=!1,ht=!1,gt=null,yt=null,bt=!1,Tt=!1,St=!1,Et=!1,At=!0,Nt=!1;const _t="user-content-";let wt=!0,Ot=!1,vt={},Dt=null;const Rt=M({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","selectedcontent","style","svg","template","thead","title","video","xmp"]);let Ct=null;const It=M({},["audio","video","img","source","image","track"]);let xt=null;const kt=M({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),Lt="http://www.w3.org/1998/Math/MathML",Mt="http://www.w3.org/2000/svg",zt="http://www.w3.org/1999/xhtml";let Pt=zt,Ut=!1,Ft=null;const Ht=M({},[Lt,Mt,zt],S),jt=l(["mi","mo","mn","ms","mtext"]);let Bt=M({},jt);const Gt=l(["annotation-xml"]);let Wt=M({},Gt);const Yt=M({},["title","style","font","a","script"]);let qt=null;const Xt=["application/xhtml+xml","text/html"];let $t=null,Kt=null;const Vt=r.createElement("form"),Zt=function(e){return e instanceof RegExp||e instanceof Function},Jt=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(Kt&&Kt===e)return;e&&"object"==typeof e||(e={}),e=P(e),qt=-1===Xt.indexOf(e.PARSER_MEDIA_TYPE)?"text/html":e.PARSER_MEDIA_TYPE,$t="application/xhtml+xml"===qt?S:T,et=ye(e,"ALLOWED_TAGS",tt,{transform:$t}),nt=ye(e,"ALLOWED_ATTR",ot,{transform:$t}),Ft=ye(e,"ALLOWED_NAMESPACES",Ht,{transform:S}),xt=ye(e,"ADD_URI_SAFE_ATTR",kt,{transform:$t,base:kt}),Ct=ye(e,"ADD_DATA_URI_TAGS",It,{transform:$t,base:It}),Dt=ye(e,"FORBID_CONTENTS",Rt,{transform:$t}),it=ye(e,"FORBID_TAGS",P({}),{transform:$t}),at=ye(e,"FORBID_ATTR",P({}),{transform:$t}),vt=!!R(e,"USE_PROFILES")&&(e.USE_PROFILES&&"object"==typeof e.USE_PROFILES?P(e.USE_PROFILES):e.USE_PROFILES),ct=!1!==e.ALLOW_ARIA_ATTR,st=!1!==e.ALLOW_DATA_ATTR,ut=e.ALLOW_UNKNOWN_PROTOCOLS||!1,ft=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,pt=e.SAFE_FOR_TEMPLATES||!1,mt=!1!==e.SAFE_FOR_XML,dt=e.WHOLE_DOCUMENT||!1,Tt=e.RETURN_DOM||!1,St=e.RETURN_DOM_FRAGMENT||!1,Et=e.RETURN_TRUSTED_TYPE||!1,bt=e.FORCE_BODY||!1,At=!1!==e.SANITIZE_DOM,Nt=e.SANITIZE_NAMED_PROPS||!1,wt=!1!==e.KEEP_CONTENT,Ot=e.IN_PLACE||!1,Qe=function(e){try{return I(e,""),!0}catch(e){return!1}}(e.ALLOWED_URI_REGEXP)?e.ALLOWED_URI_REGEXP:te,Pt="string"==typeof e.NAMESPACE?e.NAMESPACE:zt,Bt=R(e,"MATHML_TEXT_INTEGRATION_POINTS")&&e.MATHML_TEXT_INTEGRATION_POINTS&&"object"==typeof e.MATHML_TEXT_INTEGRATION_POINTS?P(e.MATHML_TEXT_INTEGRATION_POINTS):M({},jt),Wt=R(e,"HTML_INTEGRATION_POINTS")&&e.HTML_INTEGRATION_POINTS&&"object"==typeof e.HTML_INTEGRATION_POINTS?P(e.HTML_INTEGRATION_POINTS):M({},Gt);const t=R(e,"CUSTOM_ELEMENT_HANDLING")&&e.CUSTOM_ELEMENT_HANDLING&&"object"==typeof e.CUSTOM_ELEMENT_HANDLING?P(e.CUSTOM_ELEMENT_HANDLING):s(null);if(rt=s(null),R(t,"tagNameCheck")&&Zt(t.tagNameCheck)&&(rt.tagNameCheck=t.tagNameCheck),R(t,"attributeNameCheck")&&Zt(t.attributeNameCheck)&&(rt.attributeNameCheck=t.attributeNameCheck),R(t,"allowCustomizedBuiltInElements")&&"boolean"==typeof t.allowCustomizedBuiltInElements&&(rt.allowCustomizedBuiltInElements=t.allowCustomizedBuiltInElements),c(rt),pt&&(st=!1),St&&(Tt=!0),vt&&(et=M({},Y),nt=s(null),!0===vt.html&&(M(et,F),M(nt,q)),!0===vt.svg&&(M(et,H),M(nt,X),M(nt,K)),!0===vt.svgFilters&&(M(et,j),M(nt,X),M(nt,K)),!0===vt.mathMl&&(M(et,G),M(nt,$),M(nt,K))),lt.tagCheck=null,lt.attributeCheck=null,R(e,"ADD_TAGS")&&("function"==typeof e.ADD_TAGS?lt.tagCheck=e.ADD_TAGS:b(e.ADD_TAGS)&&(et===tt&&(et=P(et)),M(et,e.ADD_TAGS,$t))),R(e,"ADD_ATTR")&&("function"==typeof e.ADD_ATTR?lt.attributeCheck=e.ADD_ATTR:b(e.ADD_ATTR)&&(nt===ot&&(nt=P(nt)),M(nt,e.ADD_ATTR,$t))),R(e,"ADD_URI_SAFE_ATTR")&&b(e.ADD_URI_SAFE_ATTR)&&M(xt,e.ADD_URI_SAFE_ATTR,$t),R(e,"FORBID_CONTENTS")&&b(e.FORBID_CONTENTS)&&(Dt===Rt&&(Dt=P(Dt)),M(Dt,e.FORBID_CONTENTS,$t)),R(e,"ADD_FORBID_CONTENTS")&&b(e.ADD_FORBID_CONTENTS)&&(Dt===Rt&&(Dt=P(Dt)),M(Dt,e.ADD_FORBID_CONTENTS,$t)),wt&&(et["#text"]=!0),dt&&M(et,["html","head","body"]),et.table&&(M(et,["tbody"]),delete it.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw x('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw x('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');const t=Ce;Ce=e.TRUSTED_TYPES_POLICY;try{xe=ze("")}catch(e){throw Ce=t,e}}else null===e.TRUSTED_TYPES_POLICY?(Ce=void 0,xe=""):(void 0===Ce&&(Ce=Pe()),Ce&&"string"==typeof xe&&(xe=ze("")));l&&l(e),Kt=e},Qt=M({},[...H,...j,...B]),en=M({},[...G,...W]),tn=function(e){let t=_e(e);t&&t.tagName||(t={namespaceURI:Pt,tagName:"template"});const n=T(e.tagName),o=T(t.tagName);return!!Ft[e.namespaceURI]&&(e.namespaceURI===Mt?function(e,t,n){return t.namespaceURI===zt?"svg"===e:t.namespaceURI===Lt?"svg"===e&&("annotation-xml"===n||Bt[n]):Boolean(Qt[e])}(n,t,o):e.namespaceURI===Lt?function(e,t,n){return t.namespaceURI===zt?"math"===e:t.namespaceURI===Mt?"math"===e&&Wt[n]:Boolean(en[e])}(n,t,o):e.namespaceURI===zt?function(e,t,n){return!(t.namespaceURI===Mt&&!Wt[n])&&!(t.namespaceURI===Lt&&!Bt[n])&&!en[e]&&(Yt[e]||!Qt[e])}(n,t,o):!("application/xhtml+xml"!==qt||!Ft[e.namespaceURI]))},nn=function(e){g(o.removed,{element:e});try{_e(e).removeChild(e)}catch(t){if(Ee(e),!_e(e))throw x("a node selected for removal could not be detached from its tree and cannot be safely returned; refusing to sanitize in place")}},on=function(e){ln(e);const t=Ne(e);if(t){const e=[];m(t,t=>{g(e,t)}),m(e,e=>{try{Ee(e)}catch(e){}})}const n=Oe(e);if(n)for(let t=n.length-1;t>=0;--t){const o=n[t],r=o&&o.name;if("string"==typeof r)try{e.removeAttribute(r)}catch(e){}}},rn=function(e,t){try{g(o.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){g(o.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e)if(Tt||St)try{nn(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},an=function(e){const t=Oe(e);if(t)for(let n=t.length-1;n>=0;--n){const o=t[n],r=o&&o.name;if("string"==typeof r&&!nt[$t(r)])try{e.removeAttribute(r)}catch(e){}}},ln=function(e){const t=[e];for(;t.length>0;){const e=t.pop();(ve?ve(e):e.nodeType)===ue&&an(e);const n=Ne(e);if(n)for(let e=n.length-1;e>=0;--e)t.push(n[e])}},cn=function(e){let t=null,n=null;if(bt)e=""+e;else{const t=E(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===qt&&Pt===zt&&(e=''+e+"");const o=Ce?ze(e):e;if(Pt===zt)try{t=(new z).parseFromString(o,qt)}catch(e){}if(!t||!t.documentElement){t=Fe.createDocument(Pt,"template",null);try{t.documentElement.innerHTML=Ut?xe:o}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),Pt===zt?Be.call(t,dt?"html":"body")[0]:dt?t.documentElement:i},sn=function(e){const t=Re?Re(e):e.ownerDocument;return He.call(t||e,e,k.SHOW_ELEMENT|k.SHOW_COMMENT|k.SHOW_TEXT|k.SHOW_PROCESSING_INSTRUCTION|k.SHOW_CDATA_SECTION,null)},un=function(e){return e=A(e,Ye," "),e=A(e,qe," "),e=A(e,Xe," ")},fn=function(e){var t;e.normalize();const n=Re?Re(e):e.ownerDocument,o=He.call(n||e,e,k.SHOW_TEXT|k.SHOW_COMMENT|k.SHOW_CDATA_SECTION|k.SHOW_PROCESSING_INSTRUCTION,null);let r=o.nextNode();for(;r;)r.data=un(r.data),r=o.nextNode();const i=null===(t=e.querySelectorAll)||void 0===t?void 0:t.call(e,"template");i&&m(i,e=>{mn(e.content)&&fn(e.content)})},pn=function(e){const t=De?De(e):null;return"string"==typeof t&&("form"===$t(t)&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||e.attributes!==Oe(e)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes||e.nodeType!==ve(e)||e.childNodes!==Ne(e)))},mn=function(e){if(!ve||"object"!=typeof e||null===e)return!1;try{return ve(e)===he}catch(e){return!1}},dn=function(e){if(!ve||"object"!=typeof e||null===e)return!1;try{return"number"==typeof ve(e)}catch(e){return!1}};function hn(e,t,n){0!==e.length&&m(e,e=>{e.call(o,t,n,Kt)})}const gn=function(e,t,n,o){return 0===e.length?t:t===n||t===o?P(t):t},yn=function(e,t){if(hn(We.beforeSanitizeElements,e,null),e!==t&&null===_e(e))return Ot&&ln(e),!0;if(pn(e))return nn(e),!0;const n=$t(De?De(e):e.nodeName);if(et=gn(We.uponSanitizeElement,et,tt,gt),hn(We.uponSanitizeElement,e,{tagName:n,allowedTags:et}),e!==t&&null===_e(e))return Ot&&ln(e),!0;if(function(e,t){return!!(mt&&e.hasChildNodes()&&!dn(e.firstElementChild)&&I(ae,e.textContent)&&I(ae,e.innerHTML))||!(!mt||e.namespaceURI!==zt||"style"!==t||!dn(e.firstElementChild))||e.nodeType===pe||!(!mt||e.nodeType!==me||!I(le,e.data))}(e,n))return nn(e),!0;if(it[n]||!(lt.tagCheck instanceof Function&<.tagCheck(n))&&!et[n]){const o=function(e,t,n){if(!it[t]&&Sn(t)){if(rt.tagNameCheck instanceof RegExp&&I(rt.tagNameCheck,t))return!1;if(rt.tagNameCheck instanceof Function&&rt.tagNameCheck(t))return!1}if(wt&&!Dt[t]){const t=_e(e),o=Ne(e);if(o&&t)for(let r=o.length-1;r>=0;--r){const i=e===n?Se(o[r],!0):o[r];t.insertBefore(i,Ae(e))}}return nn(e),!0}(e,n,t);return!1===o&&hn(We.afterSanitizeElements,e,null),o}if((ve?ve(e):e.nodeType)===ue&&!tn(e))return nn(e),!0;if(("noscript"===n||"noembed"===n||"noframes"===n)&&I(ce,e.innerHTML))return nn(e),!0;if(pt&&e.nodeType===fe){const t=un(e.textContent);e.textContent!==t&&(g(o.removed,{element:e.cloneNode()}),e.textContent=t)}return hn(We.afterSanitizeElements,e,null),!1},bn=function(e,t,n){if(at[t])return!1;if(mt&&"patchsrc"===t)return!1;if(mt&&"for"===t&&"label"!==e&&"output"!==e)return!1;if(At&&("id"===t||"name"===t)&&(n in r||n in Vt))return!1;const o=nt[t]||lt.attributeCheck instanceof Function&<.attributeCheck(t,e);if(st&&I($e,t));else if(ct&&I(Ke,t));else if(o)if(xt[t]);else if(I(Qe,A(n,Ze,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==N(n,"data:")||!Ct[e]){if(ut&&!I(Ve,A(n,Ze,"")));else if(n)return!1}else;else if(!(Sn(e)&&(rt.tagNameCheck instanceof RegExp&&I(rt.tagNameCheck,e)||rt.tagNameCheck instanceof Function&&rt.tagNameCheck(e))&&(rt.attributeNameCheck instanceof RegExp&&I(rt.attributeNameCheck,t)||rt.attributeNameCheck instanceof Function&&rt.attributeNameCheck(t,e))||"is"===t&&rt.allowCustomizedBuiltInElements&&(rt.tagNameCheck instanceof RegExp&&I(rt.tagNameCheck,n)||rt.tagNameCheck instanceof Function&&rt.tagNameCheck(n))))return!1;return!0},Tn=M({},["annotation-xml","color-profile","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","missing-glyph"]),Sn=function(e){return!Tn[T(e)]&&I(Je,e)},En=function(e,t,n,o){if(Ce&&"object"==typeof be&&"function"==typeof be.getAttributeType&&!n)switch(be.getAttributeType(e,t)){case"TrustedHTML":return ze(o);case"TrustedScriptURL":return function(e){Me(),Le++;try{return Ce.createScriptURL(e)}finally{Le--}}(o)}return o},An=function(e,t,n,r){try{n?e.setAttributeNS(n,t,r):e.setAttribute(t,r),pn(e)?nn(e):h(o.removed)}catch(n){rn(t,e)}},Nn=function(e){hn(We.beforeSanitizeAttributes,e,null);const t=e.attributes;if(!t||pn(e))return;nt=gn(We.uponSanitizeAttribute,nt,ot,yt);const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:nt,forceKeepAttr:void 0};let o=t.length;const r=$t(e.nodeName);for(;o--;){const i=t[o],a=i.name,l=i.namespaceURI,c=i.value,s=$t(a),u=c;let f="value"===a?u:_(u);n.attrName=s,n.attrValue=f,n.keepAttr=!0,n.forceKeepAttr=void 0,hn(We.uponSanitizeAttribute,e,n),f=n.attrValue,!Nt||"id"!==s&&"name"!==s||0===N(f,_t)||(rn(a,e),f=_t+f),mt&&I(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i,f)?rn(a,e):"attributename"===s&&E(f,"href")?rn(a,e):n.forceKeepAttr||(n.keepAttr&&(ft||!I(se,f))?(pt&&(f=un(f)),bn(r,s,f)?(f=En(r,s,l,f),f!==u&&An(e,a,l,f)):rn(a,e)):rn(a,e))}hn(We.afterSanitizeAttributes,e,null)},_n=function(e){let t=null;const n=sn(e);for(hn(We.beforeSanitizeShadowDOM,e,null);t=n.nextNode();){hn(We.uponSanitizeShadowNode,t,null),yn(t,e),Nn(t),mn(t.content)&&_n(t.content);if((ve?ve(t):t.nodeType)===ue){const e=we(t);mn(e)&&(wn(e),_n(e))}}hn(We.afterSanitizeShadowDOM,e,null)},wn=function(e){const t=[{node:e,shadow:null}];for(;t.length>0;){const e=t.pop();if(e.shadow){_n(e.shadow);continue}const n=e.node,o=(ve?ve(n):n.nodeType)===ue,r=Ne(n);if(r)for(let e=r.length-1;e>=0;--e)t.push({node:r[e],shadow:null});if(o){const e=De?De(n):null;if("string"==typeof e&&"template"===$t(e)){const e=n.content;mn(e)&&t.push({node:e,shadow:null})}}if(o){const e=we(n);mn(e)&&t.push({node:null,shadow:e},{node:e,shadow:null})}}};return o.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,a=null,l=null;if(Ut=!e,Ut&&(e="\x3c!--\x3e"),"string"!=typeof e&&!dn(e)&&"string"!=typeof(e=function(e){switch(typeof e){case"string":return e;case"number":return w(e);case"boolean":return O(e);case"bigint":return v?v(e):"0";case"symbol":return D?D(e):"Symbol()";case"undefined":default:return C(e);case"function":case"object":{if(null===e)return C(e);const t=e,n=U(t,"toString");if("function"==typeof n){const e=n(t);return"string"==typeof e?e:C(e)}return C(e)}}}(e)))throw x("dirty is not a string, aborting");if(!o.isSupported)return e;ht?(et=gt,nt=yt):Jt(t),(We.uponSanitizeElement.length>0||We.uponSanitizeAttribute.length>0)&&(et=P(et)),We.uponSanitizeAttribute.length>0&&(nt=P(nt)),o.removed=[];const c=Ot&&"string"!=typeof e&&dn(e);if(c){!function(e){if(!mt)return;const t=[e];for(;t.length>0;){const e=t.pop(),n=ve?ve(e):e.nodeType;if(n===pe||n===me&&I(le,e.data)){try{Ee(e)}catch(e){}continue}if(n===ue){const t=e,n=$t(De?De(e):e.nodeName);try{t.hasAttribute&&t.hasAttribute("patchsrc")&&t.removeAttribute("patchsrc"),t.hasAttribute&&t.hasAttribute("for")&&"label"!==n&&"output"!==n&&t.removeAttribute("for")}catch(e){}}const o=Ne(e);if(o)for(let e=o.length-1;e>=0;--e)t.push(o[e])}}(e);const t=De?De(e):e.nodeName;if("string"==typeof t){const n=$t(t);if(!et[n]||it[n])throw on(e),x("root node is forbidden and cannot be sanitized in-place")}if(pn(e))throw on(e),x("root node is clobbered and cannot be sanitized in-place");try{wn(e)}catch(t){throw on(e),t}}else if(dn(e))n=cn("\x3c!----\x3e"),r=n.ownerDocument.importNode(e,!0),r.nodeType===ue&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r),wn(r);else{if(!Tt&&!pt&&!dt&&-1===e.indexOf("<"))return Ce&&Et?ze(e):e;if(n=cn(e),!n)return Tt?null:Et?xe:""}n&&bt&&nn(n.firstChild);const s=c?e:n;try{const e=sn(s);for(;a=e.nextNode();)yn(a,s),Nn(a),mn(a.content)&&_n(a.content)}catch(t){throw c&&(on(e),m(o.removed,e=>{e.element&&ln(e.element)})),t}if(c)return m(o.removed,e=>{e.element&&ln(e.element)}),pt&&fn(e),e;if(Tt){if(pt&&fn(n),St)for(l=je.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(nt.shadowroot||nt.shadowrootmode)&&(l=Ge.call(i,l,!0)),l}let u=dt?n.outerHTML:n.innerHTML;return dt&&et["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&I(re,n.ownerDocument.doctype.name)&&(u="\n"+u),pt&&(u=un(u)),Ce&&Et?ze(u):u},o.setConfig=function(){Jt(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),ht=!0,gt=et,yt=nt},o.clearConfig=function(){Kt=null,ht=!1,gt=null,yt=null,Ce=Ie,xe=""},o.isValidAttribute=function(e,t,n){Kt||Jt({});const o=$t(e),r=$t(t);return bn(o,r,n)},o.addHook=function(e,t){"function"==typeof t&&R(We,e)&&g(We[e],t)},o.removeHook=function(e,t){if(R(We,e)){if(void 0!==t){const n=d(We[e],t);return-1===n?void 0:y(We[e],n,1)[0]}return h(We[e])}},o.removeHooks=function(e){R(We,e)&&(We[e]=[])},o.removeAllHooks=function(){We={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},o}();return be}); -//# sourceMappingURL=purify.min.js.map +/*! @license DOMPurify 3.4.16 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.16/LICENSE */ +(function(e,t){typeof exports==`object`&&typeof module<`u`?module.exports=t():typeof define==`function`&&define.amd?define([],t):(e=typeof globalThis<`u`?globalThis:e||self,e.DOMPurify=t())})(this,function(){"use strict";function e(e,t){this.v=e,this.k=t}function t(e,t){(t==null||t>e.length)&&(t=e.length);for(var n=0,r=Array(t);n`u`?null:T(BigInt.prototype.toString),pe=typeof Symbol>`u`?null:T(Symbol.prototype.toString),x=T(Object.prototype.hasOwnProperty),S=T(Object.prototype.toString),C=T(RegExp.prototype.test),w=E(TypeError);function T(e){return function(t){t instanceof RegExp&&(t.lastIndex=0);var n=[...arguments].slice(1);return g(e,t,n)}}function E(e){return function(){return te(e,[...arguments])}}function D(e,t){let n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:oe;if(l&&l(e,null),!v(t))return e;let r=t.length;for(;r--;){let i=t[r];if(typeof i==`string`){let e=n(i);e!==i&&(u(t)||(t[r]=e),i=e)}e[i]=!0}return e}function me(e){for(let t=0;t/g),Ae=m(/\${[\w\W]*/g),je=m(/^data-[\-\w.\u00B7-\uFFFF]+$/),Me=m(/^aria-[\-\w]+$/),Ne=m(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),Pe=m(/^(?:\w+script|data):/i),Fe=m(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),Ie=m(/^html$/i),Le=m(/^[a-z][.\w]*(-[.\w]+)+$/i),Re=m(/<[/\w!]/g),ze=m(/<[/\w]/g),Be=m(/<\/no(script|embed|frames)/i),Ve=m(/\/>/i),A={element:1,attribute:2,text:3,cdataSection:4,entityReference:5,entityNode:6,processingInstruction:7,comment:8,document:9,documentType:10,documentFragment:11,notation:12},j=[`style`,`script`,`xmp`,`iframe`,`noembed`,`noframes`,`plaintext`,`noscript`],He=p(D({},j)),Ue=function(){let e={};return _(j,t=>{e[t]=m(RegExp(`])`,`i`))}),p(e)}(),We=function(){return typeof window>`u`?null:window},Ge=function(e,t){if(typeof e!=`object`||typeof e.createPolicy!=`function`)return null;let n=null,r=`data-tt-policy-suffix`;t&&t.hasAttribute(r)&&(n=t.getAttribute(r));let i=`dompurify`+(n?`#`+n:``);try{return e.createPolicy(i,{createHTML(e){return e},createScriptURL(e){return e}})}catch(e){return console.warn(`TrustedTypes policy `+i+` could not be created.`),null}},Ke=function(){return{afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},M=function(e,t,n,r){return x(e,t)&&v(e[t])?D(r.base?O(r.base):{},e[t],r.transform):n},qe=function(e,t,n){let r=x(e,t)?e[t]:void 0;return r&&typeof r==`object`?O(r):n()};function Je(){let e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:We(),t=e=>Je(e);if(t.version=`3.4.16`,t.removed=[],!e||!e.document||e.document.nodeType!==A.document||!e.Element)return t.isSupported=!1,t;let n=e.document,r=n,i=r.currentScript;e.DocumentFragment;let a=e.HTMLTemplateElement,o=e.Node,s=e.Element,l=e.NodeFilter;e.NamedNodeMap===void 0&&(e.NamedNodeMap||e.MozNamedAttrMap),e.HTMLFormElement;let u=e.DOMParser,d=e.trustedTypes,f=s.prototype,ee=k(f,`cloneNode`),g=k(f,`remove`),te=k(f,`removeAttributeNode`),fe=k(f,`nextSibling`),y=k(f,`childNodes`),b=k(f,`parentNode`),pe=k(f,`shadowRoot`),S=k(f,`attributes`),T=o&&o.prototype?k(o.prototype,`nodeType`):null,E=o&&o.prototype?k(o.prototype,`nodeName`):null,me=o&&o.prototype?k(o.prototype,`ownerDocument`):null,j=function(e){return T?T(e):e.nodeType},Ye=function(e){return E?E(e):e.nodeName};if(typeof a==`function`){let e=n.createElement(`template`);e.content&&e.content.ownerDocument&&(n=e.content.ownerDocument)}let N,P=``,Xe,Ze=!1,Qe=0,$e=function(){if(Qe>0)throw w(`A configured TRUSTED_TYPES_POLICY callback (createHTML or createScriptURL) must not call DOMPurify.sanitize, as that causes infinite recursion. Do not pass a policy whose callbacks wrap DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted Types" section of the README.`)},F=function(e){$e(),Qe++;try{return N.createHTML(e)}finally{Qe--}},et=function(e){$e(),Qe++;try{return N.createScriptURL(e)}finally{Qe--}},tt=function(){return Ze||(Xe=Ge(d,i),Ze=!0),Xe},nt=n,rt=nt.implementation,it=nt.createNodeIterator,at=nt.createDocumentFragment,ot=nt.getElementsByTagName,st=r.importNode,I=Ke();t.isSupported=typeof c==`function`&&typeof b==`function`&&rt&&rt.createHTMLDocument!==void 0;let ct=Oe,lt=ke,ut=Ae,dt=je,ft=Me,pt=Pe,mt=Fe,ht=Le,gt=Ne,L=null,_t=D({},[..._e,...ve,...ye,...xe,...Ce]),R=null,vt=D({},[...we,...Te,...Ee,...De]),z=Object.seal(h(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),yt=null,bt=null,B=Object.seal(h(null,{tagCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeCheck:{writable:!0,configurable:!1,enumerable:!0,value:null}})),xt=!0,St=!0,Ct=!1,wt=!0,V=!1,H=!0,U=!1,Tt=!1,Et=null,Dt=null,Ot=!1,W=!1,kt=!1,At=!1,jt=!0,Mt=!1,Nt=`user-content-`,Pt=!0,Ft=!1,G={},K=null,It=D({},/* @__PURE__ */ `annotation-xml.audio.colgroup.desc.foreignobject.head.iframe.math.mi.mn.mo.ms.mtext.noembed.noframes.noscript.plaintext.script.selectedcontent.style.svg.template.thead.title.video.xmp`.split(`.`)),Lt=null,Rt=D({},[`audio`,`video`,`img`,`source`,`image`,`track`]),zt=null,Bt=D({},[`alt`,`class`,`for`,`id`,`label`,`name`,`pattern`,`placeholder`,`role`,`summary`,`title`,`value`,`style`,`xmlns`]),Vt=`http://www.w3.org/1998/Math/MathML`,Ht=`http://www.w3.org/2000/svg`,q=`http://www.w3.org/1999/xhtml`,J=q,Ut=!1,Wt=null,Gt=D({},[Vt,Ht,q],se),Kt=p([`mi`,`mo`,`mn`,`ms`,`mtext`]),qt=D({},Kt),Jt=p([`annotation-xml`]),Yt=D({},Jt),Xt=D({},[`title`,`style`,`font`,`a`,`script`]),Zt=null,Qt=[`application/xhtml+xml`,`text/html`],Y=null,$t=null,en=n.createElement(`form`),tn=function(e){return e instanceof RegExp||e instanceof Function},nn=function(){let e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};if($t&&$t===e)return;(!e||typeof e!=`object`)&&(e={}),e=O(e),Zt=Qt.indexOf(e.PARSER_MEDIA_TYPE)===-1?`text/html`:e.PARSER_MEDIA_TYPE,Y=Zt===`application/xhtml+xml`?se:oe,L=M(e,`ALLOWED_TAGS`,_t,{transform:Y}),R=M(e,`ALLOWED_ATTR`,vt,{transform:Y}),Wt=M(e,`ALLOWED_NAMESPACES`,Gt,{transform:se}),zt=M(e,`ADD_URI_SAFE_ATTR`,Bt,{transform:Y,base:Bt}),Lt=M(e,`ADD_DATA_URI_TAGS`,Rt,{transform:Y,base:Rt}),K=M(e,`FORBID_CONTENTS`,It,{transform:Y}),yt=M(e,`FORBID_TAGS`,O({}),{transform:Y}),bt=M(e,`FORBID_ATTR`,O({}),{transform:Y}),G=x(e,`USE_PROFILES`)?e.USE_PROFILES&&typeof e.USE_PROFILES==`object`?O(e.USE_PROFILES):e.USE_PROFILES:!1,xt=e.ALLOW_ARIA_ATTR!==!1,St=e.ALLOW_DATA_ATTR!==!1,Ct=e.ALLOW_UNKNOWN_PROTOCOLS||!1,wt=e.ALLOW_SELF_CLOSE_IN_ATTR!==!1,V=e.SAFE_FOR_TEMPLATES||!1,H=e.SAFE_FOR_XML!==!1,U=e.WHOLE_DOCUMENT||!1,W=e.RETURN_DOM||!1,kt=e.RETURN_DOM_FRAGMENT||!1,At=e.RETURN_TRUSTED_TYPE||!1,Ot=e.FORCE_BODY||!1,jt=e.SANITIZE_DOM!==!1,Mt=e.SANITIZE_NAMED_PROPS||!1,Pt=e.KEEP_CONTENT!==!1,Ft=e.IN_PLACE||!1,gt=ge(e.ALLOWED_URI_REGEXP)?e.ALLOWED_URI_REGEXP:Ne,J=typeof e.NAMESPACE==`string`?e.NAMESPACE:q,qt=qe(e,`MATHML_TEXT_INTEGRATION_POINTS`,()=>D({},Kt)),Yt=qe(e,`HTML_INTEGRATION_POINTS`,()=>D({},Jt));let t=qe(e,`CUSTOM_ELEMENT_HANDLING`,()=>h(null));if(z=h(null),x(t,`tagNameCheck`)&&tn(t.tagNameCheck)&&(z.tagNameCheck=t.tagNameCheck),x(t,`attributeNameCheck`)&&tn(t.attributeNameCheck)&&(z.attributeNameCheck=t.attributeNameCheck),x(t,`allowCustomizedBuiltInElements`)&&typeof t.allowCustomizedBuiltInElements==`boolean`&&(z.allowCustomizedBuiltInElements=t.allowCustomizedBuiltInElements),m(z),V&&(St=!1),kt&&(W=!0),G&&(L=D({},Ce),R=h(null),G.html===!0&&(D(L,_e),D(R,we)),G.svg===!0&&(D(L,ve),D(R,Te),D(R,De)),G.svgFilters===!0&&(D(L,ye),D(R,Te),D(R,De)),G.mathMl===!0&&(D(L,xe),D(R,Ee),D(R,De))),B.tagCheck=null,B.attributeCheck=null,x(e,`ADD_TAGS`)&&(typeof e.ADD_TAGS==`function`?B.tagCheck=e.ADD_TAGS:v(e.ADD_TAGS)&&(L===_t&&(L=O(L)),D(L,e.ADD_TAGS,Y))),x(e,`ADD_ATTR`)&&(typeof e.ADD_ATTR==`function`?B.attributeCheck=e.ADD_ATTR:v(e.ADD_ATTR)&&(R===vt&&(R=O(R)),D(R,e.ADD_ATTR,Y))),x(e,`ADD_FORBID_CONTENTS`)&&v(e.ADD_FORBID_CONTENTS)&&(K===It&&(K=O(K)),D(K,e.ADD_FORBID_CONTENTS,Y)),Pt&&(L[`#text`]=!0),U&&D(L,[`html`,`head`,`body`]),L.table&&(D(L,[`tbody`]),delete yt.tbody),e.TRUSTED_TYPES_POLICY){if(typeof e.TRUSTED_TYPES_POLICY.createHTML!=`function`)throw w(`TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.`);if(typeof e.TRUSTED_TYPES_POLICY.createScriptURL!=`function`)throw w(`TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.`);let t=N;N=e.TRUSTED_TYPES_POLICY;try{P=F(``)}catch(e){throw N=t,e}}else e.TRUSTED_TYPES_POLICY===null?(N=void 0,P=``):(N===void 0&&(N=tt()),N&&typeof P==`string`&&(P=F(``)));p&&p(e),$t=e},rn=D({},[...ve,...ye,...be]),an=D({},[...xe,...Se]),on=function(e,t,n){return t.namespaceURI===q?e===`svg`:t.namespaceURI===Vt?e===`svg`&&(n===`annotation-xml`||qt[n]):!!rn[e]},sn=function(e,t,n){return t.namespaceURI===q?e===`math`:t.namespaceURI===Ht?e===`math`&&Yt[n]:!!an[e]},cn=function(e,t,n){return t.namespaceURI===Ht&&!Yt[n]||t.namespaceURI===Vt&&!qt[n]?!1:!an[e]&&(Xt[e]||!rn[e])},ln=function(e){let t=b(e);(!t||!t.tagName)&&(t={namespaceURI:J,tagName:`template`});let n=oe(e.tagName),r=oe(t.tagName);return Wt[e.namespaceURI]?e.namespaceURI===Ht?on(n,t,r):e.namespaceURI===Vt?sn(n,t,r):e.namespaceURI===q?cn(n,t,r):!!(Zt===`application/xhtml+xml`&&Wt[e.namespaceURI]):!1},X=function(e){ie(t.removed,{element:e});try{b(e).removeChild(e)}catch(t){if(g(e),!b(e))throw w(`a node selected for removal could not be detached from its tree and cannot be safely returned; refusing to sanitize in place`)}},un=function(e,t,n){try{te(e,t)}catch(t){try{e.removeAttribute(n)}catch(e){}}},dn=function(e){pn(e);let t=y(e);if(t){let e=[];_(t,t=>{ie(e,t)}),_(e,e=>{try{g(e)}catch(e){}})}let n=S(e);if(n)for(let t=n.length-1;t>=0;--t){let r=n[t],i=r&&r.name;typeof i==`string`&&un(e,r,i)}},Z=function(e,n,r){if(!r)try{r=n.getAttributeNode(e)}catch(e){r=null}ie(t.removed,{attribute:r||null,from:n});try{r?te(n,r):n.removeAttribute(e)}catch(t){try{n.removeAttribute(e)}catch(e){}}if(e===`is`){if(W||kt)try{X(n)}catch(e){}else try{n.setAttribute(e,``)}catch(e){}}},fn=function(e){let t=S(e);if(t)for(let n=t.length-1;n>=0;--n){let r=t[n],i=r&&r.name;typeof i!=`string`||R[Y(i)]||un(e,r,i)}},pn=function(e){let t=[e];for(;t.length>0;){let e=t.pop();j(e)===A.element&&fn(e);let n=y(e);if(n)for(let e=n.length-1;e>=0;--e)t.push(n[e])}},mn=function(e,t){return H?e===`patchsrc`||e===`for`&&t!==`label`&&t!==`output`:!1},hn=function(e){if(!H)return;let t=[e];for(;t.length>0;){let e=t.pop(),n=j(e);if(n===A.processingInstruction||n===A.comment&&C(ze,e.data)){try{g(e)}catch(e){}continue}if(n===A.element){let t=e,n=Y(Ye(e));try{t.hasAttribute&&t.hasAttribute(`patchsrc`)&&t.removeAttribute(`patchsrc`),t.hasAttribute&&t.hasAttribute(`for`)&&mn(`for`,n)&&t.removeAttribute(`for`)}catch(e){}}let r=y(e);if(r)for(let e=r.length-1;e>=0;--e)t.push(r[e])}},gn=function(e){let t=null,r=null;if(Ot)e=``+e;else{let t=ce(e,/^[\r\n\t ]+/);r=t&&t[0]}Zt===`application/xhtml+xml`&&J===q&&(e=``+e+``);let i=N?F(e):e;if(J===q)try{t=new u().parseFromString(i,Zt)}catch(e){}if(!t||!t.documentElement){t=rt.createDocument(J,`template`,null);try{t.documentElement.innerHTML=Ut?P:i}catch(e){}}let a=t.body||t.documentElement;return e&&r&&a.insertBefore(n.createTextNode(r),a.childNodes[0]||null),J===q?ot.call(t,U?`html`:`body`)[0]:U?t.documentElement:a},_n=function(e){let t=me?me(e):e.ownerDocument;return it.call(t||e,e,l.SHOW_ELEMENT|l.SHOW_COMMENT|l.SHOW_TEXT|l.SHOW_PROCESSING_INSTRUCTION|l.SHOW_CDATA_SECTION,null)},vn=function(e){return e=le(e,ct,` `),e=le(e,lt,` `),e=le(e,ut,` `),e},yn=function(e){var t;e.normalize();let n=me?me(e):e.ownerDocument,r=it.call(n||e,e,l.SHOW_TEXT|l.SHOW_COMMENT|l.SHOW_CDATA_SECTION|l.SHOW_PROCESSING_INSTRUCTION,null),i=r.nextNode();for(;i;)i.data=vn(i.data),i=r.nextNode();let a=(t=e.querySelectorAll)==null?void 0:t.call(e,`template`);a&&_(a,e=>{xn(e.content)&&yn(e.content)})},bn=function(e){let t=E?E(e):null;return typeof t!=`string`||Y(t)!==`form`?!1:typeof e.nodeName!=`string`||typeof e.textContent!=`string`||typeof e.removeChild!=`function`||e.attributes!==S(e)||typeof e.removeAttribute!=`function`||typeof e.removeAttributeNode!=`function`||typeof e.getAttributeNode!=`function`||typeof e.setAttribute!=`function`||typeof e.namespaceURI!=`string`||typeof e.insertBefore!=`function`||typeof e.hasChildNodes!=`function`||e.nodeType!==T(e)||e.childNodes!==y(e)},xn=function(e){if(!T||typeof e!=`object`||!e)return!1;try{return T(e)===A.documentFragment}catch(e){return!1}},Sn=function(e){if(!T||typeof e!=`object`||!e)return!1;try{return typeof T(e)==`number`}catch(e){return!1}};function Q(e,n,r){e.length!==0&&_(e,e=>{e.call(t,n,r,$t)})}let Cn=function(e,t){return!!(H&&e.hasChildNodes()&&!Sn(e.firstElementChild)&&C(Re,e.textContent)&&C(Re,e.innerHTML)||H&&e.namespaceURI===q&&He[t]&&(Sn(e.firstElementChild)||typeof e.textContent==`string`&&C(Ue[t],e.textContent))||e.nodeType===A.processingInstruction||H&&e.nodeType===A.comment&&C(ze,e.data))},wn=function(e,t){return e instanceof RegExp?C(e,t):e instanceof Function&&!!e(t,...[...arguments].slice(2))},Tn=function(e,t,n){if(!yt[t]&&An(t)&&wn(z.tagNameCheck,t))return!1;if(Pt&&!K[t]){let t=b(e),r=y(e);if(r&&t){let i=r.length;for(let a=i-1;a>=0;--a){let i=e===n?ee(r[a],!0):r[a];t.insertBefore(i,fe(e))}}}return X(e),!0},En=function(e,t,n,r){return e.length===0?t:t===n||t===r?O(t):t},$=function(e,t){return e===t||b(e)!==null?!1:(Ft&&pn(e),!0)},Dn=function(e,n){if(Q(I.beforeSanitizeElements,e,null),$(e,n))return!0;if(bn(e))return X(e),!0;let r=Y(Ye(e));if(L=En(I.uponSanitizeElement,L,_t,Et),Q(I.uponSanitizeElement,e,{tagName:r,allowedTags:L}),$(e,n))return!0;if(Cn(e,r))return X(e),!0;if(yt[r]||!(B.tagCheck instanceof Function&&B.tagCheck(r))&&!L[r]){let t=Tn(e,r,n);return t===!1&&(Q(I.afterSanitizeElements,e,null),$(e,n))?!0:t}if(j(e)===A.element&&!ln(e)||(r===`noscript`||r===`noembed`||r===`noframes`)&&C(Be,e.innerHTML))return X(e),!0;if(V&&e.nodeType===A.text){let n=vn(e.textContent);e.textContent!==n&&(ie(t.removed,{element:e.cloneNode()}),e.textContent=n)}return Q(I.afterSanitizeElements,e,null),$(e,n)},On=function(e,t,r){if(bt[t]||mn(t,e)||jt&&(t===`id`||t===`name`)&&(r in n||r in en))return!1;let i=R[t]||B.attributeCheck instanceof Function&&B.attributeCheck(t,e);return St&&C(dt,t)||xt&&C(ft,t)?!0:i?zt[t]||C(gt,le(r,mt,``))||(t===`src`||t===`xlink:href`||t===`href`)&&e!==`script`&&ue(r,`data:`)===0&&Lt[e]||Ct&&!C(pt,le(r,mt,``))?!0:!r:An(e)&&wn(z.tagNameCheck,e)&&wn(z.attributeNameCheck,t,e)||t===`is`&&z.allowCustomizedBuiltInElements&&wn(z.tagNameCheck,r)},kn=D({},[`annotation-xml`,`color-profile`,`font-face`,`font-face-format`,`font-face-name`,`font-face-src`,`font-face-uri`,`missing-glyph`]),An=function(e){return!kn[oe(e)]&&C(ht,e)},jn=function(e,t,n,r){if(N&&typeof d==`object`&&typeof d.getAttributeType==`function`&&!n)switch(d.getAttributeType(e,t)){case`TrustedHTML`:return F(r);case`TrustedScriptURL`:return et(r)}return r},Mn=function(e,t,n,r){try{return n?e.setAttributeNS(n,t,r):e.setAttribute(t,r),!bn(e)||(X(e),!1)}catch(n){return Z(t,e),!1}},Nn=function(e,n){if(Q(I.beforeSanitizeAttributes,e,null),$(e,n))return;let r=e.attributes;if(!r||bn(e))return;R=En(I.uponSanitizeAttribute,R,vt,Dt);let i={attrName:``,attrValue:``,keepAttr:!0,allowedAttributes:R,forceKeepAttr:void 0},a=r.length,o=Y(e.nodeName);for(;a--;){let n=r[a],s=n.name,c=n.namespaceURI,l=n.value,u=Y(s),d=l,f=s===`value`?d:de(d),p=!1;if(i.attrName=u,i.attrValue=f,i.keepAttr=!0,i.forceKeepAttr=void 0,Q(I.uponSanitizeAttribute,e,i),f=i.attrValue,Mt&&(u===`id`||u===`name`)&&ue(f,Nt)!==0&&(Z(s,e,n),f=Nt+f,p=!0),H&&C(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i,f)){Z(s,e,n);continue}if(u===`attributename`&&ce(f,`href`)){Z(s,e,n);continue}if(!i.forceKeepAttr){if(!i.keepAttr){Z(s,e,n);continue}if(!wt&&C(Ve,f)){Z(s,e,n);continue}if(V&&(f=vn(f)),!On(o,u,f)){Z(s,e,n);continue}f=jn(o,u,c,f),f!==d&&Mn(e,s,c,f)&&p&&re(t.removed)}}Q(I.afterSanitizeAttributes,e,null),$(e,n)},Pn=function(e){let t=null,n=_n(e);for(Q(I.beforeSanitizeShadowDOM,e,null);t=n.nextNode();)if(Q(I.uponSanitizeShadowNode,t,null),Dn(t,e),Nn(t,e),xn(t.content)&&Pn(t.content),j(t)===A.element){let e=pe(t);xn(e)&&(Fn(e),Pn(e))}Q(I.afterSanitizeShadowDOM,e,null)},Fn=function(e){let t=[{node:e,shadow:null}];for(;t.length>0;){let e=t.pop();if(e.shadow){Pn(e.shadow);continue}let n=e.node,r=j(n)===A.element,i=y(n);if(i)for(let e=i.length-1;e>=0;--e)t.push({node:i[e],shadow:null});if(r){let e=E?E(n):null;if(typeof e==`string`&&Y(e)===`template`){let e=n.content;xn(e)&&t.push({node:e,shadow:null})}}if(r){let e=pe(n);xn(e)&&t.push({node:null,shadow:e},{node:e,shadow:null})}}};return t.sanitize=function(e){let n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},i=null,a=null,o=null,s=null;if(Ut=!e,Ut&&(e=``),typeof e!=`string`&&!Sn(e)&&(e=he(e),typeof e!=`string`))throw w(`dirty is not a string, aborting`);if(!t.isSupported)return e;Tt?(L=Et,R=Dt):nn(n),(I.uponSanitizeElement.length>0||I.uponSanitizeAttribute.length>0)&&(L=O(L)),I.uponSanitizeAttribute.length>0&&(R=O(R)),t.removed=[];let c=Ft&&typeof e!=`string`&&Sn(e);if(c){hn(e);let t=Ye(e);if(typeof t==`string`){let n=Y(t);if(!L[n]||yt[n])throw dn(e),w(`root node is forbidden and cannot be sanitized in-place`)}if(bn(e))throw dn(e),w(`root node is clobbered and cannot be sanitized in-place`);try{Fn(e)}catch(t){throw dn(e),t}}else if(Sn(e))i=gn(``),a=i.ownerDocument.importNode(e,!0),a.nodeType===A.element&&a.nodeName===`BODY`||a.nodeName===`HTML`?i=a:i.appendChild(a),Fn(i);else{if(!W&&!V&&!U&&e.indexOf(`<`)===-1)return N&&At?F(e):e;if(i=gn(e),!i)return W?null:At?P:``}i&&Ot&&X(i.firstChild);let l=c?e:i;try{let e=_n(l);for(;o=e.nextNode();)Dn(o,l),Nn(o,l),xn(o.content)&&Pn(o.content)}catch(n){throw c&&(dn(e),_(t.removed,e=>{e.element&&pn(e.element)})),n}if(c){let n=!1;if(_(t.removed,t=>{t.element&&(t.element===e&&(n=!0),pn(t.element))}),n)throw w(`a node selected for removal could not be safely returned; refusing to sanitize in place`);return V&&yn(e),e}if(W){if(V&&yn(i),kt)for(s=at.call(i.ownerDocument);i.firstChild;)s.appendChild(i.firstChild);else s=i;return(R.shadowroot||R.shadowrootmode)&&(s=st.call(r,s,!0)),s}let u=U?i.outerHTML:i.innerHTML;return U&&L[`!doctype`]&&i.ownerDocument&&i.ownerDocument.doctype&&i.ownerDocument.doctype.name&&C(Ie,i.ownerDocument.doctype.name)&&(u=` +`+u),V&&(u=vn(u)),N&&At?F(u):u},t.setConfig=function(){let e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};nn(e),Tt=!0,Et=L,Dt=R},t.clearConfig=function(){$t=null,Tt=!1,Et=null,Dt=null,N=Xe,P=``},t.isValidAttribute=function(e,t,n){$t||nn({});let r=Y(e),i=Y(t);return On(r,i,n)},t.addHook=function(e,t){typeof t==`function`&&x(I,e)&&ie(I[e],t)},t.removeHook=function(e,t){if(x(I,e)){if(t!==void 0){let n=ne(I[e],t);return n===-1?void 0:ae(I[e],n,1)[0]}return re(I[e])}},t.removeHooks=function(e){x(I,e)&&(I[e]=[])},t.removeAllHooks=function(){I=Ke()},t}return Je()}); +//# sourceMappingURL=purify.min.js.map \ No newline at end of file From 38ac800244ce3bb4237bf2345c544a2d77f1ebd2 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:39:58 +0200 Subject: [PATCH 004/155] build(deps): update highlight.js to 11.12.0 --- THIRD-PARTY-NOTICES.md | 17 +- src/main/resources/jcef/highlight.min.js | 785 ++++++++++++----------- 2 files changed, 406 insertions(+), 396 deletions(-) diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 4d246f85..5b1f8a7b 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -21,10 +21,9 @@ grant is silent. The vendored versions below are the ones read out of the shipped files themselves (`marked`'s and `highlight.js`'s embedded version strings, DOMPurify's `version` constant), not the ones named in a -banner. `marked.min.js` and `purify.min.js` were additionally confirmed **byte-identical** to the -upstream published `dist` for their stated version, which is what substantiates "redistributed -verbatim, unmodified" below; `highlight.min.js` is a curated subset build and so matches no upstream -artifact by construction. +banner. All three files were additionally confirmed **byte-identical** to the upstream published +artifact for their stated version (the npm tarball, integrity-checked against the registry), which is +what substantiates "redistributed verbatim, unmodified" below. The license texts referenced as `LICENSES/…` live at the repository root during development and are packaged into the artifact under `META-INF/licenses/` (see `build.gradle.kts`), alongside this file @@ -36,7 +35,7 @@ carries the vendored web assets), the two kotlinx.serialization jars listed belo `searchableOptions` jar. Everything with a third-party license in that list has an entry here. Last verified: 2026-08-11, against the upstream `LICENSE` files at the pinned tags -(`markedjs/marked@v12.0.0`, `cure53/DOMPurify@3.4.16`, `highlightjs/highlight.js@11.11.2`, +(`markedjs/marked@v12.0.0`, `cure53/DOMPurify@3.4.16`, `highlightjs/highlight.js@11.12.0`, `Kotlin/kotlinx.serialization@v1.7.3`). --- @@ -45,8 +44,7 @@ Last verified: 2026-08-11, against the upstream `LICENSE` files at the pinned ta These are vendored into the plugin's embedded web UI under `jcef/` and are served to the JCEF browser at runtime. Each is redistributed exactly as published upstream, with its license banner -intact and no edit of our own — verbatim for marked and DOMPurify, and for highlight.js the upstream -subset build as generated (see its entry). +intact and no edit of our own. ### marked — 12.0.0 - **License:** `MIT AND BSD-3-Clause` — marked itself is MIT; its `LICENSE.md` additionally @@ -92,12 +90,13 @@ subset build as generated (see its entry). - **Verified:** the DOMPurify repository publishes **no `NOTICE` file** at tag `3.4.16`, so having chosen Apache-2.0 there is nothing further to propagate under Apache-2.0 §4(d). -### highlight.js — 11.11.2 +### highlight.js — 11.12.0 - **License:** BSD-3-Clause (`SPDX-License-Identifier: BSD-3-Clause`) - **Copyright:** Copyright (c) 2006, Ivan Sagalaev. All rights reserved. - **Project:** https://github.com/highlightjs/highlight.js - **Full text:** `LICENSES/BSD-3-Clause.txt` — byte-identical to the upstream `LICENSE` at this tag. -- **Note:** a curated subset build (37 bundled grammars), redistributed as built. The banner inside +- **Note:** the upstream common build (`@highlightjs/cdn-assets@11.12.0`, `highlight.min.js`, 36 + bundled grammars), redistributed verbatim. The banner inside `highlight.min.js` reads *"(c) 2006-2026 Josh Goebel <hello@joshgoebel.com> and other contributors"*, but the `LICENSE` at this tag still names only Ivan Sagalaev — so the copyright above is the license's, not the banner's, and it has not changed across the versions vendored here. diff --git a/src/main/resources/jcef/highlight.min.js b/src/main/resources/jcef/highlight.min.js index 00a92e10..70a7ef60 100644 --- a/src/main/resources/jcef/highlight.min.js +++ b/src/main/resources/jcef/highlight.min.js @@ -1,5 +1,5 @@ /*! - Highlight.js v11.11.2 (git: f273f007f8) + Highlight.js v11.12.0 (git: f7f7d3803b) (c) 2006-2026 Josh Goebel and other contributors License: BSD-3-Clause */ @@ -7,22 +7,22 @@ var hljs=function(){"use strict";function e(n){ return n instanceof Map?n.clear=n.delete=n.set=()=>{ throw Error("map is read-only")}:n instanceof Set&&(n.add=n.clear=n.delete=()=>{ throw Error("set is read-only") -}),Object.freeze(n),Object.getOwnPropertyNames(n).forEach((t=>{ +}),Object.freeze(n),Object.getOwnPropertyNames(n).forEach(t=>{ const a=n[t],i=typeof a;"object"!==i&&"function"!==i||Object.isFrozen(a)||e(a) -})),n}class n{constructor(e){ +}),n}class n{constructor(e){ void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1} ignoreMatch(){this.isMatchIgnored=!0}}function t(e){ return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'") }function a(e,...n){const t=Object.create(null);for(const n in e)t[n]=e[n] -;return n.forEach((e=>{for(const n in e)t[n]=e[n]})),t}const i=e=>!!e.scope +;return n.forEach(e=>{for(const n in e)t[n]=e[n]}),t}const i=e=>!!e.scope ;class r{constructor(e,n){ this.buffer="",this.classPrefix=n.classPrefix,e.walk(this)}addText(e){ this.buffer+=t(e)}openNode(e){if(!i(e))return;const n=((e,{prefix:n})=>{ if(e.startsWith("language:"))return e.replace("language:","language-") ;if(e.includes(".")){const t=e.split(".") -;return[`${n}${t.shift()}`,...t.map(((e,n)=>`${e}${"_".repeat(n+1)}`))].join(" ") -}return`${n}${e}`})(e.scope,{prefix:this.classPrefix});this.span(n)} -closeNode(e){i(e)&&(this.buffer+="")}value(){return this.buffer}span(e){ +;return[`${n}${t.shift()}`,...t.map((e,n)=>`${e}${"_".repeat(n+1)}`)].join(" ")} +return`${n}${e}`})(e.scope,{prefix:this.classPrefix});this.span(n)}closeNode(e){ +i(e)&&(this.buffer+="")}value(){return this.buffer}span(e){ this.buffer+=``}}const s=(e={})=>{const n={children:[]} ;return Object.assign(n,e),n};class o{constructor(){ this.rootNode=s(),this.stack=[this.rootNode]}get top(){ @@ -33,27 +33,27 @@ if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){ for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)} walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,n){ return"string"==typeof n?e.addText(n):n.children&&(e.openNode(n), -n.children.forEach((n=>this._walk(e,n))),e.closeNode(n)),e}static _collapse(e){ -"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{ -o._collapse(e)})))}}class l extends o{constructor(e){super(),this.options=e} +n.children.forEach(n=>this._walk(e,n)),e.closeNode(n)),e}static _collapse(e){ +"string"!=typeof e&&e.children&&(e.children.every(e=>"string"==typeof e)?e.children=[e.children.join("")]:e.children.forEach(e=>{ +o._collapse(e)}))}}class c extends o{constructor(e){super(),this.options=e} addText(e){""!==e&&this.add(e)}startScope(e){this.openNode(e)}endScope(){ this.closeNode()}__addSublanguage(e,n){const t=e.root ;n&&(t.scope="language:"+n),this.add(t)}toHTML(){ return new r(this,this.options).value()}finalize(){ -return this.closeAllNodes(),!0}}function c(e){ +return this.closeAllNodes(),!0}}function l(e){ return e?"string"==typeof e?e:e.source:null}function d(e){return b("(?=",e,")")} function g(e){return b("(?:",e,")*")}function u(e){return b("(?:",e,")?")} -function b(...e){return e.map((e=>c(e))).join("")}function m(...e){const n=(e=>{ +function b(...e){return e.map(e=>l(e)).join("")}function m(...e){const n=(e=>{ const n=e[e.length-1] ;return"object"==typeof n&&n.constructor===Object?(e.splice(e.length-1,1),n):{} -})(e);return"("+(n.capture?"":"?:")+e.map((e=>c(e))).join("|")+")"} -function p(e){return RegExp(e.toString()+"|").exec("").length-1} -const _=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./ -;function h(e,{joinWith:n}){let t=0;return e.map((e=>{t+=1;const n=t -;let a=c(e),i="";for(;a.length>0;){const e=_.exec(a);if(!e){i+=a;break} +})(e);return"("+(n.capture?"":"?:")+e.map(e=>l(e)).join("|")+")"}function p(e){ +return RegExp(e.toString()+"|").exec("").length-1} +const _=RegExp(m(/\[(?:[^\\\]]|\\.)*\]/,/\(\?<(?![=!])[^>]+>/,/\(\?'[^']+'/,/\(\??/,/\\([1-9][0-9]*)/,/\\./)) +;function h(e,{joinWith:n}){let t=0;return e.map(e=>{t+=1;const n=t +;let a=l(e),i="";for(;a.length>0;){const e=_.exec(a);if(!e){i+=a;break} i+=a.substring(0,e.index), a=a.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?i+="\\"+(Number(e[1])+n):(i+=e[0], -"("===e[0]&&t++)}return i})).map((e=>`(${e})`)).join(n)} +("("===e[0]||/^\(\?[<']/.test(e[0]))&&t++)}return i}).map(e=>`(${e})`).join(n)} const f="[a-zA-Z]\\w*",E="[a-zA-Z_]\\w*",y="\\b\\d+(\\.\\d+)?",w="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",v="\\b(0b[01]+)",N={ begin:"\\\\[\\s\\S]",relevance:0},k={scope:"string",begin:"'",end:"'", illegal:"\\n",contains:[N]},x={scope:"string",begin:'"',end:'"',illegal:"\\n", @@ -93,16 +93,16 @@ if(e.begin||e.end)throw Error("begin & end are not supported with match") ;e.begin=e.match,delete e.match}}function B(e,n){ void 0===e.relevance&&(e.relevance=1)}const F=(e,n)=>{if(!e.beforeMatch)return ;if(e.starts)throw Error("beforeMatch cannot be used with starts") -;const t=Object.assign({},e);Object.keys(e).forEach((n=>{delete e[n] -})),e.keywords=t.keywords,e.begin=b(t.beforeMatch,d(t.begin)),e.starts={ +;const t=Object.assign({},e);Object.keys(e).forEach(n=>{delete e[n] +}),e.keywords=t.keywords,e.begin=b(t.beforeMatch,d(t.begin)),e.starts={ relevance:0,contains:[Object.assign(t,{endsParent:!0})] },e.relevance=0,delete t.beforeMatch },$=["of","and","for","in","not","or","if","then","parent","list","value"] ;function z(e,n,t="keyword"){const a=Object.create(null) -;return"string"==typeof e?i(t,e.split(" ")):Array.isArray(e)?i(t,e):Object.keys(e).forEach((t=>{ -Object.assign(a,z(e[t],n,t))})),a;function i(e,t){ -n&&(t=t.map((e=>e.toLowerCase()))),t.forEach((n=>{const t=n.split("|") -;a[t[0]]=[e,j(t[0],t[1])]}))}}function j(e,n){ +;return"string"==typeof e?i(t,e.split(" ")):Array.isArray(e)?i(t,e):Object.keys(e).forEach(t=>{ +Object.assign(a,z(e[t],n,t))}),a;function i(e,t){ +n&&(t=t.map(e=>e.toLowerCase())),t.forEach(n=>{const t=n.split("|") +;a[t[0]]=[e,j(t[0],t[1])]})}}function j(e,n){ return n?Number(n):(e=>$.includes(e.toLowerCase()))(e)?0:1}const U={},P=e=>{ console.error(e)},K=(e,...n)=>{console.log("WARN: "+e,...n)},H=(e,n)=>{ U[`${e}/${n}`]||(console.log(`Deprecated as of ${e}. ${n}`),U[`${e}/${n}`]=!0) @@ -123,21 +123,21 @@ q ;if("object"!=typeof e.endScope||null===e.endScope)throw P("endScope must be object"), q;G(e,e.end,{key:"endScope"}),e.end=h(e.end,{joinWith:""})}})(e)}function W(e){ function n(n,t){ -return RegExp(c(n),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(t?"g":"")) +return RegExp(l(n),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(t?"g":"")) }class t{constructor(){ this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0} addRule(e,n){ n.position=this.position++,this.matchIndexes[this.matchAt]=n,this.regexes.push([n,e]), this.matchAt+=p(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null) -;const e=this.regexes.map((e=>e[1]));this.matcherRe=n(h(e,{joinWith:"|" +;const e=this.regexes.map(e=>e[1]);this.matcherRe=n(h(e,{joinWith:"|" }),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex ;const n=this.matcherRe.exec(e);if(!n)return null -;const t=n.findIndex(((e,n)=>n>0&&void 0!==e)),a=this.matchIndexes[t] +;const t=n.findIndex((e,n)=>n>0&&void 0!==e),a=this.matchIndexes[t] ;return n.splice(0,t),Object.assign(n,a)}}class i{constructor(){ this.rules=[],this.multiRegexes=[], this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){ if(this.multiRegexes[e])return this.multiRegexes[e];const n=new t -;return this.rules.slice(e).forEach((([e,t])=>n.addRule(e,t))), +;return this.rules.slice(e).forEach(([e,t])=>n.addRule(e,t)), n.compile(),this.multiRegexes[e]=n,n}resumingScanAtSamePosition(){ return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,n){ this.rules.push([e,n]),"begin"===n.type&&this.count++}exec(e){ @@ -151,33 +151,33 @@ if(e.compilerExtensions||(e.compilerExtensions=[]), e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.") ;return e.classNameAliases=a(e.classNameAliases||{}),function t(r,s){const o=r ;if(r.isCompiled)return o -;[R,L,Z,F].forEach((e=>e(r,s))),e.compilerExtensions.forEach((e=>e(r,s))), -r.__beforeBegin=null,[D,I,B].forEach((e=>e(r,s))),r.isCompiled=!0;let l=null +;[R,L,Z,F].forEach(e=>e(r,s)),e.compilerExtensions.forEach(e=>e(r,s)), +r.__beforeBegin=null,[D,I,B].forEach(e=>e(r,s)),r.isCompiled=!0;let c=null ;return"object"==typeof r.keywords&&r.keywords.$pattern&&(r.keywords=Object.assign({},r.keywords), -l=r.keywords.$pattern, -delete r.keywords.$pattern),l=l||/\w+/,r.keywords&&(r.keywords=z(r.keywords,e.case_insensitive)), -o.keywordPatternRe=n(l,!0), +c=r.keywords.$pattern, +delete r.keywords.$pattern),c=c||/\w+/,r.keywords&&(r.keywords=z(r.keywords,e.case_insensitive)), +o.keywordPatternRe=n(c,!0), s&&(r.begin||(r.begin=/\B|\b/),o.beginRe=n(o.begin),r.end||r.endsWithParent||(r.end=/\B|\b/), r.end&&(o.endRe=n(o.end)), -o.terminatorEnd=c(o.end)||"",r.endsWithParent&&s.terminatorEnd&&(o.terminatorEnd+=(r.end?"|":"")+s.terminatorEnd)), +o.terminatorEnd=l(o.end)||"",r.endsWithParent&&s.terminatorEnd&&(o.terminatorEnd+=(r.end?"|":"")+s.terminatorEnd)), r.illegal&&(o.illegalRe=n(r.illegal)), -r.contains||(r.contains=[]),r.contains=[].concat(...r.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((n=>a(e,{ -variants:null},n)))),e.cachedVariants?e.cachedVariants:Q(e)?a(e,{ +r.contains||(r.contains=[]),r.contains=[].concat(...r.contains.map(e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map(n=>a(e,{ +variants:null},n))),e.cachedVariants?e.cachedVariants:Q(e)?a(e,{ starts:e.starts?a(e.starts):null -}):Object.isFrozen(e)?a(e):e))("self"===e?r:e)))),r.contains.forEach((e=>{t(e,o) -})),r.starts&&t(r.starts,s),o.matcher=(e=>{const n=new i -;return e.contains.forEach((e=>n.addRule(e.begin,{rule:e,type:"begin" -}))),e.terminatorEnd&&n.addRule(e.terminatorEnd,{type:"end" +}):Object.isFrozen(e)?a(e):e))("self"===e?r:e))),r.contains.forEach(e=>{t(e,o) +}),r.starts&&t(r.starts,s),o.matcher=(e=>{const n=new i +;return e.contains.forEach(e=>n.addRule(e.begin,{rule:e,type:"begin" +})),e.terminatorEnd&&n.addRule(e.terminatorEnd,{type:"end" }),e.illegal&&n.addRule(e.illegal,{type:"illegal"}),n})(o),o}(e)}function Q(e){ return!!e&&(e.endsWithParent||Q(e.starts))}class X extends Error{ constructor(e,n){super(e),this.name="HTMLInjectionError",this.html=n}} const V=t,J=a,Y=Symbol("nomatch"),ee=t=>{ const a=Object.create(null),i=Object.create(null),r=[];let s=!0 -;const o="Could not find the language '{}', did you forget to load/include a language module?",c={ +;const o="Could not find the language '{}', did you forget to load/include a language module?",l={ disableAutodetect:!0,name:"Plain text",contains:[]};let p={ ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i, languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-", -cssSelector:"pre code",languages:null,__emitter:l};function _(e){ +cssSelector:"pre code",languages:null,__emitter:c};function _(e){ return p.noHighlightRe.test(e)}function h(e,n,t){let a="",i="" ;"object"==typeof n?(a=e, t=n.ignoreIllegals,i=n.language):(H("10.7.0","highlight(lang, code, ...args) has been deprecated."), @@ -185,82 +185,82 @@ H("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/hig i=e,a=n),void 0===t&&(t=!0);const r={code:a,language:i};O("before:highlight",r) ;const s=r.result?r.result:f(r.language,r.code,t) ;return s.code=r.code,O("after:highlight",s),s}function f(e,t,i,r){ -const l=Object.create(null);function c(){if(!O.keywords)return void A.addText(S) -;let e=0;O.keywordPatternRe.lastIndex=0;let n=O.keywordPatternRe.exec(S),t="" -;for(;n;){t+=S.substring(e,n.index) -;const i=v.case_insensitive?n[0].toLowerCase():n[0],r=(a=i,O.keywords[a]);if(r){ -const[e,a]=r -;if(A.addText(t),t="",l[i]=(l[i]||0)+1,l[i]<=7&&(C+=a),e.startsWith("_"))t+=n[0];else{ -const t=v.classNameAliases[e]||e;g(n[0],t)}}else t+=n[0] -;e=O.keywordPatternRe.lastIndex,n=O.keywordPatternRe.exec(S)}var a -;t+=S.substring(e),A.addText(t)}function d(){null!=O.subLanguage?(()=>{ -if(""===S)return;let e=null;if("string"==typeof O.subLanguage){ -if(!a[O.subLanguage])return void A.addText(S) -;e=f(O.subLanguage,S,!0,M[O.subLanguage]),M[O.subLanguage]=e._top -}else e=E(S,O.subLanguage.length?O.subLanguage:null) -;O.relevance>0&&(C+=e.relevance),A.__addSublanguage(e._emitter,e.language) -})():c(),S=""}function g(e,n){ -""!==e&&(A.startScope(n),A.addText(e),A.endScope())}function u(e,n){let t=1 +const c=Object.create(null);function l(e,n){return e.keywords[n]}function d(){ +if(!M.keywords)return void S.addText(C);let e=0;M.keywordPatternRe.lastIndex=0 +;let n=M.keywordPatternRe.exec(C),t="";for(;n;){t+=C.substring(e,n.index) +;const a=k.case_insensitive?n[0].toLowerCase():n[0],i=l(M,a);if(i){const[e,r]=i +;if(S.addText(t), +t="",c[a]=(c[a]||0)+1,c[a]<=7&&(T+=r),e.startsWith("_"))t+=n[0];else{ +const t=k.classNameAliases[e]||e;u(n[0],t)}}else t+=n[0] +;e=M.keywordPatternRe.lastIndex,n=M.keywordPatternRe.exec(C)} +t+=C.substring(e),S.addText(t)}function g(){null!=M.subLanguage?(()=>{ +if(""===C)return;let e=null;if("string"==typeof M.subLanguage){ +if(!a[M.subLanguage])return void S.addText(C) +;e=f(M.subLanguage,C,!0,A[M.subLanguage]),A[M.subLanguage]=e._top +}else e=E(C,M.subLanguage.length?M.subLanguage:null) +;M.relevance>0&&(T+=e.relevance),S.__addSublanguage(e._emitter,e.language) +})():d(),C=""}function u(e,n){ +""!==e&&(S.startScope(n),S.addText(e),S.endScope())}function b(e,n){let t=1 ;const a=n.length-1;for(;t<=a;){if(!e._emit[t]){t++;continue} -const a=v.classNameAliases[e[t]]||e[t],i=n[t];a?g(i,a):(S=i,c(),S=""),t++}} -function b(e,n){ -return e.scope&&"string"==typeof e.scope&&A.openNode(v.classNameAliases[e.scope]||e.scope), -e.beginScope&&(e.beginScope._wrap?(g(S,v.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap), -S=""):e.beginScope._multi&&(u(e.beginScope,n),S="")),O=Object.create(e,{parent:{ -value:O}}),O}function m(e,t,a){let i=((e,n)=>{const t=e&&e.exec(n) +const a=k.classNameAliases[e[t]]||e[t],i=n[t];a?u(i,a):(C=i,d(),C=""),t++}} +function m(e,n){ +return e.scope&&"string"==typeof e.scope&&S.openNode(k.classNameAliases[e.scope]||e.scope), +e.beginScope&&(e.beginScope._wrap?(u(C,k.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap), +C=""):e.beginScope._multi&&(b(e.beginScope,n),C="")),M=Object.create(e,{parent:{ +value:M}}),M}function _(e,t,a){let i=((e,n)=>{const t=e&&e.exec(n) ;return t&&0===t.index})(e.endRe,a);if(i){if(e["on:end"]){const a=new n(e) ;e["on:end"](t,a),a.isMatchIgnored&&(i=!1)}if(i){ for(;e.endsParent&&e.parent;)e=e.parent;return e}} -if(e.endsWithParent)return m(e.parent,t,a)}function _(e){ -return 0===O.matcher.regexIndex?(S+=e[0],1):(D=!0,0)}function h(e){ -const n=e[0],a=t.substring(e.index),i=m(O,e,a);if(!i)return Y;const r=O -;O.endScope&&O.endScope._wrap?(d(), -g(n,O.endScope._wrap)):O.endScope&&O.endScope._multi?(d(), -u(O.endScope,e)):r.skip?S+=n:(r.returnEnd||r.excludeEnd||(S+=n), -d(),r.excludeEnd&&(S=n));do{ -O.scope&&A.closeNode(),O.skip||O.subLanguage||(C+=O.relevance),O=O.parent -}while(O!==i.parent);return i.starts&&b(i.starts,e),r.returnEnd?0:n.length} -let y={};function w(a,r){const o=r&&r[0];if(S+=a,null==o)return d(),0 -;if("begin"===y.type&&"end"===r.type&&y.index===r.index&&""===o){ -if(S+=t.slice(r.index,r.index+1),!s){const n=Error(`0 width match regex (${e})`) -;throw n.languageName=e,n.badRule=y.rule,n}return 1} -if(y=r,"begin"===r.type)return(e=>{ +if(e.endsWithParent)return _(e.parent,t,a)}function h(e){ +return 0===M.matcher.regexIndex?(C+=e[0],1):(I=!0,0)}function y(e){ +const n=e[0],a=t.substring(e.index),i=_(M,e,a);if(!i)return Y;const r=M +;M.endScope&&M.endScope._wrap?(g(), +u(n,M.endScope._wrap)):M.endScope&&M.endScope._multi?(g(), +b(M.endScope,e)):r.skip?C+=n:(r.returnEnd||r.excludeEnd||(C+=n), +g(),r.excludeEnd&&(C=n));do{ +M.scope&&S.closeNode(),M.skip||M.subLanguage||(T+=M.relevance),M=M.parent +}while(M!==i.parent);return i.starts&&m(i.starts,e),r.returnEnd?0:n.length} +let w={};function v(a,r){const o=r&&r[0];if(C+=a,null==o)return g(),0 +;if("begin"===w.type&&"end"===r.type&&w.index===r.index&&""===o){ +if(C+=t.slice(r.index,r.index+1),!s){const n=Error(`0 width match regex (${e})`) +;throw n.languageName=e,n.badRule=w.rule,n}return 1} +if(w=r,"begin"===r.type)return(e=>{ const t=e[0],a=e.rule,i=new n(a),r=[a.__beforeBegin,a["on:begin"]] -;for(const n of r)if(n&&(n(e,i),i.isMatchIgnored))return _(t) -;return a.skip?S+=t:(a.excludeBegin&&(S+=t), -d(),a.returnBegin||a.excludeBegin||(S=t)),b(a,e),a.returnBegin?0:t.length})(r) +;for(const n of r)if(n&&(n(e,i),i.isMatchIgnored))return h(t) +;return a.skip?C+=t:(a.excludeBegin&&(C+=t), +g(),a.returnBegin||a.excludeBegin||(C=t)),m(a,e),a.returnBegin?0:t.length})(r) ;if("illegal"===r.type&&!i){ -const e=Error('Illegal lexeme "'+o+'" for mode "'+(O.scope||"")+'"') -;throw e.mode=O,e}if("end"===r.type){const e=h(r);if(e!==Y)return e} -if("illegal"===r.type&&""===o)return r.index===t.length||(S+="\n"),1 -;if(R>1e5&&R>3*r.index)throw Error("potential infinite loop, way more iterations than matches") -;return S+=o,o.length}const v=N(e) -;if(!v)throw P(o.replace("{}",e)),Error('Unknown language: "'+e+'"') -;const k=W(v);let x="",O=r||k;const M={},A=new p.__emitter(p);(()=>{const e=[] -;for(let n=O;n!==v;n=n.parent)n.scope&&e.unshift(n.scope) -;e.forEach((e=>A.openNode(e)))})();let S="",C=0,T=0,R=0,D=!1;try{ -if(v.__emitTokens)v.__emitTokens(t,A);else{for(O.matcher.considerAll();;){ -R++,D?D=!1:O.matcher.considerAll(),O.matcher.lastIndex=T -;const e=O.matcher.exec(t);if(!e)break;const n=w(t.substring(T,e.index),e) -;T=e.index+n}w(t.substring(T))}return A.finalize(),x=A.toHTML(),{language:e, -value:x,relevance:C,illegal:!1,_emitter:A,_top:O}}catch(n){ +const e=Error('Illegal lexeme "'+o+'" for mode "'+(M.scope||"")+'"') +;throw e.mode=M,e}if("end"===r.type){const e=y(r);if(e!==Y)return e} +if("illegal"===r.type&&""===o)return r.index===t.length||(C+="\n"),1 +;if(D>1e5&&D>3*r.index)throw Error("potential infinite loop, way more iterations than matches") +;return C+=o,o.length}const k=N(e) +;if(!k)throw P(o.replace("{}",e)),Error('Unknown language: "'+e+'"') +;const x=W(k);let O="",M=r||x;const A={},S=new p.__emitter(p);(()=>{const e=[] +;for(let n=M;n!==k;n=n.parent)n.scope&&e.unshift(n.scope) +;e.forEach(e=>S.openNode(e))})();let C="",T=0,R=0,D=0,I=!1;try{ +if(k.__emitTokens)k.__emitTokens(t,S);else{for(M.matcher.considerAll();;){ +D++,I?I=!1:M.matcher.considerAll(),M.matcher.lastIndex=R +;const e=M.matcher.exec(t);if(!e)break;const n=v(t.substring(R,e.index),e) +;R=e.index+n}v(t.substring(R))}return S.finalize(),O=S.toHTML(),{language:e, +value:O,relevance:T,illegal:!1,_emitter:S,_top:M}}catch(n){ if(n.message&&n.message.includes("Illegal"))return{language:e,value:V(t), -illegal:!0,relevance:0,_illegalBy:{message:n.message,index:T, -context:t.slice(T-100,T+100),mode:n.mode,resultSoFar:x},_emitter:A};if(s)return{ -language:e,value:V(t),illegal:!1,relevance:0,errorRaised:n,_emitter:A,_top:O} +illegal:!0,relevance:0,_illegalBy:{message:n.message,index:R, +context:t.slice(R-100,R+100),mode:n.mode,resultSoFar:O},_emitter:S};if(s)return{ +language:e,value:V(t),illegal:!1,relevance:0,errorRaised:n,_emitter:S,_top:M} ;throw n}}function E(e,n){n=n||p.languages||Object.keys(a);const t=(e=>{ -const n={value:V(e),illegal:!1,relevance:0,_top:c,_emitter:new p.__emitter(p)} -;return n._emitter.addText(e),n})(e),i=n.filter(N).filter(x).map((n=>f(n,e,!1))) -;i.unshift(t);const r=i.sort(((e,n)=>{ +const n={value:V(e),illegal:!1,relevance:0,_top:l,_emitter:new p.__emitter(p)} +;return n._emitter.addText(e),n})(e),i=n.filter(N).filter(x).map(n=>f(n,e,!1)) +;i.unshift(t);const r=i.sort((e,n)=>{ if(e.relevance!==n.relevance)return n.relevance-e.relevance ;if(e.language&&n.language){if(N(e.language).supersetOf===n.language)return 1 -;if(N(n.language).supersetOf===e.language)return-1}return 0})),[s,o]=r,l=s -;return l.secondBest=o,l}function y(e){let n=null;const t=(e=>{ +;if(N(n.language).supersetOf===e.language)return-1}return 0}),[s,o]=r,c=s +;return c.secondBest=o,c}function y(e){let n=null;const t=(e=>{ let n=e.className+" ";n+=e.parentNode?e.parentNode.className:"" ;const t=p.languageDetectRe.exec(n);if(t){const n=N(t[1]) ;return n||(K(o.replace("{}",t[1])), K("Falling back to no-highlight mode for this block.",e)),n?t[1]:"no-highlight"} -return n.split(/\s+/).find((e=>_(e)||N(e)))})(e);if(_(t))return +return n.split(/\s+/).find(e=>_(e)||N(e))})(e);if(_(t))return ;if(O("before:highlightElement",{el:e,language:t }),e.dataset.highlighted)return void console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",e) ;if(e.children.length>0&&(p.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."), @@ -274,14 +274,13 @@ console.warn(e)),p.throwUnescapedHTML))throw new X("One of your code blocks incl relevance:r.relevance},r.secondBest&&(e.secondBest={ language:r.secondBest.language,relevance:r.secondBest.relevance }),O("after:highlightElement",{el:e,result:r,text:a})}let w=!1;function v(){ -if("loading"===document.readyState)return w||window.addEventListener("DOMContentLoaded",(()=>{ -v()}),!1),void(w=!0);document.querySelectorAll(p.cssSelector).forEach(y)} +if("loading"===document.readyState)return w||window.addEventListener("DOMContentLoaded",()=>{ +v()},!1),void(w=!0);document.querySelectorAll(p.cssSelector).forEach(y)} function N(e){return e=(e||"").toLowerCase(),a[e]||a[i[e]]} -function k(e,{languageName:n}){"string"==typeof e&&(e=[e]),e.forEach((e=>{ -i[e.toLowerCase()]=n}))}function x(e){const n=N(e) -;return n&&!n.disableAutodetect}function O(e,n){const t=e;r.forEach((e=>{ -e[t]&&e[t](n)}))}Object.assign(t,{highlight:h,highlightAuto:E,highlightAll:v, -highlightElement:y, +function k(e,{languageName:n}){"string"==typeof e&&(e=[e]),e.forEach(e=>{ +i[e.toLowerCase()]=n})}function x(e){const n=N(e);return n&&!n.disableAutodetect +}function O(e,n){const t=e;r.forEach(e=>{e[t]&&e[t](n)})}Object.assign(t,{ +highlight:h,highlightAuto:E,highlightAll:v,highlightElement:y, highlightBlock:e=>(H("10.7.0","highlightBlock will be removed entirely in v12.0"), H("10.7.0","Please use highlightElement now."),y(e)),configure:e=>{p=J(p,e)}, initHighlighting:()=>{ @@ -290,7 +289,7 @@ initHighlightingOnLoad:()=>{ v(),H("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.") },registerLanguage:(e,n)=>{let i=null;try{i=n(t)}catch(n){ if(P("Language definition for '{}' could not be registered.".replace("{}",e)), -!s)throw n;P(n),i=c} +!s)throw n;P(n),i=l} i.name||(i.name=e),a[e]=i,i.rawDefinition=n.bind(null,t),i.aliases&&k(i.aliases,{ languageName:e})},unregisterLanguage:e=>{delete a[e] ;for(const n of Object.keys(i))i[n]===e&&delete i[n]}, @@ -301,35 +300,15 @@ e["before:highlightBlock"](Object.assign({block:n.el},n)) }),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=n=>{ e["after:highlightBlock"](Object.assign({block:n.el},n))})})(e),r.push(e)}, removePlugin:e=>{const n=r.indexOf(e);-1!==n&&r.splice(n,1)}}),t.debugMode=()=>{ -s=!1},t.safeMode=()=>{s=!0},t.versionString="11.11.2",t.regex={concat:b, +s=!1},t.safeMode=()=>{s=!0},t.versionString="11.12.0",t.regex={concat:b, lookahead:d,either:m,optional:u,anyNumberOfTimes:g} ;for(const n in C)"object"==typeof C[n]&&e(C[n]);return Object.assign(t,C),t -},ne=ee({});ne.newInstance=()=>ee({});const te=e=>({IMPORTANT:{scope:"meta", -begin:"!important"},BLOCK_COMMENT:e.C_BLOCK_COMMENT_MODE,HEXCOLOR:{ -scope:"number",begin:/#(([0-9a-fA-F]{3,4})|(([0-9a-fA-F]{2}){3,4}))\b/}, -UNICODE_RANGE:{scope:"number", -begin:/\b[Uu]\+[0-9A-Fa-f][0-9A-Fa-f?]{0,4}(-[0-9A-Fa-f][0-9A-Fa-f]{0,4})?/}, -FUNCTION_DISPATCH:{className:"built_in",begin:/[\w-]+(?=\()/}, -ATTRIBUTE_SELECTOR_MODE:{scope:"selector-attr",begin:/\[/,end:/\]/,illegal:"$", -contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},CSS_NUMBER_MODE:{ +},ne=ee({});ne.newInstance=()=>ee({});const te="[A-Za-z$_][0-9A-Za-z$_]*",ae={ scope:"number", -begin:e.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?", -relevance:0},CSS_VARIABLE:{className:"attr",begin:/--[A-Za-z_][A-Za-z0-9_-]*/} -}),ae=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","optgroup","option","p","picture","q","quote","samp","section","select","source","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video","defs","g","marker","mask","pattern","svg","switch","symbol","feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feFlood","feGaussianBlur","feImage","feMerge","feMorphology","feOffset","feSpecularLighting","feTile","feTurbulence","linearGradient","radialGradient","stop","circle","ellipse","image","line","path","polygon","polyline","rect","text","use","textPath","tspan","foreignObject","clipPath"],ie=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"].sort().reverse(),re=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"].sort().reverse(),se=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"].sort().reverse(),oe=["accent-color","align-content","align-items","align-self","alignment-baseline","all","anchor-name","animation","animation-composition","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-range","animation-range-end","animation-range-start","animation-timeline","animation-timing-function","appearance","aspect-ratio","backdrop-filter","backface-visibility","background","background-attachment","background-blend-mode","background-clip","background-color","background-image","background-origin","background-position","background-position-x","background-position-y","background-repeat","background-size","baseline-shift","block-size","border","border-block","border-block-color","border-block-end","border-block-end-color","border-block-end-style","border-block-end-width","border-block-start","border-block-start-color","border-block-start-style","border-block-start-width","border-block-style","border-block-width","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-end-end-radius","border-end-start-radius","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-inline","border-inline-color","border-inline-end","border-inline-end-color","border-inline-end-style","border-inline-end-width","border-inline-start","border-inline-start-color","border-inline-start-style","border-inline-start-width","border-inline-style","border-inline-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-start-end-radius","border-start-start-radius","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-align","box-decoration-break","box-direction","box-flex","box-flex-group","box-lines","box-ordinal-group","box-orient","box-pack","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","caret-color","clear","clip","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","color-scheme","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","contain","contain-intrinsic-block-size","contain-intrinsic-height","contain-intrinsic-inline-size","contain-intrinsic-size","contain-intrinsic-width","container","container-name","container-type","content","content-visibility","counter-increment","counter-reset","counter-set","cue","cue-after","cue-before","cursor","cx","cy","direction","display","dominant-baseline","empty-cells","enable-background","field-sizing","fill","fill-opacity","fill-rule","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","flood-color","flood-opacity","flow","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-optical-sizing","font-palette","font-size","font-size-adjust","font-smooth","font-smoothing","font-stretch","font-style","font-synthesis","font-synthesis-position","font-synthesis-small-caps","font-synthesis-style","font-synthesis-weight","font-variant","font-variant-alternates","font-variant-caps","font-variant-east-asian","font-variant-emoji","font-variant-ligatures","font-variant-numeric","font-variant-position","font-variation-settings","font-weight","forced-color-adjust","gap","glyph-orientation-horizontal","glyph-orientation-vertical","grid","grid-area","grid-auto-columns","grid-auto-flow","grid-auto-rows","grid-column","grid-column-end","grid-column-start","grid-gap","grid-row","grid-row-end","grid-row-start","grid-template","grid-template-areas","grid-template-columns","grid-template-rows","hanging-punctuation","height","hyphenate-character","hyphenate-limit-chars","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","initial-letter","initial-letter-align","inline-size","inset","inset-area","inset-block","inset-block-end","inset-block-start","inset-inline","inset-inline-end","inset-inline-start","isolation","justify-content","justify-items","justify-self","kerning","left","letter-spacing","lighting-color","line-break","line-height","line-height-step","list-style","list-style-image","list-style-position","list-style-type","margin","margin-block","margin-block-end","margin-block-start","margin-bottom","margin-inline","margin-inline-end","margin-inline-start","margin-left","margin-right","margin-top","margin-trim","marker","marker-end","marker-mid","marker-start","marks","mask","mask-border","mask-border-mode","mask-border-outset","mask-border-repeat","mask-border-slice","mask-border-source","mask-border-width","mask-clip","mask-composite","mask-image","mask-mode","mask-origin","mask-position","mask-repeat","mask-size","mask-type","masonry-auto-flow","math-depth","math-shift","math-style","max-block-size","max-height","max-inline-size","max-width","min-block-size","min-height","min-inline-size","min-width","mix-blend-mode","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","offset","offset-anchor","offset-distance","offset-path","offset-position","offset-rotate","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-anchor","overflow-block","overflow-clip-margin","overflow-inline","overflow-wrap","overflow-x","overflow-y","overlay","overscroll-behavior","overscroll-behavior-block","overscroll-behavior-inline","overscroll-behavior-x","overscroll-behavior-y","padding","padding-block","padding-block-end","padding-block-start","padding-bottom","padding-inline","padding-inline-end","padding-inline-start","padding-left","padding-right","padding-top","page","page-break-after","page-break-before","page-break-inside","paint-order","pause","pause-after","pause-before","perspective","perspective-origin","place-content","place-items","place-self","pointer-events","position","position-anchor","position-visibility","print-color-adjust","quotes","r","resize","rest","rest-after","rest-before","right","rotate","row-gap","ruby-align","ruby-position","scale","scroll-behavior","scroll-margin","scroll-margin-block","scroll-margin-block-end","scroll-margin-block-start","scroll-margin-bottom","scroll-margin-inline","scroll-margin-inline-end","scroll-margin-inline-start","scroll-margin-left","scroll-margin-right","scroll-margin-top","scroll-padding","scroll-padding-block","scroll-padding-block-end","scroll-padding-block-start","scroll-padding-bottom","scroll-padding-inline","scroll-padding-inline-end","scroll-padding-inline-start","scroll-padding-left","scroll-padding-right","scroll-padding-top","scroll-snap-align","scroll-snap-stop","scroll-snap-type","scroll-timeline","scroll-timeline-axis","scroll-timeline-name","scrollbar-color","scrollbar-gutter","scrollbar-width","shape-image-threshold","shape-margin","shape-outside","shape-rendering","speak","speak-as","src","stop-color","stop-opacity","stroke","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke-width","tab-size","table-layout","text-align","text-align-all","text-align-last","text-anchor","text-combine-upright","text-decoration","text-decoration-color","text-decoration-line","text-decoration-skip","text-decoration-skip-ink","text-decoration-style","text-decoration-thickness","text-emphasis","text-emphasis-color","text-emphasis-position","text-emphasis-style","text-indent","text-justify","text-orientation","text-overflow","text-rendering","text-shadow","text-size-adjust","text-transform","text-underline-offset","text-underline-position","text-wrap","text-wrap-mode","text-wrap-style","timeline-scope","top","touch-action","transform","transform-box","transform-origin","transform-style","transition","transition-behavior","transition-delay","transition-duration","transition-property","transition-timing-function","translate","unicode-bidi","unicode-range","user-modify","user-select","vector-effect","vertical-align","view-timeline","view-timeline-axis","view-timeline-inset","view-timeline-name","view-transition-name","visibility","voice-balance","voice-duration","voice-family","voice-pitch","voice-range","voice-rate","voice-stress","voice-volume","white-space","white-space-collapse","widows","width","will-change","word-break","word-spacing","word-wrap","writing-mode","x","y","z-index","zoom"].sort().reverse(),le=re.concat(se).sort().reverse() -;var ce="[0-9](_*[0-9])*",de=`\\.(${ce})`,ge="[0-9a-fA-F](_*[0-9a-fA-F])*",ue={ -className:"number",variants:[{ -begin:`(\\b(${ce})((${de})|\\.)?|(${de}))[eE][+-]?(${ce})[fFdD]?\\b`},{ -begin:`\\b(${ce})((${de})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{ -begin:`(${de})[fFdD]?\\b`},{begin:`\\b(${ce})[fFdD]\\b`},{ -begin:`\\b0[xX]((${ge})\\.?|(${ge})?\\.(${ge}))[pP][+-]?(${ce})[fFdD]?\\b`},{ -begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${ge})[lL]?\\b`},{ -begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}], -relevance:0};function be(e,n,t){return-1===t?"":e.replace(n,(a=>be(e,n,t-1)))} -const me="[A-Za-z$_][0-9A-Za-z$_]*",pe={scope:"number", match:"([-+]?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)|NaN|[-+]?Infinity", relevance:0 -},_e=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends","using"],he=["true","false","null","undefined","NaN","Infinity"],fe=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],Ee=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],ye=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],we=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],ve=[].concat(ye,fe,Ee) -;function Ne(e){const n=e.regex,t=me,a={begin:/<[A-Za-z0-9\\._:-]+/, +},ie=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends","using"],re=["true","false","null","undefined","NaN","Infinity"],se=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],oe=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],ce=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],le=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","self","global"],de=[].concat(ce,se,oe) +;function ge(e){const n=e.regex,t=te,a={begin:/<[A-Za-z0-9\\._:-]+/, end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{ const t=e[0].length+e.index,a=e.input[t] ;if("<"===a||","===a)return void n.ignoreMatch();let i @@ -337,42 +316,42 @@ const t=e[0].length+e.index,a=e.input[t] ;return-1!==e.input.indexOf(t,n)})(e,{after:t})||n.ignoreMatch()) ;const r=e.input.substring(t) ;((i=r.match(/^\s*=/))||(i=r.match(/^\s+extends\s+/))&&0===i.index)&&n.ignoreMatch() -}},i={$pattern:me,keyword:_e,literal:he,built_in:ve,"variable.language":we -},r="[0-9](_?[0-9])*",s=`\\.(${r})`,o="0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*",l={ +}},i={$pattern:te,keyword:ie,literal:re,built_in:de,"variable.language":le +},r="[0-9](_?[0-9])*",s=`\\.(${r})`,o="0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*",c={ className:"number",variants:[{ begin:`(\\b(${o})((${s})|\\.)?|(${s}))[eE][+-]?(${r})\\b`},{ begin:`\\b(${o})\\b((${s})\\b|\\.)?|(${s})\\b`},{ begin:"\\b(0|[1-9](_?[0-9])*)n\\b"},{ begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\b"},{ begin:"\\b0[bB][0-1](_?[0-1])*n?\\b"},{begin:"\\b0[oO][0-7](_?[0-7])*n?\\b"},{ -begin:"\\b0[0-7]+n?\\b"}],relevance:0},c={className:"subst",begin:"\\$\\{", +begin:"\\b0[0-7]+n?\\b"}],relevance:0},l={className:"subst",begin:"\\$\\{", end:"\\}",keywords:i,contains:[]},d={begin:".?html`",end:"",starts:{end:"`", -returnEnd:!1,contains:[e.BACKSLASH_ESCAPE,c],subLanguage:"xml"}},g={ +returnEnd:!1,contains:[e.BACKSLASH_ESCAPE,l],subLanguage:"xml"}},g={ begin:".?css`",end:"",starts:{end:"`",returnEnd:!1, -contains:[e.BACKSLASH_ESCAPE,c],subLanguage:"css"}},u={begin:".?gql`",end:"", -starts:{end:"`",returnEnd:!1,contains:[e.BACKSLASH_ESCAPE,c], +contains:[e.BACKSLASH_ESCAPE,l],subLanguage:"css"}},u={begin:".?gql`",end:"", +starts:{end:"`",returnEnd:!1,contains:[e.BACKSLASH_ESCAPE,l], subLanguage:"graphql"}},b={className:"string",begin:"`",end:"`", -contains:[e.BACKSLASH_ESCAPE,c]},m={className:"comment", +contains:[e.BACKSLASH_ESCAPE,l]},m={className:"comment", variants:[e.COMMENT(/\/\*\*(?!\/)/,"\\*/",{relevance:0,contains:[{ begin:"(?=@[A-Za-z]+)",relevance:0,contains:[{className:"doctag", begin:"@[A-Za-z]+"},{className:"type",begin:"\\{",end:"\\}",excludeEnd:!0, excludeBegin:!0,relevance:0},{className:"variable",begin:t+"(?=\\s*(-)|$)", endsParent:!0,relevance:0},{begin:/(?=[^\n])\s/,relevance:0}]}] }),e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE] -},p=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,d,g,u,b,{match:/\$\d+/},l] -;c.contains=p.concat({begin:/\{/,end:/\}/,keywords:i,contains:["self"].concat(p) -});const _=[].concat(m,c.contains),h=_.concat([{begin:/(\s*)\(/,end:/\)/, +},p=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,d,g,u,b,{match:/\$\d+/},c] +;l.contains=p.concat({begin:/\{/,end:/\}/,keywords:i,contains:["self"].concat(p) +});const _=[].concat(m,l.contains),h=_.concat([{begin:/(\s*)\(/,end:/\)/, keywords:i,contains:["self"].concat(_)}]),f={className:"params",begin:/(\s*)\(/, end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:i,contains:h},E={variants:[{ match:[/class/,/\s+/,t,/\s+/,/extends/,/\s+/,n.concat(t,"(",n.concat(/\./,t),")*")], scope:{1:"keyword",3:"title.class",5:"keyword",7:"title.class.inherited"}},{ match:[/class/,/\s+/,t],scope:{1:"keyword",3:"title.class"}}]},y={relevance:0, match:n.either(/\bJSON/,/\b[A-Z][a-z]+([A-Z][a-z]*|\d)*/,/\b[A-Z]{2,}([A-Z][a-z]+|\d)+([A-Z][a-z]*)*/,/\b[A-Z]{2,}[a-z]+([A-Z][a-z]+|\d)*([A-Z][a-z]*)*/), -className:"title.class",keywords:{_:[...fe,...Ee]}},w={variants:[{ +className:"title.class",keywords:{_:[...se,...oe]}},w={variants:[{ match:[/function/,/\s+/,t,/(?=\s*\()/]},{match:[/function/,/\s*(?=\()/]}], className:{1:"keyword",3:"title.function"},label:"func.def",contains:[f], illegal:/%/},v={ -match:n.concat(/\b/,(N=[...ye,"super","import","await"].map((e=>e+"\\s*\\(")), +match:n.concat(/\b/,(N=[...ce,"super","import","await"].map(e=>e+"\\s*\\("), n.concat("(?!",N.join("|"),")")),t,n.lookahead(/\s*\(/)), className:"title.function",relevance:0};var N;const k={ begin:n.concat(/\./,n.lookahead(n.concat(t,/(?![0-9A-Za-z$_(])/))),end:t, @@ -387,7 +366,7 @@ PARAMS_CONTAINS:h,CLASS_REFERENCE:y},illegal:/#(?![$_A-Za-z])/, contains:[e.SHEBANG({label:"shebang",binary:"node",relevance:5}),{ label:"use_strict",className:"meta",relevance:10, begin:/^\s*['"]use (strict|asm)['"]/ -},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,d,g,u,b,m,{match:/\$\d+/},l,y,{ +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,d,g,u,b,m,{match:/\$\d+/},c,y,{ scope:"attr",match:t+n.lookahead(":"),relevance:0},M,{ begin:"("+e.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*", keywords:"return throw case",relevance:0,contains:[m,e.REGEXP_MODE,{ @@ -406,8 +385,29 @@ className:"title.function"})]},{match:/\.\.\./,relevance:0},k,{match:"\\$"+t, relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"}, contains:[f]},v,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, className:"variable.constant"},E,x,{match:/\$[(.]/}]}} -const ke=e=>b(/\b/,e,/\w$/.test(e)?/\b/:/\B/),xe=["Protocol","Type"].map(ke),Oe=["init","self"].map(ke),Me=["Any","Self"],Ae=["actor","any","associatedtype","async","await",/as\?/,/as!/,"as","borrowing","break","case","catch","class","consume","consuming","continue","convenience","copy","default","defer","deinit","didSet","distributed","do","dynamic","each","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","isolated","nonisolated","lazy","let","macro","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","package","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],Se=["false","nil","true"],Ce=["assignment","associativity","higherThan","left","lowerThan","none","right"],Te=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warning"],Re=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],De=m(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),Ie=m(De,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),Le=b(De,Ie,"*"),Be=m(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),Fe=m(Be,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),$e=b(Be,Fe,"*"),ze=b(/[A-Z]/,Fe,"*"),je=["attached","autoclosure",b(/convention\(/,m("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","freestanding","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",b(/objc\(/,$e,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","resultBuilder","Sendable","testable","UIApplicationMain","unchecked","unknown","usableFromInline","warn_unqualified_access"],Ue=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"] -;var Pe=Object.freeze({__proto__:null,grmr_bash:e=>{const n=e.regex,t={},a={ +const ue=e=>b(/\b/,e,/\w$/.test(e)?/\b/:/\B/),be=["Protocol","Type"].map(ue),me=["init","self"].map(ue),pe=["Any","Self"],_e=["actor","any","associatedtype","async","await",/as\?/,/as!/,"as","borrowing","break","case","catch","class","consume","consuming","continue","convenience","copy","default","defer","deinit","didSet","distributed","do","dynamic","each","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","isolated","nonisolated","lazy","let","macro","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","package","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],he=["false","nil","true"],fe=["assignment","associativity","higherThan","left","lowerThan","none","right"],Ee=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warning"],ye=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],we=m(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),ve=m(we,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),Ne=b(we,ve,"*"),ke=m(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),xe=m(ke,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),Oe=b(ke,xe,"*"),Me=b(/[A-Z]/,xe,"*"),Ae=["attached","autoclosure",b(/convention\(/,m("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","freestanding","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",b(/objc\(/,Oe,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","resultBuilder","Sendable","testable","UIApplicationMain","unchecked","unknown","usableFromInline","warn_unqualified_access"],Se=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"],Ce=e=>({ +IMPORTANT:{scope:"meta",begin:"!important"}, +BLOCK_COMMENT:e.C_BLOCK_COMMENT_MODE,HEXCOLOR:{scope:"number", +begin:/#(([0-9a-fA-F]{3,4})|(([0-9a-fA-F]{2}){3,4}))\b/},UNICODE_RANGE:{ +scope:"number", +begin:/\b[Uu]\+[0-9A-Fa-f][0-9A-Fa-f?]{0,5}(-[0-9A-Fa-f][0-9A-Fa-f]{0,5})?/}, +FUNCTION_DISPATCH:{className:"built_in",begin:/[\w-]+(?=\()/}, +ATTRIBUTE_SELECTOR_MODE:{scope:"selector-attr",begin:/\[/,end:/\]/,illegal:"$", +contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},CSS_NUMBER_MODE:{ +scope:"number", +begin:e.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?", +relevance:0},CSS_VARIABLE:{className:"attr",begin:/--[A-Za-z_][A-Za-z0-9_-]*/} +}),Te=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","optgroup","option","p","picture","q","quote","samp","section","select","source","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video","defs","g","marker","mask","pattern","svg","switch","symbol","feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feFlood","feGaussianBlur","feImage","feMerge","feMorphology","feOffset","feSpecularLighting","feTile","feTurbulence","linearGradient","radialGradient","stop","circle","ellipse","image","line","path","polygon","polyline","rect","text","use","textPath","tspan","foreignObject","clipPath"],Re=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"].sort().reverse(),De=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"].sort().reverse(),Ie=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"].sort().reverse(),Le=["accent-color","align-content","align-items","align-self","alignment-baseline","all","anchor-name","animation","animation-composition","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-range","animation-range-end","animation-range-start","animation-timeline","animation-timing-function","appearance","aspect-ratio","backdrop-filter","backface-visibility","background","background-attachment","background-blend-mode","background-clip","background-color","background-image","background-origin","background-position","background-position-x","background-position-y","background-repeat","background-size","baseline-shift","block-size","border","border-block","border-block-color","border-block-end","border-block-end-color","border-block-end-style","border-block-end-width","border-block-start","border-block-start-color","border-block-start-style","border-block-start-width","border-block-style","border-block-width","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-end-end-radius","border-end-start-radius","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-inline","border-inline-color","border-inline-end","border-inline-end-color","border-inline-end-style","border-inline-end-width","border-inline-start","border-inline-start-color","border-inline-start-style","border-inline-start-width","border-inline-style","border-inline-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-start-end-radius","border-start-start-radius","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-align","box-decoration-break","box-direction","box-flex","box-flex-group","box-lines","box-ordinal-group","box-orient","box-pack","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","caret-color","clear","clip","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","color-scheme","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","contain","contain-intrinsic-block-size","contain-intrinsic-height","contain-intrinsic-inline-size","contain-intrinsic-size","contain-intrinsic-width","container","container-name","container-type","content","content-visibility","corner-bottom-left-shape","corner-bottom-right-shape","corner-shape","corner-top-left-shape","corner-top-right-shape","counter-increment","counter-reset","counter-set","cue","cue-after","cue-before","cursor","cx","cy","direction","display","dominant-baseline","empty-cells","enable-background","field-sizing","fill","fill-opacity","fill-rule","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","flood-color","flood-opacity","flow","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-optical-sizing","font-palette","font-size","font-size-adjust","font-smooth","font-smoothing","font-stretch","font-style","font-synthesis","font-synthesis-position","font-synthesis-small-caps","font-synthesis-style","font-synthesis-weight","font-variant","font-variant-alternates","font-variant-caps","font-variant-east-asian","font-variant-emoji","font-variant-ligatures","font-variant-numeric","font-variant-position","font-variation-settings","font-weight","forced-color-adjust","gap","glyph-orientation-horizontal","glyph-orientation-vertical","grid","grid-area","grid-auto-columns","grid-auto-flow","grid-auto-rows","grid-column","grid-column-end","grid-column-start","grid-gap","grid-row","grid-row-end","grid-row-start","grid-template","grid-template-areas","grid-template-columns","grid-template-rows","hanging-punctuation","height","hyphenate-character","hyphenate-limit-chars","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","initial-letter","initial-letter-align","inline-size","inset","inset-area","inset-block","inset-block-end","inset-block-start","inset-inline","inset-inline-end","inset-inline-start","isolation","justify-content","justify-items","justify-self","kerning","left","letter-spacing","lighting-color","line-break","line-height","line-height-step","list-style","list-style-image","list-style-position","list-style-type","margin","margin-block","margin-block-end","margin-block-start","margin-bottom","margin-inline","margin-inline-end","margin-inline-start","margin-left","margin-right","margin-top","margin-trim","marker","marker-end","marker-mid","marker-start","marks","mask","mask-border","mask-border-mode","mask-border-outset","mask-border-repeat","mask-border-slice","mask-border-source","mask-border-width","mask-clip","mask-composite","mask-image","mask-mode","mask-origin","mask-position","mask-repeat","mask-size","mask-type","masonry-auto-flow","math-depth","math-shift","math-style","max-block-size","max-height","max-inline-size","max-width","min-block-size","min-height","min-inline-size","min-width","mix-blend-mode","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","offset","offset-anchor","offset-distance","offset-path","offset-position","offset-rotate","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-anchor","overflow-block","overflow-clip-margin","overflow-inline","overflow-wrap","overflow-x","overflow-y","overlay","overscroll-behavior","overscroll-behavior-block","overscroll-behavior-inline","overscroll-behavior-x","overscroll-behavior-y","padding","padding-block","padding-block-end","padding-block-start","padding-bottom","padding-inline","padding-inline-end","padding-inline-start","padding-left","padding-right","padding-top","page","page-break-after","page-break-before","page-break-inside","paint-order","pause","pause-after","pause-before","perspective","perspective-origin","place-content","place-items","place-self","pointer-events","position","position-anchor","position-visibility","print-color-adjust","quotes","r","resize","rest","rest-after","rest-before","right","rotate","row-gap","ruby-align","ruby-position","scale","scroll-behavior","scroll-margin","scroll-margin-block","scroll-margin-block-end","scroll-margin-block-start","scroll-margin-bottom","scroll-margin-inline","scroll-margin-inline-end","scroll-margin-inline-start","scroll-margin-left","scroll-margin-right","scroll-margin-top","scroll-padding","scroll-padding-block","scroll-padding-block-end","scroll-padding-block-start","scroll-padding-bottom","scroll-padding-inline","scroll-padding-inline-end","scroll-padding-inline-start","scroll-padding-left","scroll-padding-right","scroll-padding-top","scroll-snap-align","scroll-snap-stop","scroll-snap-type","scroll-timeline","scroll-timeline-axis","scroll-timeline-name","scrollbar-color","scrollbar-gutter","scrollbar-width","shape-image-threshold","shape-margin","shape-outside","shape-rendering","speak","speak-as","src","stop-color","stop-opacity","stroke","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke-width","tab-size","table-layout","text-align","text-align-all","text-align-last","text-anchor","text-combine-upright","text-decoration","text-decoration-color","text-decoration-line","text-decoration-skip","text-decoration-skip-ink","text-decoration-style","text-decoration-thickness","text-emphasis","text-emphasis-color","text-emphasis-position","text-emphasis-style","text-indent","text-justify","text-orientation","text-overflow","text-rendering","text-shadow","text-size-adjust","text-transform","text-underline-offset","text-underline-position","text-wrap","text-wrap-mode","text-wrap-style","timeline-scope","top","touch-action","transform","transform-box","transform-origin","transform-style","transition","transition-behavior","transition-delay","transition-duration","transition-property","transition-timing-function","translate","unicode-bidi","unicode-range","user-modify","user-select","vector-effect","vertical-align","view-timeline","view-timeline-axis","view-timeline-inset","view-timeline-name","view-transition-name","visibility","voice-balance","voice-duration","voice-family","voice-pitch","voice-range","voice-rate","voice-stress","voice-volume","white-space","white-space-collapse","widows","width","will-change","word-break","word-spacing","word-wrap","writing-mode","x","y","z-index","zoom"].sort().reverse(),Be=De.concat(Ie).sort().reverse() +;var Fe="[0-9](_*[0-9])*",$e=`\\.(${Fe})`,ze="[0-9a-fA-F](_*[0-9a-fA-F])*",je={ +className:"number",variants:[{ +begin:`(\\b(${Fe})((${$e})|\\.)?|(${$e}))[eE][+-]?(${Fe})[fFdD]?\\b`},{ +begin:`\\b(${Fe})((${$e})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{ +begin:`(${$e})[fFdD]?\\b`},{begin:`\\b(${Fe})[fFdD]\\b`},{ +begin:`\\b0[xX]((${ze})\\.?|(${ze})?\\.(${ze}))[pP][+-]?(${Fe})[fFdD]?\\b`},{ +begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${ze})[lL]?\\b`},{ +begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}], +relevance:0};function Ue(e,n,t){return-1===t?"":e.replace(n,a=>Ue(e,n,t-1))} +var Pe=Object.freeze({__proto__:null,grmr_bash:e=>{const n=e.regex,t={},a={ begin:/\$\{/,end:/\}/,contains:["self",{begin:/:-/,contains:[t]}]} ;Object.assign(t,{className:"variable",variants:[{ begin:n.concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},a]});const i={ @@ -415,72 +415,79 @@ className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE] },r=e.inherit(e.COMMENT(),{match:[/(^|\s)/,/#.*$/],scope:{2:"comment"}}),s={ begin:/<<-?\s*(?=\w+)/,starts:{contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/, end:/(\w+)/,className:"string"})]}},o={className:"string",begin:/"/,end:/"/, -contains:[e.BACKSLASH_ESCAPE,t,i]};i.contains.push(o);const l={begin:/\$?\(\(/, +contains:[e.BACKSLASH_ESCAPE,t,i]};i.contains.push(o);const c={begin:/\$?\(\(/, end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,t] -},c=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10 +},l=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10 }),d={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0, contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{ name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/, keyword:["if","then","else","elif","fi","time","for","while","until","in","do","done","case","esac","coproc","function","select"], literal:["true","false"], built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","sudo","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"] -},contains:[c,e.SHEBANG(),d,l,r,s,{match:/(\/[a-z._-]+)+/},o,{match:/\\"/},{ +},contains:[l,e.SHEBANG(),d,c,r,s,{match:/(\/[a-z._-]+)+/},o,{match:/\\"/},{ className:"string",begin:/'/,end:/'/},{match:/\\'/},t]}},grmr_c:e=>{ const n=e.regex,t=e.COMMENT("//","$",{contains:[{begin:/\\\n/}] }),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ className:"type",variants:[{begin:"\\b[a-z\\d_]*_t\\b"},{ -match:/\batomic_[a-z]{3,6}\b/}]},o={className:"string",variants:[{ -begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +match:n.concat(/\batomic_/,n.either("bool","char","schar","uchar","short","ushort","int","uint","long","ulong","llong","ullong","char16_t","char32_t","wchar_t","int_least8_t","uint_least8_t","int_least16_t","uint_least16_t","int_least32_t","uint_least32_t","int_least64_t","uint_least64_t","int_fast8_t","uint_fast8_t","int_fast16_t","uint_fast16_t","int_fast32_t","uint_fast32_t","int_fast64_t","uint_fast64_t","intptr_t","uintptr_t","size_t","ptrdiff_t","intmax_t","uintmax_t"),/\b/) +}]},o={className:"string",variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n", +contains:[e.BACKSLASH_ESCAPE]},{ begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ -begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +begin:/(?:u8?|U|L)?R"([^()\\\s"]{0,16})\(/,end:/\)([^()\\\s"]{0,16})"/})]},c={ className:"number",variants:[{match:/\b(0b[01']+)/},{ match:/(-?)\b([\d']+(\.[\d']*)?|\.[\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)/ },{ match:/(-?)\b(0[xX][a-fA-F0-9]+(?:'[a-fA-F0-9]+)*(?:\.[a-fA-F0-9]*(?:'[a-fA-F0-9]*)*)?(?:[pP][-+]?[0-9]+)?(l|L)?(u|U)?)/ },{match:/(-?)\b\d+(?:'\d+)*(?:\.\d*(?:'\d*)*)?(?:[eE][-+]?\d+)?/}],relevance:0 -},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +},l={scope:"meta",begin:/#\s*include\b/,end:/$/,keywords:{keyword:"include"}, +contains:[{begin:/\\\n/},o,{scope:"string",begin:/<.*?>/ +},t,e.C_BLOCK_COMMENT_MODE]},d={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/, +keywords:{ keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef elifdef elifndef include" -},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ -className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ -className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 -},g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string" +}),t,e.C_BLOCK_COMMENT_MODE]},g=[l,d],u={className:"title", +begin:n.optional(i)+e.IDENT_RE,relevance:0 +},b=n.optional(i)+e.IDENT_RE+"\\s*\\(",m={ keyword:["asm","auto","break","case","continue","default","do","else","enum","extern","for","fortran","goto","if","inline","register","restrict","return","sizeof","typeof","typeof_unqual","struct","switch","typedef","union","volatile","while","_Alignas","_Alignof","_Atomic","_Generic","_Noreturn","_Static_assert","_Thread_local","alignas","alignof","noreturn","static_assert","thread_local","_Pragma"], type:["float","double","signed","unsigned","int","short","long","char","void","_Bool","_BitInt","_Complex","_Imaginary","_Decimal32","_Decimal64","_Decimal96","_Decimal128","_Decimal64x","_Decimal128x","_Float16","_Float32","_Float64","_Float128","_Float32x","_Float64x","_Float128x","const","static","constexpr","complex","bool","imaginary"], literal:"true false NULL", built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr" -},b=[c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],m={variants:[{begin:/=/,end:/;/},{ +},p=[...g,s,t,e.C_BLOCK_COMMENT_MODE,c,o],_={variants:[{begin:/=/,end:/;/},{ begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], -keywords:u,contains:b.concat([{begin:/\(/,end:/\)/,keywords:u, -contains:b.concat(["self"]),relevance:0}]),relevance:0},p={ -begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, -keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ -begin:g,returnBegin:!0,contains:[e.inherit(d,{className:"title.function"})], +keywords:m,contains:p.concat([{begin:/\(/,end:/\)/,keywords:m, +contains:p.concat(["self"]),relevance:0}]),relevance:0},h={ +begin:"("+r+"[\\*&\\s]+){1,12}"+b,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:m,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:m,relevance:0},{ +begin:b,returnBegin:!0,contains:[e.inherit(u,{className:"title.function"})], relevance:0},{relevance:0,match:/,/},{className:"params",begin:/\(/,end:/\)/, -keywords:u,relevance:0,contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/, -end:/\)/,keywords:u,relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s] -}]},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C",aliases:["h"],keywords:u, -disableAutodetect:!0,illegal:"=]/,contains:[{ -beginKeywords:"final class struct"},e.TITLE_MODE]}]),exports:{preprocessor:c, -strings:o,keywords:u}}},grmr_cpp:e=>{const n=e.regex,t=e.COMMENT("//","$",{ +beginKeywords:"final class struct"},e.TITLE_MODE]}]),exports:{preprocessor:d, +strings:o,keywords:m}}},grmr_cpp:e=>{const n=e.regex,t=e.COMMENT("//","$",{ contains:[{begin:/\\\n/}] }),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="(?!struct)("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ className:"type",begin:"\\b[a-z\\d_]*_t\\b"},o={className:"string",variants:[{ begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ -begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +begin:/(?:u8?|U|L)?R"([^()\\\s"]{0,16})\(/,end:/\)([^()\\\s"]{0,16})"/})]},c={ className:"number",variants:[{ -begin:"[+-]?(?:(?:[0-9](?:'?[0-9])*\\.(?:[0-9](?:'?[0-9])*)?|\\.[0-9](?:'?[0-9])*)(?:[Ee][+-]?[0-9](?:'?[0-9])*)?|[0-9](?:'?[0-9])*[Ee][+-]?[0-9](?:'?[0-9])*|0[Xx](?:[0-9A-Fa-f](?:'?[0-9A-Fa-f])*(?:\\.(?:[0-9A-Fa-f](?:'?[0-9A-Fa-f])*)?)?|\\.[0-9A-Fa-f](?:'?[0-9A-Fa-f])*)[Pp][+-]?[0-9](?:'?[0-9])*)(?:[Ff](?:16|32|64|128)?|(BF|bf)16|[Ll]|)" +begin:"[+-]?(?:(?:\\b[0-9](?:'?[0-9])*\\.(?:[0-9](?:'?[0-9])*)?|\\.[0-9](?:'?[0-9])*)(?:[Ee][+-]?[0-9](?:'?[0-9])*)?|\\b[0-9](?:'?[0-9])*[Ee][+-]?[0-9](?:'?[0-9])*|\\b0[Xx](?:[0-9A-Fa-f](?:'?[0-9A-Fa-f])*(?:\\.(?:[0-9A-Fa-f](?:'?[0-9A-Fa-f])*)?)?|\\.[0-9A-Fa-f](?:'?[0-9A-Fa-f])*)[Pp][+-]?[0-9](?:'?[0-9])*)(?:[Ff](?:16|32|64|128)?|(BF|bf)16|[Ll]|)" },{ begin:"[+-]?\\b(?:0[Bb][01](?:'?[01])*|0[Xx][0-9A-Fa-f](?:'?[0-9A-Fa-f])*|0(?:'?[0-7])*|[1-9](?:'?[0-9])*)(?:[Uu](?:LL?|ll?)|[Uu][Zz]?|(?:LL?|ll?)[Uu]?|[Zz][Uu]|)" -}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +}],relevance:0},l=[{scope:"meta",begin:/#\s*include\b/,end:/$/,keywords:{ +keyword:"include"},contains:[{begin:/\\\n/},o,{scope:"string",begin:/<.*?>/ +},t,e.C_BLOCK_COMMENT_MODE]},{className:"meta",begin:/#\s*[a-z]+\b/,end:/$/, +keywords:{ keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" -},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ -className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ -className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string" +}),t,e.C_BLOCK_COMMENT_MODE]}],d={className:"title", +begin:n.optional(i)+e.IDENT_RE,relevance:0 },g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u=["alignas","alignof","and","and_eq","asm","atomic_cancel","atomic_commit","atomic_noexcept","auto","bitand","bitor","break","case","catch","class","co_await","co_return","co_yield","compl","concept","const_cast|10","consteval","constexpr","constinit","continue","decltype","default","delete","do","dynamic_cast|10","else","enum","explicit","export","extern","false","final","for","friend","goto","if","import","inline","module","mutable","namespace","new","noexcept","not","not_eq","nullptr","operator","or","or_eq","override","private","protected","public","reflexpr","register","reinterpret_cast|10","requires","return","sizeof","static_assert","static_cast|10","struct","switch","synchronized","template","this","thread_local","throw","transaction_safe","transaction_safe_dynamic","true","try","typedef","typeid","typename","union","using","virtual","volatile","while","xor","xor_eq"],b={ type:["bool","char","char16_t","char32_t","char8_t","double","float","int","long","short","void","wchar_t","unsigned","signed","const","static"], keyword:u,literal:["NULL","false","nullopt","nullptr","true"], @@ -490,21 +497,21 @@ _type_hints:["any","auto_ptr","barrier","binary_semaphore","bitset","complex","c _hint:["abort","abs","acos","apply","as_const","asin","atan","atan2","calloc","ceil","cerr","cin","clog","cos","cosh","cout","declval","endl","exchange","exit","exp","fabs","floor","fmod","forward","fprintf","fputs","free","frexp","fscanf","future","invoke","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","labs","launder","ldexp","log","log10","make_pair","make_shared","make_shared_for_overwrite","make_tuple","make_unique","malloc","memchr","memcmp","memcpy","memset","modf","move","pow","printf","putchar","puts","realloc","scanf","sin","sinh","snprintf","sprintf","sqrt","sscanf","std","stderr","stdin","stdout","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","swap","tan","tanh","terminate","to_underlying","tolower","toupper","vfprintf","visit","vprintf","vsprintf"] }, begin:n.concat(/\b/,`(?!${u.join("|")})`,e.IDENT_RE,n.lookahead(/(<[^<>]+>|)\s*\(/)) -},p=[m,c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],_={variants:[{begin:/=/,end:/;/},{ +},p=[m,...l,s,t,e.C_BLOCK_COMMENT_MODE,c,o],_={variants:[{begin:/=/,end:/;/},{ begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], keywords:b,contains:p.concat([{begin:/\(/,end:/\)/,keywords:b, contains:p.concat(["self"]),relevance:0}]),relevance:0},h={className:"function", -begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +begin:"("+r+"[\\*&\\s]+){1,12}"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, keywords:b,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:b,relevance:0},{ begin:g,returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{ -begin:/:/,endsWithParent:!0,contains:[o,l]},{relevance:0,match:/,/},{ +begin:/:/,endsWithParent:!0,contains:[o,c]},{relevance:0,match:/,/},{ className:"params",begin:/\(/,end:/\)/,keywords:b,relevance:0, -contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/,end:/\)/,keywords:b, -relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s]}] -},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C++", +contains:[t,e.C_BLOCK_COMMENT_MODE,o,c,s,{begin:/\(/,end:/\)/,keywords:b, +relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,c,s]}] +},s,t,e.C_BLOCK_COMMENT_MODE,...l]};return{name:"C++", aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:b,illegal:"",keywords:b,contains:["self",s]},{begin:e.IDENT_RE+"::",keywords:b},{ match:[/\b(?:enum(?:\s+(?:class|struct))?|class|struct|union)/,/\s+/,/\w+/], @@ -512,24 +519,24 @@ className:{1:"keyword",3:"title.class"}}])}},grmr_csharp:e=>{const n={ keyword:["abstract","as","base","break","case","catch","class","const","continue","do","else","event","explicit","extern","finally","fixed","for","foreach","goto","if","implicit","in","interface","internal","is","lock","namespace","new","operator","out","override","params","private","protected","public","readonly","record","ref","return","scoped","sealed","sizeof","stackalloc","static","struct","switch","this","throw","try","typeof","unchecked","unsafe","using","virtual","void","volatile","while"].concat(["add","alias","and","ascending","args","async","await","by","descending","dynamic","equals","file","from","get","global","group","init","into","join","let","nameof","not","notnull","on","or","orderby","partial","record","remove","required","scoped","select","set","unmanaged","value|0","var","when","where","with","yield"]), built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","uint","ushort"], literal:["default","false","null","true"]},t=e.inherit(e.TITLE_MODE,{ -begin:"[a-zA-Z](\\.?\\w)*"}),a={className:"number",variants:[{ -begin:"\\b(0b[01']+)"},{ -begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{ -begin:"(-?)(\\b0[xX][a-fA-F0-9'_]+|(\\b[\\d'_]+(\\.[\\d'_]*)?|\\.[\\d'_]+)([eE][-+]?[\\d'_]+)?)" -}],relevance:0},i={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}] -},r=e.inherit(i,{illegal:/\n/}),s={className:"subst",begin:/\{/,end:/\}/, -keywords:n},o=e.inherit(s,{illegal:/\n/}),l={className:"string",begin:/\$"/, +begin:"[a-zA-Z](\\.?\\w)*"}),a="\\d(_*\\d)*",i="([uU][lL]?|[lL][uU]?)?",r={ +className:"number",variants:[{begin:"\\b0[bB]_*[01](_*[01])*"+i},{ +begin:"(-?)\\b0[xX]_*[a-fA-F0-9](_*[a-fA-F0-9])*"+i},{ +begin:"(-?)(\\b"+a+"(\\.("+a+")?)?|\\."+a+")([eE][-+]?"+a+")?([fFdDmM]|[uU][lL]?|[lL][uU]?)?" +}],relevance:0},s={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}] +},o=e.inherit(s,{illegal:/\n/}),c={className:"subst",begin:/\{/,end:/\}/, +keywords:n},l=e.inherit(c,{illegal:/\n/}),d={className:"string",begin:/\$"/, end:'"',illegal:/\n/,contains:[{begin:/\{\{/},{begin:/\}\}/ -},e.BACKSLASH_ESCAPE,o]},c={className:"string",begin:/\$@"/,end:'"',contains:[{ -begin:/\{\{/},{begin:/\}\}/},{begin:'""'},s]},d=e.inherit(c,{illegal:/\n/, -contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},o]}) -;s.contains=[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.C_BLOCK_COMMENT_MODE], -o.contains=[d,l,r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.inherit(e.C_BLOCK_COMMENT_MODE,{ -illegal:/\n/})];const g={variants:[{className:"string", +},e.BACKSLASH_ESCAPE,l]},g={className:"string",begin:/\$@"/,end:'"',contains:[{ +begin:/\{\{/},{begin:/\}\}/},{begin:'""'},c]},u=e.inherit(g,{illegal:/\n/, +contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},l]}) +;c.contains=[g,d,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,r,e.C_BLOCK_COMMENT_MODE], +l.contains=[u,d,o,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,r,e.inherit(e.C_BLOCK_COMMENT_MODE,{ +illegal:/\n/})];const b={variants:[{className:"string", begin:/"""("*)(?!")(.|\n)*?"""\1/,relevance:1 -},c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},u={begin:"<",end:">", +},g,d,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},m={begin:"<",end:">", contains:[{beginKeywords:"in out"},t] -},b=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",m={ +},p=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",_={ begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"], keywords:n,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0, contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{ @@ -537,33 +544,33 @@ begin:"\x3c!--|--\x3e"},{begin:""}]}] }),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#", end:"$",keywords:{ keyword:"if else elif endif define undef warning error line region endregion pragma checksum" -}},g,a,{beginKeywords:"class interface",relevance:0,end:/[{;=]/, +}},b,r,{beginKeywords:"class interface",relevance:0,end:/[{;=]/, illegal:/[^\s:,]/,contains:[{beginKeywords:"where class" -},t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace", +},t,m,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace", relevance:0,end:/[{;=]/,illegal:/[^\s:]/, contains:[t,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ beginKeywords:"record",relevance:0,end:/[{;=]/,illegal:/[^\s:]/, -contains:[t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta", +contains:[t,m,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta", begin:"^\\s*\\[(?=[\\w])",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{ className:"string",begin:/"/,end:/"/}]},{ beginKeywords:"new return throw await else",relevance:0},{className:"function", -begin:"("+b+"\\s+)+"+e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +begin:"("+p+"\\s+)+"+e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, end:/\s*[{;=]/,excludeEnd:!0,keywords:n,contains:[{ beginKeywords:"public private protected static internal protected abstract async extern override unsafe virtual new sealed partial", relevance:0},{begin:e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, -contains:[e.TITLE_MODE,u],relevance:0},{match:/\(\)/},{className:"params", +contains:[e.TITLE_MODE,m],relevance:0},{match:/\(\)/},{className:"params", begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0, -contains:[g,a,e.C_BLOCK_COMMENT_MODE] -},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},m]}},grmr_css:e=>{ -const n=e.regex,t=te(e),a=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE];return{ +contains:[b,r,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},_]}},grmr_css:e=>{ +const n=e.regex,t=Ce(e),a=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE];return{ name:"CSS",case_insensitive:!0,illegal:/[=|'\$]/,keywords:{ keyframePosition:"from to"},classNameAliases:{keyframePosition:"selector-tag"}, contains:[t.BLOCK_COMMENT,{begin:/-(webkit|moz|ms|o)-(?=[a-z])/ },t.CSS_NUMBER_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/,relevance:0 },{className:"selector-class",begin:"\\.[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0 },t.ATTRIBUTE_SELECTOR_MODE,{className:"selector-pseudo",variants:[{ -begin:":("+re.join("|")+")"},{begin:":(:)?("+se.join("|")+")"}] -},t.CSS_VARIABLE,{className:"attribute",begin:"\\b("+oe.join("|")+")\\b"},{ +begin:":("+De.join("|")+")"},{begin:":(:)?("+Ie.join("|")+")"}] +},t.CSS_VARIABLE,{className:"attribute",begin:"\\b("+Le.join("|")+")\\b"},{ begin:/:/,end:/[;}{]/, contains:[t.BLOCK_COMMENT,t.HEXCOLOR,t.IMPORTANT,t.CSS_NUMBER_MODE,t.UNICODE_RANGE,...a,{ begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri" @@ -571,9 +578,9 @@ begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri" excludeEnd:!0}]},t.FUNCTION_DISPATCH]},{begin:n.lookahead(/@/),end:"[{;]", relevance:0,illegal:/:/,contains:[{className:"keyword",begin:/@-?\w[\w]*(-\w+)*/ },{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,keywords:{ -$pattern:/[a-z-]+/,keyword:"and or not only",attribute:ie.join(" ")},contains:[{ +$pattern:/[a-z-]+/,keyword:"and or not only",attribute:Re.join(" ")},contains:[{ begin:/[a-z-]+(?=:)/,className:"attribute"},...a,t.CSS_NUMBER_MODE]}]},{ -className:"selector-tag",begin:"\\b("+ae.join("|")+")\\b"}]}},grmr_diff:e=>{ +className:"selector-tag",begin:"\\b("+Te.join("|")+")\\b"}]}},grmr_diff:e=>{ const n=e.regex;return{name:"Diff",aliases:["patch"],contains:[{ className:"meta",relevance:10, match:n.either(/^@@ +-\d+,\d+ +\+\d+,\d+ +@@/,/^@@ +-\d+ +\+\d+,\d+ +@@/,/^@@ +-\d+,\d+ +\+\d+ +@@/,/^@@ +-\d+ +\+\d+ +@@/,/^\*\*\* +\d+,\d+ +\*\*\*\*$/,/^--- +\d+,\d+ +----$/) @@ -594,6 +601,7 @@ match:/-?\b0[xX]\.[a-fA-F0-9](_?[a-fA-F0-9])*[pP][+-]?\d(_?\d)*i?/,relevance:0 },{ match:/-?\b0[xX](_?[a-fA-F0-9])+((\.([a-fA-F0-9](_?[a-fA-F0-9])*)?)?[pP][+-]?\d(_?\d)*)?i?/, relevance:0},{match:/-?\b0[oO](_?[0-7])*i?/,relevance:0},{ +match:/-?\b0[bB](_?[01])*i?/,relevance:0},{ match:/-?\.\d(_?\d)*([eE][+-]?\d(_?\d)*)?i?/,relevance:0},{ match:/-?\b\d(_?\d)*(\.(\d(_?\d)*)?)?([eE][+-]?\d(_?\d)*)?i?/,relevance:0}]},{ begin:/:=/},{className:"function",beginKeywords:"func",end:"\\s*(\\{|$)", @@ -617,19 +625,19 @@ begin:/\bon|off|true|false|yes|no\b/},s={className:"string", contains:[e.BACKSLASH_ESCAPE],variants:[{begin:"'''",end:"'''",relevance:10},{ begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"'},{begin:"'",end:"'"}] },o={begin:/\[/,end:/\]/,contains:[a,r,i,s,t,"self"],relevance:0 -},l=n.either(/[A-Za-z0-9_-]+/,/"(\\"|[^"])*"/,/'[^']*'/);return{ +},c=n.either(/[A-Za-z0-9_-]+/,/"(\\"|[^"])*"/,/'[^']*'/);return{ name:"TOML, also INI",aliases:["toml"],case_insensitive:!0,illegal:/\S/, contains:[a,{className:"section",begin:/\[+/,end:/\]+/},{ -begin:n.concat(l,"(\\s*\\.\\s*",l,")*",n.lookahead(/\s*=\s*[^#\s]/)), +begin:n.concat(c,"(\\s*\\.\\s*",c,")*",n.lookahead(/\s*=\s*[^#\s]/)), className:"attr",starts:{end:/$/,contains:[a,o,r,i,s,t]}}]}},grmr_java:e=>{ -const n=e.regex,t="[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*",a=t+be("(?:<"+t+"~~~(?:\\s*,\\s*"+t+"~~~)*>)?",/~~~/g,2),i={ +const n=e.regex,t="[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*",a="(?:(?:\\s*\\[\\s*])+)?",i=t+"<@@@>"+a,r="(?:\\?(?:\\s+(?:extends|super)\\s+"+i+")?|"+i+")",s=Ue("(?:\\s*<\\s*"+r+"(?:\\s*,\\s*"+r+")*\\s*>)?",/<@@@>/g,2),o={ keyword:["synchronized","abstract","private","var","static","if","const ","for","while","strictfp","finally","protected","import","native","final","void","enum","else","break","transient","catch","instanceof","volatile","case","assert","package","default","public","try","switch","continue","throws","protected","public","private","module","requires","exports","do","sealed","yield","permits","goto","when"], literal:["false","true","null"], type:["char","boolean","long","float","int","byte","short","double"], -built_in:["super","this"]},r={className:"meta",begin:"@"+t,contains:[{ -begin:/\(/,end:/\)/,contains:["self"]}]},s={className:"params",begin:/\(/, -end:/\)/,keywords:i,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE],endsParent:!0} -;return{name:"Java",aliases:["jsp"],keywords:i,illegal:/<\/|#/, +built_in:["super","this"]},c={className:"meta",begin:"@"+t,contains:[{ +begin:/\(/,end:/\)/,contains:["self"]}]},l={className:"params",begin:/\(/, +end:/\)/,keywords:o,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE],endsParent:!0} +;return{name:"Java",aliases:["jsp"],keywords:o,illegal:/<\/|#/, contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/, relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),{ begin:/import java\.[a-z]+\./,keywords:"import",relevance:2 @@ -638,21 +646,21 @@ className:"string",contains:[e.BACKSLASH_ESCAPE] },e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{ match:[/\b(?:class|interface|enum|extends|implements|new)/,/\s+/,t],className:{ 1:"keyword",3:"title.class"}},{match:/non-sealed/,scope:"keyword"},{ -begin:[n.concat(/(?!else)/,t),/\s+/,t,/\s+/,/=(?!=)/],className:{1:"type", -3:"variable",5:"operator"}},{begin:[/record/,/\s+/,t],className:{1:"keyword", -3:"title.class"},contains:[s,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ -beginKeywords:"new throw return else",relevance:0},{ -begin:["(?:"+a+"\\s+)",e.UNDERSCORE_IDENT_RE,/\s*(?=\()/],className:{ -2:"title.function"},keywords:i,contains:[{className:"params",begin:/\(/, -end:/\)/,keywords:i,relevance:0, -contains:[r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,ue,e.C_BLOCK_COMMENT_MODE] -},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},ue,r]}},grmr_javascript:Ne, +beginKeywords:"new throw return else yield assert",relevance:0},{ +begin:[t,n.concat(s,a,/\s+/),t,a,/\s*/,/=(?!=)/],className:{1:"type", +3:"variable",6:"operator"}},{begin:[/record/,/\s+/,t],className:{1:"keyword", +3:"title.class"},contains:[l,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +begin:[t,n.concat(s,a,/\s+/),t,/\s*(?=\()/],className:{1:"type", +3:"title.function"},keywords:o,contains:[{className:"params",begin:/\(/, +end:/\)/,keywords:o,relevance:0, +contains:[c,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,je,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},je,c]}},grmr_javascript:ge, grmr_json:e=>{const n=["true","false","null"],t={scope:"literal", beginKeywords:n.join(" ")};return{name:"JSON",aliases:["jsonc","json5"], keywords:{literal:n},contains:[{className:"attr", begin:/(("(\\.|[^\\"\r\n])*")|('(\\.|[^\\'\r\n])*'))(?=\s*:)/,relevance:1.01},{ match:/[{}[\],:]/,className:"punctuation",relevance:0 -},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,t,pe,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE], +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,t,ae,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE], illegal:"\\S"}},grmr_kotlin:e=>{const n={ keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual", built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing", @@ -666,12 +674,12 @@ className:"meta", begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+e.UNDERSCORE_IDENT_RE+")?" },o={className:"meta",begin:"@"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/, end:/\)/,contains:[e.inherit(r,{className:"string"}),"self"]}] -},l=ue,c=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),d={ +},c=je,l=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),d={ variants:[{className:"type",begin:e.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/, contains:[]}]},g=d;return g.variants[1].contains=[d],d.variants[1].contains=[g], -{name:"Kotlin",aliases:["kt","kts"],keywords:n, +{name:"Kotlin",aliases:["kt","kts","ktm","ktx"],keywords:n, contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag", -begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,c,{className:"keyword", +begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,l,{className:"keyword", begin:/\b(break|continue|return|this)\b/,starts:{contains:[{className:"symbol", begin:/@\w+/}]}},t,s,o,{className:"function",beginKeywords:"fun",end:"[(]|$", returnBegin:!0,excludeEnd:!0,keywords:n,relevance:5,contains:[{ @@ -679,8 +687,8 @@ begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0, contains:[e.UNDERSCORE_TITLE_MODE]},{className:"type",begin://, keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/, endsParent:!0,keywords:n,relevance:0,contains:[{begin:/:/,end:/[=,\/]/, -endsWithParent:!0,contains:[d,e.C_LINE_COMMENT_MODE,c],relevance:0 -},e.C_LINE_COMMENT_MODE,c,s,o,r,e.C_NUMBER_MODE]},c]},{ +endsWithParent:!0,contains:[d,e.C_LINE_COMMENT_MODE,l],relevance:0 +},e.C_LINE_COMMENT_MODE,l,s,o,r,e.C_NUMBER_MODE]},l]},{ begin:[/class|interface|trait/,/\s+/,e.UNDERSCORE_IDENT_RE],beginScope:{ 3:"title.class"},keywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0, illegal:"extends implements",contains:[{ @@ -688,35 +696,35 @@ beginKeywords:"public protected internal private constructor" },e.UNDERSCORE_TITLE_MODE,{className:"type",begin://,excludeBegin:!0, excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,){\s]|$/, excludeBegin:!0,returnEnd:!0},s,o]},r,{className:"meta",begin:"^#!/usr/bin/env", -end:"$",illegal:"\n"},l]}},grmr_less:e=>{ -const n=te(e),t=le,a="[\\w-]+",i="("+a+"|@\\{"+a+"\\})",r=[],s=[],o=e=>({ -className:"string",begin:"~?"+e+".*?"+e}),l=(e,n,t)=>({className:e,begin:n, -relevance:t}),c={$pattern:/[a-z-]+/,keyword:"and or not only", -attribute:ie.join(" ")},d={begin:"\\(",end:"\\)",contains:s,keywords:c, +end:"$",illegal:"\n"},c]}},grmr_less:e=>{ +const n=Ce(e),t=Be,a="[\\w-]+",i="("+a+"|@\\{"+a+"\\})",r=[],s=[],o=e=>({ +className:"string",begin:"~?"+e+".*?"+e}),c=(e,n,t)=>({className:e,begin:n, +relevance:t}),l={$pattern:/[a-z-]+/,keyword:"and or not only", +attribute:Re.join(" ")},d={begin:"\\(",end:"\\)",contains:s,keywords:l, relevance:0} ;s.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,o("'"),o('"'),n.CSS_NUMBER_MODE,{ begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]", excludeEnd:!0} -},n.UNICODE_RANGE,n.HEXCOLOR,d,l("variable","@@?"+a,10),l("variable","@\\{"+a+"\\}"),l("built_in","~?`[^`]*?`"),{ +},n.UNICODE_RANGE,n.HEXCOLOR,d,c("variable","@@?"+a,10),c("variable","@\\{"+a+"\\}"),c("built_in","~?`[^`]*?`"),{ className:"attribute",begin:a+"\\s*:",end:":",returnBegin:!0,excludeEnd:!0 },n.IMPORTANT,{beginKeywords:"and not"},n.FUNCTION_DISPATCH);const g=s.concat({ begin:/\{/,end:/\}/,contains:r}),u={beginKeywords:"when",endsWithParent:!0, contains:[{beginKeywords:"and not"}].concat(s)},b={begin:i+"\\s*:", returnBegin:!0,end:/[;}]/,relevance:0,contains:[{begin:/-(webkit|moz|ms|o)-/ -},n.CSS_VARIABLE,{className:"attribute",begin:"\\b("+oe.join("|")+")\\b", +},n.CSS_VARIABLE,{className:"attribute",begin:"\\b("+Le.join("|")+")\\b", end:/(?=:)/,starts:{endsWithParent:!0,illegal:"[<=$]",relevance:0,contains:s}}] },m={className:"keyword", begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b", -starts:{end:"[;{}]",keywords:c,returnEnd:!0,contains:s,relevance:0}},p={ +starts:{end:"[;{}]",keywords:l,returnEnd:!0,contains:s,relevance:0}},p={ className:"variable",variants:[{begin:"@"+a+"\\s*:",relevance:15},{begin:"@"+a }],starts:{end:"[;}]",returnEnd:!0,contains:g}},_={variants:[{ begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:i,end:/\{/}],returnBegin:!0, returnEnd:!0,illegal:"[<='$\"]",relevance:0, -contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,u,l("keyword","all\\b"),l("variable","@\\{"+a+"\\}"),{ -begin:"\\b("+ae.join("|")+")\\b",className:"selector-tag" -},n.CSS_NUMBER_MODE,l("selector-tag",i,0),l("selector-id","#"+i),l("selector-class","\\."+i,0),l("selector-tag","&",0),n.ATTRIBUTE_SELECTOR_MODE,{ -className:"selector-pseudo",begin:":("+re.join("|")+")"},{ -className:"selector-pseudo",begin:":(:)?("+se.join("|")+")"},{begin:/\(/, +contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,u,c("keyword","all\\b"),c("variable","@\\{"+a+"\\}"),{ +begin:"\\b("+Te.join("|")+")\\b",className:"selector-tag" +},n.CSS_NUMBER_MODE,c("selector-tag",i,0),c("selector-id","#"+i),c("selector-class","\\."+i,0),c("selector-tag","&",0),n.ATTRIBUTE_SELECTOR_MODE,{ +className:"selector-pseudo",begin:":("+De.join("|")+")"},{ +className:"selector-pseudo",begin:":(:)?("+Ie.join("|")+")"},{begin:/\(/, end:/\)/,relevance:0,contains:g},{begin:"!important"},n.FUNCTION_DISPATCH]},h={ begin:a+":(:)?"+`(${t.join("|")})`,returnBegin:!0,contains:[_]} ;return r.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,m,p,h,b,_,u,n.FUNCTION_DISPATCH), @@ -725,7 +733,7 @@ grmr_lua:e=>{const n="\\[=*\\[",t="\\]=*\\]",a={begin:n,end:t,contains:["self"] },i=[e.COMMENT("--(?!"+n+")","$"),e.COMMENT("--"+n,t,{contains:[a],relevance:10 })];return{name:"Lua",aliases:["pluto"],keywords:{ $pattern:e.UNDERSCORE_IDENT_RE,literal:"true false nil", -keyword:"and break do else elseif end for goto if in local not or repeat return then until while", +keyword:"and break do else elseif end for goto if in local global not or repeat return then until while", built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove" },contains:i.concat([{className:"function",beginKeywords:"function",end:"\\)", contains:[e.inherit(e.TITLE_MODE,{ @@ -759,19 +767,20 @@ variants:[{begin:/_{2}(?!\s)/,end:/_{2}/},{begin:/\*{2}(?!\s)/,end:/\*{2}/}] },i={className:"emphasis",contains:[],variants:[{begin:/\*(?![*\s])/,end:/\*/},{ begin:/_(?![_\s])/,end:/_/,relevance:0}]},r=e.inherit(a,{contains:[] }),s=e.inherit(i,{contains:[]});a.contains.push(s),i.contains.push(r) -;let o=[n,t];return[a,i,r,s].forEach((e=>{e.contains=e.contains.concat(o) -})),o=o.concat(a,i),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{ +;let o=[n,t];return[a,i,r,s].forEach(e=>{e.contains=e.contains.concat(o) +}),o=o.concat(a,i),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{ className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:o},{ begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n", contains:o}]}]},n,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)", -end:"\\s+",excludeEnd:!0},a,i,{className:"quote",begin:"^>\\s+",contains:o, -end:"$"},{className:"code",variants:[{begin:"(`{3,})[^`](.|\\n)*?\\1`*[ ]*"},{ +end:"\\s+",excludeEnd:!0},{match:/^ {0,3}([-*_])[ \t]*(?:\1[ \t]*){2,}$/},a,i,{ +className:"quote",begin:"^>\\s+",contains:o,end:"$"},{className:"code", +variants:[{begin:"(`{3,})[^`](.|\\n)*?\\1`*[ ]*"},{ begin:"(~{3,})[^~](.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{ begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))", -contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{ -begin:"^[-\\*]{3,}",end:"$"},t,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{ -className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{ -className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]},{scope:"literal", +contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},t,{ +begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{className:"symbol",begin:/\[/, +end:/\]/,excludeBegin:!0,excludeEnd:!0},{className:"link",begin:/:\s*/,end:/$/, +excludeBegin:!0}]},{scope:"literal", match:/&([a-zA-Z0-9]+|#[0-9]{1,7}|#[Xx][0-9a-fA-F]{1,6});/}]}}, grmr_objectivec:e=>{const n=/[a-zA-Z@][a-zA-Z0-9_]*/,t={$pattern:n, keyword:["@interface","@class","@protocol","@implementation"]};return{ @@ -800,23 +809,23 @@ keyword:"abs accept alarm and atan2 bind binmode bless break caller chdir chmod end:/\}/},s={scope:"attr",match:/\s+:\s*\w+(\s*\(.*?\))?/},o={scope:"variable", variants:[{begin:/\$\d/},{ begin:n.concat(/[$%@](?!")(\^\w\b|#\w+(::\w+)*|\{\w+\}|\w+(::\w*)*)/,"(?![A-Za-z])(?![@$%])") -},{begin:/[$%@](?!")[^\s\w{=]|\$=/,relevance:0}],contains:[s]},l={ +},{begin:/[$%@](?!")[^\s\w{=]|\$=/,relevance:0}],contains:[s]},c={ className:"number",variants:[{match:/0?\.[0-9][0-9_]+\b/},{ match:/\bv?(0|[1-9][0-9_]*(\.[0-9_]+)?|[1-9][0-9_]*)\b/},{ match:/\b0[0-7][0-7_]*\b/},{match:/\b0x[0-9a-fA-F][0-9a-fA-F_]*\b/},{ match:/\b0b[0-1][0-1_]*\b/}],relevance:0 -},c=[e.BACKSLASH_ESCAPE,i,o],d=[/!/,/\//,/\|/,/\?/,/'/,/"/,/#/],g=(e,a,i="\\1")=>{ +},l=[e.BACKSLASH_ESCAPE,i,o],d=[/!/,/\//,/\|/,/\?/,/'/,/"/,/#/],g=(e,a,i="\\1")=>{ const r="\\1"===i?i:n.concat(i,a) ;return n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,r,/(?:\\.|[^\\\/])*?/,i,t) },u=(e,a,i)=>n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,i,t),b=[o,e.HASH_COMMENT_MODE,e.COMMENT(/^=\w/,/=cut/,{ -endsWithParent:!0}),r,{className:"string",contains:c,variants:[{ +endsWithParent:!0}),r,{className:"string",contains:l,variants:[{ begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[", end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{ begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*<",end:">", relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'", contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`", contains:[e.BACKSLASH_ESCAPE]},{begin:/\{\w+\}/,relevance:0},{ -begin:"-?\\w+\\s*=>",relevance:0}]},l,{ +begin:"-?\\w+\\s*=>",relevance:0}]},c,{ begin:"(\\/\\/|"+e.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*", keywords:"split return print reverse grep",relevance:0, contains:[e.HASH_COMMENT_MODE,{className:"regexp",variants:[{ @@ -828,15 +837,15 @@ begin:u("(?:m|qr)?",/\//,/\//)},{begin:u("m|qr",n.either(...d,{capture:!0 begin:u("m|qr",/\{/,/\}/)}]}]},{className:"function",beginKeywords:"sub method", end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE,s] },{className:"class",beginKeywords:"class",end:"[;{]",excludeEnd:!0,relevance:5, -contains:[e.TITLE_MODE,s,l]},{begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$", +contains:[e.TITLE_MODE,s,c]},{begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$", end:"^__END__$",subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$", className:"comment"}]}];return i.contains=b,r.contains=b,{name:"Perl", aliases:["pl","pm"],keywords:a,contains:b}},grmr_php:e=>{ const n=e.regex,t=/(?![A-Za-z0-9])(?![$])/,a=n.concat(/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/,t),i=n.concat(/(\\?[A-Z][a-z0-9_\x7f-\xff]+|\\?[A-Z]+(?=[A-Z][a-z0-9_\x7f-\xff])){1,}/,t),r=n.concat(/[A-Z]+/,t),s={ scope:"variable",match:"\\$+"+a},o={scope:"subst",variants:[{begin:/\$\w+/},{ -begin:/\{\$/,end:/\}/}]},l=e.inherit(e.APOS_STRING_MODE,{illegal:null -}),c="[ \t\n]",d={scope:"string",variants:[e.inherit(e.QUOTE_STRING_MODE,{ -illegal:null,contains:e.QUOTE_STRING_MODE.contains.concat(o)}),l,{ +begin:/\{\$/,end:/\}/}]},c=e.inherit(e.APOS_STRING_MODE,{illegal:null +}),l="[ \t\n]",d={scope:"string",variants:[e.inherit(e.QUOTE_STRING_MODE,{ +illegal:null,contains:e.QUOTE_STRING_MODE.contains.concat(o)}),c,{ begin:/<<<[ \t]*(?:(\w+)|"(\w+)")\n/,end:/[ \t]*(\w+)\b/, contains:e.QUOTE_STRING_MODE.contains.concat(o),"on:begin":(e,n)=>{ n.data._beginMatch=e[1]||e[2]},"on:end":(e,n)=>{ @@ -847,10 +856,10 @@ begin:"\\b0[xX][\\da-fA-F]+(?:_[\\da-fA-F]+)*\\b"},{ begin:"(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:[eE][+-]?\\d+)?" }],relevance:0 },u=["false","null","true"],b=["__CLASS__","__DIR__","__FILE__","__FUNCTION__","__COMPILER_HALT_OFFSET__","__LINE__","__METHOD__","__NAMESPACE__","__TRAIT__","die","echo","exit","include","include_once","print","require","require_once","array","abstract","and","as","binary","bool","boolean","break","callable","case","catch","class","clone","const","continue","declare","default","do","double","else","elseif","empty","enddeclare","endfor","endforeach","endif","endswitch","endwhile","enum","eval","extends","final","finally","float","for","foreach","from","global","goto","if","implements","instanceof","insteadof","int","integer","interface","isset","iterable","list","match|0","mixed","new","never","object","or","private","protected","public","readonly","real","return","string","switch","throw","trait","try","unset","use","var","void","while","xor","yield"],m=["Error|0","AppendIterator","ArgumentCountError","ArithmeticError","ArrayIterator","ArrayObject","AssertionError","BadFunctionCallException","BadMethodCallException","CachingIterator","CallbackFilterIterator","CompileError","Countable","DirectoryIterator","DivisionByZeroError","DomainException","EmptyIterator","ErrorException","Exception","FilesystemIterator","FilterIterator","GlobIterator","InfiniteIterator","InvalidArgumentException","IteratorIterator","LengthException","LimitIterator","LogicException","MultipleIterator","NoRewindIterator","OutOfBoundsException","OutOfRangeException","OuterIterator","OverflowException","ParentIterator","ParseError","RangeException","RecursiveArrayIterator","RecursiveCachingIterator","RecursiveCallbackFilterIterator","RecursiveDirectoryIterator","RecursiveFilterIterator","RecursiveIterator","RecursiveIteratorIterator","RecursiveRegexIterator","RecursiveTreeIterator","RegexIterator","RuntimeException","SeekableIterator","SplDoublyLinkedList","SplFileInfo","SplFileObject","SplFixedArray","SplHeap","SplMaxHeap","SplMinHeap","SplObjectStorage","SplObserver","SplPriorityQueue","SplQueue","SplStack","SplSubject","SplTempFileObject","TypeError","UnderflowException","UnexpectedValueException","UnhandledMatchError","ArrayAccess","BackedEnum","Closure","Fiber","Generator","Iterator","IteratorAggregate","Serializable","Stringable","Throwable","Traversable","UnitEnum","WeakReference","WeakMap","Directory","__PHP_Incomplete_Class","parent","php_user_filter","self","static","stdClass"],p={ -keyword:b,literal:(e=>{const n=[];return e.forEach((e=>{ -n.push(e),e.toLowerCase()===e?n.push(e.toUpperCase()):n.push(e.toLowerCase()) -})),n})(u),built_in:m},_=e=>e.map((e=>e.replace(/\|\d+$/,""))),h={variants:[{ -match:[/new/,n.concat(c,"+"),n.concat("(?!",_(m).join("\\b|"),"\\b)"),i],scope:{ +keyword:b,literal:(e=>{const n=[];return e.forEach(e=>{ +n.push(e),e.toLowerCase()===e?n.push(e.toUpperCase()):n.push(e.toLowerCase())}), +n})(u),built_in:m},_=e=>e.map(e=>e.replace(/\|\d+$/,"")),h={variants:[{ +match:[/new/,n.concat(l,"+"),n.concat("(?!",_(m).join("\\b|"),"\\b)"),i],scope:{ 1:"keyword",4:"title.class"}}]},f=n.concat(a,"\\b(?!\\()"),E={variants:[{ match:[n.concat(/::/,n.lookahead(/(?!class\b)/)),f],scope:{2:"variable.constant" }},{match:[/::/,/class/],scope:{2:"variable.language"}},{ @@ -862,7 +871,7 @@ match:n.concat(a,n.lookahead(":"),n.lookahead(/(?!::)/))},w={relevance:0, begin:/\(/,end:/\)/,keywords:p, contains:[y,s,E,e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE,e.HASH_COMMENT_MODE,d,g,h] },v={relevance:0, -match:[/\b/,n.concat("(?!fn\\b|function\\b|",_(b).join("\\b|"),"|",_(m).join("\\b|"),"\\b)"),a,n.concat(c,"*"),n.lookahead(/(?=\()/)], +match:[/\b/,n.concat("(?!fn\\b|function\\b|",_(b).join("\\b|"),"|",_(m).join("\\b|"),"\\b)"),a,n.concat(l,"*"),n.lookahead(/(?=\()/)], scope:{3:"title.function.invoke"},contains:[w]};w.contains.push(v) ;const N=[y,E,e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE,e.HASH_COMMENT_MODE,d,g,h],k={ begin:n.concat(/#\[\s*\\?/,n.either(i,r)),beginScope:"meta",end:/]/, @@ -896,45 +905,46 @@ end:"\\*/",skip:!0},{begin:'b"',end:'"',skip:!0},{begin:"b'",end:"'",skip:!0 skip:!0}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null,className:null, contains:null,skip:!0})]}]}),grmr_plaintext:e=>({name:"Plain text", aliases:["text","txt"],disableAutodetect:!0}),grmr_python:e=>{ -const n=e.regex,t=/[\p{XID_Start}_]\p{XID_Continue}*/u,a=["and","as","assert","async","await","break","case","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","match","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],i={ +const n=e.regex,t=/[\p{XID_Start}_]\p{XID_Continue}*/u,a=["and","as","assert","async","await","break","case","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","lazy","match","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],i={ $pattern:/[A-Za-z]\w+|__\w+__/,keyword:a, -built_in:["__import__","abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"], +built_in:["__import__","abs","aiter","all","anext","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozendict","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","sentinel","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"], literal:["__debug__","Ellipsis","False","None","NotImplemented","True"], type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"] },r={className:"meta",begin:/^(>>>|\.\.\.) /},s={className:"subst",begin:/\{/, -end:/\}/,keywords:i,illegal:/#/},o={begin:/\{\{/,relevance:0},l={ +end:/\}/,keywords:i,illegal:/#/},o={begin:/\{\{/,relevance:0},c={ className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{ begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/, contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/, contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ -begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/, -contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/, +begin:/([fFtT][rR]|[rR][fFtT]|[fFtT])'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([fFtT][rR]|[rR][fFtT]|[fFtT])"""/, end:/"""/,contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([uU]|[rR])'/,end:/'/, relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{ begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/, -end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/, -contains:[e.BACKSLASH_ESCAPE,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/, -contains:[e.BACKSLASH_ESCAPE,o,s]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] -},c="[0-9](_?[0-9])*",d=`(\\b(${c}))?\\.(${c})|\\b(${c})\\.`,g="\\b|"+a.join("|"),u={ +end:/"/},{begin:/([fFtT][rR]|[rR][fFtT]|[fFtT])'/,end:/'/, +contains:[e.BACKSLASH_ESCAPE,o,s]},{begin:/([fFtT][rR]|[rR][fFtT]|[fFtT])"/, +end:/"/,contains:[e.BACKSLASH_ESCAPE,o,s] +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] +},l="[0-9](_?[0-9])*",d=`(\\b(${l}))?\\.(${l})|\\b(${l})\\.`,g="\\b|"+a.join("|"),u={ className:"number",relevance:0,variants:[{ -begin:`(\\b(${c})|(${d}))[eE][+-]?(${c})[jJ]?(?=${g})`},{begin:`(${d})[jJ]?`},{ +begin:`(\\b(${l})|(${d}))[eE][+-]?(${l})[jJ]?(?=${g})`},{begin:`(${d})[jJ]?`},{ begin:`\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?(?=${g})`},{ begin:`\\b0[bB](_?[01])+[lL]?(?=${g})`},{begin:`\\b0[oO](_?[0-7])+[lL]?(?=${g})` -},{begin:`\\b0[xX](_?[0-9a-fA-F])+[lL]?(?=${g})`},{begin:`\\b(${c})[jJ](?=${g})` +},{begin:`\\b0[xX](_?[0-9a-fA-F])+[lL]?(?=${g})`},{begin:`\\b(${l})[jJ](?=${g})` }]},b={className:"comment",begin:n.lookahead(/# type:/),end:/$/,keywords:i, contains:[{begin:/# type:/},{begin:/#/,end:/\b\B/,endsWithParent:!0}]},m={ className:"params",variants:[{className:"",begin:/\(\s*\)/,skip:!0},{begin:/\(/, end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:i, -contains:["self",r,u,l,e.HASH_COMMENT_MODE]}]};return s.contains=[l,u,r],{ +contains:["self",r,u,c,e.HASH_COMMENT_MODE]}]};return s.contains=[c,u,r],{ name:"Python",aliases:["py","gyp","ipython"],unicodeRegex:!0,keywords:i, illegal:/(<\/|\?)|=>/,contains:[r,u,{scope:"variable.language",match:/\bself\b/ },{beginKeywords:"if",relevance:0},{match:/\bor\b/,scope:"keyword" -},l,b,e.HASH_COMMENT_MODE,{match:[/\bdef/,/\s+/,t],scope:{1:"keyword", +},c,b,e.HASH_COMMENT_MODE,{match:[/\bdef/,/\s+/,t],scope:{1:"keyword", 3:"title.function"},contains:[m]},{variants:[{ match:[/\bclass/,/\s+/,t,/\s*/,/\(\s*/,t,/\s*\)/]},{match:[/\bclass/,/\s+/,t]}], scope:{1:"keyword",3:"title.class",6:"title.class.inherited"}},{ -className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[u,m,l]}]}}, +className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[u,m,c]}]}}, grmr_python_repl:e=>({aliases:["pycon"],contains:[{className:"meta.prompt", starts:{end:/ |$/,starts:{end:"$",subLanguage:"python"}},variants:[{ begin:/^>>>(?=[ ]|$)/},{begin:/^\.\.\.(?=[ ]|$)/}]}]}),grmr_r:e=>{ @@ -968,10 +978,10 @@ const n=e.regex,t="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[ keyword:["alias","and","begin","BEGIN","break","case","class","defined","do","else","elsif","end","END","ensure","for","if","in","module","next","not","or","redo","require","rescue","retry","return","then","undef","unless","until","when","while","yield","include","extend","prepend","public","private","protected","raise","throw"], built_in:["proc","lambda","attr_accessor","attr_reader","attr_writer","define_method","private_constant","module_function"], literal:["true","false","nil"]},s={className:"doctag",begin:"@[A-Za-z]+"},o={ -begin:"#<",end:">"},l=[e.COMMENT("#","$",{contains:[s] +begin:"#<",end:">"},c=[e.COMMENT("#","$",{contains:[s] }),e.COMMENT("^=begin","^=end",{contains:[s],relevance:10 -}),e.COMMENT("^__END__",e.MATCH_NOTHING_RE)],c={className:"subst",begin:/#\{/, -end:/\}/,keywords:r},d={className:"string",contains:[e.BACKSLASH_ESCAPE,c], +}),e.COMMENT("^__END__",e.MATCH_NOTHING_RE)],l={className:"subst",begin:/#\{/, +end:/\}/,keywords:r},d={className:"string",contains:[e.BACKSLASH_ESCAPE,l], variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{ begin:/%[qQwWx]?\(/,end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{ begin:/%[qQwWx]?\{/,end:/\}/},{begin:/%[qQwWx]?/},{begin:/%[qQwWx]?\//, @@ -982,7 +992,7 @@ begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{ begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{ begin:n.concat(/<<[-~]?'?/,n.lookahead(/(\w+)(?=\W)[^\n]*\n(?:[^\n]*\n)*?\s*\1\b/)), contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/, -contains:[e.BACKSLASH_ESCAPE,c]})]}]},g="[0-9](_?[0-9])*",u={className:"number", +contains:[e.BACKSLASH_ESCAPE,l]})]}]},g="[0-9](_?[0-9])*",u={className:"number", relevance:0,variants:[{ begin:`\\b([1-9](_?[0-9])*|0)(\\.(${g}))?([eE][+-]?(${g})|r)?i?\\b`},{ begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b" @@ -997,99 +1007,100 @@ match:[/\b(class|module)\s+/,i]}],scope:{2:"title.class", 1:"title.class"}},{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, className:"variable.constant"},{relevance:0,match:a,scope:"title.class"},{ match:[/def/,/\s+/,t],scope:{1:"keyword",3:"title.function"},contains:[b]},{ -begin:e.IDENT_RE+"::"},{className:"symbol", -begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol", -begin:":(?!\\s)",contains:[d,{begin:t}],relevance:0},u,{className:"variable", +begin:"::"},{className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:", +relevance:0},{className:"symbol",begin:":(?!\\s)",contains:[d,{begin:t}], +relevance:0},u,{className:"variable", begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{ className:"params",begin:/\|(?!=)/,end:/\|/,excludeBegin:!0,excludeEnd:!0, relevance:0,keywords:r},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*", -keywords:"unless",contains:[{className:"regexp",contains:[e.BACKSLASH_ESCAPE,c], +keywords:"unless",contains:[{className:"regexp",contains:[e.BACKSLASH_ESCAPE,l], illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{ begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[", -end:"\\][a-z]*"}]}].concat(o,l),relevance:0}].concat(o,l) -;c.contains=m,b.contains=m;const p=[{begin:/^\s*=>/,starts:{end:"$",contains:m} +end:"\\][a-z]*"}]}].concat(o,c),relevance:0}].concat(o,c) +;l.contains=m,b.contains=m;const p=[{begin:/^\s*=>/,starts:{end:"$",contains:m} },{className:"meta.prompt", begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+[>*]|(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>)(?=[ ])", -starts:{end:"$",keywords:r,contains:m}}];return l.unshift(o),{name:"Ruby", +starts:{end:"$",keywords:r,contains:m}}];return c.unshift(o),{name:"Ruby", aliases:["rb","gemspec","podspec","thor","irb"],keywords:r,illegal:/\/\*/, -contains:[e.SHEBANG({binary:"ruby"})].concat(p).concat(l).concat(m)}}, +contains:[e.SHEBANG({binary:"ruby"})].concat(p).concat(c).concat(m)}}, grmr_rust:e=>{ const n=e.regex,t=/(r#)?/,a=n.concat(t,e.UNDERSCORE_IDENT_RE),i=n.concat(t,e.IDENT_RE),r={ -className:"title.function.invoke",relevance:0, -begin:n.concat(/\b/,/(?!let|for|while|if|else|match\b)/,i,n.lookahead(/\s*\(/)) -},s="([ui](8|16|32|64|128|size)|f(32|64))?",o=["drop ","Copy","Send","Sized","Sync","Drop","Fn","FnMut","FnOnce","ToOwned","Clone","Debug","PartialEq","PartialOrd","Eq","Ord","AsRef","AsMut","Into","From","Default","Iterator","Extend","IntoIterator","DoubleEndedIterator","ExactSizeIterator","SliceConcatExt","ToString","assert!","assert_eq!","bitflags!","bytes!","cfg!","col!","concat!","concat_idents!","debug_assert!","debug_assert_eq!","env!","eprintln!","panic!","file!","format!","format_args!","include_bytes!","include_str!","line!","local_data_key!","module_path!","option_env!","print!","println!","select!","stringify!","try!","unimplemented!","unreachable!","vec!","write!","writeln!","macro_rules!","assert_ne!","debug_assert_ne!"],l=["i8","i16","i32","i64","i128","isize","u8","u16","u32","u64","u128","usize","f32","f64","str","char","bool","Box","Option","Result","String","Vec"] -;return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?",type:l, -keyword:["abstract","as","async","await","become","box","break","const","continue","crate","do","dyn","else","enum","extern","false","final","fn","for","if","impl","in","let","loop","macro","match","mod","move","mut","override","priv","pub","ref","return","self","Self","static","struct","super","trait","true","try","type","typeof","union","unsafe","unsized","use","virtual","where","while","yield"], +scope:"title.function.invoke",relevance:0, +begin:n.concat(/\b/,/(?!(?:let|for|while|if|else|match)\b)/,i,n.lookahead(/\s*\(/)) +},s="([ui](8|16|32|64|128|size)|f(16|32|64|128))?",o=["drop ","Copy","Send","Sized","Sync","Drop","Fn","FnMut","FnOnce","ToOwned","Clone","Debug","PartialEq","PartialOrd","Eq","Ord","AsRef","AsMut","Into","From","Default","Iterator","Extend","IntoIterator","DoubleEndedIterator","ExactSizeIterator","SliceConcatExt","ToString","assert!","assert_eq!","bitflags!","bytes!","cfg!","col!","concat!","concat_idents!","debug_assert!","debug_assert_eq!","env!","eprintln!","panic!","file!","format!","format_args!","include_bytes!","include_str!","line!","local_data_key!","module_path!","option_env!","print!","println!","select!","stringify!","try!","unimplemented!","unreachable!","vec!","write!","writeln!","macro_rules!","assert_ne!","debug_assert_ne!"],c=["i8","i16","i32","i64","i128","isize","u8","u16","u32","u64","u128","usize","f16","f32","f64","f128","str","char","bool","Box","Option","Result","String","Vec"] +;return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?",type:c, +keyword:["abstract","as","async","await","become","box","break","const","continue","crate","do","dyn","else","enum","extern","false","final","fn","for","if","impl","in","let","loop","macro","match","mod","move","mut","override","priv","pub","raw","ref","return","self","Self","static","struct","super","trait","true","try","type","typeof","union","unsafe","unsized","use","virtual","where","while","yield"], literal:["true","false","Some","None","Ok","Err"],built_in:o},illegal:""},r]}}, -grmr_scss:e=>{const n=te(e),t=se,a=re,i="@[a-z-]+",r={className:"variable", +begin:[/\bsafe/,/\s+/,/extern/],scope:{1:"keyword",3:"keyword"}},{ +begin:[/fn/,/\s+/,a],scope:{1:"keyword",3:"title.function"}},{scope:"meta", +begin:"#!?\\[",end:"\\]",contains:[{scope:"string",begin:/"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE]}]},{begin:[/let/,/\s+/,/(?:mut\s+)?/,a],scope:{ +1:"keyword",3:"keyword",4:"variable"}},{begin:[/for/,/\s+/,a,/\s+/,/in/],scope:{ +1:"keyword",3:"variable",5:"keyword"}},{begin:[/type/,/\s+/,a],scope:{ +1:"keyword",3:"title.class"}},{ +begin:[/(?:trait|enum|struct|union|impl|for)/,/\s+/,a],scope:{1:"keyword", +3:"title.class"}},{begin:e.IDENT_RE+"::",keywords:{keyword:"Self",built_in:o, +type:c}},{scope:"punctuation",begin:"->"},r]}},grmr_scss:e=>{ +const n=Ce(e),t=Ie,a=De,i="@[a-z-]+",r={className:"variable", begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b",relevance:0};return{name:"SCSS", case_insensitive:!0,illegal:"[=/|']", contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,n.CSS_NUMBER_MODE,{ className:"selector-id",begin:"#[A-Za-z0-9_-]+",relevance:0},{ className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0 },n.ATTRIBUTE_SELECTOR_MODE,{className:"selector-tag", -begin:"\\b("+ae.join("|")+")\\b",relevance:0},{className:"selector-pseudo", +begin:"\\b("+Te.join("|")+")\\b",relevance:0},{className:"selector-pseudo", begin:":("+a.join("|")+")"},{className:"selector-pseudo", begin:":(:)?("+t.join("|")+")"},r,{begin:/\(/,end:/\)/, contains:[n.CSS_NUMBER_MODE]},n.CSS_VARIABLE,{className:"attribute", -begin:"\\b("+oe.join("|")+")\\b"},{ +begin:"\\b("+Le.join("|")+")\\b"},{ begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b" },{begin:/:/,end:/[;}{]/,relevance:0, contains:[n.BLOCK_COMMENT,r,n.HEXCOLOR,n.CSS_NUMBER_MODE,n.UNICODE_RANGE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.IMPORTANT,n.FUNCTION_DISPATCH] },{begin:"@(page|font-face)",keywords:{$pattern:i,keyword:"@page @font-face"}},{ begin:"@",end:"[{;]",returnBegin:!0,keywords:{$pattern:/[a-z-]+/, -keyword:"and or not only",attribute:ie.join(" ")},contains:[{begin:i, +keyword:"and or not only",attribute:Re.join(" ")},contains:[{begin:i, className:"keyword"},{begin:/[a-z-]+(?=:)/,className:"attribute" },r,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.HEXCOLOR,n.CSS_NUMBER_MODE] },n.FUNCTION_DISPATCH]}},grmr_shell:e=>({name:"Shell Session", aliases:["console","shellsession"],contains:[{className:"meta.prompt", -begin:/^\s{0,3}[/~\w\d[\]()@-]*[>%$#][ ]?/,starts:{end:/[^\\](?=\s*$)/, +begin:/^\s{0,3}[./~\w\d[\]()@-]*[>%$#][ ]?/,starts:{end:/[^\\](?=\s*$)/, subLanguage:"bash"}}]}),grmr_sql:e=>{ -const n=e.regex,t=e.COMMENT("--","$"),a=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],i=a,r=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((e=>!a.includes(e))),s={ +const n=e.regex,t=e.COMMENT("--","$"),a=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],i=a,r=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter(e=>!a.includes(e)),s={ match:n.concat(/\b/,n.either(...i),/\s*\(/),relevance:0,keywords:{built_in:i}} ;function o(e){ -return n.concat(/\b/,n.either(...e.map((e=>e.replace(/\s+/,"\\s+")))),/\b/)} -const l={scope:"keyword", +return n.concat(/\b/,n.either(...e.map(e=>e.replace(/\s+/,"\\s+"))),/\b/)} +const c={scope:"keyword", match:o(["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"]), relevance:0};return{name:"SQL",case_insensitive:!0,illegal:/[{}]|<\//,keywords:{ $pattern:/\b[\w\.]+/,keyword:((e,{exceptions:n,when:t}={})=>{const a=t -;return n=n||[],e.map((e=>e.match(/\|\d+$/)||n.includes(e)?e:a(e)?e+"|0":e)) -})(r,{when:e=>e.length<3}),literal:["true","false","unknown"], +;return n=n||[],e.map(e=>e.match(/\|\d+$/)||n.includes(e)?e:a(e)?e+"|0":e)})(r,{ +when:e=>e.length<3}),literal:["true","false","unknown"], type:["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"], built_in:["current_catalog","current_date","current_default_transform_group","current_path","current_role","current_schema","current_transform_group_for_type","current_user","session_user","system_time","system_user","current_time","localtime","current_timestamp","localtimestamp"] },contains:[{scope:"type", match:o(["double precision","large object","with timezone","without timezone"]) -},l,s,{scope:"variable",match:/@[a-z0-9][a-z0-9_]*/},{scope:"string",variants:[{ +},c,s,{scope:"variable",match:/@[a-z0-9][a-z0-9_]*/},{scope:"string",variants:[{ begin:/'/,end:/'/,contains:[{match:/''/}]}]},{begin:/"/,end:/"/,contains:[{ match:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,{scope:"operator", match:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/,relevance:0}]}}, grmr_swift:e=>{const n={match:/\s+/,relevance:0},t=e.COMMENT("/\\*","\\*/",{ -contains:["self"]}),a=[e.C_LINE_COMMENT_MODE,t],i={match:[/\./,m(...xe,...Oe)], -className:{2:"keyword"}},r={match:b(/\./,m(...Ae)),relevance:0 -},s=Ae.filter((e=>"string"==typeof e)).concat(["_|0"]),o={variants:[{ +contains:["self"]}),a=[e.C_LINE_COMMENT_MODE,t],i={match:[/\./,m(...be,...me)], +className:{2:"keyword"}},r={match:b(/\./,m(..._e)),relevance:0 +},s=_e.filter(e=>"string"==typeof e).concat(["_|0"]),o={variants:[{ className:"keyword", -match:m(...Ae.filter((e=>"string"!=typeof e)).concat(Me).map(ke),...Oe)}]},l={ -$pattern:m(/\b\w+/,/#\w+/),keyword:s.concat(Te),literal:Se},c=[i,r,o],g=[{ -match:b(/\./,m(...Re)),relevance:0},{className:"built_in", -match:b(/\b/,m(...Re),/(?=\()/)}],u={match:/->/,relevance:0},p=[u,{ -className:"operator",relevance:0,variants:[{match:Le},{match:`\\.(\\.|${Ie})+`}] +match:m(..._e.filter(e=>"string"!=typeof e).concat(pe).map(ue),...me)}]},c={ +$pattern:m(/\b\w+/,/#\w+/),keyword:s.concat(Ee),literal:he},l=[i,r,o],g=[{ +match:b(/\./,m(...ye)),relevance:0},{className:"built_in", +match:b(/\b/,m(...ye),/(?=\()/)}],u={match:/->/,relevance:0},p=[u,{ +className:"operator",relevance:0,variants:[{match:Ne},{match:`\\.(\\.|${ve})+`}] }],_="([0-9]_*)+",h="([0-9a-fA-F]_*)+",f={className:"number",relevance:0, variants:[{match:`\\b(${_})(\\.(${_}))?([eE][+-]?(${_}))?\\b`},{ match:`\\b0x(${h})(\\.(${h}))?([pP][+-]?(${_}))?\\b`},{match:/\b0o([0-7]_*)+\b/ @@ -1105,65 +1116,65 @@ variants:[v(),v("#"),v("##"),v("###"),N(),N("#"),N("##"),N("###")] contains:[e.BACKSLASH_ESCAPE]}],O={begin:/\/[^\s](?=[^/\n]*\/)/,end:/\//, contains:x},M=e=>{const n=b(e,/\//),t=b(/\//,e);return{begin:n,end:t, contains:[...x,{scope:"comment",begin:`#(?!.*${t})`,end:/$/}]}},A={ -scope:"regexp",variants:[M("###"),M("##"),M("#"),O]},S={match:b(/`/,$e,/`/) +scope:"regexp",variants:[M("###"),M("##"),M("#"),O]},S={match:b(/`/,Oe,/`/) },C=[S,{className:"variable",match:/\$\d+/},{className:"variable", -match:`\\$${Fe}+`}],T=[{match:/(@|#(un)?)available/,scope:"keyword",starts:{ -contains:[{begin:/\(/,end:/\)/,keywords:Ue,contains:[...p,f,k]}]}},{ -scope:"keyword",match:b(/@/,m(...je),d(m(/\(/,/\s+/)))},{scope:"meta", -match:b(/@/,$e)}],R={match:d(/\b[A-Z]/),relevance:0,contains:[{className:"type", -match:b(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,Fe,"+") -},{className:"type",match:ze,relevance:0},{match:/[?!]+/,relevance:0},{ -match:/\.\.\./,relevance:0},{match:b(/\s+&\s+/,d(ze)),relevance:0}]},D={ -begin://,keywords:l,contains:[...a,...c,...T,u,R]};R.contains.push(D) -;const I={begin:/\(/,end:/\)/,relevance:0,keywords:l,contains:["self",{ -match:b($e,/\s*:/),keywords:"_|0",relevance:0 -},...a,A,...c,...g,...p,f,k,...C,...T,R]},L={begin://, -keywords:"repeat each",contains:[...a,R]},B={begin:/\(/,end:/\)/,keywords:l, -contains:[{begin:m(d(b($e,/\s*:/)),d(b($e,/\s+/,$e,/\s*:/))),end:/:/, +match:`\\$${xe}+`}],T=[{match:/(@|#(un)?)available/,scope:"keyword",starts:{ +contains:[{begin:/\(/,end:/\)/,keywords:Se,contains:[...p,f,k]}]}},{ +scope:"keyword",match:b(/@/,m(...Ae),d(m(/\(/,/\s+/)))},{scope:"meta", +match:b(/@/,Oe)}],R={match:d(/\b[A-Z]/),relevance:0,contains:[{className:"type", +match:b(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,xe,"+") +},{className:"type",match:Me,relevance:0},{match:/[?!]+/,relevance:0},{ +match:/\.\.\./,relevance:0},{match:b(/\s+&\s+/,d(Me)),relevance:0}]},D={ +begin://,keywords:c,contains:[...a,...l,...T,u,R]};R.contains.push(D) +;const I={begin:/\(/,end:/\)/,relevance:0,keywords:c,contains:["self",{ +match:b(Oe,/\s*:/),keywords:"_|0",relevance:0 +},...a,A,...l,...g,...p,f,k,...C,...T,R]},L={begin://, +keywords:"repeat each",contains:[...a,R]},B={begin:/\(/,end:/\)/,keywords:c, +contains:[{begin:m(d(b(Oe,/\s*:/)),d(b(Oe,/\s+/,Oe,/\s*:/))),end:/:/, relevance:0,contains:[{className:"keyword",match:/\b_\b/},{className:"params", -match:$e}]},...a,...c,...p,f,k,...T,R,I],endsParent:!0,illegal:/["']/},F={ -match:[/(func|macro)/,/\s+/,m(S.match,$e,Le)],className:{1:"keyword", +match:Oe}]},...a,...l,...p,f,k,...T,R,I],endsParent:!0,illegal:/["']/},F={ +match:[/(func|macro)/,/\s+/,m(S.match,Oe,Ne)],className:{1:"keyword", 3:"title.function"},contains:[L,B,n],illegal:[/\[/,/%/]},$={ match:[/\b(?:subscript|init[?!]?)/,/\s*(?=[<(])/],className:{1:"keyword"}, -contains:[L,B,n],illegal:/\[|%/},z={match:[/operator/,/\s+/,Le],className:{ -1:"keyword",3:"title"}},j={begin:[/precedencegroup/,/\s+/,ze],className:{ -1:"keyword",3:"title"},contains:[R],keywords:[...Ce,...Se],end:/}/},U={ -begin:[/(struct|protocol|class|extension|enum|actor)/,/\s+/,$e,/\s*/], -beginScope:{1:"keyword",3:"title.class"},keywords:l,contains:[L,...c,{begin:/:/, -end:/\{/,keywords:l,contains:[{scope:"title.class.inherited",match:ze},...c], +contains:[L,B,n],illegal:/\[|%/},z={match:[/operator/,/\s+/,Ne],className:{ +1:"keyword",3:"title"}},j={begin:[/precedencegroup/,/\s+/,Me],className:{ +1:"keyword",3:"title"},contains:[R],keywords:[...fe,...he],end:/}/},U={ +begin:[/(struct|protocol|class|extension|enum|actor)/,/\s+/,Oe,/\s*/], +beginScope:{1:"keyword",3:"title.class"},keywords:c,contains:[L,...l,{begin:/:/, +end:/\{/,keywords:c,contains:[{scope:"title.class.inherited",match:Me},...l], relevance:0}]};for(const e of k.variants){ -const n=e.contains.find((e=>"interpol"===e.label));n.keywords=l -;const t=[...c,...g,...p,f,k,...C];n.contains=[...t,{begin:/\(/,end:/\)/, -contains:["self",...t]}]}return{name:"Swift",keywords:l,contains:[...a,F,$,{ +const n=e.contains.find(e=>"interpol"===e.label);n.keywords=c +;const t=[...l,...g,...p,f,k,...C];n.contains=[...t,{begin:/\(/,end:/\)/, +contains:["self",...t]}]}return{name:"Swift",keywords:c,contains:[...a,F,$,{ match:[/class\b/,/\s+/,/func\b/,/\s+/,/\b[A-Za-z_][A-Za-z0-9_]*\b/],scope:{ 1:"keyword",3:"keyword",5:"title.function"}},{match:[/class\b/,/\s+/,/var\b/], scope:{1:"keyword",3:"keyword"}},U,z,j,{beginKeywords:"import",end:/$/, -contains:[...a],relevance:0},A,...c,...g,...p,f,k,...C,...T,R,I]}}, +contains:[...a],relevance:0},A,...l,...g,...p,f,k,...C,...T,R,I]}}, grmr_typescript:e=>{ -const n=e.regex,t=Ne(e),a=me,i=["any","void","number","boolean","string","object","never","symbol","bigint","unknown"],r={ +const n=e.regex,t=ge(e),a=te,i=["any","void","number","boolean","string","object","never","symbol","bigint","unknown"],r={ begin:[/namespace/,/\s+/,e.IDENT_RE],beginScope:{1:"keyword",3:"title.class"} },s={beginKeywords:"interface",end:/\{/,excludeEnd:!0,keywords:{ keyword:"interface extends",built_in:i},contains:[t.exports.CLASS_REFERENCE] -},o={$pattern:me, -keyword:_e.concat(["type","interface","public","private","protected","implements","declare","abstract","readonly","enum","override","satisfies"]), -literal:he,built_in:ve.concat(i),"variable.language":we},l={className:"meta", -begin:"@"+a},c=(e,n,t)=>{const a=e.contains.findIndex((e=>e.label===n)) +},o={$pattern:te, +keyword:ie.concat(["type","interface","public","private","protected","implements","declare","abstract","readonly","enum","override","satisfies"]), +literal:re,built_in:de.concat(i),"variable.language":le},c={className:"meta", +begin:"@"+a},l=(e,n,t)=>{const a=e.contains.findIndex(e=>e.label===n) ;if(-1===a)throw Error("can not find mode to replace");e.contains.splice(a,1,t)} -;Object.assign(t.keywords,o),t.exports.PARAMS_CONTAINS.push(l) -;const d=t.contains.find((e=>"attr"===e.scope)),g=Object.assign({},d,{ +;Object.assign(t.keywords,o),t.exports.PARAMS_CONTAINS.push(c) +;const d=t.contains.find(e=>"attr"===e.scope),g=Object.assign({},d,{ match:n.concat(a,n.lookahead(/\s*\?:/))}) ;return t.exports.PARAMS_CONTAINS.push([t.exports.CLASS_REFERENCE,d,g]), -t.contains=t.contains.concat([l,r,s,g]), -c(t,"shebang",e.SHEBANG()),c(t,"use_strict",{className:"meta",relevance:10, +t.contains=t.contains.concat([c,r,s,g]), +l(t,"shebang",e.SHEBANG()),l(t,"use_strict",{className:"meta",relevance:10, begin:/^\s*['"]use strict['"]/ -}),t.contains.find((e=>"func.def"===e.label)).relevance=0,Object.assign(t,{ +}),t.contains.find(e=>"func.def"===e.label).relevance=0,Object.assign(t,{ name:"TypeScript",aliases:["ts","tsx","mts","cts"]}),t},grmr_vbnet:e=>{ const n=e.regex,t=/\d{1,2}\/\d{1,2}\/\d{4}/,a=/\d{4}-\d{1,2}-\d{1,2}/,i=/(\d|1[012])(:\d+){0,2} *(AM|PM)/,r=/\d{1,2}(:\d{1,2}){1,2}/,s={ className:"literal",variants:[{begin:n.concat(/# */,n.either(a,t),/ *#/)},{ begin:n.concat(/# */,r,/ *#/)},{begin:n.concat(/# */,i,/ *#/)},{ begin:n.concat(/# */,n.either(a,t),/ +/,n.either(i,r),/ *#/)}] },o=e.COMMENT(/'''/,/$/,{contains:[{className:"doctag",begin:/<\/?/,end:/>/}] -}),l=e.COMMENT(null,/$/,{variants:[{begin:/'/},{begin:/([\t ]|^)REM(?=\s)/}]}) +}),c=e.COMMENT(null,/$/,{variants:[{begin:/'/},{begin:/([\t ]|^)REM(?=\s)/}]}) ;return{name:"Visual Basic .NET",aliases:["vb"],case_insensitive:!0, classNameAliases:{label:"symbol"},keywords:{ keyword:"addhandler alias aggregate ansi as async assembly auto binary by byref byval call case catch class compare const continue custom declare default delegate dim distinct do each equals else elseif end enum erase error event exit explicit finally for friend from function get global goto group handles if implements imports in inherits interface into iterator join key let lib loop me mid module mustinherit mustoverride mybase myclass namespace narrowing new next notinheritable notoverridable of off on operator option optional order overloads overridable overrides paramarray partial preserve private property protected public raiseevent readonly redim removehandler resume return select set shadows shared skip static step stop structure strict sub synclock take text then throw to try unicode until using when where while widening with withevents writeonly yield", @@ -1176,11 +1187,11 @@ end:/"/,illegal:/\n/,contains:[{begin:/""/}]},s,{className:"number",relevance:0, variants:[{begin:/\b\d[\d_]*((\.[\d_]+(E[+-]?[\d_]+)?)|(E[+-]?[\d_]+))[RFD@!#]?/ },{begin:/\b\d[\d_]*((U?[SIL])|[%&])?/},{begin:/&H[\dA-F_]+((U?[SIL])|[%&])?/},{ begin:/&O[0-7_]+((U?[SIL])|[%&])?/},{begin:/&B[01_]+((U?[SIL])|[%&])?/}]},{ -className:"label",begin:/^\w+:/},o,l,{className:"meta", +className:"label",begin:/^\w+:/},o,c,{className:"meta", begin:/[\t ]*#(const|disable|else|elseif|enable|end|externalsource|if|region)\b/, end:/$/,keywords:{ keyword:"const disable else elseif enable end externalsource if region then"}, -contains:[l]}]}},grmr_wasm:e=>{e.regex;const n=e.COMMENT(/\(;/,/;\)/) +contains:[c]}]}},grmr_wasm:e=>{e.regex;const n=e.COMMENT(/\(;/,/;\)/) ;return n.contains.push("self"),{name:"WebAssembly",keywords:{$pattern:/[\w.]+/, keyword:["anyfunc","block","br","br_if","br_table","call","call_indirect","data","drop","elem","else","end","export","func","global.get","global.set","local.get","local.set","local.tee","get_global","get_local","global","if","import","local","loop","memory","memory.grow","memory.size","module","mut","nop","offset","param","result","return","select","set_global","set_local","start","table","tee_local","then","type","unreachable"] },contains:[e.COMMENT(/;;/,/$/),n,{match:[/(?:offset|align)/,/\s*/,/=/], @@ -1197,7 +1208,7 @@ const n=e.regex,t=n.concat(/[\p{L}_]/u,n.optional(/[\p{L}0-9_.-]*:/u),/[\p{L}0-9 className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},i={begin:/\s/, contains:[{className:"keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}] },r=e.inherit(i,{begin:/\(/,end:/\)/}),s=e.inherit(e.APOS_STRING_MODE,{ -className:"string"}),o=e.inherit(e.QUOTE_STRING_MODE,{className:"string"}),l={ +className:"string"}),o=e.inherit(e.QUOTE_STRING_MODE,{className:"string"}),c={ endsWithParent:!0,illegal://,contains:[i,r,o,s]}]}] },e.COMMENT(//,{relevance:10}),{begin://, relevance:10},a,{className:"meta",end:/\?>/,variants:[{begin:/<\?xml/, relevance:10,contains:[o]},{begin:/<\?[a-z][a-z0-9]+/}]},{className:"tag", -begin:/)/,end:/>/,keywords:{name:"style"},contains:[l],starts:{ -end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag", -begin:/)/,end:/>/,keywords:{name:"script"},contains:[l],starts:{ -end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{ -className:"tag",begin:/<>|<\/>/},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"style"},contains:[c],starts:{ +end:/<\/style>/,returnEnd:!0,subLanguage:"css"}},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"script"},contains:[c],starts:{ +end:/<\/script>/,returnEnd:!0,subLanguage:"javascript"}},{className:"tag", +begin:/<>|<\/>/},{className:"tag", begin:n.concat(//,/>/,/\s/)))), -end:/\/?>/,contains:[{className:"name",begin:t,relevance:0,starts:l}]},{ +end:/\/?>/,contains:[{className:"name",begin:t,relevance:0,starts:c}]},{ className:"tag",begin:n.concat(/<\//,n.lookahead(n.concat(t,/>/))),contains:[{ className:"name",begin:t,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]} },grmr_yaml:e=>{ @@ -1227,7 +1238,7 @@ begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(a,{variants:[{ begin:/'/,end:/'/,contains:[{begin:/''/,relevance:0}]},{begin:/"/,end:/"/},{ begin:/[^\s,{}[\]]+/}]}),r={end:",",endsWithParent:!0,excludeEnd:!0,keywords:n, relevance:0},s={begin:/\{/,end:/\}/,contains:[r],illegal:"\\n",relevance:0},o={ -begin:"\\[",end:"\\]",contains:[r],illegal:"\\n",relevance:0},l=[{ +begin:"\\[",end:"\\]",contains:[r],illegal:"\\n",relevance:0},c=[{ className:"attr",variants:[{begin:/[\w*@][\w*@ :()\./-]*:(?=[ \t]|$)/},{ begin:/"[\w*@][\w*@ :()\./-]*":(?=[ \t]|$)/},{ begin:/'[\w*@][\w*@ :()\./-]*':(?=[ \t]|$)/}]},{className:"meta", @@ -1243,9 +1254,9 @@ className:"number", begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b" },{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},s,o,{ className:"string",relevance:0,begin:/'/,end:/'/,contains:[{match:/''/, -scope:"char.escape",relevance:0}]},a],c=[...l] -;return c.pop(),c.push(i),r.contains=c,{name:"YAML",case_insensitive:!0, -aliases:["yml"],contains:l}}});const Ke=ne;for(const e of Object.keys(Pe)){ +scope:"char.escape",relevance:0}]},a],l=[...c] +;return l.pop(),l.push(i),r.contains=l,{name:"YAML",case_insensitive:!0, +aliases:["yml"],contains:c}}});const Ke=ne;for(const e of Object.keys(Pe)){ const n=e.replace("grmr_","").replace("_","-");Ke.registerLanguage(n,Pe[e])} return Ke}() ;"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs); \ No newline at end of file From 265b255694586968212cdeddcc3aee0bcecbb405 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:43:11 +0200 Subject: [PATCH 005/155] refactor(git): remove the unused primary remote lookup --- .../claudejb/controller/git/GitGateway.kt | 12 -- .../controller/git/GitHistoryService.kt | 4 - .../lain/claudejb/model/git/GitRemoteInfo.kt | 65 ------- .../claudejb/model/git/GitRemoteInfoTest.kt | 164 ------------------ 4 files changed, 245 deletions(-) delete mode 100644 src/main/kotlin/dev/lain/claudejb/model/git/GitRemoteInfo.kt delete mode 100644 src/test/kotlin/dev/lain/claudejb/model/git/GitRemoteInfoTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/git/GitGateway.kt b/src/main/kotlin/dev/lain/claudejb/controller/git/GitGateway.kt index 4a9f1281..e2ccd5e9 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/git/GitGateway.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/git/GitGateway.kt @@ -11,14 +11,12 @@ import dev.lain.claudejb.model.git.GitCommitInfo import dev.lain.claudejb.model.git.GitLogScope import dev.lain.claudejb.model.git.GitRefInfo import dev.lain.claudejb.model.git.GitRefKind -import dev.lain.claudejb.model.git.GitRemoteInfo import git4idea.GitCommit import git4idea.GitRevisionNumber import git4idea.branch.GitBranchesCollection import git4idea.history.GitFileHistory import git4idea.history.GitHistoryUtils import git4idea.repo.GitBranchTrackInfo -import git4idea.repo.GitRemote import git4idea.repo.GitRepository import git4idea.repo.GitRepositoryChangeListener import git4idea.repo.GitRepositoryManager @@ -106,16 +104,6 @@ internal object GitGateway { private fun countBetween(repository: GitRepository, from: String, to: String): Int? = GitBranchTopology.commitCount(GitHistoryUtils.getNumberOfCommitsBetween(repository, from, to)) - fun primaryRemote(project: Project, root: VirtualFile): GitRemoteInfo? { - val remotes = repositoryAt(project, root)?.remotes.orEmpty() - val chosen = remotes.firstOrNull { it.name == GitRemote.ORIGIN } - ?: remotes.firstOrNull { it.name == UPSTREAM } - ?: remotes.singleOrNull() - return chosen?.firstUrl?.let { GitRemoteInfo.parse(it) } - } - - private const val UPSTREAM = "upstream" - private fun toInfo(commit: GitCommit, repositoryRoot: String): GitCommitInfo { val subject = commit.subject.ifBlank { GitCommitInfo.subjectOf(commit.fullMessage) } val paths = commit.changes diff --git a/src/main/kotlin/dev/lain/claudejb/controller/git/GitHistoryService.kt b/src/main/kotlin/dev/lain/claudejb/controller/git/GitHistoryService.kt index d5b24023..74a081e0 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/git/GitHistoryService.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/git/GitHistoryService.kt @@ -12,7 +12,6 @@ import dev.lain.claudejb.model.git.GitBranchTopology import dev.lain.claudejb.model.git.GitCommitInfo import dev.lain.claudejb.model.git.GitLogScope import dev.lain.claudejb.model.git.GitRefInfo -import dev.lain.claudejb.model.git.GitRemoteInfo import dev.lain.claudejb.util.logger @Service(Service.Level.PROJECT) @@ -58,9 +57,6 @@ class GitHistoryService(private val project: Project) { fun refs(): List = withPrimaryRoot(emptyList()) { root -> GitGateway.refs(project, root) } - fun primaryRemote(): GitRemoteInfo? = - withPrimaryRoot(null) { root -> GitGateway.primaryRemote(project, root) } - fun workingTreeChanges(): List { val root = primaryRepositoryRoot() ?: return emptyList() return ChangeListManager.getInstance(project).allChanges diff --git a/src/main/kotlin/dev/lain/claudejb/model/git/GitRemoteInfo.kt b/src/main/kotlin/dev/lain/claudejb/model/git/GitRemoteInfo.kt deleted file mode 100644 index 8cfb4205..00000000 --- a/src/main/kotlin/dev/lain/claudejb/model/git/GitRemoteInfo.kt +++ /dev/null @@ -1,65 +0,0 @@ -package dev.lain.claudejb.model.git - -data class GitRemoteInfo( - val url: String, - val provider: GitRemoteProvider, - val host: String?, - val owner: String?, - val repo: String?, -) { - - companion object { - - fun parse(url: String): GitRemoteInfo { - val trimmed = url.trim() - val (host, path) = hostAndPath(trimmed) - val segments = path.trim('/').removeSuffix(GIT_SUFFIX).split('/').filter { it.isNotBlank() } - val owner = segments.dropLast(1).takeIf { host != null && it.isNotEmpty() } - return GitRemoteInfo( - url = trimmed, - provider = providerOf(host), - host = host, - owner = owner?.joinToString("/"), - repo = segments.lastOrNull(), - ) - } - - private fun hostAndPath(url: String): Pair { - val scheme = url.indexOf(SCHEME_SEPARATOR) - if (scheme >= 0) { - val rest = url.substring(scheme + SCHEME_SEPARATOR.length) - return hostOf(rest.substringBefore('/')) to rest.substringAfter('/', "") - } - val colon = url.indexOf(':') - val authority = if (colon > 0) url.substring(0, colon) else "" - if (looksLikeHost(authority)) return hostOf(authority) to url.substring(colon + 1) - return null to url - } - - private fun looksLikeHost(authority: String): Boolean = - '/' !in authority && ('@' in authority || '.' in authority) - - private fun hostOf(authority: String): String? = - authority.substringAfterLast('@').substringBefore(':').lowercase().takeIf { it.isNotBlank() } - - private fun providerOf(host: String?): GitRemoteProvider { - val labels = host?.split('.').orEmpty() - return when { - GITHUB in labels -> GitRemoteProvider.GITHUB - GITLAB in labels -> GitRemoteProvider.GITLAB - else -> GitRemoteProvider.OTHER - } - } - - private const val SCHEME_SEPARATOR = "://" - private const val GIT_SUFFIX = ".git" - private const val GITHUB = "github" - private const val GITLAB = "gitlab" - } -} - -enum class GitRemoteProvider { - GITHUB, - GITLAB, - OTHER, -} diff --git a/src/test/kotlin/dev/lain/claudejb/model/git/GitRemoteInfoTest.kt b/src/test/kotlin/dev/lain/claudejb/model/git/GitRemoteInfoTest.kt deleted file mode 100644 index 6e397e74..00000000 --- a/src/test/kotlin/dev/lain/claudejb/model/git/GitRemoteInfoTest.kt +++ /dev/null @@ -1,164 +0,0 @@ -package dev.lain.claudejb.model.git - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Test - -class GitRemoteInfoTest { - - @Test - fun `an https GitHub url yields provider, owner and repo`() { - val remote = GitRemoteInfo.parse("https://github.com/anthropics/claude-code.git") - assertEquals(GitRemoteProvider.GITHUB, remote.provider) - assertEquals("anthropics", remote.owner) - assertEquals("claude-code", remote.repo) - assertEquals("https://github.com/anthropics/claude-code.git", remote.url) - } - - @Test - fun `the dot-git suffix is optional, which is how the browser's copy button writes it`() { - val remote = GitRemoteInfo.parse("https://github.com/anthropics/claude-code") - assertEquals(GitRemoteProvider.GITHUB, remote.provider) - assertEquals("anthropics", remote.owner) - assertEquals("claude-code", remote.repo) - } - - @Test - fun `a trailing slash is not a fourth path segment`() { - assertEquals("claude-code", GitRemoteInfo.parse("https://github.com/anthropics/claude-code/").repo) - } - - @Test - fun `credentials in the url do not become the host, and never reach the parsed fields`() { - val remote = GitRemoteInfo.parse("https://oauth2:glpat-secret@gitlab.com/group/repo.git") - assertEquals(GitRemoteProvider.GITLAB, remote.provider) - assertEquals("group", remote.owner) - assertEquals("repo", remote.repo) - } - - @Test - fun `scp syntax is not a url and is parsed as its own shape`() { - val remote = GitRemoteInfo.parse("git@github.com:anthropics/claude-code.git") - assertEquals(GitRemoteProvider.GITHUB, remote.provider) - assertEquals("anthropics", remote.owner) - assertEquals("claude-code", remote.repo) - } - - @Test - fun `an ssh url with an explicit port keeps the port out of the host`() { - val remote = GitRemoteInfo.parse("ssh://git@github.com:22/anthropics/claude-code.git") - assertEquals(GitRemoteProvider.GITHUB, remote.provider) - assertEquals("anthropics", remote.owner) - assertEquals("claude-code", remote.repo) - } - - @Test - fun `the git protocol is a scheme like any other`() { - val remote = GitRemoteInfo.parse("git://github.com/anthropics/claude-code.git") - assertEquals(GitRemoteProvider.GITHUB, remote.provider) - assertEquals("anthropics", remote.owner) - } - - @Test - fun `a self-hosted GitLab is recognised by its host label, not by the dot-com`() { - val remote = GitRemoteInfo.parse("git@gitlab.example.com:platform/tooling/deploy.git") - assertEquals(GitRemoteProvider.GITLAB, remote.provider) - assertEquals("platform/tooling", remote.owner) - assertEquals("deploy", remote.repo) - } - - @Test - fun `a nested GitLab group keeps every level of the namespace`() { - val remote = GitRemoteInfo.parse("https://gitlab.com/a/b/c/thing.git") - assertEquals("a/b/c", remote.owner) - assertEquals("thing", remote.repo) - } - - @Test - fun `a host that merely contains the word is not the provider`() { - val remote = GitRemoteInfo.parse("https://mygithub.example.org/team/app.git") - assertEquals(GitRemoteProvider.OTHER, remote.provider) - assertEquals("team", remote.owner) - assertEquals("app", remote.repo) - } - - @Test - fun `a self-hosted forge under a neutral name is OTHER, and that is the honest answer`() { - assertEquals(GitRemoteProvider.OTHER, GitRemoteInfo.parse("git@git.example.com:team/app.git").provider) - } - - @Test - fun `the host is matched case-insensitively, because DNS is`() { - assertEquals(GitRemoteProvider.GITHUB, GitRemoteInfo.parse("https://GitHub.com/Anthropics/Claude-Code.git").provider) - assertEquals("Anthropics", GitRemoteInfo.parse("https://GitHub.com/Anthropics/Claude-Code.git").owner) - } - - @Test - fun `a local path has a repository but no owner`() { - val remote = GitRemoteInfo.parse("/srv/git/mirror.git") - assertEquals(GitRemoteProvider.OTHER, remote.provider) - assertNull(remote.owner) - assertEquals("mirror", remote.repo) - } - - @Test - fun `a relative remote is a local remote too`() { - val remote = GitRemoteInfo.parse("../sibling") - assertNull(remote.owner) - assertEquals("sibling", remote.repo) - } - - @Test - fun `a windows path is not a host called C`() { - val remote = GitRemoteInfo.parse("C:/repos/thing") - assertEquals(GitRemoteProvider.OTHER, remote.provider) - assertNull(remote.owner) - assertEquals("thing", remote.repo) - } - - @Test - fun `an empty or blank url parses to nothing at all rather than throwing`() { - listOf("", " ", "\n\t").forEach { raw -> - val remote = GitRemoteInfo.parse(raw) - assertEquals("", remote.url, "surrounding whitespace is not part of the URL") - assertEquals(GitRemoteProvider.OTHER, remote.provider) - assertNull(remote.owner) - assertNull(remote.repo) - } - } - - @Test - fun `a host with nothing after it yields neither owner nor repository`() { - val remote = GitRemoteInfo.parse("https://github.com/") - assertEquals(GitRemoteProvider.GITHUB, remote.provider) - assertNull(remote.owner) - assertNull(remote.repo) - } - - @Test - fun `rubbish is carried through verbatim instead of being guessed at`() { - val junk = GitRemoteInfo.parse("::::") - assertEquals("::::", junk.url) - assertEquals(GitRemoteProvider.OTHER, junk.provider) - assertNull(junk.owner) - - val prose = GitRemoteInfo.parse("not a url at all") - assertEquals(GitRemoteProvider.OTHER, prose.provider) - assertNull(prose.owner) - assertEquals("not a url at all", prose.repo) - } - - @Test - fun `the surrounding whitespace of a hand-edited config is trimmed`() { - val remote = GitRemoteInfo.parse(" https://github.com/anthropics/claude-code.git ") - assertEquals("https://github.com/anthropics/claude-code.git", remote.url) - assertEquals("anthropics", remote.owner) - } - - @Test - fun `two parses of the same url are the same value`() { - val url = "git@github.com:anthropics/claude-code.git" - assertEquals(GitRemoteInfo.parse(url), GitRemoteInfo.parse(url)) - assertEquals(GitRemoteInfo.parse(url).hashCode(), GitRemoteInfo.parse(url).hashCode()) - } -} From 97e8d4859770074ae510682574b5ed136434811c Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:45:41 +0200 Subject: [PATCH 006/155] fix(process): end a CLI blocked on stdin and scan stdout only once --- .../controller/process/ClaudeProcess.kt | 5 +- .../model/protocol/parse/ProtocolParser.kt | 7 ++- .../process/ClaudeProcessStdoutTest.kt | 54 +++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 src/test/kotlin/dev/lain/claudejb/controller/process/ClaudeProcessStdoutTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/process/ClaudeProcess.kt b/src/main/kotlin/dev/lain/claudejb/controller/process/ClaudeProcess.kt index c042800b..44efb6be 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/process/ClaudeProcess.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/process/ClaudeProcess.kt @@ -88,9 +88,10 @@ class ClaudeProcess( private fun consumeStdout(text: String) { val lines = ArrayList() synchronized(stdoutBuffer) { + val scanFrom = stdoutBuffer.length stdoutBuffer.append(text) var start = 0 - var newline = stdoutBuffer.indexOf("\n", start) + var newline = stdoutBuffer.indexOf("\n", scanFrom) while (newline >= 0) { lines.add(stdoutBuffer.substring(start, newline)) start = newline + 1 @@ -141,7 +142,7 @@ class ClaudeProcess( } private fun endProcess(dying: KillableProcessHandler) { - runCatching { synchronized(writeLock) { dying.processInput?.close() } } dying.destroyProcess() + runCatching { dying.processInput.close() } } } diff --git a/src/main/kotlin/dev/lain/claudejb/model/protocol/parse/ProtocolParser.kt b/src/main/kotlin/dev/lain/claudejb/model/protocol/parse/ProtocolParser.kt index f6fec950..4173ea4f 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/protocol/parse/ProtocolParser.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/protocol/parse/ProtocolParser.kt @@ -59,10 +59,9 @@ object ProtocolParser { } fun parse(line: String): List { - val trimmed = line.trim() - if (trimmed.isEmpty()) return emptyList() - val root = runCatching { ClaudeJson.parseToJsonElement(trimmed) }.getOrNull() as? JsonObject - ?: return listOf(ClaudeEvent.Other("?", null, JsonObject(emptyMap()), "malformed JSON (${trimmed.length} chars)")) + if (line.isBlank()) return emptyList() + val root = runCatching { ClaudeJson.parseToJsonElement(line) }.getOrNull() as? JsonObject + ?: return listOf(ClaudeEvent.Other("?", null, JsonObject(emptyMap()), "malformed JSON (${line.length} chars)")) val type = root.str("type") ?: return listOf(ClaudeEvent.Other("?", null, root, "no type field")) val decoder = TOP_LEVEL_DECODERS[type] diff --git a/src/test/kotlin/dev/lain/claudejb/controller/process/ClaudeProcessStdoutTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/process/ClaudeProcessStdoutTest.kt new file mode 100644 index 00000000..d0b1b564 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/process/ClaudeProcessStdoutTest.kt @@ -0,0 +1,54 @@ +package dev.lain.claudejb.controller.process + +import dev.lain.claudejb.model.protocol.ClaudeEvent +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Path + +class ClaudeProcessStdoutTest { + + @TempDir + lateinit var dir: Path + + private val events = mutableListOf() + + private fun consumer(): (String) -> Unit { + val process = ClaudeProcess(File("claude"), dir.toFile(), emptyList(), onEvent = { events += it }, onTerminated = {}) + val method = ClaudeProcess::class.java.getDeclaredMethod("consumeStdout", String::class.java) + method.isAccessible = true + return { chunk -> method.invoke(process, chunk) } + } + + @Test + fun `a line split over many chunks is parsed once, when its newline arrives`() { + val consume = consumer() + val line = """{"type":"keep_alive"}""" + "\n" + """{"type":"something_new","subtype":"x"}""" + "\n" + + line.chunked(3).forEach(consume) + + assertEquals(1, events.size) + assertEquals("something_new", (events.single() as ClaudeEvent.Other).type) + } + + @Test + fun `blank and carriage-returned lines are handled without a copy of the line`() { + val consume = consumer() + + consume(" \n\r\n") + consume(" {\"type\":\"something_new\"}\r\n") + + assertEquals(listOf("something_new"), events.map { (it as ClaudeEvent.Other).type }) + } + + @Test + fun `several lines in one chunk come out in order`() { + val consume = consumer() + + consume("{\"type\":\"a_type\"}\n{\"type\":\"b_type\"}\n{\"type\":\"c_") + consume("type\"}\n") + + assertEquals(listOf("a_type", "b_type", "c_type"), events.map { (it as ClaudeEvent.Other).type }) + } +} From 9b67e88ad6fd01dccd4bf036f09993112f809c68 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:45:42 +0200 Subject: [PATCH 007/155] perf(session): write prompts to the CLI off the EDT --- .../controller/session/turn/PromptQueue.kt | 48 ++++++++++++++----- .../session/turn/PromptQueueTest.kt | 23 +++++++++ 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/turn/PromptQueue.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/turn/PromptQueue.kt index b04121d1..f03a1275 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/turn/PromptQueue.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/turn/PromptQueue.kt @@ -1,5 +1,6 @@ package dev.lain.claudejb.controller.session.turn +import com.intellij.util.concurrency.AppExecutorUtil import dev.lain.claudejb.model.protocol.control.ControlProtocol import dev.lain.claudejb.model.session.transcript.Speaker import dev.lain.claudejb.model.session.transcript.TranscriptModel @@ -13,12 +14,15 @@ class PromptQueue( private val canSend: () -> Boolean, private val onSent: () -> Unit, private val fireState: () -> Unit, + private val offload: (Runnable) -> Unit = AppExecutorUtil.createBoundedApplicationPoolExecutor(WRITER, 1)::execute, ) { private data class Outgoing(val text: String, val images: List>, val displayText: String) private val queue = ArrayDeque() + private var inFlight: Outgoing? = null + private val toolUseTurn = ConcurrentHashMap() @Volatile var currentUserMessageId: String? = null @@ -27,7 +31,7 @@ class PromptQueue( @Volatile var suggestion: String? = null private set - fun queued(): List = queue.map { it.displayText } + fun queued(): List = listOfNotNull(inFlight?.displayText) + queue.map { it.displayText } fun userMessageIdFor(toolUseId: String): String? = toolUseTurn[toolUseId] @@ -42,24 +46,38 @@ class PromptQueue( } fun remove(index: Int) = edt { - if (index in queue.indices) { - queue.removeAt(index) + val at = if (inFlight != null) index - 1 else index + if (at in queue.indices) { + queue.removeAt(at) fireState() } } fun pump() { - while (canSend() && queue.isNotEmpty()) { - val next = queue.first() - val msgUuid = UUID.randomUUID().toString() - if (!write(ControlProtocol.userMessageWithImages(next.text, next.images, uuid = msgUuid))) return - queue.removeFirst() - transcript.add(Speaker.USER, next.displayText) - currentUserMessageId = msgUuid - onSent() - dropSuggestion() - fireState() + if (inFlight != null || !canSend() || queue.isEmpty()) return + val next = queue.removeFirst() + inFlight = next + val msgUuid = UUID.randomUUID().toString() + offload( + Runnable { + val sent = write(ControlProtocol.userMessageWithImages(next.text, next.images, uuid = msgUuid)) + edt { settle(next, msgUuid, sent) } + }, + ) + } + + private fun settle(next: Outgoing, msgUuid: String, sent: Boolean) { + inFlight = null + if (!sent) { + queue.addFirst(next) + return } + transcript.add(Speaker.USER, next.displayText) + currentUserMessageId = msgUuid + onSent() + dropSuggestion() + fireState() + pump() } fun dropSuggestion() { @@ -83,4 +101,8 @@ class PromptQueue( toolUseTurn.clear() currentUserMessageId = null } + + private companion object { + const val WRITER = "Claude Code prompt writer" + } } diff --git a/src/test/kotlin/dev/lain/claudejb/controller/session/turn/PromptQueueTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/session/turn/PromptQueueTest.kt index a1c022d5..fa60ef6b 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/session/turn/PromptQueueTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/session/turn/PromptQueueTest.kt @@ -23,8 +23,12 @@ class PromptQueueTest { canSend = { ready }, onSent = {}, fireState = { stateFired++ }, + offload = { job -> if (holdWrites) held.add(job) else job.run() }, ) + private var holdWrites = false + private val held = mutableListOf() + private fun sessionReady() { ready = true queue.pump() @@ -69,6 +73,25 @@ class PromptQueueTest { assertEquals(listOf("lost?"), transcript.entries.map { it.text }) } + @Test + fun `a write still in flight holds the next prompt back and keeps the row until it lands`() { + holdWrites = true + queue.enqueue("first", emptyList(), "first") + queue.enqueue("second", emptyList(), "second") + + assertEquals(1, held.size) + assertEquals(listOf("first", "second"), queue.queued()) + assertEquals(emptyList(), transcript.entries.map { it.text }) + + held.removeAt(0).run() + assertEquals(listOf("first"), transcript.entries.map { it.text }) + assertEquals(1, held.size) + + held.removeAt(0).run() + assertEquals(listOf("first", "second"), transcript.entries.map { it.text }) + assertEquals(listOf(true, true), listOf("first" in written[0], "second" in written[1])) + } + @Test fun `sending clears the suggestion and a tool call is bound to the turn that sent it`() { queue.suggest("Add tests") From fb3ff7402b6613dfd8f725e3d5c9a3ad5edbafb1 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:46:25 +0200 Subject: [PATCH 008/155] build(deps): update marked to 18.0.14 --- THIRD-PARTY-NOTICES.md | 13 +- src/main/resources/jcef/marked.min.js | 82 ++++++++- src/main/ts/jcef/core/markdown.ts | 167 ++++++++++++------ src/main/ts/jcef/types/core.d.ts | 18 +- .../frontend/chat/markdown-fragment.test.js | 86 +++++++++ 5 files changed, 300 insertions(+), 66 deletions(-) create mode 100644 src/test/frontend/chat/markdown-fragment.test.js diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 5b1f8a7b..efa2babf 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -35,7 +35,7 @@ carries the vendored web assets), the two kotlinx.serialization jars listed belo `searchableOptions` jar. Everything with a third-party license in that list has an entry here. Last verified: 2026-08-11, against the upstream `LICENSE` files at the pinned tags -(`markedjs/marked@v12.0.0`, `cure53/DOMPurify@3.4.16`, `highlightjs/highlight.js@11.12.0`, +(`markedjs/marked@v18.0.14`, `cure53/DOMPurify@3.4.16`, `highlightjs/highlight.js@11.12.0`, `Kotlin/kotlinx.serialization@v1.7.3`). --- @@ -46,8 +46,8 @@ These are vendored into the plugin's embedded web UI under `jcef/` and are serve browser at runtime. Each is redistributed exactly as published upstream, with its license banner intact and no edit of our own. -### marked — 12.0.0 -- **License:** `MIT AND BSD-3-Clause` — marked itself is MIT; its `LICENSE.md` additionally +### marked — 18.0.14 +- **License:** `MIT AND BSD-3-Clause` — marked itself is MIT; its `LICENSE` additionally reproduces the notice of the original **Markdown** (John Gruber, 2004), which is a BSD-3-Clause form license. Both are conditions of redistributing the file, so both are reproduced here. - **Copyright:** @@ -56,9 +56,10 @@ intact and no edit of our own. - Copyright © 2004, John Gruber (the Markdown notice) - **Project:** https://github.com/markedjs/marked - **Full text:** `LICENSES/MIT.txt` (marked) and `LICENSES/BSD-3-Clause-Markdown.txt` (Markdown) -- **Note:** the banner inside `marked.min.js` reads *"Copyright (c) 2011-2024, Christopher Jeffrey"* - — a single line that names neither MarkedJS nor Gruber and states a date range that does not - appear in the license. `LICENSE.md` at `v12.0.0` is the grant and is what is reproduced above. +- **Note:** `marked.min.js` is upstream's `lib/marked.umd.js`, the minified browser build that + replaced `marked.min.js` in marked 16, kept under the old name. Its banner names MarkedJS and + Christopher Jeffrey but not Gruber; `LICENSE` at `v18.0.14` is the grant and is what is + reproduced above. ### DOMPurify — 3.4.16 - **License:** `MPL-2.0 OR Apache-2.0` — dual-licensed, as upstream's own `package.json` states it diff --git a/src/main/resources/jcef/marked.min.js b/src/main/resources/jcef/marked.min.js index be3f0969..4a215efc 100644 --- a/src/main/resources/jcef/marked.min.js +++ b/src/main/resources/jcef/marked.min.js @@ -1,6 +1,82 @@ /** - * marked v12.0.0 - a markdown parser - * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed) + * marked v18.0.14 - a markdown parser + * Copyright (c) 2018-2026, MarkedJS. (MIT License) + * Copyright (c) 2011-2018, Christopher Jeffrey. (MIT License) * https://github.com/markedjs/marked */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s=/[&<>"']/,r=new RegExp(s.source,"g"),i=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,l=new RegExp(i.source,"g"),o={"&":"&","<":"<",">":">",'"':""","'":"'"},a=e=>o[e];function c(e,t){if(t){if(s.test(e))return e.replace(r,a)}else if(i.test(e))return e.replace(l,a);return e}const h=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;function p(e){return e.replace(h,((e,t)=>"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""))}const u=/(^|[^\[])\^/g;function k(e,t){let n="string"==typeof e?e:e.source;t=t||"";const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(u,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}function g(e){try{e=encodeURI(e).replace(/%25/g,"%")}catch(e){return null}return e}const f={exec:()=>null};function d(e,t){const n=e.replace(/\|/g,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(/ \|/);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n[n.length-1].trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(/^ {1,4}/gm,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:x(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t){const n=e.match(/^(\s+)(?:```)/);if(null===n)return t;const s=n[1];return t.split("\n").map((e=>{const t=e.match(/^\s+/);if(null===t)return e;const[n]=t;return n.length>=s.length?e.slice(s.length):e})).join("\n")}(e,t[3]||"");return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(/#$/.test(e)){const t=x(e,"#");this.options.pedantic?e=t.trim():t&&!/ $/.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:t[0]}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){const e=x(t[0].replace(/^ *>[ \t]?/gm,""),"\n"),n=this.lexer.state.top;this.lexer.state.top=!0;const s=this.lexer.blockTokens(e);return this.lexer.state.top=n,{type:"blockquote",raw:t[0],tokens:s,text:e}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim();const s=n.length>1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=new RegExp(`^( {0,3}${n})((?:[\t ][^\\n]*)?(?:\\n|$))`);let l="",o="",a=!1;for(;e;){let n=!1;if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;l=t[0],e=e.substring(l.length);let s=t[2].split("\n",1)[0].replace(/^\t+/,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=0;this.options.pedantic?(h=2,o=s.trimStart()):(h=t[2].search(/[^ ]/),h=h>4?1:h,o=s.slice(h),h+=t[1].length);let p=!1;if(!s&&/^ *$/.test(c)&&(l+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),n=new RegExp(`^ {0,${Math.min(3,h-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),r=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:\`\`\`|~~~)`),i=new RegExp(`^ {0,${Math.min(3,h-1)}}#`);for(;e;){const a=e.split("\n",1)[0];if(c=a,this.options.pedantic&&(c=c.replace(/^ {1,4}(?=( {4})*[^ ])/g," ")),r.test(c))break;if(i.test(c))break;if(t.test(c))break;if(n.test(e))break;if(c.search(/[^ ]/)>=h||!c.trim())o+="\n"+c.slice(h);else{if(p)break;if(s.search(/[^ ]/)>=4)break;if(r.test(s))break;if(i.test(s))break;if(n.test(s))break;o+="\n"+c}p||c.trim()||(p=!0),l+=a+"\n",e=e.substring(a.length+1),s=c.slice(h)}}r.loose||(a?r.loose=!0:/\n *\n *$/.test(l)&&(a=!0));let u,k=null;this.options.gfm&&(k=/^\[[ xX]\] /.exec(o),k&&(u="[ ] "!==k[0],o=o.replace(/^\[[ xX]\] +/,""))),r.items.push({type:"list_item",raw:l,task:!!k,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=l}r.items[r.items.length-1].raw=l.trimEnd(),r.items[r.items.length-1].text=o.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>/\n.*\n/.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e$/,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",s=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:e,raw:t[0],href:n,title:s}}}table(e){const t=this.rules.block.table.exec(e);if(!t)return;if(!/[:|]/.test(t[2]))return;const n=d(t[1]),s=t[2].replace(/^\||\| *$/g,"").split("|"),r=t[3]&&t[3].trim()?t[3].replace(/\n[ \t]*$/,"").split("\n"):[],i={type:"table",raw:t[0],header:[],align:[],rows:[]};if(n.length===s.length){for(const e of s)/^ *-+: *$/.test(e)?i.align.push("right"):/^ *:-+: *$/.test(e)?i.align.push("center"):/^ *:-+ *$/.test(e)?i.align.push("left"):i.align.push(null);for(const e of n)i.header.push({text:e,tokens:this.lexer.inline(e)});for(const e of r)i.rows.push(d(e,i.header.length).map((e=>({text:e,tokens:this.lexer.inline(e)}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:c(t[1])}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&/^/i.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\s|>)/i.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&/^$/.test(e))return;const t=x(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=/^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),/^$/.test(e)?n.slice(1):n.slice(1,-1)),b(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(/\s+/g," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return b(n,e,n[0],this.lexer)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(/[\p{L}\p{N}]/u))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(/\n/g," ");const n=/[^ ]/.test(e),s=/^ /.test(e)&&/ $/.test(e);return n&&s&&(e=e.substring(1,e.length-1)),e=c(e,!0),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=c(t[1]),n="mailto:"+e):(e=c(t[1]),n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=c(t[0]),n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=c(t[0]),n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){let e;return e=this.lexer.state.inRawBlock?t[0]:c(t[0]),{type:"text",raw:t[0],text:e}}}}const m=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,y=/(?:[*+-]|\d{1,9}[.)])/,$=k(/^(?!bull )((?:.|\n(?!\s*?\n|bull ))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g,y).getRegex(),z=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,T=/(?!\s*\])(?:\\.|[^\[\]\\])+/,R=k(/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/).replace("label",T).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),_=k(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,y).getRegex(),A="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",S=/|$))/,I=k("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$))","i").replace("comment",S).replace("tag",A).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),E=k(z).replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex(),Z={blockquote:k(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",E).getRegex(),code:/^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,def:R,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:m,html:I,lheading:$,list:_,newline:/^(?: *(?:\n|$))+/,paragraph:E,table:f,text:/^[^\n]+/},q=k("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code"," {4}[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex(),L={...Z,table:q,paragraph:k(z).replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",q).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex()},P={...Z,html:k("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",S).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:f,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:k(z).replace("hr",m).replace("heading"," *#{1,6} *[^\n]").replace("lheading",$).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Q=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,v=/^( {2,}|\\)\n(?!\s*$)/,B="\\p{P}\\p{S}",M=k(/^((?![*_])[\spunctuation])/,"u").replace(/punctuation/g,B).getRegex(),O=k(/^(?:\*+(?:((?!\*)[punct])|[^\s*]))|^_+(?:((?!_)[punct])|([^\s_]))/,"u").replace(/punct/g,B).getRegex(),C=k("^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)[punct](\\*+)(?=[\\s]|$)|[^punct\\s](\\*+)(?!\\*)(?=[punct\\s]|$)|(?!\\*)[punct\\s](\\*+)(?=[^punct\\s])|[\\s](\\*+)(?!\\*)(?=[punct])|(?!\\*)[punct](\\*+)(?!\\*)(?=[punct])|[^punct\\s](\\*+)(?=[^punct\\s])","gu").replace(/punct/g,B).getRegex(),D=k("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)[punct](_+)(?=[\\s]|$)|[^punct\\s](_+)(?!_)(?=[punct\\s]|$)|(?!_)[punct\\s](_+)(?=[^punct\\s])|[\\s](_+)(?!_)(?=[punct])|(?!_)[punct](_+)(?!_)(?=[punct])","gu").replace(/punct/g,B).getRegex(),j=k(/\\([punct])/,"gu").replace(/punct/g,B).getRegex(),H=k(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),U=k(S).replace("(?:--\x3e|$)","--\x3e").getRegex(),X=k("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",U).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),F=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,N=k(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",F).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),G=k(/^!?\[(label)\]\[(ref)\]/).replace("label",F).replace("ref",T).getRegex(),J=k(/^!?\[(ref)\](?:\[\])?/).replace("ref",T).getRegex(),K={_backpedal:f,anyPunctuation:j,autolink:H,blockSkip:/\[[^[\]]*?\]\([^\(\)]*?\)|`[^`]*?`|<[^<>]*?>/g,br:v,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:f,emStrongLDelim:O,emStrongRDelimAst:C,emStrongRDelimUnd:D,escape:Q,link:N,nolink:J,punctuation:M,reflink:G,reflinkSearch:k("reflink|nolink(?!\\()","g").replace("reflink",G).replace("nolink",J).getRegex(),tag:X,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\t+" ".repeat(n.length)));e;)if(!(this.options.extensions&&this.options.extensions.block&&this.options.extensions.block.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.space(e))e=e.substring(n.raw.length),1===n.raw.length&&t.length>0?t[t.length-1].raw+="\n":t.push(n);else if(n=this.tokenizer.code(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?t.push(n):(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.fences(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.heading(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.hr(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.blockquote(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.list(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.html(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.def(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?this.tokens.links[n.tag]||(this.tokens.links[n.tag]={href:n.href,title:n.title}):(s.raw+="\n"+n.raw,s.text+="\n"+n.raw,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.table(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.lheading(e))e=e.substring(n.raw.length),t.push(n);else{if(r=e,this.options.extensions&&this.options.extensions.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(n=this.tokenizer.paragraph(r)))s=t[t.length-1],i&&"paragraph"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n),i=r.length!==e.length,e=e.substring(n.raw.length);else if(n=this.tokenizer.text(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n,s,r,i,l,o,a=e;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(i=this.tokenizer.rules.inline.reflinkSearch.exec(a));)e.includes(i[0].slice(i[0].lastIndexOf("[")+1,-1))&&(a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(i=this.tokenizer.rules.inline.blockSkip.exec(a));)a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(i=this.tokenizer.rules.inline.anyPunctuation.exec(a));)a=a.slice(0,i.index)+"++"+a.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;e;)if(l||(o=""),l=!1,!(this.options.extensions&&this.options.extensions.inline&&this.options.extensions.inline.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.escape(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.tag(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.link(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.reflink(e,this.tokens.links))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.emStrong(e,a,o))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.codespan(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.br(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.del(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.autolink(e))e=e.substring(n.raw.length),t.push(n);else if(this.state.inLink||!(n=this.tokenizer.url(e))){if(r=e,this.options.extensions&&this.options.extensions.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(n=this.tokenizer.inlineText(r))e=e.substring(n.raw.length),"_"!==n.raw.slice(-1)&&(o=n.raw.slice(-1)),l=!0,s=t[t.length-1],s&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}else e=e.substring(n.raw.length),t.push(n);return t}}class se{options;constructor(t){this.options=t||e.defaults}code(e,t,n){const s=(t||"").match(/^\S*/)?.[0];return e=e.replace(/\n$/,"")+"\n",s?'
'+(n?e:c(e,!0))+"
\n":"
"+(n?e:c(e,!0))+"
\n"}blockquote(e){return`
\n${e}
\n`}html(e,t){return e}heading(e,t,n){return`${e}\n`}hr(){return"
\n"}list(e,t,n){const s=t?"ol":"ul";return"<"+s+(t&&1!==n?' start="'+n+'"':"")+">\n"+e+"\n"}listitem(e,t,n){return`
  • ${e}
  • \n`}checkbox(e){return"'}paragraph(e){return`

    ${e}

    \n`}table(e,t){return t&&(t=`${t}`),"\n\n"+e+"\n"+t+"
    \n"}tablerow(e){return`\n${e}\n`}tablecell(e,t){const n=t.header?"th":"td";return(t.align?`<${n} align="${t.align}">`:`<${n}>`)+e+`\n`}strong(e){return`${e}`}em(e){return`${e}`}codespan(e){return`${e}`}br(){return"
    "}del(e){return`${e}`}link(e,t,n){const s=g(e);if(null===s)return n;let r='
    ",r}image(e,t,n){const s=g(e);if(null===s)return n;let r=`${n}0&&"paragraph"===n.tokens[0].type?(n.tokens[0].text=e+" "+n.tokens[0].text,n.tokens[0].tokens&&n.tokens[0].tokens.length>0&&"text"===n.tokens[0].tokens[0].type&&(n.tokens[0].tokens[0].text=e+" "+n.tokens[0].tokens[0].text)):n.tokens.unshift({type:"text",text:e+" "}):o+=e+" "}o+=this.parse(n.tokens,i),l+=this.renderer.listitem(o,r,!!s)}n+=this.renderer.list(l,t,s);continue}case"html":{const e=r;n+=this.renderer.html(e.text,e.block);continue}case"paragraph":{const e=r;n+=this.renderer.paragraph(this.parseInline(e.tokens));continue}case"text":{let i=r,l=i.tokens?this.parseInline(i.tokens):i.text;for(;s+1{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new se(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if("options"===n)continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new w(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new le;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if("options"===n)continue;const s=n,r=e.hooks[s],i=t[s];le.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return ne.lex(e,t??this.defaults)}parser(e,t){return ie.parse(e,t??this.defaults)}#e(e,t){return(n,s)=>{const r={...s},i={...this.defaults,...r};!0===this.defaults.async&&!1===r.async&&(i.silent||console.warn("marked(): The async option was set to true by an extension. The async: false option sent to parse will be ignored."),i.async=!0);const l=this.#t(!!i.silent,!!i.async);if(null==n)return l(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof n)return l(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));if(i.hooks&&(i.hooks.options=i),i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(n):n).then((t=>e(t,i))).then((e=>i.hooks?i.hooks.processAllTokens(e):e)).then((e=>i.walkTokens?Promise.all(this.walkTokens(e,i.walkTokens)).then((()=>e)):e)).then((e=>t(e,i))).then((e=>i.hooks?i.hooks.postprocess(e):e)).catch(l);try{i.hooks&&(n=i.hooks.preprocess(n));let s=e(n,i);i.hooks&&(s=i.hooks.processAllTokens(s)),i.walkTokens&&this.walkTokens(s,i.walkTokens);let r=t(s,i);return i.hooks&&(r=i.hooks.postprocess(r)),r}catch(e){return l(e)}}}#t(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="

    An error occurred:

    "+c(n.message+"",!0)+"
    ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const ae=new oe;function ce(e,t){return ae.parse(e,t)}ce.options=ce.setOptions=function(e){return ae.setOptions(e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.getDefaults=t,ce.defaults=e.defaults,ce.use=function(...e){return ae.use(...e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.walkTokens=function(e,t){return ae.walkTokens(e,t)},ce.parseInline=ae.parseInline,ce.Parser=ie,ce.parser=ie.parse,ce.Renderer=se,ce.TextRenderer=re,ce.Lexer=ne,ce.lexer=ne.lex,ce.Tokenizer=w,ce.Hooks=le,ce.parse=ce;const he=ce.options,pe=ce.setOptions,ue=ce.use,ke=ce.walkTokens,ge=ce.parseInline,fe=ce,de=ie.parse,xe=ne.lex;e.Hooks=le,e.Lexer=ne,e.Marked=oe,e.Parser=ie,e.Renderer=se,e.TextRenderer=re,e.Tokenizer=w,e.getDefaults=t,e.lexer=xe,e.marked=ce,e.options=he,e.parse=fe,e.parseInline=ge,e.parser=de,e.setOptions=pe,e.use=ue,e.walkTokens=ke})); + +/** + * DO NOT EDIT THIS FILE + * The code in this file is generated from files in ./src/ + */ +(function(g,f){if(typeof exports=="object"&&typeof module<"u"){module.exports=f()}else if("function"==typeof define && define.amd){define("marked",f)}else {g["marked"]=f()}}(typeof globalThis < "u" ? globalThis : typeof self < "u" ? self : this,function(){var exports={};var __exports=exports;var module={exports}; +"use strict";var W=Object.defineProperty;var Le=Object.getOwnPropertyDescriptor;var ze=Object.getOwnPropertyNames;var Ae=Object.prototype.hasOwnProperty;var Ee=(l,e)=>{for(var t in e)W(l,t,{get:e[t],enumerable:!0})},Me=(l,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of ze(e))!Ae.call(l,s)&&s!==t&&W(l,s,{get:()=>e[s],enumerable:!(n=Le(e,s))||n.enumerable});return l};var Ie=l=>Me(W({},"__esModule",{value:!0}),l);var Zt={};Ee(Zt,{Hooks:()=>_,Lexer:()=>b,Marked:()=>v,Parser:()=>R,Renderer:()=>S,TextRenderer:()=>$,Tokenizer:()=>P,defaults:()=>O,getDefaults:()=>A,lexer:()=>vt,marked:()=>k,options:()=>Mt,parse:()=>Dt,parseInline:()=>Bt,parser:()=>qt,setOptions:()=>It,use:()=>Pe,walkTokens:()=>Ct});module.exports=Ie(Zt);function A(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var O=A();function X(l){O=l}var E={exec:()=>null};function C(l){let e=[];return t=>{let n=Math.max(0,Math.min(3,t-1)),s=e[n];return s||(s=l(n),e[n]=s),s}}function h(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(s,r)=>{let o=typeof r=="string"?r:r.source;return o=o.replace(x.caret,"$1"),t=t.replace(s,o),n},getRegex:()=>new RegExp(t,e)};return n}var Ce=((l="")=>{try{return!!new RegExp("(?<=1)(?/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] +\S/,listReplaceTask:/^\[[ xX]\] +/,listTaskCheckbox:/\[[ xX]\]/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^
    /i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,numericCharacterReference:/&#(?:(\d{1,7})|[Xx]([A-Fa-f0-9]{1,6}));/g,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}${l})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:C(l=>new RegExp(`^ {0,${l}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`)),hrRegex:C(l=>new RegExp(`^ {0,${l}}((?:-[ ]*){3,}|(?:_[ ]*){3,}|(?:\\*[ ]*){3,})(?:\\n+|$)`)),fencesBeginRegex:C(l=>new RegExp(`^ {0,${l}}(?:\`\`\`|~~~)`)),headingBeginRegex:C(l=>new RegExp(`^ {0,${l}}#`)),htmlBeginRegex:C(l=>new RegExp(`^ {0,${l}}(?:)|<(?:script|pre|style|textarea|!--))`,"i")),blockquoteBeginRegex:C(l=>new RegExp(`^ {0,${l}}>`))},Be=/^(?:[ \t]*(?:\n|$))+/,De=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,qe=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,N=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,ve=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,V=/ {0,3}(?:[*+-]|\d{1,9}[.)])/,he=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |fences|blockquote|heading|hr|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,de=h(he).replace(/bull/g,V).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}(?:\s|$)/).replace(/hr/g,/ {0,3}(?:(?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Ze=h(he).replace(/bull/g,V).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}(?:\s|$)/).replace(/hr/g,/ {0,3}(?:(?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),Y=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table|[ \t]+\n)[^\n]+)*)/,He=/^[^\n]+/,ee=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,Ge=h(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",ee).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Ne=h(/^(bull)([ \t][^\n]*?)?(?:\n|$)/).replace(/bull/g,V).getRegex(),Q="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",te=/|$))/,Qe=h("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n*|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>[^\\n]*\\n*|$)|[^\\n]*\\n*|$)|[^\\n]*\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][a-z0-9-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",te).replace("tag",Q).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),ke=l=>h(Y).replace("hr",N).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*(?:\\n|$))|~~~)[^\\n]*(?:\\n|$)").replace("list",l).replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",Q).getRegex(),je=ke(/ {0,3}(?:[*+-]|1[.)])[ \t]+[^ \t\n]/),Fe=ke(/ {0,3}(?:[*+-]|\d{1,9}[.)])(?:[ \t]|\n|$)/),Ue=h(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",Fe).getRegex(),ne={blockquote:Ue,code:De,def:Ge,fences:qe,heading:ve,hr:N,html:Qe,lheading:de,list:Ne,newline:Be,paragraph:je,table:E,text:He},ue=h("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",N).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*(?:\\n|$))|~~~)[^\\n]*(?:\\n|$)").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",Q).getRegex(),Ke={...ne,lheading:Ze,table:ue,paragraph:h(Y).replace("hr",N).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",ue).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*(?:\\n|$))|~~~)[^\\n]*(?:\\n|$)").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]+[^ \\t\\n]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",Q).getRegex()},We={...ne,html:h(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",te).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:E,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:h(Y).replace("hr",N).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",de).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Xe=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,Je=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,ge=/^( {2,}|\\)\n(?!\s*$)[ \t]*/,Ve=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\`+)[^`]+\k(?!`))*?\]\((?:\\[\s\S]|[^\\\(\)]|\((?:\\[\s\S]|[^\\\(\)])*\))*\)/).replace("precode-",Ce?"(?`+)[^`]+\k(?!`)/).replace("html",/<(?! )[^<>]*?>/).getRegex(),me=/^(?:\*+(?:((?!\*)punct)|([^\s*]))?)|^_+(?:((?!_)punct)|([^\s_]))?/,st=h(me,"u").replace(/punct/g,L).getRegex(),it=h(me,"u").replace(/punct/g,fe).getRegex(),ot=/^(?:\*+(?:((?!\*)(?!openQuote)punct)|([^\s*]))?)|^_+(?:((?!_)(?!openQuote)punct)|([^\s_]))?/,at=h(ot,"u").replace(/openQuote/g,et).replace(/punct/g,L).getRegex(),xe="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",lt=h(xe,"gu").replace(/notPunctSpace/g,j).replace(/punctSpace/g,B).replace(/punct/g,L).getRegex(),ut=h(xe,"gu").replace(/notPunctSpace/g,nt).replace(/punctSpace/g,tt).replace(/punct/g,fe).getRegex(),pt="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)[\\s](\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|(?:(?!\\*)punct|notPunctSpace)(\\*+)(?!\\*)(?=notPunctSpace)",ct=h(pt,"gu").replace(/notPunctSpace/g,j).replace(/punctSpace/g,B).replace(/punct/g,L).getRegex(),ht=h("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,j).replace(/punctSpace/g,B).replace(/punct/g,L).getRegex(),dt="^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)[\\s](_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)|(?:(?!_)punct|notPunctSpace)(_+)(?!_)(?=notPunctSpace)",kt=h(dt,"gu").replace(/notPunctSpace/g,j).replace(/punctSpace/g,B).replace(/punct/g,L).getRegex(),gt=h(/^~~?(?:((?!~)punct)|[^\s~])/,"u").replace(/punct/g,L).getRegex(),ft="^[^~]+(?=[^~])|(?!~)punct(~~?)(?=[\\s]|$)|notPunctSpace(~~?)(?!~)(?=punctSpace|$)|(?!~)punctSpace(~~?)(?=notPunctSpace)|[\\s](~~?)(?!~)(?=punct)|(?!~)punct(~~?)(?!~)(?=punct)|notPunctSpace(~~?)(?=notPunctSpace)",mt=h(ft,"gu").replace(/notPunctSpace/g,j).replace(/punctSpace/g,B).replace(/punct/g,L).getRegex(),xt=h(/\\(punct)/,"gu").replace(/punct/g,L).getRegex(),bt=h(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),Rt=h(te).replace("(?:-->|$)","-->").getRegex(),Tt=h("^comment|^|^<[a-zA-Z][a-zA-Z0-9-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",Rt).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),be=/\[(?:\\[\s\S]|[^\[\]\\])*\]/,U=h(/(?:\[(?:brackets|\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`+(?!`)[^`]*?`+(?!`)|``+(?=\])|[^\[\]\\`])*?/).replace("brackets",be).getRegex(),Ot=h(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]+(?:\n[ \t]*)?|\n[ \t]*)(title))?\s*\)/).replace("label",U).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]+|(?=\))/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),wt=h(/^!?\[(label)\]\[(ref)\]/).replace("label",U).replace("ref",ee).getRegex(),yt=h(/^!?\[(ref)\](?:\[\])?/).replace("ref",ee).getRegex(),pe=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\]){1,999}/,Pt=h(/(?:[^\[\]\\`]*(?:\[(?:brackets|\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`+(?!`)[^`]*?`+(?!`)|``+(?=\]))){0,999}?[^\[\]\\`]*?/).replace("brackets",be).getRegex(),St=h("reflink|nolink(?!\\()","g").replace("reflink",h(/^!?\[(label)\]\[(ref)\]/).replace("label",Pt).replace("ref",pe).getRegex()).replace("nolink",h(/^!?\[(ref)\](?:\[\])?/).replace("ref",pe).getRegex()).getRegex(),ce=/[hH][tT][tT][pP][sS]?|[fF][tT][pP]/,_t=/[A-Za-z0-9._+-]+@[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![\w-])/,$t=h(/(?:mailto:email|xmpp:email(?:\/[A-Za-z0-9@.]+)?)/).replace(/email/g,_t).getRegex(),re={_backpedal:E,anyPunctuation:xt,autolink:bt,blockSkip:rt,br:ge,code:Je,del:E,delLDelim:E,delRDelim:E,emStrongLDelim:st,emStrongRDelimAst:lt,emStrongRDelimUnd:ht,escape:Xe,link:Ot,nolink:yt,punctuation:Ye,reflink:wt,reflinkSearch:St,tag:Tt,text:Ve,url:E},Lt={...re,emStrongLDelim:at,emStrongRDelimAst:ct,emStrongRDelimUnd:kt,link:h(/^!?\[(label)\]\((.*?)\)/).replace("label",U).getRegex(),reflink:h(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",U).getRegex()},J={...re,emStrongRDelimAst:ut,emStrongLDelim:it,delLDelim:gt,delRDelim:mt,url:h(/^emailProtocol|^((?:protocol):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/).replace("emailProtocol",$t).replace("protocol",ce).replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![\w-])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:h(/^(?:[^a-zA-Z0-9](?=emailProtocol)|(`+|~+|[^`~])(?:(?=[`~])|(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},Re=l=>At[l];function w(l,e){if(e){if(x.escapeTest.test(l))return l.replace(x.escapeReplace,Re)}else if(x.escapeTestNoEncode.test(l))return l.replace(x.escapeReplaceNoEncode,Re);return l}function Te(l){return l.replace(x.numericCharacterReference,(e,t,n)=>{let s=t===void 0?Number.parseInt(n,16):Number.parseInt(t,10);return s===0||s>1114111||s>=55296&&s<=57343?"\uFFFD":String.fromCodePoint(s)})}function se(l){try{l=encodeURI(l).replace(x.percentDecode,"%")}catch{return null}return l}function ie(l,e){let t=l.replace(x.findPipe,(r,o,i)=>{let u=!1,a=o;for(;--a>=0&&i[a]==="\\";)u=!u;return u?"|":" |"}),n=t.split(x.splitPipe),s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length=0&&x.blankLine.test(e[t]);)t--;return e.length-t<=2?l:e.slice(0,t+1).join(` +`)}function q(l){return l.trim().toLowerCase().toUpperCase().toLowerCase()}function Oe(l,e){if(l.indexOf(e[1])===-1)return-1;let t=0;for(let n=0;n0?-2:-1}function ae(l,e=0){let t=e,n="";for(let s of l)if(s===" "){let r=4-t%4;n+=" ".repeat(r),t+=r}else n+=s,t++;return n}function we(l,e,t,n,s){let r=e.href,o=e.title||null,i=l[1].replace(s.other.outputLinkReplace,"$1"),u=l[0].charAt(0)==="!";n.state.inLink=!0;let a=n.state.linkEmitted,p=n.state.inRawBlock;n.state.linkEmitted=!1;let c=n.inlineTokens(i),d=n.state.linkEmitted;if(n.state.linkEmitted=a,n.state.inLink=!1,!u){if(d){n.state.inRawBlock=p;return}n.state.linkEmitted=!0}return{type:u?"image":"link",raw:t,href:r,title:o,text:i,tokens:c}}function Et(l,e,t){let n=l.match(t.other.indentCodeCompensation);if(n===null)return e;let s=n[1];return e.split(` +`).map(r=>{let o=r.match(t.other.beginningSpace);if(o===null)return r;let[i]=o;return r.slice(Math.min(i.length,s.length))}).join(` +`)}function ye(l,e,t,n){if(!e.includes("<"))return!1;for(let s=0;se.length-s)return!0;s+=o[0].length-1}}return!1}var P=class{options;rules;lexer;constructor(e){this.options=e||O}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=this.options.pedantic?t[0]:oe(t[0]),s=n.replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:n,codeBlockStyle:"indented",text:s}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],s=Et(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:s}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let s=z(n,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceTabChar.test(s))&&(n=s.trim())}return{type:"heading",raw:z(t[0],` +`),depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:z(t[0],` +`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=z(t[0],` +`).split(` +`),s="",r="",o=[];for(;n.length>0;){let i=!1,u=[],a;for(a=0;a0&&(s=`${s} +${H.join(` +`)}`),r=r.substring(0,r.length-T.text.length)+f.text;break}else if(m?.type==="list"){let T=m,g=T.raw+` +`+n.join(` +`),y=this.list(g);o[o.length-1]=y,s=s.substring(0,s.length-m.raw.length)+y.raw,r=r.substring(0,r.length-T.raw.length)+y.raw,n=g.substring(o.at(-1).raw.length).split(` +`);continue}}return{type:"blockquote",raw:s,tokens:o,text:r}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim(),s=n.length>1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");let o=this.rules.other.listItemRegex(n),i=!1;for(;e;){let a=!1,p="",c="";if(!(t=o.exec(e))||this.rules.block.hr.test(e))break;p=t[0],e=e.substring(p.length);let d=t[2].split(` +`,1)[0],m=t[1].length,T=this.options.pedantic?ae(d,m):d.replace(this.rules.other.leadingSpaceTab,I=>ae(I,m)),g=e.split(` +`,1)[0],y=!T.trim(),f=0;if(this.options.pedantic?(f=2,c=T.trimStart()):y?f=m+1:(f=T.search(this.rules.other.nonSpaceChar),f=f>4?1:f,c=T.slice(f),f+=m),y&&this.rules.other.blankLine.test(g)&&(p+=g+` +`,e=e.substring(g.length+1),a=!0),!a){let I=this.rules.other.nextBulletRegex(f),Z=this.rules.other.hrRegex(f),H=this.rules.other.fencesBeginRegex(f),le=this.rules.other.headingBeginRegex(f),Se=this.rules.other.htmlBeginRegex(f),_e=this.rules.other.blockquoteBeginRegex(f);for(;e;){let K=e.split(` +`,1)[0],G;if(g=K,this.options.pedantic?(g=g.replace(this.rules.other.listReplaceNesting," "),G=g):G=g.replace(this.rules.other.leadingSpaceTab,$e=>$e.replace(this.rules.other.tabCharGlobal," ")),H.test(g)||le.test(g)||Se.test(g)||_e.test(g)||I.test(g)||Z.test(g))break;if(G.search(this.rules.other.nonSpaceChar)>=f||!g.trim())c+=` +`+G.slice(f);else{if(y||T.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||H.test(T)||le.test(T)||Z.test(T))break;c+=` +`+g}y=!g.trim(),p+=K+` +`,e=e.substring(K.length+1),T=G.slice(f)}}r.loose||(i?r.loose=!0:this.rules.other.doubleBlankLine.test(p)&&(i=!0)),r.items.push({type:"list_item",raw:p,task:!!this.options.gfm&&this.rules.other.listIsTask.test(c),loose:!1,text:c,tokens:[]}),r.raw+=p}let u=r.items.at(-1);if(u)u.raw=u.raw.trimEnd(),u.text=u.text.trimEnd();else return;r.raw=r.raw.trimEnd();for(let a of r.items)if(this.lexer.state.top=!1,a.tokens=this.lexer.blockTokens(a.text,[]),!r.loose){let p=a.tokens.filter(d=>d.type==="space"),c=p.length>0&&p.some(d=>this.rules.other.anyLine.test(d.raw));r.loose=c}for(let a of r.items){let p=a.tokens[0];if(a.task&&(p?.type==="text"||p?.type==="paragraph")){a.text=a.text.replace(this.rules.other.listReplaceTask,""),p.raw=p.raw.replace(this.rules.other.listReplaceTask,""),p.text=p.text.replace(this.rules.other.listReplaceTask,"");for(let d=this.lexer.inlineQueue.length-1;d>=0;d--)if(this.rules.other.listIsTask.test(this.lexer.inlineQueue[d].src)){this.lexer.inlineQueue[d].src=this.lexer.inlineQueue[d].src.replace(this.rules.other.listReplaceTask,"");break}let c=this.rules.other.listTaskCheckbox.exec(a.raw);if(c){let d={type:"checkbox",raw:c[0]+" ",checked:c[0]!=="[ ]"};a.checked=d.checked,r.loose?a.tokens[0]&&["paragraph","text"].includes(a.tokens[0].type)&&"tokens"in a.tokens[0]&&a.tokens[0].tokens?(a.tokens[0].raw=d.raw+a.tokens[0].raw,a.tokens[0].text=d.raw+a.tokens[0].text,a.tokens[0].tokens.unshift(d)):a.tokens.unshift({type:"paragraph",raw:d.raw,text:d.raw,tokens:[d]}):a.tokens.unshift(d)}}else a.task&&(a.task=!1)}if(r.loose)for(let a of r.items){a.loose=!0;for(let p of a.tokens)p.type==="text"&&(p.type="paragraph")}return r}}html(e){let t=this.rules.block.html.exec(e);if(t){let n=oe(t[0]);return{type:"html",block:!0,raw:n,pre:t[1]==="pre"||t[1]==="script"||t[1]==="style",text:n}}}def(e){let t=this.rules.block.def.exec(e);if(t){let n=q(t[1]).replace(this.rules.other.multipleSpaceGlobal," "),s=t[2]?t[2].replace(this.rules.other.hrefBrackets,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",r=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:n,raw:z(t[0],` +`),href:s,title:r}}}table(e){let t=this.rules.block.table.exec(e);if(!t||!this.rules.other.tableDelimiter.test(t[2]))return;let n=ie(t[1]),s=t[2].replace(this.rules.other.tableAlignChars,"").split("|"),r=t[3]?.trim()?t[3].replace(this.rules.other.tableRowBlankLine,"").split(` +`):[],o={type:"table",raw:z(t[0],` +`),header:[],align:[],rows:[]};if(n.length===s.length){for(let i of s)this.rules.other.tableAlignRight.test(i)?o.align.push("right"):this.rules.other.tableAlignCenter.test(i)?o.align.push("center"):this.rules.other.tableAlignLeft.test(i)?o.align.push("left"):o.align.push(null);for(let i=0;i({text:u,tokens:this.lexer.inline(u),header:!1,align:o.align[a]})));return o}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t){let n=t[1].trim();return{type:"heading",raw:z(t[0],` +`),depth:t[2].charAt(0)==="="?1:2,text:n,tokens:this.lexer.inline(n)}}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===` +`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[0].charAt(0)==="!"?2:1;if(!this.options.pedantic&&ye(e,t[1],n,this.rules))return;let s=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(s)){if(!this.rules.other.endAngleBracket.test(s))return;let i=z(s.slice(0,-1),"\\");if((s.length-i.length)%2===0)return}else{let i=Oe(t[2],"()");if(i===-2)return;if(i>-1){let a=(t[0].indexOf("!")===0?5:4)+t[1].length+i;t[2]=t[2].substring(0,i),t[0]=t[0].substring(0,a).trim(),t[3]=""}}let r=t[2],o="";if(this.options.pedantic){let i=this.rules.other.pedanticHrefTitle.exec(r);i&&(r=i[1],o=i[3])}else o=t[3]?t[3].slice(1,-1):"";return r=r.trim(),this.rules.other.startAngleBracket.test(r)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(s)?r=r.slice(1):r=r.slice(1,-1)),we(t,{href:r&&r.replace(this.rules.inline.anyPunctuation,"$1"),title:o&&o.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let s=n[0].charAt(0)==="!"?2:1;if(!this.options.pedantic&&ye(e,n[1],s,this.rules))return;let r=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),o=t[q(r)];if(!o){let i=n[0].charAt(0);return{type:"text",raw:i,text:i}}return we(n,o,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s||!s[1]&&!s[2]&&!s[3]&&!s[4]||s[4]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[3]||"")||!n||this.rules.inline.punctuation.exec(n)){let o=[...s[0]].length-1,i,u,a=o,p=0,c=s[0][0],d=n===c,m=c==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(m.lastIndex=0,t=t.slice(-1*e.length+o);(s=m.exec(t))!==null;){if(i=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!i)continue;if(u=[...i].length,s[3]||s[4]){a+=u;continue}else if(s[5]||s[6]){if(o%3&&!((o+u)%3)){p+=u;continue}if(d)break}if(a-=u,a>0)continue;u=Math.min(u,u+a+p);let T=[...s[0]][0].length,g=e.slice(0,o+s.index+T+u);if(Math.min(o,u)%2){let f=g.slice(1,-1);return{type:"em",raw:g,text:f,tokens:this.lexer.inlineTokens(f)}}let y=g.slice(2,-2);return{type:"strong",raw:g,text:y,tokens:this.lexer.inlineTokens(y)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),s=this.rules.other.nonSpaceChar.test(n),r=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return s&&r&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e,t,n=""){let s=this.rules.inline.delLDelim.exec(e);if(!s)return;if(!(s[1]||"")||!n||this.rules.inline.punctuation.exec(n)){let o=[...s[0]].length-1,i,u,a=o,p=this.rules.inline.delRDelim;for(p.lastIndex=0,t=t.slice(-1*e.length+o);(s=p.exec(t))!==null;){if(i=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!i||(u=[...i].length,u!==o))continue;if(s[3]||s[4]){a+=u;continue}if(a-=u,a>0)continue;u=Math.min(u,u+a);let c=[...s[0]][0].length,d=e.slice(0,o+s.index+c+u),m=d.slice(o,-o);return{type:"del",raw:d,text:m,tokens:this.lexer.inlineTokens(m)}}}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,s;return t[2]==="@"?(n=t[1],s="mailto:"+n):(n=t[1],s=n),{type:"link",raw:t[0],text:n,href:s,autolink:!0,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,s;if(t[2]==="@")n=t[0],s="mailto:"+n;else{let r;do r=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(r!==t[0]);n=t[0],t[1]==="www."?s="http://"+t[0]:s=t[0]}return{type:"link",raw:t[0],text:n,href:s,autolink:!0,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:n?t[0]:Te(t[0]),escaped:n}}}};var b=class l{tokens;options;state;inlineQueue;tokenizer;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||O,this.options.tokenizer=this.options.tokenizer||new P,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,linkEmitted:!1,top:!0};let t={other:x,block:F.normal,inline:D.normal};this.options.pedantic?(t.block=F.pedantic,t.inline=D.pedantic):this.options.gfm&&(t.block=F.gfm,this.options.breaks?t.inline=D.breaks:t.inline=D.gfm),this.tokenizer.rules=t}static get rules(){return{block:F,inline:D}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(x.carriageReturn,` +`),this.blockTokens(e,this.tokens);for(let t=0;t(r=i.call({lexer:this},e,t))?(e=e.substring(r.raw.length),t.push(r),!0):!1))continue;if(r=this.tokenizer.space(e)){e=e.substring(r.raw.length);let i=t.at(-1);r.raw.length===1&&i!==void 0?i.raw+=` +`:t.push(r);continue}if(r=this.tokenizer.code(e)){e=e.substring(r.raw.length);let i=t.at(-1);i?.type==="paragraph"||i?.type==="text"?(i.raw+=(i.raw.endsWith(` +`)?"":` +`)+r.raw,i.text+=` +`+r.text,this.inlineQueue.at(-1).src=i.text):t.push(r);continue}if(r=this.tokenizer.fences(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.heading(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.hr(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.blockquote(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.list(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.html(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.def(e)){e=e.substring(r.raw.length);let i=t.at(-1);i?.type==="paragraph"||i?.type==="text"?(i.raw+=(i.raw.endsWith(` +`)?"":` +`)+r.raw,i.text+=` +`+r.raw,this.inlineQueue.at(-1).src=i.text):this.tokens.links[r.tag]||(this.tokens.links[r.tag]={href:r.href,title:r.title},t.push(r));continue}if(r=this.tokenizer.table(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.lheading(e)){e=e.substring(r.raw.length),t.push(r);continue}let o=e;if(this.options.extensions?.startBlock){let i=1/0,u=e.slice(1),a;this.options.extensions.startBlock.forEach(p=>{a=p.call({lexer:this},u),typeof a=="number"&&a>=0&&(i=Math.min(i,a))}),i<1/0&&i>=0&&(o=e.substring(0,i+1))}if(this.state.top&&(r=this.tokenizer.paragraph(o))){let i=t.at(-1);n&&i?.type==="paragraph"?(i.raw+=(i.raw.endsWith(` +`)?"":` +`)+r.raw,i.text+=` +`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=i.text):t.push(r),n=o.length!==e.length,e=e.substring(r.raw.length);continue}if(r=this.tokenizer.text(e)){e=e.substring(r.raw.length);let i=t.at(-1);i?.type==="text"?(i.raw+=(i.raw.endsWith(` +`)?"":` +`)+r.raw,i.text+=` +`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=i.text):t.push(r);continue}if(e){this.infiniteLoopError(e.charCodeAt(0));break}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}linkInText(e){if(!e.includes("["))return!1;let t=this.tokenizer.rules.inline.link;for(let n of e.matchAll(this.tokenizer.rules.inline.blockSkip))if(t.test(n[0])&&e.charAt(n.index-1)!=="!")return!0;for(let n of e.matchAll(this.tokenizer.rules.inline.reflinkSearch)){let s=n[0],r=s.lastIndexOf("[");if(!(s.charAt(0)==="!"||!Object.hasOwn(this.tokens.links,q(s.slice(r+1,-1))))&&!(r>1&&this.linkInText(s.slice(1,r-1))))return!0}return!1}inlineTokens(e,t=[]){this.tokenizer.lexer=this;let n=e;if(this.tokens.links&&e.includes("[")){let i=this.tokenizer.rules.inline.reflinkSearch,u=a=>{let p=a.lastIndexOf("[");if(!Object.hasOwn(this.tokens.links,q(a.slice(p+1,-1))))return a;if(p>1&&a.charAt(0)!=="!"){let c=a.slice(1,p-1);if(this.linkInText(c))return"["+c.replace(i,u)+"]["+"a".repeat(a.length-p-2)+"]"}return"["+"a".repeat(a.length-2)+"]"};n=n.replace(i,u)}n=n.replace(this.tokenizer.rules.inline.anyPunctuation,i=>"+".repeat(i.length)),n=n.replace(this.tokenizer.rules.inline.blockSkip,(i,u,a)=>{let p=a?a.length:0;return i.slice(0,p)+"["+"a".repeat(i.length-p-2)+"]"}),n=this.options.hooks?.emStrongMask?.call({lexer:this},n)??n;let s=!1,r="",o=1/0;for(;e;){if(e.length(i=a.call({lexer:this},e,t))?(e=e.substring(i.raw.length),t.push(i),!0):!1))continue;if(i=this.tokenizer.escape(e)){e=e.substring(i.raw.length),t.push(i);continue}if(i=this.tokenizer.tag(e)){e=e.substring(i.raw.length),t.push(i);continue}if(i=this.tokenizer.link(e)){e=e.substring(i.raw.length),t.push(i);continue}if(i=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(i.raw.length);let a=t.at(-1);i.type==="text"&&a?.type==="text"?(a.raw+=i.raw,a.text+=i.text):t.push(i);continue}if(i=this.tokenizer.emStrong(e,n,r)){e=e.substring(i.raw.length),t.push(i);continue}if(i=this.tokenizer.codespan(e)){e=e.substring(i.raw.length),t.push(i);continue}if(i=this.tokenizer.br(e)){e=e.substring(i.raw.length),t.push(i);continue}if(i=this.tokenizer.del(e,n,r)){e=e.substring(i.raw.length),t.push(i);continue}if(i=this.tokenizer.autolink(e)){e=e.substring(i.raw.length),t.push(i);continue}if(!this.state.inLink&&(i=this.tokenizer.url(e))){e=e.substring(i.raw.length),t.push(i);continue}let u=e;if(this.options.extensions?.startInline){let a=1/0,p=e.slice(1),c;this.options.extensions.startInline.forEach(d=>{c=d.call({lexer:this},p),typeof c=="number"&&c>=0&&(a=Math.min(a,c))}),a<1/0&&a>=0&&(u=e.substring(0,a+1))}if(i=this.tokenizer.inlineText(u)){e=e.substring(i.raw.length),i.raw.slice(-1)!=="_"&&(r=i.raw.slice(-1)),s=!0;let a=t.at(-1);a?.type==="text"?(a.raw+=i.raw,a.text+=i.text):t.push(i);continue}if(e){this.infiniteLoopError(e.charCodeAt(0));break}}return t}infiniteLoopError(e){let t="Infinite loop on byte: "+e;if(this.options.silent)console.error(t);else throw new Error(t)}};var S=class{options;parser;constructor(e){this.options=e||O}space(e){return""}code({text:e,lang:t,escaped:n}){let s=(t||"").match(x.notSpaceStart)?.[0],r=e?e.replace(x.endingNewline,"")+` +`:"";return s?'
    '+(n?r:w(r,!0))+`
    +`:"
    "+(n?r:w(r,!0))+`
    +`}blockquote({tokens:e}){return`
    +${this.parser.parse(e)}
    +`}html({text:e}){return e}def(e){return""}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)} +`}hr(e){return`
    +`}list(e){let t=e.ordered,n=e.start,s="";for(let i=0;i +`+s+" +`}listitem(e){return`
  • ${this.parser.parse(e.tokens)}
  • +`}checkbox({checked:e}){return" '}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    +`}table(e){let t="",n="";for(let r=0;r${s}`),` + +`+t+` +`+s+`
    +`}tablerow({text:e}){return` +${e} +`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+` +`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${w(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,text:n,tokens:s,autolink:r}){let o=r?w(n,!0):this.parser.parseInline(s),i=se(e);if(i===null)return o;e=w(i,r);let u='
    ",u}image({href:e,title:t,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));let r=se(e);if(r===null)return w(n);e=r;let o=`${w(n)}{let i=r[o].flat(1/0);n=n.concat(this.walkTokens(i,t))}):r.tokens&&(n=n.concat(this.walkTokens(r.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(r=>{if(!r.name)throw new Error("extension name required");if("renderer"in r){let o=t.renderers[r.name];o?t.renderers[r.name]=function(...i){let u=r.renderer.apply(this,i);return u===!1&&(u=o.apply(this,i)),u}:t.renderers[r.name]=r.renderer}if("tokenizer"in r){if(!r.level||r.level!=="block"&&r.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let o=t[r.level];o?o.unshift(r.tokenizer):t[r.level]=[r.tokenizer],r.start&&(r.level==="block"?t.startBlock?t.startBlock.push(r.start):t.startBlock=[r.start]:r.level==="inline"&&(t.startInline?t.startInline.push(r.start):t.startInline=[r.start]))}"childTokens"in r&&r.childTokens&&(t.childTokens[r.name]=r.childTokens)}),s.extensions=t),n.renderer){let r=this.defaults.renderer||new S(this.defaults);for(let o in n.renderer){if(!(o in r))throw new Error(`renderer '${o}' does not exist`);if(["options","parser"].includes(o))continue;let i=o,u=n.renderer[i],a=r[i];r[i]=(...p)=>{let c=u.apply(r,p);return c===!1&&(c=a.apply(r,p)),c||""}}s.renderer=r}if(n.tokenizer){let r=this.defaults.tokenizer||new P(this.defaults);for(let o in n.tokenizer){if(!(o in r))throw new Error(`tokenizer '${o}' does not exist`);if(["options","rules","lexer"].includes(o))continue;let i=o,u=n.tokenizer[i],a=r[i];r[i]=(...p)=>{let c=u.apply(r,p);return c===!1&&(c=a.apply(r,p)),c}}s.tokenizer=r}if(n.hooks){let r=this.defaults.hooks||new _;for(let o in n.hooks){if(!(o in r))throw new Error(`hook '${o}' does not exist`);if(["options","block"].includes(o))continue;let i=o,u=n.hooks[i],a=r[i];_.passThroughHooks.has(o)?r[i]=p=>{if(this.defaults.async&&_.passThroughHooksRespectAsync.has(o))return(async()=>{let d=await u.call(r,p);return a.call(r,d)})();let c=u.call(r,p);return a.call(r,c)}:r[i]=(...p)=>{if(this.defaults.async)return(async()=>{let d=await u.apply(r,p);return d===!1&&(d=await a.apply(r,p)),d})();let c=u.apply(r,p);return c===!1&&(c=a.apply(r,p)),c}}s.hooks=r}if(n.walkTokens){let r=this.defaults.walkTokens,o=n.walkTokens;s.walkTokens=function(i){let u=[];return u.push(o.call(this,i)),r&&(u=u.concat(r.call(this,i))),u}}this.defaults={...this.defaults,...s}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return b.lex(e,t??this.defaults)}parser(e,t){return R.parse(e,t??this.defaults)}parseMarkdown(e){return(n,s)=>{let r={...s},o={...this.defaults,...r},i=this.onError(!!o.silent,!!o.async);if(this.defaults.async===!0&&r.async===!1)return i(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return i(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return i(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));if(o.hooks&&(o.hooks.options=o,o.hooks.block=e),o.async)return(async()=>{let u=o.hooks?await o.hooks.preprocess(n):n,p=await(o.hooks?await o.hooks.provideLexer(e):e?b.lex:b.lexInline)(u,o),c=o.hooks?await o.hooks.processAllTokens(p):p;o.walkTokens&&await Promise.all(this.walkTokens(c,o.walkTokens));let m=await(o.hooks?await o.hooks.provideParser(e):e?R.parse:R.parseInline)(c,o);return o.hooks?await o.hooks.postprocess(m):m})().catch(i);try{o.hooks&&(n=o.hooks.preprocess(n));let a=(o.hooks?o.hooks.provideLexer(e):e?b.lex:b.lexInline)(n,o);o.hooks&&(a=o.hooks.processAllTokens(a)),o.walkTokens&&this.walkTokens(a,o.walkTokens);let c=(o.hooks?o.hooks.provideParser(e):e?R.parse:R.parseInline)(a,o);return o.hooks&&(c=o.hooks.postprocess(c)),c}catch(u){return i(u)}}}onError(e,t){return n=>{if(n.message+=` +Please report this to https://github.com/markedjs/marked.`,e){let s="

    An error occurred:

    "+w(n.message+"",!0)+"
    ";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}};var M=new v;function k(l,e){return M.parse(l,e)}k.options=k.setOptions=function(l){return M.setOptions(l),k.defaults=M.defaults,X(k.defaults),k};k.getDefaults=A;k.defaults=O;function Pe(...l){return M.use(...l),k.defaults=M.defaults,X(k.defaults),k}k.use=Pe;k.walkTokens=function(l,e){return M.walkTokens(l,e)};k.parseInline=M.parseInline;k.Parser=R;k.parser=R.parse;k.Renderer=S;k.TextRenderer=$;k.Lexer=b;k.lexer=b.lex;k.Tokenizer=P;k.Hooks=_;k.parse=k;var Mt=k.options,It=k.setOptions,Ct=k.walkTokens,Bt=k.parseInline,Dt=k,qt=R.parse,vt=b.lex; + +if(__exports != exports)module.exports = exports;return module.exports})); +//# sourceMappingURL=marked.umd.js.map diff --git a/src/main/ts/jcef/core/markdown.ts b/src/main/ts/jcef/core/markdown.ts index 51182e16..a7c9dcdf 100644 --- a/src/main/ts/jcef/core/markdown.ts +++ b/src/main/ts/jcef/core/markdown.ts @@ -8,89 +8,154 @@ const HOST_LINKS = /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|jb):|data:image\/|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))/i; - CC.markdown = function (text: unknown, opts?: MarkdownOptions): string { - if (text === null || text === undefined) return ''; - const src = String(text); + let parser: MarkedParser | null = null; + + function toHtml(src: string): string { + const md = window.marked; + if (!md) return CC.escape(src); + if (!parser) parser = new md.Marked({ breaks: true, gfm: true, async: false }); + return parser.parse(src) as string; + } + + function textFragment(src: string): DocumentFragment { + const frag = document.createDocumentFragment(); + frag.appendChild(document.createTextNode(src)); + return frag; + } + + function sanitizedFragment(src: string, opts?: MarkdownOptions): DocumentFragment { let raw: string; try { - const mdOpts = { breaks: true, gfm: true }; - const md = window.marked; - raw = md ? (typeof md.parse === 'function' ? md.parse(src, mdOpts) : md(src, mdOpts)) : CC.escape(src); + raw = toHtml(src); } catch (e) { raw = CC.escape(src); } - - let clean: string; try { const purify = window.DOMPurify; - clean = purify - ? purify.sanitize(raw, { - ADD_ATTR: ['target'], - FORBID_ATTR: ['style'], - ALLOWED_URI_REGEXP: opts && opts.hostLinks ? HOST_LINKS : WEB_LINKS, - }) - : CC.escape(src); - } catch (e2) { - clean = CC.escape(src); - } + if (purify) { + return purify.sanitize(raw, { + ADD_ATTR: ['target'], + FORBID_ATTR: ['style'], + ALLOWED_URI_REGEXP: opts && opts.hostLinks ? HOST_LINKS : WEB_LINKS, + RETURN_DOM_FRAGMENT: true, + }); + } + } catch (e2) {} + return textFragment(src); + } + CC.markdownFragment = function (text: unknown, opts?: MarkdownOptions): DocumentFragment { + if (text === null || text === undefined) return document.createDocumentFragment(); + const frag = sanitizedFragment(String(text), opts); try { - const holder = document.createElement('div'); - holder.innerHTML = clean; - decorateCodeBlocks(holder); - return holder.innerHTML; - } catch (e3) { - return clean; - } + const blocks = frag.querySelectorAll('pre > code'); + for (let i = 0; i < blocks.length; i++) decorateOneCodeBlock(blocks[i], !!(opts && opts.streaming)); + } catch (e) {} + return frag; }; - function decorateCodeBlocks(root: Element | null): void { - if (!root) return; - const blocks = root.querySelectorAll('pre > code'); - for (let i = 0; i < blocks.length; i++) { - decorateOneCodeBlock(blocks[i]); - } - } - - function decorateOneCodeBlock(code: Element): void { - const pre = code && (code.parentNode as Element | null); - if (!pre || pre.getAttribute('data-cc-decorated') === '1') return; - pre.setAttribute('data-cc-decorated', '1'); + CC.markdown = function (text: unknown, opts?: MarkdownOptions): string { + const holder = document.createElement('div'); + holder.appendChild(CC.markdownFragment(text, opts)); + return holder.innerHTML; + }; - let lang = ''; + function languageOf(code: Element): string { const cls = (code.className || '').split(/\s+/); for (let c = 0; c < cls.length; c++) { - if (cls[c].indexOf('language-') === 0) { - lang = cls[c].slice('language-'.length); - break; - } + if (cls[c].indexOf('language-') === 0) return cls[c].slice('language-'.length); } + return ''; + } + function codeHead(lang: string): HTMLElement { const head = document.createElement('div'); head.className = 'code-head'; - const label = document.createElement('span'); label.className = 'code-lang'; label.textContent = lang || 'text'; head.appendChild(label); - const copy = document.createElement('span'); copy.className = 'copy'; copy.setAttribute('role', 'button'); copy.setAttribute('tabindex', '0'); copy.textContent = 'Copy'; head.appendChild(copy); + return head; + } + + function decorateOneCodeBlock(code: Element, plain?: boolean): void { + const pre = code && (code.parentNode as Element | null); + if (!pre) return; + const lang = languageOf(code); + if (pre.getAttribute('data-cc-decorated') !== '1') { + pre.setAttribute('data-cc-decorated', '1'); + pre.insertBefore(codeHead(lang), code); + } + if (plain) return; + const html = CC.highlight(code.textContent || '', lang); + if (html !== null) code.innerHTML = html; + } + CC.decorateOneCodeBlock = decorateOneCodeBlock; - pre.insertBefore(head, code); + const CACHE_CHARS = 4000000; + const highlighted = new Map(); + let cachedChars = 0; + function remember(key: string, html: string): void { + const size = key.length + html.length; + if (size > CACHE_CHARS / 8) return; + while (cachedChars + size > CACHE_CHARS && highlighted.size) { + const oldest = highlighted.keys().next().value as string; + cachedChars -= oldest.length + (highlighted.get(oldest) || '').length; + highlighted.delete(oldest); + } + highlighted.set(key, html); + cachedChars += size; + } + + CC.highlight = function (text: string, lang: string | null | undefined): string | null { + const hl = window.hljs; + if (!lang || !hl || typeof hl.getLanguage !== 'function' || !hl.getLanguage(lang)) return null; + const key = lang + '\u0000' + text; + const hit = highlighted.get(key); + if (hit !== undefined) return hit; try { - const highlighter = window.hljs; - if (highlighter && typeof highlighter.highlightElement === 'function') { - highlighter.highlightElement(code); + const html = hl.highlight(text, { language: lang, ignoreIllegals: true }).value; + remember(key, html); + return html; + } catch (e) { + return null; + } + }; + + const HTML_TOKEN = /]*>|<\/span>|[^<]+/g; + + CC.highlightLines = function (html: string): string[] { + const lines: string[] = []; + const open: string[] = []; + let line = ''; + const tokens = html.match(HTML_TOKEN) || []; + for (let t = 0; t < tokens.length; t++) { + const token = tokens[t]; + if (token.charAt(0) === '<') { + if (token.charAt(1) === '/') open.pop(); + else open.push(token); + line += token; + continue; } - } catch (e) {} - } - CC.decorateOneCodeBlock = decorateOneCodeBlock; + const parts = token.split('\n'); + for (let p = 0; p < parts.length; p++) { + if (p > 0) { + lines.push(line + '
    '.repeat(open.length)); + line = open.join(''); + } + line += parts[p]; + } + } + lines.push(line + ''.repeat(open.length)); + return lines; + }; const EXT_LANG: Record = { kt: 'kotlin', diff --git a/src/main/ts/jcef/types/core.d.ts b/src/main/ts/jcef/types/core.d.ts index 6f1603cd..c538a14e 100644 --- a/src/main/ts/jcef/types/core.d.ts +++ b/src/main/ts/jcef/types/core.d.ts @@ -1,24 +1,26 @@ +interface MarkedParser { + parse(markdown: string): string | Promise; +} + interface MarkedApi { - (markdown: string, options?: object): string; - parse(markdown: string, options?: object): string; - use(...extensions: object[]): void; + Marked: new (options?: object) => MarkedParser; } interface DomPurifyApi { + sanitize(dirty: string, config: { RETURN_DOM_FRAGMENT: true; [key: string]: unknown }): DocumentFragment; sanitize(dirty: string, config?: object): string; - addHook(name: string, hook: (node: Element, data?: unknown) => void): void; } interface HighlightApi { highlight(code: string, options: { language: string; ignoreIllegals?: boolean }): { value: string }; getLanguage(name: string): unknown; - highlightElement(element: Element): void; } type CcMethod = (payload?: unknown) => void; interface MarkdownOptions { hostLinks?: boolean; + streaming?: boolean; } interface CcHost { @@ -117,7 +119,11 @@ interface CcShared { selfCheck(): void; diagnostics(): void; markdown(text: unknown, opts?: MarkdownOptions): string; - decorateOneCodeBlock(code: Element): void; + markdownFragment(text: unknown, opts?: MarkdownOptions): DocumentFragment; + decorateOneCodeBlock(code: Element, plain?: boolean): void; + highlight(text: string, lang: string | null | undefined): string | null; + highlightLines(html: string): string[]; + reportError(what: string, error: unknown): void; languageForPath(path: unknown): string | null; diagramLabel(kind: string | null | undefined, depth: number, label: unknown): string; diagramShown(kind: string | null | undefined, depth: number, label: unknown): string; diff --git a/src/test/frontend/chat/markdown-fragment.test.js b/src/test/frontend/chat/markdown-fragment.test.js new file mode 100644 index 00000000..dc94f0d4 --- /dev/null +++ b/src/test/frontend/chat/markdown-fragment.test.js @@ -0,0 +1,86 @@ +const { loadFrontend } = require('../helpers/load'); + +describe('markdown renders to a sanitised fragment', () => { + it('returns a DocumentFragment with the handler stripped', () => { + const win = loadFrontend(['app-transcript.js']); + const frag = win.CC.markdownFragment(' **hi**'); + expect(frag.nodeType).toBe(11); + const host = win.document.createElement('div'); + host.appendChild(frag); + expect(host.querySelector('img').getAttribute('onerror')).toBeNull(); + expect(host.querySelector('strong').textContent).toBe('hi'); + }); + + it('falls back to text when the sanitiser is missing', () => { + const win = loadFrontend(['app-transcript.js']); + delete win.DOMPurify; + const host = win.document.createElement('div'); + host.appendChild(win.CC.markdownFragment('')); + expect(host.querySelector('img')).toBeNull(); + expect(host.textContent).toBe(''); + }); + + it('parses with the marked 18 instance API', () => { + const win = loadFrontend(['app-transcript.js']); + expect(typeof win.marked.Marked).toBe('function'); + const out = win.CC.markdown('- one\n- two\n\n| a | b |\n|---|---|\n| 1 | 2 |'); + expect(out).toContain('
  • one
  • '); + expect(out).toContain(''); + }); +}); + +describe('code highlighting', () => { + it('leaves an unlabelled block as plain text instead of guessing a language', () => { + const win = loadFrontend(['app-transcript.js']); + const out = win.CC.markdown('```\nconst x = 1;\n```'); + expect(out).not.toMatch(/hljs-/); + expect(out).toContain('code-head'); + }); + + it('highlights a labelled block', () => { + const win = loadFrontend(['app-transcript.js']); + expect(win.CC.markdown('```js\nconst x = 1;\n```')).toMatch(/hljs-keyword/); + }); + + it('skips highlighting while streaming but still adds the chrome', () => { + const win = loadFrontend(['app-transcript.js']); + const out = win.CC.markdown('```js\nconst x = 1;\n```', { streaming: true }); + expect(out).not.toMatch(/hljs-/); + expect(out).toContain('code-head'); + }); + + it('re-highlights a decorated block whose text changed', () => { + const win = loadFrontend(['app-transcript.js']); + const pre = win.document.createElement('pre'); + const code = win.document.createElement('code'); + code.className = 'language-js'; + code.textContent = 'x'; + pre.appendChild(code); + win.CC.decorateOneCodeBlock(code); + code.textContent = 'const y = 2;'; + win.CC.decorateOneCodeBlock(code); + expect(code.querySelector('.hljs-keyword')).not.toBeNull(); + expect(pre.querySelectorAll('.code-head').length).toBe(1); + }); + + it('returns the same markup for the same text from the cache', () => { + const win = loadFrontend(['app-transcript.js']); + const spy = vi.spyOn(win.hljs, 'highlight'); + const first = win.CC.highlight('val a = 1', 'kotlin'); + const second = win.CC.highlight('val a = 1', 'kotlin'); + expect(second).toBe(first); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('answers null for an unknown language', () => { + const win = loadFrontend(['app-transcript.js']); + expect(win.CC.highlight('x', 'no-such-language')).toBeNull(); + expect(win.CC.highlight('x', null)).toBeNull(); + }); + + it('splits highlighted markup into balanced lines', () => { + const win = loadFrontend(['app-transcript.js']); + const lines = win.CC.highlightLines('/* a\nb */ x'); + expect(lines).toEqual(['/* a', 'b */ x']); + }); +}); From 3115862b789e7bd50473d1689bb9bc35d0d9bbe5 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:47:19 +0200 Subject: [PATCH 009/155] docs: record kotlinx-serialization 1.9.0 and its NOTICE in the third-party notices --- THIRD-PARTY-NOTICES.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index efa2babf..fac8a9c5 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -34,9 +34,9 @@ The inventory is **complete against the artifact, not against the source tree**: carries the vendored web assets), the two kotlinx.serialization jars listed below, and the generated `searchableOptions` jar. Everything with a third-party license in that list has an entry here. -Last verified: 2026-08-11, against the upstream `LICENSE` files at the pinned tags +Last verified: 2026-09-23, against the upstream `LICENSE` files at the pinned tags (`markedjs/marked@v18.0.14`, `cure53/DOMPurify@3.4.16`, `highlightjs/highlight.js@11.12.0`, -`Kotlin/kotlinx.serialization@v1.7.3`). +`Kotlin/kotlinx.serialization@v1.9.0`). --- @@ -106,17 +106,19 @@ intact and no edit of our own. ## Shipped as separate jars in `lib/` -### kotlinx.serialization (`kotlinx-serialization-core-jvm`, `kotlinx-serialization-json-jvm`) — 1.7.3 +### kotlinx.serialization (`kotlinx-serialization-core-jvm`, `kotlinx-serialization-json-jvm`) — 1.9.0 - **License:** Apache-2.0 (`SPDX-License-Identifier: Apache-2.0`) -- **Copyright:** Copyright 2017-2024 JetBrains s.r.o. +- **Copyright:** Copyright 2017-2025 JetBrains s.r.o. - **Project:** https://github.com/Kotlin/kotlinx.serialization - **Full text:** `LICENSES/Apache-2.0.txt` - **Verified:** the published jars carry no `META-INF/LICENSE` **and no `META-INF/NOTICE`** (checked - in `kotlinx-serialization-core-jvm-1.7.3.jar` and `-json-jvm-1.7.3.jar`), so the license was read - from the project's `LICENSE.txt` at tag `v1.7.3` rather than inferred from the artifact. That file + in `kotlinx-serialization-core-jvm-1.9.0.jar` and `-json-jvm-1.9.0.jar`), so the license was read + from the project's `LICENSE.txt` at tag `v1.9.0` rather than inferred from the artifact. That file is the bare Apache-2.0 text with no copyright line appended; the copyright above is the one the - project's own source headers carry (`Copyright 2017- JetBrains s.r.o.`, latest year 2024). - The repository publishes **no `NOTICE` file**, so Apache-2.0 §4(d) adds no obligation here. + project's own source headers carry (`Copyright 2017- JetBrains s.r.o.`, latest year 2025). +- **NOTICE (Apache-2.0 §4(d)):** the repository carries `license/NOTICE.txt` at tag `v1.9.0`, which + reads, verbatim: *"kotlinx.serialization library. Copyright 2017-2019 JetBrains s.r.o and + respective authors and developers"*. It is reproduced here because the jars do not carry it. --- From 0cb4e71810ad34d5b17c52e2a77c9c072b8474f3 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:48:27 +0200 Subject: [PATCH 010/155] fix(credentials): keep a renewed credential when the keyring refuses the write --- .../process/credentials/CredentialsVault.kt | 79 +++++++++++++------ .../CredentialsVaultRenewalHeadlessTest.kt | 78 ++++++++++++++++++ 2 files changed, 134 insertions(+), 23 deletions(-) create mode 100644 src/test/kotlin/dev/lain/claudejb/headless/CredentialsVaultRenewalHeadlessTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/process/credentials/CredentialsVault.kt b/src/main/kotlin/dev/lain/claudejb/controller/process/credentials/CredentialsVault.kt index c7bbd323..a19be661 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/process/credentials/CredentialsVault.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/process/credentials/CredentialsVault.kt @@ -84,16 +84,22 @@ object CredentialsVault { fun hasUsableToken(): Boolean = usableToken() != null - fun canRenew(): Boolean { - if (System.currentTimeMillis() < renewBlockedUntil) return false - val oauth = oauthNode() ?: return false + fun canRenew(): Boolean = !renewBlocked() && renewable(oauthNode()) + + private fun renewBlocked(): Boolean = System.currentTimeMillis() < renewBlockedUntil + + private fun renewable(oauth: kotlinx.serialization.json.JsonObject?): Boolean { + if (oauth == null) return false if (oauth.string("refreshToken") == null) return false if (oauth.strings("scopes").isNullOrEmpty()) return false val expiresAt = oauth["refreshTokenExpiresAt"]?.jsonPrimitive?.longOrNull ?: return true return expiresAt - System.currentTimeMillis() > EXPIRY_MARGIN_MS } - fun needsRenewal(): Boolean = usableToken() == null && canRenew() + fun needsRenewal(): Boolean { + val oauth = oauthNode() + return (oauth == null || usableToken(oauth) == null) && !renewBlocked() && renewable(oauth) + } fun renew(binary: File, baseEnv: Map): Boolean = renewOnDisk(binary, baseEnv) @@ -118,15 +124,12 @@ object CredentialsVault { } renewingOnDisk = true val renewed = try { - if (!plant(file, blob)) { - false - } else { - AuthCli.refreshUsingOwnFiles(binary, refreshEnv(baseEnv)) - AccountProfile.capture() - harvestNow() && hasUsableToken() + when (plant(file, blob)) { + Planting.FOREIGN -> false + Planting.FAILED -> false.also { wipe(file) } + Planting.PLANTED -> refreshPlanted(binary, baseEnv, file, blob) } } finally { - wipe(file) renewingOnDisk = false } if (!renewed) log.warn("the on-disk credential refresh did not produce a usable token") @@ -134,19 +137,49 @@ object CredentialsVault { return renewed } - private fun plant(file: File, blob: String): Boolean = runCatching { - file.parentFile?.mkdirs() - if (!file.createNewFile()) return false - val ownerOnly = file.setReadable(false, false) && file.setWritable(false, false) && - file.setReadable(true, true) && file.setWritable(true, true) - if (!ownerOnly) { - log.warn("could not make the credentials file owner-only; refusing to write a credential to it") - file.delete() - return false + internal enum class Planting { FOREIGN, FAILED, PLANTED } + + private fun refreshPlanted(binary: File, baseEnv: Map, file: File, blob: String): Boolean { + try { + AuthCli.refreshUsingOwnFiles(binary, refreshEnv(baseEnv)) + AccountProfile.capture() + return harvestNow() && hasUsableToken() + } finally { + settlePlanted(file, blob) + } + } + + internal fun settlePlanted(file: File, planted: String) { + if (!file.isFile) return + val now = runCatching { file.readText() }.getOrNull() + if (now != null && (now.isBlank() || now == planted)) { + wipe(file) + } else { + log.warn("the renewed credential stays on disk until the password safe accepts it") } - file.writeText(blob) - true - }.onFailure { log.warn("could not plant the credentials file for a refresh", it) }.getOrDefault(false) + } + + internal fun plant(file: File, blob: String): Planting { + val created = runCatching { + file.parentFile?.mkdirs() + file.createNewFile() + }.onFailure { log.warn("could not create the credentials file for a refresh", it) }.getOrDefault(false) + if (!created) { + log.info("another writer holds the credentials file; leaving it untouched") + return Planting.FOREIGN + } + return runCatching { + val ownerOnly = file.setReadable(false, false) && file.setWritable(false, false) && + file.setReadable(true, true) && file.setWritable(true, true) + if (!ownerOnly) { + log.warn("could not make the credentials file owner-only; refusing to write a credential to it") + file.delete() + return Planting.FAILED + } + file.writeText(blob) + Planting.PLANTED + }.onFailure { log.warn("could not plant the credentials file for a refresh", it) }.getOrDefault(Planting.FAILED) + } private fun wipe(file: File) { if (!file.isFile) return diff --git a/src/test/kotlin/dev/lain/claudejb/headless/CredentialsVaultRenewalHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/headless/CredentialsVaultRenewalHeadlessTest.kt new file mode 100644 index 00000000..2f6bf770 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/headless/CredentialsVaultRenewalHeadlessTest.kt @@ -0,0 +1,78 @@ +package dev.lain.claudejb.headless + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import dev.lain.claudejb.controller.process.credentials.CredentialsVault +import dev.lain.claudejb.model.settings.SecretStore +import java.io.File +import java.nio.file.Files + +class CredentialsVaultRenewalHeadlessTest : BasePlatformTestCase() { + + private val file get() = CredentialsVault.credentialsFile() + private lateinit var home: File + + override fun setUp() { + super.setUp() + home = Files.createTempDirectory("claudejb-renew").toFile() + CredentialsVault.homeOverride = home + SecretStore.storeOverride = mutableMapOf() + } + + override fun tearDown() { + try { + SecretStore.storeOverride = null + CredentialsVault.homeOverride = null + home.deleteRecursively() + } finally { + super.tearDown() + } + } + + private class RefusingStore(seed: Map) : HashMap(seed) { + override fun put(key: String, value: String): String? = get(key) + } + + fun `test planting never takes over a file another writer created`() { + file.parentFile?.mkdirs() + file.writeText("someone else's credential") + + assertEquals(CredentialsVault.Planting.FOREIGN, CredentialsVault.plant(file, "planted")) + CredentialsVault.settlePlanted(file, "planted") + assertEquals("someone else's credential", file.readText()) + } + + fun `test a planted file holds the blob`() { + assertEquals(CredentialsVault.Planting.PLANTED, CredentialsVault.plant(file, "planted")) + assertEquals("planted", file.readText()) + } + + fun `test an unrotated planted file is wiped`() { + assertEquals(CredentialsVault.Planting.PLANTED, CredentialsVault.plant(file, "planted")) + CredentialsVault.settlePlanted(file, "planted") + assertFalse(file.exists()) + } + + fun `test a rotated credential the safe did not take stays on disk`() { + SecretStore.storeOverride = RefusingStore(mapOf(SecretStore.CREDENTIALS_JSON to "old")) + assertEquals(CredentialsVault.Planting.PLANTED, CredentialsVault.plant(file, "old")) + file.writeText("rotated") + + assertFalse(CredentialsVault.harvest()) + CredentialsVault.settlePlanted(file, "old") + + assertEquals("rotated", file.readText()) + assertEquals("old", SecretStore.get(SecretStore.CREDENTIALS_JSON)) + } + + fun `test a rotated credential the safe took leaves nothing on disk`() { + SecretStore.set(SecretStore.CREDENTIALS_JSON, "old") + assertEquals(CredentialsVault.Planting.PLANTED, CredentialsVault.plant(file, "old")) + file.writeText("rotated") + + assertTrue(CredentialsVault.harvest()) + CredentialsVault.settlePlanted(file, "old") + + assertFalse(file.exists()) + assertEquals("rotated", SecretStore.get(SecretStore.CREDENTIALS_JSON)) + } +} From cf34f86a42cee03b076ab603caa3cb50a7426fe6 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:48:37 +0200 Subject: [PATCH 011/155] fix(mcp): grant an admission credit only after the spawn succeeds --- .../claudejb/controller/mcp/IdeMcpService.kt | 82 ++++++++++++++++--- .../controller/session/SessionProcess.kt | 65 ++++++++++----- .../controller/mcp/AdmissionCreditsTest.kt | 54 ++++++++++++ .../controller/session/GenerationSlotTest.kt | 39 +++++++++ 4 files changed, 208 insertions(+), 32 deletions(-) create mode 100644 src/test/kotlin/dev/lain/claudejb/controller/mcp/AdmissionCreditsTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/controller/session/GenerationSlotTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdeMcpService.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdeMcpService.kt index 0d87e244..4b336575 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdeMcpService.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdeMcpService.kt @@ -22,20 +22,20 @@ import dev.lain.claudejb.util.PluginIdentity import dev.lain.claudejb.util.thisLogger import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.awt.datatransfer.StringSelection import java.nio.file.Path -import java.util.concurrent.atomic.AtomicInteger @Service(Service.Level.PROJECT) internal class IdeMcpService(private val project: Project, private val scope: CoroutineScope) : Disposable { private val log = thisLogger() private val tokens = TokenRing() - private val expected = AtomicInteger() + private val credits = AdmissionCredits(ADMISSION_TTL_MILLIS) private var home: SocketHome? = null private var endpoints: List = emptyList() private var rotation: Job? = null @@ -69,21 +69,29 @@ internal class IdeMcpService(private val project: Project, private val scope: Co notification.notify(project) } - fun expectConnections(count: Int) { - expected.addAndGet(count) + fun expectConnections(count: Int): AdmissionGrant = credits.grant(count) + + fun admitReconnect(name: String): AdmissionGrant? { + val ours = synchronized(this) { endpoints.any { it.server.mcpName == name } } + return if (ours) credits.grant(1) else null + } + + fun prewarm() { + if (!ClaudeSettings.getInstance(project).state.ideMcp.enabled) return + scope.launch(Dispatchers.IO) { + runCatching { sockets() }.onFailure { log.warn("The IDE MCP servers could not be started ahead of a session", it) } + } } private fun start() { if (home != null) return val home = SocketHome.create(listOf(Path.of(PathManager.getTempPath()), Path.of(System.getProperty("java.io.tmpdir")))) - home.writeToken(tokens.token) - val gate = GuardGate { ClaudeSettings.getInstance(project).sensitiveDecision(it, project.basePath) } - endpoints = IdeServer.entries.mapNotNull { server -> - val catalog = IdeToolCatalog.catalog(server, project, scope) - if (catalog.domains.isEmpty()) return@mapNotNull null - val mcp = McpServer(server.key, PluginIdentity.PLUGIN_VERSION, MetaTools(catalog, gate, OutputBudget())) - ServerEndpoint(server, home.socket(server), mcp, tokens, scope, ::connected).also { it.start() } - } + val opened = mutableListOf() + runCatching { open(home, opened) }.onFailure { + opened.forEach(ServerEndpoint::close) + home.remove() + }.getOrThrow() + endpoints = opened rotation = scope.launch { while (isActive) { delay(TokenRing.ROTATION_MILLIS) @@ -94,8 +102,21 @@ internal class IdeMcpService(private val project: Project, private val scope: Co log.info("IDE MCP servers listening under ${home.dir}: ${endpoints.joinToString { it.server.key }}") } + private fun open(home: SocketHome, opened: MutableList) { + home.writeToken(tokens.token) + val gate = GuardGate { ClaudeSettings.getInstance(project).sensitiveDecision(it, project.basePath) } + IdeServer.entries.forEach { server -> + val catalog = IdeToolCatalog.catalog(server, project, scope) + if (catalog.domains.isEmpty()) return@forEach + val mcp = McpServer(server.key, PluginIdentity.PLUGIN_VERSION, MetaTools(catalog, gate, OutputBudget())) + val endpoint = ServerEndpoint(server, home.socket(server), mcp, tokens, scope, ::connected) + opened += endpoint + endpoint.start() + } + } + private suspend fun connected(): Boolean { - if (expected.getAndUpdate { if (it > 0) it - 1 else 0 } > 0) return true + if (credits.consume()) return true log.warn("an MCP client connected that no chat tab of this project announced") val mustApprove = ClaudeSettings.getInstance(project).state.ideMcp.approveClients val verdict = CompletableDeferred() @@ -123,6 +144,7 @@ internal class IdeMcpService(private val project: Project, private val scope: Co companion object { + const val ADMISSION_TTL_MILLIS = 2 * 60 * 1000L const val INFORMED_TEXT = "Something other than this project's chat tabs opened a connection. If that was not you, close the " + "project: the sockets and their token die with it." const val HELD_TEXT = "Something other than this project's chat tabs opened a connection. It is held until you answer; " + @@ -136,3 +158,37 @@ internal class IdeMcpService(private val project: Project, private val scope: Co fun getInstance(project: Project): IdeMcpService = project.service() } } + +internal fun interface AdmissionGrant { + fun withdraw() +} + +internal class AdmissionCredits(private val ttlMillis: Long, private val clock: () -> Long = System::currentTimeMillis) { + + private class Ticket(var left: Int, val until: Long) + + private val tickets = ArrayDeque() + + @Synchronized + fun grant(count: Int): AdmissionGrant { + if (count <= 0) return AdmissionGrant {} + val ticket = Ticket(count, clock() + ttlMillis) + tickets.addLast(ticket) + return AdmissionGrant { withdraw(ticket) } + } + + @Synchronized + fun consume(): Boolean { + val now = clock() + tickets.removeAll { it.left <= 0 || it.until < now } + val ticket = tickets.firstOrNull() ?: return false + ticket.left-- + return true + } + + @Synchronized + private fun withdraw(ticket: Ticket) { + ticket.left = 0 + tickets.remove(ticket) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/SessionProcess.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/SessionProcess.kt index 188284a6..7330d3cf 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/SessionProcess.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/SessionProcess.kt @@ -1,5 +1,6 @@ package dev.lain.claudejb.controller.session +import dev.lain.claudejb.controller.mcp.AdmissionGrant import dev.lain.claudejb.controller.mcp.IdeMcpService import dev.lain.claudejb.controller.process.ClaudeProcess import dev.lain.claudejb.model.protocol.ClaudeEvent @@ -9,6 +10,7 @@ import dev.lain.claudejb.model.session.transcript.Speaker import dev.lain.claudejb.model.settings.ClaudeSettings import dev.lain.claudejb.util.thisLogger import java.io.File +import java.util.concurrent.atomic.AtomicReference class SessionProcess( private val s: ClaudeSession, @@ -20,28 +22,28 @@ class SessionProcess( private val log = thisLogger() - @Volatile private var process: ClaudeProcess? = null + private val slot = GenerationSlot() - @Volatile var generation = 0 - private set + val generation: Int get() = slot.generation @Volatile private var resumedLaunch = false - fun isRunning(): Boolean = process?.isRunning() == true + fun isRunning(): Boolean = slot.current?.isRunning() == true - fun write(line: String): Boolean = process?.writeLine(line) ?: false + fun write(line: String): Boolean = slot.current?.writeLine(line) ?: false - fun supersede(): Int = ++generation + fun supersede(): Int = slot.supersede() fun terminate() { - process?.terminate() - process = null + slot.take()?.terminate() } fun spawn(launchGen: Int, binary: File, workDir: File, env: Map, resume: Boolean): Boolean { if (launchGen != generation) return false - resumedLaunch = resume - val opts = s.launch.copy(sessionId = s.sessionId, ideSockets = ideSockets()) + val sockets = ideSockets() + if (launchGen != generation) return false + val opts = s.launch.copy(sessionId = s.sessionId, ideSockets = sockets) + val grant = AtomicReference() val proc = ClaudeProcess( binary = binary, workDir = workDir, @@ -49,34 +51,36 @@ class SessionProcess( nodeOverride = ClaudeSettings.getInstance(s.project).nodePath, extraEnv = env, onEvent = onEvent, - onTerminated = { code -> onTerminated(launchGen, code) }, + onTerminated = { code -> + grant.getAndSet(null)?.withdraw() + onTerminated(launchGen, code) + }, ) - process = proc val started = runCatching { proc.start() } if (started.isFailure) { - process = null log.warn("Failed to start the claude process", started.exceptionOrNull()) s.notifier.error("Failed to start Claude Code: ${started.exceptionOrNull()?.message ?: "unknown error"}") return false } - if (launchGen != generation) { + if (!slot.publish(launchGen, proc) { resumedLaunch = resume }) { proc.terminate() - if (process === proc) process = null return false } + grant.set(admit(sockets.size)) + if (!proc.isRunning()) grant.getAndSet(null)?.withdraw() return true } private fun ideSockets(): Map { if (!s.launch.ideIntegration) return emptyMap() - val service = IdeMcpService.getInstance(s.project) - val sockets = runCatching { service.sockets() } + return runCatching { IdeMcpService.getInstance(s.project).sockets() } .onFailure { log.warn("The IDE MCP servers could not start; the session runs without them", it) } .getOrDefault(emptyMap()) - service.expectConnections(sockets.size) - return sockets } + private fun admit(count: Int): AdmissionGrant? = + if (count > 0) IdeMcpService.getInstance(s.project).expectConnections(count) else null + private fun onTerminated(gen: Int, exitCode: Int) { if (gen != generation) return val staleResume = resumedLaunch && !s.catalog.initialized @@ -117,3 +121,26 @@ class SessionProcess( s.start(resume = false) } } + +internal class GenerationSlot { + + @Volatile var generation = 0 + private set + + @Volatile var current: T? = null + private set + + @Synchronized + fun supersede(): Int = ++generation + + @Synchronized + fun publish(gen: Int, value: T, onPublish: () -> Unit = {}): Boolean { + if (gen != generation) return false + onPublish() + current = value + return true + } + + @Synchronized + fun take(): T? = current.also { current = null } +} diff --git a/src/test/kotlin/dev/lain/claudejb/controller/mcp/AdmissionCreditsTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/mcp/AdmissionCreditsTest.kt new file mode 100644 index 00000000..0369bb36 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/mcp/AdmissionCreditsTest.kt @@ -0,0 +1,54 @@ +package dev.lain.claudejb.controller.mcp + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class AdmissionCreditsTest { + + private var now = 1_000L + private val credits = AdmissionCredits(ttlMillis = 100) { now } + + @Test + fun `no grant admits nobody`() { + assertFalse(credits.consume()) + } + + @Test + fun `a grant admits exactly its count`() { + credits.grant(2) + assertTrue(credits.consume()) + assertTrue(credits.consume()) + assertFalse(credits.consume()) + } + + @Test + fun `a withdrawn grant leaves nothing for a later client`() { + val grant = credits.grant(3) + assertTrue(credits.consume()) + grant.withdraw() + assertFalse(credits.consume()) + } + + @Test + fun `withdrawing one grant keeps another`() { + val dead = credits.grant(1) + credits.grant(1) + dead.withdraw() + assertTrue(credits.consume()) + assertFalse(credits.consume()) + } + + @Test + fun `an unused grant expires`() { + credits.grant(1) + now += 101 + assertFalse(credits.consume()) + } + + @Test + fun `a zero grant admits nobody and withdraws cleanly`() { + credits.grant(0).withdraw() + assertFalse(credits.consume()) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/controller/session/GenerationSlotTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/session/GenerationSlotTest.kt new file mode 100644 index 00000000..4ade322c --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/session/GenerationSlotTest.kt @@ -0,0 +1,39 @@ +package dev.lain.claudejb.controller.session + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class GenerationSlotTest { + + private val slot = GenerationSlot() + + @Test + fun `a stale spawn never overwrites the newer process`() { + val stale = slot.supersede() + val fresh = slot.supersede() + assertTrue(slot.publish(fresh, "proc2")) + assertFalse(slot.publish(stale, "proc1")) + assertEquals("proc2", slot.current) + } + + @Test + fun `a spawn superseded before publishing is refused`() { + val gen = slot.supersede() + slot.supersede() + var published = false + assertFalse(slot.publish(gen, "proc") { published = true }) + assertFalse(published) + assertNull(slot.current) + } + + @Test + fun `take hands the process over once`() { + val gen = slot.supersede() + assertTrue(slot.publish(gen, "proc")) + assertEquals("proc", slot.take()) + assertNull(slot.take()) + } +} From 54fe2927861488265c4200054be0d215e65e6277 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:48:38 +0200 Subject: [PATCH 012/155] fix(mcp): rotate the token file with an atomic move --- .../claudejb/controller/mcp/SocketHome.kt | 15 +++++-- .../controller/mcp/SocketHomeRotationTest.kt | 39 +++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 src/test/kotlin/dev/lain/claudejb/controller/mcp/SocketHomeRotationTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/SocketHome.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/SocketHome.kt index e8e9cd52..fc67e7d5 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/SocketHome.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/SocketHome.kt @@ -5,6 +5,7 @@ import dev.lain.claudejb.model.session.launch.IdeServer import java.nio.file.FileSystems import java.nio.file.Files import java.nio.file.Path +import java.nio.file.StandardCopyOption import java.nio.file.StandardOpenOption import java.nio.file.attribute.PosixFilePermissions import java.security.SecureRandom @@ -17,12 +18,16 @@ internal class SocketHome private constructor(val dir: Path) { fun socket(server: IdeServer): Path = dir.resolve("${server.key}$SOCKET_SUFFIX") fun writeToken(token: String) { - val file = dir.resolve(StdioBridge.TOKEN_FILE) + val staging = dir.resolve(StdioBridge.TOKEN_FILE + STAGING_SUFFIX) if (POSIX) { - file.deleteIfExists() - Files.createFile(file, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))) + if (Files.notExists(staging)) { + Files.createFile(staging, PosixFilePermissions.asFileAttribute(TOKEN_OWNER_ONLY)) + } else { + Files.setPosixFilePermissions(staging, TOKEN_OWNER_ONLY) + } } - Files.writeString(file, token, StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) + Files.writeString(staging, token, StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) + Files.move(staging, dir.resolve(StdioBridge.TOKEN_FILE), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) } fun remove() { @@ -38,6 +43,8 @@ internal class SocketHome private constructor(val dir: Path) { private const val ID_BYTES = 16 private val POSIX = FileSystems.getDefault().supportedFileAttributeViews().contains("posix") private val OWNER_ONLY = PosixFilePermissions.fromString("rwx------") + private val TOKEN_OWNER_ONLY = PosixFilePermissions.fromString("rw-------") + private const val STAGING_SUFFIX = ".next" fun create(bases: List): SocketHome { val id = Base64.getUrlEncoder().withoutPadding().encodeToString(ByteArray(ID_BYTES).also(SecureRandom()::nextBytes)) diff --git a/src/test/kotlin/dev/lain/claudejb/controller/mcp/SocketHomeRotationTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/mcp/SocketHomeRotationTest.kt new file mode 100644 index 00000000..a4d31e0a --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/mcp/SocketHomeRotationTest.kt @@ -0,0 +1,39 @@ +package dev.lain.claudejb.controller.mcp + +import dev.lain.claudejb.mcp.StdioBridge +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermissions +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.thread + +class SocketHomeRotationTest { + + @TempDir + lateinit var tmp: Path + + @Test + fun `a reader never sees a missing or empty token while it rotates`() { + val home = SocketHome.create(listOf(tmp)) + home.writeToken("token-0") + val file = home.dir.resolve(StdioBridge.TOKEN_FILE) + val stop = AtomicBoolean() + val bad = mutableListOf() + val reader = thread { + while (!stop.get()) { + val seen = runCatching { Files.readString(file) }.getOrElse { "<${it.javaClass.simpleName}>" } + if (!seen.startsWith("token-")) synchronized(bad) { bad += seen } + } + } + repeat(2_000) { home.writeToken("token-$it") } + stop.set(true) + reader.join() + assertTrue(bad.isEmpty()) { bad.take(5).toString() } + assertEquals("rw-------", PosixFilePermissions.toString(Files.getPosixFilePermissions(file))) + home.remove() + } +} From bd855b34c0a90cf6905d1cfc9e2668d045111cbb Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:48:39 +0200 Subject: [PATCH 013/155] fix(mcp): bound frame headers and keep the bridge's reply thread alive --- .../java/dev/lain/claudejb/mcp/Frames.java | 20 ++++--- .../dev/lain/claudejb/mcp/StdioBridge.java | 42 +++++++++++---- .../claudejb/mcp/StdioBridgeResilienceTest.kt | 53 +++++++++++++++++++ 3 files changed, 99 insertions(+), 16 deletions(-) create mode 100644 src/test/kotlin/dev/lain/claudejb/mcp/StdioBridgeResilienceTest.kt diff --git a/src/main/java/dev/lain/claudejb/mcp/Frames.java b/src/main/java/dev/lain/claudejb/mcp/Frames.java index 4d33927f..9dc19cf7 100644 --- a/src/main/java/dev/lain/claudejb/mcp/Frames.java +++ b/src/main/java/dev/lain/claudejb/mcp/Frames.java @@ -1,6 +1,5 @@ package dev.lain.claudejb.mcp; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -9,6 +8,7 @@ public final class Frames { public static final int MAX_FRAME_BYTES = 64 * 1024 * 1024; + private static final int MAX_HEADER_DIGITS = 10; private Frames() { } @@ -21,16 +21,22 @@ public static void write(OutputStream out, String text) throws IOException { } public static String read(InputStream in) throws IOException { - ByteArrayOutputStream header = new ByteArrayOutputStream(); + long length = 0; + int digits = 0; while (true) { int b = in.read(); - if (b < 0) return header.size() == 0 ? null : fail("stream ended inside a frame header"); + if (b < 0) return digits == 0 ? null : fail("stream ended inside a frame header"); if (b == '\n') break; - if (b < '0' || b > '9' || header.size() > 9) return fail("malformed frame header"); - header.write(b); + if (b < '0' || b > '9' || digits == MAX_HEADER_DIGITS) return fail("malformed frame header"); + length = length * 10 + (b - '0'); + digits++; + if (length > MAX_FRAME_BYTES) return fail("frame exceeds the ceiling of " + MAX_FRAME_BYTES + " bytes"); } - int length = Integer.parseInt(header.toString(StandardCharsets.US_ASCII)); - if (length > MAX_FRAME_BYTES) return fail("frame of " + length + " bytes exceeds the ceiling"); + if (digits == 0) return fail("malformed frame header"); + return body(in, (int) length); + } + + private static String body(InputStream in, int length) throws IOException { byte[] payload = in.readNBytes(length); if (payload.length != length) return fail("stream ended inside a frame body"); return new String(payload, StandardCharsets.UTF_8); diff --git a/src/main/java/dev/lain/claudejb/mcp/StdioBridge.java b/src/main/java/dev/lain/claudejb/mcp/StdioBridge.java index 96e19714..e37685a7 100644 --- a/src/main/java/dev/lain/claudejb/mcp/StdioBridge.java +++ b/src/main/java/dev/lain/claudejb/mcp/StdioBridge.java @@ -1,5 +1,6 @@ package dev.lain.claudejb.mcp; +import java.io.BufferedInputStream; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; @@ -20,6 +21,8 @@ public final class StdioBridge { public static final String TOKEN_KEY = "dev.lain.claudejb/token"; public static final String TOKEN_FILE = "token"; + private static final int PARSE_ERROR = -32700; + private static final int INTERNAL_ERROR = -32603; private final Path socket; private final Path tokenFile; @@ -44,8 +47,8 @@ void pump(InputStream stdin) throws IOException { try (SocketChannel channel = SocketChannel.open(StandardProtocolFamily.UNIX)) { channel.connect(UnixDomainSocketAddress.of(socket)); OutputStream toPlugin = Channels.newOutputStream(channel); - InputStream fromPlugin = Channels.newInputStream(channel); - Thread replies = new Thread(() -> relayReplies(fromPlugin), "mcp-replies"); + InputStream fromPlugin = new BufferedInputStream(Channels.newInputStream(channel)); + Thread replies = new Thread(() -> relayReplies(fromPlugin, channel), "mcp-replies"); replies.setDaemon(true); replies.start(); relayRequests(stdin, toPlugin); @@ -63,7 +66,7 @@ private void relayRequests(InputStream stdin, OutputStream toPlugin) throws IOEx try { message = Json.parse(line); } catch (IllegalArgumentException e) { - emit(Json.write(parseError())); + emit(Json.write(error(PARSE_ERROR, "Parse error"))); continue; } synchronized (toPlugin) { @@ -72,13 +75,34 @@ private void relayRequests(InputStream stdin, OutputStream toPlugin) throws IOEx } } - private void relayReplies(InputStream fromPlugin) { + void relayReplies(InputStream fromPlugin, SocketChannel channel) { try { String frame; while ((frame = Frames.read(fromPlugin)) != null) { - emit(Json.write(Toon.decode(frame))); + relayReply(frame); } - } catch (IOException | ToonException e) { + } catch (IOException e) { + System.err.println("StdioBridge: " + e.getMessage()); + closeQuietly(channel); + } + } + + private void relayReply(String frame) { + String line; + try { + line = Json.write(Toon.decode(frame)); + } catch (RuntimeException e) { + System.err.println("StdioBridge: unreadable reply: " + e.getMessage()); + line = Json.write(error(INTERNAL_ERROR, "unreadable reply from the IDE")); + } + emit(line); + } + + private static void closeQuietly(SocketChannel channel) { + if (channel == null) return; + try { + channel.close(); + } catch (IOException e) { System.err.println("StdioBridge: " + e.getMessage()); } } @@ -115,10 +139,10 @@ private void emit(String line) { } } - private static Map parseError() { + private static Map error(int code, String message) { Map error = new LinkedHashMap<>(); - error.put("code", -32700); - error.put("message", "Parse error"); + error.put("code", code); + error.put("message", message); Map response = new LinkedHashMap<>(); response.put("jsonrpc", "2.0"); response.put("id", null); diff --git a/src/test/kotlin/dev/lain/claudejb/mcp/StdioBridgeResilienceTest.kt b/src/test/kotlin/dev/lain/claudejb/mcp/StdioBridgeResilienceTest.kt new file mode 100644 index 00000000..15f874a8 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/mcp/StdioBridgeResilienceTest.kt @@ -0,0 +1,53 @@ +package dev.lain.claudejb.mcp + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.PrintStream +import java.nio.file.Path + +class StdioBridgeResilienceTest { + + private fun frames(vararg header: String) = ByteArrayInputStream(header.joinToString("").toByteArray()) + + @Test + fun `a ten digit header is refused as a malformed frame, not a number format crash`() { + assertThrows(IOException::class.java) { Frames.read(frames("9999999999\n")) } + } + + @Test + fun `a header of endless zeros is bounded`() { + assertThrows(IOException::class.java) { Frames.read(frames("0".repeat(64) + "\n")) } + } + + @Test + fun `an empty header is malformed`() { + assertThrows(IOException::class.java) { Frames.read(frames("\n")) } + } + + @Test + fun `a frame at the ceiling is still read by length`() { + assertThrows(IOException::class.java) { Frames.read(frames("${Frames.MAX_FRAME_BYTES + 1}\n")) } + assertEquals("ab", Frames.read(frames("0002\nab"))) + } + + @Test + fun `an unreadable reply becomes a JSON-RPC error and the replies keep flowing`() { + val wire = ByteArrayOutputStream() + Frames.write(wire, "a: \"open") + Frames.write(wire, Toon.encode(Json.parse("""{"jsonrpc":"2.0","id":7,"result":{}}"""))) + val stdout = ByteArrayOutputStream() + val bridge = StdioBridge(Path.of("unused.sock"), PrintStream(stdout, true, Charsets.UTF_8)) + + bridge.relayReplies(ByteArrayInputStream(wire.toByteArray()), null) + + val lines = stdout.toString(Charsets.UTF_8).trim().lines() + assertEquals(2, lines.size, lines.toString()) + assertTrue(lines[0].contains("\"code\":-32603"), lines[0]) + assertEquals("""{"jsonrpc":"2.0","id":7,"result":{}}""", lines[1]) + } +} From 58d05dc8aa082cea27225779cc94197d91dbe105 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:51:10 +0200 Subject: [PATCH 014/155] fix(onboarding): verify an API key before approving it and refuse it when unverifiable --- .../commands/OnboardingController.kt | 14 +++++++-- .../controller/commands/ApiKeyRefusalTest.kt | 29 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 src/test/kotlin/dev/lain/claudejb/controller/commands/ApiKeyRefusalTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/commands/OnboardingController.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/OnboardingController.kt index 52e701dc..7026eb10 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/commands/OnboardingController.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/OnboardingController.kt @@ -152,12 +152,13 @@ internal class OnboardingController( } pushAuthState("verifying") ApplicationManager.getApplication().executeOnPooledThread { - ApiKeyApproval.approve(trimmed) val binary = ClaudeBinaryLocator.locate(ClaudeSettings.getInstance(project).claudePath) val state = binary?.let { AuthCli.status(it, mapOf(SecretStore.API_KEY to trimmed)) } + val refusal = apiKeyRefusal(binary != null, state) + if (refusal == null) ApiKeyApproval.approve(trimmed) ApplicationManager.getApplication().invokeLater { - if (state != null && !state.loggedIn) { - pushAuthState("error", message = "That API key was refused. Check it and try again.") + if (refusal != null) { + pushAuthState("error", message = refusal) return@invokeLater } ClaudeSettings.getInstance(project).setProviderApiKey(Provider.ANTHROPIC, trimmed) @@ -194,3 +195,10 @@ internal class OnboardingController( const val BOOT_WATCH_MS = 3_000 } } + +internal fun apiKeyRefusal(binaryFound: Boolean, state: AuthCli.AuthState?): String? = when { + !binaryFound -> "Claude Code is not installed yet, so the API key cannot be checked. Install it, then enter the key again." + state == null -> "The API key could not be verified: Claude Code gave no answer. Check the key and try again." + !state.loggedIn -> "That API key was refused. Check it and try again." + else -> null +} diff --git a/src/test/kotlin/dev/lain/claudejb/controller/commands/ApiKeyRefusalTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/commands/ApiKeyRefusalTest.kt new file mode 100644 index 00000000..ea57b7b3 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/commands/ApiKeyRefusalTest.kt @@ -0,0 +1,29 @@ +package dev.lain.claudejb.controller.commands + +import dev.lain.claudejb.controller.process.auth.AuthCli +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class ApiKeyRefusalTest { + + @Test + fun `a key is refused when the binary is missing`() { + assertNotNull(apiKeyRefusal(binaryFound = false, state = null)) + } + + @Test + fun `a key is refused when the status is unknown`() { + assertNotNull(apiKeyRefusal(binaryFound = true, state = null)) + } + + @Test + fun `a key the binary does not accept is refused`() { + assertNotNull(apiKeyRefusal(binaryFound = true, state = AuthCli.AuthState(loggedIn = false))) + } + + @Test + fun `only a verified key is accepted`() { + assertNull(apiKeyRefusal(binaryFound = true, state = AuthCli.AuthState(loggedIn = true))) + } +} From 435d428a41f2b8d34dde4181c198919de161c5c5 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:51:12 +0200 Subject: [PATCH 015/155] fix(guard): decide permissions off the protocol reader and bound the alert list --- .../controller/session/guard/BoundedRing.kt | 24 ++++++++++ .../controller/session/guard/SessionGuard.kt | 24 +++++++--- .../session/guard/BoundedRingTest.kt | 31 +++++++++++++ .../headless/SessionGuardOrderHeadlessTest.kt | 46 +++++++++++++++++++ 4 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/session/guard/BoundedRing.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/controller/session/guard/BoundedRingTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/headless/SessionGuardOrderHeadlessTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/guard/BoundedRing.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/guard/BoundedRing.kt new file mode 100644 index 00000000..517f7bd8 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/guard/BoundedRing.kt @@ -0,0 +1,24 @@ +package dev.lain.claudejb.controller.session.guard + +internal class BoundedRing(private val capacity: Int) { + + private val items = ArrayDeque(capacity) + + @Synchronized + fun add(item: T) { + if (items.size == capacity) items.removeFirst() + items.addLast(item) + } + + @Synchronized + fun replaceAll(replacement: List) { + items.clear() + items.addAll(replacement.takeLast(capacity)) + } + + @Synchronized + fun isEmpty(): Boolean = items.isEmpty() + + @Synchronized + fun filter(predicate: (T) -> Boolean): List = items.filter(predicate) +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/guard/SessionGuard.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/guard/SessionGuard.kt index 9a2daefd..b9061e20 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/guard/SessionGuard.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/guard/SessionGuard.kt @@ -26,7 +26,7 @@ import dev.lain.claudejb.model.settings.guard.GuardCommandApprovals import dev.lain.claudejb.model.settings.guard.guardSuspended import dev.lain.claudejb.model.settings.guard.sensitiveDecision import dev.lain.claudejb.util.thisLogger -import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executor class SessionGuard( private val session: ClaudeSession, @@ -43,7 +43,9 @@ class SessionGuard( val guardLog = GuardLogTally() - private val alerts = CopyOnWriteArrayList() + private val alerts = BoundedRing(MAX_ALERTS) + + private val decisions: Executor = AppExecutorUtil.createBoundedApplicationPoolExecutor(DECISIONS, 1) val broker = PermissionBroker( permissionMode = { session.launch.permissionMode }, @@ -62,7 +64,13 @@ class SessionGuard( onSensitiveBypassed = ::onBypassed, ) - fun onPermission(requestId: String, request: CanUseToolRequest) { + fun onPermission(requestId: String, request: CanUseToolRequest) = inOrder { decide(requestId, request) } + + fun inOrder(action: () -> Unit) = decisions.execute { + runCatching(action).onFailure { log.warn("a guard step failed off the protocol reader", it) } + } + + private fun decide(requestId: String, request: CanUseToolRequest) { if (ToolFilters.isDisallowed(ClaudeSettings.getInstance(project).state, request.toolName)) { write(ControlProtocol.permissionDeny(requestId, "${request.toolName} is a disallowed tool in this project's settings.")) edt { session.transcript.add(Speaker.SYSTEM, "Refused ${request.toolName}: it is on the disallowed tools list.") } @@ -173,7 +181,7 @@ class SessionGuard( command = command, inAgent = inAgent, ) - alerts += alert + alerts.add(alert) val scope = settings.scope val retention = settings.state.guardLogRetentionDays AppExecutorUtil.getAppExecutorService().execute { @@ -215,8 +223,12 @@ class SessionGuard( fun restore(savedSessionId: String): List { val saved = GuardAlertLog.forSession(ClaudeSettings.getInstance(project).scope, savedSessionId) - alerts.clear() - alerts.addAll(saved) + alerts.replaceAll(saved) return saved } + + private companion object { + const val MAX_ALERTS = 1_000 + const val DECISIONS = "Claude Code guard decisions" + } } diff --git a/src/test/kotlin/dev/lain/claudejb/controller/session/guard/BoundedRingTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/session/guard/BoundedRingTest.kt new file mode 100644 index 00000000..8ce850a8 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/session/guard/BoundedRingTest.kt @@ -0,0 +1,31 @@ +package dev.lain.claudejb.controller.session.guard + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class BoundedRingTest { + + @Test + fun `the ring keeps only the newest items`() { + val ring = BoundedRing(3) + (1..5).forEach(ring::add) + assertEquals(listOf(3, 4, 5), ring.filter { true }) + } + + @Test + fun `a restore larger than the ring keeps its tail`() { + val ring = BoundedRing(2) + ring.add(9) + ring.replaceAll(listOf(1, 2, 3)) + assertEquals(listOf(2, 3), ring.filter { true }) + } + + @Test + fun `an empty ring reports it`() { + val ring = BoundedRing(2) + assertTrue(ring.isEmpty()) + ring.replaceAll(emptyList()) + assertTrue(ring.isEmpty()) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/headless/SessionGuardOrderHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/headless/SessionGuardOrderHeadlessTest.kt new file mode 100644 index 00000000..d102341c --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/headless/SessionGuardOrderHeadlessTest.kt @@ -0,0 +1,46 @@ +package dev.lain.claudejb.headless + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import dev.lain.claudejb.controller.session.ClaudeSession +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class SessionGuardOrderHeadlessTest : BasePlatformTestCase() { + + fun `test guard steps leave the caller's thread and keep their order`() { + val session = ClaudeSession(project, "t") + try { + val seen = Collections.synchronizedList(mutableListOf()) + val threads = ConcurrentHashMap.newKeySet() + val done = CountDownLatch(1) + (1..50).forEach { i -> + session.guard.inOrder { + threads += Thread.currentThread() + seen += i + } + } + session.guard.inOrder { done.countDown() } + + assertTrue(done.await(10, TimeUnit.SECONDS)) + assertEquals((1..50).toList(), seen.toList()) + assertFalse(Thread.currentThread() in threads) + } finally { + session.dispose() + } + } + + fun `test a failing step does not stop the ones after it`() { + val session = ClaudeSession(project, "t") + try { + val done = CountDownLatch(1) + session.guard.inOrder { error("boom") } + session.guard.inOrder { done.countDown() } + + assertTrue(done.await(10, TimeUnit.SECONDS)) + } finally { + session.dispose() + } + } +} From 90c6c7f6678c17030c01f0b084b240f9ed555136 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:53:15 +0200 Subject: [PATCH 016/155] fix(diff): count context in unified-diff hunks and merge overlapping ones --- .../lain/claudejb/model/diff/DiffPresenter.kt | 14 +--- .../lain/claudejb/model/diff/UnifiedDiff.kt | 43 ++++++++++++ .../claudejb/model/diff/UnifiedDiffTest.kt | 70 +++++++++++++++++++ 3 files changed, 114 insertions(+), 13 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/model/diff/UnifiedDiff.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/model/diff/UnifiedDiffTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/model/diff/DiffPresenter.kt b/src/main/kotlin/dev/lain/claudejb/model/diff/DiffPresenter.kt index d9cad66a..b8758b98 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/diff/DiffPresenter.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/diff/DiffPresenter.kt @@ -78,19 +78,7 @@ object DiffPresenter { fun unifiedDiff(current: String, proposed: String, context: Int = 3): String { val hunks = computeHunks(current, proposed) if (hunks.isEmpty()) return "" - val cur = current.split("\n") - val pro = proposed.split("\n") - val sb = StringBuilder() - for (h in hunks) { - val ctxStart = (h.start1 - context).coerceAtLeast(0) - val ctxEnd = (h.end1 + context).coerceAtMost(cur.size) - sb.append("@@ -${h.start1 + 1},${h.end1 - h.start1} +${h.start2 + 1},${h.end2 - h.start2} @@\n") - for (i in ctxStart until h.start1) sb.append(' ').append(cur[i]).append('\n') - for (i in h.start1 until h.end1) sb.append('-').append(cur.getOrElse(i) { "" }).append('\n') - for (i in h.start2 until h.end2) sb.append('+').append(pro.getOrElse(i) { "" }).append('\n') - for (i in h.end1 until ctxEnd) sb.append(' ').append(cur[i]).append('\n') - } - return sb.toString().trimEnd('\n') + return UnifiedDiff.format(current.split("\n"), proposed.split("\n"), hunks, context) } } diff --git a/src/main/kotlin/dev/lain/claudejb/model/diff/UnifiedDiff.kt b/src/main/kotlin/dev/lain/claudejb/model/diff/UnifiedDiff.kt new file mode 100644 index 00000000..07e52a6c --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/model/diff/UnifiedDiff.kt @@ -0,0 +1,43 @@ +package dev.lain.claudejb.model.diff + +object UnifiedDiff { + + fun format(current: List, proposed: List, hunks: List, context: Int): String { + val sb = StringBuilder() + var from = 0 + while (from < hunks.size) { + var to = from + while (to + 1 < hunks.size && hunks[to + 1].start1 - hunks[to].end1 <= 2 * context) to++ + appendGroup(sb, current, proposed, hunks.subList(from, to + 1), context) + from = to + 1 + } + return sb.toString().trimEnd('\n') + } + + private fun appendGroup(sb: StringBuilder, current: List, proposed: List, group: List, context: Int) { + val first = group.first() + val last = group.last() + val lead = minOf(context, first.start1, first.start2) + val trail = minOf(context, current.size - last.end1, proposed.size - last.end2).coerceAtLeast(0) + val start1 = first.start1 - lead + val start2 = first.start2 - lead + val count1 = last.end1 + trail - start1 + val count2 = last.end2 + trail - start2 + sb.append("@@ -").append(position(start1, count1)).append(',').append(count1) + .append(" +").append(position(start2, count2)).append(',').append(count2).append(" @@\n") + var cursor = start1 + for (h in group) { + for (i in cursor until h.start1) line(sb, ' ', current, i) + for (i in h.start1 until h.end1) line(sb, '-', current, i) + for (i in h.start2 until h.end2) line(sb, '+', proposed, i) + cursor = h.end1 + } + for (i in cursor until last.end1 + trail) line(sb, ' ', current, i) + } + + private fun position(start: Int, count: Int): Int = if (count == 0) start else start + 1 + + private fun line(sb: StringBuilder, mark: Char, lines: List, index: Int) { + sb.append(mark).append(lines.getOrElse(index) { "" }).append('\n') + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/model/diff/UnifiedDiffTest.kt b/src/test/kotlin/dev/lain/claudejb/model/diff/UnifiedDiffTest.kt new file mode 100644 index 00000000..5b299ea1 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/model/diff/UnifiedDiffTest.kt @@ -0,0 +1,70 @@ +package dev.lain.claudejb.model.diff + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class UnifiedDiffTest { + + private val ten = (1..10).map { "l$it" } + + @Test + fun `a hunk header counts its context lines on both sides`() { + val proposed = ten.toMutableList().apply { this[4] = "L5" } + + val diff = UnifiedDiff.format(ten, proposed, listOf(Hunk(4, 5, 4, 5)), context = 3) + + assertEquals( + listOf("@@ -2,7 +2,7 @@", " l2", " l3", " l4", "-l5", "+L5", " l6", " l7", " l8"), + diff.lines(), + ) + } + + @Test + fun `changes whose context overlaps share one hunk and repeat no line`() { + val proposed = ten.toMutableList().apply { + this[2] = "L3" + this[6] = "L7" + } + + val diff = UnifiedDiff.format(ten, proposed, listOf(Hunk(2, 3, 2, 3), Hunk(6, 7, 6, 7)), context = 3) + + assertEquals( + listOf( + "@@ -1,10 +1,10 @@", " l1", " l2", "-l3", "+L3", " l4", " l5", " l6", "-l7", "+L7", " l8", " l9", " l10", + ), + diff.lines(), + ) + } + + @Test + fun `changes far apart get a hunk each`() { + val lines = (1..20).map { "l$it" } + val proposed = lines.toMutableList().apply { + this[0] = "L1" + this[19] = "L20" + } + + val diff = UnifiedDiff.format(lines, proposed, listOf(Hunk(0, 1, 0, 1), Hunk(19, 20, 19, 20)), context = 2) + + assertEquals( + listOf("@@ -1,3 +1,3 @@", "-l1", "+L1", " l2", " l3", "@@ -18,3 +18,3 @@", " l18", " l19", "-l20", "+L20"), + diff.lines(), + ) + } + + @Test + fun `a pure insertion counts only context on the old side`() { + val proposed = ten.toMutableList().apply { add(5, "new") } + + val diff = UnifiedDiff.format(ten, proposed, listOf(Hunk(5, 5, 5, 6)), context = 1) + + assertEquals(listOf("@@ -5,2 +5,3 @@", " l5", "+new", " l6"), diff.lines()) + } + + @Test + fun `an insertion into an empty file starts at line zero`() { + val diff = UnifiedDiff.format(emptyList(), listOf("a", "b"), listOf(Hunk(0, 0, 0, 2)), context = 3) + + assertEquals(listOf("@@ -0,0 +1,2 @@", "+a", "+b"), diff.lines()) + } +} From 272e2457eb04b690451120814a2b5ee1cfbfe0d0 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:53:16 +0200 Subject: [PATCH 017/155] perf(diff): cap edit snapshots at 1 MB and bound the store by bytes --- .../claudejb/model/diff/EditSnapshotStore.kt | 41 ++++++++++++++----- .../model/diff/EditSnapshotStoreTest.kt | 28 ++++++++++++- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/model/diff/EditSnapshotStore.kt b/src/main/kotlin/dev/lain/claudejb/model/diff/EditSnapshotStore.kt index 24648382..f0334e8f 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/diff/EditSnapshotStore.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/diff/EditSnapshotStore.kt @@ -11,21 +11,34 @@ data class EditSnapshot( val existedBefore: Boolean = true, ) -class EditSnapshotStore(private val capacity: Int = DEFAULT_CAPACITY) { +class EditSnapshotStore(private val maxBytes: Long = DEFAULT_MAX_BYTES) { - private val byToolUseId = object : LinkedHashMap(INITIAL_BUCKETS, LOAD_FACTOR, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean = size > capacity - } + private val byToolUseId = LinkedHashMap(INITIAL_BUCKETS, LOAD_FACTOR, true) + + private var bytes = 0L - @Synchronized fun capture(toolName: String, input: JsonObject, toolUseId: String): EditSnapshot? { val path = DiffPresenter.filePathOf(input) ?: return null - if (toolUseId.isNotBlank()) byToolUseId[toolUseId]?.let { return it } + if (toolUseId.isNotBlank()) get(toolUseId)?.let { return it } val file = File(path) val existedBefore = file.isFile + if (existedBefore && file.length() > DiffPresenter.MAX_DIFF_FILE_BYTES) return null val beforeText = if (existedBefore) runCatching { file.readText() }.getOrDefault("") else "" - return EditSnapshot(toolName, input, beforeText, path, existedBefore) - .also { if (toolUseId.isNotBlank()) byToolUseId[toolUseId] = it } + val snapshot = EditSnapshot(toolName, input, beforeText, path, existedBefore) + return if (toolUseId.isBlank()) snapshot else remember(toolUseId, snapshot) + } + + @Synchronized + private fun remember(toolUseId: String, snapshot: EditSnapshot): EditSnapshot { + byToolUseId[toolUseId]?.let { return it } + byToolUseId[toolUseId] = snapshot + bytes += weight(snapshot) + val eldest = byToolUseId.entries.iterator() + while (bytes > maxBytes && byToolUseId.size > 1) { + bytes -= weight(eldest.next().value) + eldest.remove() + } + return snapshot } @Synchronized @@ -38,11 +51,19 @@ class EditSnapshotStore(private val capacity: Int = DEFAULT_CAPACITY) { } @Synchronized - fun clear() = byToolUseId.clear() + fun clear() { + byToolUseId.clear() + bytes = 0 + } companion object { - const val DEFAULT_CAPACITY = 500 + const val DEFAULT_MAX_BYTES = 32L * 1024 * 1024 private const val INITIAL_BUCKETS = 16 private const val LOAD_FACTOR = 0.75f + + private const val ENTRY_OVERHEAD_BYTES = 1024L + + fun weight(snapshot: EditSnapshot): Long = + ENTRY_OVERHEAD_BYTES + snapshot.beforeText.length.toLong() * Char.SIZE_BYTES } } diff --git a/src/test/kotlin/dev/lain/claudejb/model/diff/EditSnapshotStoreTest.kt b/src/test/kotlin/dev/lain/claudejb/model/diff/EditSnapshotStoreTest.kt index 8d70ab17..2e7a3be1 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/diff/EditSnapshotStoreTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/diff/EditSnapshotStoreTest.kt @@ -55,10 +55,10 @@ class EditSnapshotStoreTest { } @Test - fun `the store keeps the most recent snapshots and forgets the oldest past its capacity`(@TempDir dir: Path) { - val store = EditSnapshotStore(capacity = 2) + fun `the store keeps the most recent snapshots and forgets the oldest past its byte budget`(@TempDir dir: Path) { val file = File(dir.toFile(), "a.kt").apply { writeText("v") } val input = buildJsonObject { put("file_path", file.path) } + val store = EditSnapshotStore(maxBytes = 2 * EditSnapshotStore.weight(EditSnapshot("Edit", input, "v", file.path))) store.capture("Edit", input, "tool-1") store.capture("Edit", input, "tool-2") store.get("tool-1") @@ -72,6 +72,30 @@ class EditSnapshotStoreTest { assertNull(store.get("tool-1")) } + @Test + fun `a file above the diff cap is not read and leaves no snapshot`(@TempDir dir: Path) { + val file = File(dir.toFile(), "big.txt").apply { writeText("x".repeat(DiffPresenter.MAX_DIFF_FILE_BYTES.toInt() + 1)) } + val store = EditSnapshotStore() + val input = buildJsonObject { put("file_path", file.path) } + + assertNull(store.capture("Edit", input, "tool-big")) + assertNull(store.get("tool-big")) + } + + @Test + fun `one snapshot larger than the whole budget is still kept until the next one arrives`(@TempDir dir: Path) { + val file = File(dir.toFile(), "a.kt").apply { writeText("0123456789") } + val input = buildJsonObject { put("file_path", file.path) } + val store = EditSnapshotStore(maxBytes = 1) + + store.capture("Edit", input, "tool-1") + assertEquals("0123456789", store.get("tool-1")?.beforeText) + + store.capture("Edit", input, "tool-2") + assertNull(store.get("tool-1")) + assertEquals("0123456789", store.get("tool-2")?.beforeText) + } + @Test fun `snapshot plus proposedContent reproduces the live pre-write diff`(@TempDir dir: Path) { val file = File(dir.toFile(), "a.kt").apply { writeText("foo foo") } From a4df2b3f6fd503dc2db901ebb061f59ac2861c05 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:53:17 +0200 Subject: [PATCH 018/155] perf(session): refresh the VFS only after tools that write --- .../model/session/transcript/ToolNaming.kt | 3 +- .../session/transcript/ToolNamingTest.kt | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 src/test/kotlin/dev/lain/claudejb/model/session/transcript/ToolNamingTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/transcript/ToolNaming.kt b/src/main/kotlin/dev/lain/claudejb/model/session/transcript/ToolNaming.kt index f662b1e7..8c0c1126 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/session/transcript/ToolNaming.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/session/transcript/ToolNaming.kt @@ -1,5 +1,6 @@ package dev.lain.claudejb.model.session.transcript +import dev.lain.claudejb.model.mcp.OwnTools import dev.lain.claudejb.model.protocol.str import kotlinx.serialization.json.JsonObject @@ -21,7 +22,7 @@ object ToolNaming { fun mayHaveWrittenUnknownFiles(toolName: String?): Boolean { val name = toolName?.takeIf { it.isNotBlank() } ?: return false if (name == "Bash") return true - if (name in FILE_TOOLS || name in BUILTIN_TOOLS) return false + if (name in FILE_TOOLS || name in BUILTIN_TOOLS || OwnTools.isOwn(name)) return false return MUTATING_TOOL_NAME.containsMatchIn(name) } diff --git a/src/test/kotlin/dev/lain/claudejb/model/session/transcript/ToolNamingTest.kt b/src/test/kotlin/dev/lain/claudejb/model/session/transcript/ToolNamingTest.kt new file mode 100644 index 00000000..dea1c8f3 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/model/session/transcript/ToolNamingTest.kt @@ -0,0 +1,33 @@ +package dev.lain.claudejb.model.session.transcript + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ToolNamingTest { + + @Test + fun `our own tools never trigger a project-wide refresh, whatever their name says`() { + listOf("mcp__code__run", "mcp__run__run", "mcp__vcs__tools", "mcp__ops__domains").forEach { name -> + assertFalse(ToolNaming.mayHaveWrittenUnknownFiles(name)) { "$name must not refresh the whole project" } + } + } + + @Test + fun `Bash still refreshes the project tree`() { + assertTrue(ToolNaming.mayHaveWrittenUnknownFiles("Bash")) + } + + @Test + fun `built-in file tools and readers do not refresh the tree`() { + listOf("Read", "Edit", "Write", "Grep", "Glob", "WebFetch", null, "").forEach { name -> + assertFalse(ToolNaming.mayHaveWrittenUnknownFiles(name)) { "$name must not refresh the whole project" } + } + } + + @Test + fun `a third-party tool is judged by its name`() { + assertTrue(ToolNaming.mayHaveWrittenUnknownFiles("mcp__other__write_file")) + assertFalse(ToolNaming.mayHaveWrittenUnknownFiles("mcp__other__query_docs")) + } +} From fe029b25dcaa23b093fa4a735d6c2e786501d126 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:53:18 +0200 Subject: [PATCH 019/155] perf(session): coalesce live tool output and diff edits off the EDT --- .../controller/session/events/EditReviews.kt | 64 +++++++ .../session/events/LiveToolOutput.kt | 82 +++++++++ .../controller/session/events/ToolEvents.kt | 174 ++++++++---------- 3 files changed, 220 insertions(+), 100 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/session/events/EditReviews.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/session/events/LiveToolOutput.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/events/EditReviews.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/events/EditReviews.kt new file mode 100644 index 00000000..9d667650 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/events/EditReviews.kt @@ -0,0 +1,64 @@ +package dev.lain.claudejb.controller.session.events + +import com.intellij.util.concurrency.AppExecutorUtil +import dev.lain.claudejb.controller.session.ClaudeSession +import dev.lain.claudejb.model.diff.DiffPresenter +import dev.lain.claudejb.model.diff.EditSnapshot +import dev.lain.claudejb.model.mcp.OwnTools +import kotlinx.serialization.json.JsonObject +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executor + +class EditReviews( + private val s: ClaudeSession, + private val edt: (() -> Unit) -> Unit, + private val worker: Executor = AppExecutorUtil.createBoundedApplicationPoolExecutor(WORKER, 1), +) { + + private val reviewing = ConcurrentHashMap.newKeySet() + + fun capture(toolName: String, input: JsonObject, toolUseId: String) { + reviewing.add(toolUseId) + worker.execute { s.diffs.captureForReview(toolName, input, toolUseId) } + } + + fun capture(review: OwnTools.Review, toolUseId: String) { + reviewing.add(toolUseId) + worker.execute { asWrite(review)?.let { s.diffs.captureForReview(it.toolName, it.input, toolUseId) } } + } + + fun settle(toolUseId: String, show: (String?) -> Unit) { + if (reviewing.remove(toolUseId)) { + worker.execute { deliver(s.diffs.onToolResult(toolUseId), show) } + return + } + val snapshot = s.diffs.onToolResult(toolUseId) ?: return show(null) + worker.execute { deliver(snapshot, show) } + } + + fun forget(toolUseId: String) { + reviewing.remove(toolUseId) + s.diffs.onToolResult(toolUseId) + } + + private fun deliver(snapshot: EditSnapshot?, show: (String?) -> Unit) { + val diff = snapshot?.let(::diffOf) + edt { show(diff) } + } + + private fun asWrite(review: OwnTools.Review): OwnTools.Review? { + if (review.toolName != OwnTools.INSERT) return review + val path = DiffPresenter.filePathOf(review.input) ?: return null + val before = DiffPresenter.readCurrent(path, s.project.basePath) ?: return null + return OwnTools.asWrite(review, before) + } + + private fun diffOf(snap: EditSnapshot): String? = DiffPresenter.proposedContent(snap.toolName, snap.input, snap.beforeText) + ?.takeIf { it.length <= DiffPresenter.MAX_DIFF_FILE_BYTES } + ?.let { DiffPresenter.unifiedDiff(snap.beforeText, it) } + ?.takeIf { it.isNotBlank() } + + private companion object { + const val WORKER = "Claude Code edit review" + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/events/LiveToolOutput.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/events/LiveToolOutput.kt new file mode 100644 index 00000000..0465ac70 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/events/LiveToolOutput.kt @@ -0,0 +1,82 @@ +package dev.lain.claudejb.controller.session.events + +import dev.lain.claudejb.controller.mcp.ToolOutput +import dev.lain.claudejb.controller.mcp.ToolOutputListener +import dev.lain.claudejb.controller.session.ClaudeSession +import dev.lain.claudejb.model.session.transcript.LiveLines +import dev.lain.claudejb.model.session.transcript.TranscriptEntry + +class LiveToolOutput(private val s: ClaudeSession, private val edt: (() -> Unit) -> Unit) { + + private val lock = Any() + private val pending = LinkedHashMap>() + private var flushQueued = false + + private val live = HashMap>() + private val early = bounded(MAX_EARLY_TOOLS) + private val settled = bounded(MAX_SETTLED_TOOLS) + + init { + s.project.messageBus.connect(s).subscribe( + ToolOutput.TOPIC, + ToolOutputListener { toolUseId, line -> offer(toolUseId, line) }, + ) + } + + fun offer(toolUseId: String, line: String) { + val schedule = synchronized(lock) { + val lines = pending.getOrPut(toolUseId) { ArrayDeque() } + lines.addLast(line) + if (lines.size > MAX_PENDING_LINES) lines.removeFirst() + !flushQueued.also { flushQueued = true } + } + if (schedule) edt { flush() } + } + + private fun flush() { + val batch = synchronized(lock) { + flushQueued = false + LinkedHashMap(pending).also { pending.clear() } + } + batch.forEach { (toolUseId, lines) -> absorb(toolUseId, lines) } + } + + private fun absorb(toolUseId: String, lines: Collection) { + if (toolUseId in settled) return + if (!s.transcript.knowsTool(toolUseId)) { + val ring = early.getOrPut(toolUseId) { LiveLines() } + lines.forEach(ring::add) + return + } + val (entry, ring) = live.getOrPut(toolUseId) { + s.transcript.addToolOutput(toolUseId, "", meta = LIVE) to (early.remove(toolUseId) ?: LiveLines()) + } + lines.forEach(ring::add) + s.transcript.replaceText(entry, ring.render()) + } + + fun flushEarly(toolUseId: String) { + val ring = early.remove(toolUseId) ?: return + val entry = s.transcript.addToolOutput(toolUseId, ring.render(), meta = LIVE) + live[toolUseId] = entry to ring + } + + fun settle(toolUseId: String) { + val rest = synchronized(lock) { pending.remove(toolUseId) } + if (rest != null) absorb(toolUseId, rest) + live.remove(toolUseId) + early.remove(toolUseId) + settled[toolUseId] = Unit + } + + private companion object { + const val LIVE = "live" + const val MAX_PENDING_LINES = 400 + const val MAX_EARLY_TOOLS = 64 + const val MAX_SETTLED_TOOLS = 256 + + fun bounded(cap: Int): LinkedHashMap = object : LinkedHashMap() { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean = size > cap + } + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/events/ToolEvents.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/events/ToolEvents.kt index d2a5e2c4..86931e4d 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/events/ToolEvents.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/events/ToolEvents.kt @@ -1,21 +1,16 @@ package dev.lain.claudejb.controller.session.events -import dev.lain.claudejb.controller.mcp.ToolOutput -import dev.lain.claudejb.controller.mcp.ToolOutputListener import dev.lain.claudejb.controller.session.ClaudeSession import dev.lain.claudejb.model.diff.DiffPresenter -import dev.lain.claudejb.model.diff.EditSnapshot import dev.lain.claudejb.model.mcp.Batch import dev.lain.claudejb.model.mcp.OwnTools import dev.lain.claudejb.model.permission.scan.ToolInputScanner import dev.lain.claudejb.model.protocol.ClaudeEvent import dev.lain.claudejb.model.session.agents.AgentStatus import dev.lain.claudejb.model.session.transcript.CardPlaces -import dev.lain.claudejb.model.session.transcript.LiveLines import dev.lain.claudejb.model.session.transcript.Speaker import dev.lain.claudejb.model.session.transcript.ToolNaming import dev.lain.claudejb.model.session.transcript.ToolState -import dev.lain.claudejb.model.session.transcript.TranscriptEntry import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -26,94 +21,67 @@ class ToolEvents( private val fireState: () -> Unit, ) { - private class Item(val id: String, val args: JsonObject) + private class Item(val id: String, val args: JsonObject, val review: OwnTools.Review?) private class Own(val call: OwnTools.Call, val items: List, val batch: Boolean) - private val live = HashMap>() - private val early = HashMap() + private val liveOutput = LiveToolOutput(s, edt) + private val reviews = EditReviews(s, edt) private val ownCalls = HashMap() - init { - s.project.messageBus.connect(s).subscribe( - ToolOutput.TOPIC, - ToolOutputListener { toolUseId, line -> edt { onLiveLine(toolUseId, line) } }, - ) - } - - private fun onLiveLine(toolUseId: String, line: String) { - if (!s.transcript.knowsTool(toolUseId)) { - early.getOrPut(toolUseId) { LiveLines() }.add(line) - return - } - val (entry, ring) = live.getOrPut(toolUseId) { - s.transcript.addToolOutput(toolUseId, "", meta = LIVE) to (early.remove(toolUseId) ?: LiveLines()) - } - ring.add(line) - s.transcript.replaceText(entry, ring.render()) - } - - private fun flushEarly(toolUseId: String) { - val ring = early.remove(toolUseId) ?: return - val entry = s.transcript.addToolOutput(toolUseId, ring.render(), meta = LIVE) - live[toolUseId] = entry to ring - } - - fun onToolUse(event: ClaudeEvent.ToolUse) = edt { - if (event.parentToolUseId == null) s.reconciler.onMessageBoundary() - val own = OwnTools.parse(event.name, event.input) - if (own != null) { - ownCalls[event.id] = ownUse(own, event) - return@edt - } - s.transcript.add( - Speaker.TOOL, - ToolNaming.formatToolUse(event.name, event.input, s.project.basePath), - meta = event.name, - toolUseId = event.id, - parentToolUseId = event.parentToolUseId, - toolState = ToolState.LOADING, - filePath = ToolNaming.toolFilePath(event.name, event.input, s.project.basePath), - commandText = ToolInputScanner.commandText(event.input), - messageText = ToolInputScanner.messageText(event.input), - reviewable = event.name in DiffPresenter.REVIEWABLE_TOOLS, - ) - flushEarly(event.id) - if (event.name in DiffPresenter.REVIEWABLE_TOOLS) { - s.diffs.captureForReview(event.name, event.input, event.id) - s.prompts.bindTool(event.id) + fun onToolUse(event: ClaudeEvent.ToolUse) { + val own = OwnTools.parse(event.name, event.input)?.let { plan(it, event) } + val reviewable = own == null && event.name in DiffPresenter.REVIEWABLE_TOOLS + if (reviewable) reviews.capture(event.name, event.input, event.id) + own?.items?.forEach { item -> item.review?.let { reviews.capture(it, item.id) } } + edt { + if (event.parentToolUseId == null) s.reconciler.onMessageBoundary() + if (own != null) { + ownCalls[event.id] = own + own.items.forEach { ownRow(it, event) } + return@edt + } + s.transcript.add( + Speaker.TOOL, + ToolNaming.formatToolUse(event.name, event.input, s.project.basePath), + meta = event.name, + toolUseId = event.id, + parentToolUseId = event.parentToolUseId, + toolState = ToolState.LOADING, + filePath = ToolNaming.toolFilePath(event.name, event.input, s.project.basePath), + commandText = ToolInputScanner.commandText(event.input), + messageText = ToolInputScanner.messageText(event.input), + reviewable = reviewable, + ) + liveOutput.flushEarly(event.id) + if (reviewable) s.prompts.bindTool(event.id) } } - private fun ownUse(own: OwnTools.Call, event: ClaudeEvent.ToolUse): Own { + private fun plan(own: OwnTools.Call, event: ClaudeEvent.ToolUse): Own { val args = OwnTools.argsOf(event.input) val split = Batch.split(own.argument, args) val items = (split ?: listOf(args)).mapIndexed { index, itemArgs -> val id = if (split == null) event.id else Batch.itemId(event.id, index) - val review = OwnTools.reviewAs(own, itemArgs, s.project.basePath)?.let(::asWrite) - s.transcript.add( - Speaker.TOOL, - OwnTools.label(own, itemArgs), - meta = event.name, - toolUseId = id, - parentToolUseId = event.parentToolUseId, - toolState = ToolState.LOADING, - filePath = OwnTools.path(itemArgs), - messageText = if (review == null) OwnTools.argsToon(itemArgs) else null, - reviewable = review != null, - ) - flushEarly(id) - review?.let { s.diffs.captureForReview(it.toolName, it.input, id) } - Item(id, itemArgs) + Item(id, itemArgs, OwnTools.reviewAs(own, itemArgs, s.project.basePath)) } return Own(own, items, split != null) } - private fun asWrite(review: OwnTools.Review): OwnTools.Review? { - if (review.toolName != OwnTools.INSERT) return review - val path = DiffPresenter.filePathOf(review.input) ?: return null - val before = DiffPresenter.readCurrent(path, s.project.basePath) ?: return null - return OwnTools.asWrite(review, before) + private fun ownRow(item: Item, event: ClaudeEvent.ToolUse) { + val call = ownCalls[event.id]?.call ?: return + s.transcript.add( + Speaker.TOOL, + OwnTools.label(call, item.args), + meta = event.name, + toolUseId = item.id, + parentToolUseId = event.parentToolUseId, + toolState = ToolState.LOADING, + filePath = OwnTools.path(item.args), + messageText = if (item.review == null) OwnTools.argsToon(item.args) else null, + reviewable = item.review != null, + ) + liveOutput.flushEarly(item.id) } fun onToolResult(event: ClaudeEvent.ToolResult) = edt { @@ -121,29 +89,32 @@ class ToolEvents( s.poll.ensureOutputTail() fireState() } - if (!event.isError) { - s.diffs.refreshTouched() - if (ToolNaming.mayHaveWrittenUnknownFiles(s.transcript.toolNameOf(event.toolUseId))) { - s.diffs.refreshProjectTree() - } - } val own = ownCalls.remove(event.toolUseId) + if (!event.isError) refreshAfter(event.toolUseId, own) if (own != null) { ownResult(event, own) return@edt } settle(event.toolUseId, event.isError) - val diff = s.diffs.onToolResult(event.toolUseId)?.let(::diffOf) - if (diff != null) { - s.transcript.addToolOutput(event.toolUseId, diff, meta = DIFF) - return@edt + val text = event.content.trim() + reviews.settle(event.toolUseId) { diff -> + if (diff != null) { + s.transcript.addToolOutput(event.toolUseId, diff, meta = DIFF) + } else { + rawOutput(event.toolUseId, text, event.isError) + } + } + } + + private fun refreshAfter(toolUseId: String, own: Own?) { + s.diffs.refreshTouched() + if (own == null && ToolNaming.mayHaveWrittenUnknownFiles(s.transcript.toolNameOf(toolUseId))) { + s.diffs.refreshProjectTree() } - rawOutput(event.toolUseId, event.content.trim(), event.isError) } private fun settle(toolUseId: String, failed: Boolean) { - live.remove(toolUseId) - early.remove(toolUseId) + liveOutput.settle(toolUseId) if (s.runningAgents.nodes.values.none { it.meta.toolUseId == toolUseId }) { s.transcript.setToolState(toolUseId, if (failed) ToolState.ERROR else ToolState.FINISHED) } @@ -160,12 +131,20 @@ class ToolEvents( val result = results.getOrNull(index) val error = result?.let { errorOf(it) } settle(item.id, event.isError || result == null || error != null) - val diff = s.diffs.onToolResult(item.id)?.let(::diffOf) when { - result == null -> if (index == 0) rawOutput(item.id, text, event.isError) - error != null -> s.transcript.addToolOutput(item.id, error, meta = ERROR) - diff != null -> s.transcript.addToolOutput(item.id, diff, meta = DIFF) - else -> ownOutput(own.call, item, result) + result == null -> { + reviews.forget(item.id) + if (index == 0) rawOutput(item.id, text, event.isError) + } + + error != null -> { + reviews.forget(item.id) + s.transcript.addToolOutput(item.id, error, meta = ERROR) + } + + else -> reviews.settle(item.id) { diff -> + if (diff != null) s.transcript.addToolOutput(item.id, diff, meta = DIFF) else ownOutput(own.call, item, result) + } } } } @@ -189,15 +168,10 @@ class ToolEvents( s.transcript.addToolOutput(toolUseId, text, meta = tags.joinToString(" ").ifBlank { null }) } - private fun diffOf(snap: EditSnapshot): String? = DiffPresenter.proposedContent(snap.toolName, snap.input, snap.beforeText) - ?.let { DiffPresenter.unifiedDiff(snap.beforeText, it) } - ?.takeIf { it.isNotBlank() } - private fun errorOf(result: JsonObject): String? = (result[ERROR] as? JsonPrimitive)?.takeIf { it.isString }?.content private companion object { const val TOON = "toon" - const val LIVE = "live" const val DIFF = "diff" const val ERROR = "error" } From c1fecd9ae2ebb255e7d015e85d79793b64614f48 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:53:19 +0200 Subject: [PATCH 020/155] fix(session): dispose closed chats through the Disposer and persist open tabs off the EDT --- .../claudejb/controller/session/ChatSessionManager.kt | 9 +++++---- .../dev/lain/claudejb/model/settings/ClaudeSettings.kt | 9 +++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/ChatSessionManager.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/ChatSessionManager.kt index 83c77e7a..61483be8 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/ChatSessionManager.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/ChatSessionManager.kt @@ -4,6 +4,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer import dev.lain.claudejb.controller.session.history.SessionHistory import dev.lain.claudejb.model.session.launch.LaunchOptions import dev.lain.claudejb.model.settings.ClaudeSettings @@ -67,21 +68,21 @@ class ChatSessionManager(private val project: Project) : Disposable { fun remove(session: ClaudeSession) { if (!sessions.remove(session)) return - session.dispose() + Disposer.dispose(session) if (active == session) active = sessions.lastOrNull() persistOpenTabs() fireChanged() } private fun persistOpenTabs() { - SessionHistory.getInstance(project) - .setOpenSessions(sessions.filterNot { it.gitIntegration }.mapNotNull { it.sessionId }) + val ids = sessions.filterNot { it.gitIntegration }.mapNotNull { it.sessionId } + ClaudeSettings.persist { if (!project.isDisposed) SessionHistory.getInstance(project).setOpenSessions(ids) } } private fun fireChanged() = listeners.forEach { it() } override fun dispose() { - sessions.forEach { it.dispose() } + sessions.forEach { Disposer.dispose(it) } sessions.clear() } diff --git a/src/main/kotlin/dev/lain/claudejb/model/settings/ClaudeSettings.kt b/src/main/kotlin/dev/lain/claudejb/model/settings/ClaudeSettings.kt index c3a1026e..94851da0 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/settings/ClaudeSettings.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/settings/ClaudeSettings.kt @@ -9,6 +9,7 @@ import com.intellij.util.concurrency.AppExecutorUtil import dev.lain.claudejb.model.settings.guard.AlwaysAllowTools import dev.lain.claudejb.model.settings.guard.GuardMode import dev.lain.claudejb.model.settings.legacy.LegacyProjectSettings +import java.util.concurrent.CompletableFuture @Service(Service.Level.PROJECT) class ClaudeSettings(internal val project: Project? = null) { @@ -202,10 +203,12 @@ class ClaudeSettings(internal val project: Project? = null) { fun isToolAlwaysAllowed(toolName: String): Boolean = toolName in alwaysAllow - fun warm() { - state + private val warmed: CompletableFuture by lazy { + CompletableFuture.supplyAsync({ also { state } }, AppExecutorUtil.getAppExecutorService()) } + fun warm(): CompletableFuture = warmed + companion object { const val DEFAULT_GUARD_LOG_RETENTION_DAYS = 30 @@ -213,6 +216,8 @@ class ClaudeSettings(internal val project: Project? = null) { private val writes = AppExecutorUtil.createBoundedApplicationPoolExecutor("Claude Code settings", 1) + fun persist(task: () -> Unit) = writes.execute(task) + @org.jetbrains.annotations.TestOnly fun awaitWrites() { writes.submit(Runnable { }).get() From fcc8b2943a6039b4bb3bb9b36a675f9c7a0ac6ad Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:53:27 +0200 Subject: [PATCH 021/155] fix(mcp): keep a server alive when one frame is malformed --- .../claudejb/controller/mcp/ServerEndpoint.kt | 92 ++++++++++++++----- .../lain/claudejb/model/mcp/Cancellation.kt | 9 ++ .../dev/lain/claudejb/model/mcp/JsonRpc.kt | 3 +- .../claudejb/model/mcp/toon/ToonHeader.kt | 11 ++- .../lain/claudejb/model/mcp/toon/ToonLines.kt | 6 +- .../lain/claudejb/model/mcp/toon/ToonText.kt | 9 +- .../controller/mcp/ServerEndpointTest.kt | 34 ++++++- .../claudejb/model/mcp/toon/ToonLimitsTest.kt | 41 +++++++++ 8 files changed, 172 insertions(+), 33 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/model/mcp/Cancellation.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/model/mcp/toon/ToonLimitsTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/ServerEndpoint.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/ServerEndpoint.kt index a548aca0..a04d3eb7 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/ServerEndpoint.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/ServerEndpoint.kt @@ -5,20 +5,23 @@ import dev.lain.claudejb.mcp.StdioBridge import dev.lain.claudejb.model.mcp.JsonRpc import dev.lain.claudejb.model.mcp.McpServer import dev.lain.claudejb.model.mcp.TokenRing +import dev.lain.claudejb.model.mcp.rethrowIfCancelled import dev.lain.claudejb.model.mcp.toon.Toon -import dev.lain.claudejb.model.mcp.toon.ToonException import dev.lain.claudejb.model.session.launch.IdeServer import dev.lain.claudejb.util.thisLogger import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import java.io.InputStream @@ -48,26 +51,47 @@ internal class ServerEndpoint( private val channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX).bind(UnixDomainSocketAddress.of(socket)) .also { ownerOnly(socket) } private val inFlight = AtomicInteger() + private val clients: MutableSet = ConcurrentHashMap.newKeySet() fun start(): Job = scope.launch(io) { - while (isActive) { - val client = runCatching { channel.accept() }.getOrNull() ?: break - launch { if (admit()) Connection(client).serve() else runCatching { client.close() } } + supervisorScope { + while (isActive) { + val client = withContext(io) { runCatching { channel.accept() }.getOrNull() } ?: break + clients += client + launch { attend(client) } + } } } fun close() { runCatching { channel.close() } + clients.forEach { runCatching { it.close() } } + } + + private suspend fun attend(client: SocketChannel) { + try { + if (admit()) Connection(client).serve() + } catch (e: Exception) { + rethrowIfCancelled(e) + log.warn("${server.key}: a connection failed", e) + } finally { + release(client) + } } - private inner class Connection(private val client: SocketChannel) { + private fun release(client: SocketChannel) { + clients -= client + runCatching { client.close() } + } + + private inner class Connection(client: SocketChannel) { + private val input: InputStream = Channels.newInputStream(client) private val output: OutputStream = Channels.newOutputStream(client) private val writing = Mutex() private val jobs = ConcurrentHashMap() suspend fun serve() = withContext(io) { - val input: InputStream = Channels.newInputStream(client) try { while (true) { val frame = runCatching { Frames.read(input) }.getOrNull() ?: break @@ -75,22 +99,25 @@ internal class ServerEndpoint( } } finally { jobs.values.forEach { it.cancel() } - runCatching { client.close() } } } private suspend fun receive(frame: String) { - val message = try { - Toon.decode(frame) - } catch (ignored: ToonException) { + val message = runCatching { Toon.decode(frame) }.getOrElse { log.debug { "${server.key}: unreadable frame" } send(JsonRpc.error(null, JsonRpc.PARSE_ERROR, "Parse error")) return } - when (val parsed = JsonRpc.parse(message)) { - is JsonRpc.Request -> request(parsed, message) - is JsonRpc.Notification -> notification(parsed, message) - else -> mcp.handle(message)?.let { send(it) } + try { + when (val parsed = JsonRpc.parse(message)) { + is JsonRpc.Request -> request(parsed, message) + is JsonRpc.Notification -> notification(parsed, message) + else -> mcp.handle(message)?.let { send(it) } + } + } catch (e: Exception) { + rethrowIfCancelled(e) + log.warn("${server.key}: a frame could not be handled", e) + idOf(message)?.let { send(JsonRpc.error(it, JsonRpc.INTERNAL_ERROR, FAILED)) } } } @@ -107,14 +134,21 @@ internal class ServerEndpoint( } val key = request.id.toString() inFlight.incrementAndGet() - jobs[key] = scope.launch { - try { - mcp.handle(message)?.let { send(it) } - } finally { - inFlight.decrementAndGet() - jobs.remove(key) - } + val job = scope.launch(start = CoroutineStart.LAZY) { answer(request.id, message)?.let { send(it) } } + jobs[key] = job + job.invokeOnCompletion { + inFlight.decrementAndGet() + jobs.remove(key, job) } + job.start() + } + + private suspend fun answer(id: JsonElement, message: JsonElement): JsonObject? = try { + mcp.handle(message) + } catch (e: Exception) { + rethrowIfCancelled(e) + log.warn("${server.key}: a request failed", e) + JsonRpc.error(id, JsonRpc.INTERNAL_ERROR, FAILED) } private suspend fun notification(notification: JsonRpc.Notification, message: JsonElement) { @@ -129,14 +163,26 @@ internal class ServerEndpoint( private fun authorized(params: JsonObject): Boolean = tokens.accepts((JsonRpc.meta(params)[StdioBridge.TOKEN_KEY] as? JsonPrimitive)?.content) - private suspend fun send(reply: JsonObject) = writing.withLock { - withContext(io) { runCatching { Frames.write(output, Toon.encode(reply)) } } + private fun idOf(message: JsonElement): JsonElement? = (message as? JsonObject)?.get("id")?.takeUnless { it is JsonNull } + + private suspend fun send(reply: JsonObject) { + val text = encoded(reply) + writing.withLock { withContext(io) { runCatching { Frames.write(output, text) } } } + } + + private fun encoded(reply: JsonObject): String = try { + Toon.encode(reply) + } catch (e: RuntimeException) { + log.warn("${server.key}: a reply could not be encoded", e) + Toon.encode(JsonRpc.error(reply["id"], JsonRpc.INTERNAL_ERROR, UNENCODABLE)) } } companion object { const val QUEUE_DEPTH = 16 const val REJECTED = "request rejected" + const val FAILED = "the IDE failed while answering; the server stays up, retry or narrow the request" + const val UNENCODABLE = "the IDE's answer could not be encoded as TOON; narrow the request" private const val CANCELLED = "notifications/cancelled" private val OWNER_ONLY = PosixFilePermissions.fromString("rw-------") diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/Cancellation.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/Cancellation.kt new file mode 100644 index 00000000..70300532 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/Cancellation.kt @@ -0,0 +1,9 @@ +package dev.lain.claudejb.model.mcp + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive + +suspend fun rethrowIfCancelled(cause: Throwable) { + if (cause is CancellationException && !currentCoroutineContext().isActive) throw cause +} diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/JsonRpc.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/JsonRpc.kt index c0c62d58..d3f635ba 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/mcp/JsonRpc.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/JsonRpc.kt @@ -5,7 +5,6 @@ import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put object JsonRpc { @@ -34,7 +33,7 @@ object JsonRpc { val method = (obj["method"] as? JsonPrimitive)?.takeIf { it.isString }?.content val params = obj["params"]?.let { it as? JsonObject ?: return Malformed(id, "params must be an object") } return when { - obj["jsonrpc"]?.jsonPrimitive?.content != VERSION -> Malformed(id, "jsonrpc must be \"$VERSION\"") + (obj["jsonrpc"] as? JsonPrimitive)?.content != VERSION -> Malformed(id, "jsonrpc must be \"$VERSION\"") method == null && id != null -> Reply(id) method == null -> Malformed(null, "a message carries a method or an id") id == null -> Notification(method, params ?: EMPTY) diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/toon/ToonHeader.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/toon/ToonHeader.kt index 05056e5f..29403a69 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/mcp/toon/ToonHeader.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/toon/ToonHeader.kt @@ -22,7 +22,7 @@ internal object ToonHeader { if (content.getOrNull(at) == '{') { val end = ToonScan.matchingBrace(content, at) if (end < 0) return reject(strict, "unmatched brace in the field list of: $content") - fields = fields(content.substring(at + 1, end), segment.delimiter, strict) + fields = fields(content.substring(at + 1, end), segment.delimiter, strict, 1) ?: return reject(strict, "malformed field list in: $content") at = end + 1 } @@ -60,13 +60,14 @@ internal object ToonHeader { return Segment(length, keyed, delimiter) } - private fun fields(text: String, delimiter: Char, strict: Boolean): List? { + private fun fields(text: String, delimiter: Char, strict: Boolean, level: Int): List? { + if (level > ToonLines.MAX_DEPTH) toonError("a field list nests deeper than ${ToonLines.MAX_DEPTH} levels") val out = ArrayList() for (entry in entries(text, delimiter)) { val brace = ToonScan.indexOfUnquoted(entry, '{') val raw = ToonScan.trimSpaces(if (brace < 0) entry else entry.substring(0, brace)) if (raw.isEmpty() || mismatched(raw, delimiter)) return null - val children = if (brace < 0) null else nested(entry, brace, delimiter, strict) ?: return null + val children = if (brace < 0) null else nested(entry, brace, delimiter, strict, level) ?: return null val name = ToonText.keyOf(raw) if (strict && out.any { it.name == name }) toonError("duplicate field name $name in {$text}") out += Field(name, children) @@ -74,10 +75,10 @@ internal object ToonHeader { return out } - private fun nested(entry: String, brace: Int, delimiter: Char, strict: Boolean): List? { + private fun nested(entry: String, brace: Int, delimiter: Char, strict: Boolean, level: Int): List? { val end = ToonScan.matchingBrace(entry, brace) if (end != entry.lastIndex) return null - return fields(entry.substring(brace + 1, end), delimiter, strict) + return fields(entry.substring(brace + 1, end), delimiter, strict, level + 1) } private fun mismatched(name: String, delimiter: Char): Boolean = diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/toon/ToonLines.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/toon/ToonLines.kt index 60088a8c..d5645651 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/mcp/toon/ToonLines.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/toon/ToonLines.kt @@ -18,9 +18,13 @@ internal object ToonLines { if (options.strict && spaces % options.indentSize != 0) { toonError("indentation of line $number is not a multiple of ${options.indentSize}") } - return Line(number, spaces / options.indentSize + tabs, raw.substring(spaces + tabs)) + val depth = spaces / options.indentSize + tabs + if (depth > MAX_DEPTH) toonError("line $number nests deeper than $MAX_DEPTH levels") + return Line(number, depth, raw.substring(spaces + tabs)) } + const val MAX_DEPTH = 64 + private const val BOM = "" } diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/toon/ToonText.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/toon/ToonText.kt index 5c493470..43e9243d 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/mcp/toon/ToonText.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/toon/ToonText.kt @@ -67,7 +67,12 @@ internal object ToonText { } @OptIn(ExperimentalSerializationApi::class) - private fun number(token: String): JsonPrimitive = JsonUnquotedLiteral(canonical(BigDecimal(token))) + private fun number(token: String): JsonPrimitive { + if (token.length > MAX_NUMBER_LENGTH) toonError("a number longer than $MAX_NUMBER_LENGTH characters") + val value = token.toBigDecimalOrNull() ?: toonError("a number out of range: $token") + if (abs(value.precision().toLong() - value.scale()) > MAX_EXPONENT) toonError("a number out of range: $token") + return JsonUnquotedLiteral(canonical(value)) + } private fun escape(token: String, at: Int, out: StringBuilder): Int { val c = token.getOrNull(at) ?: toonError("unterminated escape in $token") @@ -124,6 +129,8 @@ internal object ToonText { } private const val HEX_DIGITS = 4 + private const val MAX_NUMBER_LENGTH = 400 + private const val MAX_EXPONENT = 400L private const val LEADING = " \t-#" private const val TRAILING = " \t" private const val STRUCTURAL = ":\"\\[]{}" diff --git a/src/test/kotlin/dev/lain/claudejb/controller/mcp/ServerEndpointTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/mcp/ServerEndpointTest.kt index 7b99334e..10bdfbe0 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/mcp/ServerEndpointTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/mcp/ServerEndpointTest.kt @@ -2,6 +2,7 @@ package dev.lain.claudejb.controller.mcp import dev.lain.claudejb.mcp.Frames import dev.lain.claudejb.mcp.StdioBridge +import dev.lain.claudejb.model.mcp.JsonRpc import dev.lain.claudejb.model.mcp.McpServer import dev.lain.claudejb.model.mcp.MetaTools import dev.lain.claudejb.model.mcp.OutputBudget @@ -57,7 +58,8 @@ class ServerEndpointTest { ToolResult("slow: done") } val fast = Tool(ToolSpec("fast", "answers at once")) { ToolResult("fast: done") } - val catalog = ToolCatalog(listOf(ToolDomain("d", "", listOf(slow, fast)))) + val lone = Tool(ToolSpec("lone", "answers half a surrogate pair")) { ToolResult("half \uD800 a pair") } + val catalog = ToolCatalog(listOf(ToolDomain("d", "", listOf(slow, fast, lone)))) val mcp = McpServer("code", "test", MetaTools(catalog, { _, _ -> null }, OutputBudget())) endpoint = ServerEndpoint(IdeServer.CODE, dir.resolve("code.sock"), mcp, tokens, scope, { admitted }) endpoint.start() @@ -124,6 +126,34 @@ class ServerEndpointTest { repeat(ServerEndpoint.QUEUE_DEPTH) { receive(input) } } + @Test + fun `malformed frames get an error and the server keeps answering`() = connect { input, output -> + send(output, """{"jsonrpc":{},"id":5,"method":"ping"}""") + assertEquals("5", receive(input)["id"]?.jsonPrimitive?.content) + Frames.write(output, "jsonrpc: \"2.0\"\nid: 1e99999999999\nmethod: ping") + assertEquals(JsonRpc.PARSE_ERROR, code(receive(input))) + Frames.write(output, (0 until 200).joinToString("\n") { " ".repeat(it) + "k$it:" }) + assertEquals(JsonRpc.PARSE_ERROR, code(receive(input))) + send(output, request(6, "ping", "{}")) + assertEquals("6", receive(input)["id"]?.jsonPrimitive?.content) + } + + @Test + fun `a reply that cannot be encoded is answered with an error, never left pending`() = connect { input, output -> + send(output, request(7, "tools/call", """{"name":"run","arguments":{"tool":"lone"}}""")) + val reply = receive(input) + assertEquals("7", reply["id"]?.jsonPrimitive?.content) + assertEquals(ServerEndpoint.UNENCODABLE, reply["error"]!!.jsonObject["message"]?.jsonPrimitive?.content) + } + + @Test + fun `closing the endpoint closes the connections it accepted`() = connect { input, output -> + send(output, request(1, "ping", "{}")) + receive(input) + endpoint.close() + assertNull(Frames.read(input)) + } + @Test fun `a connection the project does not admit is closed`() { admitted = false @@ -150,6 +180,8 @@ class ServerEndpointTest { private fun receive(input: InputStream): JsonObject = Toon.decode(Frames.read(input)!!).jsonObject + private fun code(reply: JsonObject): Int = reply["error"]!!.jsonObject["code"]!!.jsonPrimitive.content.toInt() + private fun text(reply: JsonObject): String = reply["result"]!!.jsonObject["content"]!!.jsonArray.single().jsonObject["text"]!!.jsonPrimitive.content } diff --git a/src/test/kotlin/dev/lain/claudejb/model/mcp/toon/ToonLimitsTest.kt b/src/test/kotlin/dev/lain/claudejb/model/mcp/toon/ToonLimitsTest.kt new file mode 100644 index 00000000..9d84eb21 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/model/mcp/toon/ToonLimitsTest.kt @@ -0,0 +1,41 @@ +package dev.lain.claudejb.model.mcp.toon + +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class ToonLimitsTest { + + @Test + fun `nesting deeper than the limit is a typed error, not a stack overflow`() { + val deep = (0 until 20_000).joinToString("\n") { " ".repeat(it) + "k$it:" } + assertThrows { Toon.decode(deep) } + val fields = "rows[1]{" + "a{".repeat(5_000) + "b" + "}".repeat(5_000) + "}:\n 1" + assertThrows { Toon.decode(fields) } + } + + @Test + fun `nesting within the limit still decodes`() { + val nested = (0 until ToonLines.MAX_DEPTH).joinToString("\n") { " ".repeat(it) + "k$it:" } + " 1" + var node = Toon.decode(nested).jsonObject + repeat(ToonLines.MAX_DEPTH - 1) { node = node.getValue("k$it").jsonObject } + assertEquals("1", node.getValue("k${ToonLines.MAX_DEPTH - 1}").jsonPrimitive.content) + } + + @Test + fun `a number beyond any sane range is a typed error`() { + assertThrows { Toon.decode("id: 1e99999999999") } + assertThrows { Toon.decode("id: 1e999999999") } + assertThrows { Toon.decode("id: " + "9".repeat(1_000)) } + } + + @Test + fun `ordinary numbers keep their canonical form`() { + val decoded = Toon.decode("a: 12\nb: -0.5\nc: 1e300").jsonObject + assertEquals("12", decoded.getValue("a").jsonPrimitive.content) + assertEquals("-0.5", decoded.getValue("b").jsonPrimitive.content) + assertEquals("1e+300", decoded.getValue("c").jsonPrimitive.content) + } +} From fca677a18bbe31b77b090917f0592b4a49ab313e Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:53:35 +0200 Subject: [PATCH 022/155] fix(mcp): answer every call and every batch item whatever the tool throws --- .../dev/lain/claudejb/model/mcp/Batch.kt | 30 +----- .../dev/lain/claudejb/model/mcp/BatchRows.kt | 88 +++++++++++++++++ .../dev/lain/claudejb/model/mcp/Clip.kt | 12 +++ .../dev/lain/claudejb/model/mcp/MetaTools.kt | 9 +- .../dev/lain/claudejb/model/mcp/ToolArgs.kt | 12 ++- .../dev/lain/claudejb/model/mcp/ToolResult.kt | 8 +- .../dev/lain/claudejb/model/mcp/ToolSpec.kt | 8 +- .../lain/claudejb/model/mcp/BatchRowsTest.kt | 99 +++++++++++++++++++ .../lain/claudejb/model/mcp/McpServerTest.kt | 31 ++++++ .../claudejb/model/mcp/OutputBudgetTest.kt | 28 ++++++ .../lain/claudejb/model/mcp/ToolArgsTest.kt | 38 +++++++ 11 files changed, 328 insertions(+), 35 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/model/mcp/BatchRows.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/model/mcp/Clip.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/model/mcp/BatchRowsTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/model/mcp/OutputBudgetTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/model/mcp/ToolArgsTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/Batch.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/Batch.kt index 30ad3688..41b37ba1 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/mcp/Batch.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/Batch.kt @@ -2,10 +2,9 @@ package dev.lain.claudejb.model.mcp import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put object Batch { @@ -87,16 +86,11 @@ object Batch { suspend fun run(args: ToolArgs, plural: Plural, one: suspend (ToolArgs) -> JsonObject): JsonObject { val items = expand(args, plural) ?: return one(args) - val rows = items.map { item -> row(item, plural.identity, one) } - return buildJsonObject { - put("count", rows.size) - put("failed", rows.count { it.containsKey("error") }) - put("items", JsonArray(rows)) - } + return BatchRows.summary(BatchRows.run(items, plural.identity, args.parallel, one), plural.identity) } fun expand(args: ToolArgs, plural: Plural): List? { - val list = args.json[plural.key] ?: return null + val list = args.json[plural.key]?.takeUnless { it is JsonNull } ?: return null val elements = checked(args, plural, list) val base = args.json.filterKeys { it != plural.key } return elements.mapIndexed { index, item -> @@ -105,7 +99,7 @@ object Batch { } private fun checked(args: ToolArgs, plural: Plural, list: JsonElement): JsonArray { - val clash = (plural.alone + plural.identity).firstOrNull { args.json.containsKey(it) } + val clash = (plural.alone + plural.identity).firstOrNull { args.json[it].let { value -> value != null && value !is JsonNull } } val problem = when { clash != null -> "give $clash or ${plural.key}, not both" list !is JsonArray -> "argument ${plural.key} must be an array" @@ -121,20 +115,4 @@ object Batch { element is JsonObject -> element else -> throw ToolException("every item of ${plural.key} must be a string or an object") } - - private suspend fun row(item: ToolArgs, identity: String, one: suspend (ToolArgs) -> JsonObject): JsonObject { - val id = item.optionalString(identity) - val result = try { - one(item) - } catch (e: ToolException) { - return buildJsonObject { - put(identity, id ?: "") - put("error", e.message ?: "failed") - } - } - return buildJsonObject { - if (id != null) put(identity, id) - result.forEach { (key, value) -> if (key != identity || id == null) put(key, value) } - } - } } diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/BatchRows.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/BatchRows.kt new file mode 100644 index 00000000..32a58a00 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/BatchRows.kt @@ -0,0 +1,88 @@ +package dev.lain.claudejb.model.mcp + +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal object BatchRows { + + const val PARALLEL = 4 + const val TRUNCATED = "truncated" + private const val ERROR = "error" + private val ZERO = JsonPrimitive(0) + val FALSE = JsonPrimitive(false) + + suspend fun run( + items: List, + identity: String, + parallel: Boolean, + one: suspend (ToolArgs) -> JsonObject, + ): List { + if (!parallel) return items.map { row(it, identity, one) } + val gate = Semaphore(PARALLEL) + return coroutineScope { items.map { async { gate.withPermit { row(it, identity, one) } } }.awaitAll() } + } + + fun summary(rows: List, identity: String): JsonObject { + val clean = rows.indices.filter { clean(rows[it], identity) }.toSet() + val fold = clean.isNotEmpty() && clean.size >= rows.size - clean.size + return buildJsonObject { + put("count", rows.size) + put("failed", rows.count { it.containsKey(ERROR) }) + if (fold) put("clean", buildJsonArray { clean.sorted().forEach { add(rows[it].getValue(identity)) } }) + put( + "items", + buildJsonArray { + rows.forEachIndexed { index, row -> + when { + !fold -> add(row) + index !in clean -> add(indexed(index, row)) + } + } + }, + ) + } + } + + private fun indexed(index: Int, row: JsonObject): JsonObject = buildJsonObject { + put("index", index) + row.forEach { (key, value) -> put(key, value) } + } + + private fun clean(row: JsonObject, identity: String): Boolean = + row[identity] is JsonPrimitive && + row.values.any { it is JsonArray } && + row.all { (key, value) -> key == identity || (key == "count" && value == ZERO) || (value is JsonArray && value.isEmpty()) } + + private suspend fun row(item: ToolArgs, identity: String, one: suspend (ToolArgs) -> JsonObject): JsonObject { + val id = (item.json[identity] as? JsonPrimitive)?.takeUnless { it is JsonNull }?.content + val result = try { + one(item) + } catch (e: Exception) { + rethrowIfCancelled(e) + return failed(identity, id, e) + } catch (e: LinkageError) { + return failed(identity, id, e) + } + return buildJsonObject { + if (id != null) put(identity, id) + result.forEach { (key, value) -> + if ((key != identity || id == null) && !(key == TRUNCATED && value == FALSE)) put(key, value) + } + } + } + + private fun failed(identity: String, id: String?, cause: Throwable): JsonObject = buildJsonObject { + put(identity, id ?: "") + put(ERROR, if (cause is ToolException) cause.message ?: "failed" else "${cause::class.simpleName}: ${cause.message}") + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/Clip.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/Clip.kt new file mode 100644 index 00000000..4a5f8d4d --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/Clip.kt @@ -0,0 +1,12 @@ +package dev.lain.claudejb.model.mcp + +object Clip { + + const val ELLIPSIS = "…" + + fun line(text: CharSequence, max: Int): String = + if (text.length <= max) text.toString() else text.subSequence(0, boundary(text, max)).toString() + ELLIPSIS + + fun boundary(text: CharSequence, end: Int): Int = + if (end in 1 until text.length && text[end - 1].isHighSurrogate() && text[end].isLowSurrogate()) end - 1 else end +} diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/MetaTools.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/MetaTools.kt index d6aee434..566c515a 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/mcp/MetaTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/MetaTools.kt @@ -86,15 +86,18 @@ class MetaTools(private val catalog: ToolCatalog, private val gate: ToolGate, pr gate.denial(tool.spec, arguments)?.let { return ToolResult.error(it) } val args = arguments["args"]?.let { it as? JsonObject } ?: JsonObject(emptyMap()) val toolUseId = (meta[OwnTools.TOOL_USE_ID_KEY] as? JsonPrimitive)?.content - val result = runCatching { withTimeout(tool.spec.timeoutMillis) { tool.run(ToolArgs(args, toolUseId)) } } - .getOrElse { failure(name, tool.spec.timeoutMillis, it) } + val result = runCatching { withTimeout(tool.spec.timeoutMillis) { tool.run(ToolArgs(args, toolUseId, tool.spec.parallel)) } } + .getOrElse { + rethrowIfCancelled(it) + failure(name, tool.spec.timeoutMillis, it) + } return ToolResult(budget.fit(result.text), result.isError) } private fun failure(name: String, timeoutMillis: Long, cause: Throwable): ToolResult = when (cause) { is ToolException -> ToolResult.error(cause.message ?: "tool failed") is TimeoutCancellationException -> ToolResult.error(overrun(name, timeoutMillis, cause)) - is CancellationException -> throw cause + is CancellationException -> ToolResult.error("$name was interrupted by the IDE (${cause::class.simpleName}); retry it") is LinkageError -> ToolResult.error("$name needs an API this IDE build does not have: ${cause.message}") else -> ToolResult.error("$name failed: ${cause::class.simpleName}: ${cause.message}") } diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolArgs.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolArgs.kt index ef36d75f..9a64f961 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolArgs.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolArgs.kt @@ -1,24 +1,26 @@ package dev.lain.claudejb.model.mcp import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive class ToolException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) -class ToolArgs(val json: JsonObject, val toolUseId: String? = null) { +class ToolArgs(val json: JsonObject, val toolUseId: String? = null, val parallel: Boolean = false) { fun string(key: String): String = optionalString(key) ?: throw ToolException("missing argument: $key") fun strings(key: String): List { - val value = json[key] ?: return emptyList() + val value = present(key) ?: return emptyList() val items = (value as? JsonArray)?.map { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content } if (items == null || items.any { it == null }) throw ToolException("argument $key must be an array of strings") return items.filterNotNull() } fun optionalString(key: String): String? { - val value = json[key] ?: return null + val value = present(key) ?: return null val primitive = value as? JsonPrimitive ?: throw ToolException("argument $key must be a string") return primitive.content } @@ -28,6 +30,8 @@ class ToolArgs(val json: JsonObject, val toolUseId: String? = null) { return raw.toIntOrNull() ?: throw ToolException("argument $key must be an integer") } + fun max(default: Int, ceiling: Int): Int = int("max", default).coerceIn(1, ceiling) + fun boolean(key: String, default: Boolean): Boolean = optionalBoolean(key) ?: default fun optionalBoolean(key: String): Boolean? = when (optionalString(key)) { @@ -36,4 +40,6 @@ class ToolArgs(val json: JsonObject, val toolUseId: String? = null) { "false" -> false else -> throw ToolException("argument $key must be a boolean") } + + private fun present(key: String): JsonElement? = json[key]?.takeUnless { it is JsonNull } } diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolResult.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolResult.kt index f725d2cc..4598f7a4 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolResult.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolResult.kt @@ -2,6 +2,7 @@ package dev.lain.claudejb.model.mcp import dev.lain.claudejb.model.mcp.toon.Toon import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -9,7 +10,10 @@ class ToolResult(val text: String, val isError: Boolean = false) { companion object { - fun toon(value: JsonElement): ToolResult = ToolResult(Toon.encode(value)) + fun toon(value: JsonElement): ToolResult = ToolResult(Toon.encode(lean(value))) + + private fun lean(value: JsonElement): JsonElement = + if (value is JsonObject && value[BatchRows.TRUNCATED] == BatchRows.FALSE) JsonObject(value - BatchRows.TRUNCATED) else value fun error(message: String): ToolResult = ToolResult(Toon.encode(buildJsonObject { put("error", message) }), isError = true) } @@ -20,7 +24,7 @@ class OutputBudget(val maxChars: Int = DEFAULT_MAX_CHARS) { fun fit(text: String): String { if (text.length <= maxChars) return text val room = maxChars - NOTICE_ROOM - val cut = text.lastIndexOf('\n', room).takeIf { it > 0 } ?: room + val cut = text.lastIndexOf('\n', room).takeIf { it > 0 } ?: Clip.boundary(text, room) return text.substring(0, cut) + "\n# truncated: $cut of ${text.length} chars shown; narrow the request" } diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolSpec.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolSpec.kt index ee76018d..a4ca5677 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolSpec.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolSpec.kt @@ -12,6 +12,7 @@ data class ToolSpec( val params: List = emptyList(), val mutates: Boolean = false, val timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS, + val parallel: Boolean = !mutates, ) { val inputSchema: JsonObject get() = buildJsonObject { @@ -54,7 +55,12 @@ data class Param( val type: String = "string", val required: Boolean = true, val items: Items? = null, -) +) { + companion object { + fun max(what: String, default: Int, ceiling: Int): Param = + Param("max", "Maximum $what to return (default $default, at most $ceiling)", type = "integer", required = false) + } +} class Tool(val spec: ToolSpec, val run: suspend (ToolArgs) -> ToolResult) diff --git a/src/test/kotlin/dev/lain/claudejb/model/mcp/BatchRowsTest.kt b/src/test/kotlin/dev/lain/claudejb/model/mcp/BatchRowsTest.kt new file mode 100644 index 00000000..bc66199a --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/model/mcp/BatchRowsTest.kt @@ -0,0 +1,99 @@ +package dev.lain.claudejb.model.mcp + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration.Companion.milliseconds + +class BatchRowsTest { + + private fun args(json: String, parallel: Boolean = false) = ToolArgs(Json.parseToJsonElement(json).jsonObject, "tu_1", parallel) + + private fun items(out: JsonObject): List = out["items"]!!.jsonArray.map { it.jsonObject } + + @Test + fun `any failure of one item is that item's error, and the items after it still run`() = runBlocking { + val applied = mutableListOf() + val out = Batch.run(args("""{"paths":["a.kt","boom.kt","pce.kt","c.kt"]}"""), Batch.PATHS) { item -> + val path = item.string("path") + if (path == "boom.kt") throw IllegalStateException("no document") + if (path == "pce.kt") throw CancellationException("a read action was interrupted") + applied += path + buildJsonObject { put("text", path) } + } + assertEquals(listOf("a.kt", "c.kt"), applied) + assertEquals(2, out["failed"]!!.jsonPrimitive.content.toInt()) + val rows = items(out) + assertEquals("IllegalStateException: no document", rows[1]["error"]!!.jsonPrimitive.content) + assertTrue("interrupted" in rows[2]["error"]!!.jsonPrimitive.content) + assertEquals("c.kt", rows[3]["text"]!!.jsonPrimitive.content) + } + + @Test + fun `read-only items run a few at a time and come back in request order`() = runBlocking { + val running = AtomicInteger() + val peak = AtomicInteger() + val paths = (1..10).joinToString(",") { "\"f$it.kt\"" } + val out = Batch.run(args("""{"paths":[$paths]}""", parallel = true), Batch.PATHS) { item -> + peak.accumulateAndGet(running.incrementAndGet(), ::maxOf) + delay(20.milliseconds) + running.decrementAndGet() + buildJsonObject { put("text", item.string("path")) } + } + assertEquals((1..10).map { "f$it.kt" }, items(out).map { it["path"]!!.jsonPrimitive.content }) + assertTrue(peak.get() in 2..BatchRows.PARALLEL) { "peak ${peak.get()}" } + } + + @Test + fun `clean items fold into one line and the rest keep their request index`() = runBlocking { + val out = Batch.run(args("""{"paths":["a.kt","b.kt","c.kt"]}"""), Batch.PATHS) { item -> + val dirty = item.string("path") == "b.kt" + buildJsonObject { + put("count", if (dirty) 1 else 0) + put("truncated", false) + put("problems", if (dirty) buildJsonArray { add(buildJsonObject { put("line", 3) }) } else JsonArray(emptyList())) + } + } + assertEquals(listOf("a.kt", "c.kt"), out["clean"]!!.jsonArray.map { it.jsonPrimitive.content }) + val row = items(out).single() + assertEquals(1, row["index"]!!.jsonPrimitive.content.toInt()) + assertEquals("b.kt", row["path"]!!.jsonPrimitive.content) + assertNull(row["truncated"]) + } + + @Test + fun `a batch with few clean items stays positional`() = runBlocking { + val out = Batch.run(args("""{"paths":["a.kt","b.kt","c.kt"]}"""), Batch.PATHS) { item -> + val clean = item.string("path") == "a.kt" + buildJsonObject { put("matches", if (clean) JsonArray(emptyList()) else buildJsonArray { add(buildJsonObject { put("line", 1) }) }) } + } + assertNull(out["clean"]) + assertEquals(listOf("a.kt", "b.kt", "c.kt"), items(out).map { it["path"]!!.jsonPrimitive.content }) + assertNull(items(out)[0]["index"]) + } + + @Test + fun `a truncated flag that is false is left out of a single answer`() { + val answer = buildJsonObject { + put("truncated", false) + put("count", 1) + } + val text = ToolResult.toon(answer).text + assertEquals("count: 1", text) + assertTrue("truncated: true" in ToolResult.toon(buildJsonObject { put("truncated", true) }).text) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/model/mcp/McpServerTest.kt b/src/test/kotlin/dev/lain/claudejb/model/mcp/McpServerTest.kt index 26806f2b..72b7ef02 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/mcp/McpServerTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/mcp/McpServerTest.kt @@ -1,6 +1,10 @@ package dev.lain.claudejb.model.mcp +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -31,6 +35,10 @@ class McpServerTest { ToolResult.toon(buildJsonObject { put("path", args.string("path")) }) }, Tool(ToolSpec("fail", "Always fails")) { throw ToolException("boom") }, + Tool(ToolSpec("interrupted", "Throws a cancellation while nothing was cancelled")) { + throw CancellationException("read action interrupted") + }, + Tool(ToolSpec("crash", "Throws what no tool should")) { throw IllegalStateException("no document") }, ), ), ToolDomain("search", "Text and files", emptyList()), @@ -132,6 +140,29 @@ class McpServerTest { assertEquals("error: boom", text(result)) } + @Test + fun `a cancellation thrown while the call is still active is a tool error, not a request left without an answer`() { + val result = callTool("run", """{"tool":"interrupted"}""") + assertTrue(result["isError"]!!.jsonPrimitive.boolean) + assertTrue("interrupted" in text(result)) { text(result) } + val crash = callTool("run", """{"tool":"crash"}""") + assertTrue(crash["isError"]!!.jsonPrimitive.boolean) + assertTrue("IllegalStateException: no document" in text(crash)) { text(crash) } + } + + @Test + fun `a cancellation of the call itself still propagates`() { + val tool = Tool(ToolSpec("hang", "Waits forever")) { awaitCancellation() } + val meta = MetaTools(ToolCatalog(listOf(ToolDomain("d", "", listOf(tool)))), { _, _ -> null }, OutputBudget()) + runBlocking { + val call = async { meta.call("run", buildJsonObject { put("tool", "hang") }) } + yield() + call.cancel() + val outcome = runCatching { call.await() } + assertTrue(outcome.exceptionOrNull() is CancellationException) { outcome.toString() } + } + } + @Test fun `an unknown inner tool is a tool error and an unknown meta tool a protocol error`() { assertTrue("unknown tool nope" in text(callTool("run", """{"tool":"nope"}"""))) diff --git a/src/test/kotlin/dev/lain/claudejb/model/mcp/OutputBudgetTest.kt b/src/test/kotlin/dev/lain/claudejb/model/mcp/OutputBudgetTest.kt new file mode 100644 index 00000000..daf5047c --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/model/mcp/OutputBudgetTest.kt @@ -0,0 +1,28 @@ +package dev.lain.claudejb.model.mcp + +import dev.lain.claudejb.model.mcp.toon.Toon +import kotlinx.serialization.json.JsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class OutputBudgetTest { + + @Test + fun `a cut never falls inside a surrogate pair, so the reply still encodes`() { + val budget = OutputBudget(200) + for (shift in 0..3) { + val text = "x".repeat(shift) + "😀".repeat(200) + val fitted = budget.fit(text) + Toon.encode(JsonPrimitive(fitted)) + assertTrue(fitted.length <= 200) { fitted } + } + } + + @Test + fun `a clipped line keeps whole code points and ends with an ellipsis`() { + val clipped = Clip.line("a" + "😀".repeat(10), 4) + assertEquals("a😀" + Clip.ELLIPSIS, clipped) + assertEquals("short", Clip.line("short", 10)) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/model/mcp/ToolArgsTest.kt b/src/test/kotlin/dev/lain/claudejb/model/mcp/ToolArgsTest.kt new file mode 100644 index 00000000..8eaa571f --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/model/mcp/ToolArgsTest.kt @@ -0,0 +1,38 @@ +package dev.lain.claudejb.model.mcp + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class ToolArgsTest { + + private fun args(json: String) = ToolArgs(Json.parseToJsonElement(json).jsonObject) + + @Test + fun `a JSON null is an absent argument, never the text null`() { + val a = args("""{"path":null,"max":null,"regex":null,"paths":null}""") + assertNull(a.optionalString("path")) + assertEquals(7, a.int("max", 7)) + assertEquals(false, a.boolean("regex", false)) + assertEquals(emptyList(), a.strings("paths")) + assertThrows { a.string("path") } + assertNull(Batch.expand(a, Batch.PATHS)) + } + + @Test + fun `a null singular beside its plural is no clash`() { + val items = Batch.expand(args("""{"path":null,"paths":["a.kt","b.kt"]}"""), Batch.PATHS)!! + assertEquals(listOf("a.kt", "b.kt"), items.map { it.string("path") }) + } + + @Test + fun `max falls back to its default and never passes its ceiling`() { + assertEquals(50, args("{}").max(50, 500)) + assertEquals(500, args("""{"max":100000}""").max(50, 500)) + assertEquals(1, args("""{"max":0}""").max(50, 500)) + assertEquals(20, args("""{"max":"20"}""").max(50, 500)) + } +} From 64c458df7ae98f231f2f3040e1ce57f21757e27d Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:53:36 +0200 Subject: [PATCH 023/155] perf(mcp): number the lines of replace_all incrementally --- .../kotlin/dev/lain/claudejb/model/mcp/TextEdit.kt | 14 ++++++++++++-- .../dev/lain/claudejb/model/mcp/TextEditTest.kt | 12 ++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/TextEdit.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/TextEdit.kt index 83947a6f..5fe9d2f3 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/mcp/TextEdit.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/TextEdit.kt @@ -8,11 +8,15 @@ object TextEdit { val starts = chosen(text, occurrences(text, old, new), all) val out = StringBuilder(text.length + (new.length - old.length) * starts.size) val lines = ArrayList(starts.size) + val added = newlines(new) var from = 0 + var line = 1 for (start in starts) { + line += newlines(text, from, start) out.append(text, from, start) - lines += newlines(out) + 1 + lines += line out.append(new) + line += added from = start + old.length } out.append(text, from, text.length) @@ -70,5 +74,11 @@ object TextEdit { return offset } - private fun newlines(text: CharSequence): Int = text.count { it == '\n' } + private fun newlines(text: CharSequence): Int = newlines(text, 0, text.length) + + private fun newlines(text: CharSequence, from: Int, to: Int): Int { + var count = 0 + for (i in from until to) if (text[i] == '\n') count++ + return count + } } diff --git a/src/test/kotlin/dev/lain/claudejb/model/mcp/TextEditTest.kt b/src/test/kotlin/dev/lain/claudejb/model/mcp/TextEditTest.kt index c94c8e7e..9ff33b01 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/mcp/TextEditTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/mcp/TextEditTest.kt @@ -40,6 +40,18 @@ class TextEditTest { assertEquals(listOf(2, 5), outcome.lines) } + @Test + fun `replace_all over a large file stays linear and numbers every line right`() { + val big = "x\n".repeat(100_000) + val started = System.nanoTime() + val outcome = TextEdit.replace(big, "x", "y\nz", all = true) + assertTrue(System.nanoTime() - started < 5_000_000_000L) { "replace_all took ${(System.nanoTime() - started) / 1_000_000} ms" } + assertEquals(100_000, outcome.count) + assertEquals(1, outcome.lines.first()) + assertEquals(3, outcome.lines[1]) + assertEquals(1 + 2 * 99_999, outcome.lines.last()) + } + @Test fun `an empty or identical old_string is refused before any search`() { assertThrows { TextEdit.replace(text, "", "x", all = true) } From 0cea2b602c444833bca27b16c03438a03840fa57 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:53:40 +0200 Subject: [PATCH 024/155] fix(mcp): report the exit code of the run that was started --- .../controller/mcp/tools/run/ProcessRun.kt | 27 +++++++++------ .../controller/mcp/tools/run/RunIdentity.kt | 14 ++++++++ .../mcp/tools/run/RunIdentityTest.kt | 34 +++++++++++++++++++ 3 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/RunIdentity.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/run/RunIdentityTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/ProcessRun.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/ProcessRun.kt index 6e180345..e633777f 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/ProcessRun.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/ProcessRun.kt @@ -39,8 +39,13 @@ internal class ProcessRun(private val project: Project) { val pending = Pending() val connection = project.messageBus.connect() try { - connection.subscribe(ExecutionManager.EXECUTION_TOPIC, listener(settings, tail, onHandler, pending)) - withContext(Dispatchers.EDT) { launch(settings, executorId) } + withContext(Dispatchers.EDT) { + val environment = environment(settings, executorId) + val id = environment.executionId + val identity = RunIdentity { it === environment || it.executionId == id } + connection.subscribe(ExecutionManager.EXECUTION_TOPIC, listener(settings.name, identity, tail, onHandler, pending)) + ExecutionManager.getInstance(project).restartRunProfile(environment) + } withTimeoutOrNull(START_TIMEOUT_MILLIS) { pending.started.await() } ?: throw ToolException( "${settings.name} did not start within ${START_TIMEOUT_MILLIS / MILLIS} s: a before-launch task may have " + @@ -53,26 +58,26 @@ internal class ProcessRun(private val project: Project) { } } - private fun launch(settings: RunnerAndConfigurationSettings, executorId: String) { + private fun environment(settings: RunnerAndConfigurationSettings, executorId: String): ExecutionEnvironment { val executor = ExecutorRegistry.getInstance().getExecutorById(executorId) ?: throw ToolException("this IDE has no executor $executorId; is its plugin (Coverage, Profiler) installed?") - val environment = try { - ExecutionEnvironmentBuilder.create(executor, settings).activeTarget().build() + return try { + ExecutionEnvironmentBuilder.create(executor, settings).activeTarget().build().also { it.assignNewExecutionId() } } catch (e: ExecutionException) { throw ToolException("${settings.name} cannot run: ${e.message}", e) } - ExecutionManager.getInstance(project).restartRunProfile(environment) } private fun listener( - settings: RunnerAndConfigurationSettings, + name: String, + identity: RunIdentity, tail: OutputTail, onHandler: (ProcessHandler) -> Unit, pending: Pending, ): ExecutionListener = object : ExecutionListener { override fun processStarting(executorId: String, env: ExecutionEnvironment, handler: ProcessHandler) { - if (env.runnerAndConfigurationSettings !== settings) return + if (!identity.started(env, handler)) return pending.external.set(ExternalTaskOutput.attach(handler, tail)) if (pending.external.get() == null) handler.addProcessListener(textListener(tail)) onHandler(handler) @@ -80,13 +85,13 @@ internal class ProcessRun(private val project: Project) { } override fun processTerminated(executorId: String, env: ExecutionEnvironment, handler: ProcessHandler, exitCode: Int) { - if (env.runnerAndConfigurationSettings === settings) pending.exited.complete(exitCode) + if (identity.terminated(handler)) pending.exited.complete(exitCode) } override fun processNotStarted(executorId: String, env: ExecutionEnvironment, cause: Throwable?) { - if (env.runnerAndConfigurationSettings !== settings) return + if (!identity.ours(env)) return val reason = cause?.message?.let { ": $it" } ?: "" - pending.started.completeExceptionally(ToolException("${settings.name} did not start$reason", cause)) + pending.started.completeExceptionally(ToolException("$name did not start$reason", cause)) } } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/RunIdentity.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/RunIdentity.kt new file mode 100644 index 00000000..4a09e57b --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/RunIdentity.kt @@ -0,0 +1,14 @@ +package dev.lain.claudejb.controller.mcp.tools.run + +import java.util.concurrent.atomic.AtomicReference + +internal class RunIdentity(private val matches: (E) -> Boolean) { + + private val handler = AtomicReference() + + fun ours(environment: E): Boolean = matches(environment) + + fun started(environment: E, handler: H): Boolean = ours(environment) && this.handler.compareAndSet(null, handler) + + fun terminated(handler: H): Boolean = this.handler.get() === handler +} diff --git a/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/run/RunIdentityTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/run/RunIdentityTest.kt new file mode 100644 index 00000000..99de0f9a --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/run/RunIdentityTest.kt @@ -0,0 +1,34 @@ +package dev.lain.claudejb.controller.mcp.tools.run + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class RunIdentityTest { + + private val identity = RunIdentity { it == "run-2" } + + @Test + fun `the previous instance a restart kills does not complete the new run`() { + val previous = Any() + val ours = Any() + assertFalse(identity.started("run-1", previous)) + assertTrue(identity.started("run-2", ours)) + assertFalse(identity.terminated(previous)) + assertTrue(identity.terminated(ours)) + } + + @Test + fun `only the first process of the run is followed`() { + val first = Any() + assertTrue(identity.started("run-2", first)) + assertFalse(identity.started("run-2", Any())) + assertTrue(identity.terminated(first)) + } + + @Test + fun `a refusal counts only for this run`() { + assertTrue(identity.ours("run-2")) + assertFalse(identity.ours("run-1")) + } +} From ac7f7c50d6613f90d39389a0eb5999d17650f52f Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:53:41 +0200 Subject: [PATCH 025/155] fix(mcp): release shell processes and finished jobs --- .../claudejb/controller/mcp/tools/run/Jobs.kt | 14 +++++++++++++- .../controller/mcp/tools/run/TerminalTools.kt | 15 +++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/Jobs.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/Jobs.kt index 1dc55a9a..99c926e2 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/Jobs.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/Jobs.kt @@ -4,11 +4,15 @@ import dev.lain.claudejb.model.mcp.Param import dev.lain.claudejb.model.mcp.ToolArgs import dev.lain.claudejb.model.mcp.ToolException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration.Companion.minutes internal class Job(val id: String, val tail: OutputTail, val deferred: Deferred) @@ -32,11 +36,18 @@ internal class Jobs(private val scope: CoroutineScope, private val prefix: St fun start(tail: OutputTail, block: suspend () -> T): Job { val id = prefix + "-" + counter.incrementAndGet() - val job = Job(id, tail, scope.async { block() }) + val job = Job(id, tail, scope.async(start = CoroutineStart.LAZY) { block() }) running[id] = job + job.deferred.invokeOnCompletion { scope.launch { evict(job) } } + job.deferred.start() return job } + private suspend fun evict(job: Job) { + delay(RETAIN) + running.remove(job.id, job) + } + fun find(id: String): Job = running[id] ?: throw ToolException("no job $id: it never started, or its result was already delivered") @@ -51,6 +62,7 @@ internal class Jobs(private val scope: CoroutineScope, private val prefix: St const val DEFAULT_WAIT_SECONDS = 45 const val MAX_WAIT_SECONDS = 110 private const val MILLIS = 1000L + private val RETAIN = 10.minutes val WAIT = Param( "wait", diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TerminalTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TerminalTools.kt index 69d7c386..b1c73263 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TerminalTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TerminalTools.kt @@ -95,9 +95,8 @@ internal class TerminalTools(private val project: Project, scope: CoroutineScope val directory = readAction { args.optionalString("cwd")?.let { ReadTools.resolveDirectory(project, it).path } ?: project.basePath } ?: throw ToolException("this project has no directory on disk; pass cwd") - val handler = handler(command, directory) val tail = OutputTail.toCard(project, args) - return jobs.start(tail) { run(handler, command, tail) } + return jobs.start(tail) { run(handler(command, directory), command, tail) } } private fun handler(command: String, directory: String): ColoredProcessHandler { @@ -129,10 +128,14 @@ internal class TerminalTools(private val project: Project, scope: CoroutineScope } }, ) - FocusKeeper.keep(project) { show(handler) } - handler.notifyTextAvailable("$ $command\n", ProcessOutputType.SYSTEM) - handler.startNotify() - return exited.await() + try { + FocusKeeper.keep(project) { show(handler) } + handler.notifyTextAvailable("$ $command\n", ProcessOutputType.SYSTEM) + handler.startNotify() + return exited.await() + } finally { + if (!handler.isProcessTerminated) handler.destroyProcess() + } } private class Tab(val console: ConsoleView, @Volatile var handler: ProcessHandler) From 416263e83fc581c88944de7d4c02891558e6277f Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:53:42 +0200 Subject: [PATCH 026/155] fix(mcp): cap each line of a run's output tail --- .../dev/lain/claudejb/controller/mcp/tools/run/OutputTail.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/OutputTail.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/OutputTail.kt index f1fbb4a2..068ba37e 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/OutputTail.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/OutputTail.kt @@ -2,6 +2,7 @@ package dev.lain.claudejb.controller.mcp.tools.run import com.intellij.openapi.project.Project import dev.lain.claudejb.controller.mcp.ToolOutput +import dev.lain.claudejb.model.mcp.Clip import dev.lain.claudejb.model.mcp.Param import dev.lain.claudejb.model.mcp.ToolArgs import kotlinx.serialization.json.JsonObjectBuilder @@ -23,7 +24,7 @@ internal class OutputTail(private val publish: (String) -> Unit = {}) { fun line(text: String) { synchronized(lock) { lines++ - last.addLast(text) + last.addLast(Clip.line(text, MAX_LINE)) if (last.size > KEEP) last.removeFirst() } publish(text) @@ -34,6 +35,7 @@ internal class OutputTail(private val publish: (String) -> Unit = {}) { companion object { const val KEEP = 200 + const val MAX_LINE = 400 private const val DEFAULT_TAIL = 40 private const val SERVICE_MESSAGE = "##teamcity[" From 66e2877ef45d5043e752236d3f0694a67fec8367 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:54:58 +0200 Subject: [PATCH 027/155] perf(mcp): split the read budget between the files of a batch --- .../controller/mcp/tools/code/ReadTools.kt | 46 +++++++----- .../dev/lain/claudejb/model/mcp/TextWindow.kt | 72 +++++++++++++++++++ .../lain/claudejb/model/mcp/TextWindowTest.kt | 55 ++++++++++++++ 3 files changed, 154 insertions(+), 19 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/model/mcp/TextWindow.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/model/mcp/TextWindowTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadTools.kt index b89a101d..0a5694d1 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadTools.kt @@ -4,20 +4,24 @@ import com.intellij.openapi.application.readAction import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VfsUtilCore import com.intellij.openapi.vfs.VirtualFile import dev.lain.claudejb.controller.mcp.Reveal import dev.lain.claudejb.model.mcp.Batch +import dev.lain.claudejb.model.mcp.OutputBudget import dev.lain.claudejb.model.mcp.Param import dev.lain.claudejb.model.mcp.Tool import dev.lain.claudejb.model.mcp.ToolArgs import dev.lain.claudejb.model.mcp.ToolDomain import dev.lain.claudejb.model.mcp.ToolException import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.TextWindow import dev.lain.claudejb.model.mcp.ToolSpec import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import java.nio.file.Path +import java.util.concurrent.atomic.AtomicBoolean internal class ReadTools(private val project: Project, private val reveal: Reveal) { @@ -27,30 +31,30 @@ internal class ReadTools(private val project: Project, private val reveal: Revea listOf(Tool(READ_FILE, ::readFile)), ) - private suspend fun readFile(args: ToolArgs): ToolResult = ToolResult.toon(Batch.run(args, Batch.PATHS, ::readOne)) + private suspend fun readFile(args: ToolArgs): ToolResult { + val share = (SHARED_BUDGET / (Batch.expand(args, Batch.PATHS)?.size ?: 1) - ROW_ROOM).coerceAtLeast(ROW_ROOM) + val revealed = AtomicBoolean(!reveal.mirroring) + return ToolResult.toon(Batch.run(args, Batch.PATHS) { readOne(it, share, revealed) }) + } - private suspend fun readOne(args: ToolArgs): JsonObject { + private suspend fun readOne(args: ToolArgs, share: Int, revealed: AtomicBoolean): JsonObject { val path = args.string("path") val offset = args.int("offset", 1) val limit = args.int("limit", DEFAULT_LIMIT) if (offset < 1 || limit < 1) throw ToolException("offset and limit start at 1") val file = readAction { resolveFile(project, path) } - val (row, from) = readAction { - val text = FileDocumentManager.getInstance().getDocument(file)?.immutableCharSequence?.toString() - ?: String(file.contentsToByteArray(), file.charset) - val lines = text.lines() - val from = minOf(offset, lines.size + 1) - val to = minOf(from + limit - 1, lines.size) - buildJsonObject { - put("path", path) - put("lines", lines.size) - put("from", from) - put("to", to) - put("text", lines.subList(from - 1, to).joinToString("\n")) - } to from + val slice = readAction { + val text = FileDocumentManager.getInstance().getCachedDocument(file)?.immutableCharSequence ?: VfsUtilCore.loadText(file) + TextWindow.slice(text, offset, minOf(limit, TextWindow.MAX_LIMIT), share) + } + if (revealed.compareAndSet(false, true)) reveal.file(file, slice.from, preview = true) + return buildJsonObject { + put("path", path) + put("lines", slice.lines) + put("from", slice.from) + put("to", slice.to) + put("text", slice.text) } - if (reveal.mirroring) reveal.file(file, from, preview = true) - return row } companion object { @@ -76,18 +80,22 @@ internal class ReadTools(private val project: Project, private val reveal: Revea } private const val DEFAULT_LIMIT = 400 + private const val ROW_ROOM = 120 + private const val SHARED_BUDGET = OutputBudget.DEFAULT_MAX_CHARS - 1_000 val READ_FILE = ToolSpec( "read_file", "Reads a text file through the IDE, so unsaved editor changes are included, or several files at once with " + - "paths. Use offset and limit for large files. The file is shown in the editor's preview tab, without focus.", + "paths. Use offset and limit for large files. The files of one call share the output budget: each returns " + + "whole lines up to its share and to says where it stopped, so read on with offset=to+1. Lines longer than " + + "${TextWindow.MAX_LINE} characters end with …. The first file is shown in the editor's preview tab, without focus.", listOf( Param("path", "File path, absolute or relative to the project root", required = false), Batch.paths("offset and limit apply to each"), Param("offset", "First line to return, 1-based (default 1)", type = "integer", required = false), Param( "limit", - "Maximum number of lines to return (default $DEFAULT_LIMIT)", + "Maximum number of lines to return (default $DEFAULT_LIMIT, at most ${TextWindow.MAX_LIMIT})", type = "integer", required = false, ), diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/TextWindow.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/TextWindow.kt new file mode 100644 index 00000000..ff5bfaac --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/TextWindow.kt @@ -0,0 +1,72 @@ +package dev.lain.claudejb.model.mcp + +object TextWindow { + + const val MAX_LINE = 2_000 + const val MAX_LIMIT = 2_000 + private const val ESCAPED_NEWLINE = 2 + private const val UNICODE_ESCAPE = 5 + + class Slice(val lines: Int, val from: Int, val to: Int, val text: String) + + fun slice(text: CharSequence, offset: Int, limit: Int, budget: Int): Slice { + val total = lineCount(text) + val from = minOf(offset, total + 1) + val out = StringBuilder() + var at = startOf(text, from) + var to = from - 1 + var used = 0 + while (to < total && to - from + 1 < limit) { + val end = breakAt(text, at) + val line = Clip.line(text.subSequence(at, end), MAX_LINE) + val cost = cost(line) + if (to >= from && used + cost > budget) break + if (to >= from) out.append('\n') + out.append(line) + used += cost + to++ + at = after(text, end) + } + return Slice(total, from, to, out.toString()) + } + + fun lineCount(text: CharSequence): Int { + var count = 1 + var at = breakAt(text, 0) + while (at < text.length) { + count++ + at = breakAt(text, after(text, at)) + } + return count + } + + private fun startOf(text: CharSequence, line: Int): Int { + var at = 0 + repeat(line - 1) { at = after(text, breakAt(text, at)) } + return at + } + + private fun breakAt(text: CharSequence, from: Int): Int { + var i = from + while (i < text.length && text[i] != '\n' && text[i] != '\r') i++ + return i + } + + private fun after(text: CharSequence, end: Int): Int = when { + end >= text.length -> text.length + text[end] == '\r' && end + 1 < text.length && text[end + 1] == '\n' -> end + 2 + else -> end + 1 + } + + private fun cost(line: String): Int { + var cost = ESCAPED_NEWLINE + for (c in line) { + cost += when { + c == '\\' || c == '"' || c == '\t' -> 2 + c < ' ' -> UNICODE_ESCAPE + 1 + else -> 1 + } + } + return cost + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/model/mcp/TextWindowTest.kt b/src/test/kotlin/dev/lain/claudejb/model/mcp/TextWindowTest.kt new file mode 100644 index 00000000..984838a7 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/model/mcp/TextWindowTest.kt @@ -0,0 +1,55 @@ +package dev.lain.claudejb.model.mcp + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class TextWindowTest { + + private val file = (1..1_000).joinToString("\n") { "line number $it of a long file" } + + @Test + fun `without pressure the window is offset and limit, as before`() { + val slice = TextWindow.slice("a\nb\nc\nd", 2, 2, 10_000) + assertEquals(4, slice.lines) + assertEquals(2, slice.from) + assertEquals(3, slice.to) + assertEquals("b\nc", slice.text) + } + + @Test + fun `a share of the budget stops at a whole line and says where`() { + val slice = TextWindow.slice(file, 1, 2_000, 1_000) + assertTrue(slice.to in 2 until 1_000) { "to ${slice.to}" } + assertTrue(slice.text.length <= 1_000) { "${slice.text.length} chars" } + assertEquals("line number ${slice.to} of a long file", slice.text.lines().last()) + val next = TextWindow.slice(file, slice.to + 1, 2_000, 1_000) + assertEquals("line number ${slice.to + 1} of a long file", next.text.lines().first()) + } + + @Test + fun `three files in one batch all get lines instead of the first eating the budget`() { + val share = OutputBudget.DEFAULT_MAX_CHARS / 3 + val slices = List(3) { TextWindow.slice(file, 1, 400, share) } + assertTrue(slices.all { it.to > 1 }) + assertTrue(slices.sumOf { it.text.length } < OutputBudget.DEFAULT_MAX_CHARS) + } + + @Test + fun `the first line always comes back, clipped at the line ceiling`() { + val slice = TextWindow.slice("x".repeat(10_000) + "\nnext", 1, 10, 50) + assertEquals(1, slice.to) + assertEquals(TextWindow.MAX_LINE + 1, slice.text.length) + assertTrue(slice.text.endsWith(Clip.ELLIPSIS)) + } + + @Test + fun `line breaks of every kind count once and past the end is empty`() { + assertEquals(3, TextWindow.lineCount("a\r\nb\rc")) + assertEquals("b", TextWindow.slice("a\r\nb\rc", 2, 1, 100).text) + val past = TextWindow.slice("a\nb", 9, 5, 100) + assertEquals(3, past.from) + assertEquals(2, past.to) + assertEquals("", past.text) + } +} From bbc1ad0a7c7e4df51e455cf3c3dc49e441e2c09b Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:56:02 +0200 Subject: [PATCH 028/155] perf(session): stream assistant deltas on a coalescing drain and expose them as append events --- .../session/events/SessionEventRouter.kt | 55 ++++++++++++++++-- .../claudejb/model/protocol/ClaudeEvent.kt | 2 + .../model/protocol/parse/MessageParsers.kt | 2 + .../session/transcript/TranscriptModel.kt | 50 +++++++++++++--- .../model/session/turn/StreamBuffer.kt | 4 ++ .../protocol/parse/ProtocolParserTest.kt | 7 +++ .../session/transcript/TranscriptModelTest.kt | 58 +++++++++++++++++++ .../model/session/turn/StreamBufferTest.kt | 14 +++++ 8 files changed, 178 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/events/SessionEventRouter.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/events/SessionEventRouter.kt index 3b528856..f91c96a1 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/events/SessionEventRouter.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/events/SessionEventRouter.kt @@ -1,10 +1,13 @@ package dev.lain.claudejb.controller.session.events import com.intellij.openapi.application.ApplicationManager +import com.intellij.util.concurrency.AppExecutorUtil import dev.lain.claudejb.controller.session.AttentionReason import dev.lain.claudejb.controller.session.ClaudeSession import dev.lain.claudejb.model.protocol.ClaudeEvent import dev.lain.claudejb.model.session.turn.StreamBuffer +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean class SessionEventRouter( private val s: ClaudeSession, @@ -16,6 +19,8 @@ class SessionEventRouter( ) { private val stream = StreamBuffer() + private val flushLock = Any() + private val drainScheduled = AtomicBoolean(false) val toolEvents = ToolEvents(s, edt, fireState) private val taskEvents = TaskEvents(s, edt, fireState) @@ -26,6 +31,7 @@ class SessionEventRouter( fun onEvent(event: ClaudeEvent) { if (event is ClaudeEvent.Stream) { stream.buffer(event) + if (event == ClaudeEvent.BlockStop) flushDeltas() else scheduleDrain() return } flushDeltas() @@ -41,13 +47,50 @@ class SessionEventRouter( } fun flushDeltas() { - val drained = stream.drain() ?: return - val apply = { - for ((isThinking, text) in drained.runs) { - if (isThinking) s.reconciler.appendThinking(text) else s.reconciler.appendAssistant(text) + flush(null) + } + + private fun scheduleDrain() { + if (!drainScheduled.compareAndSet(false, true)) return + AppExecutorUtil.getAppScheduledExecutorService().schedule({ drainOnTimer() }, DRAIN_MS, TimeUnit.MILLISECONDS) + } + + private fun drainOnTimer() { + if (!flush(::drainSettled)) drainSettled() + } + + private fun drainSettled() { + drainScheduled.set(false) + if (stream.hasPending()) scheduleDrain() + } + + private fun flush(then: (() -> Unit)?): Boolean { + val onEdt = ApplicationManager.getApplication().isDispatchThread + val drained = synchronized(flushLock) { + val drained = stream.drain() ?: return false + if (!onEdt) { + edt { + apply(drained) + then?.invoke() + } } - drained.usage?.let { s.tokens.onLiveUsage(it[0], it[1], it[2], it[3]) } + drained + } + if (onEdt) { + apply(drained) + then?.invoke() } - if (ApplicationManager.getApplication().isDispatchThread) apply() else edt { apply() } + return true + } + + private fun apply(drained: StreamBuffer.Drained) { + for ((isThinking, text) in drained.runs) { + if (isThinking) s.reconciler.appendThinking(text) else s.reconciler.appendAssistant(text) + } + drained.usage?.let { s.tokens.onLiveUsage(it[0], it[1], it[2], it[3]) } + } + + private companion object { + const val DRAIN_MS = 40L } } diff --git a/src/main/kotlin/dev/lain/claudejb/model/protocol/ClaudeEvent.kt b/src/main/kotlin/dev/lain/claudejb/model/protocol/ClaudeEvent.kt index 603d2d17..b06ac66f 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/protocol/ClaudeEvent.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/protocol/ClaudeEvent.kt @@ -80,6 +80,8 @@ sealed interface ClaudeEvent { data class ThinkingDelta(val text: String, val parentToolUseId: String?) : Stream + data object BlockStop : Stream + data class LiveUsage( val inputTokens: Int = 0, val cacheCreationTokens: Int = 0, diff --git a/src/main/kotlin/dev/lain/claudejb/model/protocol/parse/MessageParsers.kt b/src/main/kotlin/dev/lain/claudejb/model/protocol/parse/MessageParsers.kt index 53547413..4c3a0da6 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/protocol/parse/MessageParsers.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/protocol/parse/MessageParsers.kt @@ -57,6 +57,8 @@ internal object MessageParsers { "content_block_delta" -> parseContentBlockDelta(event, parentToolUseId) + "content_block_stop" -> listOf(ClaudeEvent.BlockStop) + else -> emptyList() } } diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptModel.kt b/src/main/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptModel.kt index bb588889..195a1c55 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptModel.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptModel.kt @@ -23,8 +23,22 @@ class TranscriptEntry( val bypassAction: String? = null, val reviewable: Boolean = false, ) { - var text: String = text - internal set + private var settled: String? = text + + private var growing: StringBuilder? = null + + var text: String + get() = settled ?: growing.toString().also { settled = it } + internal set(value) { + settled = value + growing = null + } + + internal fun grow(delta: String) { + val builder = growing ?: StringBuilder(settled.orEmpty()).also { growing = it } + builder.append(delta) + settled = null + } var toolState: ToolState = toolState internal set @@ -51,6 +65,8 @@ class TranscriptModel { interface Listener { fun onAdded(entry: TranscriptEntry, index: Int) {} fun onUpdated(entry: TranscriptEntry) {} + + fun onAppended(entry: TranscriptEntry, delta: String) = onUpdated(entry) fun onCleared() {} fun onTrimmed(removedIds: List, totalTrimmed: Int) {} @@ -63,6 +79,8 @@ class TranscriptModel { private val byToolUseId = HashMap() private val parentOf = HashMap() + private var indexById: HashMap? = HashMap() + val entries: List get() = backing var trimmedCount: Int = 0 @@ -100,7 +118,7 @@ class TranscriptModel { if (parentToolUseId != null) parentOf[toolUseId] = parentToolUseId else parentOf.remove(toolUseId) } val index = insertionIndexFor(parentToolUseId) - backing.add(index, entry) + place(entry, index) listeners.forEach { it.onAdded(entry, index) } trimToCap() return entry @@ -119,13 +137,14 @@ class TranscriptModel { parentOf.remove(toolUseId) } } + indexById = null trimmedCount += removedIds.size listeners.forEach { it.onTrimmed(removedIds, trimmedCount) } } fun addToolOutput(toolUseId: String, text: String, parentToolUseId: String? = null, meta: String? = null): TranscriptEntry { val toolEntry = byToolUseId[toolUseId] - val toolIdx = if (toolEntry != null) backing.indexOf(toolEntry) else -1 + val toolIdx = if (toolEntry != null) indexOf(toolEntry.id) else -1 val parent = parentToolUseId ?: toolEntry?.parentToolUseId val insertAt = if (toolIdx < 0) { backing.size @@ -135,7 +154,7 @@ class TranscriptModel { i } val entry = TranscriptEntry(nextId++, Speaker.TOOL_OUTPUT, text, meta, toolUseId, parent) - backing.add(insertAt, entry) + place(entry, insertAt) listeners.forEach { it.onAdded(entry, insertAt) } trimToCap() return entry @@ -157,16 +176,30 @@ class TranscriptModel { private fun insertionIndexFor(parent: String?): Int { if (parent == null) return backing.size val parentEntry = byToolUseId[parent] ?: return backing.size - val anchor = backing.indexOf(parentEntry) + val anchor = indexOf(parentEntry.id) if (anchor < 0) return backing.size var i = anchor + 1 while (i < backing.size && belongsToSubtree(backing[i], parent)) i++ return i } + private fun place(entry: TranscriptEntry, index: Int) { + backing.add(index, entry) + val cache = indexById ?: return + if (index == backing.size - 1) cache[entry.id] = index else indexById = null + } + + fun indexOf(entryId: Long): Int { + val cache = indexById ?: HashMap(backing.size * 2).also { rebuilt -> + backing.forEachIndexed { i, e -> rebuilt[e.id] = i } + indexById = rebuilt + } + return cache[entryId] ?: -1 + } + fun append(entry: TranscriptEntry, delta: String) { - entry.text += delta - listeners.forEach { it.onUpdated(entry) } + entry.grow(delta) + listeners.forEach { it.onAppended(entry, delta) } } fun replaceText(entry: TranscriptEntry, text: String) { @@ -204,6 +237,7 @@ class TranscriptModel { fun clear() { backing.clear() + indexById = HashMap() byToolUseId.clear() parentOf.clear() trimmedCount = 0 diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/turn/StreamBuffer.kt b/src/main/kotlin/dev/lain/claudejb/model/session/turn/StreamBuffer.kt index e61779b3..0af1a94c 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/session/turn/StreamBuffer.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/session/turn/StreamBuffer.kt @@ -20,9 +20,13 @@ class StreamBuffer { is ClaudeEvent.LiveUsage -> synchronized(lock) { usage = intArrayOf(event.inputTokens, event.cacheCreationTokens, event.cacheReadTokens, event.outputTokens) } + + ClaudeEvent.BlockStop -> Unit } } + fun hasPending(): Boolean = synchronized(lock) { runs.isNotEmpty() || usage != null } + private fun append(isThinking: Boolean, text: String) = synchronized(lock) { val last = runs.lastOrNull() if (last != null && last.first == isThinking) { diff --git a/src/test/kotlin/dev/lain/claudejb/model/protocol/parse/ProtocolParserTest.kt b/src/test/kotlin/dev/lain/claudejb/model/protocol/parse/ProtocolParserTest.kt index f771f2d9..cb8e094c 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/protocol/parse/ProtocolParserTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/protocol/parse/ProtocolParserTest.kt @@ -290,6 +290,13 @@ class ProtocolParserTest { ) } + @Test + fun `content_block_stop becomes a block stop so the stream is flushed`() { + assertEquals( + listOf(ClaudeEvent.BlockStop), + ProtocolParser.parse("""{"type":"stream_event","event":{"type":"content_block_stop","index":0}}"""), + ) + } @Test fun `result end of turn carries cost and session`() { val line = """{"type":"result","subtype":"success","result":"ok","total_cost_usd":0.12,"session_id":"s9"}""" diff --git a/src/test/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptModelTest.kt b/src/test/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptModelTest.kt index 9afb253b..839b8cd4 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptModelTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptModelTest.kt @@ -154,6 +154,64 @@ class TranscriptModelTest { assertEquals("Hello", entry.text) } + @Test + fun `append hands listeners the entry and only the appended delta`() { + val model = TranscriptModel() + val entry = model.add(Speaker.ASSISTANT, "a") + val deltas = mutableListOf>() + model.addListener( + object : TranscriptModel.Listener { + override fun onAppended(entry: TranscriptEntry, delta: String) { + deltas += entry.id to delta + } + }, + ) + + model.append(entry, "b") + assertEquals("ab", entry.text) + model.append(entry, "c") + model.replaceText(entry, "final") + model.append(entry, "!") + + assertEquals(listOf(entry.id to "b", entry.id to "c", entry.id to "!"), deltas) + assertEquals("final!", entry.text) + } + + @Test + fun `a listener that does not know deltas still sees every append as an update`() { + val model = TranscriptModel() + val entry = model.add(Speaker.ASSISTANT, "a") + var updates = 0 + model.addListener( + object : TranscriptModel.Listener { + override fun onUpdated(entry: TranscriptEntry) { + updates++ + } + }, + ) + + repeat(3) { model.append(entry, "x") } + + assertEquals(3, updates) + assertEquals("axxx", entry.text) + } + + @Test + fun `indexOf follows inserts in the middle and trims at the front`() { + val model = TranscriptModel() + val tool = model.add(Speaker.TOOL, "Bash", toolUseId = "t1") + val tail = model.add(Speaker.ASSISTANT, "after") + val output = model.addToolOutput("t1", "out") + + assertEquals(listOf(0, 1, 2), listOf(tool, output, tail).map { model.indexOf(it.id) }) + + model.fill(cap) + + assertEquals(-1, model.indexOf(tool.id)) + assertEquals(model.entries.lastIndex, model.indexOf(model.entries.last().id)) + assertEquals(0, model.indexOf(model.entries.first().id)) + } + @Test fun `replaceText substitutes the entry text`() { val model = TranscriptModel() diff --git a/src/test/kotlin/dev/lain/claudejb/model/session/turn/StreamBufferTest.kt b/src/test/kotlin/dev/lain/claudejb/model/session/turn/StreamBufferTest.kt index 24180117..c231bad0 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/session/turn/StreamBufferTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/session/turn/StreamBufferTest.kt @@ -2,6 +2,8 @@ package dev.lain.claudejb.model.session.turn import dev.lain.claudejb.model.protocol.ClaudeEvent import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test @@ -34,6 +36,18 @@ class StreamBufferTest { assertNull(buffer.drain()) } + @Test + fun `a block stop buffers nothing and pending tells whether a drain would yield`() { + buffer.buffer(ClaudeEvent.BlockStop) + assertFalse(buffer.hasPending()) + assertNull(buffer.drain()) + + buffer.buffer(ClaudeEvent.TextDelta("x", null)) + assertTrue(buffer.hasPending()) + buffer.drain() + assertFalse(buffer.hasPending()) + } + @Test fun `the last usage wins and travels with the runs`() { buffer.buffer(ClaudeEvent.LiveUsage(1, 2, 3, 4)) From 4618e7567d0013e5766db1d45e7830994edea59a Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:56:50 +0200 Subject: [PATCH 029/155] build: move to Gradle 9.7.1 with a pinned distribution checksum --- gradle/wrapper/gradle-wrapper.properties | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5dd3c012..fae6f659 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +distributionSha256Sum=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 072da3f4e243127936546775323a72a2f0c7c55d Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:57:00 +0200 Subject: [PATCH 030/155] perf(mcp): group search matches by file without opening documents --- .../controller/mcp/tools/code/SearchTools.kt | 71 ++++++++++++++----- 1 file changed, 52 insertions(+), 19 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/SearchTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/SearchTools.kt index 69e7d0e4..0e1ce10a 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/SearchTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/SearchTools.kt @@ -8,6 +8,8 @@ import com.intellij.openapi.progress.coroutineToIndicator import com.intellij.openapi.project.IndexNotReadyException import com.intellij.openapi.project.Project import com.intellij.openapi.roots.ProjectFileIndex +import com.intellij.openapi.util.text.StringUtil +import com.intellij.openapi.vfs.VfsUtilCore import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.search.FilenameIndex import com.intellij.psi.search.GlobalSearchScope @@ -47,7 +49,7 @@ internal class SearchTools(private val project: Project, private val io: Corouti private suspend fun searchOne(args: ToolArgs): JsonObject { val query = args.string("query") - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, MAX_MATCHES) val model = FindModel().apply { stringToFind = query isRegularExpressions = args.boolean("regex", false) @@ -60,10 +62,11 @@ internal class SearchTools(private val project: Project, private val io: Corouti } } val hits = Collections.synchronizedList(ArrayList()) + val presentation = FindUsagesProcessPresentation(UsageViewPresentation()) withContext(io) { try { coroutineToIndicator { indicator -> - FindInProjectUtil.findUsages(model, project, indicator, PRESENTATION, emptySet()) { info -> + FindInProjectUtil.findUsages(model, project, indicator, presentation, emptySet()) { info -> hits += info hits.size < max } @@ -73,25 +76,54 @@ internal class SearchTools(private val project: Project, private val io: Corouti } } val found = synchronized(hits) { hits.toList() } - val rows = readAction { found.map { describe(it) }.distinct() } + val files = readAction { byFile(found) } return buildJsonObject { put("query", query) - put("count", rows.size) + put("count", files.sumOf { it.second.size }) put("truncated", found.size >= max) - put("matches", buildJsonArray { rows.forEach { add(it) } }) + put( + "files", + buildJsonArray { + for ((file, lines) in files) { + add( + buildJsonObject { + put("file", file) + if (lines.size == 1) put("lines", lines.single()) else put("lines", lines.joinToString(" ")) + }, + ) + } + }, + ) } } - private fun describe(info: UsageInfo): JsonObject = buildJsonObject { - val file = info.virtualFile - val document = file?.let { FileDocumentManager.getInstance().getDocument(it) } - put("file", file?.let(::relative) ?: "") - if (document != null) put("line", document.getLineNumber(info.navigationOffset) + 1) + private fun byFile(found: List): List>> = + found.groupBy { it.virtualFile }.mapNotNull { (file, infos) -> + file ?: return@mapNotNull null + relative(file) to lineNumbers(textOf(file), infos.map { it.navigationOffset }.sorted()) + } + + private fun textOf(file: VirtualFile): CharSequence = FileDocumentManager.getInstance().getCachedDocument(file)?.immutableCharSequence + ?: StringUtil.convertLineSeparators(VfsUtilCore.loadText(file)) + + private fun lineNumbers(text: CharSequence, offsets: List): List { + val out = ArrayList(offsets.size) + var line = 1 + var at = 0 + for (offset in offsets) { + val end = offset.coerceIn(0, text.length) + while (at < end) { + if (text[at] == '\n') line++ + at++ + } + if (out.lastOrNull() != line) out += line + } + return out } private suspend fun findOne(args: ToolArgs): JsonObject { val name = args.string("name") - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, MAX_FILES) val found = readAction { try { if ('*' in name || '?' in name) glob(name, max) else byName(name, max) @@ -123,8 +155,8 @@ internal class SearchTools(private val project: Project, private val io: Corouti private suspend fun listDirectory(args: ToolArgs): ToolResult { val path = args.optionalString("path") ?: "." val depth = args.int("depth", 1) - val max = args.int("max", DEFAULT_MAX) - if (depth < 1 || max < 1) throw ToolException("depth and max start at 1") + val max = args.max(DEFAULT_MAX, MAX_FILES) + if (depth < 1) throw ToolException("depth starts at 1") val rows = readAction { val root = ReadTools.resolveDirectory(project, path) ArrayList().also { walk(root, depth, ProjectFileIndex.getInstance(project), it, max) } @@ -157,19 +189,20 @@ internal class SearchTools(private val project: Project, private val io: Corouti companion object { private const val DEFAULT_MAX = 50 - private val PRESENTATION = FindUsagesProcessPresentation(UsageViewPresentation()) + private const val MAX_MATCHES = 500 + private const val MAX_FILES = 500 val SEARCH_TEXT = ToolSpec( "search_text", - "Finds text or a regular expression across the project: one row per matching line with file and line, no text; " + - "read_file the lines you need.", + "Finds text or a regular expression across the project: one row per file with its matching lines " + + "(space-separated), no text; read_file the lines you need.", listOf( Param("query", "Text or regular expression to find", required = false), Batch.param(Batch.QUERIES, "Several searches at once, one result per query; the other arguments apply to each"), Param("regex", "true to treat query as a regular expression (default false)", type = "boolean", required = false), Param("case_sensitive", "true to match case (default false)", type = "boolean", required = false), Param("path", "Directory to search under, relative to the project root (default: whole project)", required = false), - Param("max", "Maximum matches to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("matches", DEFAULT_MAX, MAX_MATCHES), ), ) @@ -179,7 +212,7 @@ internal class SearchTools(private val project: Project, private val io: Corouti listOf( Param("name", "Exact file name, or a glob on the file name", required = false), Batch.param(Batch.NAMES, "Several names or globs at once, one result per name"), - Param("max", "Maximum files to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("files", DEFAULT_MAX, MAX_FILES), ), ) @@ -191,7 +224,7 @@ internal class SearchTools(private val project: Project, private val io: Corouti listOf( Param("path", "Directory, absolute or relative to the project root (default: the project root)", required = false), Param("depth", "How many levels to descend (default 1)", type = "integer", required = false), - Param("max", "Maximum entries to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("entries", DEFAULT_MAX, MAX_FILES), ), ) } From b9444308397bab683baf9e7c8e5e9a054b215bf9 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:57:00 +0200 Subject: [PATCH 031/155] perf(mcp): collect project problems off the EDT and group them by file --- .../mcp/tools/code/DiagnosticsTools.kt | 44 ++++--------- .../mcp/tools/code/ProjectProblems.kt | 66 +++++++++++++++++++ 2 files changed, 78 insertions(+), 32 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProjectProblems.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/DiagnosticsTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/DiagnosticsTools.kt index 30240f8a..6d405ba3 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/DiagnosticsTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/DiagnosticsTools.kt @@ -1,8 +1,5 @@ package dev.lain.claudejb.controller.mcp.tools.code -import com.intellij.analysis.problemsView.FileProblem -import com.intellij.analysis.problemsView.Problem -import com.intellij.analysis.problemsView.ProblemsCollector import com.intellij.analysis.problemsView.toolWindow.ProblemsViewTab import com.intellij.analysis.problemsView.toolWindow.ProblemsViewToolWindowUtils import com.intellij.openapi.application.EDT @@ -44,7 +41,7 @@ internal class DiagnosticsTools( private suspend fun problemsOne(args: ToolArgs): JsonObject { val path = args.string("path") val severity = Severities.minimum(args) - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, MAX_PROBLEMS) val (file, document) = readAction { val file = ReadTools.resolveFile(project, path) file to Locations.document(project, file) @@ -75,22 +72,11 @@ internal class DiagnosticsTools( } private suspend fun projectProblems(args: ToolArgs): ToolResult { - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, MAX_PROBLEMS) val group = args.optionalString("group") - val (rows, total) = withContext(Dispatchers.EDT) { - val collector = ProblemsCollector.getInstance(project) - val all = collector.getProblemFiles().flatMap { collector.getFileProblems(it) } + collector.getOtherProblems() - val matching = all.filter { group == null || it.group?.contains(group, ignoreCase = true) == true } - matching.take(max).map(::row) to matching.size - } + val snapshot = readAction { ProjectProblems.snapshot(project, group, max) } if (reveal.mirroring) reveal.problems("") - return ToolResult.toon( - buildJsonObject { - put("count", total) - put("truncated", total > rows.size) - put("problems", buildJsonArray { rows.forEach { add(it) } }) - }, - ) + return ToolResult.toon(snapshot.toJson(max)) } private suspend fun problemsView(args: ToolArgs): ToolResult { @@ -137,18 +123,10 @@ internal class DiagnosticsTools( tabs.firstOrNull { it.id.equals(name, ignoreCase = true) || it.name.equals(name, ignoreCase = true) } ?: throw ToolException("no Problems tab named $name; the tabs are ${tabs.joinToString { it.id }}") - private fun row(problem: Problem): JsonObject = buildJsonObject { - val inFile = problem as? FileProblem - put("file", inFile?.let { Locations.relative(project, it.file) } ?: "") - put("line", inFile?.line?.takeIf { it >= 0 }?.plus(1) ?: 0) - put("column", inFile?.column?.takeIf { it >= 0 }?.plus(1) ?: 0) - put("group", problem.group ?: "") - put("message", problem.text) - } - companion object { private const val DEFAULT_MAX = 100 + private const val MAX_PROBLEMS = 500 private val WHITESPACE = Regex("\\s+") val PROBLEMS = ToolSpec( @@ -157,18 +135,20 @@ internal class DiagnosticsTools( "that raised each. Opens the file in an editor tab, since the IDE analyses open files.", listOf( Param("path", "File path, absolute or relative to the project root", required = false), - Batch.paths("every touched file in one call"), + Batch.paths("every touched file in one call; files with nothing to report are folded into one clean line"), Severities.PARAM, - Param("max", "Maximum problems to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("problems", DEFAULT_MAX, MAX_PROBLEMS), ), + parallel = false, ) val PROJECT_PROBLEMS = ToolSpec( "project_problems", - "Everything the Problems view lists right now across the project: file problems with their positions, " + - "and problems with no file. Filter by group to isolate what one inspection family or plugin reports.", + "Everything the Problems view lists right now across the project: one entry per file with its problems " + + "(line and message), then the problems with no file under other. Filter by group to isolate what one " + + "inspection family or plugin reports.", listOf( - Param("max", "Maximum problems to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("problems", DEFAULT_MAX, MAX_PROBLEMS), Param("group", "Only problems whose group contains this text, e.g. an inspection family or a plugin", required = false), ), ) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProjectProblems.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProjectProblems.kt new file mode 100644 index 00000000..14ea96bf --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProjectProblems.kt @@ -0,0 +1,66 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.analysis.problemsView.FileProblem +import com.intellij.analysis.problemsView.Problem +import com.intellij.analysis.problemsView.ProblemsCollector +import com.intellij.openapi.project.Project +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal class ProjectProblems private constructor( + private val files: Map>, + private val other: List, + private val total: Int, +) { + + fun toJson(max: Int): JsonObject = buildJsonObject { + put("count", total) + put("truncated", total > max) + put( + "files", + buildJsonArray { + for ((file, problems) in files) { + add( + buildJsonObject { + put("file", file) + put("problems", buildJsonArray { problems.forEach { add(it) } }) + }, + ) + } + }, + ) + if (other.isNotEmpty()) put("other", buildJsonArray { other.forEach { add(JsonPrimitive(it)) } }) + } + + companion object { + + fun snapshot(project: Project, group: String?, max: Int): ProjectProblems { + val collector = ProblemsCollector.getInstance(project) + val files = LinkedHashMap>() + val other = ArrayList() + var total = 0 + for (file in collector.getProblemFiles()) { + for (problem in collector.getFileProblems(file)) { + if (!wanted(problem, group)) continue + if (total++ < max) files.getOrPut(Locations.relative(project, file)) { ArrayList() } += row(problem) + } + } + for (problem in collector.getOtherProblems()) { + if (!wanted(problem, group)) continue + if (total++ < max) other += problem.text + } + return ProjectProblems(files, other, total) + } + + private fun wanted(problem: Problem, group: String?): Boolean = + group == null || problem.group?.contains(group, ignoreCase = true) == true + + private fun row(problem: Problem): JsonObject = buildJsonObject { + put("line", (problem as? FileProblem)?.line?.takeIf { it >= 0 }?.plus(1) ?: 0) + put("message", problem.text) + } + } +} From 2462b0639aff456ec40df8d9c11bf7973f8f66ec Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:57:17 +0200 Subject: [PATCH 032/155] build: split the plugin into shared, frontend and backend modules on 2026.2 --- backend/build.gradle.kts | 16 + .../resources/dev.lain.claudejb.backend.xml | 55 ++ build.gradle.kts | 836 +++--------------- frontend/build.gradle.kts | 30 + .../resources/dev.lain.claudejb.frontend.xml | 15 + gradle/coverage.gradle.kts | 58 ++ gradle/quality.gradle.kts | 52 ++ gradle/release-notes.gradle.kts | 53 ++ gradle/test-suites.gradle.kts | 85 ++ settings.gradle.kts | 25 + .../resources/dev.lain.claudejb.shared.xml | 0 src/main/resources/META-INF/claude-git.xml | 17 - src/main/resources/META-INF/claude-github.xml | 11 - .../resources/META-INF/claude-intellilang.xml | 2 - src/main/resources/META-INF/claude-java.xml | 2 - .../resources/META-INF/claude-terminal.xml | 9 - src/main/resources/META-INF/plugin.xml | 15 +- 17 files changed, 496 insertions(+), 785 deletions(-) create mode 100644 backend/build.gradle.kts create mode 100644 backend/src/main/resources/dev.lain.claudejb.backend.xml create mode 100644 frontend/build.gradle.kts create mode 100644 frontend/src/main/resources/dev.lain.claudejb.frontend.xml create mode 100644 gradle/coverage.gradle.kts create mode 100644 gradle/quality.gradle.kts create mode 100644 gradle/release-notes.gradle.kts create mode 100644 gradle/test-suites.gradle.kts rename src/main/resources/META-INF/claude-database.xml => shared/src/main/resources/dev.lain.claudejb.shared.xml (100%) delete mode 100644 src/main/resources/META-INF/claude-git.xml delete mode 100644 src/main/resources/META-INF/claude-github.xml delete mode 100644 src/main/resources/META-INF/claude-intellilang.xml delete mode 100644 src/main/resources/META-INF/claude-java.xml delete mode 100644 src/main/resources/META-INF/claude-terminal.xml diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts new file mode 100644 index 00000000..cc1a0e61 --- /dev/null +++ b/backend/build.gradle.kts @@ -0,0 +1,16 @@ +dependencies { + intellijPlatform { + bundledModules( + "intellij.platform.backend", + "intellij.platform.kernel.backend", + "intellij.platform.rpc.backend", + ) + bundledPlugins( + "org.jetbrains.plugins.terminal", + "Git4Idea", + "org.jetbrains.plugins.github", + "com.intellij.java", + ) + } + implementation(project(":shared")) +} diff --git a/backend/src/main/resources/dev.lain.claudejb.backend.xml b/backend/src/main/resources/dev.lain.claudejb.backend.xml new file mode 100644 index 00000000..fac583d9 --- /dev/null +++ b/backend/src/main/resources/dev.lain.claudejb.backend.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts index 24573fd0..8055ba31 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,384 +1,152 @@ import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType import org.jetbrains.intellij.platform.gradle.TestFrameworkType -import org.jetbrains.intellij.platform.gradle.extensions.intellijPlatform import org.jetbrains.intellij.platform.gradle.models.ProductRelease import org.jetbrains.intellij.platform.gradle.tasks.VerifyPluginTask +import org.jetbrains.intellij.platform.gradle.tasks.aware.SplitModeAware +import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension plugins { - kotlin("jvm") version "2.1.20" - kotlin("plugin.serialization") version "2.1.20" - // PINNED AT 2.16.0 DELIBERATELY. 2.18.1 exists and the build warns about it on every run, but bumping it - // hangs the headless suite: `ChatSessionManagerHeadlessTest` never starts, because - // BasePlatformTestCase.setUp → LightPlatformTestCase.doSetup → IndexingTestUtil.waitUntilIndexesAreReady - // waits forever (confirmed by thread dump — the EDT sits in that frame; our code is never reached). The - // bump changes which platform test-framework is resolved, so this is a fixture-level regression, not ours - // to fix from here. Re-attempt as its own change, with the headless suite as the acceptance test — NOT as - // a drive-by inside a release branch, which is exactly how it got in and straight back out. - id("org.jetbrains.intellij.platform") version "2.16.0" - // Coverage, gated per package — see the `kover { }` block near the bottom for the thresholds and why they - // differ by package. (Until 5.0.0 this comment claimed a "≥90% target documented in - // docs/RELEASE_CHECKLIST.md". That document says nothing about coverage, and the real figure was 53%. A - // number nobody measured, pointing at a requirement that did not exist.) - id("org.jetbrains.kotlinx.kover") version "0.9.9" - // Static analysis (detekt) and formatting (ktlint via Spotless). Added in 5.0.0: until then the whole - // quality bar rested on review, which is exactly the thing the standards say to mechanise — "if format - // is being discussed in a review, a formatter is missing". - id("io.gitlab.arturbosch.detekt") version "1.23.8" - id("com.diffplug.spotless") version "8.9.0" + id("org.jetbrains.intellij.platform") + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.kotlinx.kover") + id("dev.detekt") + id("com.diffplug.spotless") } group = "dev.lain" version = "6.0.1" -repositories { - mavenCentral() - intellijPlatform { - defaultRepositories() +val platformBuild = "262.8665.258" +val serialization = "1.9.0" +val pluginModules = listOf("shared", "frontend", "backend") + +allprojects { + plugins.withId("org.jetbrains.kotlin.jvm") { + extensions.configure { + jvmToolchain(25) + compilerOptions { + allWarningsAsErrors.set(true) + jvmDefault.set(JvmDefaultMode.NO_COMPATIBILITY) + } + } + } + tasks.withType().configureEach { + exclude("**/PROJECTMAP.md") } } -// --------------------------------------------------------------------------- -// Test layout (the pyramid in docs/ + the plan): -// src/test, package `…` and `…headless`/`…integration` → `test` (unit) + `integrationTest` (headless+fake-claude) -// src/uiTest → `uiTest` (RemoteRobot, drives an external IDE; gated) -// Headless/integration tests live in src/test so they inherit the IntelliJ Platform classpath the plugin -// already wires for the `test` task (a custom source set does NOT get `Project` on its classpath). The -// `integrationTest` task simply re-runs the test classes filtered to the heavy packages; `test` excludes them. -// uiTest is a real separate source set: it talks to a running IDE over HTTP (remote-robot), so it must NOT -// pull the platform into its own classpath. -// --------------------------------------------------------------------------- -sourceSets { - create("uiTest") { - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output - runtimeClasspath += output + compileClasspath - kotlin.srcDir("src/uiTest/kotlin") - resources.srcDir("src/uiTest/resources") +subprojects { + apply(plugin = "org.jetbrains.intellij.platform.module") + apply(plugin = "org.jetbrains.kotlin.jvm") + apply(plugin = "org.jetbrains.kotlin.plugin.serialization") + apply(plugin = "rpc") + apply(plugin = "org.jetbrains.kotlinx.kover") + extensions.configure { + archivesName.set("dev.lain.claudejb.$name") + } + dependencies { + "compileOnly"("org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:$serialization") + "compileOnly"("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:$serialization") } } -configurations { - named("uiTestImplementation") { extendsFrom(configurations.testImplementation.get()) } - named("uiTestRuntimeOnly") { extendsFrom(configurations.testRuntimeOnly.get()) } -} +apply(from = "gradle/test-suites.gradle.kts") +apply(from = "gradle/quality.gradle.kts") +apply(from = "gradle/coverage.gradle.kts") +apply(from = "gradle/release-notes.gradle.kts") dependencies { intellijPlatform { - // Compile against IntelliJ IDEA Community 2025.3 — the declared since-build floor (253), so we build - // against the oldest IDE we support (never below it) and the plugin still loads in newer IDEs because - // untilBuild is widened below. - // - // Raised from 2025.2 together with the floor: `com.intellij.modules.jcef`, which the descriptor now - // declares, does not exist in 252 at all — compiling against an IDE that cannot satisfy the plugin's - // own dependencies makes `runIde` a sandbox the plugin refuses to load in, and the "build against the - // floor" rule stops meaning anything. - // By BUILD NUMBER, not "2025.3.1": that marketing version is not published in the Maven repository the - // plugin resolves from (only the point releases are), so the plain name fails to resolve. - // `useInstaller = false` resolves the Maven artifact instead of the `.tar.gz` installer — smaller, and - // it carries everything this build needs, `com.intellij.modules.jcef` included (checked in the - // artifact's own `lib/product-backend.jar`). - // NOT `IntellijIdeaCommunity`: the `ideaIC` artifact stopped being published at 2025.3 (253) — the very - // floor this release moved to — and the Gradle plugin warns about it on every build. JetBrains ships a - // single unified IDEA distribution from 253 onwards, which is what `intellijIdea(…)` resolves. - // - // **253.29346.138 (2025.3.1), not 253.28294.334 (2025.3) — and the ten days between them are the whole - // point.** `com.intellij.modules.jcef`, which `plugin.xml` declares a MANDATORY dependency on, does not - // exist in 2025.3: its `product-backend.jar` carries 38 `com.intellij.modules.*` aliases and none of - // them is that one. It appears in 2025.3.1 — 39 aliases, the extra one being exactly `jcef`. So on - // 2025.3 the IDE refuses to load this plugin outright ("has dependency on 'com.intellij.modules.jcef' - // which is not installed"), which is the same failure that made 5.1.1 dead on 2026.2, at the other end - // of the range. The dependency cannot simply be dropped: from 262 JCEF is a bundled plugin, and without - // declaring it the plugin's classloader has no `com.intellij.ui.jcef.*` at all. It is mandatory from - // the build that has it and impossible before — so the FLOOR moves, and `sinceBuild` below moves with - // it. (On 2025.3 itself `JBCefApp` does live in `lib/app.jar`, i.e. everything compiles and 5.1.1 ran - // there happily; it is the declaration that cannot be satisfied, not the classes that are missing.) - intellijIdea("253.29346.138") { + intellijIdea(platformBuild) { useInstaller = false } - // Bundled IDE Terminal: used to open an interactive `claude login` session (the OAuth flow needs a - // TTY, which the stream-json process doesn't have). Compile-only coupling; TerminalLauncher guards - // its use behind PluginManager.isPluginInstalled so a disabled Terminal plugin degrades gracefully. - bundledPlugin("org.jetbrains.plugins.terminal") - // Bundled Git plugin: compile-only coupling for `git4idea.*` (GitRepositoryManager, GitHistoryUtils), - // read-only. Declared OPTIONAL in META-INF/plugin.xml (config-file claude-git.xml) — unlike JCEF, an IDE - // without Git, or a project that is not a working copy, must still load the plugin; `GitGateway` is the - // only file that names a git4idea type and it is never reached unless `GitAvailability` says yes. - bundledPlugin("Git4Idea") - // Bundled GitHub plugin: compile-only coupling for the pull-request data (`GHAccountsUtil`, the API - // executor, `GHGQLRequests`). OPTIONAL in META-INF/plugin.xml (config-file claude-github.xml); - // `GitHubGateway` is the only file that names an org.jetbrains.plugins.github type and checks the - // plugin before touching it, so an IDE without it answers "not available" instead of dying. - bundledPlugin("org.jetbrains.plugins.github") - // Bundled Java plugin: compile-only coupling for UAST (the unified AST over Java, Kotlin, Scala, Groovy), - // which ships inside it. OPTIONAL in META-INF/plugin.xml (config-file claude-java.xml): PyCharm and the - // other IDEs without Java load the plugin without the uast domain; UastTools is the only file naming - // org.jetbrains.uast and the catalog row checks JavaAvailability before touching it. - bundledPlugin("com.intellij.java") + pluginModules.forEach { pluginModule(implementation(project(":$it"))) } + bundledModules( + "intellij.platform.frontend", + "intellij.platform.backend", + "intellij.platform.kernel.backend", + "intellij.platform.rpc.backend", + ) + bundledPlugins( + "com.intellij.modules.jcef", + "org.jetbrains.plugins.terminal", + "Git4Idea", + "org.jetbrains.plugins.github", + "com.intellij.java", + ) + testFramework(TestFrameworkType.Platform) } - - // JSON (de)serialization for the stream-json / control protocol. - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") - - // Unit tests (pure JVM: protocol parsing/building, no IntelliJ Platform fixtures needed). - testImplementation(platform("org.junit:junit-bom:6.1.2")) + testImplementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serialization") + testImplementation(platform("org.junit:junit-bom:6.1.3")) testImplementation("org.junit.jupiter:junit-jupiter") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") - // JUnit4/3 on the COMPILE classpath: the plugin's test executor references JUnit4 API, and - // BasePlatformTestCase (headless component tests) descends from JUnit3 `junit.framework.TestCase`. testImplementation("junit:junit:4.13.2") - - // Headless/integration tests (in src/test) use BasePlatformTestCase, which descends from JUnit3 TestCase; - // the vintage engine lets the JUnit Platform discover and run them alongside the JUnit5 unit tests. + testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("org.junit.vintage:junit-vintage-engine") - // Full IntelliJ Platform test fixtures (BasePlatformTestCase, LightVirtualFile, EDT helpers). - intellijPlatform { - testFramework(TestFrameworkType.Platform) - } - - // --- uiTest: RemoteRobot end-to-end (Layer D), gated by -PuiTest.enabled=true --- - "uiTestImplementation"(platform("org.junit:junit-bom:6.1.2")) - "uiTestImplementation"("org.junit.jupiter:junit-jupiter") - "uiTestRuntimeOnly"("org.junit.platform:junit-platform-launcher") - "uiTestImplementation"("com.intellij.remoterobot:remote-robot:0.11.23") - "uiTestImplementation"("com.intellij.remoterobot:remote-fixtures:0.11.23") - "uiTestImplementation"("com.squareup.okhttp3:okhttp:4.12.0") } -// The Kotlin stdlib and JetBrains annotations are provided by the IntelliJ Platform at runtime; keep -// them out of the bundled plugin (where they would shadow the platform copies and trip the verifier). -// runtimeClasspath is the configuration the plugin is assembled from. -configurations.named("runtimeClasspath") { - exclude(mapOf("group" to "org.jetbrains.kotlin", "module" to "kotlin-stdlib")) - exclude(mapOf("group" to "org.jetbrains", "module" to "annotations")) -} +val npm = if (System.getProperty("os.name").startsWith("Windows")) "npm.cmd" else "npm" +val fakeClaude: String = file("bin/fake-claude").absolutePath tasks { - // Attribution travels INSIDE the artifact (opensource-licensing-standards §5.4). - // - // The published zip redistributes third-party code: marked, DOMPurify and highlight.js are vendored into - // the plugin jar under `jcef/`, and kotlinx.serialization ships as its own jars. MIT, BSD-3-Clause and - // Apache-2.0 all require the copyright notice and licence text to be preserved *on redistribution* — and a - // file sitting in the Git repository does not accompany the binary a user installs from the Marketplace. - // Copying them into the jar's resources is what actually discharges the obligation. - // - // Kept as a build step rather than a checked-in copy under `src/main/resources/`, so the notices cannot - // drift out of sync with the files they describe: one source of truth at the repository root, packaged at - // build time. `THIRD-PARTY-NOTICES.md` is surfaced to the user by the About dialog (see InfoDialogs). - val npm = if (System.getProperty("os.name").startsWith("Windows")) "npm.cmd" else "npm" - - val npmInstall by registering(Exec::class) { - inputs.files("package.json", "package-lock.json") - outputs.file("node_modules/.package-lock.json") - commandLine(npm, "ci") - } - - val compileWeb by registering(Exec::class) { - dependsOn(npmInstall) - inputs.dir("src/main/ts/jcef") - inputs.file("tsconfig.json") - outputs.dir(layout.buildDirectory.dir("web")) - doFirst { delete(layout.buildDirectory.dir("web")) } - commandLine(npm, "run", "build") - } - val frontendTest by registering(Exec::class) { - dependsOn(compileWeb) + dependsOn(":frontend:compileWeb") inputs.dir("src/test/frontend") - inputs.dir("src/main/resources/jcef") - inputs.dir("src/main/ts/jcef") + inputs.dir("frontend/src/main/resources/jcef") + inputs.dir("frontend/src/main/ts/jcef") outputs.dir(layout.buildDirectory.dir("reports/frontend")) environment("CI", "true") commandLine(npm, "test") } - check { dependsOn(frontendTest) } - processResources { - from(compileWeb) - // The distributed map is written FOR this repository and lands in the artifact by accident: the one - // under `src/main/resources/jcef/` is a resource like any other, so it shipped inside the plugin jar — - // 20 KB of internal design notes and source paths handed to every user, for nothing. Excluded by - // pattern rather than by path, because the next directory to get a map will be a `resources` one again - // and nobody will think of this file. Nothing reads it at runtime: `JcefHost` names every script and - // stylesheet it loads explicitly (`appNames`, `CSS_PARTS`) and globs no resource directory. - exclude("**/PROJECTMAP.md") - from(rootProject.file("THIRD-PARTY-NOTICES.md")) { into("META-INF") } - from(rootProject.file("LICENSE")) { into("META-INF") } - from(rootProject.file("LICENSES")) { into("META-INF/licenses") } + from(file("THIRD-PARTY-NOTICES.md")) { into("META-INF") } + from(file("LICENSE")) { into("META-INF") } + from(file("LICENSES")) { into("META-INF/licenses") } } runIde { jvmArgs("-Djb.privacy.policy.text=", "-Djb.consents.confirmation.enabled=false", "-Dclaudejb.debug=true") } test { - // Exclude the live drift check: it downloads the latest SDK from npm and spawns the real binary, - // so it must not run in the default suite. It lives in the `checkDrift` task below. (excludeTags only - // affects JUnit5/jupiter discovery — the JUnit3 vintage headless tests carry no tags and still run.) - useJUnitPlatform { excludeTags("driftLive") } - // Runs the whole non-UI pyramid: unit (jupiter) + headless/integration (BasePlatformTestCase via the - // vintage engine). The IntelliJ Platform Gradle plugin only instruments ITS `test` task with the - // platform runtime, so headless tests must run here rather than in a hand-rolled Test task. - systemProperty("claudejb.fakeClaude", rootProject.file("bin/fake-claude").absolutePath) + useJUnitPlatform { excludeTags("driftLive", "bench") } + filter { excludeTestsMatching("*Bench") } + systemProperty("claudejb.fakeClaude", fakeClaude) } +} - // On-demand protocol drift watcher (NOT wired into `check`). Downloads the latest published SDK and - // probes the locally-installed (auto-updated) `claude` binary, then prints an agent-consumable report - // and fails on real surface drift. Runs only the `driftLive`-tagged DriftLiveCheck against the test - // classpath (the pure extraction/diff logic is covered offline by DriftDetectorTest in the normal suite). - // ./gradlew checkDrift # uses ~/.local/bin/claude - // ./gradlew checkDrift -PclaudeBinary=/path # or CLAUDE_BINARY env var - val checkDrift by registering(Test::class) { - description = "Download latest SDK + probe the installed binary; report protocol drift (on-demand)." +val bench by intellijPlatformTesting.testIde.registering { + testFrameworks(TestFrameworkType.Platform) + task { group = "verification" - // Only the jupiter engine: the vintage engine would try to DISCOVER (instantiate) the JUnit3 - // headless BasePlatformTestCase classes, which aren't on this task's classpath (only the plugin's - // own `test` task gets the platform runtime) — that fails before tag filtering even applies. - useJUnitPlatform { - includeTags("driftLive") - includeEngines("junit-jupiter") + description = "Runs the benchmarks and keeps their figures in build/bench/results.txt." + useJUnitPlatform() + filter { + includeTestsMatching("*Bench") + includeTestsMatching("dev.lain.claudejb.bench.*") } - // Belt-and-suspenders: restrict discovery to the drift package. - filter { includeTestsMatching("dev.lain.claudejb.drift.*") } - testClassesDirs = - sourceSets.test - .get() - .output.classesDirs - classpath = sourceSets.test.get().runtimeClasspath - // Always re-run (it polls the network + binary); never serve a cached result. - outputs.upToDateWhen { false } - val binaryPath = ( - providers.gradleProperty("claudeBinary").orNull - ?: providers.environmentVariable("CLAUDE_BINARY").orNull - ?: "${System.getProperty("user.home")}/.local/bin/claude" - ) - systemProperty("claudejb.drift.projectDir", rootProject.projectDir.absolutePath) - systemProperty( - "claudejb.drift.sdkDir", - rootProject.file("node_modules/@anthropic-ai/claude-agent-sdk").absolutePath, - ) - systemProperty("claudejb.drift.binary", binaryPath) - systemProperty("claudejb.drift.baseline", rootProject.file("scripts/drift-baseline.properties").absolutePath) - // Surface the report (println from the test) on the console. + workingDir(layout.projectDirectory) + systemProperty("claudejb.fakeClaude", fakeClaude) testLogging { showStandardStreams = true } - } - - // NB there is deliberately NO `checkProjectMap` task, and the `PROJECTMAP.md` files are not gated. - // - // They are an orientation index for AI-assisted sessions — a local tooling convention, not part of the - // product: nothing in them reaches the artifact, and `processResources` excludes them from it. Gating the - // build on them made the one check whose failure can never be a defect in the plugin, and imposed a - // Python script on anyone who clones the repository and edits a file. Regenerate with - // `python3 scripts/gen-projectmap.py` when you want them current. - - // Convenience alias: run only the heavy IntelliJ-fixture packages (headless + fake-claude integration). - val integrationTest by registering { - description = "Runs only the headless + fake-claude integration tests (subset of `test`)." - group = "verification" - finalizedBy(named("test")) - doFirst { - (named("test").get() as Test).filter { - includeTestsMatching("dev.lain.claudejb.headless.*") - includeTestsMatching("dev.lain.claudejb.integration.*") - } - } - } - - val uiTest by registering(Test::class) { - description = "End-to-end UI tests driving the IDE via RemoteRobot (Layer D)." - group = "verification" - useJUnitPlatform() - testClassesDirs = sourceSets["uiTest"].output.classesDirs - classpath = sourceSets["uiTest"].runtimeClasspath - // RemoteRobot's HTTP client (Retrofit + Gson) reflects into JDK-internal fields to (de)serialize - // responses/exceptions; under JDK 17+ strong encapsulation that throws InaccessibleObjectException - // unless we open the relevant java.base packages to the (unnamed) test module. - jvmArgs( - "--add-opens=java.base/java.lang=ALL-UNNAMED", - "--add-opens=java.base/java.util=ALL-UNNAMED", - "--add-opens=java.base/java.text=ALL-UNNAMED", - "--add-opens=java.desktop/java.awt=ALL-UNNAMED", - "--add-opens=java.desktop/java.awt.event=ALL-UNNAMED", - ) - shouldRunAfter("integrationTest") - // Let a remote runner override where the robot-server lives (defaults to 127.0.0.1:8082 in UiTestBase). - System.getProperty("robot-server.url")?.let { systemProperty("robot-server.url", it) } - // RemoteRobot needs a running IDE on a display, and this task starts neither, so the flag is an - // acknowledgement that both are already up. It is asserted rather than used as an `onlyIf`: a Gradle - // skip is `BUILD SUCCESSFUL` with zero tests executed, which is the one outcome a verification task - // must never produce. `uiTest` hangs off no aggregate task (see `check` below), so the only way to - // reach this is to ask for it by name — and asking for it without the flag is a mistake worth a red. - doFirst { - if (project.findProperty("uiTest.enabled") != "true") { - throw GradleException( - "uiTest needs an IDE already running with robot-server on a display, and does not start " + - "one. Boot it with `./gradlew runIdeForUiTests` (under xvfb-run if headless), then " + - "re-run this task with -PuiTest.enabled=true.", - ) - } + val results = layout.buildDirectory.file("bench/results.txt") + outputs.file(results) + outputs.upToDateWhen { false } + doFirst { results.get().asFile.delete() } + doLast { + val file = results.get().asFile + logger.lifecycle(if (file.exists()) file.readText() else "No benchmark wrote a result.") } } - - // The uiTest source set inherits the test classpath, so the sandbox-project fixture can be contributed - // from more than one resource root; tolerate the duplicate deterministically instead of failing the copy. - named("processUiTestResources") { - duplicatesStrategy = DuplicatesStrategy.EXCLUDE - } - - // `check` already depends on `test`, which now includes the headless/integration packages. - // - // `uiTest` is kept out of TWO graphs, and both are needed — enumerating one and stopping there invites the - // conclusion that it is enough, which it is not: - // 1. out of `check`, because it needs a display and an already-running IDE, so it runs nightly or by - // hand and would otherwise fail every ordinary `check`; - // 2. out of Kover's report graph, via `disabledForTestTasks` in the `kover { }` block below — a `Test` - // task is pulled in as a dependency of `koverXmlReport`/`koverVerify` whether or not `check` wants - // it, so staying out of `check` alone leaves the coverage tasks unable to run anywhere the IDE is - // not already up. } -// --------------------------------------------------------------------------- -// RemoteRobot harness (Layer D). `intellijPlatformTesting.runIde` is the canonical 2.x DSL for a custom -// IDE-under-test: it builds a sandbox, installs extra plugins (here `robotServerPlugin()`, which exposes the -// HTTP endpoint RemoteRobot talks to), and lets us tune the JVM. We launch it on port 8082 and point the -// plugin at `bin/fake-claude` via the two `claudejb.fake*` system properties (read by ClaudeSettings only -// when those props are present — a no-op in shipped IDEs). -// -// Flow (two terminals / two background steps — the IDE must be UP before the client tests connect): -// 1. ./gradlew runIdeForUiTests # starts the IDE with robot-server on :8082 (keep it running) -// 2. ./gradlew uiTest -PuiTest.enabled=true # the RemoteRobot client suite connects to :8082 -// Headless CI: wrap step 1 in `xvfb-run`. See docs/UI_TESTING.md. -// --------------------------------------------------------------------------- intellijPlatformTesting { runIde { register("runIdeForUiTests") { - // Install the robot-server plugin into this IDE's sandbox; that's what RemoteRobot drives. - plugins { - robotServerPlugin() - } + plugins { robotServerPlugin() } task { - // Open the minimal sandbox project so the IDE doesn't sit on the "open a project" screen — the - // tool window, composer and editor context only exist with a Project. We open a tiny build-less - // project (not this repo) to keep the suite fast and free of Gradle-import/trust prompts. The - // IDE opens the directory passed as the first positional CLI arg. - args(rootProject.file("src/uiTest/resources/sandbox-project").absolutePath) + args(file("src/uiTest/resources/sandbox-project").absolutePath) jvmArgs( - // RemoteRobot endpoint (UiTestBase connects to http://127.0.0.1:8082). "-Drobot-server.port=8082", - // THE WHOLE UI IS A BROWSER, and this is what lets the suite talk to it. Not a magic - // number: `ide.browser.jcef.jsQueryPoolSize` is a platform REGISTRY key — `JBCefClient` - // reads it once via `RegistryManager.intValue(...)` into `JS_QUERY_POOL_DEFAULT_SIZE`, and - // a registry value falls back to the system property of the same name (verified in the - // bytecode of `RegistryValue`/`JBCefClient` in the IDE distribution), so a `-D` on the - // IDE's command line IS how it is set. - // A `JBCefJSQuery` can only be attached to a browser that has ALREADY loaded if its slot - // was reserved when the client was created; with the pool at its default the platform - // refuses with "Set the property JBCefClient.Properties.JS_QUERY_POOL_SIZE to use - // JBCefJSQuery after the browser has been created". That is exactly what JetBrains' - // `JCefBrowserFixture` does, and it is the only route src/uiTest has into the DOM — so - // without this line every DOM-driving test fails at fixture construction, before it can - // assert anything. 10000 is the size the fixture's own documented precondition asks for - // (recorded again in `UiTestBase`'s KDoc); it costs reserved callback slots in a - // throwaway sandbox IDE and nothing else. Do not "tidy" it away. "-Dide.browser.jcef.jsQueryPoolSize=10000", - // Quiet, deterministic first run: no privacy/consent gates, no tips, no "what's new". "-Djb.privacy.policy.text=", "-Djb.consents.confirmation.enabled=false", "-Dide.show.tips.on.startup.default.value=false", @@ -386,19 +154,10 @@ intellijPlatformTesting { "-Dide.mac.file.chooser.native=false", "-DjbScreenMenuBar.enabled=false", "-Dapple.laf.useScreenMenuBar=false", - // Auto-trust opened projects so no "Trust this project?" modal blocks the robot. The key is - // `idea.` — the neighbour above is `ide.` because it is a platform REGISTRY key, and this one - // is a system property read by `TrustedProjects` via `Boolean.getBoolean`. The two - // namespaces sit ten lines apart, so the wrong prefix reads as consistent with its - // neighbour: verified against the 253 distribution, where `ide.trust.all.projects` appears - // nowhere. Under Xvfb there is a real display, so the headless escape hatch - // (`idea.trust.headless.disabled`) never fires and the modal has nothing to dismiss it. "-Didea.trust.all.projects=true", "-Dide.show.new.ui.welcome.screen=false", - // Point the plugin at the deterministic fake binary + default fixture (per-test scenarios - // can override FAKE_FIXTURE; see docs/UI_TESTING.md). Read by ClaudeSettings test hook. - "-Dclaudejb.fakeClaude=${rootProject.file("bin/fake-claude").absolutePath}", - "-Dclaudejb.fakeFixture=${rootProject.file("src/test/resources/fixtures/multi_message.jsonl").absolutePath}", + "-Dclaudejb.fakeClaude=$fakeClaude", + "-Dclaudejb.fakeFixture=${file("src/test/resources/fixtures/multi_message.jsonl").absolutePath}", ) } } @@ -406,40 +165,14 @@ intellijPlatformTesting { } intellijPlatform { + pluginInstallationTarget = SplitModeAware.PluginInstallationTarget.BOTH pluginConfiguration { - // id/name/vendor/description live in META-INF/plugin.xml; only compatibility range is set here. ideaVersion { - // Floor 253 (2025.3), raised from 251 in 5.5.0 — and it is JCEF that raises it, not an API tidy-up. - // - // Since 262 the platform ships the embedded browser as a separate bundled plugin, so a plugin that - // wants `com.intellij.ui.jcef.*` in its classloader must declare `com.intellij.modules.jcef` (see - // META-INF/plugin.xml). That id does not exist on 251/252 — verified in the IDE distributions - // themselves: on 251/252 `JBCefApp` sits in `lib/app-client.jar` and nothing declares the module; - // on 253 and 261 the platform declares `` in - // `product-backend.jar`; on 262 it is the plugin. Declaring it therefore costs 2025.1 and 2025.2, - // and the alternative was leaving the plugin DEAD on 2026.2 — the whole UI is that browser, so - // there is nothing to degrade to. - // - // (The previous floor note still holds for the API: `FileChooserDescriptorFactory.multiFiles()` - // does not exist before 251. It is simply no longer the binding constraint.) - // - // Ceiling 263.*: declared ahead of the 2026.3 branch on purpose, so an EAP user is never locked out - // by a range we forgot to widen. It is not a guess — `verifyPlugin` verifies against the EAP and RC - // channels up to that bound (see the `select` block below), so the claim is checked on every run. - // 253.29346.138 = IntelliJ IDEA 2025.3.1, the FIRST build that ships - // `com.intellij.modules.jcef` — the mandatory dependency this plugin declares. 2025.3 itself - // (253.28294.334, ten days earlier) does not have it, and there the IDE refuses to load the plugin - // at all. A floor of plain "253" was therefore a promise that could not be kept for the first - // release of that branch; see the platform declaration above for the full reasoning. - sinceBuild = "253.29346.138" + sinceBuild = platformBuild untilBuild = "263.*" } - // "What's new" on the Marketplace = the latest version section of RELEASE_NOTES.md, as HTML. - changeNotes = provider { latestReleaseNotesHtml() } + changeNotes = provider { extra["changeNotesHtml"] as String } } - - // Marketplace publishing + plugin signing. All credentials come from the environment (GitHub Actions - // secrets); never commit them. Locally these are simply absent and the publish/sign tasks aren't run. publishing { token = providers.environmentVariable("PUBLISH_TOKEN") } @@ -448,34 +181,9 @@ intellijPlatform { privateKey = providers.environmentVariable("PRIVATE_KEY") password = providers.environmentVariable("PRIVATE_KEY_PASSWORD") } - pluginVerification { - // 'JetBrains' in the plugin name is a Marketplace naming lint, not an API problem; muting it lets - // the verifier proceed to the actual binary-compatibility / internal-API checks we care about. freeArgs = listOf("-mute", "TemplateWordInPluginName") externalPrefixes = listOf("org.jetbrains.uast") - - // The "zero deprecations" rule, ENFORCED rather than merely written down. - // - // The plugin's default failure level is COMPATIBILITY_PROBLEMS + INTERNAL_API_USAGES + - // OVERRIDE_ONLY_API_USAGES — deprecated usages are only REPORTED. So this repo's stated policy - // ("never ship a deprecated or scheduled-for-removal API — treat it as a blocker, not a warning") - // was a promise a human had to keep by reading logs, and a rule that lives only in prose is not a - // rule. Adding DEPRECATED_API_USAGES is what makes the sentence true. - // - // EXPERIMENTAL_API_USAGES is deliberately NOT here, and that is a decision rather than an oversight: - // `DiffTabCleanup` uses `ProjectCloseListener.projectClosingBeforeSave` knowingly, because it is the - // only hook that runs BEFORE the workspace state is written — which is the whole point of it. An - // experimental API is acceptable with a reason; a deprecated one is not acceptable at all, because - // it has an announced removal date and the plugin has to keep working across the IDE range. - // - // MISSING_DEPENDENCIES is deliberately NOT here either. A mandatory `` the target IDE cannot - // satisfy means the plugin does not load at all, and the verifier does detect it — but the Gradle plugin - // (2.16.0, and 2.18.1 alike) parses the verifier's stdout by the "Missing dependencies" heading and - // cannot tell `(optional): Unavailable` from a mandatory gap, so with that level on, every PyCharm - // target fails on the optional com.intellij.modules.java dependency that PyCharm lacks by design. - // The protection that level gave lives in PluginDependenciesContractTest instead: every non-optional - // must be a platform module every IntelliJ-based IDE ships. failureLevel = listOf( VerifyPluginTask.FailureLevel.COMPATIBILITY_PROBLEMS, @@ -484,370 +192,28 @@ intellijPlatform { VerifyPluginTask.FailureLevel.DEPRECATED_API_USAGES, ) ides { - // No hardcoded path in the repo: a developer can point the verifier at local IDE installs to skip the - // downloads, via -PlocalIdePath=[,…] or the LOCAL_IDE_PATH env var (comma-separated). This is - // what makes an OFFLINE verification of the whole declared range possible — download.jetbrains.com is - // not always reachable from every network, and the verifier is the only thing that catches a *binary* - // incompatibility (see InstalledPlugins: `PluginId` is a Kotlin class since 2025.2, so `PluginId.getId` - // compiles fine and then dies with NoSuchFieldError on 242–251). - // When unset/missing (or on CI), fall back to recommended() — which spans the plugin's whole declared - // range including the since-build FLOOR, the gate that catches a too-new API. val localIdes = - ( - providers.gradleProperty("localIdePath").orNull - ?: providers.environmentVariable("LOCAL_IDE_PATH").orNull - )?.split(',') + (providers.gradleProperty("localIdePath").orNull ?: providers.environmentVariable("LOCAL_IDE_PATH").orNull) + ?.split(',') ?.map { it.trim() } ?.filter { it.isNotEmpty() } ?.map { file(it) } ?.filter { it.exists() } .orEmpty() - val offline = localIdes.isNotEmpty() && !providers.environmentVariable("CI").isPresent - if (offline) { - // OFFLINE mode: the given installs are the ENTIRE set to verify against. Neither recommended() - // nor select() may run here — both resolve through download.jetbrains.com, so leaving either in - // made `-PlocalIdePath` a lie: it added local IDEs but still downloaded, and on a network that - // truncates a 1.6 GB transfer the task failed before verifying anything. The flag's whole - // purpose is verification WITHOUT the CDN, so in this mode there is nothing to download. + if (localIdes.isNotEmpty() && !providers.environmentVariable("CI").isPresent) { localIdes.forEach { local(it) } } else { - // THE DECLARED FLOOR, PINNED BY BUILD NUMBER — and this line exists because its absence cost a - // release. The comment below used to claim that `recommended()` covers "the since-build floor"; - // it does not. `recommended()` returns JetBrains' recommended set, which for 253 resolves to - // the LAST point release (253.33813.55), never the first. So the one build the plugin promises - // to support and is most likely to break on — the oldest — was the only one never verified, - // and `com.intellij.modules.jcef` missing from 2025.3 went unnoticed through every green run. - // Keep this in step with `sinceBuild` above: they are the same claim, stated twice, and a gate - // that drifts from the promise it checks is not a gate. - // `useInstaller = false` for the same reason the compile platform above uses it: the CDN has no - // `.tar.gz` named after a build number (`ideaIU-253.28294.334.tar.gz` → 404), only the Maven - // artifact carries one, and pinning the exact build is the entire point of this entry. - create(IntelliJPlatformType.IntellijIdea, "253.29346.138") { + create(IntelliJPlatformType.IntellijIdea, platformBuild) { useInstaller = false } - // Online (CI, or no local installs): recommended() adds JetBrains' recommended spread across - // the declared range, and select() adds the NEWEST EAP/RC. The upper bound matches the declared untilBuild; as of Aug 2026 the newest - // build on either channel is 262.9437.65 (2026.2.1 RC), so this resolves there today and picks - // up a real 263 automatically the day one ships. - // - // BOTH families, not just IDEA. The plugin is used in PyCharm as much as in IDEA, and the - // packaging differences between products are exactly where a classloader problem hides — 5.1.1 - // shipped unusable on 2026.2 because JCEF moved into a bundled plugin there, and verifying one - // product tells you nothing about how another bundles the same platform. recommended() select { - types = - listOf( - IntelliJPlatformType.IntellijIdeaCommunity, - IntelliJPlatformType.PyCharmCommunity, - ) + types = listOf(IntelliJPlatformType.IntellijIdea, IntelliJPlatformType.PyCharm) channels = listOf(ProductRelease.Channel.EAP, ProductRelease.Channel.RC) - sinceBuild = "262" + sinceBuild = "263" untilBuild = "263.*" } } } } } - -kotlin { - jvmToolchain(21) - compilerOptions { - allWarningsAsErrors.set(true) - freeCompilerArgs.add("-Xjvm-default=all") - } -} - -// --- Static analysis and formatting -------------------------------------------------------------------- -// Two tools because they answer different questions, and conflating them is how projects end up arguing -// about braces in code review: Spotless/ktlint decides how the code LOOKS (mechanical, never a judgement -// call), detekt decides whether it is likely WRONG (complexity, swallowed errors, suspicious constructs). -detekt { - buildUponDefaultConfig = true - config.setFrom(files("config/detekt/detekt.yml")) - // Set unconditionally: `detektBaseline` needs the path as an OUTPUT (it is the file it writes), so making - // it conditional on the file already existing makes generating it for the first time impossible. The - // `detekt` task tolerates the file being absent. - baseline = file("config/detekt/baseline.xml") - // Analyse main and test alike. A test that swallows an exception hides a defect just as effectively as - // production code doing it — arguably more so, because it does it while claiming to prove correctness. - source.setFrom(files("src/main/kotlin", "src/test/kotlin")) - parallel = true -} - -tasks.withType().configureEach { - jvmTarget = "21" - reports { - html.required.set(true) - sarif.required.set(true) // consumable by GitHub code scanning if we ever want the findings inline - xml.required.set(false) - txt.required.set(false) - md.required.set(false) - } -} -tasks.withType().configureEach { - jvmTarget = "21" -} - -// --------------------------------------------------------------------------- -// Coverage gates. The INTENT is per package, because one global number would be a lie either way; what the -// tool can actually enforce is a floor plus an aggregate, and the `verify` block below says why. -// -// The honest shape of this codebase is that its risk is NOT evenly distributed. `permission/` decides whether -// the agent may read your SSH key; `ui/` paints a browser. A single global threshold either sets the bar so low -// that the guard could rot unnoticed, or so high that it can only be met by writing tests against Swing and -// JCEF that assert nothing anyone cares about. So the exclusions below say which packages are not gated at -// all, and the rules say what the gated ones must hold — each bound sitting slightly BELOW what is measured, -// so it is a gate that catches regression rather than a target that invites test-padding. -// -// `ui`/`ui.jcef` are excluded rather than gated at a token value. They need a live IDE and a live Chromium, and -// they are covered by a different layer entirely: the vitest suite drives the real shipped JS (`npm test` -// reports how many, and that is the only honest way to say it — a count written here ages on its own, in -// silence, with nobody to notice), and the release checklist requires a manual pass through the UI. Excluding -// them says that out loud; gating them at 20% would dress the same fact up as a passing check. -// -// `context`/`process` are ungated: they wrap the OS (clipboard, process spawn, shell env) and most of what is -// uncovered there cannot run in CI. That is a known gap, not an endorsement. -// -// The measured figures live in ONE place — docs/RELEASE_CHECKLIST.md §Coverage policy — beside the exclusion -// list this block has to agree with. They are deliberately not repeated here: a measurement written into two -// files is a measurement that will disagree with itself, and this file has no way to notice when it does. -// --------------------------------------------------------------------------- -kover { - // Kover aggregates EVERY `Test` task in the project, so a task that is on-demand everywhere else is still - // pulled into the coverage graph and becomes a dependency of `koverXmlReport`/`koverVerify`. Being absent - // from `check` does not keep a task out of this one: it has to be named here. - // - // `checkDrift` is registered as a `Test` task and downloads the latest SDK and probes a LOCALLY INSTALLED - // `claude` binary, which a CI runner does not have. - currentProject { - instrumentation { - disabledForTestTasks.add("checkDrift") - // `uiTest` is the same shape and must be out for two independent reasons. It is a `Test` task - // (registered above), so it lands in the dependency graph of `koverXmlReport`/`koverVerify` — and - // it drives an ALREADY-RUNNING IDE over HTTP, asserting `-PuiTest.enabled=true` rather than - // skipping, so without this line neither report can be produced anywhere that IDE is not already - // up on a display. Its coverage would also be empty either way: the code it exercises runs in that - // other IDE process, which this build never instruments. - disabledForTestTasks.add("uiTest") - } - // The `uiTest` source set is a custom one, so kover reads it as code to measure rather than as tests - // that measure: its RemoteRobot suites showed up as a package `dev.lain.claudejb.ui` at 0% and failed the - // floor. They drive an external IDE and are never code under test. - sources { - excludedSourceSets.add("uiTest") - } - } - reports { - filters { - excludes { - // Need a live IDE / live Chromium to execute at all. Covered instead by the vitest suite, - // which drives the REAL shipped JS (`npm test` is what counts it), and by the manual UI pass - // the release checklist requires. - classes( - "dev.lain.claudejb.view.*", - "dev.lain.claudejb.model.bridge.*", - "dev.lain.claudejb.controller.bridge.*", - "dev.lain.claudejb.controller.commands.*", - "dev.lain.claudejb.controller.context.Link*", - ) - // Thin IDE-action shells: their bodies are one delegate call each, and exercising them means - // booting an IDE to assert that a menu item calls a method. - classes("dev.lain.claudejb.controller.actions.*") - // Wrappers over the OS — system clipboard, process spawn, shell environment. Most of what is - // uncovered here cannot run on a CI box at all. A KNOWN GAP, listed so it is not mistaken for - // coverage; the parts that are pure ARE tested — `ClipboardCli` in `controller/context/`, - // `ImageAttachments` in `model/context/` (ClipboardCliTest, ImageAttachmentsTest) and - // `EnvScriptLoader.parse` in `model/settings/env/`. Those names are load-bearing: a comment - // citing a file that no longer exists is worse than none. - classes( - "dev.lain.claudejb.model.context.*", - "dev.lain.claudejb.controller.context.*", - "dev.lain.claudejb.controller.process.*", - ) - // The Git integration's IDE-bound half: the availability probe (asks the running IDE's plugin - // set), the git4idea gateway (spawns `git log` through the platform) and the hand-off to the - // Version Control tool window. Exercising any of them means a live IDE AND a real repository on - // disk, which is exactly the headless/integration test this package deliberately does not have. - // `GitCommitInfo` — the pure half, and the only place a bug would be silent — is NOT excluded: - // it stays gated and is covered by GitCommitInfoTest. The read-only and API contracts are - // pinned by source/reflection tests instead (GitReadOnlyContractTest, GitApiContractTest). - // - // The trailing `*` is not decoration. `GitGateway.refs()` sorts with - // `compareByDescending {}.thenBy {}`, and each of those compiles to a SYNTHETIC class of its - // own (`GitGateway$refs$$inlined$thenBy$1` and friends) that an exact-name pattern does not - // match. A lambda added inside an excluded object would otherwise start counting against the - // package's floor, which reads as coverage erosion in code that was never gated. - classes( - "dev.lain.claudejb.controller.git.GitAvailability*", - "dev.lain.claudejb.controller.git.GitGateway*", - "dev.lain.claudejb.controller.git.GitHistoryService*", - "dev.lain.claudejb.controller.git.GitLogNavigator*", - ) - // A single line delegating to PluginManager.isPluginInstalled. It exists precisely BECAUSE it - // must run against a real platform (PluginId is a Kotlin class since 2025.2, so the naive call - // dies with NoSuchFieldError below 252) — which is also why a unit test cannot exercise it. - classes("dev.lain.claudejb.util.*") - // The vulnerability view's two platform-bound halves, excluded on the same grounds as - // `process.*` and `ui.*` above and NOT as a blanket on the package: `OsvHttp` is a java.net.http - // wrapper whose every branch needs a live socket, and `VulnService` is a project `@Service` that - // needs a Project, the pooled thread and the EDT. `OsvScanner` is deliberately NOT excluded — - // it talks to OsvHttp through a plain call and its gap is real debt, so it stays gated and - // visible rather than being defined out of the measurement. - classes("dev.lain.claudejb.controller.vuln.OsvHttp*", "dev.lain.claudejb.controller.vuln.VulnService*") - // The IDE MCP servers' platform-bound half: the project `@Service` that owns the sockets and - // the approval notifications, the catalog that binds servers to a Project, and the tools - // themselves (FileDocumentManager, FindInProjectUtil, FilenameIndex — every one needs a live - // index). `ServerEndpoint`, `SocketHome` and `GuardGate` are NOT excluded: they run on a real - // Unix socket and the real guard in unit tests, and the whole `model.mcp` layer is pure. - classes( - "dev.lain.claudejb.controller.mcp.IdeMcpService*", - "dev.lain.claudejb.controller.mcp.IdeToolCatalog*", - "dev.lain.claudejb.controller.mcp.tools.*", - "dev.lain.claudejb.controller.db.*", - ) - // The GitHub plugin's gateway: every call needs the plugin loaded, an account in the IDE's safe and - // GitHub itself — the same grounds as `controller.db.*`. Its availability check and the Marketplace - // gateway stay measured: the first runs headless, the second takes its fetch as a parameter. - classes("dev.lain.claudejb.controller.github.GitHubGateway*") - } - } - verify { - // `KoverVerifyRule` has no per-rule `filters` — re-checked at 0.9.9, the version this build - // resolves, against the plugin's own DSL sources: a rule exposes `groupBy`, `disabled` and its - // bounds, and filters exist on the report set, never on a rule. A report variant is no substitute - // either, because a variant is scoped by source set and not by package. So a threshold per package - // cannot be written. What CAN be written is a FLOOR applied to every package on its own, plus an - // AGGREGATE over all gated code, and the two see different failures: the floor catches one package - // collapsing, the aggregate catches erosion spread too thin for any single package to show it. - // - // Each rule carries a line bound and a branch bound, because they answer different questions. A - // line bound says the code RAN. A branch bound says the decision was taken BOTH ways — and in - // `permission/` and `session/` a branch never taken is a security decision never exercised: the - // guard's deny path, the admission fixpoint's rejection. That code reaches high line coverage - // while never once having said no, which is exactly what a line bound cannot see. - // - // The branch floor is much lower than the line floor because a floor is fixed by the weakest - // package, and on branches the weakest is far below the rest. It is therefore a collapse detector, - // not a regression detector — and the aggregate cannot stand in for it, because a small package - // carries too little of the branch mass to move the total: `permission/` could lose half its - // branch coverage without the aggregate reaching its bound. The floor is the only bound here that - // looks at a package on its own. - // - // Both files must agree, and the measured figures every bound sits below live only in - // docs/RELEASE_CHECKLIST.md §Coverage policy. - rule("every gated package holds its floor") { - groupBy = kotlinx.kover.gradle.plugin.dsl.GroupingEntityType.PACKAGE - minBound(65) - minBound(20, coverageUnits = kotlinx.kover.gradle.plugin.dsl.CoverageUnit.BRANCH) - } - rule("gated code as a whole") { - minBound(75) - minBound(40, coverageUnits = kotlinx.kover.gradle.plugin.dsl.CoverageUnit.BRANCH) - } - } - } -} - -spotless { - kotlin { - target("src/**/*.kt") - ktlint("1.8.0").editorConfigOverride( - mapOf( - // The codebase reads at ~120 columns and has done for its whole life; reflowing 13k lines to - // ktlint's default would be a huge diff that buys nothing. - "max_line_length" to "140", - // Trailing commas stay: they are why adding a parameter touches one line instead of two. - "ij_kotlin_allow_trailing_comma" to "true", - "ij_kotlin_allow_trailing_comma_on_call_site" to "true", - // function-signature off. Its only effect here was to COLLAPSE multi-line parameter lists back - // onto one line because they now fit in 140 columns — which trades away the thing the - // multi-line + trailing-comma style buys: adding a parameter is a one-line diff, not a reflow - // of the whole signature. The Kotlin conventions endorse trailing commas for exactly that - // reason and do not require collapsing a signature that happens to fit. - "ktlint_standard_function-signature" to "disabled", - "ktlint_standard_class-signature" to "disabled", - "ktlint_standard_function-expression-body" to "disabled", - // ── One owner per rule ────────────────────────────────────────────────────────────────── - // Below, ktlint duplicates a rule detekt also enforces, and only detekt can scope itself to a - // source set. Running both means the stricter-but-blinder one decides, which is how you end up - // reformatting test fixtures to satisfy a tool that cannot be told they are fixtures. So each - // of these has exactly one owner, and it is the one that can express the exception: - // - // max-line-length → detekt MaxLineLength (excludes the test tree: single-line raw-string - // protocol fixtures, one NDJSON frame each, exactly as the binary emits them). - // function-naming → detekt FunctionNaming (excludes the test tree: test methods are - // backtick-quoted sentences, which is why a failure report reads like a sentence). - // - // Production code is still covered for both — by detekt, at the same strictness as before. - "ktlint_standard_max-line-length" to "disabled", - "ktlint_standard_function-naming" to "disabled", - ), - ) - trimTrailingWhitespace() - endWithNewline() - } - kotlinGradle { - target("*.gradle.kts") - ktlint("1.8.0") - } -} - -/** Extracts the top (latest) `## vX.Y.Z` section of RELEASE_NOTES.md and renders it as the HTML subset - * the Marketplace accepts for change notes. Falls back to a generic line if the file is missing. */ -fun latestReleaseNotesHtml(): String { - val notes = file("RELEASE_NOTES.md") - if (!notes.exists()) return "See RELEASE_NOTES.md." - val lines = notes.readLines() - val start = lines.indexOfFirst { it.startsWith("## v") } - if (start < 0) return "See RELEASE_NOTES.md." - val end = - lines.drop(start + 1).indexOfFirst { it.startsWith("## v") }.let { - if (it < 0) lines.size else start + 1 + it - } - - fun inline(s: String): String = - s - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace(Regex("\\*\\*(.+?)\\*\\*"), "$1") - .replace(Regex("`(.+?)`"), "$1") - - val html = StringBuilder() - var inList = false - - fun closeList() { - if (!inList) return - html.append("") - inList = false - } - - for (raw in lines.subList(start, end)) { - val line = raw.trim() - when { - line.startsWith("## v") -> { - html.append("

    ").append(inline(line.removePrefix("## ").trim())).append("

    ") - } - - line == "---" || line.isEmpty() -> { - closeList() - } - - line.startsWith("- ") -> { - if (!inList) { - html.append("
      ") - inList = true - } - html.append("
    • ").append(inline(line.removePrefix("- ").trim())).append("
    • ") - } - - else -> { - closeList() - html.append("

      ").append(inline(line)).append("

      ") - } - } - } - closeList() - return html.toString() -} diff --git a/frontend/build.gradle.kts b/frontend/build.gradle.kts new file mode 100644 index 00000000..6dc3d0b5 --- /dev/null +++ b/frontend/build.gradle.kts @@ -0,0 +1,30 @@ +val npm = if (System.getProperty("os.name").startsWith("Windows")) "npm.cmd" else "npm" + +dependencies { + intellijPlatform { + bundledModule("intellij.platform.frontend") + bundledPlugin("com.intellij.modules.jcef") + } + implementation(project(":shared")) +} + +val npmInstall by tasks.registering(Exec::class) { + workingDir(rootDir) + inputs.files(rootProject.file("package.json"), rootProject.file("package-lock.json")) + outputs.file(rootProject.file("node_modules/.package-lock.json")) + commandLine(npm, "ci") +} + +val compileWeb by tasks.registering(Exec::class) { + dependsOn(npmInstall) + workingDir(rootDir) + inputs.dir("src/main/ts/jcef") + inputs.file(rootProject.file("tsconfig.json")) + outputs.dir(layout.buildDirectory.dir("web")) + doFirst { delete(layout.buildDirectory.dir("web")) } + commandLine(npm, "run", "build") +} + +tasks.processResources { + from(compileWeb) +} diff --git a/frontend/src/main/resources/dev.lain.claudejb.frontend.xml b/frontend/src/main/resources/dev.lain.claudejb.frontend.xml new file mode 100644 index 00000000..be160ad9 --- /dev/null +++ b/frontend/src/main/resources/dev.lain.claudejb.frontend.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/gradle/coverage.gradle.kts b/gradle/coverage.gradle.kts new file mode 100644 index 00000000..02b22a57 --- /dev/null +++ b/gradle/coverage.gradle.kts @@ -0,0 +1,58 @@ +import kotlinx.kover.gradle.plugin.dsl.CoverageUnit +import kotlinx.kover.gradle.plugin.dsl.GroupingEntityType +import kotlinx.kover.gradle.plugin.dsl.KoverProjectExtension + +dependencies { + listOf("shared", "frontend", "backend").forEach { "kover"(project(":$it")) } +} + +configure { + currentProject { + instrumentation { + disabledForTestTasks.addAll("checkDrift", "uiTest", "bench") + } + sources { + excludedSourceSets.add("uiTest") + } + } + reports { + filters { + excludes { + classes( + "dev.lain.claudejb.frontend.*", + "dev.lain.claudejb.view.*", + "dev.lain.claudejb.model.bridge.*", + "dev.lain.claudejb.controller.bridge.*", + "dev.lain.claudejb.controller.commands.*", + "dev.lain.claudejb.controller.actions.*", + "dev.lain.claudejb.model.context.*", + "dev.lain.claudejb.controller.context.*", + "dev.lain.claudejb.controller.process.*", + "dev.lain.claudejb.controller.git.GitAvailability*", + "dev.lain.claudejb.controller.git.GitGateway*", + "dev.lain.claudejb.controller.git.GitHistoryService*", + "dev.lain.claudejb.controller.git.GitLogNavigator*", + "dev.lain.claudejb.util.*", + "dev.lain.claudejb.controller.vuln.OsvHttp*", + "dev.lain.claudejb.controller.vuln.VulnService*", + "dev.lain.claudejb.controller.mcp.IdeMcpService*", + "dev.lain.claudejb.controller.mcp.IdeToolCatalog*", + "dev.lain.claudejb.controller.mcp.tools.*", + "dev.lain.claudejb.controller.db.*", + "dev.lain.claudejb.controller.github.GitHubGateway*", + ) + } + } + verify { + rule("every gated package holds its floor") { + groupBy.set(GroupingEntityType.PACKAGE) + minBound(65) + minBound(20, coverageUnits = CoverageUnit.BRANCH) + } + rule("gated code as a whole") { + minBound(75) + minBound(40, coverageUnits = CoverageUnit.BRANCH) + } + } + } +} diff --git a/gradle/quality.gradle.kts b/gradle/quality.gradle.kts new file mode 100644 index 00000000..940fc2da --- /dev/null +++ b/gradle/quality.gradle.kts @@ -0,0 +1,52 @@ +import com.diffplug.gradle.spotless.SpotlessExtension +import dev.detekt.gradle.Detekt +import dev.detekt.gradle.DetektCreateBaselineTask +import dev.detekt.gradle.extensions.DetektExtension + +val pluginModules = listOf("shared", "frontend", "backend") + +configure { + buildUponDefaultConfig.set(true) + config.setFrom(files("config/detekt/detekt.yml")) + baseline.set(file("config/detekt/baseline.xml")) + source.setFrom(pluginModules.map { "$it/src/main/kotlin" } + "src/test/kotlin") + parallel.set(true) +} + +tasks.withType().configureEach { + jvmTarget.set("25") + reports { + html.required.set(true) + sarif.required.set(true) + checkstyle.required.set(false) + markdown.required.set(false) + } +} + +tasks.withType().configureEach { + jvmTarget.set("25") +} + +configure { + kotlin { + target("src/**/*.kt", *pluginModules.map { "$it/src/**/*.kt" }.toTypedArray()) + ktlint("1.8.0").editorConfigOverride( + mapOf( + "max_line_length" to "140", + "ij_kotlin_allow_trailing_comma" to "true", + "ij_kotlin_allow_trailing_comma_on_call_site" to "true", + "ktlint_standard_function-signature" to "disabled", + "ktlint_standard_class-signature" to "disabled", + "ktlint_standard_function-expression-body" to "disabled", + "ktlint_standard_max-line-length" to "disabled", + "ktlint_standard_function-naming" to "disabled", + ), + ) + trimTrailingWhitespace() + endWithNewline() + } + kotlinGradle { + target("*.gradle.kts", "*/build.gradle.kts", "gradle/*.gradle.kts") + ktlint("1.8.0") + } +} diff --git a/gradle/release-notes.gradle.kts b/gradle/release-notes.gradle.kts new file mode 100644 index 00000000..9c2337b0 --- /dev/null +++ b/gradle/release-notes.gradle.kts @@ -0,0 +1,53 @@ +fun inline(text: String): String = + text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(Regex("\\*\\*(.+?)\\*\\*"), "$1") + .replace(Regex("`(.+?)`"), "$1") + +fun latestSection(lines: List): List { + val start = lines.indexOfFirst { it.startsWith("## v") } + if (start < 0) return emptyList() + val next = lines.drop(start + 1).indexOfFirst { it.startsWith("## v") } + return lines.subList(start, if (next < 0) lines.size else start + 1 + next) +} + +fun releaseNotesHtml(lines: List): String { + val html = StringBuilder() + var inList = false + + fun closeList() { + if (inList) html.append("
    ") + inList = false + } + + for (line in lines.map { it.trim() }) { + when { + line.startsWith("## v") -> { + html.append("

    ").append(inline(line.removePrefix("## ").trim())).append("

    ") + } + + line == "---" || line.isEmpty() -> { + closeList() + } + + line.startsWith("- ") -> { + if (!inList) html.append("
      ") + inList = true + html.append("
    • ").append(inline(line.removePrefix("- ").trim())).append("
    • ") + } + + else -> { + closeList() + html.append("

      ").append(inline(line)).append("

      ") + } + } + } + closeList() + return html.toString() +} + +val notes = file("RELEASE_NOTES.md") +val section = if (notes.exists()) latestSection(notes.readLines()) else emptyList() +extra["changeNotesHtml"] = if (section.isEmpty()) "See RELEASE_NOTES.md." else releaseNotesHtml(section) diff --git a/gradle/test-suites.gradle.kts b/gradle/test-suites.gradle.kts new file mode 100644 index 00000000..1b27072f --- /dev/null +++ b/gradle/test-suites.gradle.kts @@ -0,0 +1,85 @@ +val sourceSets = the() + +val uiTest = + sourceSets.create("uiTest") { + compileClasspath += sourceSets["main"].output + sourceSets["test"].output + runtimeClasspath += output + compileClasspath + } + +configurations { + named("uiTestImplementation") { extendsFrom(configurations["testImplementation"]) } + named("uiTestRuntimeOnly") { extendsFrom(configurations["testRuntimeOnly"]) } +} + +dependencies { + "uiTestImplementation"(platform("org.junit:junit-bom:6.1.3")) + "uiTestImplementation"("org.junit.jupiter:junit-jupiter") + "uiTestRuntimeOnly"("org.junit.platform:junit-platform-launcher") + "uiTestImplementation"("com.intellij.remoterobot:remote-robot:0.11.23") + "uiTestImplementation"("com.intellij.remoterobot:remote-fixtures:0.11.23") + "uiTestImplementation"("com.squareup.okhttp3:okhttp:4.12.0") +} + +tasks.register("checkDrift") { + description = "Download latest SDK + probe the installed binary; report protocol drift (on-demand)." + group = "verification" + useJUnitPlatform { + includeTags("driftLive") + includeEngines("junit-jupiter") + } + filter { includeTestsMatching("dev.lain.claudejb.drift.*") } + testClassesDirs = sourceSets["test"].output.classesDirs + classpath = sourceSets["test"].runtimeClasspath + outputs.upToDateWhen { false } + val binaryPath = + providers.gradleProperty("claudeBinary").orNull + ?: providers.environmentVariable("CLAUDE_BINARY").orNull + ?: "${System.getProperty("user.home")}/.local/bin/claude" + systemProperty("claudejb.drift.projectDir", rootDir.absolutePath) + systemProperty("claudejb.drift.sdkDir", file("node_modules/@anthropic-ai/claude-agent-sdk").absolutePath) + systemProperty("claudejb.drift.binary", binaryPath) + systemProperty("claudejb.drift.baseline", file("scripts/drift-baseline.properties").absolutePath) + testLogging { showStandardStreams = true } +} + +tasks.register("integrationTest") { + description = "Runs only the headless + fake-claude integration tests (subset of `test`)." + group = "verification" + finalizedBy(tasks.named("test")) + doFirst { + tasks.named("test").get().filter { + includeTestsMatching("dev.lain.claudejb.headless.*") + includeTestsMatching("dev.lain.claudejb.integration.*") + } + } +} + +tasks.register("uiTest") { + description = "End-to-end UI tests driving the IDE via RemoteRobot (Layer D)." + group = "verification" + useJUnitPlatform() + testClassesDirs = uiTest.output.classesDirs + classpath = uiTest.runtimeClasspath + jvmArgs( + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.base/java.util=ALL-UNNAMED", + "--add-opens=java.base/java.text=ALL-UNNAMED", + "--add-opens=java.desktop/java.awt=ALL-UNNAMED", + "--add-opens=java.desktop/java.awt.event=ALL-UNNAMED", + ) + shouldRunAfter("integrationTest") + System.getProperty("robot-server.url")?.let { systemProperty("robot-server.url", it) } + doFirst { + if (project.findProperty("uiTest.enabled") != "true") { + throw GradleException( + "uiTest needs an IDE already running with robot-server on a display, and does not start " + + "one. Boot it with `./gradlew runIdeForUiTests` (under xvfb-run if headless), then " + + "re-run this task with -PuiTest.enabled=true.", + ) + } + } +} + +tasks.named("processUiTestResources") { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 640104e1..ab5f9808 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,7 +1,32 @@ +import org.jetbrains.intellij.platform.gradle.extensions.intellijPlatform + rootProject.name = "claude-code-native" pluginManagement { repositories { gradlePluginPortal() + mavenCentral() + maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies/") + } +} + +plugins { + id("org.jetbrains.intellij.platform.settings") version "2.19.0" + id("org.jetbrains.kotlin.jvm") version "2.4.0" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.4.0" apply false + id("rpc") version "2.4.0-RC-0.1" apply false + id("org.jetbrains.kotlinx.kover") version "0.9.9" apply false + id("dev.detekt") version "2.0.0-alpha.6" apply false + id("com.diffplug.spotless") version "8.10.2" apply false +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + intellijPlatform { + defaultRepositories() + } } } + +include("shared", "frontend", "backend") diff --git a/src/main/resources/META-INF/claude-database.xml b/shared/src/main/resources/dev.lain.claudejb.shared.xml similarity index 100% rename from src/main/resources/META-INF/claude-database.xml rename to shared/src/main/resources/dev.lain.claudejb.shared.xml diff --git a/src/main/resources/META-INF/claude-git.xml b/src/main/resources/META-INF/claude-git.xml deleted file mode 100644 index 58866126..00000000 --- a/src/main/resources/META-INF/claude-git.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/src/main/resources/META-INF/claude-github.xml b/src/main/resources/META-INF/claude-github.xml deleted file mode 100644 index e8c7d7ad..00000000 --- a/src/main/resources/META-INF/claude-github.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/src/main/resources/META-INF/claude-intellilang.xml b/src/main/resources/META-INF/claude-intellilang.xml deleted file mode 100644 index 164f46cf..00000000 --- a/src/main/resources/META-INF/claude-intellilang.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/src/main/resources/META-INF/claude-java.xml b/src/main/resources/META-INF/claude-java.xml deleted file mode 100644 index 164f46cf..00000000 --- a/src/main/resources/META-INF/claude-java.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/src/main/resources/META-INF/claude-terminal.xml b/src/main/resources/META-INF/claude-terminal.xml deleted file mode 100644 index 475ede32..00000000 --- a/src/main/resources/META-INF/claude-terminal.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index f27ed504..50dfac94 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -1,9 +1,6 @@ dev.lain.claude-code-for-jetbrains Claude Code Native - lain -

      Works in any JetBrains IDE on the 2025.3 platform or newer — IntelliJ IDEA, PyCharm, - WebStorm, PhpStorm, GoLand, RubyMine, CLion, Rider, DataGrip, DataSpell, Aqua and RustRover.

      +

      Works in any JetBrains IDE on the 2026.2 platform or newer — IntelliJ IDEA, PyCharm, + WebStorm, PhpStorm, GoLand, RubyMine, CLion, Rider, DataGrip, DataSpell and RustRover — locally, + in Remote Development and in Code With Me.

      Unofficial, community-built, open-source plugin. Not affiliated with, sponsored by, or endorsed by Anthropic or JetBrains. It requires the user's own separately-installed @@ -121,10 +119,9 @@

      Requirements

        -
      • JetBrains IDE 2025.3 or newer (build 253+). 2025.1 and 2025.2 are not supported: - the whole chat UI is an embedded browser (JCEF), and 2025.3 is the first release where the - platform exposes it as a declarable module — which is what keeps the plugin working on 2026.2, - where JCEF moved out of the IDE core into a bundled plugin of its own
      • +
      • JetBrains IDE 2026.2 or newer (build 262.8665+). Earlier releases are not + supported: the plugin is split into a frontend and a backend so that it runs in Remote + Development and Code With Me, and 2026.2 is the platform that plugin model targets
      • claude CLI installed and on PATH or ~/.local/bin/ (npm install -g @anthropic-ai/claude-code)
      • A Claude subscription (Pro / Max / Team / Enterprise) or an ANTHROPIC_API_KEY
      • From c0a63e3093a46c6f7a64e6afcfa7cf042b4aab55 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:57:57 +0200 Subject: [PATCH 033/155] build: declare the content modules in the plugin descriptor instead of dependencies --- src/main/resources/META-INF/plugin.xml | 109 ++----------------------- 1 file changed, 5 insertions(+), 104 deletions(-) diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 50dfac94..e56942ef 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -133,108 +133,9 @@ s.r.o. Used here for identification only; this project is not affiliated with either company.

        ]]> - - com.intellij.modules.platform - - com.intellij.modules.jcef - - org.jetbrains.plugins.terminal - - Git4Idea - - org.jetbrains.plugins.github - - com.intellij.modules.java - org.intellij.intelliLang - com.intellij.database - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + From 5fc8a1ab6371b330f0a0cd3bba1a36e3d9fb283d Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:58:05 +0200 Subject: [PATCH 034/155] perf(mcp): trim the services listing and reuse a fresh tree walk --- .../controller/mcp/tools/ops/ServiceTools.kt | 23 ++++++++++--------- .../controller/mcp/tools/ops/ServiceTree.kt | 14 ++++++++++- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ServiceTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ServiceTools.kt index 633e52be..afb43101 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ServiceTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ServiceTools.kt @@ -33,7 +33,7 @@ internal class ServiceTools(private val project: Project, private val scope: Cor ) private suspend fun services(args: ToolArgs): ToolResult { - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, MAX_NODES) val filter = args.optionalString("filter").orEmpty() val walk = withContext(Dispatchers.Default) { tree.walk(if (filter.isEmpty()) max else FILTER_WALK_CEILING) } val matching = walk.nodes.filter { filter.isEmpty() || it.matches(filter) } @@ -42,6 +42,7 @@ internal class ServiceTools(private val project: Project, private val scope: Cor buildJsonObject { put("count", matching.size) put("truncated", walk.truncated || matching.size > rows.size) + put("kinds", buildJsonObject { rows.distinctBy { it.rootPath }.forEach { put(it.rootPath, it.kind) } }) put("services", buildJsonArray { rows.forEach { add(row(it)) } }) }, ) @@ -49,14 +50,12 @@ internal class ServiceTools(private val project: Project, private val scope: Cor private fun row(node: ServiceNode): JsonObject = buildJsonObject { put("path", node.path) - put("name", node.name) - put("kind", node.kind) put("state", node.state) } private suspend fun serviceActions(args: ToolArgs): ToolResult { val path = args.string("path") - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_ACTIONS, MAX_NODES) val node = withContext(Dispatchers.Default) { tree.find(path) } val (entries, fromTree) = withContext(Dispatchers.EDT) { ServiceActions(project, node).run { @@ -124,17 +123,19 @@ internal class ServiceTools(private val project: Project, private val scope: Cor companion object { - private const val DEFAULT_MAX = 200 + private const val DEFAULT_MAX = 50 + private const val DEFAULT_ACTIONS = 200 + private const val MAX_NODES = 1_000 private const val FILTER_WALK_CEILING = 5_000 val SERVICES = ToolSpec( "services", - "The Services tree as the user sees it, flattened in tree order: every node's path (Contributor/parent/child), " + - "name, kind (the contributing plugin's class) and state (the grey text beside it, e.g. running or exited). " + - "Use it before any other services tool: the path is how a node is named to them. " + - "filter keeps only nodes whose path, state or kind contains the text.", + "The Services tree as the user sees it, flattened in tree order: every node's path (Contributor/parent/child; " + + "its last segment is the name) and state (the grey text beside it, e.g. running or exited), with kinds " + + "naming the contributing plugin's class once per root. Use it before any other services tool: the path is " + + "how a node is named to them. filter keeps only nodes whose path, state or kind contains the text.", listOf( - Param("max", "Maximum nodes to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("nodes", DEFAULT_MAX, MAX_NODES), Param("filter", "Case-insensitive text a node's path, state or kind must contain", required = false), ), ) @@ -146,7 +147,7 @@ internal class ServiceTools(private val project: Project, private val scope: Cor "expanded. Use it to learn what service_action can do on a node.", listOf( Param("path", "The node's path as services lists it"), - Param("max", "Maximum actions to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("actions", DEFAULT_ACTIONS, MAX_NODES), ), ) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ServiceTree.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ServiceTree.kt index 85b1767d..04fbdbc7 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ServiceTree.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ServiceTree.kt @@ -13,6 +13,7 @@ internal class ServiceNode( val value: Any, val root: ServiceViewContributor<*>, val descriptor: ServiceViewDescriptor, + val rootPath: String, ) { val kind: String get() = root.javaClass.simpleName @@ -24,13 +25,20 @@ internal class ServiceWalk(val nodes: List, val truncated: Boolean) internal class ServiceTree(private val project: Project) { + private class Recent(val nodes: List, val at: Long) + + @Volatile + private var recent: Recent? = null + fun walk(max: Int): ServiceWalk { val walker = Walker(max, wanted = null) walker.roots() + recent = Recent(walker.nodes, System.nanoTime()) return ServiceWalk(walker.nodes.take(max), walker.nodes.size > max) } fun find(path: String): ServiceNode { + recent?.takeIf { System.nanoTime() - it.at < FRESH_NANOS }?.nodes?.firstOrNull { it.path == path }?.let { return it } val walker = Walker(LOOKUP_CEILING, wanted = path) walker.roots() return walker.nodes.firstOrNull { it.path == path } @@ -41,6 +49,7 @@ internal class ServiceTree(private val project: Project) { val nodes = ArrayList() private val taken = HashSet() + private var rootPath = "" private fun done(): Boolean = nodes.size > max || (wanted != null && nodes.lastOrNull()?.path == wanted) @@ -51,6 +60,7 @@ internal class ServiceTree(private val project: Project) { if (services.isEmpty()) continue val descriptor = runCatching { root.getViewDescriptor(project) }.getOrNull() ?: continue val node = add("", descriptor, root, root) + rootPath = node.path descend(root, root, services, node.path, 1) } } @@ -80,7 +90,8 @@ internal class ServiceTree(private val project: Project) { val name = presentation.presentableText.orEmpty() val path = uniquePath(parentPath, name, taken) taken += path - val node = ServiceNode(path, name, presentation.locationString.orEmpty(), value, root, descriptor) + val top = if (parentPath.isEmpty()) path else rootPath + val node = ServiceNode(path, name, presentation.locationString.orEmpty(), value, root, descriptor, top) nodes += node return node } @@ -94,6 +105,7 @@ internal class ServiceTree(private val project: Project) { private const val MAX_DEPTH = 12 private const val LOOKUP_CEILING = 5_000 + private const val FRESH_NANOS = 5_000_000_000L const val UNNAMED = "unnamed" fun uniquePath(parentPath: String, name: String, taken: Set): String { From 37db36e7bef8f4e396c5c52c6f40671578c0bf5c Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 20:58:23 +0200 Subject: [PATCH 035/155] build(deps): update the npm toolchain and point it at the frontend module --- .nvmrc | 2 +- eslint.config.mjs | 57 ++++--------------------------- package.json | 24 +++++++------ scripts/drift-baseline.properties | 9 +---- tsconfig.json | 6 ++-- vitest.config.js | 29 ++++++++-------- 6 files changed, 38 insertions(+), 89 deletions(-) diff --git a/.nvmrc b/.nvmrc index 2bd5a0a9..a45fd52c 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22 +24 diff --git a/eslint.config.mjs b/eslint.config.mjs index bfd4606d..17128bcb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,34 +2,15 @@ import js from '@eslint/js'; import globals from 'globals'; import prettier from 'eslint-config-prettier'; -/** - * ESLint for the JCEF web app — the ~3.6k lines of JavaScript that SHIP INSIDE the plugin jar. - * - * Until 5.0.0 this code had no linter at all, which mattered more here than it would in most projects: it runs - * inside an embedded Chromium under a hash-pinned CSP, where a mistake surfaces as a blank panel in a user's - * IDE rather than as a stack trace anyone sees. - * - * The vendored libraries (marked, DOMPurify, highlight.js) are deliberately NOT linted: they are third-party - * minified bundles we redistribute unmodified, so a finding in them is not ours to fix and fixing it would fork - * a dependency. Their licences ride along in META-INF (see THIRD-PARTY-NOTICES.md). - */ export default [ { - // Never lint what we did not write, or build output. ignores: [ - 'src/main/resources/jcef/marked.min.js', - 'src/main/resources/jcef/purify.min.js', - 'src/main/resources/jcef/highlight.min.js', - 'build/**', + 'frontend/src/main/resources/jcef/marked.min.js', + 'frontend/src/main/resources/jcef/purify.min.js', + 'frontend/src/main/resources/jcef/highlight.min.js', + '**/build/**', 'node_modules/**', - // `runIde` unpacks a sandbox IDE here, and the bundled Database plugin ships its data extractors as - // `.js` files that are not JavaScript we own — they run inside the IDE's own scripting host against - // injected globals (`com`, `OUT`, `COLUMNS`, `ROWS`, …), so `no-undef` fires 12 times on them. The - // directory is gitignored, so CI never sees it; without this line `npm run lint` is red on every - // workstation that has ever run `runIde`, which is every workstation. '.intellijPlatform/**', - // Assistant scratch tooling: node scripts that run on the workstation, never shipped and gitignored. - // They are CommonJS against node globals, so `no-undef` fires on every one of them. '.claudetools/**', ], }, @@ -37,60 +18,37 @@ export default [ js.configs.recommended, { - // The shipped web app. Classic scripts (no bundler, no modules): shell.html loads each file with a - // " } + fun block(sources: List): String = sources.joinToString("\n") { "" } - val html = shell + val html = PageMinifier.shell(shell) .replace("", "") .replace("", "") - .replace("", block(LIB_NAMES)) - .replace("", block(appNames)) + .replace("", block(libs)) + .replace("", block(listOfNotNull(app))) return Page(html, headersFor(csp)) } diff --git a/src/main/kotlin/dev/lain/claudejb/view/jcef/PageDelivery.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/PageDelivery.kt similarity index 83% rename from src/main/kotlin/dev/lain/claudejb/view/jcef/PageDelivery.kt rename to frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/PageDelivery.kt index 6a56d6a3..91802c93 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/jcef/PageDelivery.kt +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/PageDelivery.kt @@ -1,15 +1,13 @@ -package dev.lain.claudejb.view.jcef +package dev.lain.claudejb.frontend.jcef import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.logger import com.intellij.ui.jcef.JBCefBrowser import com.intellij.util.Alarm -import dev.lain.claudejb.util.edtNow -import dev.lain.claudejb.util.logger internal class PageDelivery( private val browser: JBCefBrowser, - private val page: Page, parentDisposable: Disposable, private val webReady: () -> Boolean, private val onRedeliver: () -> Unit, @@ -21,13 +19,24 @@ internal class PageDelivery( private val readyWatchdog = Alarm(Alarm.ThreadToUse.SWING_THREAD, parentDisposable) + private var page: Page? = null + var route: PageRoute? = null private set @Volatile private var loopback: LoopbackPageServer? = null - fun start() = deliver(startRoute(schemeAvailable = SchemePageServer.register(page))) + fun start() { + ApplicationManager.getApplication().executeOnPooledThread { + val assembled = PageAssembly.page + edtNow { + if (isDisposed()) return@edtNow + page = assembled + deliver(startRoute(schemeAvailable = SchemePageServer.register(assembled))) + } + } + } fun isOwnPage(url: String?): Boolean = isOwnPageUrl(url, SchemePageServer.PAGE_URL, loopback?.url) @@ -73,8 +82,9 @@ internal class PageDelivery( } private fun serveOverLoopback() { + val served = page ?: return ApplicationManager.getApplication().executeOnPooledThread { - val server = LoopbackPageServer.start(page.html, page.headers) + val server = LoopbackPageServer.start(served.html, served.headers) edtNow { if (isDisposed() || route != PageRoute.LOOPBACK) { server?.stop() diff --git a/src/main/kotlin/dev/lain/claudejb/view/jcef/PageLoadHandler.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/PageLoadHandler.kt similarity index 96% rename from src/main/kotlin/dev/lain/claudejb/view/jcef/PageLoadHandler.kt rename to frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/PageLoadHandler.kt index 1096cf5b..c512fcb6 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/jcef/PageLoadHandler.kt +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/PageLoadHandler.kt @@ -1,4 +1,4 @@ -package dev.lain.claudejb.view.jcef +package dev.lain.claudejb.frontend.jcef import org.cef.browser.CefBrowser import org.cef.browser.CefFrame diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/PageMinifier.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/PageMinifier.kt new file mode 100644 index 00000000..7cbfeee0 --- /dev/null +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/PageMinifier.kt @@ -0,0 +1,49 @@ +package dev.lain.claudejb.frontend.jcef + +internal object PageMinifier { + + private val HTML_COMMENT = Regex(")[\\s\\S]*?-->") + + fun shell(html: String): String = + html.replace(HTML_COMMENT, "").lineSequence().filter { it.isNotBlank() }.joinToString("\n") + + fun css(source: String): String = + withoutCssComments(source).lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.joinToString("\n") + + fun isolated(script: String): String = + "try{\n" + script + "\n}catch(e){setTimeout(function(){throw e})}" + + private fun withoutCssComments(source: String): String { + val out = StringBuilder(source.length) + var quote: Char? = null + var i = 0 + while (i < source.length) { + val c = source[i] + when { + quote != null -> { + out.append(c) + if (c == '\\' && i + 1 < source.length) { + out.append(source[i + 1]) + i++ + } else if (c == quote) { + quote = null + } + } + + c == '"' || c == '\'' -> { + quote = c + out.append(c) + } + + c == '/' && source.startsWith("/*", i) -> { + val end = source.indexOf("*/", i + 2) + i = if (end < 0) source.length else end + 1 + } + + else -> out.append(c) + } + i++ + } + return out.toString() + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/view/jcef/PageRoute.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/PageRoute.kt similarity index 95% rename from src/main/kotlin/dev/lain/claudejb/view/jcef/PageRoute.kt rename to frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/PageRoute.kt index ebbecfee..9ccc9803 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/jcef/PageRoute.kt +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/PageRoute.kt @@ -1,4 +1,4 @@ -package dev.lain.claudejb.view.jcef +package dev.lain.claudejb.frontend.jcef internal enum class PageRoute { SCHEME, LOOPBACK } diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/PendingCalls.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/PendingCalls.kt new file mode 100644 index 00000000..f635e00b --- /dev/null +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/PendingCalls.kt @@ -0,0 +1,41 @@ +package dev.lain.claudejb.frontend.jcef + +internal class PendingCalls(private val maxDeltas: Int = MAX_DELTAS) { + + class Drained(val calls: List, val overflowed: Boolean) + + private val calls = LinkedHashMap() + + private var deltas = 0 + + private var overflowed = false + + fun add(key: String?, js: String) { + if (key != null) { + calls.remove(key) + calls[key] = js + return + } + if (overflowed) return + if (deltas < maxDeltas) { + calls[Any()] = js + deltas++ + return + } + calls.keys.removeIf { it !is String } + deltas = 0 + overflowed = true + } + + fun drain(): Drained { + val drained = Drained(calls.values.toList(), overflowed) + calls.clear() + deltas = 0 + overflowed = false + return drained + } + + companion object { + const val MAX_DELTAS = 512 + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/view/jcef/SchemePageServer.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/SchemePageServer.kt similarity index 98% rename from src/main/kotlin/dev/lain/claudejb/view/jcef/SchemePageServer.kt rename to frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/SchemePageServer.kt index ec4900ca..ed8f2596 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/jcef/SchemePageServer.kt +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/SchemePageServer.kt @@ -1,4 +1,4 @@ -package dev.lain.claudejb.view.jcef +package dev.lain.claudejb.frontend.jcef import org.cef.CefApp import org.cef.browser.CefBrowser diff --git a/src/test/kotlin/dev/lain/claudejb/view/jcef/JcefNavigationGuardTest.kt b/src/test/kotlin/dev/lain/claudejb/frontend/jcef/JcefNavigationGuardTest.kt similarity index 97% rename from src/test/kotlin/dev/lain/claudejb/view/jcef/JcefNavigationGuardTest.kt rename to src/test/kotlin/dev/lain/claudejb/frontend/jcef/JcefNavigationGuardTest.kt index d94fb0ad..de1580fa 100644 --- a/src/test/kotlin/dev/lain/claudejb/view/jcef/JcefNavigationGuardTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/frontend/jcef/JcefNavigationGuardTest.kt @@ -1,4 +1,4 @@ -package dev.lain.claudejb.view.jcef +package dev.lain.claudejb.frontend.jcef import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue diff --git a/src/test/kotlin/dev/lain/claudejb/view/jcef/LoopbackPageServerTest.kt b/src/test/kotlin/dev/lain/claudejb/frontend/jcef/LoopbackPageServerTest.kt similarity index 99% rename from src/test/kotlin/dev/lain/claudejb/view/jcef/LoopbackPageServerTest.kt rename to src/test/kotlin/dev/lain/claudejb/frontend/jcef/LoopbackPageServerTest.kt index f4a77070..79d37125 100644 --- a/src/test/kotlin/dev/lain/claudejb/view/jcef/LoopbackPageServerTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/frontend/jcef/LoopbackPageServerTest.kt @@ -1,4 +1,4 @@ -package dev.lain.claudejb.view.jcef +package dev.lain.claudejb.frontend.jcef import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse diff --git a/src/test/kotlin/dev/lain/claudejb/view/jcef/PageArrivalTest.kt b/src/test/kotlin/dev/lain/claudejb/frontend/jcef/PageArrivalTest.kt similarity index 97% rename from src/test/kotlin/dev/lain/claudejb/view/jcef/PageArrivalTest.kt rename to src/test/kotlin/dev/lain/claudejb/frontend/jcef/PageArrivalTest.kt index 932f3014..6f06e94d 100644 --- a/src/test/kotlin/dev/lain/claudejb/view/jcef/PageArrivalTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/frontend/jcef/PageArrivalTest.kt @@ -1,4 +1,4 @@ -package dev.lain.claudejb.view.jcef +package dev.lain.claudejb.frontend.jcef import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue diff --git a/src/test/kotlin/dev/lain/claudejb/frontend/jcef/PageAssemblyTest.kt b/src/test/kotlin/dev/lain/claudejb/frontend/jcef/PageAssemblyTest.kt new file mode 100644 index 00000000..251f7730 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/frontend/jcef/PageAssemblyTest.kt @@ -0,0 +1,45 @@ +package dev.lain.claudejb.frontend.jcef + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.Base64 + +class PageAssemblyTest { + + private val page = PageAssembly.build() + + private val csp = page.headers.getValue("Content-Security-Policy") + + private fun inline(tag: String): List = + Regex("<$tag>([\\s\\S]*?)").findAll(page.html).map { it.groupValues[1] }.toList() + + private fun hash(s: String): String = + "'sha256-" + Base64.getEncoder().encodeToString( + MessageDigest.getInstance("SHA-256").digest(s.toByteArray(StandardCharsets.UTF_8)), + ) + "'" + + @Test + fun `the app ships as one script beside the three vendored libraries`() { + assertEquals(4, inline("script").size) + } + + @Test + fun `every inline script and the stylesheet are admitted by their own hash`() { + inline("script").forEach { assertTrue(csp.contains(hash(it)), "a script is not in the CSP") } + inline("style").forEach { assertTrue(csp.contains("style-src ${hash(it)}"), "the stylesheet is not in the CSP") } + } + + @Test + fun `the meta policy and the header policy are the same policy`() { + assertTrue(page.html.contains("content=\"$csp\"")) + } + + @Test + fun `the page is assembled once for every tab`() { + assertSame(PageAssembly.page, PageAssembly.page) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/frontend/jcef/PageMinifierTest.kt b/src/test/kotlin/dev/lain/claudejb/frontend/jcef/PageMinifierTest.kt new file mode 100644 index 00000000..492fae65 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/frontend/jcef/PageMinifierTest.kt @@ -0,0 +1,44 @@ +package dev.lain.claudejb.frontend.jcef + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PageMinifierTest { + + @Test + fun `the shell loses its comments and keeps the four injection markers`() { + val shell = "\n \n \n \n\n" + + "\n \n \n \n" + + val out = PageMinifier.shell(shell) + + listOf("", "", "", "").forEach { assertTrue(out.contains(it), it) } + assertFalse(out.contains("why the CSP")) + assertFalse(out.contains("a note")) + assertFalse(out.lines().any { it.isBlank() }) + } + + @Test + fun `css loses comments and indentation but never the text of a string`() { + val css = "/* header */\n.a {\n content: \"/* kept */\";\n margin: 0 auto; /* trailing */\n}\n\n" + + assertEquals(".a {\ncontent: \"/* kept */\";\nmargin: 0 auto;\n}", PageMinifier.css(css)) + } + + @Test + fun `an escaped quote does not end a css string`() { + val css = ".a { content: \"x\\\" /* kept */\"; }" + + assertEquals(css, PageMinifier.css(css)) + } + + @Test + fun `each app script is isolated so one failure does not stop the rest`() { + val isolated = PageMinifier.isolated("(function(){ boom(); })();") + + assertTrue(isolated.startsWith("try{\n(function(){ boom(); })();\n}catch(e){")) + assertTrue(isolated.contains("setTimeout(function(){throw e})")) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/view/jcef/PageRouteTest.kt b/src/test/kotlin/dev/lain/claudejb/frontend/jcef/PageRouteTest.kt similarity index 94% rename from src/test/kotlin/dev/lain/claudejb/view/jcef/PageRouteTest.kt rename to src/test/kotlin/dev/lain/claudejb/frontend/jcef/PageRouteTest.kt index 76b267aa..fc42cc67 100644 --- a/src/test/kotlin/dev/lain/claudejb/view/jcef/PageRouteTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/frontend/jcef/PageRouteTest.kt @@ -1,4 +1,4 @@ -package dev.lain.claudejb.view.jcef +package dev.lain.claudejb.frontend.jcef import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -43,7 +43,7 @@ class PageRouteTest { } private fun source(name: String): File { - val path = "src/main/kotlin/dev/lain/claudejb/view/jcef/$name" + val path = "frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/$name" return sequenceOf(File(path), File("../$path")).firstOrNull { it.isFile } ?: error("could not locate $path from ${File("").absolutePath}") } diff --git a/src/test/kotlin/dev/lain/claudejb/frontend/jcef/PendingCallsTest.kt b/src/test/kotlin/dev/lain/claudejb/frontend/jcef/PendingCallsTest.kt new file mode 100644 index 00000000..4bc1374e --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/frontend/jcef/PendingCallsTest.kt @@ -0,0 +1,54 @@ +package dev.lain.claudejb.frontend.jcef + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PendingCallsTest { + + @Test + fun `a snapshot keeps only its latest call, placed where the latest arrived`() { + val pending = PendingCalls() + pending.add("session", "s1") + pending.add(null, "d1") + pending.add("session", "s2") + + assertEquals(listOf("d1", "s2"), pending.drain().calls) + } + + @Test + fun `deltas below the cap are all kept, in order`() { + val pending = PendingCalls(maxDeltas = 3) + listOf("a", "b", "c").forEach { pending.add(null, it) } + + val drained = pending.drain() + assertEquals(listOf("a", "b", "c"), drained.calls) + assertFalse(drained.overflowed) + } + + @Test + fun `past the cap every delta is dropped, the snapshots survive and a resync is asked for`() { + val pending = PendingCalls(maxDeltas = 2) + pending.add("meta", "m") + listOf("a", "b", "c", "d").forEach { pending.add(null, it) } + pending.add("tabs", "t") + + val drained = pending.drain() + assertEquals(listOf("m", "t"), drained.calls) + assertTrue(drained.overflowed) + } + + @Test + fun `a drain starts the queue over`() { + val pending = PendingCalls(maxDeltas = 1) + pending.add(null, "a") + pending.add(null, "b") + pending.drain() + + pending.add(null, "c") + val drained = pending.drain() + assertEquals(listOf("c"), drained.calls) + assertFalse(drained.overflowed) + } +} From ab9a5daac86349f0cc985877d365d116529d6343 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:05:24 +0200 Subject: [PATCH 046/155] refactor(frontend): move the chat theme into the frontend module --- .../lain/claudejb/frontend/theme/ChatTheme.kt | 34 +++++++++++++++++++ .../claudejb/frontend/theme}/JcefTheme.kt | 23 ++++++------- .../dev/lain/claudejb/view/feed/ChatTheme.kt | 25 -------------- 3 files changed, 45 insertions(+), 37 deletions(-) create mode 100644 frontend/src/main/kotlin/dev/lain/claudejb/frontend/theme/ChatTheme.kt rename {src/main/kotlin/dev/lain/claudejb/view/payload => frontend/src/main/kotlin/dev/lain/claudejb/frontend/theme}/JcefTheme.kt (76%) delete mode 100644 src/main/kotlin/dev/lain/claudejb/view/feed/ChatTheme.kt diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/theme/ChatTheme.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/theme/ChatTheme.kt new file mode 100644 index 00000000..efc51e04 --- /dev/null +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/theme/ChatTheme.kt @@ -0,0 +1,34 @@ +package dev.lain.claudejb.frontend.theme + +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.util.containers.ContainerUtil +import java.awt.Color + +object ChatTheme { + + private const val VIBE_SATURATION = 0.85f + private const val VIBE_BRIGHTNESS = 1.0f + + private val CORAL = Color(0xD97757) + + private val VIBE_ACCENT = Color(Color.HSBtoRGB(0f, VIBE_SATURATION, VIBE_BRIGHTNESS)) + + private val listeners = ContainerUtil.createLockFreeCopyOnWriteList<() -> Unit>() + + @Volatile var vibeMode: Boolean = false + private set + + fun setVibeMode(on: Boolean) { + if (vibeMode == on) return + vibeMode = on + listeners.forEach { it() } + } + + fun onChange(parent: Disposable, listener: () -> Unit) { + listeners.add(listener) + Disposer.register(parent) { listeners.remove(listener) } + } + + val ACCENT: Color get() = if (vibeMode) VIBE_ACCENT else CORAL +} diff --git a/src/main/kotlin/dev/lain/claudejb/view/payload/JcefTheme.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/theme/JcefTheme.kt similarity index 76% rename from src/main/kotlin/dev/lain/claudejb/view/payload/JcefTheme.kt rename to frontend/src/main/kotlin/dev/lain/claudejb/frontend/theme/JcefTheme.kt index 920dfed7..3cd29c55 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/payload/JcefTheme.kt +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/theme/JcefTheme.kt @@ -1,13 +1,13 @@ -package dev.lain.claudejb.view.payload +package dev.lain.claudejb.frontend.theme -import com.intellij.ide.ui.UISettings +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.editor.colors.EditorFontType +import com.intellij.openapi.editor.colors.TextAttributesKey import com.intellij.ui.JBColor import com.intellij.util.ui.JBUI import com.intellij.util.ui.NamedColorUtil import com.intellij.util.ui.UIUtil -import dev.lain.claudejb.view.feed.ChatTheme import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -32,7 +32,7 @@ object JcefTheme { private const val SURFACE_NUDGE_UP = 18 private const val SURFACE_NUDGE_DOWN = -12 - fun vars(reduceMotion: Boolean = false): JsonObject { + fun vars(): JsonObject { val scheme = EditorColorsManager.getInstance().globalScheme val editorBg = scheme.defaultBackground val panelBg = UIUtil.getPanelBackground() @@ -66,17 +66,16 @@ object JcefTheme { put("fontFamily", "\"${labelFont.family}\", system-ui, sans-serif") put("monoFamily", "\"$monoFamily\", \"JetBrains Mono\", monospace") put("fontSize", "${JBUI.scale(BASE_FONT_PX)}px") - val syn = { key: com.intellij.openapi.editor.colors.TextAttributesKey, fallback: Color -> + val syn = { key: TextAttributesKey, fallback: Color -> hex(scheme.getAttributes(key)?.foregroundColor ?: fallback) } - put("synKeyword", syn(com.intellij.openapi.editor.DefaultLanguageHighlighterColors.KEYWORD, accent)) - put("synString", syn(com.intellij.openapi.editor.DefaultLanguageHighlighterColors.STRING, text)) - put("synComment", syn(com.intellij.openapi.editor.DefaultLanguageHighlighterColors.LINE_COMMENT, dim)) - put("synNumber", syn(com.intellij.openapi.editor.DefaultLanguageHighlighterColors.NUMBER, text)) - put("synFunction", syn(com.intellij.openapi.editor.DefaultLanguageHighlighterColors.FUNCTION_DECLARATION, text)) - put("synType", syn(com.intellij.openapi.editor.DefaultLanguageHighlighterColors.CLASS_NAME, text)) + put("synKeyword", syn(DefaultLanguageHighlighterColors.KEYWORD, accent)) + put("synString", syn(DefaultLanguageHighlighterColors.STRING, text)) + put("synComment", syn(DefaultLanguageHighlighterColors.LINE_COMMENT, dim)) + put("synNumber", syn(DefaultLanguageHighlighterColors.NUMBER, text)) + put("synFunction", syn(DefaultLanguageHighlighterColors.FUNCTION_DECLARATION, text)) + put("synType", syn(DefaultLanguageHighlighterColors.CLASS_NAME, text)) put("vibe", ChatTheme.vibeMode) - put("reducedMotion", reduceMotion) } } diff --git a/src/main/kotlin/dev/lain/claudejb/view/feed/ChatTheme.kt b/src/main/kotlin/dev/lain/claudejb/view/feed/ChatTheme.kt deleted file mode 100644 index 262123f6..00000000 --- a/src/main/kotlin/dev/lain/claudejb/view/feed/ChatTheme.kt +++ /dev/null @@ -1,25 +0,0 @@ -package dev.lain.claudejb.view.feed - -import com.intellij.util.ui.UIUtil -import java.awt.Color - -object ChatTheme { - - private const val VIBE_SATURATION = 0.85f - private const val VIBE_BRIGHTNESS = 1.0f - - private val CORAL = Color(0xD97757) - - private val VIBE_ACCENT = Color(Color.HSBtoRGB(0f, VIBE_SATURATION, VIBE_BRIGHTNESS)) - - val BG: Color get() = UIUtil.getPanelBackground() - - @Volatile var vibeMode: Boolean = false - private set - - fun setVibeMode(on: Boolean) { - vibeMode = on - } - - val ACCENT: Color get() = if (vibeMode) VIBE_ACCENT else CORAL -} From 85c57552e52f59018f5498251e196de4a02542ef Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:05:29 +0200 Subject: [PATCH 047/155] feat(frontend): drive the chat tabs through the chat contract --- .../lain/claudejb/frontend/rpc/ChatClient.kt | 92 ++++++++ .../lain/claudejb/frontend/rpc/ChatLink.kt | 83 +++++++ .../claudejb/frontend/rpc/ChatListener.kt | 12 + .../lain/claudejb/frontend/window/ChatCard.kt | 14 ++ .../claudejb/frontend/window/ChatTabsPanel.kt | 116 +++++++++ .../lain/claudejb/frontend/window/ChatView.kt | 100 ++++++++ .../window/ClaudeToolWindowFactory.kt | 37 +++ .../frontend/window/ClientClipboard.kt | 28 +++ .../claudejb/frontend/window/PageMessage.kt | 47 ++++ .../claudejb/view/window/ChatTabsPanel.kt | 185 --------------- .../view/window/ClaudeToolWindowFactory.kt | 201 ---------------- .../frontend/window/ChatTabsPanelTest.kt | 145 ++++++++++++ .../headless/ChatTabsCloseHeadlessTest.kt | 220 ------------------ 13 files changed, 674 insertions(+), 606 deletions(-) create mode 100644 frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatClient.kt create mode 100644 frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatLink.kt create mode 100644 frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatListener.kt create mode 100644 frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatCard.kt create mode 100644 frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatTabsPanel.kt create mode 100644 frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatView.kt create mode 100644 frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClaudeToolWindowFactory.kt create mode 100644 frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClientClipboard.kt create mode 100644 frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/PageMessage.kt delete mode 100644 src/main/kotlin/dev/lain/claudejb/view/window/ChatTabsPanel.kt delete mode 100644 src/main/kotlin/dev/lain/claudejb/view/window/ClaudeToolWindowFactory.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/frontend/window/ChatTabsPanelTest.kt delete mode 100644 src/test/kotlin/dev/lain/claudejb/headless/ChatTabsCloseHeadlessTest.kt diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatClient.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatClient.kt new file mode 100644 index 00000000..6ad6ebe1 --- /dev/null +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatClient.kt @@ -0,0 +1,92 @@ +package dev.lain.claudejb.frontend.rpc + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.asContextElement +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.platform.project.projectId +import dev.lain.claudejb.rpc.ChatApi +import dev.lain.claudejb.rpc.ChatId +import dev.lain.claudejb.rpc.PagePush +import fleet.rpc.client.durable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout + +@Service(Service.Level.PROJECT) +class ChatClient(private val project: Project, private val scope: CoroutineScope) { + + fun connect(parent: Disposable, listener: ChatListener) { + val job = scope.launch { + if (reachable()) follow(listener) else onEdt { listener.unavailable() } + } + Disposer.register(parent) { job.cancel() } + } + + fun select(chatId: ChatId) { + scope.launch { + try { + ChatApi.getInstance().select(project.projectId(), chatId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.warn("Claude Code could not tell the host which chat is on screen", e) + } + } + } + + fun link( + chatId: ChatId, + parent: Disposable, + onPush: (PagePush) -> Unit, + isWebReady: () -> Boolean, + ): ChatLink = ChatLink(scope, project.projectId(), chatId, onPush, isWebReady).also { Disposer.register(parent, it) } + + private suspend fun reachable(): Boolean = + try { + withTimeout(CONNECT_TIMEOUT_MS) { ChatApi.getInstance().chats(project.projectId()) } + true + } catch (_: TimeoutCancellationException) { + log.warn("Claude Code chat host did not answer within ${CONNECT_TIMEOUT_MS}ms") + false + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.warn("Claude Code chat host is not reachable from this client", e) + false + } + + private suspend fun follow(listener: ChatListener) { + val projectId = project.projectId() + durable { + val api = ChatApi.getInstance() + val events = api.events(projectId) + coroutineScope { + launch(start = CoroutineStart.UNDISPATCHED) { + events.collect { event -> onEdt { listener.event(event) } } + } + val chats = api.chats(projectId).ifEmpty { listOf(api.newChat(projectId)) } + onEdt { listener.sync(chats) } + } + } + } + + private suspend fun onEdt(block: () -> Unit) = + withContext(Dispatchers.EDT + ModalityState.any().asContextElement()) { block() } + + private companion object { + private val log = logger() + + private const val CONNECT_TIMEOUT_MS = 30_000L + } +} diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatLink.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatLink.kt new file mode 100644 index 00000000..1fcf3da0 --- /dev/null +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatLink.kt @@ -0,0 +1,83 @@ +package dev.lain.claudejb.frontend.rpc + +import com.intellij.openapi.Disposable +import com.intellij.openapi.diagnostic.logger +import com.intellij.platform.project.ProjectId +import dev.lain.claudejb.rpc.ChatApi +import dev.lain.claudejb.rpc.ChatId +import dev.lain.claudejb.rpc.PagePush +import fleet.rpc.client.durable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +class ChatLink internal constructor( + scope: CoroutineScope, + private val projectId: ProjectId, + private val chatId: ChatId, + private val onPush: (PagePush) -> Unit, + private val isWebReady: () -> Boolean, +) : Disposable { + + private sealed interface Outgoing { + class Post(val json: String) : Outgoing + + data object Ready : Outgoing + } + + private val outgoing = Channel(Channel.UNLIMITED) + + private val job = scope.launch { + launch { send() } + launch { receive() } + } + + fun post(json: String) { + outgoing.trySend(Outgoing.Post(json)) + } + + fun ready() { + outgoing.trySend(Outgoing.Ready) + } + + override fun dispose() { + outgoing.close() + job.cancel() + } + + private suspend fun send() { + for (item in outgoing) deliver(item) + } + + private suspend fun deliver(item: Outgoing) { + try { + val api = ChatApi.getInstance() + when (item) { + is Outgoing.Post -> api.post(projectId, chatId, item.json) + Outgoing.Ready -> api.ready(projectId, chatId) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.warn("Claude Code could not hand a chat page message to the host", e) + } + } + + private suspend fun receive() { + durable { + val api = ChatApi.getInstance() + val pushes = api.pushes(projectId, chatId) + coroutineScope { + launch(start = CoroutineStart.UNDISPATCHED) { pushes.collect { onPush(it) } } + if (isWebReady()) api.ready(projectId, chatId) + } + } + } + + private companion object { + private val log = logger() + } +} diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatListener.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatListener.kt new file mode 100644 index 00000000..693cda9d --- /dev/null +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatListener.kt @@ -0,0 +1,12 @@ +package dev.lain.claudejb.frontend.rpc + +import dev.lain.claudejb.rpc.ChatEvent +import dev.lain.claudejb.rpc.ChatRef + +interface ChatListener { + fun unavailable() + + fun sync(chats: List) + + fun event(event: ChatEvent) +} diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatCard.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatCard.kt new file mode 100644 index 00000000..36b90990 --- /dev/null +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatCard.kt @@ -0,0 +1,14 @@ +package dev.lain.claudejb.frontend.window + +import com.intellij.openapi.Disposable +import javax.swing.JComponent + +internal interface ChatCard : Disposable { + val component: JComponent + + fun focus() + + fun focusTarget(): JComponent? + + fun whenReady(block: () -> Unit) +} diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatTabsPanel.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatTabsPanel.kt new file mode 100644 index 00000000..b3148c5a --- /dev/null +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatTabsPanel.kt @@ -0,0 +1,116 @@ +package dev.lain.claudejb.frontend.window + +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBPanel +import com.intellij.util.ui.JBUI +import dev.lain.claudejb.frontend.rpc.ChatListener +import dev.lain.claudejb.rpc.ChatEvent +import dev.lain.claudejb.rpc.ChatId +import dev.lain.claudejb.rpc.ChatRef +import java.awt.BorderLayout +import java.awt.CardLayout +import javax.swing.JComponent +import javax.swing.JPanel + +internal class ChatTabsPanel( + private val onSelect: (ChatId) -> Unit, + private val newCard: (ChatId) -> ChatCard, +) : JBPanel(BorderLayout()), Disposable, ChatListener { + + private val cards = CardLayout() + + private val deck = JPanel(cards) + + private val open = LinkedHashMap() + + var selected: ChatId? = null + private set + + init { + add(deck, BorderLayout.CENTER) + } + + fun chats(): List = open.keys.toList() + + fun focusTarget(): JComponent? = selected?.let { open[it] }?.focusTarget() + + override fun unavailable() { + removeAll() + add(JBLabel(UNAVAILABLE).apply { border = JBUI.Borders.empty(16) }, BorderLayout.CENTER) + revalidate() + repaint() + } + + override fun sync(chats: List) { + val listed = chats.mapTo(HashSet()) { it.id } + open.keys.filterNot { it in listed }.forEach(::remove) + chats.forEach { add(it.id) } + if (selected == null) chats.lastOrNull()?.let { if (show(it.id, focus = false)) onSelect(it.id) } + } + + override fun event(event: ChatEvent) { + when (event) { + is ChatEvent.Opened -> opened(event.chat.id, event.select) + is ChatEvent.Closed -> remove(event.id) + is ChatEvent.Selected -> show(event.id, focus = true) + is ChatEvent.Renamed -> Unit + } + } + + fun select(id: ChatId) { + if (show(id, focus = true)) onSelect(id) + } + + fun focus(id: ChatId) { + show(id, focus = true) + } + + private fun opened(id: ChatId, select: Boolean) { + add(id) + when { + selected == null -> show(id, focus = select) + select -> open[id]?.whenReady { if (open.keys.lastOrNull() == id) show(id, focus = true) } + } + } + + private fun add(id: ChatId) { + if (id in open) return + val card = newCard(id) + Disposer.register(this, card) + open[id] = card + deck.add(card.component, id.value) + } + + private fun show(id: ChatId, focus: Boolean): Boolean { + val card = open[id] ?: return false + selected = id + cards.show(deck, id.value) + deck.revalidate() + deck.repaint() + if (focus) card.focus() + return true + } + + private fun remove(id: ChatId) { + val card = open.remove(id) ?: return + if (selected == id) { + selected = null + open.keys.lastOrNull()?.let(::select) + } + card.component.isVisible = false + deck.remove(card.component) + Disposer.dispose(card) + deck.revalidate() + deck.repaint() + } + + override fun dispose() = Unit + + private companion object { + const val UNAVAILABLE = + "The Claude Code chat runs on the host IDE. Install and enable the Claude Code plugin there " + + "to use it from this client." + } +} diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatView.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatView.kt new file mode 100644 index 00000000..f6f07efa --- /dev/null +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatView.kt @@ -0,0 +1,100 @@ +package dev.lain.claudejb.frontend.window + +import com.intellij.ide.BrowserUtil +import com.intellij.ide.ui.LafManagerListener +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.logger +import dev.lain.claudejb.frontend.jcef.JcefHost +import dev.lain.claudejb.frontend.jcef.edtNow +import dev.lain.claudejb.frontend.rpc.ChatClient +import dev.lain.claudejb.frontend.theme.ChatTheme +import dev.lain.claudejb.frontend.theme.JcefTheme +import dev.lain.claudejb.rpc.ChatId +import dev.lain.claudejb.rpc.FrontendChannel +import dev.lain.claudejb.rpc.PagePush +import javax.swing.JComponent + +internal class ChatView( + chatId: ChatId, + client: ChatClient, + private val onFocusRequest: () -> Unit, +) : ChatCard { + + private val host = JcefHost(this, ::onPageMessage, onResync = ::resync) + + private val link = client.link(chatId, this, ::onPush) { host.isWebReady } + + override val component: JComponent get() = host.component + + init { + ApplicationManager.getApplication().messageBus.connect(this) + .subscribe(LafManagerListener.TOPIC, LafManagerListener { pushTheme() }) + ChatTheme.onChange(this, ::pushTheme) + } + + override fun focus() = host.requestFocus() + + override fun focusTarget(): JComponent? = host.inputComponent() + + override fun whenReady(block: () -> Unit) = host.whenWebReady(block = block) + + override fun dispose() = Unit + + private fun onPageMessage(json: String) { + val message = PageMessage.parse(json) + when { + message.type in FrontendChannel.clientMessages -> paste(message.type) + message.type == PageMessage.OPEN && PageMessage.isSecureLink(message.url) -> BrowserUtil.browse(message.url.trim()) + else -> { + link.post(json) + if (message.type == PageMessage.READY) pageReady() + } + } + } + + private fun pageReady() { + host.markWebReady() + pushTheme() + link.ready() + } + + private fun resync() { + if (host.isWebReady) link.ready() + } + + private fun onPush(push: PagePush) { + if (!FrontendChannel.isFrontendPush(push.method)) { + host.call(push.method, push.json) + return + } + when (push.method) { + FrontendChannel.COPY -> PageMessage.string(push.json)?.let { text -> edtNow { ClientClipboard.copy(text) } } + FrontendChannel.BROWSE -> PageMessage.string(push.json)?.takeIf(PageMessage::isWebLink)?.let { BrowserUtil.browse(it) } + FrontendChannel.VIBE -> ChatTheme.setVibeMode(push.json.trim() == "true") + FrontendChannel.FOCUS -> edtNow(onFocusRequest) + else -> log.warn("Claude Code host sent a frontend push this client does not know: ${push.method}") + } + } + + private fun paste(type: String) { + ApplicationManager.getApplication().executeOnPooledThread { + val image = ClientClipboard.pngBase64() + when { + image != null -> link.post(PageMessage.imageAttachment(image)) + type == PageMessage.PASTE_TEXT -> ClientClipboard.text()?.let { host.call(INSERT_TEXT, PageMessage.quote(it)) } + } + } + } + + private fun pushTheme() = edtNow { + host.exec("window.cc.theme && window.cc.theme(" + JcefTheme.vars() + ")", THEME_KEY) + } + + private companion object { + private val log = logger() + + const val INSERT_TEXT = "insertText" + + const val THEME_KEY = "frontend.theme" + } +} diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClaudeToolWindowFactory.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClaudeToolWindowFactory.kt new file mode 100644 index 00000000..59a2db65 --- /dev/null +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClaudeToolWindowFactory.kt @@ -0,0 +1,37 @@ +package dev.lain.claudejb.frontend.window + +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.ui.content.ContentFactory +import dev.lain.claudejb.frontend.jcef.PageAssembly +import dev.lain.claudejb.frontend.rpc.ChatClient +import java.util.concurrent.Callable + +class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { + + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + ApplicationManager.getApplication().executeOnPooledThread(Callable { PageAssembly.page }) + val client = project.service() + lateinit var tabs: ChatTabsPanel + tabs = ChatTabsPanel(client::select) { id -> + ChatView(id, client) { toolWindow.show { tabs.focus(id) } } + } + val content = ContentFactory.getInstance().createContent(tabs, "", false) + content.isCloseable = false + content.setPreferredFocusedComponent { tabs.focusTarget() } + content.setDisposer(tabs) + toolWindow.contentManager.addContent(content) + (ActionManager.getInstance().getAction(GEAR_GROUP) as? ActionGroup)?.let(toolWindow::setAdditionalGearActions) + client.connect(tabs, tabs) + } + + private companion object { + const val GEAR_GROUP = "ClaudeCode.ToolWindowGear" + } +} diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClientClipboard.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClientClipboard.kt new file mode 100644 index 00000000..ce933b42 --- /dev/null +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClientClipboard.kt @@ -0,0 +1,28 @@ +package dev.lain.claudejb.frontend.window + +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.util.ui.ImageUtil +import java.awt.Image +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.StringSelection +import java.io.ByteArrayOutputStream +import java.util.Base64 +import javax.imageio.ImageIO + +internal object ClientClipboard { + + fun text(): String? = + runCatching { CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor) } + .getOrNull() + ?.takeIf { it.isNotEmpty() } + + fun pngBase64(): String? { + val image = runCatching { CopyPasteManager.getInstance().getContents(DataFlavor.imageFlavor) } + .getOrNull() ?: return null + val out = ByteArrayOutputStream() + val written = runCatching { ImageIO.write(ImageUtil.toBufferedImage(image), "png", out) }.getOrDefault(false) + return if (written) Base64.getEncoder().encodeToString(out.toByteArray()) else null + } + + fun copy(text: String) = CopyPasteManager.getInstance().setContents(StringSelection(text)) +} diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/PageMessage.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/PageMessage.kt new file mode 100644 index 00000000..da366af4 --- /dev/null +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/PageMessage.kt @@ -0,0 +1,47 @@ +package dev.lain.claudejb.frontend.window + +import dev.lain.claudejb.rpc.FrontendChannel +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +internal class PageMessage(val type: String, val url: String) { + + companion object { + const val READY = "ready" + const val OPEN = "open" + const val PASTE_TEXT = "pasteClipboard" + + private val lenient = Json { + ignoreUnknownKeys = true + isLenient = true + } + + fun parse(text: String): PageMessage { + val obj = runCatching { lenient.parseToJsonElement(text).jsonObject }.getOrNull() + return PageMessage(obj.field("type"), obj.field("url")) + } + + fun string(json: String): String? = + runCatching { lenient.parseToJsonElement(json).jsonPrimitive.contentOrNull }.getOrNull() + + fun quote(text: String): String = JsonPrimitive(text).toString() + + fun imageAttachment(base64: String): String = buildJsonObject { + put("type", FrontendChannel.ATTACH_IMAGE) + put("mime", "image/png") + put("base64", base64) + }.toString() + + fun isSecureLink(url: String): Boolean = url.trim().startsWith("https://", ignoreCase = true) + + fun isWebLink(url: String): Boolean = isSecureLink(url) || url.trim().startsWith("http://", ignoreCase = true) + + private fun JsonObject?.field(key: String): String = (this?.get(key) as? JsonPrimitive)?.contentOrNull.orEmpty() + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/ChatTabsPanel.kt b/src/main/kotlin/dev/lain/claudejb/view/window/ChatTabsPanel.kt deleted file mode 100644 index ba29f0b7..00000000 --- a/src/main/kotlin/dev/lain/claudejb/view/window/ChatTabsPanel.kt +++ /dev/null @@ -1,185 +0,0 @@ -package dev.lain.claudejb.view.window - -import com.intellij.openapi.Disposable -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.util.Disposer -import com.intellij.ui.components.JBPanel -import dev.lain.claudejb.controller.commands.TabSessionCommands -import dev.lain.claudejb.controller.session.ClaudeSession -import dev.lain.claudejb.util.logger -import dev.lain.claudejb.view.payload.chat.JcefTabsData -import dev.lain.claudejb.view.payload.panel.JcefSessionData -import java.awt.BorderLayout -import java.awt.CardLayout -import javax.swing.JComponent -import javax.swing.JPanel - -internal class ChatTabsPanel : JBPanel(BorderLayout()), Disposable { - - internal class ChatTab( - val id: String, - val component: JComponent, - var title: String, - var tooltip: String, - val disposer: Disposable?, - ) { - var attention: Boolean = false - - var lastNotified: Long = 0L - - val session: ClaudeSession? get() = (component as? JcefChatPanel)?.session - } - - private val cards = CardLayout() - private val content = JPanel(cards) - - private val tabs = ArrayList() - private var selectedTab: ChatTab? = null - private var seq = 0 - - private var onClosed: (ChatTab) -> Unit = {} - private var onSelected: (ChatTab?) -> Unit = {} - - private var disposed = false - - val selected: ChatTab? get() = selectedTab - - val selectedChat: JcefChatPanel? get() = selectedTab?.component as? JcefChatPanel - - var commands: TabSessionCommands? = null - - init { - add(content, BorderLayout.CENTER) - } - - fun onEvents(selected: (ChatTab?) -> Unit, closed: (ChatTab) -> Unit) { - onSelected = selected - onClosed = closed - } - - fun add(component: JComponent, title: String, tooltip: String, disposer: Disposable?): ChatTab { - val tab = ChatTab("chat-${seq++}", component, title, tooltip, disposer) - tabs += tab - content.add(component, tab.id) - pushChats() - return tab - } - - fun select(tab: ChatTab) { - if (tab !in tabs) return - selectedTab = tab - tab.attention = false - pushChats() - cards.show(content, tab.id) - (tab.component as? JcefChatPanel)?.let { panel -> - runCatching { panel.transcript.showTranscript(null) } - .onFailure { LOG.warn("Claude Code tabs: showing '${tab.title}' failed to reset its transcript", it) } - runCatching { panel.focusInput() } - .onFailure { LOG.warn("Claude Code tabs: showing '${tab.title}' failed to take focus", it) } - } - onSelected(tab) - } - - fun panelOf(id: String): JcefChatPanel? = - tabs.firstOrNull { it.id == id }?.component as? JcefChatPanel - - fun tabFor(session: ClaudeSession): ChatTab? = - tabs.firstOrNull { (it.component as? JcefChatPanel)?.session === session } - - fun selectById(id: String) { - val tab = tabs.firstOrNull { it.id == id } - if (tab == null) { - LOG.warn(unknownTab("selectChat", id)) - return - } - select(tab) - } - - fun closeById(id: String) { - val tab = tabs.firstOrNull { it.id == id } - if (tab == null) { - LOG.warn(unknownTab("closeChat", id)) - return - } - close(tab) - } - - private fun unknownTab(gesture: String, id: String): String = - "Claude Code tabs: $gesture named '$id', which is not an open tab — the page is drawing a list this " + - "strip no longer has. Open now: ${tabs.map { it.id to it.title }}" - - fun close(tab: ChatTab) { - if (!tabs.remove(tab)) return - try { - if (selectedTab === tab) { - selectedTab = null - tabs.firstOrNull()?.let { select(it) } ?: pushChats() - } else { - pushChats() - } - tab.component.isVisible = false - onClosed(tab) - content.remove(tab.component) - tab.disposer?.let { Disposer.dispose(it) } - content.revalidate() - content.repaint() - } finally { - replaceLastChat() - } - } - - private fun replaceLastChat() { - if (tabs.isNotEmpty()) return - val open = commands - if (open == null) { - LOG.warn( - "The last chat was closed with no tab commands wired, so no replacement was opened: the tool " + - "window is left with no chats and no control to create one.", - ) - return - } - ApplicationManager.getApplication().invokeLater( - { if (!disposed && tabs.isEmpty()) open.newChat() }, - ModalityState.defaultModalityState(), - ) - } - - fun all(): List = tabs.toList() - - fun relabel(tab: ChatTab, title: String, tooltip: String) { - tab.title = title - tab.tooltip = tooltip - pushChats() - } - - fun badge(tab: ChatTab, attention: Boolean) { - if (tab === selectedTab) return - tab.attention = attention - pushChats() - } - - private fun pushChats() { - val list = chatList() - tabs.forEach { (it.component as? JcefChatPanel)?.agentTabs?.setChats(list) } - } - - fun chatList(): List = tabs.map { - JcefTabsData.Chat(it.id, it.title, it === selectedTab, it.attention) - } - - fun workloads(): List = tabs.mapNotNull { tab -> - (tab.component as? JcefChatPanel)?.let { panel -> - JcefSessionData.Workload(tab.id, tab.title, tab === selectedTab, panel.session) - } - } - - override fun dispose() { - disposed = true - tabs.forEach { tab -> tab.disposer?.let { Disposer.dispose(it) } } - } - - private companion object { - private val LOG = logger() - } -} diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/ClaudeToolWindowFactory.kt b/src/main/kotlin/dev/lain/claudejb/view/window/ClaudeToolWindowFactory.kt deleted file mode 100644 index dd5bf1a5..00000000 --- a/src/main/kotlin/dev/lain/claudejb/view/window/ClaudeToolWindowFactory.kt +++ /dev/null @@ -1,201 +0,0 @@ -package dev.lain.claudejb.view.window - -import com.intellij.notification.NotificationAction -import com.intellij.notification.NotificationGroupManager -import com.intellij.notification.NotificationType -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.actionSystem.DefaultActionGroup -import com.intellij.openapi.options.ShowSettingsUtil -import com.intellij.openapi.project.DumbAware -import com.intellij.openapi.project.Project -import com.intellij.openapi.wm.ToolWindow -import com.intellij.openapi.wm.ToolWindowFactory -import com.intellij.openapi.wm.ToolWindowManager -import com.intellij.ui.content.ContentFactory -import dev.lain.claudejb.controller.commands.SessionDiffAction -import dev.lain.claudejb.controller.commands.TabSessionCommands -import dev.lain.claudejb.controller.commands.git.GitContextActions -import dev.lain.claudejb.controller.commands.git.GitIdeMenu -import dev.lain.claudejb.controller.commands.git.GitPromptedActions -import dev.lain.claudejb.controller.session.AttentionLanding -import dev.lain.claudejb.controller.session.AttentionReason -import dev.lain.claudejb.controller.session.ChatSessionManager -import dev.lain.claudejb.controller.session.ClaudeSession -import dev.lain.claudejb.controller.session.SessionListener -import dev.lain.claudejb.model.session.launch.LaunchOptions -import dev.lain.claudejb.model.settings.ClaudeSettings -import dev.lain.claudejb.view.settings.ClaudeSettingsConfigurable -import javax.swing.JComponent - -class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { - - override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { - val settings = ClaudeSettings.getInstance(project) - com.intellij.util.concurrency.AppExecutorUtil.getAppExecutorService().execute { settings.warm() } - val manager = ChatSessionManager.getInstance(project) - val cm = toolWindow.contentManager - - val tabs = ChatTabsPanel() - tabs.onEvents( - selected = { tab -> tab?.session?.let { manager.setActive(it) } }, - closed = { tab -> tab.session?.let { manager.remove(it) } }, - ) - val content = ContentFactory.getInstance().createContent(tabs, "", false) - content.isCloseable = false - content.setPreferredFocusedComponent { tabs.selectedChat?.focusTarget() } - content.setDisposer(tabs) - cm.addContent(content) - - val commands = TabSessionCommands(project, tabs) { session, select -> openChat(project, tabs, session, select) } - tabs.commands = commands - commands.restoreOrCreate() - - toolWindow.setAdditionalGearActions(buildGearGroup(project, tabs, commands)) - } - - private fun openChat(project: Project, tabs: ChatTabsPanel, session: ClaudeSession, select: Boolean = true) { - session.settings.adopt(LaunchOptions.from(ClaudeSettings.getInstance(project))) - session.start() - - val panel = JcefChatPanel(project, session) - val tab = tabs.add(panel, tabTitle(session.title), session.title, panel) - session.addListener(object : SessionListener { - override fun onAttention(reason: AttentionReason, landing: AttentionLanding) = - onSessionAttention(project, tabs, session, reason, landing) - override fun onTitleChanged() { - tabs.tabFor(session)?.let { tabs.relabel(it, tabTitle(session.title), session.title) } - } - }) - if (select) { - panel.host.whenWebReady { - if (tabs.all().lastOrNull() === tab) tabs.select(tab) - } - } - } - - private fun onSessionAttention( - project: Project, - tabs: ChatTabsPanel, - session: ClaudeSession, - reason: AttentionReason, - landing: AttentionLanding, - ) { - val tw = resolveToolWindow(project) - val tab = tabs.tabFor(session) ?: return - val tabOnScreen = tw != null && tw.isVisible && tabs.selected === tab - if (tabOnScreen && showsWhereItLanded(tab, reason, landing)) return - - tabs.badge(tab, true) - - val now = System.currentTimeMillis() - if (now - tab.lastNotified <= NOTIFY_THROTTLE_MS) return - tab.lastNotified = now - - val text = when (reason) { - AttentionReason.PERMISSION -> "Claude needs your approval in \"${session.title}\"." - AttentionReason.TURN_DONE -> "Claude finished responding in \"${session.title}\"." - AttentionReason.ERROR -> "Claude hit an error in \"${session.title}\"." - AttentionReason.GUARD_BLOCKED -> "The guard blocked a tool call in \"${session.title}\"." - } - NotificationGroupManager.getInstance().getNotificationGroup("Claude Code") - .createNotification("Claude Code", text, notificationTypeFor(reason)) - .addAction( - NotificationAction.createSimpleExpiring("Open") { - tabs.tabFor(session)?.let { tabs.select(it) } - resolveToolWindow(project)?.activate(null) - }, - ) - .notify(project) - } - - private fun showsWhereItLanded( - tab: ChatTabsPanel.ChatTab, - reason: AttentionReason, - landing: AttentionLanding, - ): Boolean = - reason != AttentionReason.GUARD_BLOCKED || - (tab.component as? JcefChatPanel)?.transcript?.shows(landing) != false - - private fun notificationTypeFor(reason: AttentionReason): NotificationType = when (reason) { - AttentionReason.ERROR -> NotificationType.ERROR - AttentionReason.GUARD_BLOCKED -> NotificationType.WARNING - else -> NotificationType.INFORMATION - } - - private fun resolveToolWindow(project: Project): ToolWindow? = - ToolWindowManager.getInstance(project).getToolWindow(TOOL_WINDOW_ID) - - private fun activePanel(tabs: ChatTabsPanel): JcefChatPanel? = tabs.selectedChat - - private fun buildGearGroup(project: Project, tabs: ChatTabsPanel, commands: TabSessionCommands) = - DefaultActionGroup().apply { - add(simple("Session Info (Context · Cost · Account · MCP)…") { activePanel(tabs)?.openDashboard() }) - add(simple("Dependency Vulnerabilities…") { activePanel(tabs)?.security?.showVulnView() }) - add(simple("Agents") { activePanel(tabs)?.let { InfoDialogs.showAgents(project, it.session) } }) - add(simple("Security Guard Log (Blocked · Allowed · Whitelisted · Disabled)") { showGuardView(project) }) - add(SessionDiffAction(project, tabs)) - add(simple("Binary Version…") { activePanel(tabs)?.let { InfoDialogs.showBinaryVersion(project, it.session) } }) - add(simple("Effective Settings…") { activePanel(tabs)?.let { InfoDialogs.showEffectiveSettings(project, it.session) } }) - addSeparator() - add(simple("Rename Session…") { commands.renameActiveSession() }) - add(simple("Fork Session") { commands.forkActiveSession() }) - add(simple("Open Previous Session…") { commands.openPreviousSession() }) - add(simple("Add Current File as @-context") { activePanel(tabs)?.mentionCurrentFile() }) - addSeparator() - addAll(GitContextActions.gearEntries(project)) - addAll( - GitPromptedActions.gearEntries(project) { - activePanel(project)?.gitChat?.session() - ?: ChatSessionManager.getInstance(project).gitChatOrCreate() - }, - ) - add(GitIdeMenu.gearEntry()) - addSeparator() - add( - simple("Settings…") { - ShowSettingsUtil.getInstance().showSettingsDialog(project, ClaudeSettingsConfigurable::class.java) - }, - ) - } - - private fun tabTitle(title: String): String = - TabSessionCommands.truncate(title.trim().ifBlank { "Chat" }, TAB_TITLE_MAX) - - private fun simple(text: String, action: () -> Unit): AnAction = object : AnAction(text) { - override fun actionPerformed(e: AnActionEvent) = action() - } - - companion object { - const val TOOL_WINDOW_ID = "Claude Code" - - private fun tabsPanel(project: Project): ChatTabsPanel? { - val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(TOOL_WINDOW_ID) ?: return null - return toolWindow.contentManager.contents.firstNotNullOfOrNull { it.component as? ChatTabsPanel } - } - - fun activePanel(project: Project): JcefChatPanel? = tabsPanel(project)?.selectedChat - - internal fun chatTabs(project: Project): ChatTabsPanel? = tabsPanel(project) - - fun contextComponent(project: Project): JComponent? = tabsPanel(project) - - fun newChat(project: Project) { - tabsPanel(project)?.commands?.newChat() - } - - fun showGuardView(project: Project) { - activePanel(project)?.security?.openGuardView() - } - - fun showGitView(project: Project) { - val panel = activePanel(project) ?: return - panel.pushGit() - panel.host.exec("window.cc.showGitView && window.cc.showGitView()") - } - - const val NOTIFY_THROTTLE_MS = 3000L - - const val TAB_TITLE_MAX = 22 - } -} diff --git a/src/test/kotlin/dev/lain/claudejb/frontend/window/ChatTabsPanelTest.kt b/src/test/kotlin/dev/lain/claudejb/frontend/window/ChatTabsPanelTest.kt new file mode 100644 index 00000000..e61b8cbb --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/frontend/window/ChatTabsPanelTest.kt @@ -0,0 +1,145 @@ +package dev.lain.claudejb.frontend.window + +import com.intellij.openapi.util.Disposer +import dev.lain.claudejb.rpc.ChatEvent +import dev.lain.claudejb.rpc.ChatId +import dev.lain.claudejb.rpc.ChatRef +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import javax.swing.JComponent +import javax.swing.JPanel + +class ChatTabsPanelTest { + + private class FakeCard : ChatCard { + override val component: JComponent = JPanel() + var focused = 0 + var disposed = false + + override fun focus() { + focused++ + } + + override fun focusTarget(): JComponent = component + + val waiting = mutableListOf<() -> Unit>() + + override fun whenReady(block: () -> Unit) { + waiting += block + } + + fun pageReady() = waiting.toList().also { waiting.clear() }.forEach { it() } + + override fun dispose() { + disposed = true + } + } + + private val built = LinkedHashMap() + + private val toldHost = mutableListOf() + + private val tabs = ChatTabsPanel({ toldHost += it.value }) { id -> FakeCard().also { built[id.value] = it } } + + private fun ref(id: String) = ChatRef(ChatId(id), id) + + private fun open() = tabs.chats().map { it.value } + + @AfterEach + fun tearDown() = Disposer.dispose(tabs) + + @Test + fun `the first sync opens every chat and shows the last without taking the focus`() { + tabs.sync(listOf(ref("a"), ref("b"))) + + assertEquals(listOf("a", "b"), open()) + assertEquals("b", tabs.selected?.value) + assertEquals(listOf("b"), toldHost) + assertEquals(0, built.getValue("b").focused) + } + + @Test + fun `closing the last chat shows its replacement even when the host does not ask to select it`() { + tabs.sync(listOf(ref("a"))) + + tabs.event(ChatEvent.Closed(ChatId("a"))) + assertNull(tabs.selected) + tabs.event(ChatEvent.Opened(ref("b"), select = false)) + + assertEquals(listOf("b"), open()) + assertEquals("b", tabs.selected?.value) + assertTrue(built.getValue("b").component.isVisible) + } + + @Test + fun `a chat opened beside the one on screen is shown only once its page can draw`() { + tabs.sync(listOf(ref("a"))) + + tabs.event(ChatEvent.Opened(ref("b"), select = true)) + assertEquals("a", tabs.selected?.value) + + built.getValue("b").pageReady() + assertEquals("b", tabs.selected?.value) + assertEquals(1, built.getValue("b").focused) + } + + @Test + fun `a closed chat is hidden, out of the deck and disposed`() { + tabs.sync(listOf(ref("a"), ref("b"))) + + tabs.event(ChatEvent.Closed(ChatId("a"))) + + val closed = built.getValue("a") + assertTrue(closed.disposed) + assertNull(closed.component.parent) + assertFalse(closed.component.isVisible) + } + + @Test + fun `closing the chat on screen shows a survivor and tells the host`() { + tabs.sync(listOf(ref("a"), ref("b"))) + + tabs.event(ChatEvent.Closed(ChatId("b"))) + + assertEquals("a", tabs.selected?.value) + assertEquals(listOf("b", "a"), toldHost) + assertTrue(built.getValue("a").component.isVisible) + } + + @Test + fun `a resync drops the chats the host no longer has and never builds one twice`() { + tabs.sync(listOf(ref("a"), ref("b"))) + tabs.event(ChatEvent.Opened(ref("b"), select = false)) + + tabs.sync(listOf(ref("b"), ref("c"))) + + assertEquals(listOf("b", "c"), open()) + assertEquals(listOf("a", "b", "c"), built.keys.toList()) + assertTrue(built.getValue("a").disposed) + } + + @Test + fun `the host selecting a chat shows it and focuses its input`() { + tabs.sync(listOf(ref("a"), ref("b"))) + + tabs.event(ChatEvent.Selected(ChatId("a"))) + + assertEquals("a", tabs.selected?.value) + assertEquals(1, built.getValue("a").focused) + } + + @Test + fun `an event for a chat that is not open changes nothing`() { + tabs.sync(listOf(ref("a"))) + + tabs.event(ChatEvent.Selected(ChatId("ghost"))) + tabs.event(ChatEvent.Closed(ChatId("ghost"))) + + assertEquals(listOf("a"), open()) + assertEquals("a", tabs.selected?.value) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/headless/ChatTabsCloseHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/headless/ChatTabsCloseHeadlessTest.kt deleted file mode 100644 index dde9964f..00000000 --- a/src/test/kotlin/dev/lain/claudejb/headless/ChatTabsCloseHeadlessTest.kt +++ /dev/null @@ -1,220 +0,0 @@ -package dev.lain.claudejb.headless - -import com.intellij.testFramework.PlatformTestUtil -import com.intellij.testFramework.fixtures.BasePlatformTestCase -import dev.lain.claudejb.controller.commands.TabSessionCommands -import dev.lain.claudejb.controller.session.ChatSessionManager -import dev.lain.claudejb.view.window.ChatTabsPanel -import javax.swing.JPanel - -class ChatTabsCloseHeadlessTest : BasePlatformTestCase() { - - private lateinit var tabs: ChatTabsPanel - private lateinit var closed: MutableList - - private lateinit var shownWhenClosed: MutableList - - private lateinit var opened: MutableList - - override fun setUp() { - super.setUp() - tabs = ChatTabsPanel() - closed = mutableListOf() - shownWhenClosed = mutableListOf() - opened = mutableListOf() - tabs.onEvents( - selected = {}, - closed = { tab -> - closed += tab.title - shownWhenClosed += tabs.selected?.title - }, - ) - } - - override fun tearDown() { - try { - if (::tabs.isInitialized) tabs.dispose() - val manager = ChatSessionManager.getInstance(project) - manager.all().forEach { runCatching { manager.remove(it) } } - } finally { - super.tearDown() - } - } - - private fun open(title: String) = tabs.add(JPanel(), title, title, null) - - private fun flush() = PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue() - - private fun wireCommands() { - tabs.commands = TabSessionCommands(project, tabs) { _, select -> - val tab = tabs.add(JPanel(), "Replacement", "Replacement", null) - opened += tab.title - if (select) tabs.select(tab) - } - } - - fun `test closing one tab leaves the others alone`() { - val a = open("A") - open("B") - open("C") - tabs.select(a) - - tabs.close(a) - - assertEquals(listOf("B", "C"), tabs.all().map { it.title }) - assertEquals("the close callback fires for the closed tab, once", listOf("A"), closed) - } - - fun `test closing an unselected tab does not change which one is on screen`() { - val a = open("A") - val b = open("B") - tabs.select(a) - - tabs.close(b) - - assertEquals("closing B must leave A", listOf("A"), tabs.all().map { it.title }) - assertEquals("and must not fire a close for anything else", listOf("B"), closed) - } - - fun `test closing the selected tab shows another one rather than nothing`() { - val a = open("A") - val b = open("B") - tabs.select(a) - - tabs.close(a) - - assertEquals(listOf("B"), tabs.all().map { it.title }) - assertNotNull("the area must never be left blank while a tab remains", tabs.selectedChat ?: b) - } - - fun `test closing a tab twice is not two closes`() { - val a = open("A") - open("B") - - tabs.close(a) - tabs.close(a) - - assertEquals(listOf("A"), closed) - assertEquals(listOf("B"), tabs.all().map { it.title }) - } - - fun `test closing the last tab leaves the strip empty rather than half torn down`() { - val a = open("A") - tabs.select(a) - - tabs.close(a) - - assertTrue(tabs.all().isEmpty()) - assertEquals(listOf("A"), closed) - } - - fun `test the last tab is hidden before it leaves the deck, never shown again`() { - val a = open("A") - tabs.select(a) - - tabs.close(a) - - assertFalse( - "closing the only card must not leave the card layout re-showing the torn down chat", - a.component.isVisible, - ) - assertNull("and the card must be out of the deck", a.component.parent) - } - - fun `test closing the selected tab hides it and shows the survivor`() { - val a = open("A") - val b = open("B") - tabs.select(a) - - tabs.close(a) - - assertFalse(a.component.isVisible) - assertTrue(b.component.isVisible) - } - - fun `test the closed tab is out of the model when close returns`() { - val a = open("A") - open("B") - tabs.select(a) - - tabs.close(a) - - assertNull("no event may stand between the press and the tab going", tabs.all().firstOrNull { it === a }) - } - - fun `test the survivor is on screen before the session teardown is asked for`() { - val a = open("A") - open("B") - tabs.select(a) - - tabs.close(a) - - assertEquals(listOf("B"), shownWhenClosed) - } - - fun `test closing a background tab settles the bar before the teardown too`() { - val a = open("A") - val b = open("B") - tabs.select(a) - - tabs.close(b) - - assertEquals(listOf("A"), shownWhenClosed) - } - - fun `test closing the last chat opens exactly one replacement, on a later event`() { - wireCommands() - val a = open("A") - tabs.select(a) - - tabs.close(a) - - assertTrue("the replacement must not be built inside the close", tabs.all().isEmpty()) - assertEquals("and nothing was opened before the queue was drained", emptyList(), opened) - - flush() - - assertEquals(listOf("Replacement"), tabs.all().map { it.title }) - flush() - assertEquals("exactly one, however many events are pumped", listOf("Replacement"), opened) - assertEquals(listOf("Replacement"), tabs.all().map { it.title }) - } - - fun `test a teardown that throws still opens the replacement`() { - wireCommands() - tabs.onEvents(selected = {}, closed = { error("the session refused to die") }) - val a = open("A") - tabs.select(a) - - val outcome = runCatching { tabs.close(a) } - flush() - - assertTrue("the failure is not swallowed", outcome.isFailure) - assertEquals("but the user is not left with an empty tool window", listOf("Replacement"), opened) - } - - fun `test a strip disposed before the replacement runs opens nothing`() { - wireCommands() - val a = open("A") - tabs.select(a) - - tabs.close(a) - tabs.dispose() - flush() - - assertEquals(emptyList(), opened) - } - - fun `test a chat opened while the replacement is queued suppresses it`() { - wireCommands() - val a = open("A") - tabs.select(a) - - tabs.close(a) - open("Restored") - flush() - - assertEquals(listOf("Restored"), tabs.all().map { it.title }) - assertEquals(emptyList(), opened) - } -} From a113ca8c1426602dee12af255de124a4a961fc06 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:05:30 +0200 Subject: [PATCH 048/155] fix(page): rebuild a permission card whose content changed and mark chosen answers --- .../controllers/permissions/permissions.ts | 46 +++++++++- .../jcef/views/permissions/cards/question.ts | 20 +++-- .../permissions/permission-updates.test.js | 87 +++++++++++++++++++ 3 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 src/test/frontend/permissions/permission-updates.test.js diff --git a/src/main/ts/jcef/controllers/permissions/permissions.ts b/src/main/ts/jcef/controllers/permissions/permissions.ts index 6e6545ea..f7098fcb 100644 --- a/src/main/ts/jcef/controllers/permissions/permissions.ts +++ b/src/main/ts/jcef/controllers/permissions/permissions.ts @@ -36,6 +36,34 @@ CC.announce(count + ' requests are waiting for your response.'); } + const signatures = new WeakMap(); + + function signatureOf(card: PermissionCard): string { + return JSON.stringify(card); + } + + const FOCUSABLE = 'button, input, textarea, select, a[href], [tabindex]'; + + function focusIndexIn(node: Element): number { + const active = document.activeElement; + if (!active || !node.contains(active)) return -1; + return Array.prototype.indexOf.call(node.querySelectorAll(FOCUSABLE), active); + } + + function restoreFocus(node: Element, at: number): void { + const all = node.querySelectorAll(FOCUSABLE); + const target = all[Math.min(at, all.length - 1)]; + if (target && typeof target.focus === 'function') target.focus({ preventScroll: true }); + } + + function built(card: PermissionCard, key: string, signature: string): Element | null { + const node = PM.buildCard(card); + if (!node) return null; + node.setAttribute('data-card-id', key); + signatures.set(node, signature); + return node; + } + function permissions(list: unknown, into?: HTMLElement | null): void { const region = into || PM.mount(); if (!region) return; @@ -52,15 +80,23 @@ const wanted: Record = {}; const ordered: Element[] = []; + const refocus: { node: Element; at: number }[] = []; for (let j = 0; j < cards.length; j++) { const card = cards[j]; if (!card || card.id == null) continue; const key = String(card.id); wanted[key] = true; - let node: Element | null = existing[key]; + const signature = signatureOf(card); + const old: Element | undefined = existing[key]; + let node: Element | null = old && signatures.get(old) === signature ? old : null; if (!node) { - node = PM.buildCard(card); - if (node) node.setAttribute('data-card-id', key); + const fresh = built(card, key, signature); + if (fresh && old) { + const at = focusIndexIn(old); + if (at >= 0) refocus.push({ node: fresh, at: at }); + region.replaceChild(fresh, old); + } + node = fresh || old || null; } if (node) ordered.push(node); } @@ -74,6 +110,10 @@ for (let m = 0; m < ordered.length; m++) { if (region.children[m] !== ordered[m]) region.insertBefore(ordered[m], region.children[m] || null); } + + refocus.forEach(function (r) { + restoreFocus(r.node, r.at); + }); } cc.permissions = permissions; diff --git a/src/main/ts/jcef/views/permissions/cards/question.ts b/src/main/ts/jcef/views/permissions/cards/question.ts index e0640a73..8416ee19 100644 --- a/src/main/ts/jcef/views/permissions/cards/question.ts +++ b/src/main/ts/jcef/views/permissions/cards/question.ts @@ -11,7 +11,10 @@ const questions = Array.isArray(card.questions) ? card.questions : []; const selections: Record = {}; - const qBlocks = questions.map(function (q) { + const idBase = 'q-' + String(id).replace(/[^\w-]/g, '_') + '-'; + + const qBlocks = questions.map(function (q, index) { + const questionId = idBase + index; const qText = q && q.question != null ? String(q.question) : ''; const multi = !!(q && q.multiSelect); const options = q && Array.isArray(q.options) ? q.options : []; @@ -27,7 +30,7 @@ const props: HProps = { class: 'q-option', - attrs: { type: 'button' }, + attrs: { type: 'button', 'aria-pressed': 'false' }, on: { click: function () { const arr = selections[qText]; @@ -54,8 +57,12 @@ 'div', { class: 'q-block' }, q && q.header ? h('div', { class: 'q-header', text: String(q.header) }) : null, - h('div', { class: 'q-question', text: qText }), - h('div', { class: 'q-options' }, optionEls) + h('div', { class: 'q-question', text: qText, attrs: { id: questionId } }), + h( + 'div', + { class: 'q-options', attrs: { role: 'group', 'aria-labelledby': questionId } }, + optionEls + ) ); }); @@ -85,8 +92,9 @@ for (let i = 0; i < opts.length; i++) { const el = opts[i]; const arr = (el.__qText != null && selections[el.__qText]) || []; - if (el.__label != null && arr.indexOf(el.__label) >= 0) el.classList.add('selected'); - else el.classList.remove('selected'); + const selected = el.__label != null && arr.indexOf(el.__label) >= 0; + el.classList.toggle('selected', selected); + el.setAttribute('aria-pressed', selected ? 'true' : 'false'); } } syncSelected(); diff --git a/src/test/frontend/permissions/permission-updates.test.js b/src/test/frontend/permissions/permission-updates.test.js new file mode 100644 index 00000000..3112db20 --- /dev/null +++ b/src/test/frontend/permissions/permission-updates.test.js @@ -0,0 +1,87 @@ +const { loadFrontend } = require('../helpers/load'); + +const bash = (over = {}) => ({ + id: 'r1', + tool: 'Bash', + title: 'Bash', + summary: 'cat notes.txt', + headline: 'Bash', + ...over, +}); + +describe('a permission card updated in place', () => { + it('is rebuilt when the host adds a guard alert to it', () => { + const win = loadFrontend(['app-permissions.js']); + const region = win.CC.els.permissions; + win.cc.permissions([bash()]); + expect(region.querySelector('.perm-guard')).toBeNull(); + + win.cc.permissions([bash({ guard: { rule: 'CREDENTIALS', label: 'Block credential files' } })]); + + expect(region.querySelectorAll('[data-card-id="r1"]').length).toBe(1); + expect(region.querySelector('.perm-guard .perm-guard-rule').textContent).toContain('Block credential files'); + }); + + it('shows a blocked path and a decision reason that arrive later', () => { + const win = loadFrontend(['app-permissions.js']); + const region = win.CC.els.permissions; + win.cc.permissions([bash()]); + win.cc.permissions([bash({ blockedPath: '/etc/shadow', decisionReason: 'outside the project' })]); + + expect(region.querySelector('.perm-blocked').textContent).toContain('/etc/shadow'); + expect(region.querySelector('.perm-reason').textContent).toContain('outside the project'); + }); + + it('keeps the focus on the same control across the rebuild', () => { + const win = loadFrontend(['app-permissions.js']); + const region = win.CC.els.permissions; + win.cc.permissions([bash()]); + const reject = [...region.querySelectorAll('button')].find((b) => b.textContent === 'Reject'); + reject.focus(); + + win.cc.permissions([bash({ decisionReason: 'asked by a rule' })]); + + expect(win.document.activeElement.textContent).toBe('Reject'); + expect(region.contains(win.document.activeElement)).toBe(true); + }); + + it('keeps the very same node when nothing changed', () => { + const win = loadFrontend(['app-permissions.js']); + const region = win.CC.els.permissions; + win.cc.permissions([bash()]); + const node = region.querySelector('[data-card-id="r1"]'); + win.cc.permissions([bash()]); + expect(region.querySelector('[data-card-id="r1"]')).toBe(node); + }); +}); + +describe('question options say whether they are chosen', () => { + const question = (multiSelect) => ({ + id: 'q1', + title: 'Question', + questions: [{ question: 'Which?', multiSelect, options: [{ label: 'A' }, { label: 'B' }] }], + }); + + it('marks the picked option pressed and the others not, inside a group named by the question', () => { + const win = loadFrontend(['app-permissions.js']); + win.cc.permissions([question(false)]); + const options = [...win.CC.els.permissions.querySelectorAll('.q-option')]; + expect(options.map((o) => o.getAttribute('aria-pressed'))).toEqual(['false', 'false']); + + options[1].click(); + expect(options.map((o) => o.getAttribute('aria-pressed'))).toEqual(['false', 'true']); + + const group = options[0].parentElement; + expect(group.getAttribute('role')).toBe('group'); + expect(win.document.getElementById(group.getAttribute('aria-labelledby')).textContent).toBe('Which?'); + }); + + it('lets a multi-select question press several options', () => { + const win = loadFrontend(['app-permissions.js']); + win.cc.permissions([question(true)]); + const options = [...win.CC.els.permissions.querySelectorAll('.q-option')]; + options[0].click(); + options[1].click(); + expect(options.map((o) => o.getAttribute('aria-pressed'))).toEqual(['true', 'true']); + }); +}); From c162e4fc559968ba7a15c465503c8a636c0cf30b Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:05:31 +0200 Subject: [PATCH 049/155] fix(page): announce each guard alert once and close a replaced filter menu --- src/main/ts/jcef/views/guard/filters.ts | 34 ++++++ src/main/ts/jcef/views/guard/guard.ts | 32 ++++-- .../frontend/guard/guard-announce.test.js | 108 ++++++++++++++++++ 3 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 src/test/frontend/guard/guard-announce.test.js diff --git a/src/main/ts/jcef/views/guard/filters.ts b/src/main/ts/jcef/views/guard/filters.ts index bdab8cd5..09ff74d9 100644 --- a/src/main/ts/jcef/views/guard/filters.ts +++ b/src/main/ts/jcef/views/guard/filters.ts @@ -11,6 +11,38 @@ const DASHBOARD_ID = 'cc-dashboard'; + let menus: { label: string; menu: PickMenu; anchor: HTMLElement }[] = []; + + function isOpen(menu: PickMenu): boolean { + return !menu.menu.hasAttribute('hidden'); + } + + function focusedOption(menu: PickMenu): number { + return Array.prototype.indexOf.call(menu.menu.querySelectorAll('button'), document.activeElement); + } + + function reopen(menu: PickMenu, at: number): void { + menu.toggle(); + const option = menu.menu.querySelectorAll('button')[at]; + if (option) option.focus({ preventScroll: true }); + } + + function sweepMenus(): void { + const live = menus.filter(function (m) { + return m.anchor.isConnected; + }); + menus.forEach(function (dead) { + if (dead.anchor.isConnected || !isOpen(dead.menu)) return; + const at = Math.max(0, focusedOption(dead.menu)); + dead.menu.close(); + const heir = live.filter(function (m) { + return m.label === dead.label; + })[0]; + if (heir && !isOpen(heir.menu)) reopen(heir.menu, at); + }); + menus = live; + } + function toggled(picked: string[] | null, value: string): string[] | null { if (value === ALL) return null; const next = (picked || []).slice(); @@ -67,6 +99,7 @@ }, }); menu.sync(); + menus.push({ label: label, menu: menu, anchor: trigger }); trigger.addEventListener('click', function (ev: Event) { ev.preventDefault(); @@ -135,6 +168,7 @@ } GL.buildFiltersCard = function (): HTMLElement | null { + window.setTimeout(sweepMenus, 0); if (!GL.catalog().length) return null; return card('Filter', filterStrip(), true, 'guard-filters'); }; diff --git a/src/main/ts/jcef/views/guard/guard.ts b/src/main/ts/jcef/views/guard/guard.ts index e6c78106..06392d21 100644 --- a/src/main/ts/jcef/views/guard/guard.ts +++ b/src/main/ts/jcef/views/guard/guard.ts @@ -13,6 +13,14 @@ let open = false; + let announced: Record = {}; + + function alarm(key: string, text: string, shown: Record): HTMLElement { + const fresh = !announced[key]; + shown[key] = true; + return h('div', { class: 'guard-alarm', attrs: fresh ? { role: 'alert' } : {}, text: text }); + } + function requestLog(): void { send({ type: 'guardLog' }); } @@ -21,17 +29,17 @@ const p = GL.payload || {}; const w = (p && p.window) || {}; const rows: HTMLElement[] = []; + const shown: Record = {}; if (p.recording === false) { rows.push( - h('div', { - class: 'guard-alarm', - attrs: { role: 'alert' }, - text: - 'Alerts are NOT being written down. The guard is still deciding, but nothing it decides ' + + alarm( + 'not-recording', + 'Alerts are NOT being written down. The guard is still deciding, but nothing it decides ' + 'is reaching the log, and nothing said so until now. What you see below is whatever was ' + 'stored before that started — it is not this session.', - }) + shown + ) ); } @@ -49,15 +57,15 @@ if (GL.num(w.dropped) > 0) { rows.push( - h('div', { - class: 'guard-alarm', - attrs: { role: 'alert' }, - text: - GL.num(w.dropped) + + alarm( + 'dropped:' + GL.num(w.dropped), + GL.num(w.dropped) + ' alert(s) from this chat were dropped on the way to the log and cannot be recovered.', - }) + shown + ) ); } + announced = shown; if (GL.num(w.missing) > 0) { rows.push( diff --git a/src/test/frontend/guard/guard-announce.test.js b/src/test/frontend/guard/guard-announce.test.js new file mode 100644 index 00000000..ed33335e --- /dev/null +++ b/src/test/frontend/guard/guard-announce.test.js @@ -0,0 +1,108 @@ +const { loadFrontend } = require('../helpers/load'); + +const PAYLOAD = (over = {}) => ({ + recording: true, + window: { kept: 1, max: 500, recorded: 1, dropped: 0, missing: 0 }, + tabs: [{ id: 'blocked', label: 'Blocked', count: 1 }], + catalog: [ + { + id: 'secrets', + label: 'Sensitive data', + rules: [ + { id: 'CREDENTIALS', label: 'Block credential files' }, + { id: 'ENV_FILES', label: 'Block env files' }, + ], + }, + { id: 'git', label: 'Git', rules: [{ id: 'DESTRUCTIVE_GIT', label: 'Block destructive Git' }] }, + ], + entries: [ + { + id: 'e1', + tab: 'blocked', + verdict: 'DENIED', + rule: 'CREDENTIALS', + ruleLabel: 'Block credential files', + categoryId: 'secrets', + }, + ], + ...over, +}); + +function open() { + const win = loadFrontend(['app-session.js', 'app-composer.js'], { vendor: false }); + win.__ccSend = () => {}; + const btn = win.document.querySelector('.dash-toggle[data-view="guard"]'); + btn.dispatchEvent(new win.MouseEvent('click', { bubbles: true })); + return win; +} + +const roles = (win) => + Array.from(win.document.querySelectorAll('.dashboard .guard-alarm')).map((e) => e.getAttribute('role')); + +describe('guard alarms are announced once', () => { + it('announces a new alarm, and not again when the same alarm is drawn again', () => { + const win = open(); + win.cc.guard(PAYLOAD({ recording: false })); + expect(roles(win)).toEqual(['alert']); + + win.cc.guard(PAYLOAD({ recording: false })); + expect(roles(win)).toEqual([null]); + }); + + it('announces more drops as a new alarm', () => { + const win = open(); + win.cc.guard(PAYLOAD({ window: { kept: 1, max: 500, recorded: 3, dropped: 1, missing: 0 } })); + expect(roles(win)).toEqual(['alert']); + win.cc.guard(PAYLOAD({ window: { kept: 1, max: 500, recorded: 3, dropped: 1, missing: 0 } })); + expect(roles(win)).toEqual([null]); + win.cc.guard(PAYLOAD({ window: { kept: 1, max: 500, recorded: 5, dropped: 2, missing: 0 } })); + expect(roles(win)).toEqual(['alert']); + }); + + it('announces an alarm again once it cleared and came back', () => { + const win = open(); + win.cc.guard(PAYLOAD({ recording: false })); + win.cc.guard(PAYLOAD()); + expect(roles(win)).toEqual([]); + win.cc.guard(PAYLOAD({ recording: false })); + expect(roles(win)).toEqual(['alert']); + }); +}); + +describe('a guard filter menu never outlives its card', () => { + const openMenus = (win) => + Array.from(win.document.querySelectorAll('.guard-filter-menu')).filter((m) => !m.hasAttribute('hidden')); + + it('closes the detached menu when a pick repaints the card, and reopens the fresh one', async () => { + const win = open(); + win.cc.guard(PAYLOAD()); + const trigger = win.document.querySelector('.guard-filter-trigger'); + trigger.dispatchEvent(new win.MouseEvent('click', { bubbles: true })); + const first = openMenus(win); + expect(first.length).toBe(1); + + first[0].querySelectorAll('button')[1].dispatchEvent(new win.MouseEvent('click', { bubbles: true })); + await new Promise((r) => setTimeout(r, 0)); + + const now = openMenus(win); + expect(now.length).toBe(1); + expect(now[0]).not.toBe(first[0]); + expect(first[0].hasAttribute('hidden')).toBe(true); + const anchor = win.document.querySelector('.guard-filter-trigger[aria-expanded="true"]'); + expect(anchor).not.toBeNull(); + expect(anchor.isConnected).toBe(true); + }); + + it('closes an open menu when the host repaints the view', async () => { + const win = open(); + win.cc.guard(PAYLOAD()); + win.document.querySelector('.guard-filter-trigger').dispatchEvent(new win.MouseEvent('click', { bubbles: true })); + const first = openMenus(win)[0]; + + win.cc.guard(PAYLOAD()); + await new Promise((r) => setTimeout(r, 0)); + + expect(first.hasAttribute('hidden')).toBe(true); + expect(openMenus(win).every((m) => m.isConnected)).toBe(true); + }); +}); From 5f9dea8203113076622a4c2f02ad2cd7f3837fc5 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:05:32 +0200 Subject: [PATCH 050/155] fix(page): expose the composer toggles' state with aria-pressed --- src/main/ts/jcef/views/composer/toggles.ts | 23 ++++++-- .../frontend/composer/toggle-pressed.test.js | 58 +++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 src/test/frontend/composer/toggle-pressed.test.js diff --git a/src/main/ts/jcef/views/composer/toggles.ts b/src/main/ts/jcef/views/composer/toggles.ts index becd2e9d..73d0f6e1 100644 --- a/src/main/ts/jcef/views/composer/toggles.ts +++ b/src/main/ts/jcef/views/composer/toggles.ts @@ -39,10 +39,14 @@ applyGodMode(); }; + function pressed(btn: HTMLElement, on: boolean): void { + btn.setAttribute('aria-pressed', on ? 'true' : 'false'); + } + function applyFollow(): void { if (followBtnRef) { - if (followOn) followBtnRef.classList.add('active'); - else followBtnRef.classList.remove('active'); + followBtnRef.classList.toggle('active', followOn); + pressed(followBtnRef, followOn); } if (CC && typeof CC.emit === 'function') CC.emit('follow', followOn); } @@ -72,8 +76,8 @@ function applyGuard(): void { if (!guardBtnRef) return; - if (guardOn) guardBtnRef.classList.add('active'); - else guardBtnRef.classList.remove('active'); + guardBtnRef.classList.toggle('active', guardOn); + pressed(guardBtnRef, guardOn); guardBtnRef.title = guardOn ? 'Sensitive Guard is on — click to switch it off' : 'Sensitive Guard is OFF — click to switch it back on'; @@ -89,6 +93,7 @@ function applyRemoteControl(): void { if (!rcBtnRef) return; rcBtnRef.classList.toggle('active', rcOn); + pressed(rcBtnRef, rcOn); rcBtnRef.classList.toggle('failed', !rcOn && !!rcError); if (!rcOn && rcError) rcBtnRef.title = rcError; else if (rcOn) rcBtnRef.title = 'Remote Control is on — click to disconnect this chat from claude.ai'; @@ -104,6 +109,14 @@ applyRemoteControl(); }; + function watchVibe(btn: HTMLElement): void { + const sync = function (): void { + pressed(btn, !!(CC.isVibe && CC.isVibe())); + }; + sync(); + new MutationObserver(sync).observe(document.body, { attributes: true, attributeFilter: ['class'] }); + } + function iconButton(cls: string, label: string, glyph: string, onClick: (e: Event) => void): HTMLElement { const btn = h('button', { class: cls, @@ -141,6 +154,7 @@ }, vibeIcon ); + watchVibe(vibeBtn); const followBtn = iconButton('bar-icon active', 'Auto-follow scrolling', followGlyph(), function () { followOn = !followOn; @@ -148,6 +162,7 @@ }); followBtn.title = 'Auto-scroll (follow output)'; followBtnRef = followBtn; + pressed(followBtn, followOn); const guardBtn = h('button', { class: 'bar-icon active', diff --git a/src/test/frontend/composer/toggle-pressed.test.js b/src/test/frontend/composer/toggle-pressed.test.js new file mode 100644 index 00000000..95eff896 --- /dev/null +++ b/src/test/frontend/composer/toggle-pressed.test.js @@ -0,0 +1,58 @@ +const { loadFrontend } = require('../helpers/load'); + +function state(extra = {}) { + return { + turnActive: false, + interrupting: false, + running: true, + guardOn: true, + provider: { id: 'anthropic', label: 'Anthropic', options: [{ id: 'anthropic', label: 'Anthropic' }] }, + model: { label: 'Opus 5', options: [{ value: 'opus', label: 'Opus 5', selected: true }] }, + mode: { wire: 'default', label: 'Default', options: [{ wire: 'default', label: 'Default' }] }, + effort: { label: 'High', options: [{ value: 'high', label: 'High', selected: true }] }, + thinking: { on: true, label: 'Thinking on', options: [{ on: false, label: 'Off' }] }, + queue: [], + ...extra, + }; +} + +function mount(extra) { + const win = loadFrontend(['app-composer.js']); + win.CC.send = () => {}; + win.CC.composer.send = () => {}; + win.cc.state(state(extra)); + const byLabel = (label) => win.document.querySelector('button[aria-label="' + label + '"]'); + return { win, byLabel }; +} + +describe('the composer toggles say their state to assistive technology', () => { + it('the shield is pressed while the guard is on and not while it is off', () => { + const { win, byLabel } = mount(); + expect(byLabel('Sensitive Guard').getAttribute('aria-pressed')).toBe('true'); + win.cc.state(state({ guardOn: false })); + expect(byLabel('Sensitive Guard').getAttribute('aria-pressed')).toBe('false'); + }); + + it('auto-follow flips its pressed state with each click', () => { + const { win, byLabel } = mount(); + const follow = byLabel('Auto-follow scrolling'); + expect(follow.getAttribute('aria-pressed')).toBe('true'); + follow.dispatchEvent(new win.MouseEvent('click', { bubbles: true })); + expect(follow.getAttribute('aria-pressed')).toBe('false'); + }); + + it('remote control starts not pressed', () => { + const { byLabel } = mount(); + expect(byLabel('Remote Control').getAttribute('aria-pressed')).toBe('false'); + }); + + it('vibe mode follows the theme the host applies', async () => { + const { win, byLabel } = mount(); + const vibe = byLabel('Vibe Mode'); + expect(vibe.getAttribute('aria-pressed')).toBe('false'); + win.document.body.classList.add('vibe'); + win.CC.isVibe = () => true; + await new Promise((r) => setTimeout(r, 0)); + expect(vibe.getAttribute('aria-pressed')).toBe('true'); + }); +}); From 4433bd681848cc298d5cacdd5e0295f421642fb3 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:05:33 +0200 Subject: [PATCH 051/155] fix(page): forward only links the host opens and intercept middle clicks --- src/main/ts/jcef/controllers/boot/report.ts | 45 ++++++----- .../frontend/boot/link-forwarding.test.js | 74 +++++++++++++++++++ 2 files changed, 100 insertions(+), 19 deletions(-) create mode 100644 src/test/frontend/boot/link-forwarding.test.js diff --git a/src/main/ts/jcef/controllers/boot/report.ts b/src/main/ts/jcef/controllers/boot/report.ts index c8624dd2..ca1713dd 100644 --- a/src/main/ts/jcef/controllers/boot/report.ts +++ b/src/main/ts/jcef/controllers/boot/report.ts @@ -7,26 +7,33 @@ return node ? '' + ((node as HTMLElement).className || '') : ''; } - document.addEventListener( - 'click', - function (ev: MouseEvent) { - let node = ev.target as Node | null; - while (node && node !== document) { - const el = node as HTMLElement; - if (el.tagName === 'A' && el.hasAttribute('href')) { - const url = el.getAttribute('href'); - if (url && url !== '#') { - ev.preventDefault(); - ev.stopPropagation(); - CC.send({ type: 'open', url: url }); - } - return; - } - node = node.parentNode; + const SCHEME = /^[A-Za-z][A-Za-z0-9+.-]+:/; + + function forwardable(url: string): boolean { + const u = url.trim(); + if (/^https?:\/\//i.test(u) || /^jb:\/\//i.test(u)) return true; + if (!u || SCHEME.test(u)) return false; + return u.charAt(0) !== '#' && !/^[\\/]{2}/.test(u); + } + + function onLink(ev: MouseEvent): void { + if (ev.type === 'auxclick' && ev.button !== 1) return; + let node = ev.target as Node | null; + while (node && node !== document) { + const el = node as HTMLElement; + if (el.tagName === 'A' && el.hasAttribute('href')) { + const url = el.getAttribute('href') || ''; + if (url.trim().charAt(0) === '#' && ev.type === 'click') return; + ev.preventDefault(); + ev.stopPropagation(); + if (forwardable(url)) CC.send({ type: 'open', url: url }); + return; } - }, - true - ); + node = node.parentNode; + } + } + document.addEventListener('click', onLink, true); + document.addEventListener('auxclick', onLink, true); function copyTargetText(copyEl: HTMLElement): string { let pre: Element | null = copyEl.closest ? copyEl.closest('pre') : null; diff --git a/src/test/frontend/boot/link-forwarding.test.js b/src/test/frontend/boot/link-forwarding.test.js new file mode 100644 index 00000000..00c34f65 --- /dev/null +++ b/src/test/frontend/boot/link-forwarding.test.js @@ -0,0 +1,74 @@ +const { loadFrontend } = require('../helpers/load'); + +describe('links clicked in the page', () => { + let win; + let sent = []; + + beforeAll(() => { + win = loadFrontend([]); + win.CC.send = (m) => sent.push(m); + }); + + beforeEach(() => { + sent = []; + }); + + const link = (href) => { + const a = win.document.createElement('a'); + a.setAttribute('href', href); + a.textContent = 'x'; + win.document.body.appendChild(a); + return a; + }; + + const click = (a, type = 'click', button = 0) => { + const ev = new win.MouseEvent(type, { bubbles: true, cancelable: true, button }); + a.dispatchEvent(ev); + return ev; + }; + + it.each([ + 'https://example.com/a', + 'http://example.com/a', + 'jb://commit?hash=abc', + 'src/main/App.kt', + 'src/main/App.kt#L3', + ])('forwards %s to the host', (href) => { + click(link(href)); + expect(sent).toEqual([{ type: 'open', url: href }]); + }); + + it.each([ + 'data:image/png;base64,AAAA', + 'data:text/html,', + 'javascript:alert(1)', + 'file:///etc/passwd', + 'jb:open', + 'mailto:a@b.c', + '//evil.example/x', + '\\\\server\\share', + ])('drops %s and keeps the page where it is', (href) => { + const ev = click(link(href)); + expect(sent).toEqual([]); + expect(ev.defaultPrevented).toBe(true); + }); + + it('intercepts a middle click the same way', () => { + const ev = click(link('data:image/png;base64,AAAA'), 'auxclick', 1); + expect(ev.defaultPrevented).toBe(true); + click(link('https://example.com/b'), 'auxclick', 1); + expect(sent).toEqual([{ type: 'open', url: 'https://example.com/b' }]); + }); + + it('leaves a right click to the context menu', () => { + const ev = click(link('https://example.com/c'), 'auxclick', 2); + expect(ev.defaultPrevented).toBe(false); + expect(sent).toEqual([]); + }); + + it('lets an in-page anchor scroll the page without bothering the host', () => { + const ev = click(link('#section')); + expect(ev.defaultPrevented).toBe(false); + expect(sent).toEqual([]); + }); +}); From 931973959c780ba18bb14beb6dae04a751e5861b Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:05:47 +0200 Subject: [PATCH 052/155] perf(session): restore a chat from the end of its transcript and read it leniently --- .../session/history/AgentScanner.kt | 2 +- .../session/agents/BackgroundTaskReplay.kt | 4 +- .../model/session/history/ReverseLines.kt | 49 +++++++++++++++++ .../model/session/history/SessionStore.kt | 19 ++++++- .../history/SessionTranscriptReader.kt | 51 +++++++++++++---- .../model/session/history/ReverseLinesTest.kt | 49 +++++++++++++++++ .../history/SessionTranscriptTailTest.kt | 55 +++++++++++++++++++ 7 files changed, 213 insertions(+), 16 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/model/session/history/ReverseLines.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/model/session/history/ReverseLinesTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptTailTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/history/AgentScanner.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/history/AgentScanner.kt index dd59f4ab..35a7ecbe 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/history/AgentScanner.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/history/AgentScanner.kt @@ -106,7 +106,7 @@ class AgentScanner( private fun replayTasks(id: String, onReplayed: () -> Unit) { runCatching { - val replayed = SessionStore.readLines(id)?.let { BackgroundTaskReplay.parse(it) }.orEmpty() + val replayed = SessionStore.useLines(id) { BackgroundTaskReplay.parse(it) }.orEmpty() if (tasks.seed(replayed)) ui.edt(onReplayed) }.onFailure { log.warn("could not replay background tasks for $id", it) } } diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/agents/BackgroundTaskReplay.kt b/src/main/kotlin/dev/lain/claudejb/model/session/agents/BackgroundTaskReplay.kt index 338d5063..d0c730f8 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/session/agents/BackgroundTaskReplay.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/session/agents/BackgroundTaskReplay.kt @@ -25,7 +25,9 @@ object BackgroundTaskReplay { isLenient = true } - fun parse(lines: List): List { + fun parse(lines: List): List = parse(lines.asSequence()) + + fun parse(lines: Sequence): List { val commands = HashMap() val tasks = LinkedHashMap() diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/history/ReverseLines.kt b/src/main/kotlin/dev/lain/claudejb/model/session/history/ReverseLines.kt new file mode 100644 index 00000000..eb198e4d --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/model/session/history/ReverseLines.kt @@ -0,0 +1,49 @@ +package dev.lain.claudejb.model.session.history + +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import java.nio.file.Path +import java.nio.file.StandardOpenOption + +object ReverseLines { + + private const val BLOCK = 64 * 1024 + + fun read(path: Path, block: (Sequence) -> T): T = + FileChannel.open(path, StandardOpenOption.READ).use { channel -> block(lines(channel)) } + + private fun lines(channel: FileChannel): Sequence = sequence { + val buffer = ByteBuffer.allocate(BLOCK) + val partial = ArrayDeque() + var position = channel.size() + while (position > 0) { + val length = minOf(BLOCK.toLong(), position).toInt() + position -= length + buffer.clear().limit(length) + while (buffer.hasRemaining()) { + if (channel.read(buffer, position + buffer.position()) < 0) break + } + val bytes = buffer.array() + var end = buffer.position() + for (i in end - 1 downTo 0) { + if (bytes[i] != NEWLINE) continue + yield(join(bytes, i + 1, end, partial)) + partial.clear() + end = i + } + if (end > 0) partial.addFirst(bytes.copyOfRange(0, end)) + } + if (partial.isNotEmpty()) yield(join(ByteArray(0), 0, 0, partial)) + } + + private fun join(bytes: ByteArray, from: Int, to: Int, rest: ArrayDeque): String { + if (rest.isEmpty()) return String(bytes, from, to - from, Charsets.UTF_8) + val out = ByteArrayOutputStream(to - from + rest.sumOf { it.size }) + out.write(bytes, from, to - from) + rest.forEach { out.write(it) } + return out.toString(Charsets.UTF_8) + } + + private const val NEWLINE = '\n'.code.toByte() +} diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionStore.kt b/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionStore.kt index 40d4ba9b..9710dbe6 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionStore.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionStore.kt @@ -1,5 +1,8 @@ package dev.lain.claudejb.model.session.history +import java.io.BufferedReader +import java.io.InputStreamReader +import java.nio.charset.CodingErrorAction import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths @@ -32,8 +35,20 @@ internal object SessionStore { fun exists(sessionId: String): Boolean = locate(sessionId) != null - fun readLines(sessionId: String): List? = - locate(sessionId)?.let { runCatching { Files.readAllLines(it) }.getOrNull() } + fun readLines(sessionId: String): List? = useLines(sessionId) { it.toList() } + + fun useLines(sessionId: String, block: (Sequence) -> T): T? = + locate(sessionId)?.let { path -> runCatching { lenientReader(path).useLines(block) }.getOrNull() } + + fun useLinesFromEnd(sessionId: String, block: (Sequence) -> T): T? = + locate(sessionId)?.let { path -> runCatching { ReverseLines.read(path, block) }.getOrNull() } + + fun lenientReader(path: Path): BufferedReader { + val decoder = Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE) + return BufferedReader(InputStreamReader(Files.newInputStream(path), decoder)) + } fun sessionDir(sessionId: String): Path? { val transcript = locate(sessionId) ?: return null diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReader.kt b/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReader.kt index 1a242088..6df23394 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReader.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReader.kt @@ -27,11 +27,30 @@ object SessionTranscriptReader { private const val TOON = "toon" - fun readEntries(sessionId: String, maxEntries: Int? = null, projectRoot: String? = null): List = - SessionStore.readLines(sessionId)?.let { parseEntries(it, maxEntries, projectRoot) } ?: emptyList() + fun readEntries(sessionId: String, maxEntries: Int? = null, projectRoot: String? = null): List { + val cap = maxEntries?.takeIf { it > 0 } + ?: return SessionStore.readLines(sessionId)?.let { parseEntries(it, null, projectRoot) }.orEmpty() + return SessionStore.useLinesFromEnd(sessionId) { tailEntries(it, cap, projectRoot) }.orEmpty() + } + + fun parseEntries(lines: List, maxEntries: Int? = null, projectRoot: String? = null): List { + val cap = maxEntries?.takeIf { it > 0 } ?: return entriesOf(parseRecords(lines), null, projectRoot) + return tailEntries(lines.asReversed().asSequence(), cap, projectRoot) + } - fun parseEntries(lines: List, maxEntries: Int? = null, projectRoot: String? = null): List = - entriesOf(parseRecords(lines), maxEntries, projectRoot) + private fun tailEntries(newestFirst: Sequence, maxEntries: Int, projectRoot: String?): List { + val chunks = ArrayList>() + var count = 0 + for (line in newestFirst) { + if (count >= maxEntries) break + val record = parseRecord(line) ?: continue + val entries = entriesOf(record, projectRoot) + if (entries.isEmpty()) continue + chunks += entries + count += entries.size + } + return settle(chunks.asReversed().flatten(), maxEntries) + } fun parseRecord(line: String): JsonObject? = if (line.isBlank()) null else runCatching { JSON.parseToJsonElement(line).jsonObject }.getOrNull() @@ -44,18 +63,26 @@ object SessionTranscriptReader { projectRoot: String? = null, ): List { val out = ArrayList() - for (obj in records) { - runCatching { - when (obj["type"]?.jsonPrimitive?.contentOrNull) { - "user" -> parseUser(obj, out) - "assistant" -> parseAssistant(obj, out, projectRoot) - else -> Unit - } + for (obj in records) collect(obj, out, projectRoot) + return settle(out, maxEntries) + } + + private fun entriesOf(record: JsonObject, projectRoot: String?): List = + ArrayList().also { collect(record, it, projectRoot) } + + private fun collect(obj: JsonObject, out: MutableList, projectRoot: String?) { + runCatching { + when (obj["type"]?.jsonPrimitive?.contentOrNull) { + "user" -> parseUser(obj, out) + "assistant" -> parseAssistant(obj, out, projectRoot) + else -> Unit } } - return capTail(markInFlight(decodeOwnOutputs(tagCommandOutputs(out))), maxEntries) } + private fun settle(entries: List, maxEntries: Int?): List = + decodeOwnOutputs(capTail(markInFlight(tagCommandOutputs(entries)), maxEntries)) + private fun decodeOwnOutputs(entries: List): List { val ownCalls = entries.asSequence() .filter { it.speaker == "TOOL" && OwnTools.isOwn(it.meta) } diff --git a/src/test/kotlin/dev/lain/claudejb/model/session/history/ReverseLinesTest.kt b/src/test/kotlin/dev/lain/claudejb/model/session/history/ReverseLinesTest.kt new file mode 100644 index 00000000..303805ef --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/model/session/history/ReverseLinesTest.kt @@ -0,0 +1,49 @@ +package dev.lain.claudejb.model.session.history + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path + +class ReverseLinesTest { + + @TempDir + lateinit var dir: Path + + private fun newestFirst(bytes: ByteArray): List { + val file = dir.resolve("t.jsonl") + Files.write(file, bytes) + return ReverseLines.read(file) { it.toList() } + } + + @Test + fun `lines come back newest first, with the trailing newline as an empty line`() { + assertEquals(listOf("", "c", "b", "a"), newestFirst("a\nb\nc\n".toByteArray())) + assertEquals(listOf("c", "b", "a"), newestFirst("a\nb\nc".toByteArray())) + } + + @Test + fun `lines longer than a block are joined across blocks`() { + val long = "x".repeat(200_000) + "é" + "y".repeat(70_000) + val short = "z".repeat(10) + + assertEquals(listOf(short, long, short), newestFirst("$short\n$long\n$short".toByteArray())) + } + + @Test + fun `invalid UTF-8 is replaced instead of losing the line`() { + val bytes = "ok\n".toByteArray() + byteArrayOf(0xC3.toByte(), 0x28) + "tail".toByteArray() + + val lines = newestFirst(bytes) + + assertEquals(2, lines.size) + assertEquals("�(tail", lines[0]) + assertEquals("ok", lines[1]) + } + + @Test + fun `an empty file has no lines`() { + assertEquals(emptyList(), newestFirst(ByteArray(0))) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptTailTest.kt b/src/test/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptTailTest.kt new file mode 100644 index 00000000..a3d9b7c3 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptTailTest.kt @@ -0,0 +1,55 @@ +package dev.lain.claudejb.model.session.history + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SessionTranscriptTailTest { + + private fun user(text: String) = """{"type":"user","message":{"role":"user","content":"$text"}}""" + + private fun call(id: String, command: String) = + """{"type":"assistant","message":{"content":[{"type":"tool_use","id":"$id","name":"Bash","input":{"command":"$command"}}]}}""" + + private fun result(id: String, text: String, error: Boolean = false) = + """{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"$id","is_error":$error,"content":"$text"}]}}""" + + private fun ownCall(id: String) = + """{"type":"assistant","message":{"content":[{"type":"tool_use","id":"$id","name":"mcp__vcs__run","input":{"tool":"git_status","args":{}}}]}}""" + + private fun conversation(): List = buildList { + repeat(60) { i -> + add(user("prompt $i")) + add(call("t$i", "echo $i")) + add(result("t$i", "out $i", error = i % 7 == 0)) + add(ownCall("o$i")) + add(result("o$i", "branch: main")) + } + add(call("pending", "sleep 1")) + } + + @Test + fun `reading from the end yields exactly the capped window of a full read`() { + val lines = conversation() + for (cap in listOf(1, 2, 3, 7, 50, 199, 200, 1000)) { + val full = SessionTranscriptReader.entriesOf(SessionTranscriptReader.parseRecords(lines), cap) + assertEquals(full, SessionTranscriptReader.parseEntries(lines, cap)) { "cap $cap" } + } + } + + @Test + fun `the tail window keeps the in-flight mark and drops outputs whose call fell outside it`() { + val entries = SessionTranscriptReader.parseEntries(conversation(), 4) + + assertTrue(entries.last().inFlight) + assertTrue(entries.none { it.speaker == "TOOL_OUTPUT" && entries.none { t -> t.speaker == "TOOL" && t.toolUseId == it.toolUseId } }) + } + + @Test + fun `own tool results in the window are decoded`() { + val entries = SessionTranscriptReader.parseEntries(conversation().dropLast(1), 2) + + assertEquals(listOf("TOOL", "TOOL_OUTPUT"), entries.map { it.speaker }) + assertEquals("toon", entries.last().meta) + } +} From 7a8adb9dfdfdcd1696c4a55685ded0d0a8c210f8 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:06:15 +0200 Subject: [PATCH 053/155] test(bench): follow the page assembly into the frontend module --- .../kotlin/dev/lain/claudejb/bench/PageAssemblyBenchTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/kotlin/dev/lain/claudejb/bench/PageAssemblyBenchTest.kt b/src/test/kotlin/dev/lain/claudejb/bench/PageAssemblyBenchTest.kt index b499b629..e267cfd4 100644 --- a/src/test/kotlin/dev/lain/claudejb/bench/PageAssemblyBenchTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/bench/PageAssemblyBenchTest.kt @@ -1,6 +1,6 @@ package dev.lain.claudejb.bench -import dev.lain.claudejb.view.jcef.PageAssembly +import dev.lain.claudejb.frontend.jcef.PageAssembly import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test From f8c3d96a61865902ac659e37a2a3136e175cd5fa Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:06:48 +0200 Subject: [PATCH 054/155] fix(page): rebuild a guard notice whose rule arrives in a later update --- src/main/ts/jcef/controllers/chat/transcript.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/ts/jcef/controllers/chat/transcript.ts b/src/main/ts/jcef/controllers/chat/transcript.ts index 5c737506..97b616fb 100644 --- a/src/main/ts/jcef/controllers/chat/transcript.ts +++ b/src/main/ts/jcef/controllers/chat/transcript.ts @@ -13,6 +13,21 @@ return document.getElementById('empty'); } + function guardKind(e: TranscriptEntry): string { + return e.blockedRule ? 'guard-block' : e.bypassedRule ? 'guard-bypass' : ''; + } + + function kindChanged(rec: RowRec, entry: TranscriptEntry): boolean { + if (rec.speaker !== entry.speaker) return true; + if (entry.speaker !== 'SYSTEM' || !rec.el) return false; + const shown = rec.el.classList.contains('guard-block') + ? 'guard-block' + : rec.el.classList.contains('guard-bypass') + ? 'guard-bypass' + : ''; + return guardKind(entry) !== shown; + } + function upsert(entry: TranscriptEntry | null | undefined): RowRec | null { if (entry == null || entry.id == null) { return null; @@ -25,7 +40,7 @@ } let rec = rows.get(entry.id) || null; - if (rec && rec.speaker !== entry.speaker) { + if (rec && kindChanged(rec, entry)) { if (rec.el && rec.el.parentNode) { rec.el.parentNode.removeChild(rec.el); } From 7de575450867bcda805f6d2212124ca686928fcf Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:07:34 +0200 Subject: [PATCH 055/155] fix(session): withdraw a cancelled permission in order behind the decision it cancels --- .../lain/claudejb/controller/session/events/ControlEvents.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/events/ControlEvents.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/events/ControlEvents.kt index 801b0872..3e8898c1 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/events/ControlEvents.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/events/ControlEvents.kt @@ -33,7 +33,7 @@ class ControlEvents( is ClaudeEvent.UnsupportedControlRequest -> s.guard.broker.rejectUnsupported(event.requestId, event.subtype) - is ClaudeEvent.ControlCancel -> edt { s.cards.withdraw(event.requestId) } + is ClaudeEvent.ControlCancel -> s.guard.inOrder { edt { s.cards.withdraw(event.requestId) } } is ClaudeEvent.ControlResult -> s.controlClient.onControlResult(event) } From f3298c4bf90af0bab644031fb7f7aa4f572aac64 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:07:47 +0200 Subject: [PATCH 056/155] refactor(chat): build transcript rows, appends and trims from one payload --- .../payload/chat/JcefTranscriptPayload.kt | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/view/payload/chat/JcefTranscriptPayload.kt b/src/main/kotlin/dev/lain/claudejb/view/payload/chat/JcefTranscriptPayload.kt index a15cf21a..0694140c 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/payload/chat/JcefTranscriptPayload.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/payload/chat/JcefTranscriptPayload.kt @@ -1,11 +1,14 @@ package dev.lain.claudejb.view.payload.chat +import dev.lain.claudejb.model.diff.DiffPresenter import dev.lain.claudejb.model.permission.vocab.SecurityRule import dev.lain.claudejb.model.session.transcript.EntryDTO import dev.lain.claudejb.model.session.transcript.Speaker import dev.lain.claudejb.model.session.transcript.TranscriptEntry import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -24,15 +27,10 @@ object JcefTranscriptPayload { e.filePath?.let { put("filePath", it) } e.commandText?.let { put("command", it) } e.messageText?.let { put("message", it) } - e.blockedRule?.let { rule -> - put("blockedRule", rule) - put("blockedRuleWarns", SecurityRule.from(rule)?.whitelistable == false) - } - e.bypassedRule?.let { put("bypassedRule", it) } - e.bypassAction?.let { put("bypassAction", it) } + guardFields(e.blockedRule, e.bypassedRule, e.bypassAction) put("state", e.toolState.name) put("elapsed", e.elapsedSeconds) - cardFlags(e) + reviewableFlag(e.speaker == Speaker.TOOL && e.toolUseId != null && e.reviewable) if (e.places.isNotEmpty()) { put( "places", @@ -50,15 +48,32 @@ object JcefTranscriptPayload { } } - private fun kotlinx.serialization.json.JsonObjectBuilder.cardFlags(e: TranscriptEntry) { - if (e.speaker == Speaker.TOOL && e.toolUseId != null && e.reviewable) put("reviewable", true) + private fun JsonObjectBuilder.reviewableFlag(reviewable: Boolean) { + if (reviewable) put("reviewable", true) } - private val REVIEWABLE_TOOLS = setOf("Edit", "Write", "MultiEdit") + private fun JsonObjectBuilder.guardFields(blockedRule: String?, bypassedRule: String?, bypassAction: String?) { + blockedRule?.let { rule -> + put("blockedRule", rule) + put("blockedRuleWarns", SecurityRule.from(rule)?.whitelistable == false) + } + bypassedRule?.let { put("bypassedRule", it) } + bypassAction?.let { put("bypassAction", it) } + } fun batchJson(items: List>): String = JsonArray(items.map { (e, order) -> entryJson(e, order) }).toString() + fun appendJson(id: Long, delta: String): String = buildJsonObject { + put("id", id) + put("delta", delta) + }.toString() + + fun trimJson(removedIds: List, total: Int): String = buildJsonObject { + put("ids", buildJsonArray { removedIds.forEach { add(JsonPrimitive(it)) } }) + put("total", total) + }.toString() + fun agentRowsJson( entries: List, titles: Map = emptyMap(), @@ -86,25 +101,16 @@ object JcefTranscriptPayload { dto.filePath?.let { put("filePath", it) } dto.commandText?.let { put("command", it) } dto.messageText?.let { put("message", it) } - guardFields(dto) + guardFields(dto.blockedRule, dto.bypassedRule, dto.bypassAction) put("state", agentRowState(dto, running, ownerRunning)) if (expanded) put("open", true) put("elapsed", 0) - if (dto.speaker == "TOOL" && dto.toolUseId != null && dto.meta in REVIEWABLE_TOOLS) { - put("reviewable", true) - } + reviewableFlag( + dto.speaker == Speaker.TOOL.name && dto.toolUseId != null && dto.meta in DiffPresenter.REVIEWABLE_TOOLS, + ) } } - private fun kotlinx.serialization.json.JsonObjectBuilder.guardFields(dto: EntryDTO) { - dto.blockedRule?.let { rule -> - put("blockedRule", rule) - put("blockedRuleWarns", SecurityRule.from(rule)?.whitelistable == false) - } - dto.bypassedRule?.let { put("bypassedRule", it) } - dto.bypassAction?.let { put("bypassAction", it) } - } - private fun agentRowState(dto: EntryDTO, running: Set, ownerRunning: Boolean): String = when { dto.failed -> "ERROR" dto.toolUseId in running -> "RUNNING" From 251ca81f208dcbfa365af5f0b917ae7ace2a5740 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:08:27 +0200 Subject: [PATCH 057/155] refactor(chat): extract the chat presenter and registry from the Swing panel --- .../controller/actions/AttachmentActions.kt | 19 +- .../actions/ExplainSelectionAction.kt | 19 +- .../controller/bridge/BridgeAttachments.kt | 27 +- .../claudejb/controller/bridge/BridgeCards.kt | 16 +- .../claudejb/controller/bridge/BridgeDiffs.kt | 20 +- .../claudejb/controller/bridge/BridgeGuard.kt | 36 +-- .../controller/bridge/BridgeLifecycle.kt | 21 +- .../claudejb/controller/bridge/BridgeLog.kt | 10 +- .../controller/bridge/BridgeNavigation.kt | 32 +-- .../controller/bridge/BridgePrompting.kt | 36 +-- .../controller/bridge/BridgeSessionControl.kt | 41 +-- .../controller/bridge/BridgeSettings.kt | 31 ++- .../claudejb/controller/bridge/BridgeVuln.kt | 21 +- .../controller/bridge/ChatBridgeRouter.kt | 20 +- .../claudejb/controller/bridge/ChatLinks.kt | 14 + .../controller/commands/LivePanels.kt | 25 -- .../controller/commands/TabSessionCommands.kt | 54 ++-- .../commands/git/IdeActionInvoker.kt | 10 +- .../lain/claudejb/model/bridge/JcefBridge.kt | 3 +- .../dev/lain/claudejb/model/bridge/Msg.kt | 3 +- .../lain/claudejb/view/feed/AttachmentTray.kt | 53 +--- .../lain/claudejb/view/feed/ChatAgentTabs.kt | 75 ++---- .../claudejb/view/feed/ChatTranscriptView.kt | 100 ++++--- .../lain/claudejb/view/feed/SecurityViews.kt | 14 +- .../lain/claudejb/view/feed/SessionFeed.kt | 5 +- .../claudejb/view/feed/TranscriptDeltas.kt | 54 ++++ .../lain/claudejb/view/git/ChatGitRefresh.kt | 31 +++ .../dev/lain/claudejb/view/git/GitChatFeed.kt | 20 +- .../dev/lain/claudejb/view/guard/GuardFeed.kt | 23 +- .../dev/lain/claudejb/view/log/LogFeed.kt | 13 +- .../claudejb/view/window/ChatAttention.kt | 50 ++++ .../dev/lain/claudejb/view/window/ChatDeck.kt | 69 +++++ .../claudejb/view/window/ChatGearGroup.kt | 65 +++++ .../claudejb/view/window/ChatPresenter.kt | 209 +++++++++++++++ .../lain/claudejb/view/window/ChatRegistry.kt | 190 ++++++++++++++ .../claudejb/view/window/ChatSnapshots.kt | 91 +++++++ .../claudejb/view/window/JcefChatPanel.kt | 246 ------------------ .../claudejb/view/window/PageScriptCalls.kt | 19 ++ .../lain/claudejb/view/window/PushStream.kt | 129 +++++++++ .../AttachmentTrayBatchHeadlessTest.kt | 2 +- .../view/feed/TranscriptDeltasTest.kt | 98 +++++++ .../lain/claudejb/view/window/ChatDeckTest.kt | 97 +++++++ .../view/window/PageScriptCallsTest.kt | 31 +++ .../claudejb/view/window/PushStreamTest.kt | 141 ++++++++++ 44 files changed, 1611 insertions(+), 672 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/bridge/ChatLinks.kt delete mode 100644 src/main/kotlin/dev/lain/claudejb/controller/commands/LivePanels.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/view/feed/TranscriptDeltas.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/view/git/ChatGitRefresh.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/view/window/ChatAttention.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/view/window/ChatDeck.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/view/window/ChatGearGroup.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/view/window/ChatPresenter.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/view/window/ChatRegistry.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/view/window/ChatSnapshots.kt delete mode 100644 src/main/kotlin/dev/lain/claudejb/view/window/JcefChatPanel.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/view/window/PageScriptCalls.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/view/window/PushStream.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/view/feed/TranscriptDeltasTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/view/window/ChatDeckTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/view/window/PageScriptCallsTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/view/window/PushStreamTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/actions/AttachmentActions.kt b/src/main/kotlin/dev/lain/claudejb/controller/actions/AttachmentActions.kt index b52f9eeb..7549024a 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/actions/AttachmentActions.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/actions/AttachmentActions.kt @@ -1,25 +1,14 @@ package dev.lain.claudejb.controller.actions import com.intellij.openapi.project.Project -import com.intellij.openapi.wm.ToolWindowManager -import dev.lain.claudejb.controller.session.ChatSessionManager import dev.lain.claudejb.model.context.Attachment -import dev.lain.claudejb.view.window.ClaudeToolWindowFactory +import dev.lain.claudejb.view.window.ChatRegistry object AttachmentActions { fun pin(project: Project, attachment: Attachment) { - val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(ClaudeToolWindowFactory.TOOL_WINDOW_ID) - if (toolWindow == null) { - send(project, attachment) - return - } - toolWindow.activate { - val panel = ClaudeToolWindowFactory.activePanel(project) - if (panel != null) panel.addAttachment(attachment) else send(project, attachment) - } + val registry = ChatRegistry.getInstance(project) + registry.selectedOrNew().addAttachment(attachment) + registry.showToolWindow() } - - private fun send(project: Project, attachment: Attachment) = - ChatSessionManager.getInstance(project).activeOrCreate().send(attachment.toPromptText()) } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/actions/ExplainSelectionAction.kt b/src/main/kotlin/dev/lain/claudejb/controller/actions/ExplainSelectionAction.kt index 5842c8c1..4f3ad1aa 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/actions/ExplainSelectionAction.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/actions/ExplainSelectionAction.kt @@ -5,9 +5,7 @@ import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.vfs.VirtualFile -import com.intellij.openapi.wm.ToolWindowManager -import dev.lain.claudejb.controller.session.ChatSessionManager -import dev.lain.claudejb.view.window.ClaudeToolWindowFactory +import dev.lain.claudejb.view.window.ChatRegistry class ExplainSelectionAction : AnAction() { @@ -28,16 +26,11 @@ class ExplainSelectionAction : AnAction() { append("```") } - val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(ClaudeToolWindowFactory.TOOL_WINDOW_ID) - if (toolWindow == null) { - ChatSessionManager.getInstance(project).activeOrCreate().send(prompt) - return - } - toolWindow.activate { - val session = ClaudeToolWindowFactory.activePanel(project)?.session - ?: ChatSessionManager.getInstance(project).activeOrCreate() - session.send(prompt) - } + val registry = ChatRegistry.getInstance(project) + val chat = registry.selectedOrNew() + chat.session.send(prompt) + chat.focusInput() + registry.showToolWindow() } override fun update(e: AnActionEvent) { diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeAttachments.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeAttachments.kt index d9d1955a..d839adee 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeAttachments.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeAttachments.kt @@ -7,13 +7,13 @@ import dev.lain.claudejb.model.context.ImageAttachments import dev.lain.claudejb.util.edt import dev.lain.claudejb.util.thisLogger import dev.lain.claudejb.view.payload.chat.JcefTreeData -import dev.lain.claudejb.view.window.JcefChatPanel +import dev.lain.claudejb.view.window.ChatPresenter -internal class BridgeAttachments(private val panel: JcefChatPanel) { +internal class BridgeAttachments(private val presenter: ChatPresenter) { private val log = thisLogger() - private val tray get() = panel.tray + private val tray get() = presenter.tray fun handle(m: Msg.Attachments) { when (m) { @@ -25,14 +25,13 @@ internal class BridgeAttachments(private val panel: JcefChatPanel) { is Msg.TreeChildren -> treeChildren(m) is Msg.TreeExpand -> treeExpand(m) is Msg.AttachPaths -> attachPaths(m.paths) - Msg.PasteClipboard -> tray.pasteFromClipboard() - is Msg.PasteClipboardImage -> tray.pasteImageFromClipboard(m.notify) - is Msg.Attach -> attachImage(m) + is Msg.Attach -> attachImage(m.name, m.mediaType, m.base64) + is Msg.AttachImageData -> attachImage(PASTED_NAME + m.mime.substringAfter('/'), m.mime, m.base64) } } - private fun attachImage(m: Msg.Attach) { - val image = ImageAttachments.fromWebPayload(m.name, m.mediaType, m.base64) + private fun attachImage(name: String, mediaType: String, base64: String) { + val image = ImageAttachments.fromWebPayload(name, mediaType, base64) if (image == null) { tray.notify( "That attachment was not added: only PNG, JPEG, GIF and WebP images are accepted, " + @@ -51,21 +50,21 @@ internal class BridgeAttachments(private val panel: JcefChatPanel) { private fun treeChildren(m: Msg.TreeChildren) { val mode = treeMode(m.mode) ?: return unknownTreeMode(m.mode) - panel.host.execBuilt("window.cc.treeChildren") { - JcefTreeData.childrenJson(m.path, m.mode, ProjectTree.children(panel.project, m.path, mode)).toString() + presenter.execBuilt("treeChildren") { + JcefTreeData.childrenJson(m.path, m.mode, ProjectTree.children(presenter.project, m.path, mode)).toString() } } private fun treeExpand(m: Msg.TreeExpand) { val mode = treeMode(m.mode) ?: return unknownTreeMode(m.mode) - panel.host.execBuilt("window.cc.treeExpansion") { - JcefTreeData.expansionJson(m.path, m.mode, ProjectTree.expand(panel.project, m.path, mode)).toString() + presenter.execBuilt("treeExpansion") { + JcefTreeData.expansionJson(m.path, m.mode, ProjectTree.expand(presenter.project, m.path, mode)).toString() } } private fun attachPaths(paths: List) { if (paths.isEmpty()) return - val root = panel.project.basePath + val root = presenter.project.basePath ApplicationManager.getApplication().executeOnPooledThread { val wanted = paths.take(ProjectTree.MAX_ENTRIES) val files = wanted.mapNotNull { ProjectTree.resolve(root, it)?.path } @@ -82,5 +81,7 @@ internal class BridgeAttachments(private val panel: JcefChatPanel) { private companion object { const val BYTES_PER_MB = 1024 * 1024 + + const val PASTED_NAME = "clipboard." } } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeCards.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeCards.kt index 7f15a7b0..0b84c8c1 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeCards.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeCards.kt @@ -2,33 +2,33 @@ package dev.lain.claudejb.controller.bridge import dev.lain.claudejb.model.bridge.Msg import dev.lain.claudejb.model.settings.ClaudeSettings -import dev.lain.claudejb.view.window.JcefChatPanel +import dev.lain.claudejb.view.window.ChatPresenter -internal class BridgeCards(private val panel: JcefChatPanel) { +internal class BridgeCards(private val presenter: ChatPresenter) { fun handle(m: Msg.RequestCard) { when (m) { is Msg.ResolvePermission -> resolvePermission(m) - is Msg.ResolveQuestion -> panel.cardSession(m.scope).cards.resolveQuestion(m.id, m.answers) + is Msg.ResolveQuestion -> presenter.cardSession(m.scope).cards.resolveQuestion(m.id, m.answers) is Msg.ResolveElicitation -> - panel.cardSession(m.scope).cards.resolveElicitation(m.id, m.action, m.content) + presenter.cardSession(m.scope).cards.resolveElicitation(m.id, m.action, m.content) is Msg.AlwaysAllow -> alwaysAllow(m) } } private fun resolvePermission(m: Msg.ResolvePermission) { - val target = panel.cardSession(m.scope) + val target = presenter.cardSession(m.scope) val wasPlan = target.cards.pending().firstOrNull { it.requestId == m.id }?.isPlan == true target.cards.resolvePermission(m.id, m.allow) - if (wasPlan && m.allow && target === panel.session) panel.feed.requestPlan() + if (wasPlan && m.allow && target === presenter.session) presenter.feed.requestPlan() } private fun alwaysAllow(m: Msg.AlwaysAllow) { - ClaudeSettings.getInstance(panel.project).alwaysAllow.remember(m.tool) - val chat = panel.cardSession(m.scope) + ClaudeSettings.getInstance(presenter.project).alwaysAllow.remember(m.tool) + val chat = presenter.cardSession(m.scope) val pending = chat.cards.pending() val target = pending.firstOrNull { it.requestId == m.id } ?: pending.firstOrNull { it.toolName == m.tool } target?.let { chat.cards.resolvePermission(it.requestId, true) } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeDiffs.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeDiffs.kt index 4e34afdc..93c9a14d 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeDiffs.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeDiffs.kt @@ -10,23 +10,23 @@ import dev.lain.claudejb.util.edt import dev.lain.claudejb.util.thisLogger import dev.lain.claudejb.view.diff.DiffEditors import dev.lain.claudejb.view.payload.chat.JcefTranscriptPayload -import dev.lain.claudejb.view.window.JcefChatPanel +import dev.lain.claudejb.view.window.ChatPresenter -internal class BridgeDiffs(private val panel: JcefChatPanel) { +internal class BridgeDiffs(private val presenter: ChatPresenter) { private val log = thisLogger() fun handle(m: Msg.Diffs) { when (m) { - is Msg.ViewDiff -> panel.cardSession(m.scope).cards.pending().firstOrNull { it.requestId == m.id } + is Msg.ViewDiff -> presenter.cardSession(m.scope).cards.pending().firstOrNull { it.requestId == m.id } ?.let { viewPending(it) } is Msg.ViewDiffByTool -> snapshotAnywhere(m.toolUseId) - ?.let { DiffEditors.openDiff(panel.project, it.toolName, it.input, it.beforeText) } + ?.let { DiffEditors.openDiff(presenter.project, it.toolName, it.input, it.beforeText) } - is Msg.RevertEdit -> panel.edits.rewindOrRevert(m.toolUseId) + is Msg.RevertEdit -> presenter.edits.rewindOrRevert(m.toolUseId) - is Msg.Open -> panel.links.open(m.url) + is Msg.Open -> presenter.links.open(m.url) is Msg.ResolveLinks -> resolveLinks(m) } @@ -34,7 +34,7 @@ internal class BridgeDiffs(private val panel: JcefChatPanel) { private fun viewPending(card: PendingPermission) { val path = DiffPresenter.filePathOf(card.input) ?: return - val project = panel.project + val project = presenter.project ApplicationManager.getApplication().executeOnPooledThread { val current = DiffPresenter.readCurrent(path, project.basePath) if (current == null) { @@ -46,12 +46,12 @@ internal class BridgeDiffs(private val panel: JcefChatPanel) { } private fun snapshotAnywhere(toolUseId: String): EditSnapshot? = - panel.session.cards.editSnapshot(toolUseId) ?: panel.gitChat.session().cards.editSnapshot(toolUseId) + presenter.session.cards.editSnapshot(toolUseId) ?: presenter.gitChat.session().cards.editSnapshot(toolUseId) private fun resolveLinks(m: Msg.ResolveLinks) { if (m.paths.isEmpty() && m.symbols.isEmpty()) return - val project = panel.project - panel.host.execBuilt("window.cc.links") { + val project = presenter.project + presenter.execBuilt("links") { val resolved = LinkResolver.resolvePaths(project, m.paths) + LinkResolver.resolveSymbols(project, m.symbols) resolved.takeIf { it.isNotEmpty() }?.let { JcefTranscriptPayload.linksJson(m.rowId, it) } } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeGuard.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeGuard.kt index d5ad1e3c..a0bd22f6 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeGuard.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeGuard.kt @@ -1,7 +1,6 @@ package dev.lain.claudejb.controller.bridge import dev.lain.claudejb.controller.commands.GuardWhitelistPrompt -import dev.lain.claudejb.controller.commands.LivePanels import dev.lain.claudejb.model.bridge.Msg import dev.lain.claudejb.model.permission.SensitiveGuard import dev.lain.claudejb.model.permission.scan.ToolInputScanner @@ -11,15 +10,19 @@ import dev.lain.claudejb.model.settings.guard.GuardWhitelists import dev.lain.claudejb.model.settings.guard.SecuritySuspensions import dev.lain.claudejb.model.settings.guard.sensitivePolicy import dev.lain.claudejb.util.thisLogger -import dev.lain.claudejb.view.window.JcefChatPanel +import dev.lain.claudejb.view.window.ChatPresenter +import dev.lain.claudejb.view.window.ChatRegistry +import dev.lain.claudejb.view.window.ChatSnapshots.Kind -internal class BridgeGuard(private val panel: JcefChatPanel) { +internal class BridgeGuard(private val presenter: ChatPresenter) { private val log = thisLogger() - private val session get() = panel.session + private val session get() = presenter.session - private val settings: ClaudeSettings get() = ClaudeSettings.getInstance(panel.project) + private val settings: ClaudeSettings get() = ClaudeSettings.getInstance(presenter.project) + + private fun repaintMenu() = ChatRegistry.repaintEverywhere(Kind.MENU) fun handle(m: Msg.Guard) { when (m) { @@ -29,8 +32,8 @@ internal class BridgeGuard(private val panel: JcefChatPanel) { is Msg.GuardRevokeApproval -> revokeApproval(m) is Msg.GuardRemoveWhitelist -> removeWhitelist(m) is Msg.GuardAllowAlways -> allowAlways(m) - Msg.GuardLog -> panel.security.pushGuard() - is Msg.GuardExplain -> panel.guard.explain(m.id) + Msg.GuardLog -> presenter.security.pushGuard() + is Msg.GuardExplain -> presenter.guard.explain(m.id) } } @@ -43,7 +46,7 @@ internal class BridgeGuard(private val panel: JcefChatPanel) { } val scope = settings.scope.id settings.update { SecuritySuspensions.suspend(scope, it, rule, duration, System.currentTimeMillis()) } - LivePanels.pushSettingsMenu() + repaintMenu() session.systemNotice( "${rule.label} is disabled ${duration.phrase}. Matching calls will ask you instead of being refused.", ) @@ -69,8 +72,7 @@ internal class BridgeGuard(private val panel: JcefChatPanel) { } private fun announce(notice: String) { - LivePanels.pushSettingsMenu() - LivePanels.pushState() + ChatRegistry.repaintEverywhere(Kind.MENU, Kind.META, Kind.STATE) session.systemNotice(notice) } @@ -81,8 +83,8 @@ internal class BridgeGuard(private val panel: JcefChatPanel) { log.warn("A guard block asked to whitelist something this build cannot place: " + m.rule) return } - if (!GuardWhitelistPrompt.confirm(panel.project, rule, entry)) return - val policy = settings.sensitivePolicy(panel.project.basePath) + if (!GuardWhitelistPrompt.confirm(presenter.project, rule, entry)) return + val policy = settings.sensitivePolicy(presenter.project.basePath) val canonical = SensitiveGuard.canonicalCommand(entry, policy) val already = GuardWhitelists.all(settings.state, rule).any { SensitiveGuard.canonicalCommand(it, policy) == canonical } @@ -91,7 +93,7 @@ internal class BridgeGuard(private val panel: JcefChatPanel) { return } settings.update { GuardWhitelists.add(it, rule, entry) } - LivePanels.pushSettingsMenu() + repaintMenu() session.systemNotice( "Commands starting with `" + entry + "` are whitelisted for " + rule.label + ". Every other rule still judges them.", ) @@ -103,7 +105,7 @@ internal class BridgeGuard(private val panel: JcefChatPanel) { log.warn("A bypass warning asked to un-whitelist something this build cannot place: ${m.rule}") return } - val policy = settings.sensitivePolicy(panel.project.basePath) + val policy = settings.sensitivePolicy(presenter.project.basePath) val wanted = SensitiveGuard.canonicalCommand(m.command, policy) val covers = { entry: String -> SensitiveGuard.covers(SensitiveGuard.canonicalCommand(entry, policy), wanted) } val listed = GuardWhitelists.listedIn(settings.state, rule, covers) @@ -112,7 +114,7 @@ internal class BridgeGuard(private val panel: JcefChatPanel) { return } settings.update { GuardWhitelists.remove(it, rule, listed, covers) } - LivePanels.pushSettingsMenu() + repaintMenu() val where = listed.joinToString(" and ") { describe(it, rule) } session.systemNotice("`${m.command.trim()}` is off the $where. ${rule.label} decides it again.") } @@ -130,12 +132,12 @@ internal class BridgeGuard(private val panel: JcefChatPanel) { return } session.guard.approvals.revoke(rule, m.command.trim()) - LivePanels.pushSettingsMenu() + repaintMenu() session.systemNotice("`${m.command.trim()}` is no longer pre-approved. ${rule.label} decides again.") } private fun allowAlways(m: Msg.GuardAllowAlways) { - val chat = panel.cardSession(m.scope) + val chat = presenter.cardSession(m.scope) val target = chat.cards.pending().firstOrNull { it.requestId == m.id } ?: return val rule = target.guard?.rule ?: return ToolInputScanner.commandsIn(target.input).forEach { chat.guard.approvals.approve(rule, it) } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeLifecycle.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeLifecycle.kt index 140ba8c6..1424127b 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeLifecycle.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeLifecycle.kt @@ -2,30 +2,15 @@ package dev.lain.claudejb.controller.bridge import dev.lain.claudejb.model.bridge.Msg import dev.lain.claudejb.util.thisLogger -import dev.lain.claudejb.view.window.JcefChatPanel +import dev.lain.claudejb.view.window.ChatPresenter -internal class BridgeLifecycle(private val panel: JcefChatPanel) { +internal class BridgeLifecycle(private val presenter: ChatPresenter) { private val log = thisLogger() fun handle(m: Msg.Lifecycle) { when (m) { - Msg.Ready -> { - panel.host.markWebReady() - panel.pushTheme() - panel.pushSettingsMenu() - panel.pushMetaState() - panel.pushPermissions() - panel.tray.push() - panel.pushSession() - panel.feed.requestMcp() - panel.feed.requestVersion() - panel.agentTabs.render() - panel.pushGit() - panel.security.pushGuard() - panel.security.pushVuln() - panel.transcript.fullResync() - } + Msg.Ready -> presenter.refreshPage() is Msg.Diagnostics -> if (m.report.startsWith("uncaught ")) { diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeLog.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeLog.kt index 5d14424b..72734862 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeLog.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeLog.kt @@ -1,15 +1,15 @@ package dev.lain.claudejb.controller.bridge import dev.lain.claudejb.model.bridge.Msg -import dev.lain.claudejb.view.window.JcefChatPanel +import dev.lain.claudejb.view.window.ChatPresenter -internal class BridgeLog(private val panel: JcefChatPanel) { +internal class BridgeLog(private val presenter: ChatPresenter) { fun handle(m: Msg.Log) { when (m) { - is Msg.LogLines -> panel.logFeed.push(m.since) - is Msg.LogDebug -> panel.logFeed.setDebug(m.on) - is Msg.LogCopy -> panel.logFeed.copy(m.level) + is Msg.LogLines -> presenter.logFeed.push(m.since) + is Msg.LogDebug -> presenter.logFeed.setDebug(m.on) + is Msg.LogCopy -> presenter.logFeed.copy(m.level) } } } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeNavigation.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeNavigation.kt index 01eaaf7e..2d08c449 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeNavigation.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeNavigation.kt @@ -1,39 +1,27 @@ package dev.lain.claudejb.controller.bridge import dev.lain.claudejb.model.bridge.Msg -import dev.lain.claudejb.util.thisLogger -import dev.lain.claudejb.view.window.ChatTabsPanel -import dev.lain.claudejb.view.window.JcefChatPanel +import dev.lain.claudejb.rpc.ChatId +import dev.lain.claudejb.view.window.ChatPresenter -internal class BridgeNavigation(private val panel: JcefChatPanel) { - - private val log = thisLogger() +internal class BridgeNavigation(private val presenter: ChatPresenter) { fun handle(m: Msg.Navigation) { when (m) { - is Msg.RevealAgent -> panel.agentTabs.revealElsewhere(m.chatId) { it.agentTabs.revealFromHost(m) } + is Msg.RevealAgent -> presenter.agentTabs.revealElsewhere(m.chatId) { it.agentTabs.revealFromHost(m) } is Msg.RevealBackgroundTask -> - panel.agentTabs.revealElsewhere(m.chatId) { it.transcript.showBackgroundTask(m.taskId) } - - Msg.ShowChatTranscript -> panel.transcript.showTranscript(null) + presenter.agentTabs.revealElsewhere(m.chatId) { it.transcript.showBackgroundTask(m.taskId) } - is Msg.SelectChat -> withStrip("select chat ${m.chatId}") { it.selectById(m.chatId) } + Msg.ShowChatTranscript -> presenter.transcript.showTranscript(null) - is Msg.CloseChat -> withStrip("close chat ${m.chatId}") { it.closeById(m.chatId) } + is Msg.SelectChat -> presenter.registry.select(ChatId(m.chatId)) - is Msg.SelectAgent -> panel.transcript.showTranscript(m.agentId.ifBlank { null }) + is Msg.CloseChat -> presenter.registry.close(ChatId(m.chatId)) - is Msg.CloseAgent -> panel.agentTabs.closeAgent(m.agentId) - } - } + is Msg.SelectAgent -> presenter.transcript.showTranscript(m.agentId.ifBlank { null }) - fun withStrip(what: String, block: (ChatTabsPanel) -> Unit) { - val strip = panel.chatStrip() - if (strip == null) { - log.warn("Claude Code: no chat strip to $what — the press was dropped") - return + is Msg.CloseAgent -> presenter.agentTabs.closeAgent(m.agentId) } - block(strip) } } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgePrompting.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgePrompting.kt index e4de6f9e..a434ac0f 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgePrompting.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgePrompting.kt @@ -1,40 +1,44 @@ package dev.lain.claudejb.controller.bridge -import com.intellij.openapi.ide.CopyPasteManager import dev.lain.claudejb.model.bridge.JcefBridge import dev.lain.claudejb.model.bridge.Msg -import dev.lain.claudejb.view.window.JcefChatPanel -import java.awt.datatransfer.StringSelection +import dev.lain.claudejb.view.window.ChatPresenter -internal class BridgePrompting(private val panel: JcefChatPanel) { +internal class BridgePrompting(private val presenter: ChatPresenter) { - private val session get() = panel.session + private val session get() = presenter.session fun handle(m: Msg.Prompting) { when (m) { - is Msg.Send -> if (m.scope == JcefBridge.SCOPE_GIT) panel.gitChat.send(m.text) else send(m.text) + is Msg.Send -> if (m.scope == JcefBridge.SCOPE_GIT) presenter.gitChat.send(m.text) else send(m.text) is Msg.Interrupt -> - if (m.scope == JcefBridge.SCOPE_GIT) panel.gitChat.interrupt() else session.turnControl.interrupt() + if (m.scope == JcefBridge.SCOPE_GIT) presenter.gitChat.interrupt() else session.turnControl.interrupt() is Msg.RemoveQueued -> session.prompts.remove(m.index) - is Msg.Copy -> CopyPasteManager.getInstance().setContents(StringSelection(m.text)) + is Msg.Copy -> presenter.copyToClient(m.text) } } private fun send(raw: String) { session.prompts.clearSuggestion() - val attachments = panel.tray.take() + val attachments = presenter.tray.all() val text = raw.trim() - when { - attachments.isEmpty() && text == "/login" -> session.login.start() - - attachments.isEmpty() && BTW.matches(text.substringBefore('\n')) -> - session.sendSideQuestion(text.removePrefix("/btw").trim()) - - else -> session.send(raw, attachments) + if (attachments.isEmpty() && text == "/login") { + session.login.start() + return + } + if (!session.isRunning() && !session.start()) { + presenter.exec("insertText", JcefBridge.jsString(raw)) + return + } + if (attachments.isEmpty() && BTW.matches(text.substringBefore('\n'))) { + session.sendSideQuestion(text.removePrefix("/btw").trim()) + return } + presenter.tray.take() + session.send(raw, attachments) } private companion object { diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSessionControl.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSessionControl.kt index a4ab00fb..fc4a792b 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSessionControl.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSessionControl.kt @@ -1,24 +1,25 @@ package dev.lain.claudejb.controller.bridge -import dev.lain.claudejb.controller.commands.LivePanels import dev.lain.claudejb.controller.commands.git.GitActionCatalog import dev.lain.claudejb.controller.commands.git.GitIntegration +import dev.lain.claudejb.controller.mcp.IdeMcpService import dev.lain.claudejb.model.bridge.Msg import dev.lain.claudejb.model.settings.ClaudeSettings import dev.lain.claudejb.model.settings.WorkloadWindow import dev.lain.claudejb.util.thisLogger -import dev.lain.claudejb.view.window.ClaudeToolWindowFactory -import dev.lain.claudejb.view.window.JcefChatPanel +import dev.lain.claudejb.view.window.ChatPresenter +import dev.lain.claudejb.view.window.ChatRegistry +import dev.lain.claudejb.view.window.ChatSnapshots.Kind -internal class BridgeSessionControl(private val panel: JcefChatPanel) { +internal class BridgeSessionControl(private val presenter: ChatPresenter) { private val log = thisLogger() - private val vuln = BridgeVuln(panel) + private val vuln = BridgeVuln(presenter) - private val navigation = BridgeNavigation(panel) + private val navigation = BridgeNavigation(presenter) - private val session get() = panel.session + private val session get() = presenter.session fun handle(m: Msg.SessionControl) { when (m) { @@ -26,18 +27,19 @@ internal class BridgeSessionControl(private val panel: JcefChatPanel) { is Msg.Navigation -> navigation.handle(m) - is Msg.Onboarding -> panel.onboarding.handle(m) + is Msg.Onboarding -> presenter.onboarding.handle(m) - Msg.McpRefresh -> panel.feed.requestMcp() + Msg.McpRefresh -> presenter.feed.requestMcp() is Msg.McpReconnect -> { + IdeMcpService.getInstance(session.project).admitReconnect(m.name) session.queries.reconnectMcp(m.name) - panel.feed.requestMcp() + presenter.feed.requestMcp() } is Msg.McpToggle -> { session.queries.toggleMcp(m.name, m.enabled) - panel.feed.requestMcp() + presenter.feed.requestMcp() } is Msg.StopTask -> session.queries.stopTask(m.taskId) @@ -46,19 +48,18 @@ internal class BridgeSessionControl(private val panel: JcefChatPanel) { is Msg.GitAction -> gitAction(m) - Msg.NewChat -> ClaudeToolWindowFactory.newChat(panel.project) + Msg.NewChat -> presenter.registry.newChat() - Msg.CloseThisChat -> navigation.withStrip("close this chat") { strip -> - strip.tabFor(session)?.let { strip.close(it) } - } + Msg.CloseThisChat -> presenter.registry.close(presenter.id) - Msg.OpenGitView -> ClaudeToolWindowFactory.showGitView(panel.project) + Msg.OpenGitView -> presenter.showGitView() } } private fun gitAction(m: Msg.GitAction) { - GitIntegration.getInstance(panel.project).perform(m.id, m.hash, { panel.gitChat.session() }) { panel.pushGit() } - if (GitActionCatalog.byId(m.id)?.kind == GitActionCatalog.Kind.PROMPT) panel.gitChat.show() + GitIntegration.getInstance(presenter.project) + .perform(m.id, m.hash, { presenter.gitChat.session() }) { presenter.registry.git.request() } + if (GitActionCatalog.byId(m.id)?.kind == GitActionCatalog.Kind.PROMPT) presenter.gitChat.show() } private fun workloadWindow(minutes: Int) { @@ -66,7 +67,7 @@ internal class BridgeSessionControl(private val panel: JcefChatPanel) { log.warn("Workloads view asked for a window this build does not offer: $minutes") return } - ClaudeSettings.getInstance(panel.project).update { it.workloadWindowMinutes = minutes } - LivePanels.pushSession() + ClaudeSettings.getInstance(presenter.project).update { it.workloadWindowMinutes = minutes } + ChatRegistry.repaintEverywhere(Kind.SESSION) } } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSettings.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSettings.kt index a3e22c16..ae72142f 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSettings.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSettings.kt @@ -1,25 +1,27 @@ package dev.lain.claudejb.controller.bridge import com.intellij.openapi.options.ShowSettingsUtil -import dev.lain.claudejb.controller.commands.LivePanels import dev.lain.claudejb.controller.session.ChatSessionManager import dev.lain.claudejb.model.bridge.Msg import dev.lain.claudejb.model.settings.ClaudeSettings import dev.lain.claudejb.model.settings.LaunchDefaults import dev.lain.claudejb.model.settings.Provider import dev.lain.claudejb.util.thisLogger -import dev.lain.claudejb.view.feed.ChatTheme import dev.lain.claudejb.view.payload.menu.JcefSettingsMenu import dev.lain.claudejb.view.settings.ClaudeSettingsConfigurable -import dev.lain.claudejb.view.window.JcefChatPanel +import dev.lain.claudejb.view.window.ChatPresenter +import dev.lain.claudejb.view.window.ChatRegistry +import dev.lain.claudejb.view.window.ChatSnapshots.Kind -internal class BridgeSettings(private val panel: JcefChatPanel) { +internal class BridgeSettings(private val presenter: ChatPresenter) { private val log = thisLogger() - private val guard = BridgeGuard(panel) + private val guard = BridgeGuard(presenter) - private val session get() = panel.session + private val session get() = presenter.session + + private val repaintMenu = { ChatRegistry.repaintEverywhere(Kind.MENU) } fun handle(m: Msg.Settings) { when (m) { @@ -32,10 +34,7 @@ internal class BridgeSettings(private val panel: JcefChatPanel) { is Msg.ChangeThinking -> session.settings.changeThinkingTokens(if (m.on) LaunchDefaults.THINKING_ON else null) - is Msg.ChangeVibe -> { - ChatTheme.setVibeMode(m.on) - LivePanels.pushTheme() - } + is Msg.ChangeVibe -> ChatRegistry.changeVibe(m.on) is Msg.ChangeProvider -> session.settings.changeProvider(Provider.fromId(m.id)) @@ -43,10 +42,10 @@ internal class BridgeSettings(private val panel: JcefChatPanel) { is Msg.Guard -> guard.handle(m) - Msg.SettingsRefresh -> ClaudeSettings.getInstance(panel.project).reload { LivePanels.pushSettingsMenu() } + Msg.SettingsRefresh -> ClaudeSettings.getInstance(presenter.project).reload(repaintMenu) Msg.OpenSettings -> - ShowSettingsUtil.getInstance().showSettingsDialog(panel.project, ClaudeSettingsConfigurable::class.java) + ShowSettingsUtil.getInstance().showSettingsDialog(presenter.project, ClaudeSettingsConfigurable::class.java) } } @@ -55,11 +54,11 @@ internal class BridgeSettings(private val panel: JcefChatPanel) { log.warn("The chat's settings menu asked for a switch this build does not have: ${m.key}") return } - LivePanels.pushSettingsMenu() + repaintMenu() } private fun write(m: Msg.SettingsToggle): Boolean { - val settings = ClaudeSettings.getInstance(panel.project) + val settings = ClaudeSettings.getInstance(presenter.project) JcefSettingsMenu.alwaysAllowTool(m.key)?.let { tool -> if (m.on) settings.alwaysAllow.remember(tool) else settings.alwaysAllow.forget(tool) return true @@ -69,14 +68,14 @@ internal class BridgeSettings(private val panel: JcefChatPanel) { return true } if (JcefSettingsMenu.isRemoteControl(m.key)) { - session.remote.set(m.on) { LivePanels.pushSettingsMenu() } + session.remote.set(m.on, repaintMenu) return true } val scope = settings.scope.id val models = session.catalog.models.map { it.value } if (!JcefSettingsMenu.apply(scope, settings.state, m.key, m.on, models)) return false settings.update { JcefSettingsMenu.apply(scope, it, m.key, m.on, models) } - ChatSessionManager.getInstance(panel.project).adoptSettings() + ChatSessionManager.getInstance(presenter.project).adoptSettings() return true } } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeVuln.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeVuln.kt index f57d2d76..11a64b83 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeVuln.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeVuln.kt @@ -6,20 +6,23 @@ import dev.lain.claudejb.controller.vuln.VulnService import dev.lain.claudejb.model.bridge.Msg import dev.lain.claudejb.util.thisLogger import dev.lain.claudejb.view.payload.JcefVulnData -import dev.lain.claudejb.view.window.JcefChatPanel +import dev.lain.claudejb.view.window.ChatPresenter +import dev.lain.claudejb.view.window.ChatSnapshots.Kind -internal class BridgeVuln(private val panel: JcefChatPanel) { +internal class BridgeVuln(private val presenter: ChatPresenter) { private val log = thisLogger() - private val service: VulnService get() = VulnService.getInstance(panel.project) + private val service: VulnService get() = VulnService.getInstance(presenter.project) + + private val repaint = { presenter.snapshots.mark(Kind.SESSION) } fun handle(m: Msg.Vuln) { when (m) { - Msg.OpenVulnView -> panel.security.showVulnView() - is Msg.VulnConsentChoice -> service.setConsent(m.granted) { panel.pushSession() } - Msg.VulnScan -> service.scan { panel.pushSession() } - Msg.VulnCancel -> service.cancel { panel.pushSession() } + Msg.OpenVulnView -> presenter.security.showVulnView() + is Msg.VulnConsentChoice -> service.setConsent(m.granted, repaint) + Msg.VulnScan -> service.scan(repaint) + Msg.VulnCancel -> service.cancel(repaint) Msg.VulnInventoryRequest -> inventory() is Msg.VulnFix -> fix(m.findingId) is Msg.VulnPlan -> plan(m.tiers) @@ -29,7 +32,7 @@ internal class BridgeVuln(private val panel: JcefChatPanel) { private fun inventory() { val current = service val endpoint = current.snapshot().endpoint - panel.host.execBuilt("window.cc.vulnInventory") { JcefVulnData.inventoryJson(current.inventory(), endpoint).toString() } + presenter.execBuilt("vulnInventory") { JcefVulnData.inventoryJson(current.inventory(), endpoint).toString() } } private fun fix(findingId: String) { @@ -63,6 +66,6 @@ internal class BridgeVuln(private val panel: JcefChatPanel) { } private fun inNewChat(title: String, text: String) { - if (!PromptInNewChat.open(panel.project, title, text)) panel.session.send(text) + if (!PromptInNewChat.open(presenter.project, title, text)) presenter.session.send(text) } } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/ChatBridgeRouter.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/ChatBridgeRouter.kt index 40d8a5b7..f5ad8db3 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/bridge/ChatBridgeRouter.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/ChatBridgeRouter.kt @@ -2,18 +2,18 @@ package dev.lain.claudejb.controller.bridge import dev.lain.claudejb.model.bridge.JcefBridge import dev.lain.claudejb.model.bridge.Msg -import dev.lain.claudejb.view.window.JcefChatPanel +import dev.lain.claudejb.view.window.ChatPresenter -internal class ChatBridgeRouter(panel: JcefChatPanel) { +internal class ChatBridgeRouter(presenter: ChatPresenter) { - private val prompting = BridgePrompting(panel) - private val settings = BridgeSettings(panel) - private val cards = BridgeCards(panel) - private val diffs = BridgeDiffs(panel) - private val attachments = BridgeAttachments(panel) - private val controls = BridgeSessionControl(panel) - private val lifecycle = BridgeLifecycle(panel) - private val log = BridgeLog(panel) + private val prompting = BridgePrompting(presenter) + private val settings = BridgeSettings(presenter) + private val cards = BridgeCards(presenter) + private val diffs = BridgeDiffs(presenter) + private val attachments = BridgeAttachments(presenter) + private val controls = BridgeSessionControl(presenter) + private val lifecycle = BridgeLifecycle(presenter) + private val log = BridgeLog(presenter) fun dispatch(json: String) { when (val m = JcefBridge.parse(json)) { diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/ChatLinks.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/ChatLinks.kt new file mode 100644 index 00000000..3252af0d --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/ChatLinks.kt @@ -0,0 +1,14 @@ +package dev.lain.claudejb.controller.bridge + +import com.intellij.openapi.project.Project +import dev.lain.claudejb.controller.context.LinkNavigator + +internal class ChatLinks(project: Project, private val browse: (String) -> Unit) { + + private val navigator = LinkNavigator(project) + + fun open(url: String) { + val link = url.trim() + if (link.lowercase().startsWith("https://")) browse(link) else navigator.open(link) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/commands/LivePanels.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/LivePanels.kt deleted file mode 100644 index c0dc0593..00000000 --- a/src/main/kotlin/dev/lain/claudejb/controller/commands/LivePanels.kt +++ /dev/null @@ -1,25 +0,0 @@ -package dev.lain.claudejb.controller.commands - -import dev.lain.claudejb.view.window.JcefChatPanel -import java.util.concurrent.CopyOnWriteArrayList - -internal object LivePanels { - - private val panels = CopyOnWriteArrayList() - - fun add(panel: JcefChatPanel) { - panels += panel - } - - fun remove(panel: JcefChatPanel) { - panels -= panel - } - - fun pushTheme() = panels.forEach { it.pushTheme() } - - fun pushSession() = panels.forEach { it.pushSession() } - - fun pushSettingsMenu() = panels.forEach { it.pushSettingsMenu() } - - fun pushState() = panels.forEach { it.pushMetaState() } -} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/commands/TabSessionCommands.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/TabSessionCommands.kt index 90e15dae..9ecde5e8 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/commands/TabSessionCommands.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/TabSessionCommands.kt @@ -16,18 +16,15 @@ import dev.lain.claudejb.model.session.transcript.EntryDTO import dev.lain.claudejb.model.session.transcript.SessionRef import dev.lain.claudejb.model.settings.ClaudeSettings import dev.lain.claudejb.util.edt -import dev.lain.claudejb.view.window.ChatTabsPanel +import dev.lain.claudejb.view.window.ChatRegistry import javax.swing.JList internal class TabSessionCommands( private val project: Project, - private val tabs: ChatTabsPanel, - private val openTab: (ClaudeSession, Boolean) -> Unit, + private val registry: ChatRegistry, ) { - private fun openChat(session: ClaudeSession) = openTab(session, true) - - fun newChat() = openChat(ChatSessionManager.getInstance(project).create()) + private fun openChat(session: ClaudeSession) = registry.open(session, true) fun newChatWith(title: String, prompt: String) { val session = ChatSessionManager.getInstance(project).create() @@ -36,47 +33,38 @@ internal class TabSessionCommands( session.send(prompt) } - private fun activeSession(): ClaudeSession? = tabs.selectedChat?.session - - private data class RestoredSession(val id: String, val title: String?, val entries: List) + private fun activeSession(): ClaudeSession? = registry.selected()?.session fun restoreOrCreate() { val manager = ChatSessionManager.getInstance(project) if (!ClaudeSettings.getInstance(project).restoreOpenChatsOnStartup) { - openChat(manager.create()) + if (registry.isEmpty()) openChat(manager.create()) return } + val quiet = !registry.isEmpty() ApplicationManager.getApplication().executeOnPooledThread { val ids = SessionHistory.getInstance(project).openSessions() .filter { SessionStore.exists(it) } .ifEmpty { listOfNotNull(SessionListing.list(project).firstOrNull()?.sessionId) } - val restored = ids - .map { - RestoredSession( - it, - SessionTitleReader.readTitle(it), - SessionTranscriptReader.readEntries( - it, - SessionTranscriptReader.DEFAULT_RESTORE_CAP, - project.basePath, - ), - ) - } - edt { - if (restored.isEmpty()) { - openChat(manager.create()) - } else { - for (r in restored) { - val s = manager.create() - s.title = r.title ?: s.title - s.persistence.restore(r.id, r.entries) - openChat(s) - } - } + if (ids.isEmpty()) { + edt(project) { if (registry.isEmpty()) openChat(manager.create()) } + return@executeOnPooledThread + } + ids.forEachIndexed { index, id -> + val title = SessionTitleReader.readTitle(id) + val entries = SessionTranscriptReader.readEntries(id, SessionTranscriptReader.DEFAULT_RESTORE_CAP, project.basePath) + edt(project) { restore(manager, id, title, entries, select = !quiet && index == ids.lastIndex) } } } } + private fun restore(manager: ChatSessionManager, id: String, title: String?, entries: List, select: Boolean) { + val s = manager.create() + s.title = title ?: s.title + s.persistence.restore(id, entries) + registry.open(s, select) + } + fun renameActiveSession() { val session = activeSession() ?: return val input = Messages.showInputDialog( diff --git a/src/main/kotlin/dev/lain/claudejb/controller/commands/git/IdeActionInvoker.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/IdeActionInvoker.kt index 79b9e438..5c7931f1 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/commands/git/IdeActionInvoker.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/IdeActionInvoker.kt @@ -1,6 +1,5 @@ package dev.lain.claudejb.controller.commands.git -import com.intellij.ide.DataManager import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.ActionPlaces import com.intellij.openapi.actionSystem.ActionUiKind @@ -10,7 +9,6 @@ import com.intellij.openapi.actionSystem.impl.SimpleDataContext import com.intellij.openapi.project.Project import dev.lain.claudejb.util.logger import dev.lain.claudejb.view.git.JcefGitData -import dev.lain.claudejb.view.window.ClaudeToolWindowFactory internal object IdeActionInvoker { @@ -21,15 +19,9 @@ internal object IdeActionInvoker { LOG.warn("This IDE has no action '$actionId'; the Git view's '$gitActionId' button does nothing") return JcefGitData.ActionState.FAILED } - val component = ClaudeToolWindowFactory.contextComponent(project) - val context = if (component != null) { - DataManager.getInstance().getDataContext(component) - } else { - SimpleDataContext.getProjectContext(project) - } val event = AnActionEvent.createEvent( target, - context, + SimpleDataContext.getProjectContext(project), null, ActionPlaces.TOOLWINDOW_CONTENT, ActionUiKind.TOOLBAR, diff --git a/src/main/kotlin/dev/lain/claudejb/model/bridge/JcefBridge.kt b/src/main/kotlin/dev/lain/claudejb/model/bridge/JcefBridge.kt index 5417df27..cbef8d18 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/bridge/JcefBridge.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/bridge/JcefBridge.kt @@ -113,9 +113,8 @@ object JcefBridge { "attachPath" -> Msg.AttachPath(f.text("path")) "attachSelection" -> Msg.AttachSelection "attachCurrentFile" -> Msg.AttachCurrentFile - "pasteClipboardImage" -> Msg.PasteClipboardImage(f.bool("notify")) - "pasteClipboard" -> Msg.PasteClipboard "attach" -> Msg.Attach(f.text("name"), f.text("mediaType"), f.text("base64")) + "attachImageData" -> Msg.AttachImageData(f.text("mime"), f.text("base64")) "treeChildren" -> Msg.TreeChildren(f.text("path"), f.text("mode")) "treeExpand" -> Msg.TreeExpand(f.text("path"), f.text("mode")) "attachPaths" -> Msg.AttachPaths(f.strings("paths")) diff --git a/src/main/kotlin/dev/lain/claudejb/model/bridge/Msg.kt b/src/main/kotlin/dev/lain/claudejb/model/bridge/Msg.kt index 1867dd07..d7043088 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/bridge/Msg.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/bridge/Msg.kt @@ -84,9 +84,8 @@ sealed interface Msg { data class AttachPath(val path: String) : Attachments object AttachSelection : Attachments object AttachCurrentFile : Attachments - data class PasteClipboardImage(val notify: Boolean) : Attachments - object PasteClipboard : Attachments data class Attach(val name: String, val mediaType: String, val base64: String) : Attachments + data class AttachImageData(val mime: String, val base64: String) : Attachments object McpRefresh : SessionControl data class McpReconnect(val name: String) : SessionControl diff --git a/src/main/kotlin/dev/lain/claudejb/view/feed/AttachmentTray.kt b/src/main/kotlin/dev/lain/claudejb/view/feed/AttachmentTray.kt index 6405b2eb..19efff6d 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/feed/AttachmentTray.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/feed/AttachmentTray.kt @@ -2,21 +2,19 @@ package dev.lain.claudejb.view.feed import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType -import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project import dev.lain.claudejb.controller.context.EditorContextProvider import dev.lain.claudejb.controller.context.FilePickerHelper import dev.lain.claudejb.model.context.Attachment +import dev.lain.claudejb.rpc.PagePush import dev.lain.claudejb.util.PluginIdentity -import dev.lain.claudejb.util.edt import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put internal class AttachmentTray( private val project: Project, - private val exec: (String) -> Unit, + private val emit: (PagePush) -> Unit, private val focusInput: () -> Unit, ) { @@ -69,7 +67,7 @@ internal class AttachmentTray( fun addSelection() = EditorContextProvider.selectionAsAttachment(project)?.let { add(it) } - fun push() = exec("window.cc.attachments && window.cc.attachments(" + json() + ")") + fun push() = emit(PagePush("attachments", json())) fun pushMenuData() { val recent = FilePickerHelper.recentFiles(project, RECENT_FILES_LIMIT).map { path -> @@ -84,48 +82,7 @@ internal class AttachmentTray( put("hasSelection", EditorContextProvider.currentSelection(project) != null) put("hasFile", EditorContextProvider.currentFilePath(project) != null) } - exec("window.cc.attachData && window.cc.attachData($payload)") - } - - fun pasteFromClipboard() { - offEdt { - val img = EditorContextProvider.imageFromClipboard() - val text = if (img == null) EditorContextProvider.clipboardText() else null - val help = if (img == null && text.isNullOrEmpty()) EditorContextProvider.clipboardImageHelp() else null - edt { - when { - img != null -> add(img) - - !text.isNullOrEmpty() -> - exec("window.cc.insertText && window.cc.insertText(" + JsonPrimitive(text) + ")") - - else -> notify( - if (help != null) "Couldn't read the clipboard — $help" else "Clipboard is empty or unreadable.", - ) - } - } - } - } - - fun pasteImageFromClipboard(alwaysNotify: Boolean) { - offEdt { - val img = EditorContextProvider.imageFromClipboard() - val shouldNotify = img == null && (alwaysNotify || !EditorContextProvider.clipboardHasText()) - val help = if (shouldNotify) EditorContextProvider.clipboardImageHelp() else null - edt { - when { - img != null -> add(img) - - shouldNotify -> notify( - if (help != null) { - "Couldn't read an image from the clipboard — $help" - } else { - "No image found in the clipboard." - }, - ) - } - } - } + emit(PagePush("attachData", payload.toString())) } fun notify(message: String) { @@ -152,8 +109,6 @@ internal class AttachmentTray( }, ).toString() - private fun offEdt(block: () -> Unit) = ApplicationManager.getApplication().executeOnPooledThread(block) - private companion object { const val RECENT_FILES_LIMIT = 14 } diff --git a/src/main/kotlin/dev/lain/claudejb/view/feed/ChatAgentTabs.kt b/src/main/kotlin/dev/lain/claudejb/view/feed/ChatAgentTabs.kt index 6051b2e9..86b7221c 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/feed/ChatAgentTabs.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/feed/ChatAgentTabs.kt @@ -3,25 +3,22 @@ package dev.lain.claudejb.view.feed import com.intellij.notification.NotificationAction import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType -import com.intellij.openapi.wm.ToolWindowManager import dev.lain.claudejb.controller.session.history.PluginAgentIndex import dev.lain.claudejb.model.bridge.JcefBridge import dev.lain.claudejb.model.bridge.Msg +import dev.lain.claudejb.model.session.agents.AgentStatus import dev.lain.claudejb.model.settings.ClaudeSettings -import dev.lain.claudejb.util.logger +import dev.lain.claudejb.rpc.ChatId import dev.lain.claudejb.view.payload.chat.JcefTabsData -import dev.lain.claudejb.view.window.ClaudeToolWindowFactory -import dev.lain.claudejb.view.window.JcefChatPanel +import dev.lain.claudejb.view.window.ChatPresenter -internal class ChatAgentTabs(private val panel: JcefChatPanel) { +internal class ChatAgentTabs(private val presenter: ChatPresenter) { - private val project get() = panel.project - private val session get() = panel.session + private val project get() = presenter.project + private val session get() = presenter.session private val hiddenAgents = HashSet() - private var chats: List = emptyList() - init { session.sessionId?.let { id -> val index = PluginAgentIndex.getInstance(project) @@ -30,41 +27,30 @@ internal class ChatAgentTabs(private val panel: JcefChatPanel) { } fun render() { - val strip = panel.chatStrip() - val chats = strip?.chatList() ?: this.chats - if (chats.isEmpty()) { - LOG.warn("Claude Code tab bar: nothing to draw (strip=${strip != null}, cached=${this.chats.size})") - } val windowMinutes = ClaudeSettings.getInstance(project).workloadWindowMinutes - panel.host.exec( - "window.cc.tabs && window.cc.tabs(" + - JcefTabsData.tabsJson( - session, - chats, - hiddenAgents, - windowMinutes, - System.currentTimeMillis(), - ) + ")", + presenter.exec( + "tabs", + JcefTabsData.tabsJson( + session, + presenter.registry.chatList(), + hiddenAgents, + windowMinutes, + System.currentTimeMillis(), + ), ) } - fun setChats(list: List) { - chats = list - render() - } - fun onAgentsScanned(freshlyAdmitted: List) { render() val fresh = freshlyAdmitted .filterNot { it in hiddenAgents } - .filter { session.runningAgents.nodes[it]?.status == dev.lain.claudejb.model.session.agents.AgentStatus.RUNNING } + .filter { session.runningAgents.nodes[it]?.status == AgentStatus.RUNNING } if (fresh.isEmpty()) return notifyAgentsSpawned(fresh) } private fun notifyAgentsSpawned(fresh: List) { - val tw = ToolWindowManager.getInstance(project).getToolWindow(ClaudeToolWindowFactory.TOOL_WINDOW_ID) - if (tw != null && tw.isVisible && panel.isShowing) return + if (presenter.registry.onScreen(presenter)) return val names = fresh.mapNotNull { session.runningAgents.nodes[it]?.meta?.label() } val text = when { names.size == 1 -> "Agent started in \"${session.title}\": ${names.first()}" @@ -75,26 +61,25 @@ internal class ChatAgentTabs(private val panel: JcefChatPanel) { .createNotification("Claude Code", text, NotificationType.INFORMATION) .addAction( NotificationAction.createSimpleExpiring("Open") { + presenter.registry.reveal(presenter) fresh.firstOrNull()?.let { revealAgent(it) } - ToolWindowManager.getInstance(project).getToolWindow(ClaudeToolWindowFactory.TOOL_WINDOW_ID)?.activate(null) }, ) .notify(project) } - fun revealElsewhere(chatId: String, reveal: (JcefChatPanel) -> Unit) { - val strip = panel.chatStrip() - val target = chatId.takeIf { it.isNotBlank() }?.let { strip?.panelOf(it) } - if (target == null || target === panel) { - reveal(panel) + fun revealElsewhere(chatId: String, reveal: (ChatPresenter) -> Unit) { + val target = chatId.takeIf { it.isNotBlank() }?.let { presenter.registry.presenter(ChatId(it)) } + if (target == null || target === presenter) { + reveal(presenter) return } - strip?.selectById(chatId) + presenter.registry.select(target.id) reveal(target) } fun revealFromHost(m: Msg.RevealAgent) { - resolveAgentId(m)?.let { revealAgent(it) } ?: panel.transcript.showTranscript(null) + resolveAgentId(m)?.let { revealAgent(it) } ?: presenter.transcript.showTranscript(null) } private fun revealAgent(agentId: String) { @@ -102,16 +87,14 @@ internal class ChatAgentTabs(private val panel: JcefChatPanel) { session.sessionId?.let { PluginAgentIndex.getInstance(project).setTabOpen(it, agentId, true) } render() } - panel.host.exec( - "window.cc.revealAgentTab && window.cc.revealAgentTab(" + JcefBridge.jsString(agentId) + ")", - ) - panel.transcript.showTranscript(agentId) + presenter.exec("revealAgentTab", JcefBridge.jsString(agentId)) + presenter.transcript.showTranscript(agentId) } fun closeAgent(agentId: String) { hiddenAgents += agentId session.sessionId?.let { PluginAgentIndex.getInstance(project).setTabOpen(it, agentId, false) } - panel.transcript.showTranscript(null) + presenter.transcript.showTranscript(null) render() } @@ -120,8 +103,4 @@ internal class ChatAgentTabs(private val panel: JcefChatPanel) { val tool = m.toolUseId.takeIf { it.isNotBlank() } ?: return null return session.runningAgents.nodes.values.firstOrNull { it.meta.toolUseId == tool }?.agentId } - - private companion object { - val LOG = logger() - } } diff --git a/src/main/kotlin/dev/lain/claudejb/view/feed/ChatTranscriptView.kt b/src/main/kotlin/dev/lain/claudejb/view/feed/ChatTranscriptView.kt index 6f2b27ee..9b84eea4 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/feed/ChatTranscriptView.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/feed/ChatTranscriptView.kt @@ -4,15 +4,19 @@ import dev.lain.claudejb.controller.session.AttentionLanding import dev.lain.claudejb.controller.session.ClaudeSession import dev.lain.claudejb.controller.session.guard.GuardRestore import dev.lain.claudejb.model.bridge.JcefBridge +import dev.lain.claudejb.model.session.agents.AgentStatus import dev.lain.claudejb.model.session.transcript.EntryDTO import dev.lain.claudejb.model.session.transcript.TranscriptEntry import dev.lain.claudejb.model.session.transcript.TranscriptModel +import dev.lain.claudejb.rpc.PagePush import dev.lain.claudejb.view.payload.chat.JcefTranscriptPayload +import dev.lain.claudejb.view.window.PushStream.Companion.NO_ARGS +import kotlinx.serialization.json.JsonArray import javax.swing.Timer internal class ChatTranscriptView( private val session: ClaudeSession, - private val exec: (String) -> Unit, + private val emit: (PagePush) -> Unit, ) : TranscriptModel.Listener { private sealed interface Shown { @@ -24,11 +28,15 @@ internal class ChatTranscriptView( private val dirty = LinkedHashSet() private var structural = false private val timer = Timer(ELAPSED_TICK_MS) { onTick() }.apply { isRepeats = true } + private val deltas = TranscriptDeltas() + private val appended = LinkedHashMap>() private var shown: Shown = Shown.Chat private var lastRows: List = emptyList() + private var lastTask: Pair? = null + val showsTask: Boolean get() = shown is Shown.Task val showsChat: Boolean get() = shown is Shown.Chat @@ -40,24 +48,34 @@ internal class ChatTranscriptView( fun showBackgroundTask(taskId: String) = show(Shown.Task(taskId)) private fun show(next: Shown) { - exec("window.cc.closeDashboard && window.cc.closeDashboard()") + exec("closeDashboard", NO_ARGS) if (shown == next) return shown = next + resync() + } + + fun resync() { dirty.clear() + appended.clear() + deltas.reset() lastRows = emptyList() - exec("window.cc.clear && window.cc.clear()") - when (next) { + lastTask = null + exec("clear", NO_ARGS) + when (val current = shown) { is Shown.Chat -> { - exec("window.cc.clearAgentSelection && window.cc.clearAgentSelection()") + exec("clearAgentSelection", NO_ARGS) fullResync() trimNotice(emptyList(), session.transcript.trimmedCount) } - is Shown.Agent -> pushEntries(agentEntries(next.id)) + is Shown.Agent -> { + exec("revealAgentTab", JcefBridge.jsString(current.id)) + pushEntries(agentEntries(current.id)) + } is Shown.Task -> { - exec("window.cc.revealTaskTab && window.cc.revealTaskTab(" + JcefBridge.jsString(next.id) + ")") - pushEntries(BackgroundTaskView.entries(session, next.id), expanded = true) + exec("revealTaskTab", JcefBridge.jsString(current.id)) + refreshTask(current.id) } } } @@ -66,10 +84,18 @@ internal class ChatTranscriptView( when (val current = shown) { is Shown.Chat -> Unit is Shown.Agent -> pushEntries(agentEntries(current.id)) - is Shown.Task -> pushEntries(BackgroundTaskView.entries(session, current.id), expanded = true) + is Shown.Task -> refreshTask(current.id) } } + private fun refreshTask(taskId: String) { + val task = session.backgroundTaskRegistry.taskOf(taskId) + val signature = task?.let { it.output.length to it.running } + if (signature != null && signature == lastTask) return + lastTask = signature + pushEntries(BackgroundTaskView.entries(session, taskId), expanded = true) + } + private fun agentEntries(agentId: String): List { val entries = session.runningAgents.nodes[agentId]?.entries.orEmpty() return GuardRestore.reinstate(entries, session.guard.alertsAnchoredIn(entries)) @@ -87,27 +113,24 @@ internal class ChatTranscriptView( session.runningAgents.nodes.values.forEach { node -> val tool = node.meta.toolUseId ?: return@forEach node.meta.description?.takeIf { it.isNotBlank() }?.let { titles[tool] = "${node.kindLabel} ($it)" } - if (node.status == dev.lain.claudejb.model.session.agents.AgentStatus.RUNNING) running += tool + if (node.status == AgentStatus.RUNNING) running += tool } val ownerRunning = when (val current = shown) { - is Shown.Agent -> session.runningAgents.nodes[current.id]?.status == - dev.lain.claudejb.model.session.agents.AgentStatus.RUNNING - + is Shown.Agent -> session.runningAgents.nodes[current.id]?.status == AgentStatus.RUNNING is Shown.Task -> session.backgroundTaskRegistry.all.firstOrNull { it.taskId == current.id }?.running == true - else -> false } val rows = JcefTranscriptPayload.agentRowsJson(entries, titles, running, expanded, ownerRunning) if (rows == lastRows) return if (rows.size < lastRows.size) { lastRows = rows - exec("window.cc.clear && window.cc.clear()") - if (rows.isNotEmpty()) exec("window.cc.batch && window.cc.batch([${rows.joinToString(",")}])") + exec("clear", NO_ARGS) + if (rows.isNotEmpty()) exec("batch", "[${rows.joinToString(",")}]") return } val changed = rows.filterIndexed { index, row -> index >= lastRows.size || lastRows[index] != row } lastRows = rows - if (changed.isNotEmpty()) exec("window.cc.batch && window.cc.batch([${changed.joinToString(",")}])") + if (changed.isNotEmpty()) exec("batch", "[${changed.joinToString(",")}]") } override fun onAdded(entry: TranscriptEntry, index: Int) { @@ -118,24 +141,32 @@ internal class ChatTranscriptView( override fun onUpdated(entry: TranscriptEntry) { dirty.add(entry.id) + appended.remove(entry.id) + ensureTimer() + } + + override fun onAppended(entry: TranscriptEntry, delta: String) { + if (entry.id !in dirty) appended.getOrPut(entry.id) { entry to StringBuilder() }.second.append(delta) ensureTimer() } override fun onCleared() { dirty.clear() + appended.clear() structural = false - exec("window.cc.clear && window.cc.clear()") + deltas.reset() + exec("clear", NO_ARGS) } override fun onTrimmed(removedIds: List, totalTrimmed: Int) { dirty.removeAll(removedIds.toSet()) + appended.keys.removeAll(removedIds.toSet()) + deltas.forget(removedIds) if (shown == Shown.Chat) trimNotice(removedIds, totalTrimmed) } - private fun trimNotice(removedIds: List, totalTrimmed: Int) { - val ids = removedIds.joinToString(",") - exec("window.cc.trimRows && window.cc.trimRows({ids:[$ids],total:$totalTrimmed})") - } + private fun trimNotice(removedIds: List, totalTrimmed: Int) = + exec("trimRows", JcefTranscriptPayload.trimJson(removedIds, totalTrimmed)) private fun ensureTimer() { if (!timer.isRunning) timer.start() @@ -144,6 +175,7 @@ internal class ChatTranscriptView( private fun onTick() { if (shown != Shown.Chat) { dirty.clear() + appended.clear() structural = true timer.stop() return @@ -151,20 +183,28 @@ internal class ChatTranscriptView( val entries = session.transcript.entries val items: List> = if (structural) { structural = false + appended.clear() entries.mapIndexed { index, entry -> entry to index } } else { val idToIndex = HashMap(entries.size) entries.forEachIndexed { index, entry -> idToIndex[entry.id] = index } - dirty.mapNotNull { id -> - val idx = idToIndex[id] ?: return@mapNotNull null - entries[idx] to idx - } + dirty.mapNotNull { id -> idToIndex[id]?.let { entries[it] to it } } } dirty.clear() - if (items.isNotEmpty()) { - exec("window.cc.batch && window.cc.batch(" + JcefTranscriptPayload.batchJson(items) + ")") + val split = deltas.split(items) + if (split.rows.isNotEmpty()) exec("batch", JsonArray(split.rows).toString()) + split.appends.forEach { exec("append", JcefTranscriptPayload.appendJson(it.id, it.delta)) } + flushAppends() + if (!structural && dirty.isEmpty()) timer.stop() + } + + private fun flushAppends() { + val grown = appended.values.toList() + appended.clear() + for ((entry, delta) in grown) { + val text = delta.toString() + if (deltas.grew(entry, text)) exec("append", JcefTranscriptPayload.appendJson(entry.id, text)) else dirty.add(entry.id) } - if (!structural) timer.stop() } fun fullResync() { @@ -174,6 +214,8 @@ internal class ChatTranscriptView( fun stop() = timer.stop() + private fun exec(method: String, json: String) = emit(PagePush(method, json)) + private companion object { const val ELAPSED_TICK_MS = 30 } diff --git a/src/main/kotlin/dev/lain/claudejb/view/feed/SecurityViews.kt b/src/main/kotlin/dev/lain/claudejb/view/feed/SecurityViews.kt index afbb06a6..e485e4af 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/feed/SecurityViews.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/feed/SecurityViews.kt @@ -1,21 +1,23 @@ package dev.lain.claudejb.view.feed import dev.lain.claudejb.controller.vuln.VulnService -import dev.lain.claudejb.view.window.JcefChatPanel +import dev.lain.claudejb.view.window.ChatPresenter +import dev.lain.claudejb.view.window.ChatSnapshots.Kind +import dev.lain.claudejb.view.window.PushStream -internal class SecurityViews(private val panel: JcefChatPanel) { +internal class SecurityViews(private val presenter: ChatPresenter) { - fun pushGuard() = panel.guard.push() + fun pushGuard() = presenter.guard.push() - fun pushVuln() = VulnService.getInstance(panel.project).refresh(panel::pushSession) + fun pushVuln() = VulnService.getInstance(presenter.project).refresh { presenter.snapshots.mark(Kind.SESSION) } fun openGuardView() { pushGuard() - panel.host.exec("window.cc.openGuardView && window.cc.openGuardView()") + presenter.exec("openGuardView", PushStream.NO_ARGS) } fun showVulnView() { pushVuln() - panel.host.exec("window.cc.showVulnView && window.cc.showVulnView()") + presenter.exec("showVulnView", PushStream.NO_ARGS) } } diff --git a/src/main/kotlin/dev/lain/claudejb/view/feed/SessionFeed.kt b/src/main/kotlin/dev/lain/claudejb/view/feed/SessionFeed.kt index 789862e9..03e7e02b 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/feed/SessionFeed.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/feed/SessionFeed.kt @@ -5,13 +5,14 @@ import dev.lain.claudejb.controller.session.control.PlanInfo import dev.lain.claudejb.model.protocol.models.UsageReport import dev.lain.claudejb.model.protocol.parse.afterResets import dev.lain.claudejb.model.protocol.parse.mergedOver +import dev.lain.claudejb.rpc.PagePush import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonPrimitive import javax.swing.Timer internal class SessionFeed( private val session: ClaudeSession, - private val exec: (String) -> Unit, + private val emit: (PagePush) -> Unit, private val onRefreshed: () -> Unit, ) { @@ -56,7 +57,7 @@ internal class SessionFeed( fun requestMcp() { session.queries.requestMcpStatus { json -> - if (json != null) exec("window.cc.mcp && window.cc.mcp($json)") + if (json != null) emit(PagePush("mcp", json.toString())) } } diff --git a/src/main/kotlin/dev/lain/claudejb/view/feed/TranscriptDeltas.kt b/src/main/kotlin/dev/lain/claudejb/view/feed/TranscriptDeltas.kt new file mode 100644 index 00000000..9baf93dd --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/view/feed/TranscriptDeltas.kt @@ -0,0 +1,54 @@ +package dev.lain.claudejb.view.feed + +import dev.lain.claudejb.model.session.transcript.ToolState +import dev.lain.claudejb.model.session.transcript.TranscriptEntry +import dev.lain.claudejb.view.payload.chat.JcefTranscriptPayload +import kotlinx.serialization.json.JsonObject + +internal class TranscriptDeltas { + + data class Append(val id: Long, val delta: String) + + class Split(val rows: List, val appends: List) + + private class Sent(val text: StringBuilder, val rest: JsonObject) + + private val sent = HashMap() + + fun split(items: List>): Split { + val rows = ArrayList() + val appends = ArrayList() + for ((entry, order) in items) { + val row = JcefTranscriptPayload.entryJson(entry, order) + val text = entry.text + val rest = JsonObject(row - TEXT) + val previous = sent[entry.id] + when { + previous == null || previous.rest != rest -> rows += row + text.contentEquals(previous.text) -> Unit + entry.toolState == ToolState.RUNNING && text.length > previous.text.length && text.startsWith(previous.text) -> + appends += Append(entry.id, text.substring(previous.text.length)) + else -> rows += row + } + sent[entry.id] = Sent(StringBuilder(text), rest) + } + return Split(rows, appends) + } + + fun grew(entry: TranscriptEntry, delta: String): Boolean { + if (entry.toolState != ToolState.RUNNING) return false + val previous = sent[entry.id] ?: return false + previous.text.append(delta) + return true + } + + fun forget(ids: Collection) { + ids.forEach(sent::remove) + } + + fun reset() = sent.clear() + + private companion object { + const val TEXT = "text" + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/view/git/ChatGitRefresh.kt b/src/main/kotlin/dev/lain/claudejb/view/git/ChatGitRefresh.kt new file mode 100644 index 00000000..9834924b --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/view/git/ChatGitRefresh.kt @@ -0,0 +1,31 @@ +package dev.lain.claudejb.view.git + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.util.Alarm +import dev.lain.claudejb.controller.commands.git.GitIntegration +import dev.lain.claudejb.controller.git.GitHistoryService + +internal class ChatGitRefresh( + private val project: Project, + parent: Disposable, + private val onRefreshed: () -> Unit, +) { + + private val alarm = Alarm(Alarm.ThreadToUse.SWING_THREAD, parent) + + init { + project.service().onRepositoryChanged(parent) { request() } + } + + fun request() { + if (alarm.isDisposed) return + alarm.cancelAllRequests() + alarm.addRequest({ if (!project.isDisposed) GitIntegration.getInstance(project).refresh(onRefreshed) }, DEBOUNCE_MS) + } + + private companion object { + const val DEBOUNCE_MS = 500 + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/view/git/GitChatFeed.kt b/src/main/kotlin/dev/lain/claudejb/view/git/GitChatFeed.kt index f738a5c2..242caed7 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/git/GitChatFeed.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/git/GitChatFeed.kt @@ -2,15 +2,19 @@ package dev.lain.claudejb.view.git import dev.lain.claudejb.controller.commands.git.GitChatConversation import dev.lain.claudejb.controller.session.ClaudeSession +import dev.lain.claudejb.model.bridge.JcefBridge +import dev.lain.claudejb.rpc.PagePush import dev.lain.claudejb.view.payload.chat.JcefCardPayload -import dev.lain.claudejb.view.window.JcefChatPanel +import dev.lain.claudejb.view.window.ChatPresenter +import dev.lain.claudejb.view.window.ChatSnapshots.Kind +import dev.lain.claudejb.view.window.PushStream internal class GitChatFeed( - private val panel: JcefChatPanel, - private val exec: (String) -> Unit, + private val presenter: ChatPresenter, + private val emit: (PagePush) -> Unit, ) : GitChatConversation.View { - private val conversation = GitChatConversation.getInstance(panel.project) + private val conversation = GitChatConversation.getInstance(presenter.project) private var lastPushed: String? = null @@ -27,18 +31,18 @@ internal class GitChatFeed( fun permissionGroup(): List = conversation.permissionGroup() fun show() { - exec("window.CC && CC.dash && CC.dash.setGitSubView && CC.dash.setGitSubView('chat')") - exec("window.cc.showGitView && window.cc.showGitView()") + emit(PagePush("setGitSubView", JcefBridge.jsString("chat"))) + emit(PagePush("showGitView", PushStream.NO_ARGS)) } override fun drawGitChat(payload: String?) { val json = payload ?: "null" if (json == lastPushed) return lastPushed = json - exec("window.cc.gitChat && window.cc.gitChat($json)") + emit(PagePush("gitChat", json)) } - override fun refreshGitChatPermissions() = panel.pushPermissions() + override fun refreshGitChatPermissions() = presenter.snapshots.mark(Kind.PERMISSIONS) fun dispose() = conversation.detach(this) } diff --git a/src/main/kotlin/dev/lain/claudejb/view/guard/GuardFeed.kt b/src/main/kotlin/dev/lain/claudejb/view/guard/GuardFeed.kt index dcd526c0..d34aed9e 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/guard/GuardFeed.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/guard/GuardFeed.kt @@ -9,16 +9,15 @@ import dev.lain.claudejb.model.settings.guard.GuardAlert import dev.lain.claudejb.model.settings.guard.GuardAlertLog import dev.lain.claudejb.util.edt import dev.lain.claudejb.util.logger -import dev.lain.claudejb.view.guard.JcefGuardData -import dev.lain.claudejb.view.window.JcefChatPanel +import dev.lain.claudejb.view.window.ChatPresenter -internal class GuardFeed(private val panel: JcefChatPanel) { +internal class GuardFeed(private val presenter: ChatPresenter) { fun push() { val scope = scope() - val sessionId = panel.session.sessionId - val recorded = panel.session.guard.guardLog.recorded - val dropped = panel.session.guard.guardLog.dropped + val sessionId = presenter.session.sessionId + val recorded = presenter.session.guard.guardLog.recorded + val dropped = presenter.session.guard.guardLog.dropped offEdt { val json = JcefGuardData.guardJson( alerts = read(scope, sessionId), @@ -27,27 +26,27 @@ internal class GuardFeed(private val panel: JcefChatPanel) { recording = !SecretStore.inert(), max = GuardAlertLog.MAX_ENTRIES, ) - edt(panel.project) { panel.host.exec("window.cc.guard && window.cc.guard($json)") } + edt(presenter.project) { presenter.exec("guard", json) } } } fun explain(id: String) { val scope = scope() - val sessionId = panel.session.sessionId + val sessionId = presenter.session.sessionId offEdt { val alert = read(scope, sessionId).firstOrNull { JcefGuardData.idOf(it) == id } val prompt = alert?.let(GuardPromptedActions::explainBlockPrompt) - edt(panel.project) { + edt(presenter.project) { if (prompt == null) { - panel.session.systemNotice(GuardPromptedActions.ENTRY_GONE) + presenter.session.systemNotice(GuardPromptedActions.ENTRY_GONE) } else { - panel.session.sendSideQuestion(prompt) + presenter.session.sendSideQuestion(prompt) } } } } - private fun scope(): SettingsScope = ClaudeSettings.getInstance(panel.project).scope + private fun scope(): SettingsScope = ClaudeSettings.getInstance(presenter.project).scope private fun read(scope: SettingsScope, sessionId: String?): List { if (sessionId.isNullOrBlank()) return emptyList() diff --git a/src/main/kotlin/dev/lain/claudejb/view/log/LogFeed.kt b/src/main/kotlin/dev/lain/claudejb/view/log/LogFeed.kt index 3af22dfc..23df5163 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/log/LogFeed.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/log/LogFeed.kt @@ -2,32 +2,29 @@ package dev.lain.claudejb.view.log import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.util.SystemInfo import dev.lain.claudejb.util.LogRing import dev.lain.claudejb.util.PluginIdentity import dev.lain.claudejb.util.PluginLog import dev.lain.claudejb.util.edt import dev.lain.claudejb.util.logger -import dev.lain.claudejb.view.log.JcefLogData -import dev.lain.claudejb.view.window.JcefChatPanel -import java.awt.datatransfer.StringSelection +import dev.lain.claudejb.view.window.ChatPresenter -internal class LogFeed(private val panel: JcefChatPanel) { +internal class LogFeed(private val presenter: ChatPresenter) { fun push(since: Long) = offEdt { val json = JcefLogData.logJson(LogRing.since(since), PluginLog.debugOn) - edt(panel.project) { panel.host.exec("window.cc.log && window.cc.log($json)") } + edt(presenter.project) { presenter.exec("log", json) } } fun setDebug(on: Boolean) = PluginLog.setDebug(on) fun copy(level: String) { - val binary = panel.session.catalog.binaryVersion ?: "unknown" + val binary = presenter.session.catalog.binaryVersion ?: "unknown" offEdt { val lines = LogRing.snapshot(JcefLogData.levelOf(level)) val text = JcefLogData.reportText(lines, header(binary, level, lines.size, LogRing.since(-1).dropped)) - edt(panel.project) { CopyPasteManager.getInstance().setContents(StringSelection(text)) } + edt(presenter.project) { presenter.copyToClient(text) } } } diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/ChatAttention.kt b/src/main/kotlin/dev/lain/claudejb/view/window/ChatAttention.kt new file mode 100644 index 00000000..4637c900 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/view/window/ChatAttention.kt @@ -0,0 +1,50 @@ +package dev.lain.claudejb.view.window + +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.project.Project +import dev.lain.claudejb.controller.session.AttentionLanding +import dev.lain.claudejb.controller.session.AttentionReason + +internal class ChatAttention(private val project: Project, private val registry: ChatRegistry) { + + fun on(presenter: ChatPresenter, reason: AttentionReason, landing: AttentionLanding) { + if (registry.onScreen(presenter) && showsWhereItLanded(presenter, reason, landing)) return + + registry.badge(presenter) + + val now = System.currentTimeMillis() + if (now - presenter.lastNotified <= NOTIFY_THROTTLE_MS) return + presenter.lastNotified = now + + val title = presenter.session.title + val text = when (reason) { + AttentionReason.PERMISSION -> "Claude needs your approval in \"$title\"." + AttentionReason.TURN_DONE -> "Claude finished responding in \"$title\"." + AttentionReason.ERROR -> "Claude hit an error in \"$title\"." + AttentionReason.GUARD_BLOCKED -> "The guard blocked a tool call in \"$title\"." + } + NotificationGroupManager.getInstance().getNotificationGroup("Claude Code") + .createNotification("Claude Code", text, typeFor(reason)) + .addAction( + NotificationAction.createSimpleExpiring("Open") { + registry.presenter(presenter.id)?.let(registry::reveal) + }, + ) + .notify(project) + } + + private fun showsWhereItLanded(presenter: ChatPresenter, reason: AttentionReason, landing: AttentionLanding): Boolean = + reason != AttentionReason.GUARD_BLOCKED || presenter.transcript.shows(landing) + + private fun typeFor(reason: AttentionReason): NotificationType = when (reason) { + AttentionReason.ERROR -> NotificationType.ERROR + AttentionReason.GUARD_BLOCKED -> NotificationType.WARNING + else -> NotificationType.INFORMATION + } + + private companion object { + const val NOTIFY_THROTTLE_MS = 3000L + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/ChatDeck.kt b/src/main/kotlin/dev/lain/claudejb/view/window/ChatDeck.kt new file mode 100644 index 00000000..346f2d7b --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/view/window/ChatDeck.kt @@ -0,0 +1,69 @@ +package dev.lain.claudejb.view.window + +import dev.lain.claudejb.rpc.ChatEvent +import dev.lain.claudejb.rpc.ChatId +import dev.lain.claudejb.rpc.ChatRef +import java.util.concurrent.CopyOnWriteArrayList + +internal class ChatDeck( + private val idOf: (T) -> ChatId, + private val refOf: (T) -> ChatRef, +) { + + private val items = CopyOnWriteArrayList() + private val listeners = CopyOnWriteArrayList<(ChatEvent) -> Unit>() + + @Volatile + var selected: T? = null + private set + + fun all(): List = items.toList() + + fun isEmpty(): Boolean = items.isEmpty() + + fun find(id: ChatId): T? = items.firstOrNull { idOf(it) == id } + + fun add(item: T, select: Boolean): Boolean { + items += item + val shown = select || selected == null + if (shown) selected = item + fire(ChatEvent.Opened(refOf(item), shown)) + return shown + } + + fun select(id: ChatId): T? { + val item = find(id) ?: return null + selected = item + fire(ChatEvent.Selected(id)) + return item + } + + fun remove(id: ChatId): T? { + val item = find(id) ?: return null + items.remove(item) + if (selected === item) { + selected = null + items.firstOrNull()?.let { select(idOf(it)) } + } + fire(ChatEvent.Closed(id)) + return item + } + + fun renamed(item: T) { + if (item in items) fire(ChatEvent.Renamed(refOf(item))) + } + + fun subscribe(listener: (ChatEvent) -> Unit): () -> Unit { + items.forEach { listener(ChatEvent.Opened(refOf(it), it === selected)) } + listeners += listener + return { listeners -= listener } + } + + fun clear() { + listeners.clear() + items.clear() + selected = null + } + + private fun fire(event: ChatEvent) = listeners.forEach { it(event) } +} diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/ChatGearGroup.kt b/src/main/kotlin/dev/lain/claudejb/view/window/ChatGearGroup.kt new file mode 100644 index 00000000..e365658b --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/view/window/ChatGearGroup.kt @@ -0,0 +1,65 @@ +package dev.lain.claudejb.view.window + +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.Separator +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import dev.lain.claudejb.controller.commands.SessionDiffAction +import dev.lain.claudejb.controller.commands.git.GitContextActions +import dev.lain.claudejb.controller.commands.git.GitIdeMenu +import dev.lain.claudejb.controller.commands.git.GitPromptedActions +import dev.lain.claudejb.controller.session.ChatSessionManager +import dev.lain.claudejb.view.settings.ClaudeSettingsConfigurable + +class ChatGearGroup : ActionGroup(), DumbAware { + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun getChildren(e: AnActionEvent?): Array { + val project = e?.project ?: return EMPTY_ARRAY + if (project.isDisposed) return EMPTY_ARRAY + return entries(project, ChatRegistry.getInstance(project)).toTypedArray() + } + + private fun entries(project: Project, registry: ChatRegistry): List = buildList { + val selected = { registry.selected() } + add(simple("Session Info (Context · Cost · Account · MCP)…") { selected()?.openDashboard() }) + add(simple("Dependency Vulnerabilities…") { selected()?.security?.showVulnView() }) + add(simple("Agents") { selected()?.let { InfoDialogs.showAgents(project, it.session) } }) + add(simple("Security Guard Log (Blocked · Allowed · Whitelisted · Disabled)") { selected()?.security?.openGuardView() }) + add(SessionDiffAction(project)) + add(simple("Binary Version…") { selected()?.let { InfoDialogs.showBinaryVersion(project, it.session) } }) + add(simple("Effective Settings…") { selected()?.let { InfoDialogs.showEffectiveSettings(project, it.session) } }) + add(Separator.getInstance()) + add(simple("Rename Session…") { registry.commands.renameActiveSession() }) + add(simple("Fork Session") { registry.commands.forkActiveSession() }) + add(simple("Open Previous Session…") { registry.commands.openPreviousSession() }) + add(simple("Add Current File as @-context") { selected()?.mentionCurrentFile() }) + add(Separator.getInstance()) + addAll(GitContextActions.gearEntries(project)) + addAll( + GitPromptedActions.gearEntries(project) { + selected()?.gitChat?.session() ?: ChatSessionManager.getInstance(project).gitChatOrCreate() + }, + ) + add(GitIdeMenu.gearEntry()) + add(Separator.getInstance()) + add( + simple("Settings…") { + ShowSettingsUtil.getInstance().showSettingsDialog(project, ClaudeSettingsConfigurable::class.java) + }, + ) + } + + private fun simple(text: String, action: () -> Unit): AnAction = object : AnAction(text) { + override fun actionPerformed(e: AnActionEvent) = action() + } + + companion object { + const val ID = "ClaudeCode.ToolWindowGear" + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/ChatPresenter.kt b/src/main/kotlin/dev/lain/claudejb/view/window/ChatPresenter.kt new file mode 100644 index 00000000..50d448f0 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/view/window/ChatPresenter.kt @@ -0,0 +1,209 @@ +package dev.lain.claudejb.view.window + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import dev.lain.claudejb.controller.bridge.ChatBridgeRouter +import dev.lain.claudejb.controller.bridge.ChatLinks +import dev.lain.claudejb.controller.commands.OnboardingController +import dev.lain.claudejb.controller.session.AttentionLanding +import dev.lain.claudejb.controller.session.AttentionReason +import dev.lain.claudejb.controller.session.ClaudeSession +import dev.lain.claudejb.controller.session.SessionListener +import dev.lain.claudejb.model.bridge.JcefBridge +import dev.lain.claudejb.model.context.Attachment +import dev.lain.claudejb.rpc.ChatId +import dev.lain.claudejb.rpc.ChatRef +import dev.lain.claudejb.rpc.FrontendChannel +import dev.lain.claudejb.rpc.PagePush +import dev.lain.claudejb.util.edt +import dev.lain.claudejb.util.logger +import dev.lain.claudejb.view.feed.AttachmentTray +import dev.lain.claudejb.view.feed.ChatAgentTabs +import dev.lain.claudejb.view.feed.ChatEditReview +import dev.lain.claudejb.view.feed.ChatTranscriptView +import dev.lain.claudejb.view.feed.SecurityViews +import dev.lain.claudejb.view.feed.SessionFeed +import dev.lain.claudejb.view.git.GitChatFeed +import dev.lain.claudejb.view.guard.GuardFeed +import dev.lain.claudejb.view.log.LogFeed +import dev.lain.claudejb.view.window.ChatSnapshots.Kind + +internal class ChatPresenter( + val project: Project, + val session: ClaudeSession, + val id: ChatId, + val registry: ChatRegistry, +) : Disposable, SessionListener { + + val pushes = PushStream { edt(project) { transcript.resync() } } + + val router = ChatBridgeRouter(this) + + val links = ChatLinks(project) { exec(FrontendChannel.BROWSE, JcefBridge.jsString(it)) } + + val transcript = ChatTranscriptView(session, ::emit) + + val tray = AttachmentTray(project, ::emit, ::focusInput) + + val edits = ChatEditReview(project, session, tray::notify) + + val snapshots = ChatSnapshots(this) + + val feed = SessionFeed(session, ::emit) { snapshots.mark(Kind.META, Kind.STATE, Kind.SESSION) } + + val onboarding = OnboardingController(project, session, PageScriptCalls.into(::emit)) + + val agentTabs = ChatAgentTabs(this) + + val gitChat = GitChatFeed(this, ::emit) + + val guard = GuardFeed(this) + + val logFeed = LogFeed(this) + + val security = SecurityViews(this) + + var attention: Boolean = false + + var lastNotified: Long = 0L + + private val pendingUntilReady = mutableListOf<() -> Unit>() + + private var wasRunning = false + + private var wasTurnActive = false + + init { + agentTabs.render() + session.agentScanner.scan() + + session.transcript.addListener(transcript) + session.addListener(this) + session.login.attachUi(onboarding) + + pushVibe() + snapshots.now(*Kind.entries.toTypedArray()) + tray.push() + security.pushVuln() + whenReady(feed::onSessionReady) + feed.start() + transcript.fullResync() + registry.git.request() + } + + fun ref(): ChatRef = ChatRef(id, session.title) + + fun emit(push: PagePush) = pushes.emit(push) + + fun exec(method: String, json: String) = emit(PagePush(method, json)) + + fun execBuilt(method: String, build: () -> String?) { + ApplicationManager.getApplication().executeOnPooledThread { + val payload = runCatching(build) + .onFailure { LOG.warn("Claude Code: $method could not be answered", it) } + .getOrNull() ?: return@executeOnPooledThread + exec(method, payload) + } + } + + fun replay() = pushes.replay() + + fun refreshPage() { + feed.requestMcp() + feed.requestVersion() + agentTabs.render() + registry.git.request() + security.pushGuard() + security.pushVuln() + } + + override fun onAgentsChanged(freshlyAdmitted: List) { + agentTabs.onAgentsScanned(freshlyAdmitted) + transcript.refreshShown() + snapshots.mark(Kind.SESSION) + } + + override fun onStateChanged() { + snapshots.mark(Kind.META, Kind.STATE, Kind.SESSION, Kind.MENU, Kind.TABS) + if (transcript.showsTask) transcript.refreshShown() + drainPendingUntilReady() + val running = session.isRunning() + if (running && !wasRunning) feed.onSessionReady() + wasRunning = running + if (session.signals.rateLimits.isNotEmpty()) feed.requestUsage() + val turnActive = session.turn.active + if (wasTurnActive && !turnActive && running) { + feed.requestPlan() + registry.git.request() + } + wasTurnActive = turnActive + onboarding.onStateChanged() + } + + override fun onMetadataChanged() = snapshots.mark(Kind.META, Kind.STATE, Kind.SESSION) + + override fun onPermissionsChanged() = snapshots.mark(Kind.PERMISSIONS) + + override fun onTitleChanged() = registry.onTitleChanged(this) + + override fun onAttention(reason: AttentionReason, landing: AttentionLanding) = + registry.attention.on(this, reason, landing) + + private fun whenReady(action: () -> Unit) { + if (session.isRunning()) { + action() + return + } + pendingUntilReady += action + } + + private fun drainPendingUntilReady() { + if (pendingUntilReady.isEmpty() || !session.isRunning()) return + val queued = pendingUntilReady.toList() + pendingUntilReady.clear() + queued.forEach { it() } + } + + fun cardSession(scope: String): ClaudeSession = + if (scope == JcefBridge.SCOPE_GIT) gitChat.session() else session + + fun pushVibe() = exec(FrontendChannel.VIBE, ChatRegistry.vibe.toString()) + + fun openDashboard() { + snapshots.now(Kind.SESSION) + security.pushVuln() + feed.requestMcp() + feed.requestVersion() + feed.requestUsage() + exec("openDashboard", PushStream.NO_ARGS) + } + + fun showGitView() { + registry.git.request() + exec("showGitView", PushStream.NO_ARGS) + } + + fun focusInput() = exec(FrontendChannel.FOCUS, PushStream.NO_ARGS) + + fun copyToClient(text: String) = exec(FrontendChannel.COPY, JcefBridge.jsString(text)) + + fun mentionCurrentFile() = tray.addCurrentFile() + + fun addAttachment(attachment: Attachment) = tray.add(attachment) + + override fun dispose() { + session.transcript.removeListener(transcript) + session.removeListener(this) + session.login.detachUi(onboarding) + onboarding.dispose() + transcript.stop() + feed.stop() + gitChat.dispose() + pushes.close() + } + + private companion object { + val LOG = logger() + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/ChatRegistry.kt b/src/main/kotlin/dev/lain/claudejb/view/window/ChatRegistry.kt new file mode 100644 index 00000000..6d221d27 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/view/window/ChatRegistry.kt @@ -0,0 +1,190 @@ +package dev.lain.claudejb.view.window + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.components.serviceIfCreated +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.wm.ToolWindowManager +import dev.lain.claudejb.controller.commands.TabSessionCommands +import dev.lain.claudejb.controller.mcp.IdeMcpService +import dev.lain.claudejb.controller.session.ChatSessionManager +import dev.lain.claudejb.controller.session.ClaudeSession +import dev.lain.claudejb.model.session.launch.LaunchOptions +import dev.lain.claudejb.model.settings.ClaudeSettings +import dev.lain.claudejb.rpc.ChatEvent +import dev.lain.claudejb.rpc.ChatId +import dev.lain.claudejb.rpc.ChatRef +import dev.lain.claudejb.util.logger +import dev.lain.claudejb.view.git.ChatGitRefresh +import dev.lain.claudejb.view.payload.chat.JcefTabsData +import dev.lain.claudejb.view.payload.panel.JcefSessionData +import dev.lain.claudejb.view.window.ChatSnapshots.Kind +import java.util.UUID + +@Service(Service.Level.PROJECT) +internal class ChatRegistry(private val project: Project) : Disposable { + + private val deck = ChatDeck({ it.id }, { it.ref() }) + + private var restoring = false + + private var disposed = false + + private var prewarmed = false + + val commands = TabSessionCommands(project, this) + + val git = ChatGitRefresh(project, this) { repaint(Kind.SESSION) } + + val attention = ChatAttention(project, this) + + fun all(): List = deck.all() + + fun isEmpty(): Boolean = deck.isEmpty() + + fun selected(): ChatPresenter? = deck.selected + + fun presenter(id: ChatId): ChatPresenter? = deck.find(id) + + fun chats(): List { + if (!restoring) { + restoring = true + commands.restoreOrCreate() + } + return all().map { it.ref() } + } + + fun subscribe(listener: (ChatEvent) -> Unit): () -> Unit = deck.subscribe(listener) + + fun open(session: ClaudeSession, select: Boolean): ChatPresenter { + if (!prewarmed) { + prewarmed = true + IdeMcpService.getInstance(project).prewarm() + } + session.settings.adopt(LaunchOptions.from(ClaudeSettings.getInstance(project))) + session.start() + val presenter = ChatPresenter(project, session, ChatId(UUID.randomUUID().toString()), this) + Disposer.register(this, presenter) + deck.add(presenter, select) + deck.selected?.let { ChatSessionManager.getInstance(project).setActive(it.session) } + repaint(Kind.TABS) + return presenter + } + + fun newChat(): ChatPresenter = open(ChatSessionManager.getInstance(project).create(), true) + + fun selectedOrNew(): ChatPresenter = selected() ?: newChat() + + fun select(id: ChatId) { + val presenter = deck.select(id) ?: return LOG.warn(unknown("select", id)) + onSelected(presenter) + } + + private fun onSelected(presenter: ChatPresenter) { + presenter.attention = false + ChatSessionManager.getInstance(project).setActive(presenter.session) + runCatching { presenter.transcript.showTranscript(null) } + .onFailure { LOG.warn("Claude Code: showing '${presenter.session.title}' failed to reset its transcript", it) } + repaint(Kind.TABS) + presenter.focusInput() + } + + fun reveal(presenter: ChatPresenter) { + select(presenter.id) + showToolWindow() + } + + fun showToolWindow() { + toolWindow()?.activate(null) + } + + fun onScreen(presenter: ChatPresenter): Boolean = toolWindow()?.isVisible == true && deck.selected === presenter + + private fun toolWindow() = ToolWindowManager.getInstance(project).getToolWindow(TOOL_WINDOW_ID) + + fun close(id: ChatId) { + val wasSelected = deck.selected?.id == id + val presenter = deck.remove(id) ?: return LOG.warn(unknown("close", id)) + try { + if (wasSelected) deck.selected?.let(::onSelected) + repaint(Kind.TABS) + ChatSessionManager.getInstance(project).remove(presenter.session) + } finally { + Disposer.dispose(presenter) + replaceLastChat() + } + } + + fun onTitleChanged(presenter: ChatPresenter) { + deck.renamed(presenter) + repaint(Kind.TABS) + } + + fun badge(presenter: ChatPresenter) { + if (presenter === deck.selected) return + presenter.attention = true + repaint(Kind.TABS) + } + + fun chatList(): List = all().map { + JcefTabsData.Chat(it.id.value, tabTitle(it.session.title), it === deck.selected, it.attention) + } + + fun workloads(): List = all().map { + JcefSessionData.Workload(it.id.value, tabTitle(it.session.title), it === deck.selected, it.session) + } + + private fun repaint(kind: Kind) = all().forEach { it.snapshots.mark(kind) } + + private fun replaceLastChat() { + if (!deck.isEmpty()) return + ApplicationManager.getApplication().invokeLater( + { if (!disposed && deck.isEmpty()) newChat() }, + ModalityState.defaultModalityState(), + ) + } + + private fun unknown(gesture: String, id: ChatId): String = + "Claude Code chats: $gesture named '${id.value}', which is not an open chat. " + + "Open now: ${all().map { it.id.value to it.session.title }}" + + override fun dispose() { + disposed = true + deck.clear() + } + + companion object { + const val TOOL_WINDOW_ID = "Claude Code" + + private const val TAB_TITLE_MAX = 22 + + private val LOG = logger() + + @Volatile + var vibe: Boolean = false + private set + + fun getInstance(project: Project): ChatRegistry = project.service() + + fun everywhere(action: (ChatPresenter) -> Unit) { + ProjectManager.getInstance().openProjects.forEach { project -> + project.serviceIfCreated()?.all()?.forEach(action) + } + } + + fun repaintEverywhere(vararg kinds: Kind) = everywhere { it.snapshots.mark(*kinds) } + + fun changeVibe(on: Boolean) { + vibe = on + everywhere(ChatPresenter::pushVibe) + } + + fun tabTitle(title: String): String = + TabSessionCommands.truncate(title.trim().ifBlank { "Chat" }, TAB_TITLE_MAX) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/ChatSnapshots.kt b/src/main/kotlin/dev/lain/claudejb/view/window/ChatSnapshots.kt new file mode 100644 index 00000000..8dc9791e --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/view/window/ChatSnapshots.kt @@ -0,0 +1,91 @@ +package dev.lain.claudejb.view.window + +import dev.lain.claudejb.controller.commands.git.GitIntegration +import dev.lain.claudejb.controller.vuln.VulnService +import dev.lain.claudejb.model.settings.ClaudeSettings +import dev.lain.claudejb.util.edt +import dev.lain.claudejb.view.payload.chat.JcefCardPayload +import dev.lain.claudejb.view.payload.chat.JcefState +import dev.lain.claudejb.view.payload.menu.SettingsMenuRows +import dev.lain.claudejb.view.payload.panel.JcefSessionData +import java.util.EnumMap +import java.util.EnumSet + +internal class ChatSnapshots(private val presenter: ChatPresenter) { + + enum class Kind { META, STATE, SESSION, MENU, THEME, PERMISSIONS, TABS } + + private val dirty = EnumSet.noneOf(Kind::class.java) + private var scheduled = false + private val last = EnumMap(Kind::class.java) + + private val session get() = presenter.session + private val project get() = presenter.project + + fun mark(vararg kinds: Kind) { + synchronized(dirty) { + dirty.addAll(kinds) + if (scheduled) return + scheduled = true + } + edt(project) { flush() } + } + + fun now(vararg kinds: Kind) = kinds.forEach(::build) + + private fun flush() { + val due = synchronized(dirty) { + scheduled = false + EnumSet.copyOf(dirty).also { dirty.clear() } + } + due.forEach(::build) + } + + private fun build(kind: Kind) { + when (kind) { + Kind.META -> send(kind, "meta", JcefState.metaJson(session)) + Kind.STATE -> send(kind, "state", JcefState.stateJson(session, presenter.feed.usage)) + Kind.SESSION -> send(kind, "session", sessionJson()) + Kind.MENU -> { + send(kind, "settingsMenu", menuJson()) + build(Kind.THEME) + } + + Kind.THEME -> send(kind, "theme", themeJson()) + Kind.PERMISSIONS -> presenter.exec("permissions", permissionsJson()) + Kind.TABS -> presenter.agentTabs.render() + } + } + + private fun send(kind: Kind, method: String, json: String) { + if (last[kind] == json) return + last[kind] = json + presenter.exec(method, json) + } + + private fun sessionJson(): String = JcefSessionData.sessionJson( + session, + windowMinutes = ClaudeSettings.getInstance(project).workloadWindowMinutes, + nowMillis = System.currentTimeMillis(), + usage = presenter.feed.usage, + workloads = presenter.registry.workloads(), + plan = presenter.feed.plan, + git = GitIntegration.getInstance(project).snapshot(), + vuln = VulnService.getInstance(project).snapshot(), + ) + + private fun menuJson(): String { + val settings = ClaudeSettings.getInstance(project) + return "{\"items\":" + SettingsMenuRows.json(settings.scope.id, settings.state, session) + "}" + } + + private fun themeJson(): String = + "{\"reducedMotion\":" + ClaudeSettings.getInstance(project).reduceMotion + "}" + + private fun permissionsJson(): String { + val perms = session.cards.pending() + val groups = listOf(JcefCardPayload.Group(perms, diffByRequest = presenter.edits.diffsFor(perms))) + + presenter.gitChat.permissionGroup() + return JcefCardPayload.permissionsJson(groups) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/JcefChatPanel.kt b/src/main/kotlin/dev/lain/claudejb/view/window/JcefChatPanel.kt deleted file mode 100644 index c0c4ebae..00000000 --- a/src/main/kotlin/dev/lain/claudejb/view/window/JcefChatPanel.kt +++ /dev/null @@ -1,246 +0,0 @@ -package dev.lain.claudejb.view.window - -import com.intellij.ide.ui.LafManagerListener -import com.intellij.openapi.Disposable -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.components.service -import com.intellij.openapi.project.Project -import com.intellij.openapi.util.Disposer -import com.intellij.ui.components.JBPanel -import dev.lain.claudejb.controller.bridge.ChatBridgeRouter -import dev.lain.claudejb.controller.commands.LivePanels -import dev.lain.claudejb.controller.commands.OnboardingController -import dev.lain.claudejb.controller.commands.git.GitIntegration -import dev.lain.claudejb.controller.context.LinkNavigator -import dev.lain.claudejb.controller.git.GitHistoryService -import dev.lain.claudejb.controller.session.ClaudeSession -import dev.lain.claudejb.controller.session.IdeWithoutChat -import dev.lain.claudejb.controller.session.SessionListener -import dev.lain.claudejb.controller.vuln.VulnService -import dev.lain.claudejb.model.bridge.JcefBridge -import dev.lain.claudejb.model.context.Attachment -import dev.lain.claudejb.model.settings.ClaudeSettings -import dev.lain.claudejb.util.edt -import dev.lain.claudejb.util.logger -import dev.lain.claudejb.view.feed.AttachmentTray -import dev.lain.claudejb.view.feed.ChatAgentTabs -import dev.lain.claudejb.view.feed.ChatEditReview -import dev.lain.claudejb.view.feed.ChatTheme -import dev.lain.claudejb.view.feed.ChatTranscriptView -import dev.lain.claudejb.view.feed.SecurityViews -import dev.lain.claudejb.view.feed.SessionFeed -import dev.lain.claudejb.view.git.GitChatFeed -import dev.lain.claudejb.view.guard.GuardFeed -import dev.lain.claudejb.view.jcef.JcefHost -import dev.lain.claudejb.view.log.LogFeed -import dev.lain.claudejb.view.payload.JcefTheme -import dev.lain.claudejb.view.payload.chat.JcefCardPayload -import dev.lain.claudejb.view.payload.chat.JcefState -import dev.lain.claudejb.view.payload.menu.SettingsMenuRows -import dev.lain.claudejb.view.payload.panel.JcefSessionData -import java.awt.BorderLayout - -class JcefChatPanel(internal val project: Project, val session: ClaudeSession) : - JBPanel(BorderLayout()), Disposable, SessionListener { - - internal val router = ChatBridgeRouter(this) - - internal val host = JcefHost(this, router::dispatch) { IdeWithoutChat.serve(project) } - - internal val links = LinkNavigator(project) - - internal val transcript = ChatTranscriptView(session, host::exec) - - internal val tray = AttachmentTray(project, host::exec, ::focusInput) - - internal val edits = ChatEditReview(project, session, tray::notify) - - private val pendingUntilReady = mutableListOf<() -> Unit>() - - internal val feed = SessionFeed(session, host::exec) { - pushSession() - pushMetaState() - } - - private var wasRunning = false - - private var lastSessionJson: String? = null - private var lastSettingsMenuJson: String? = null - private var lastMetaState: Pair? = null - - internal val onboarding = OnboardingController(project, session, host::exec) - - internal val agentTabs = ChatAgentTabs(this) - - internal val gitChat = GitChatFeed(this, host::exec) - - internal val guard = GuardFeed(this) - - internal val logFeed = LogFeed(this) - - internal val security = SecurityViews(this) - - init { - background = ChatTheme.BG - add(host.component, BorderLayout.CENTER) - - agentTabs.render() - session.agentScanner.scan() - - LivePanels.add(this) - session.transcript.addListener(transcript) - session.addListener(this) - session.login.attachUi(onboarding) - - val lafConn = ApplicationManager.getApplication().messageBus.connect(this) - lafConn.subscribe(LafManagerListener.TOPIC, LafManagerListener { pushTheme() }) - Disposer.register(this, lafConn) - - project.service().onRepositoryChanged(this) { - edt(project) { pushGit() } - } - - pushTheme() - pushSettingsMenu() - pushMetaState() - pushPermissions() - tray.push() - pushSession() - security.pushVuln() - whenReady(feed::onSessionReady) - feed.start() - transcript.fullResync() - } - - override fun onAgentsChanged(freshlyAdmitted: List) { - agentTabs.onAgentsScanned(freshlyAdmitted) - transcript.refreshShown() - pushSession() - } - - override fun onStateChanged() { - pushMetaState() - pushSession() - pushSettingsMenu() - agentTabs.render() - if (transcript.showsTask) transcript.refreshShown() - drainPendingUntilReady() - val running = session.isRunning() - if (running && !wasRunning) feed.onSessionReady() - wasRunning = running - if (session.signals.rateLimits.isNotEmpty()) feed.requestUsage() - if (!session.turn.active && running) { - feed.requestPlan() - pushGit() - } - onboarding.onStateChanged() - } - - override fun onMetadataChanged() { - pushMetaState() - pushSession() - } - override fun onPermissionsChanged() = pushPermissions() - - private fun whenReady(action: () -> Unit) { - if (session.isRunning()) { - action() - return - } - pendingUntilReady += action - } - - private fun drainPendingUntilReady() { - if (pendingUntilReady.isEmpty() || !session.isRunning()) return - val queued = pendingUntilReady.toList() - pendingUntilReady.clear() - queued.forEach { it() } - } - - internal fun cardSession(scope: String): ClaudeSession = - if (scope == JcefBridge.SCOPE_GIT) gitChat.session() else session - - internal fun pushTheme() { - val reduceMotion = ClaudeSettings.getInstance(project).reduceMotion - host.exec("window.cc.theme && window.cc.theme(" + JcefTheme.vars(reduceMotion) + ")") - } - - internal fun pushSettingsMenu() { - val settings = ClaudeSettings.getInstance(project) - val items = SettingsMenuRows.json(settings.scope.id, settings.state, session).toString() - if (items == lastSettingsMenuJson) return - lastSettingsMenuJson = items - host.exec("window.cc.settingsMenu && window.cc.settingsMenu({\"items\":$items})") - } - - internal fun pushMetaState() { - val meta = JcefState.metaJson(session) - val state = JcefState.stateJson(session, feed.usage) - val current = meta to state - if (current == lastMetaState) return - lastMetaState = current - host.exec("window.cc.meta && window.cc.meta($meta);" + "window.cc.state && window.cc.state($state)") - } - - internal fun pushPermissions() { - val perms = session.cards.pending() - val groups = listOf(JcefCardPayload.Group(perms, diffByRequest = edits.diffsFor(perms))) + - gitChat.permissionGroup() - host.exec("window.cc.permissions && window.cc.permissions(" + JcefCardPayload.permissionsJson(groups) + ")") - } - - internal fun pushGit() = GitIntegration.getInstance(project).refresh(::pushSession) - - internal fun pushSession() { - val json = JcefSessionData.sessionJson( - session, - windowMinutes = ClaudeSettings.getInstance(project).workloadWindowMinutes, - nowMillis = System.currentTimeMillis(), - usage = feed.usage, - workloads = chatStrip()?.workloads().orEmpty(), - plan = feed.plan, - git = GitIntegration.getInstance(project).snapshot(), - vuln = VulnService.getInstance(project).snapshot(), - ) - if (json == lastSessionJson) return - lastSessionJson = json - LOG.debug { "pushSession $json" } - host.exec("window.cc.session && window.cc.session($json)") - } - - internal fun chatStrip(): ChatTabsPanel? = - (javax.swing.SwingUtilities.getAncestorOfClass(ChatTabsPanel::class.java, this) as? ChatTabsPanel) - ?: ClaudeToolWindowFactory.chatTabs(project) - - fun openDashboard() { - pushSession() - security.pushVuln() - feed.requestMcp() - feed.requestVersion() - feed.requestUsage() - host.exec("window.cc.openDashboard && window.cc.openDashboard()") - } - - fun focusTarget(): javax.swing.JComponent? = host.inputComponent() - - fun focusInput() = host.requestFocus() - - fun mentionCurrentFile() = tray.addCurrentFile() - - fun addAttachment(attachment: Attachment) = tray.add(attachment) - - override fun dispose() { - LivePanels.remove(this) - session.transcript.removeListener(transcript) - session.removeListener(this) - session.login.detachUi(onboarding) - onboarding.dispose() - transcript.stop() - feed.stop() - gitChat.dispose() - } - - private companion object { - val LOG = logger() - } -} diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/PageScriptCalls.kt b/src/main/kotlin/dev/lain/claudejb/view/window/PageScriptCalls.kt new file mode 100644 index 00000000..4b07d1ce --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/view/window/PageScriptCalls.kt @@ -0,0 +1,19 @@ +package dev.lain.claudejb.view.window + +import dev.lain.claudejb.rpc.PagePush +import dev.lain.claudejb.util.logger + +internal object PageScriptCalls { + + private val LOG = logger() + + private val CALL = Regex("""^window\.cc\.(\w+) && window\.cc\.\1\((.*)\)$""", RegexOption.DOT_MATCHES_ALL) + + fun parse(script: String): PagePush? = + CALL.matchEntire(script.trim())?.let { PagePush(it.groupValues[1], it.groupValues[2]) } + + fun into(emit: (PagePush) -> Unit): (String) -> Unit = { script -> + val push = parse(script) + if (push == null) LOG.warn("A page call is not a guarded window.cc call and was dropped: ${script.take(80)}") else emit(push) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/PushStream.kt b/src/main/kotlin/dev/lain/claudejb/view/window/PushStream.kt new file mode 100644 index 00000000..a463a3db --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/view/window/PushStream.kt @@ -0,0 +1,129 @@ +package dev.lain.claudejb.view.window + +import dev.lain.claudejb.rpc.FrontendChannel +import dev.lain.claudejb.rpc.PagePush + +internal interface PushSink { + fun push(push: PagePush) + + fun close() +} + +internal class PushStream( + private val cap: Int = DEFAULT_CAP, + private val onStale: () -> Unit, +) { + + enum class Kind { SNAPSHOT, TRANSCRIPT, TRANSIENT } + + private val lock = Any() + private val snapshots = LinkedHashMap() + private val deltas = ArrayDeque() + private val pending = ArrayDeque() + private val sinks = ArrayList() + private var stale = false + private var closed = false + + fun emit(push: PagePush) { + synchronized(lock) { + if (closed) return + record(push) + sinks.forEach { it.push(push) } + } + } + + fun attach(sink: PushSink): () -> Unit { + val resync = synchronized(lock) { + if (closed) { + sink.close() + return {} + } + sinks += sink + val needsResync = replayTo(sink) + while (pending.isNotEmpty()) sink.push(pending.removeFirst()) + needsResync + } + if (resync) onStale() + return { synchronized(lock) { sinks -= sink } } + } + + fun replay() { + val resync = synchronized(lock) { + if (closed) return + sinks.forEach { replayTo(it) } + stale + } + if (resync) onStale() + } + + fun close() { + synchronized(lock) { + if (closed) return + closed = true + sinks.forEach(PushSink::close) + sinks.clear() + snapshots.clear() + deltas.clear() + pending.clear() + } + } + + private fun replayTo(sink: PushSink): Boolean { + snapshots.forEach { (method, json) -> sink.push(PagePush(method, json)) } + if (stale) return true + sink.push(CLEAR) + deltas.forEach(sink::push) + return false + } + + private fun record(push: PagePush) { + when (kindOf(push.method)) { + Kind.SNAPSHOT -> snapshots[push.method] = push.json + Kind.TRANSCRIPT -> log(push) + Kind.TRANSIENT -> if (sinks.isEmpty()) hold(push) + } + } + + private fun log(push: PagePush) { + if (push.method == CLEAR.method) { + deltas.clear() + stale = false + return + } + if (stale) return + if (deltas.size >= cap) { + deltas.clear() + stale = true + return + } + deltas.addLast(push) + } + + private fun hold(push: PagePush) { + if (pending.size >= cap) pending.removeFirst() + pending.addLast(push) + } + + companion object { + const val NO_ARGS = "null" + + const val DEFAULT_CAP = 4096 + + private val CLEAR = PagePush("clear", NO_ARGS) + + val SNAPSHOT_METHODS: Set = setOf( + "meta", "state", "session", "settingsMenu", "permissions", "tabs", "mcp", "guard", "log", + "authState", "attachments", "attachData", "gitChat", "vulnInventory", "theme", FrontendChannel.VIBE, + ) + + val TRANSCRIPT_METHODS: Set = setOf( + "clear", "batch", "append", "trimRows", "revealAgentTab", "revealTaskTab", "clearAgentSelection", + ) + + fun kindOf(method: String): Kind = when (method) { + in SNAPSHOT_METHODS -> Kind.SNAPSHOT + in TRANSCRIPT_METHODS -> Kind.TRANSCRIPT + else -> Kind.TRANSIENT + } + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/headless/AttachmentTrayBatchHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/headless/AttachmentTrayBatchHeadlessTest.kt index e580da03..36a76f64 100644 --- a/src/test/kotlin/dev/lain/claudejb/headless/AttachmentTrayBatchHeadlessTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/headless/AttachmentTrayBatchHeadlessTest.kt @@ -9,7 +9,7 @@ class AttachmentTrayBatchHeadlessTest : BasePlatformTestCase() { private val pushes = mutableListOf() private var focuses = 0 - private fun tray() = AttachmentTray(project, { pushes += it }, { focuses++ }) + private fun tray() = AttachmentTray(project, { pushes += it.json }, { focuses++ }) private fun paths(tray: AttachmentTray) = tray.all().filterIsInstance().map { it.path } diff --git a/src/test/kotlin/dev/lain/claudejb/view/feed/TranscriptDeltasTest.kt b/src/test/kotlin/dev/lain/claudejb/view/feed/TranscriptDeltasTest.kt new file mode 100644 index 00000000..8b398049 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/view/feed/TranscriptDeltasTest.kt @@ -0,0 +1,98 @@ +package dev.lain.claudejb.view.feed + +import dev.lain.claudejb.model.session.transcript.Speaker +import dev.lain.claudejb.model.session.transcript.ToolState +import dev.lain.claudejb.model.session.transcript.TranscriptEntry +import dev.lain.claudejb.model.session.transcript.TranscriptModel +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class TranscriptDeltasTest { + + private val model = TranscriptModel() + + private val deltas = TranscriptDeltas() + + private fun running(text: String): TranscriptEntry = model.add(Speaker.ASSISTANT, text, toolState = ToolState.RUNNING) + + @Test + fun `a row the page has never seen goes whole`() { + val entry = running("Hel") + + val split = deltas.split(listOf(entry to 0)) + + assertEquals(1, split.rows.size) + assertTrue(split.appends.isEmpty()) + } + + @Test + fun `a running row whose text only grew goes as an append of the new part`() { + val entry = running("Hel") + deltas.split(listOf(entry to 0)) + model.append(entry, "lo") + + val split = deltas.split(listOf(entry to 0)) + + assertTrue(split.rows.isEmpty()) + assertEquals(listOf(TranscriptDeltas.Append(entry.id, "lo")), split.appends) + } + + @Test + fun `a settled row that grew goes whole, since the page appends only to running rows`() { + val entry = model.add(Speaker.ASSISTANT, "Hel") + deltas.split(listOf(entry to 0)) + model.append(entry, "lo") + + val split = deltas.split(listOf(entry to 0)) + + assertEquals(1, split.rows.size) + assertTrue(split.appends.isEmpty()) + assertFalse(deltas.grew(entry, "!")) + } + + @Test + fun `an append already sent is not sent again`() { + val entry = running("Hel") + deltas.split(listOf(entry to 0)) + model.append(entry, "lo") + assertTrue(deltas.grew(entry, "lo")) + + val split = deltas.split(listOf(entry to 0)) + + assertTrue(split.rows.isEmpty() && split.appends.isEmpty()) + } + + @Test + fun `a change beside the text sends the whole row`() { + val entry = model.add(Speaker.TOOL, "x", toolUseId = "t1", toolState = ToolState.RUNNING) + deltas.split(listOf(entry to 0)) + model.setToolState("t1", ToolState.FINISHED) + + assertEquals(1, deltas.split(listOf(entry to 0)).rows.size) + } + + @Test + fun `a rewritten text sends the whole row`() { + val entry = running("Hello") + deltas.split(listOf(entry to 0)) + model.replaceText(entry, "Help me") + + assertEquals(1, deltas.split(listOf(entry to 0)).rows.size) + } + + @Test + fun `a growth the page never saw the start of is not an append`() { + assertFalse(deltas.grew(running("Hel"), "lo")) + } + + @Test + fun `after a reset every row goes whole again`() { + val entry = running("Hel") + deltas.split(listOf(entry to 0)) + deltas.reset() + + assertEquals(1, deltas.split(listOf(entry to 0)).rows.size) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/view/window/ChatDeckTest.kt b/src/test/kotlin/dev/lain/claudejb/view/window/ChatDeckTest.kt new file mode 100644 index 00000000..5a1ae7f1 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/view/window/ChatDeckTest.kt @@ -0,0 +1,97 @@ +package dev.lain.claudejb.view.window + +import dev.lain.claudejb.rpc.ChatEvent +import dev.lain.claudejb.rpc.ChatId +import dev.lain.claudejb.rpc.ChatRef +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ChatDeckTest { + + private val deck = ChatDeck({ it.id }, { it }) + + private val events = mutableListOf() + + private fun chat(name: String) = ChatRef(ChatId(name), name) + + @Test + fun `the first chat is shown even when it was not asked to be`() { + deck.subscribe { events += it } + val a = chat("a") + + assertTrue(deck.add(a, select = false)) + + assertEquals(a, deck.selected) + assertEquals(listOf(ChatEvent.Opened(a, true)), events) + } + + @Test + fun `a chat restored in the background does not steal the selection`() { + val a = chat("a") + deck.add(a, select = true) + deck.add(chat("b"), select = false) + + assertEquals(a, deck.selected) + } + + @Test + fun `closing the selected chat selects a survivor before announcing the close`() { + val a = chat("a") + val b = chat("b") + deck.add(a, select = true) + deck.add(b, select = false) + deck.subscribe { events += it } + events.clear() + + deck.remove(a.id) + + assertEquals(b, deck.selected) + assertEquals(listOf(ChatEvent.Selected(b.id), ChatEvent.Closed(a.id)), events) + } + + @Test + fun `closing the last chat leaves nothing selected`() { + val a = chat("a") + deck.add(a, select = true) + + deck.remove(a.id) + + assertTrue(deck.isEmpty()) + assertNull(deck.selected) + } + + @Test + fun `closing or selecting an unknown chat does nothing`() { + deck.add(chat("a"), select = true) + deck.subscribe { events += it } + events.clear() + + assertNull(deck.remove(ChatId("nope"))) + assertNull(deck.select(ChatId("nope"))) + assertTrue(events.isEmpty()) + } + + @Test + fun `a new subscriber is told about every open chat and which one is shown`() { + val a = chat("a") + val b = chat("b") + deck.add(a, select = true) + deck.add(b, select = false) + + deck.subscribe { events += it } + + assertEquals(listOf(ChatEvent.Opened(a, true), ChatEvent.Opened(b, false)), events) + } + + @Test + fun `an unsubscribed listener hears nothing more`() { + val stop = deck.subscribe { events += it } + stop() + + deck.add(chat("a"), select = true) + + assertTrue(events.isEmpty()) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/view/window/PageScriptCallsTest.kt b/src/test/kotlin/dev/lain/claudejb/view/window/PageScriptCallsTest.kt new file mode 100644 index 00000000..cd6d6ab4 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/view/window/PageScriptCallsTest.kt @@ -0,0 +1,31 @@ +package dev.lain.claudejb.view.window + +import dev.lain.claudejb.rpc.PagePush +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class PageScriptCallsTest { + + @Test + fun `a guarded call becomes its method and its argument`() { + assertEquals( + PagePush("authState", "{\"step\":\"url\"}"), + PageScriptCalls.parse("window.cc.authState && window.cc.authState({\"step\":\"url\"})"), + ) + } + + @Test + fun `an argument spanning lines is kept whole`() { + assertEquals( + PagePush("bootPathError", "\"a\nb\""), + PageScriptCalls.parse("window.cc.bootPathError && window.cc.bootPathError(\"a\nb\")"), + ) + } + + @Test + fun `a script that is not one guarded call is refused`() { + assertNull(PageScriptCalls.parse("window.cc.meta && window.cc.state({})")) + assertNull(PageScriptCalls.parse("alert(1)")) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/view/window/PushStreamTest.kt b/src/test/kotlin/dev/lain/claudejb/view/window/PushStreamTest.kt new file mode 100644 index 00000000..d23b0171 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/view/window/PushStreamTest.kt @@ -0,0 +1,141 @@ +package dev.lain.claudejb.view.window + +import dev.lain.claudejb.rpc.FrontendChannel +import dev.lain.claudejb.rpc.PagePush +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PushStreamTest { + + private class Recorder : PushSink { + val pushes = mutableListOf() + var closed = false + + override fun push(push: PagePush) { + pushes += push + } + + override fun close() { + closed = true + } + + fun methods() = pushes.map { it.method } + } + + private var resyncs = 0 + + private fun stream(cap: Int = PushStream.DEFAULT_CAP) = PushStream(cap) { resyncs++ } + + @Test + fun `a late collector gets the latest snapshot of each method, then the transcript from a clear`() { + val stream = stream() + stream.emit(PagePush("meta", "1")) + stream.emit(PagePush("state", "a")) + stream.emit(PagePush("batch", "[1]")) + stream.emit(PagePush("meta", "2")) + stream.emit(PagePush("append", "{\"id\":1,\"delta\":\"x\"}")) + + val sink = Recorder() + stream.attach(sink) + + assertEquals(listOf("meta", "state", "clear", "batch", "append"), sink.methods()) + assertEquals("2", sink.pushes.first().json) + } + + @Test + fun `a clear drops the transcript that came before it`() { + val stream = stream() + stream.emit(PagePush("batch", "[1]")) + stream.emit(PagePush("clear", PushStream.NO_ARGS)) + stream.emit(PagePush("batch", "[2]")) + + val sink = Recorder() + stream.attach(sink) + + assertEquals(listOf(PagePush("clear", PushStream.NO_ARGS), PagePush("batch", "[2]")), sink.pushes) + } + + @Test + fun `a one-shot command waits for the first collector and is delivered once`() { + val stream = stream() + stream.emit(PagePush(FrontendChannel.FOCUS, PushStream.NO_ARGS)) + + val first = Recorder() + stream.attach(first) + val second = Recorder() + stream.attach(second) + + assertTrue(FrontendChannel.FOCUS in first.methods()) + assertTrue(FrontendChannel.FOCUS !in second.methods()) + } + + @Test + fun `a one-shot command sent while a page listens is never replayed`() { + val stream = stream() + val sink = Recorder() + stream.attach(sink) + stream.emit(PagePush(FrontendChannel.COPY, "\"text\"")) + sink.pushes.clear() + + stream.replay() + + assertTrue(FrontendChannel.COPY !in sink.methods()) + } + + @Test + fun `ready replays to every collector already attached`() { + val stream = stream() + val sink = Recorder() + stream.attach(sink) + stream.emit(PagePush("session", "{}")) + sink.pushes.clear() + + stream.replay() + + assertEquals(listOf("session", "clear"), sink.methods()) + } + + @Test + fun `an overflowing transcript falls back to a resync instead of replaying a partial one`() { + val stream = stream(cap = 2) + repeat(3) { stream.emit(PagePush("append", "{}")) } + + val sink = Recorder() + stream.attach(sink) + + assertEquals(1, resyncs) + assertTrue("append" !in sink.methods() && "clear" !in sink.methods()) + + stream.emit(PagePush("clear", PushStream.NO_ARGS)) + stream.emit(PagePush("batch", "[]")) + val later = Recorder() + stream.attach(later) + assertEquals(1, resyncs) + assertEquals(listOf("clear", "batch"), later.methods()) + } + + @Test + fun `a detached collector hears nothing more, and closing closes the ones left`() { + val stream = stream() + val gone = Recorder() + val stays = Recorder() + val detach = stream.attach(gone) + stream.attach(stays) + detach() + + stream.emit(PagePush("meta", "{}")) + stream.close() + + assertTrue("meta" !in gone.methods()) + assertTrue("meta" in stays.methods()) + assertTrue(stays.closed) + } + + @Test + fun `the vibe switch is a snapshot the frontend gets back on every replay`() { + assertEquals(PushStream.Kind.SNAPSHOT, PushStream.kindOf(FrontendChannel.VIBE)) + assertEquals(PushStream.Kind.TRANSIENT, PushStream.kindOf(FrontendChannel.BROWSE)) + assertEquals(PushStream.Kind.TRANSCRIPT, PushStream.kindOf("trimRows")) + } +} From 96bc8648aab7e0f397287e0fe9602cf6fcda0cc3 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:08:34 +0200 Subject: [PATCH 058/155] feat(rpc): serve the chat contract from the backend --- .../lain/claudejb/rpc/backend/ChatApiImpl.kt | 93 +++++++++++++++++++ .../claudejb/rpc/backend/ChatApiProvider.kt | 12 +++ 2 files changed, 105 insertions(+) create mode 100644 src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiProvider.kt diff --git a/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt b/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt new file mode 100644 index 00000000..b83531f8 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt @@ -0,0 +1,93 @@ +package dev.lain.claudejb.rpc.backend + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.platform.project.ProjectId +import com.intellij.platform.project.findProjectOrNull +import dev.lain.claudejb.rpc.ChatApi +import dev.lain.claudejb.rpc.ChatEvent +import dev.lain.claudejb.rpc.ChatId +import dev.lain.claudejb.rpc.ChatRef +import dev.lain.claudejb.rpc.PagePush +import dev.lain.claudejb.view.window.ChatPresenter +import dev.lain.claudejb.view.window.ChatRegistry +import dev.lain.claudejb.view.window.PushSink +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.emptyFlow + +internal class ChatApiImpl : ChatApi { + + override suspend fun chats(projectId: ProjectId): List { + val registry = registry(projectId) ?: return emptyList() + return onEdt { registry.chats() } + } + + override suspend fun events(projectId: ProjectId): Flow { + val registry = registry(projectId) ?: return emptyFlow() + return callbackFlow { + val unsubscribe = onEdt { registry.subscribe { trySend(it) } } + awaitClose(unsubscribe) + }.buffer(Channel.UNLIMITED) + } + + override suspend fun newChat(projectId: ProjectId): ChatRef { + val registry = registry(projectId) ?: error("No open project for $projectId") + return onEdt { registry.newChat().ref() } + } + + override suspend fun select(projectId: ProjectId, chatId: ChatId) { + val registry = registry(projectId) ?: return + onEdt { registry.select(chatId) } + } + + override suspend fun close(projectId: ProjectId, chatId: ChatId) { + val registry = registry(projectId) ?: return + onEdt { registry.close(chatId) } + } + + override suspend fun post(projectId: ProjectId, chatId: ChatId, json: String) { + val presenter = presenter(projectId, chatId) ?: return + ApplicationManager.getApplication().invokeLater { presenter.router.dispatch(json) } + } + + override suspend fun pushes(projectId: ProjectId, chatId: ChatId): Flow { + val presenter = presenter(projectId, chatId) ?: return emptyFlow() + return callbackFlow { + val detach = presenter.pushes.attach( + object : PushSink { + override fun push(push: PagePush) { + trySend(push) + } + + override fun close() { + channel.close() + } + }, + ) + awaitClose(detach) + }.buffer(Channel.UNLIMITED) + } + + override suspend fun ready(projectId: ProjectId, chatId: ChatId) { + val presenter = presenter(projectId, chatId) ?: return + presenter.replay() + } + + private fun registry(projectId: ProjectId): ChatRegistry? = + projectId.findProjectOrNull()?.takeUnless { it.isDisposed }?.let(ChatRegistry::getInstance) + + private fun presenter(projectId: ProjectId, chatId: ChatId): ChatPresenter? = registry(projectId)?.presenter(chatId) + + private suspend fun onEdt(block: () -> T): T { + val app = ApplicationManager.getApplication() + if (app.isDispatchThread) return block() + val result = CompletableDeferred() + app.invokeLater({ result.completeWith(runCatching(block)) }, ModalityState.any()) + return result.await() + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiProvider.kt b/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiProvider.kt new file mode 100644 index 00000000..33619692 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiProvider.kt @@ -0,0 +1,12 @@ +package dev.lain.claudejb.rpc.backend + +import com.intellij.platform.rpc.backend.RemoteApiProvider +import dev.lain.claudejb.rpc.ChatApi +import fleet.rpc.remoteApiDescriptor + +internal class ChatApiProvider : RemoteApiProvider { + + override fun RemoteApiProvider.Sink.remoteApis() { + remoteApi(remoteApiDescriptor()) { ChatApiImpl() } + } +} From f38ae35db7b2cee586b17acbefdf909d3f4534cf Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:09:04 +0200 Subject: [PATCH 059/155] perf(mcp): put a ceiling on every max a tool takes --- .../controller/mcp/tools/code/EditOpsTools.kt | 4 ++-- .../claudejb/controller/mcp/tools/ops/DbTools.kt | 12 ++++++------ .../controller/mcp/tools/vcs/ReleaseTools.kt | 14 ++++++++------ .../kotlin/dev/lain/claudejb/model/mcp/ToolSpec.kt | 4 +++- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/EditOpsTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/EditOpsTools.kt index e6b60a78..2e5637ab 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/EditOpsTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/EditOpsTools.kt @@ -61,7 +61,7 @@ internal class EditOpsTools(private val project: Project, private val reveal: Re private suspend fun searchReplace(args: ToolArgs): ToolResult { val query = args.string("query") val replacement = args.string("replacement") - val max = args.int("max", DEFAULT_MAX_FILES) + val max = args.max(DEFAULT_MAX_FILES, Param.MAX_CEILING) val directory = readAction { args.optionalString("path")?.let { ReadTools.resolveDirectory(project, it).path } } val model = replace.model(query, replacement, args.boolean("regex", false), args.boolean("case_sensitive", false), directory) val explicit = args.strings("paths") @@ -155,7 +155,7 @@ internal class EditOpsTools(private val project: Project, private val reveal: Re Batch.param(Batch.PATHS, "Only these files, all in one call, instead of every file that matches"), Param( "max", - "Maximum files to change when paths is not given (default $DEFAULT_MAX_FILES)", + "Maximum files to change when paths is not given (default $DEFAULT_MAX_FILES, at most ${Param.MAX_CEILING})", type = "integer", required = false, ), diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/DbTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/DbTools.kt index fd62e4a4..2fe319a4 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/DbTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/DbTools.kt @@ -35,7 +35,7 @@ internal class DbTools(project: Project, private val gateway: DbGateway = DbGate } private suspend fun connections(args: ToolArgs): ToolResult { - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val all = readAction { gateway.connections() } return ToolResult.toon( buildJsonObject { @@ -62,7 +62,7 @@ internal class DbTools(project: Project, private val gateway: DbGateway = DbGate private suspend fun schema(args: ToolArgs): ToolResult { val connection = args.string("connection") val table = args.optionalString("table") - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val rows = readAction { if (table == null) gateway.tables(connection).map(::tableRow) else gateway.columns(connection, table).map(::columnRow) } @@ -94,7 +94,7 @@ internal class DbTools(project: Project, private val gateway: DbGateway = DbGate private suspend fun queryOne(args: ToolArgs): JsonObject { val connection = args.string("connection") val sql = args.string("code") - val max = args.int("max", DEFAULT_MAX).coerceIn(1, MAX_ROWS) + val max = args.max(DEFAULT_MAX, MAX_ROWS) val result = runInterruptible(Dispatchers.IO) { gateway.query(connection, sql, max, QUERY_TIMEOUT_SECONDS) } val labels = labels(result.columns) return buildJsonObject { @@ -126,7 +126,7 @@ internal class DbTools(project: Project, private val gateway: DbGateway = DbGate "db_connections", "Lists the data sources configured in the IDE's Database tool window: name, DBMS and JDBC URL with credentials " + "redacted. Call it first to learn the connection name the other db tools take.", - listOf(Param("max", "Maximum data sources to return (default $DEFAULT_MAX)", type = "integer", required = false)), + listOf(Param.max("data sources", DEFAULT_MAX)), ) val DB_SCHEMA = ToolSpec( @@ -137,7 +137,7 @@ internal class DbTools(project: Project, private val gateway: DbGateway = DbGate listOf( Param("connection", "The data source name as db_connections lists it"), Param("table", "A table or view, as name or schema.name, to list its columns instead of the tables", required = false), - Param("max", "Maximum rows to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("rows", DEFAULT_MAX), ), ) @@ -150,7 +150,7 @@ internal class DbTools(project: Project, private val gateway: DbGateway = DbGate Param("connection", "The data source name as db_connections lists it"), Param("code", "The SQL statement to run", required = false), Batch.param(Batch.STATEMENTS, "Several SQL statements at once, one result each, on the same connection"), - Param("max", "Maximum rows to return (default $DEFAULT_MAX, at most $MAX_ROWS)", type = "integer", required = false), + Param.max("rows", DEFAULT_MAX, MAX_ROWS), ), mutates = true, ) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ReleaseTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ReleaseTools.kt index 2186a133..4b058897 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ReleaseTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ReleaseTools.kt @@ -31,7 +31,7 @@ internal class ReleaseTools(private val project: Project, gateway: () -> GitHubG ) private suspend fun tags(args: ToolArgs): ToolResult { - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, MAX_PAGE) val rows = rows(github.getJson("/tags?per_page=$max")) return ToolResult.toon( buildJsonObject { @@ -55,7 +55,7 @@ internal class ReleaseTools(private val project: Project, gateway: () -> GitHubG private suspend fun workflowRuns(args: ToolArgs): ToolResult { val branch = args.optionalString("branch") - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, MAX_PAGE) val query = "/actions/runs?per_page=$max" + (branch?.let { "&branch=$it" } ?: "") val rows = rows(map(github.getJson(query), "workflow_runs")) return ToolResult.toon( @@ -109,7 +109,7 @@ internal class ReleaseTools(private val project: Project, gateway: () -> GitHubG } private suspend fun marketplace(args: ToolArgs): ToolResult { - val max = args.int("max", DEFAULT_MARKETPLACE) + val max = args.max(DEFAULT_MARKETPLACE, MAX_MARKETPLACE) val updates = withContext(Dispatchers.IO) { MarketplaceGateway.updates(PluginIdentity.MARKETPLACE_ID, max) } return ToolResult.toon( buildJsonObject { @@ -146,12 +146,14 @@ internal class ReleaseTools(private val project: Project, gateway: () -> GitHubG private const val DEFAULT_MAX = 20 private const val DEFAULT_MARKETPLACE = 5 + private const val MAX_MARKETPLACE = 50 + private const val MAX_PAGE = 100 private const val SHORT_SHA = 12 val TAGS = ToolSpec( "tags", "The repository's tags on GitHub, newest first, with the commit each points at.", - listOf(Param("max", "Maximum tags (default $DEFAULT_MAX)", type = "integer", required = false)), + listOf(Param.max("tags", DEFAULT_MAX, MAX_PAGE)), ) val WORKFLOW_RUNS = ToolSpec( @@ -160,7 +162,7 @@ internal class ReleaseTools(private val project: Project, gateway: () -> GitHubG "(queued, in_progress, completed), conclusion (success, failure, cancelled…) and url.", listOf( Param("branch", "Only the runs of this branch (default: all)", required = false), - Param("max", "Maximum runs (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("runs", DEFAULT_MAX, MAX_PAGE), ), ) @@ -175,7 +177,7 @@ internal class ReleaseTools(private val project: Project, gateway: () -> GitHubG "marketplace", "The plugin's versions on the JetBrains Marketplace, newest first: version, channel, listed, approved, published " + "date and the IDE range; read from the public Marketplace API, no account involved.", - listOf(Param("max", "Maximum versions (default $DEFAULT_MARKETPLACE)", type = "integer", required = false)), + listOf(Param.max("versions", DEFAULT_MARKETPLACE, MAX_MARKETPLACE)), ) } } diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolSpec.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolSpec.kt index a4ca5677..81c9775c 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolSpec.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/ToolSpec.kt @@ -57,7 +57,9 @@ data class Param( val items: Items? = null, ) { companion object { - fun max(what: String, default: Int, ceiling: Int): Param = + const val MAX_CEILING = 500 + + fun max(what: String, default: Int, ceiling: Int = MAX_CEILING): Param = Param("max", "Maximum $what to return (default $default, at most $ceiling)", type = "integer", required = false) } } From a452239419bc7cb2d094b637e6011ed415c43a27 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:09:05 +0200 Subject: [PATCH 060/155] perf(mcp): buffer the frames a connection reads --- .../kotlin/dev/lain/claudejb/controller/mcp/ServerEndpoint.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/ServerEndpoint.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/ServerEndpoint.kt index a04d3eb7..5eb33f2b 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/ServerEndpoint.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/ServerEndpoint.kt @@ -24,6 +24,7 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import java.io.BufferedInputStream import java.io.InputStream import java.io.OutputStream import java.net.StandardProtocolFamily @@ -86,7 +87,7 @@ internal class ServerEndpoint( private inner class Connection(client: SocketChannel) { - private val input: InputStream = Channels.newInputStream(client) + private val input: InputStream = BufferedInputStream(Channels.newInputStream(client)) private val output: OutputStream = Channels.newOutputStream(client) private val writing = Mutex() private val jobs = ConcurrentHashMap() From 70d172603d9eb378899b793702dc03567eb3c8b3 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:09:42 +0200 Subject: [PATCH 061/155] perf(session): tail agent transcripts from their last offset and cap their entries --- .../model/session/agents/AgentEnding.kt | 6 +- .../model/session/agents/AgentRegistry.kt | 42 +----- .../session/agents/AgentTranscriptTail.kt | 138 ++++++++++++++++++ .../session/agents/AgentTranscriptTailTest.kt | 88 +++++++++++ 4 files changed, 237 insertions(+), 37 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentTranscriptTail.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/model/session/agents/AgentTranscriptTailTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentEnding.kt b/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentEnding.kt index b25ba39f..ff0a7695 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentEnding.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentEnding.kt @@ -17,18 +17,20 @@ internal object AgentEnding { UNFINISHED, } - fun of(records: List): Ending? { + fun of(records: List, finishedEarlier: Boolean = false): Ending? { if (records.isEmpty()) return null if (records.last().isAbortMarker()) return Ending.ABORTED val lastFinished = records.indexOfLast { it.endsTurn() } return when { lastFinished == records.lastIndex -> Ending.COMPLETED records.last().isFinalAnswer() -> Ending.COMPLETED - lastFinished >= 0 -> Ending.RESUMED + lastFinished >= 0 || finishedEarlier -> Ending.RESUMED else -> Ending.UNFINISHED } } + fun endsTurn(record: JsonObject): Boolean = record.endsTurn() + private fun JsonObject.endsTurn(): Boolean { if ((this[TOOL_ENDS_TURN] as? JsonPrimitive)?.contentOrNull == "true") return true val message = this["message"] as? JsonObject ?: return false diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentRegistry.kt b/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentRegistry.kt index 6beb19e9..fd17faa3 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentRegistry.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentRegistry.kt @@ -1,6 +1,5 @@ package dev.lain.claudejb.model.session.agents -import dev.lain.claudejb.model.session.history.SessionTranscriptReader import dev.lain.claudejb.model.session.transcript.EntryDTO import dev.lain.claudejb.model.settings.WorkloadWindow import kotlinx.serialization.json.JsonObject @@ -101,49 +100,25 @@ class AgentRegistry( val next = LinkedHashMap() for (id in admitted.sortedWith(compareBy({ metas[it]?.spawnDepth ?: 1 }, { it }))) { val meta = metas[id] ?: continue - val (entries, ending) = transcriptOf(meta.home(dir), id, previous[id]) - reopenIfGrown(meta, entries.size) - val settled = settledStateOf(meta, next, ending) + val tail = tails.getOrPut(id) { AgentTranscriptTail(meta.home(dir).resolve(AgentMeta.transcriptFile(id))) } + tail.refresh() + reopenIfGrown(meta, tail.recordCount) + val settled = settledStateOf(meta, next, tail.ending) next[id] = AgentNode( meta = meta, status = settled.status, - entries = entries, + entries = tail.entries, completedAtMillis = settled.completedAtMillis, ) } snapshot = next - cachedTranscripts.keys.retainAll(next.keys) + tails.keys.retainAll(next.keys) val fresh = next.keys - previous.keys fresh.forEach(onAdmitted) return fresh.toList() } - private fun transcriptOf( - dir: Path, - id: String, - previousNode: AgentNode?, - ): Pair, AgentEnding.Ending?> { - val stamp = stampOf(dir.resolve(AgentMeta.transcriptFile(id))) - val unchanged = cachedTranscripts[id]?.takeIf { it.stamp == stamp } - if (unchanged != null && previousNode != null) return previousNode.entries to unchanged.ending - val records = SessionTranscriptReader.parseRecords(readLines(dir, id)) - val ending = AgentEnding.of(records) - cachedTranscripts[id] = CachedTranscript(stamp, ending) - return SessionTranscriptReader.entriesOf(records) to ending - } - - private data class FileStamp(val size: Long, val modifiedAtMillis: Long) - - private class CachedTranscript(val stamp: FileStamp, val ending: AgentEnding.Ending?) - - private val cachedTranscripts = ConcurrentHashMap() - - private fun stampOf(file: Path): FileStamp = runCatching { - val attrs = Files.readAttributes(file, java.nio.file.attribute.BasicFileAttributes::class.java) - FileStamp(attrs.size(), attrs.lastModifiedTime().toMillis()) - }.getOrDefault(missingFile) - - private val missingFile = FileStamp(-1, -1) + private val tails = ConcurrentHashMap() private data class Settled(val status: AgentStatus, val completedAtMillis: Long?) @@ -242,7 +217,4 @@ class AgentRegistry( val body = runCatching { Files.readString(path) }.getOrNull() ?: return null return AgentMeta.parse(id, body, workflowRun)?.let { id to it } } - - private fun readLines(dir: Path, agentId: String): List = - runCatching { Files.readAllLines(dir.resolve(AgentMeta.transcriptFile(agentId))) }.getOrDefault(emptyList()) } diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentTranscriptTail.kt b/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentTranscriptTail.kt new file mode 100644 index 00000000..a97ab373 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentTranscriptTail.kt @@ -0,0 +1,138 @@ +package dev.lain.claudejb.model.session.agents + +import dev.lain.claudejb.model.session.history.SessionTranscriptReader +import dev.lain.claudejb.model.session.transcript.EntryDTO +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes + +internal class AgentTranscriptTail(private val file: Path) { + + private class Line(val raw: String) { + val record: JsonObject? by lazy { SessionTranscriptReader.parseRecord(raw) } + } + + private val window = ArrayDeque() + private var trailing: JsonObject? = null + private var offset = 0L + private var head = ByteArray(0) + private var stamp: Pair? = null + private var finishedEarlier = false + + var recordCount = 0 + private set + + var entries: List = emptyList() + private set + + var ending: AgentEnding.Ending? = null + private set + + @Synchronized + fun refresh() { + val now = stampOf() + if (now == stamp) return + stamp = now + if (now == null) return reset() + if (now.first < offset || !sameHead()) reset() + trailing = null + readFromOffset() + val records = window.mapNotNull { it.record } + listOfNotNull(trailing) + recordCount = accepted + if (trailing?.let(::isConversational) == true) 1 else 0 + entries = SessionTranscriptReader.entriesOf(records, MAX_ENTRIES) + ending = AgentEnding.of(records, finishedEarlier) + } + + private var accepted = 0 + + private fun reset() { + window.clear() + trailing = null + offset = 0L + head = ByteArray(0) + finishedEarlier = false + accepted = 0 + recordCount = 0 + entries = emptyList() + ending = null + } + + private fun stampOf(): Pair? = runCatching { + val attrs = Files.readAttributes(file, BasicFileAttributes::class.java) + attrs.size() to attrs.lastModifiedTime().toMillis() + }.getOrNull() + + private fun sameHead(): Boolean { + if (head.isEmpty()) return true + val now = runCatching { readAt(0, head.size) }.getOrNull() ?: return false + return now.contentEquals(head) + } + + private fun readAt(position: Long, length: Int): ByteArray = FileChannel.open(file, StandardOpenOption.READ).use { channel -> + val buffer = ByteBuffer.allocate(length) + while (buffer.hasRemaining() && channel.read(buffer, position + buffer.position()) > 0) Unit + buffer.array().copyOf(buffer.position()) + } + + private fun readFromOffset() { + val pending = ByteArrayOutputStream() + runCatching { + FileChannel.open(file, StandardOpenOption.READ).use { channel -> + channel.position(offset) + val buffer = ByteBuffer.allocate(CHUNK) + while (channel.read(buffer) > 0) { + split(buffer.array(), buffer.position(), pending) + buffer.clear() + } + offset = channel.position() - pending.size() + } + } + if (head.size < HEAD_BYTES && offset > head.size) head = runCatching { readAt(0, minOf(offset, HEAD_BYTES.toLong()).toInt()) }.getOrDefault(head) + trailing = if (pending.size() > 0) SessionTranscriptReader.parseRecord(pending.toString(Charsets.UTF_8)) else null + } + + private fun split(bytes: ByteArray, end: Int, pending: ByteArrayOutputStream) { + var start = 0 + for (i in 0 until end) { + if (bytes[i] != NEWLINE) continue + pending.write(bytes, start, i - start) + accept(pending.toString(Charsets.UTF_8)) + pending.reset() + start = i + 1 + } + pending.write(bytes, start, end - start) + } + + private fun accept(raw: String) { + if (raw.isBlank()) return + val line = Line(raw) + if (!finishedEarlier && raw.mayEndTurn() && line.record?.let(AgentEnding::endsTurn) == true) finishedEarlier = true + if (CONVERSATIONAL.any { it in raw }) accepted++ + window.addLast(line) + if (window.size > MAX_LINES) window.removeFirst() + } + + private fun isConversational(record: JsonObject): Boolean = + (record["type"] as? JsonPrimitive)?.contentOrNull in CONVERSATIONAL_TYPES + + private fun String.mayEndTurn(): Boolean = contains(END_TURN) || contains(TOOL_ENDS_TURN) + + private companion object { + const val MAX_ENTRIES = 500 + const val MAX_LINES = 1_000 + const val CHUNK = 64 * 1024 + const val HEAD_BYTES = 256 + val CONVERSATIONAL_TYPES = setOf("user", "assistant") + val CONVERSATIONAL = CONVERSATIONAL_TYPES.map { "\"type\":\"$it\"" } + const val END_TURN = "end_turn" + const val TOOL_ENDS_TURN = "toolEndsTurn" + const val NEWLINE = '\n'.code.toByte() + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/model/session/agents/AgentTranscriptTailTest.kt b/src/test/kotlin/dev/lain/claudejb/model/session/agents/AgentTranscriptTailTest.kt new file mode 100644 index 00000000..dd170455 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/model/session/agents/AgentTranscriptTailTest.kt @@ -0,0 +1,88 @@ +package dev.lain.claudejb.model.session.agents + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption + +class AgentTranscriptTailTest { + + @TempDir + lateinit var dir: Path + + private val file get() = dir.resolve("agent-a.jsonl") + + private fun text(n: Int) = """{"type":"assistant","message":{"content":[{"type":"text","text":"line $n"}]}}""" + + private val closedTurn = """{"type":"assistant","message":{"stop_reason":"end_turn","content":[]}}""" + + private fun append(vararg lines: String) { + Files.writeString(file, lines.joinToString("") { "$it\n" }, StandardOpenOption.CREATE, StandardOpenOption.APPEND) + Files.setLastModifiedTime(file, java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis() + Files.size(file))) + } + + @Test + fun `only the lines appended since the last read are added`() { + val tail = AgentTranscriptTail(file) + append(text(1), text(2)) + tail.refresh() + assertEquals(listOf("line 1", "line 2"), tail.entries.map { it.text }) + + append(text(3)) + tail.refresh() + + assertEquals(listOf("line 1", "line 2", "line 3"), tail.entries.map { it.text }) + assertEquals(3, tail.recordCount) + } + + @Test + fun `a line still being written is shown once it parses and read again when it completes`() { + val tail = AgentTranscriptTail(file) + Files.writeString(file, text(1) + "\n" + text(2).take(20)) + tail.refresh() + assertEquals(listOf("line 1"), tail.entries.map { it.text }) + + Files.writeString(file, text(2).drop(20) + "\n", StandardOpenOption.APPEND) + Files.setLastModifiedTime(file, java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis() + 5_000)) + tail.refresh() + + assertEquals(listOf("line 1", "line 2"), tail.entries.map { it.text }) + } + + @Test + fun `entries are capped while the record count keeps growing`() { + val tail = AgentTranscriptTail(file) + append(*Array(1_200) { text(it) }) + tail.refresh() + + assertEquals(500, tail.entries.size) + assertEquals("line 1199", tail.entries.last().text) + assertEquals(1_200, tail.recordCount) + } + + @Test + fun `a turn closed long before the window still makes a reopened agent resumed`() { + val tail = AgentTranscriptTail(file) + append(closedTurn, *Array(1_500) { text(it) }, """{"type":"user","message":{"content":[{"type":"tool_result","content":"x"}]}}""") + tail.refresh() + + assertEquals(AgentEnding.Ending.RESUMED, tail.ending) + } + + @Test + fun `a rewritten transcript is read again from the start`() { + val tail = AgentTranscriptTail(file) + append(text(1), text(2), text(3)) + tail.refresh() + + Files.writeString(file, text(9) + "\n") + Files.setLastModifiedTime(file, java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis() + 9_000)) + tail.refresh() + + assertEquals(listOf("line 9"), tail.entries.map { it.text }) + assertTrue(tail.recordCount >= 1) + } +} From c53384290ad6e1879f24b4e38b91c3a80cb8e5df Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:11:48 +0200 Subject: [PATCH 062/155] test(contract): scan every module's sources --- .../dev/lain/claudejb/FileSizeContractTest.kt | 8 +- .../InternalPlatformApiContractTest.kt | 6 +- .../lain/claudejb/NoCommentsContractTest.kt | 8 +- .../claudejb/PackageDependencyContractTest.kt | 35 +++--- .../PluginDependenciesContractTest.kt | 64 +++++----- .../kotlin/dev/lain/claudejb/PluginModules.kt | 39 ++++++ .../PrivateReachabilityContractTest.kt | 2 +- .../lain/claudejb/ReachabilityContractTest.kt | 25 +--- .../kotlin/dev/lain/claudejb/SourceLayout.kt | 76 ++++++++++++ .../bridge/PageStateRecoveryContractTest.kt | 36 +++--- .../commands/git/IdeActionApiContractTest.kt | 15 +-- .../controller/db/DbGatewayContractTest.kt | 3 +- .../git/GitDependencyContractTest.kt | 53 ++++----- .../github/GitHubGatewayContractTest.kt | 28 +++-- .../controller/mcp/FocusContractTest.kt | 14 +-- .../mcp/IdeRuleCoverageContractTest.kt | 7 +- .../ops/PluginDetailsGatewayContractTest.kt | 3 +- .../vcs/GitCommandsGatewayContractTest.kt | 3 +- .../process/EdtProcessTeardownContractTest.kt | 7 +- .../session/InitializeOnEdtContractTest.kt | 7 +- .../session/turn/SessionTimerContractTest.kt | 7 +- .../transcript/TranscriptCapContractTest.kt | 7 +- .../view/jcef/JcefDependencyContractTest.kt | 58 +++++---- .../view/jcef/PageAssetsContractTest.kt | 22 +++- .../view/jcef/RemoteDevApiContractTest.kt | 16 ++- .../view/log/LogViewWiringContractTest.kt | 39 +++--- .../view/window/InitOrderContractTest.kt | 12 +- .../window/ToolWindowWiringContractTest.kt | 111 ++++++++---------- 28 files changed, 415 insertions(+), 296 deletions(-) create mode 100644 src/test/kotlin/dev/lain/claudejb/PluginModules.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/SourceLayout.kt diff --git a/src/test/kotlin/dev/lain/claudejb/FileSizeContractTest.kt b/src/test/kotlin/dev/lain/claudejb/FileSizeContractTest.kt index 01f3f500..e00db098 100644 --- a/src/test/kotlin/dev/lain/claudejb/FileSizeContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/FileSizeContractTest.kt @@ -8,16 +8,15 @@ class FileSizeContractTest { @Test fun `the scan sees the whole tree`() { - assertTrue(MainSources.files().size >= MIN_SOURCES) { "only ${MainSources.files().size} sources found" } + assertTrue(SourceLayout.kotlinFiles().size >= MIN_SOURCES) { "only ${SourceLayout.kotlinFiles().size} sources found" } } @Test fun `every main source stays under the ceiling`() { - val root = MainSources.root(SOURCE_ROOT) - val offenders = MainSources.files() + val offenders = SourceLayout.kotlinFiles() .map { file -> file to file.readLines().count { !it.startsWith("import ") } } .filter { (_, lines) -> lines > CEILING } - .map { (file, lines) -> "${file.relativeTo(root).invariantSeparatorsPath}: $lines" } + .map { (file, lines) -> "${file.invariantSeparatorsPath}: $lines" } assertEquals(emptyList(), offenders) { "A file over $CEILING lines, imports aside, has more than one responsibility. Split it along its seam " + "and move the contract tests that scan it in the same commit." @@ -25,7 +24,6 @@ class FileSizeContractTest { } private companion object { - const val SOURCE_ROOT = "src/main/kotlin" const val CEILING = 250 const val MIN_SOURCES = 100 } diff --git a/src/test/kotlin/dev/lain/claudejb/InternalPlatformApiContractTest.kt b/src/test/kotlin/dev/lain/claudejb/InternalPlatformApiContractTest.kt index f9e4b0d6..ad5b7a2f 100644 --- a/src/test/kotlin/dev/lain/claudejb/InternalPlatformApiContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/InternalPlatformApiContractTest.kt @@ -7,11 +7,7 @@ import java.io.File class InternalPlatformApiContractTest { - private val sources: List = listOf("src/main/kotlin", "src/main/java") - .map(::File) - .filter { it.isDirectory } - .flatMap { it.walkTopDown().filter { f -> f.isFile && f.extension in setOf("kt", "java") } } - .sortedBy { it.path } + private val sources: List = SourceLayout.jvmFiles() @Test fun `the sources exist and this test is actually looking at them`() { diff --git a/src/test/kotlin/dev/lain/claudejb/NoCommentsContractTest.kt b/src/test/kotlin/dev/lain/claudejb/NoCommentsContractTest.kt index d1349810..e5367050 100644 --- a/src/test/kotlin/dev/lain/claudejb/NoCommentsContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/NoCommentsContractTest.kt @@ -8,16 +8,15 @@ class NoCommentsContractTest { @Test fun `the scan sees the whole tree`() { - assertTrue(MainSources.files().size >= MIN_SOURCES) { "only ${MainSources.files().size} sources found" } + assertTrue(SourceLayout.kotlinFiles().size >= MIN_SOURCES) { "only ${SourceLayout.kotlinFiles().size} sources found" } } @Test fun `no main source carries a comment`() { - val root = MainSources.root(SOURCE_ROOT) - val offenders = MainSources.files().flatMap { file -> + val offenders = SourceLayout.kotlinFiles().flatMap { file -> file.readLines().withIndex() .filter { (_, raw) -> isComment(raw) } - .map { (index, raw) -> "${file.relativeTo(root).invariantSeparatorsPath}:${index + 1}: ${raw.trim()}" } + .map { (index, raw) -> "${file.invariantSeparatorsPath}:${index + 1}: ${raw.trim()}" } } assertEquals(emptyList(), offenders) { "The reasoning goes into a name, a test or the commit message, never into a comment. " + @@ -34,7 +33,6 @@ class NoCommentsContractTest { } private companion object { - const val SOURCE_ROOT = "src/main/kotlin" const val MIN_SOURCES = 100 val PRAGMAS = listOf("noinspection", "MAP:GENERATED") val COMMENT_OPENERS = listOf("//", "/*", "* ") diff --git a/src/test/kotlin/dev/lain/claudejb/PackageDependencyContractTest.kt b/src/test/kotlin/dev/lain/claudejb/PackageDependencyContractTest.kt index 4544a5e9..de5b3036 100644 --- a/src/test/kotlin/dev/lain/claudejb/PackageDependencyContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/PackageDependencyContractTest.kt @@ -7,8 +7,8 @@ import java.io.File class PackageDependencyContractTest { - private val imports: List = MainSources.files().flatMap { file -> - val from = layerOf(packageOf(file)) + private val imports: List = SourceLayout.kotlinFiles().flatMap { file -> + val from = layerOf(SourceLayout.packagePath(file)) MainSources.codeOf(file) .map { it.trim() } .filter { it.startsWith(IMPORT_PREFIX) } @@ -17,13 +17,13 @@ class PackageDependencyContractTest { @Test fun `the scan sees the whole tree`() { - assertTrue(MainSources.files().size >= MIN_SOURCES) { "only ${MainSources.files().size} sources found" } + assertTrue(SourceLayout.kotlinFiles().size >= MIN_SOURCES) { "only ${SourceLayout.kotlinFiles().size} sources found" } assertTrue(imports.size >= MIN_IMPORTS) { "only ${imports.size} in-repo imports found" } } @Test fun `every package sits in a declared layer`() { - val orphans = MainSources.files().map { packageOf(it) }.distinct().filter { layerOf(it) == null } + val orphans = SourceLayout.kotlinFiles().map { SourceLayout.packagePath(it) }.distinct().filter { layerOf(it) == null } assertEquals(emptyList(), orphans) { "A package exists that no layer claims; add it to LAYERS with the layers it may import." } @@ -33,7 +33,7 @@ class PackageDependencyContractTest { fun `every layer imports only the layers below it`() { val offenders = imports .filter { it.from != null && it.to != null && it.to != it.from && it.to !in ALLOWED.getValue(it.from) } - .map { "${it.file.relativeTo(MainSources.root(SOURCE_ROOT))}: ${it.from} -> ${it.to}" } + .map { "${SourceLayout.pathInRoot(it.file)}: ${it.from} -> ${it.to}" } assertEquals(emptyList(), offenders) { "A layer reaches above itself. model/ never imports controller/ or view/, controller/session and " + "controller/mcp open diff editors and nothing else of view/, and the wire and the guard stay below everything: the " + @@ -43,8 +43,8 @@ class PackageDependencyContractTest { @Test fun `the wire and the guard know nothing of the platform`() { - val offenders = MainSources.files() - .filter { file -> PLATFORM_FREE.any { packageOf(file).startsWith(it) } } + val offenders = SourceLayout.kotlinFiles() + .filter { file -> PLATFORM_FREE.any { SourceLayout.packagePath(file).startsWith(it) } } .flatMap { file -> MainSources.codeOf(file).map { it.trim() } .filter { line -> PLATFORM_IMPORTS.any { line.startsWith("import $it") } } @@ -56,17 +56,10 @@ class PackageDependencyContractTest { } } - private fun packageOf(file: File): String { - val rel = file.relativeTo(MainSources.root(SOURCE_ROOT)).invariantSeparatorsPath - return rel.removePrefix("$PACKAGE_ROOT/").substringBeforeLast('/', "") - } - private data class Import(val file: File, val from: String?, val to: String?) private companion object { - const val SOURCE_ROOT = "src/main/kotlin" - const val PACKAGE_ROOT = "dev/lain/claudejb" const val IMPORT_PREFIX = "import dev.lain.claudejb." const val MIN_SOURCES = 100 const val MIN_IMPORTS = 300 @@ -104,6 +97,9 @@ class PackageDependencyContractTest { const val C_BRIDGE = "controller/bridge" const val C_COMMANDS = "controller/commands" const val C_ACTIONS = "controller/actions" + const val RPC = "rpc" + const val RPC_BACKEND = "rpc/backend" + const val FRONTEND = "frontend" val MODEL = setOf(PROTOCOL, PERMISSION, BRIDGE, SETTINGS, DIFF, CONTEXT, GIT, VULN, SESSION, MCP, UTIL) @@ -128,10 +124,13 @@ class PackageDependencyContractTest { C_GIT to MODEL, C_SESSION to MODEL + setOf(C_PROCESS, C_VULN, C_GIT, C_CONTEXT, C_MCP, V_DIFF), V_DIFF to MODEL, - VIEW to MODEL + setOf(C_SESSION, C_GIT, C_VULN, C_PROCESS, C_CONTEXT, V_DIFF, C_COMMANDS, C_BRIDGE), - C_BRIDGE to MODEL + setOf(C_SESSION, C_GIT, C_VULN, C_PROCESS, C_CONTEXT, V_DIFF, VIEW, C_COMMANDS), - C_COMMANDS to MODEL + setOf(C_SESSION, C_GIT, C_VULN, C_PROCESS, C_CONTEXT, V_DIFF, VIEW, C_BRIDGE), - C_ACTIONS to MODEL + setOf(C_SESSION, C_GIT, C_VULN, C_PROCESS, C_CONTEXT, V_DIFF, VIEW, C_BRIDGE, C_COMMANDS), + VIEW to MODEL + setOf(C_SESSION, C_GIT, C_VULN, C_PROCESS, C_CONTEXT, V_DIFF, C_COMMANDS, C_BRIDGE, RPC), + C_BRIDGE to MODEL + setOf(C_SESSION, C_GIT, C_VULN, C_PROCESS, C_CONTEXT, V_DIFF, VIEW, C_COMMANDS, RPC), + C_COMMANDS to MODEL + setOf(C_SESSION, C_GIT, C_VULN, C_PROCESS, C_CONTEXT, V_DIFF, VIEW, C_BRIDGE, RPC), + C_ACTIONS to MODEL + setOf(C_SESSION, C_GIT, C_VULN, C_PROCESS, C_CONTEXT, V_DIFF, VIEW, C_BRIDGE, C_COMMANDS, RPC), + RPC to setOf(), + RPC_BACKEND to MODEL + setOf(C_SESSION, C_GIT, C_VULN, C_PROCESS, C_CONTEXT, V_DIFF, VIEW, C_BRIDGE, C_COMMANDS, RPC), + FRONTEND to setOf(RPC), ) val PLATFORM_FREE = setOf(PROTOCOL, PERMISSION, BRIDGE, MCP) diff --git a/src/test/kotlin/dev/lain/claudejb/PluginDependenciesContractTest.kt b/src/test/kotlin/dev/lain/claudejb/PluginDependenciesContractTest.kt index 5f38f06b..609bd5b4 100644 --- a/src/test/kotlin/dev/lain/claudejb/PluginDependenciesContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/PluginDependenciesContractTest.kt @@ -8,39 +8,55 @@ import java.io.File class PluginDependenciesContractTest { - private val metaInf = File("src/main/resources/META-INF") - private val descriptor = metaInf.resolve("plugin.xml").readText() - private val depends = DEPENDS.findAll(descriptor).map { Dependency(it.groupValues[1], it.groupValues[2].trim()) }.toList() + @Test + fun `the scan reads the plugin descriptor and the modules it declares`() { + assertTrue(SourceLayout.pluginDescriptor() in SourceLayout.descriptors()) { "plugin.xml is not among ${SourceLayout.descriptors()}" } + assertTrue(PluginModules.content().isNotEmpty()) { "plugin.xml declares no ; the scan would pass vacuously" } + assertTrue(PluginModules.alwaysLoaded().sumOf { PluginModules.pluginDependencies(it).size } > 0) { + "no dependency found in the always-loaded descriptors; the pattern has stopped matching" + } + } + + @Test + fun `no descriptor declares a v1 depends, which the modular model does not honour`() { + val v1 = SourceLayout.descriptors().filter { DEPENDS.containsMatchIn(it.readText()) } + assertEquals(emptyList(), v1) { + "A plugin with modules declares its dependencies in ; a next to them is " + + "either ignored or refused, and either way the dependency it names is no longer checked." + } + } @Test fun `every mandatory dependency is a platform module that every IntelliJ-based IDE ships`() { - val mandatory = depends.filterNot { it.optional }.map { it.id } + val plugins = PluginModules.alwaysLoaded().flatMap { PluginModules.pluginDependencies(it) } assertEquals( emptyList(), - mandatory - MODULES_IN_EVERY_IDE, - "A mandatory the target IDE cannot satisfy means the plugin does not load at all, and the " + - "verifier no longer fails the build over missing dependencies because it cannot tell an optional " + - "one from a mandatory one. Make it optional with a config-file, or add it here with the proof that " + - "every IDE in the verified range ships it.", + plugins - MODULES_IN_EVERY_IDE, + "A plugin dependency of plugin.xml or of a required module the target IDE cannot satisfy means the plugin " + + "does not load at all, and the verifier does not fail the build over missing dependencies. Move it " + + "into an optional content module, or add it here with the proof that every IDE in the verified range ships it.", ) + val ours = PluginModules.content().keys + val modules = PluginModules.alwaysLoaded().flatMap { PluginModules.moduleDependencies(it) } + .filterNot { it in ours || it.startsWith(PLATFORM_MODULE) } + assertEquals(emptyList(), modules) { + "A required module depends on a module that is neither this plugin's nor the platform's own ($PLATFORM_MODULE*)." + } } @Test - fun `every optional dependency names a config-file that exists`() { - depends.filter { it.optional }.forEach { dependency -> - val configFile = dependency.configFile - assertTrue(configFile != null, "${dependency.id} is optional but names no config-file") - assertTrue( - metaInf.resolve(configFile!!).isFile, - "META-INF/$configFile is referenced by plugin.xml for ${dependency.id} but does not exist", - ) + fun `every content module has a descriptor that exists`() { + val missing = PluginModules.content().keys.filter { PluginModules.descriptorOf(it) == null } + assertEquals(emptyList(), missing) { + "plugin.xml's names a module with no .xml in any module's resources; the platform refuses the plugin" } } @Test fun `the verifier is not asked to fail on missing dependencies, because it fails the optional ones too`() { - val failureLevels = Regex("""failureLevel\s*=\s*listOf\(([^)]*)\)""").find(File("build.gradle.kts").readText()) - assertTrue(failureLevels != null, "No failureLevel list found in build.gradle.kts") + val failureLevels = SourceLayout.buildScripts() + .firstNotNullOfOrNull { Regex("""failureLevel\s*=\s*listOf\(([^)]*)\)""").find(it.readText()) } + assertTrue(failureLevels != null, "No failureLevel list found in ${SourceLayout.buildScripts()}") assertFalse( "MISSING_DEPENDENCIES" in failureLevels!!.groupValues[1], "FailureLevel.MISSING_DEPENDENCIES turns every optional dependency a target IDE lacks into a failed " + @@ -48,15 +64,9 @@ class PluginDependenciesContractTest { ) } - private class Dependency(attributes: String, val id: String) { - val optional = OPTIONAL.containsMatchIn(attributes) - val configFile = CONFIG_FILE.find(attributes)?.groupValues?.get(1) - } - private companion object { - val DEPENDS = Regex("""]*)>([^<]+)""") - val OPTIONAL = Regex("""optional\s*=\s*"true"""") - val CONFIG_FILE = Regex("""config-file\s*=\s*"([^"]+)"""") + const val PLATFORM_MODULE = "intellij.platform." + val DEPENDS = Regex(""" = + CONTENT.findAll(SourceLayout.pluginDescriptor().readText()).flatMap { MODULE.findAll(it.value) } + .associate { it.groupValues[1] to it.value } + + fun descriptorOf(module: String): File? = SourceLayout.descriptor("$module.xml") + + fun dependencies(descriptor: File): String = + DEPENDENCIES.findAll(descriptor.readText()).joinToString("\n") { it.value } + + fun pluginDependencies(descriptor: File): List = + PLUGIN.findAll(dependencies(descriptor)).map { it.groupValues[1] }.toList() + + fun moduleDependencies(descriptor: File): List = + MODULE.findAll(dependencies(descriptor)).map { it.groupValues[1] }.toList() + + fun isOptional(entry: String): Boolean = !REQUIRED.containsMatchIn(entry) && REQUIRED_IF_AVAILABLE !in entry + + fun alwaysLoaded(): List = + listOf(SourceLayout.pluginDescriptor()) + + content().filterValues { !isOptional(it) }.keys.mapNotNull { descriptorOf(it) } + + fun declaring(pluginId: String): List = SourceLayout.descriptors().filter { pluginId in pluginDependencies(it) } + + fun moduleName(descriptor: File): String = descriptor.name.removeSuffix(".xml") + + const val REQUIRED_IF_AVAILABLE = "required-if-available" + + private val CONTENT = Regex("""]*>[\s\S]*?""") + private val DEPENDENCIES = Regex("""]*>[\s\S]*?""") + private val MODULE = Regex("""]*>""") + private val PLUGIN = Regex("""]*>""") + private val REQUIRED = Regex("""\bloading\s*=\s*"(required|embedded)"""") +} diff --git a/src/test/kotlin/dev/lain/claudejb/PrivateReachabilityContractTest.kt b/src/test/kotlin/dev/lain/claudejb/PrivateReachabilityContractTest.kt index 20496387..76791902 100644 --- a/src/test/kotlin/dev/lain/claudejb/PrivateReachabilityContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/PrivateReachabilityContractTest.kt @@ -7,7 +7,7 @@ import java.io.File class PrivateReachabilityContractTest { - private val files: List = MainSources.files() + private val files: List = SourceLayout.kotlinFiles() @Test fun `the scan reaches the sources and finds private declarations to judge`() { diff --git a/src/test/kotlin/dev/lain/claudejb/ReachabilityContractTest.kt b/src/test/kotlin/dev/lain/claudejb/ReachabilityContractTest.kt index b722df66..b3b23b54 100644 --- a/src/test/kotlin/dev/lain/claudejb/ReachabilityContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/ReachabilityContractTest.kt @@ -19,7 +19,7 @@ class ReachabilityContractTest { "The declaration pattern has stopped matching this codebase's style." } assertTrue(entryPoints().isNotEmpty()) { - "No dev.lain.claudejb class is named in ${DESCRIPTORS.joinToString()}. Either the descriptors moved " + + "No dev.lain.claudejb class is named in ${entryDescriptors().joinToString()}. Either the descriptors moved " + "or they stopped declaring the plugin's entry points — both make every finding below suspect." } } @@ -102,18 +102,15 @@ class ReachabilityContractTest { } private fun entryPoints(): Set = - DESCRIPTORS - .map { resourceRoot().resolve(it) } - .filter { it.isFile } + entryDescriptors() .flatMap { file -> PLUGIN_CLASS.findAll(file.readText()).map { it.groupValues[1] }.toList() } .toSet() + private fun entryDescriptors(): List = + listOf(SourceLayout.pluginDescriptor()) + PluginModules.content().keys.mapNotNull { PluginModules.descriptorOf(it) } + private fun mainSources(): List = - sourceRoot().walkTopDown() - .filter { it.isFile && it.extension == "kt" } - .map { Source(it, codeOf(it)) } - .toList() - .sortedBy { it.file.path } + SourceLayout.kotlinFiles().map { Source(it, codeOf(it)) } private fun codeOf(file: File): List { var inBlockComment = false @@ -137,14 +134,6 @@ class ReachabilityContractTest { private fun withoutLineComment(line: String): String = line.substringBefore("//") - private fun sourceRoot(): File = resolveFromEitherRoot("src/main/kotlin") - - private fun resourceRoot(): File = resolveFromEitherRoot("src/main/resources/META-INF") - - private fun resolveFromEitherRoot(path: String): File = - sequenceOf(File(path), File("../$path")).firstOrNull { it.isDirectory } - ?: error("could not locate $path from ${File("").absolutePath}") - private fun memberName(line: String): String? { if (SKIPPED_MODIFIER.containsMatchIn(line)) return null val match = MEMBER_DECLARATION.find(line) ?: return null @@ -183,8 +172,6 @@ class ReachabilityContractTest { private companion object { - val DESCRIPTORS = listOf("plugin.xml", "claude-git.xml", "claude-terminal.xml") - const val MIN_SOURCES = 100 const val MIN_DECLARATIONS = 100 diff --git a/src/test/kotlin/dev/lain/claudejb/SourceLayout.kt b/src/test/kotlin/dev/lain/claudejb/SourceLayout.kt new file mode 100644 index 00000000..f380bbdf --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/SourceLayout.kt @@ -0,0 +1,76 @@ +package dev.lain.claudejb + +import java.io.File + +internal object SourceLayout { + + const val PACKAGE_ROOT = "dev/lain/claudejb" + + val MODULE_NAMES = mapOf( + "shared" to "dev.lain.claudejb.shared", + "frontend" to "dev.lain.claudejb.frontend", + "backend" to "dev.lain.claudejb.backend", + ) + + private val MODULES = listOf("backend", "frontend", "shared", "") + + fun roots(kind: String): List = MODULES.mapNotNull { dir("${prefix(it)}src/main/$kind") } + + fun rootsOf(module: String, kind: String): List = listOfNotNull(dir("${prefix(module)}src/main/$kind")) + + fun kotlinFiles(): List = files(roots("kotlin"), setOf("kt")) + + fun jvmFiles(): List = files(roots("kotlin") + roots("java"), setOf("kt", "java")) + + fun tsFiles(): List = files(roots("ts"), setOf("ts")) + + fun files(roots: List, extensions: Set): List = + roots.flatMap { root -> root.walkTopDown().filter { it.isFile && it.extension in extensions }.toList() } + .sortedBy { it.invariantSeparatorsPath } + + fun pathInRoot(file: File): String { + val root = (roots("kotlin") + roots("java") + roots("ts")).first { file.startsWith(it) } + return file.relativeTo(root).invariantSeparatorsPath + } + + fun packagePath(file: File): String = + pathInRoot(file).removePrefix("$PACKAGE_ROOT/").substringBeforeLast('/', "") + + fun source(relative: String): File = + roots("kotlin").map { File(it, "$PACKAGE_ROOT/$relative") }.firstOrNull { it.isFile } + ?: kotlinFiles().filter { it.name == File(relative).name }.let { found -> + check(found.size == 1) { "expected one ${File(relative).name} in ${roots("kotlin")}, found $found" } + found.single() + } + + fun mainDir(relative: String): File = + MODULES.firstNotNullOfOrNull { dir("${prefix(it)}src/main/$relative") } + ?: error("could not locate src/main/$relative in any module from ${File("").absolutePath}") + + fun mainFile(relative: String): File = + MODULES.flatMap { listOf("${prefix(it)}src/main/$relative", "../${prefix(it)}src/main/$relative") } + .map(::File).firstOrNull { it.isFile } + ?: error("could not locate src/main/$relative in any module from ${File("").absolutePath}") + + fun descriptors(): List = + roots("resources").flatMap { root -> + (root.listFiles().orEmpty().toList() + File(root, "META-INF").listFiles().orEmpty().toList()) + .filter { it.isFile && it.extension == "xml" } + }.sortedBy { it.invariantSeparatorsPath } + + fun descriptor(name: String): File? = descriptors().firstOrNull { it.name == name } + + fun pluginDescriptor(): File = mainFile("resources/META-INF/plugin.xml") + + fun moduleDescriptor(module: String): File { + val path = "${prefix(module)}src/main/resources/${MODULE_NAMES.getValue(module)}.xml" + return sequenceOf(File(path), File("../$path")).firstOrNull { it.isFile } ?: File(path) + } + + fun buildScripts(): List = + MODULES.map { File("${prefix(it)}build.gradle.kts") }.filter { it.isFile } + + private fun prefix(module: String): String = if (module.isEmpty()) "" else "$module/" + + private fun dir(path: String): File? = sequenceOf(File(path), File("../$path")).firstOrNull { it.isDirectory } +} diff --git a/src/test/kotlin/dev/lain/claudejb/controller/bridge/PageStateRecoveryContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/bridge/PageStateRecoveryContractTest.kt index 5fbbbc77..c0d57317 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/bridge/PageStateRecoveryContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/bridge/PageStateRecoveryContractTest.kt @@ -1,5 +1,6 @@ package dev.lain.claudejb.controller.bridge +import dev.lain.claudejb.SourceLayout import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -74,27 +75,31 @@ class PageStateRecoveryContractTest { val lines = source("controller/bridge/BridgeLifecycle.kt").readLines() val start = lines.indexOfFirst { it.contains("Msg.Ready ->") } assertTrue(start >= 0) { "BridgeLifecycle no longer handles Msg.Ready" } - val length = lines.drop(start + 1).indexOfFirst { it == " }" } - assertTrue(length >= 0) { "could not find the end of the Msg.Ready branch" } - val branch = lines.subList(start, start + 1 + length) + val branch = if (lines[start].trimEnd().endsWith("{")) { + val length = lines.drop(start + 1).indexOfFirst { it == " }" } + assertTrue(length >= 0) { "could not find the end of the Msg.Ready branch" } + lines.subList(start, start + 1 + length) + } else { + listOf(lines[start]) + } + val delegate = DELEGATE.find(branch.joinToString("\n"))?.groupValues?.get(1) + val reached = branch + delegate?.let { bodyOf(source("view/window/ChatPresenter.kt").readLines(), "fun $it(") }.orEmpty() - assertTrue(branch.any { it.contains("agentTabs.render()") }) { + assertTrue(reached.any { it.contains("agentTabs.render()") }) { "the Ready branch re-pushes everything the page needs EXCEPT the tab bar. A page that reloaded, " + "or that came up on a later rung of the delivery ladder, is then drawn with an empty chat " + "list — and the page hides #tabsbar entirely, dashboard view buttons included.\n" + - branch.joinToString("\n") + reached.joinToString("\n") } } @Test fun `the tab bar has exactly one emitter, and it is the one Ready calls`() { - val emitters = File(mainRoot(), "dev/lain/claudejb").walkTopDown() - .filter { it.isFile && it.extension == "kt" } - .filter { it.readText().contains("window.cc.tabs(") } + val emitters = SourceLayout.kotlinFiles() + .filter { TABS_PUSH.containsMatchIn(it.readText()) } .map { it.name } - .toList() assertEquals(listOf("ChatAgentTabs.kt"), emitters) { - "window.cc.tabs is emitted from more than one place: $emitters" + "the page's tabs push is emitted from more than one place: $emitters" } } @@ -107,16 +112,11 @@ class PageStateRecoveryContractTest { return lines.subList(from, from + 2 + length) } - private fun source(relative: String) = File(mainRoot(), "dev/lain/claudejb/$relative").also { - assertTrue(it.isFile) { "missing source file: $it" } - } - - private fun mainRoot(): File = - sequenceOf(File("src/main/kotlin"), File("../src/main/kotlin")) - .firstOrNull { it.isDirectory } - ?: error("could not locate src/main/kotlin from ${File("").absolutePath}") + private fun source(relative: String): File = SourceLayout.source(relative) private companion object { val ARMS = Regex("""\barm\(""") + val DELEGATE = Regex("""\bpresenter\.(\w+)\(\)""") + val TABS_PUSH = Regex("""window\.cc\.tabs\(|\b(?:exec|execBuilt|PagePush)\(\s*"tabs"""") } } diff --git a/src/test/kotlin/dev/lain/claudejb/controller/commands/git/IdeActionApiContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/commands/git/IdeActionApiContractTest.kt index b038b98e..154dd96b 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/commands/git/IdeActionApiContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/commands/git/IdeActionApiContractTest.kt @@ -6,6 +6,7 @@ import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.actionSystem.Presentation import com.intellij.openapi.project.Project import dev.lain.claudejb.MainSources +import dev.lain.claudejb.SourceLayout import dev.lain.claudejb.model.bridge.JcefBridge import dev.lain.claudejb.model.bridge.Msg import dev.lain.claudejb.model.session.agents.AgentStatus @@ -16,7 +17,6 @@ import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import java.awt.event.InputEvent -import java.io.File import java.lang.reflect.Method import java.nio.file.Path @@ -121,7 +121,7 @@ class IdeActionApiContractTest { @Test fun `IdeActionInvoker invokes actions through performAction and nothing else`() { - val source = source("src/main/kotlin/dev/lain/claudejb/controller/commands/git/IdeActionInvoker.kt") + val source = source("controller/commands/git/IdeActionInvoker.kt") assertTrue( "ActionUtil.performAction(" in source, "IdeActionInvoker must invoke platform actions through ActionUtil.performAction.", @@ -134,7 +134,7 @@ class IdeActionApiContractTest { @Test fun `the one command the plugin runs is a fixed argument vector, never a shell string`() { - val source = source("src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitInit.kt") + val source = source("controller/commands/git/GitInit.kt") listOf("/bin/sh", "cmd.exe", "powershell", "-c\"", "ProcessBuilder", "Runtime.getRuntime").forEach { assertFalse(it in source, "GitInit must not reach a shell or spawn a process by hand: found '$it'") } @@ -160,7 +160,7 @@ class IdeActionApiContractTest { @Test fun `nothing in this plugin decides how the IDE draws its popups`() { - val offenders = MainSources.files() + val offenders = SourceLayout.kotlinFiles() .flatMap { file -> MainSources.codeOf(file).map { file.name to it } } .filter { (_, line) -> "LightWeightPopupEnabled" in line || "System.setProperty(" in line } .map { (name, line) -> "$name: ${line.trim()}" } @@ -187,14 +187,11 @@ class IdeActionApiContractTest { private fun actionUtil(): Class<*> = load("com.intellij.openapi.actionSystem.ex.ActionUtil") - private fun codeOf(relative: String): List = - MainSources.codeOf(File(MainSources.root("src/main/kotlin"), "dev/lain/claudejb/$relative")) + private fun codeOf(relative: String): List = MainSources.codeOf(SourceLayout.source(relative)) private fun load(name: String): Class<*> = Class.forName(name, false, javaClass.classLoader) - private fun source(path: String): String = - sequenceOf(File(path), File("../$path")).firstOrNull { it.isFile }?.readText() - ?: error("could not locate $path from ${File("").absolutePath}") + private fun source(relative: String): String = SourceLayout.source(relative).readText() private fun Method.assertNotDeprecated(): Method = apply { assertFalse( diff --git a/src/test/kotlin/dev/lain/claudejb/controller/db/DbGatewayContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/db/DbGatewayContractTest.kt index 99498684..69f1a2db 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/db/DbGatewayContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/db/DbGatewayContractTest.kt @@ -1,6 +1,7 @@ package dev.lain.claudejb.controller.db import dev.lain.claudejb.MainSources +import dev.lain.claudejb.SourceLayout import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -8,7 +9,7 @@ import java.io.File class DbGatewayContractTest { - private val sources: List = MainSources.files() + private val sources: List = SourceLayout.kotlinFiles() private val gateway: File = sources.single { it.name == GATEWAY } diff --git a/src/test/kotlin/dev/lain/claudejb/controller/git/GitDependencyContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/git/GitDependencyContractTest.kt index cb61635b..58bf38cd 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/git/GitDependencyContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/git/GitDependencyContractTest.kt @@ -1,59 +1,56 @@ package dev.lain.claudejb.controller.git +import dev.lain.claudejb.PluginModules +import dev.lain.claudejb.SourceLayout import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import java.io.File class GitDependencyContractTest { - private val main = File("src/main") - private val descriptor = main.resolve("resources/META-INF/plugin.xml").readText() + private val declaring: List = PluginModules.declaring(PLUGIN) @Test - fun `the descriptor declares Git4Idea whenever the sources use it`() { + fun `a descriptor declares Git4Idea whenever the sources use it`() { val usesGit = gitPackageSources().any { "git4idea" in it.readText() } if (!usesGit) return assertTrue( - DEPENDS.containsMatchIn(descriptor), - "The sources import git4idea, so META-INF/plugin.xml MUST declare Git4Idea as a dependency — the " + - "Gradle bundledPlugin() only covers compilation, not the runtime classloader.", + declaring.isNotEmpty(), + "The sources import git4idea, so a content module MUST declare in its — " + + "the Gradle bundledPlugins() only covers compilation, not the runtime classloader.", ) } @Test fun `the Git dependency is optional, never hard`() { - val match = DEPENDS.find(descriptor) - assertTrue(match != null, "No Git4Idea found in plugin.xml") - assertTrue( - "optional=\"true\"" in match!!.value, - "Git4Idea must be an OPTIONAL dependency: an IDE with Git disabled, or a project that is not a " + - "working copy, must still load this plugin and run every chat. Found: ${match.value}", - ) - } - - @Test - fun `the optional dependency's config-file exists, or the platform refuses the descriptor`() { - val configFile = Regex("""config-file="([^"]+)"[^>]*>Git4Idea<""").find(descriptor)?.groupValues?.get(1) - assertTrue(configFile != null, "An optional must name a config-file") - assertTrue( - main.resolve("resources/META-INF/$configFile").isFile, - "META-INF/$configFile is referenced by plugin.xml but does not exist", - ) + assertTrue(declaring.isNotEmpty(), "No descriptor declares ") + val content = PluginModules.content() + declaring.forEach { descriptor -> + val entry = content[PluginModules.moduleName(descriptor)] + assertTrue( + entry != null && PluginModules.isOptional(entry), + "Git4Idea must be the dependency of an OPTIONAL content module: an IDE with Git disabled, or a project that " + + "is not a working copy, must still load this plugin and run every chat. Declared by ${descriptor.path}, " + + "loaded as: $entry", + ) + } } @Test fun `the build declares the bundled Git plugin, or nothing compiles against it`() { - val build = File("build.gradle.kts").readText() assertTrue( - Regex("""bundledPlugin\(\s*"Git4Idea"\s*\)""").containsMatchIn(build), - "build.gradle.kts must declare bundledPlugin(\"Git4Idea\") in the intellijPlatform dependencies.", + SourceLayout.buildScripts().any { script -> + BUNDLED.findAll(script.readText()).any { "\"$PLUGIN\"" in it.groupValues[1] } + }, + "A build.gradle.kts must declare \"$PLUGIN\" in bundledPlugins() of the intellijPlatform dependencies.", ) } private fun gitPackageSources(): List = - main.resolve("kotlin/dev/lain/claudejb/controller/git").walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() + SourceLayout.kotlinFiles().filter { "${SourceLayout.packagePath(it)}/".startsWith("controller/git/") } private companion object { - val DEPENDS = Regex("""]*>Git4Idea""") + const val PLUGIN = "Git4Idea" + val BUNDLED = Regex("""bundledPlugins?\(([^)]*)\)""") } } diff --git a/src/test/kotlin/dev/lain/claudejb/controller/github/GitHubGatewayContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/github/GitHubGatewayContractTest.kt index d394d70e..b4d676cf 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/github/GitHubGatewayContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/github/GitHubGatewayContractTest.kt @@ -1,6 +1,8 @@ package dev.lain.claudejb.controller.github import dev.lain.claudejb.MainSources +import dev.lain.claudejb.PluginModules +import dev.lain.claudejb.SourceLayout import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -8,8 +10,7 @@ import java.io.File class GitHubGatewayContractTest { - private val sources: List = MainSources.files() - private val descriptor = File("src/main/resources/META-INF/plugin.xml").readText() + private val sources: List = SourceLayout.kotlinFiles() @Test fun `the gateway exists and is the only file naming a GitHub plugin type`() { @@ -33,13 +34,18 @@ class GitHubGatewayContractTest { } @Test - fun `the GitHub plugin is an optional dependency with an existing config-file, and the build compiles against it`() { - val match = DEPENDS.find(descriptor) - assertTrue(match != null, "No org.jetbrains.plugins.github in plugin.xml") - assertTrue("optional=\"true\"" in match!!.value, "the GitHub dependency must be optional: ${match.value}") - val configFile = Regex("""config-file="([^"]+)"""").find(match.value)?.groupValues?.get(1) - assertTrue(configFile != null && File("src/main/resources/META-INF/$configFile").isFile, "config-file $configFile is missing") - assertTrue(Regex("""bundledPlugin\(\s*"org\.jetbrains\.plugins\.github"\s*\)""").containsMatchIn(File("build.gradle.kts").readText())) + fun `the GitHub plugin is the dependency of an optional content module, and the build compiles against it`() { + val declaring = PluginModules.declaring(PLUGIN) + assertTrue(declaring.isNotEmpty(), "No descriptor declares in its ") + declaring.forEach { descriptor -> + val entry = PluginModules.content()[PluginModules.moduleName(descriptor)] + assertTrue(entry != null && PluginModules.isOptional(entry)) { + "the GitHub dependency must belong to an optional content module named in plugin.xml: ${descriptor.path} is $entry" + } + } + assertTrue( + SourceLayout.buildScripts().any { script -> BUNDLED.findAll(script.readText()).any { "\"$PLUGIN\"" in it.groupValues[1] } }, + ) } private companion object { @@ -48,6 +54,8 @@ class GitHubGatewayContractTest { val GITHUB_TYPE = Regex("""\borg\.jetbrains\.plugins\.github\.[A-Za-z]""") val ENTRY = Regex("""^ {4}(suspend )?fun \w+\(.*""") - val DEPENDS = Regex("""]*>org\.jetbrains\.plugins\.github""") + const val PLUGIN = "org.jetbrains.plugins.github" + + val BUNDLED = Regex("""bundledPlugins?\(([^)]*)\)""") } } diff --git a/src/test/kotlin/dev/lain/claudejb/controller/mcp/FocusContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/mcp/FocusContractTest.kt index c593f616..1660fbdc 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/mcp/FocusContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/mcp/FocusContractTest.kt @@ -1,5 +1,6 @@ package dev.lain.claudejb.controller.mcp +import dev.lain.claudejb.SourceLayout import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -7,16 +8,12 @@ import java.io.File class FocusContractTest { - private val root = File("src/main/kotlin/dev/lain/claudejb/controller/mcp") - - private val sources: List = root.walkTopDown() - .filter { it.isFile && it.extension == "kt" && it.name !in CLICK_DRIVEN } - .toList() - .sortedBy { it.path } + private val sources: List = SourceLayout.kotlinFiles() + .filter { "${SourceLayout.packagePath(it)}/".startsWith("$PACKAGE/") && it.name !in CLICK_DRIVEN } @Test fun `the scan sees the tool sources and the reveal seam`() { - assertTrue(sources.size >= MIN_SOURCES) { "only ${sources.size} sources under $root" } + assertTrue(sources.size >= MIN_SOURCES) { "only ${sources.size} sources under $PACKAGE" } assertTrue(sources.any { it.name == KEEPER }) { "$KEEPER is gone; the focus contract has no seam left" } assertTrue(sources.any { it.name == REVEAL }) { "$REVEAL is gone; tools reveal through it" } } @@ -25,7 +22,7 @@ class FocusContractTest { fun `no tool takes the user's focus`() { val hits = sources.filter { it.name != KEEPER }.flatMap { file -> file.readLines().mapIndexedNotNull { index, line -> - STEALS.firstOrNull { it.containsMatchIn(line) }?.let { "${file.relativeTo(root)}:${index + 1}: ${line.trim()}" } + STEALS.firstOrNull { it.containsMatchIn(line) }?.let { "${SourceLayout.pathInRoot(file)}:${index + 1}: ${line.trim()}" } } } assertEquals(emptyList(), hits) { @@ -44,6 +41,7 @@ class FocusContractTest { } private companion object { + const val PACKAGE = "controller/mcp" const val KEEPER = "FocusKeeper.kt" const val REVEAL = "Reveal.kt" const val MIN_SOURCES = 40 diff --git a/src/test/kotlin/dev/lain/claudejb/controller/mcp/IdeRuleCoverageContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/mcp/IdeRuleCoverageContractTest.kt index 9336c5f4..3c5f54cf 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/mcp/IdeRuleCoverageContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/mcp/IdeRuleCoverageContractTest.kt @@ -1,16 +1,15 @@ package dev.lain.claudejb.controller.mcp +import dev.lain.claudejb.SourceLayout import dev.lain.claudejb.model.session.launch.IdeRule import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -import java.io.File class IdeRuleCoverageContractTest { - private val declared: Set = File("src/main/kotlin/dev/lain/claudejb/controller/mcp/tools") - .walkTopDown() - .filter { it.isFile && it.extension == "kt" } + private val declared: Set = SourceLayout.kotlinFiles() + .filter { "${SourceLayout.packagePath(it)}/".startsWith("controller/mcp/tools/") } .flatMap { SPEC_NAME.findAll(it.readText()).map { match -> match.groupValues[1] } } .toSet() diff --git a/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/PluginDetailsGatewayContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/PluginDetailsGatewayContractTest.kt index 3bb966cb..899e6640 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/PluginDetailsGatewayContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/PluginDetailsGatewayContractTest.kt @@ -1,6 +1,7 @@ package dev.lain.claudejb.controller.mcp.tools.ops import dev.lain.claudejb.MainSources +import dev.lain.claudejb.SourceLayout import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -8,7 +9,7 @@ import java.io.File class PluginDetailsGatewayContractTest { - private val sources: List = MainSources.files() + private val sources: List = SourceLayout.kotlinFiles() private val gateway: File = sources.single { it.name == GATEWAY } diff --git a/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitCommandsGatewayContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitCommandsGatewayContractTest.kt index 00d1a6e8..ee7a1e62 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitCommandsGatewayContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitCommandsGatewayContractTest.kt @@ -1,6 +1,7 @@ package dev.lain.claudejb.controller.mcp.tools.vcs import dev.lain.claudejb.MainSources +import dev.lain.claudejb.SourceLayout import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -8,7 +9,7 @@ import java.io.File class GitCommandsGatewayContractTest { - private val sources: List = MainSources.files() + private val sources: List = SourceLayout.kotlinFiles() private val gateway: File = sources.single { it.name == GATEWAY } diff --git a/src/test/kotlin/dev/lain/claudejb/controller/process/EdtProcessTeardownContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/process/EdtProcessTeardownContractTest.kt index 744a16d0..fad54537 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/process/EdtProcessTeardownContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/process/EdtProcessTeardownContractTest.kt @@ -1,5 +1,6 @@ package dev.lain.claudejb.controller.process +import dev.lain.claudejb.SourceLayout import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -96,10 +97,6 @@ class EdtProcessTeardownContractTest { val CLAUDE_SESSION = source("controller/session/ClaudeSession.kt") val SESSION_LIFECYCLE = source("controller/session/SessionLifecycle.kt") - fun source(name: String): File { - val path = "src/main/kotlin/dev/lain/claudejb/$name" - return sequenceOf(File(path), File("../$path")).firstOrNull { it.isFile } - ?: error("could not locate $path from ${File("").absolutePath}") - } + fun source(name: String): File = SourceLayout.source(name) } } diff --git a/src/test/kotlin/dev/lain/claudejb/controller/session/InitializeOnEdtContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/session/InitializeOnEdtContractTest.kt index e4ae26c6..febfa526 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/session/InitializeOnEdtContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/session/InitializeOnEdtContractTest.kt @@ -1,5 +1,6 @@ package dev.lain.claudejb.controller.session +import dev.lain.claudejb.SourceLayout import dev.lain.claudejb.controller.session.control.SessionQueries import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -22,9 +23,5 @@ class InitializeOnEdtContractTest { } } - private fun source(name: String): File { - val path = "src/main/kotlin/dev/lain/claudejb/controller/session/$name" - return sequenceOf(File(path), File("../$path")).firstOrNull { it.isFile } - ?: error("could not locate $path from ${File("").absolutePath}") - } + private fun source(name: String): File = SourceLayout.source("controller/session/$name") } diff --git a/src/test/kotlin/dev/lain/claudejb/controller/session/turn/SessionTimerContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/session/turn/SessionTimerContractTest.kt index 6dc39155..198f3feb 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/session/turn/SessionTimerContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/session/turn/SessionTimerContractTest.kt @@ -1,5 +1,6 @@ package dev.lain.claudejb.controller.session.turn +import dev.lain.claudejb.SourceLayout import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import java.io.File @@ -75,11 +76,7 @@ class SessionTimerContractTest { val DECLARATION = Regex("""^\s*(?:private )?val (\w+) = javax\.swing\.Timer\(""") - fun resolve(name: String): File { - val path = "src/main/kotlin/dev/lain/claudejb/controller/session/$name" - return sequenceOf(File(path), File("../$path")).firstOrNull { it.isFile } - ?: error("could not locate $path from ${File("").absolutePath}") - } + fun resolve(name: String): File = SourceLayout.source("controller/session/$name") fun pollSource(): File = resolve(POLL_SOURCE_NAME) fun sessionSource(): File = resolve(SESSION_SOURCE_NAME) diff --git a/src/test/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptCapContractTest.kt b/src/test/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptCapContractTest.kt index 396eefb7..262c0c07 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptCapContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptCapContractTest.kt @@ -1,5 +1,6 @@ package dev.lain.claudejb.model.session.transcript +import dev.lain.claudejb.SourceLayout import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.fail @@ -25,11 +26,9 @@ class TranscriptCapContractTest { } } - private fun trimModule(): File = - sequenceOf(File(TRIM_MODULE), File("../$TRIM_MODULE")).firstOrNull { it.isFile } - ?: error("could not locate $TRIM_MODULE from ${File("").absolutePath}") + private fun trimModule(): File = SourceLayout.mainFile(TRIM_MODULE) private companion object { - const val TRIM_MODULE = "src/main/ts/jcef/controllers/chat/trim.ts" + const val TRIM_MODULE = "ts/jcef/controllers/chat/trim.ts" } } diff --git a/src/test/kotlin/dev/lain/claudejb/view/jcef/JcefDependencyContractTest.kt b/src/test/kotlin/dev/lain/claudejb/view/jcef/JcefDependencyContractTest.kt index 753ec845..a42cf898 100644 --- a/src/test/kotlin/dev/lain/claudejb/view/jcef/JcefDependencyContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/view/jcef/JcefDependencyContractTest.kt @@ -1,5 +1,7 @@ package dev.lain.claudejb.view.jcef +import dev.lain.claudejb.PluginModules +import dev.lain.claudejb.SourceLayout import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -7,39 +9,42 @@ import java.io.File class JcefDependencyContractTest { - private val root = File("src/main") - @Test - fun `the descriptor declares the JCEF plugin whenever the sources use it`() { - val usesJcef = root.resolve("kotlin").walkTopDown() - .filter { it.isFile && it.extension == "kt" } - .any { "com.intellij.ui.jcef" in it.readText() } - if (!usesJcef) return - - val descriptor = root.resolve("resources/META-INF/plugin.xml").readText() - assertTrue( - "com.intellij.modules.jcef" in descriptor.replace(" ", ""), - "The sources import com.intellij.ui.jcef, so META-INF/plugin.xml MUST declare " + - "com.intellij.modules.jcef — since 262 those classes come from a bundled " + - "plugin and are NOT on an undeclared plugin's classpath.", - ) + fun `every module whose sources use JCEF declares the JCEF plugin`() { + val users = SourceLayout.MODULE_NAMES.keys.filter { module -> + sourcesOf(module).any { "com.intellij.ui.jcef" in it.readText() } + } + assertTrue(users.isNotEmpty()) { "no module uses com.intellij.ui.jcef; the scan is looking at the wrong tree" } + users.forEach { module -> + val descriptor = SourceLayout.moduleDescriptor(module) + assertTrue( + descriptor.isFile && JCEF in PluginModules.pluginDependencies(descriptor), + "The $module sources import com.intellij.ui.jcef, so ${descriptor.path} MUST declare " + + "in its — since 262 those classes come from a bundled plugin and are NOT on an " + + "undeclared module's classpath.", + ) + } } @Test fun `the JCEF dependency is hard, never optional`() { - val descriptor = root.resolve("resources/META-INF/plugin.xml").readText() - val optional = Regex("""]*optional[^>]*>com\.intellij\.modules\.jcef""") + val loose = PluginModules.declaring(JCEF).filterNot { it in PluginModules.alwaysLoaded() } assertTrue( - !optional.containsMatchIn(descriptor), - "com.intellij.modules.jcef must be a HARD dependency: there is no browser-less mode to fall back to.", + loose.isEmpty(), + "com.intellij.modules.jcef must be a HARD dependency of plugin.xml or of a required module: there is no " + + "browser-less mode to fall back to. Declared by an optional module: $loose", ) } + private fun sourcesOf(module: String): List { + val roots = if (module == "backend") listOf("backend", "") else listOf(module) + return SourceLayout.files(roots.flatMap { SourceLayout.rootsOf(it, "kotlin") }, setOf("kt")) + } + @Test fun `sinceBuild is not lower than the first build that has the JCEF module`() { - val build = File("build.gradle.kts").readText() - val since = Regex("""sinceBuild\s*=\s*"([^"]+)"""").find(build)?.groupValues?.get(1) - assertNotNull(since, "No sinceBuild found in build.gradle.kts") + val since = SourceLayout.buildScripts().firstNotNullOfOrNull { sinceBuildIn(it.readText()) } + assertNotNull(since, "No sinceBuild found in ${SourceLayout.buildScripts()}") assertTrue( since!!.count { it == '.' } >= 2, "sinceBuild=\"$since\" is a branch, not a build. `253` includes 253.28294.334, where " + @@ -52,6 +57,13 @@ class JcefDependencyContractTest { ) } + private fun sinceBuildIn(script: String): String? { + val match = SINCE_BUILD.find(script) ?: return null + val literal = match.groupValues[1] + if (literal.isNotEmpty()) return literal + return Regex("""\bval\s+${match.groupValues[2]}\s*=\s*"([^"]+)"""").find(script)?.groupValues?.get(1) + } + private fun atLeast(actual: String, floor: String): Boolean { val a = actual.split('.').mapNotNull { it.toIntOrNull() } val f = floor.split('.').mapNotNull { it.toIntOrNull() } @@ -64,5 +76,7 @@ class JcefDependencyContractTest { private companion object { const val FIRST_BUILD_WITH_JCEF_MODULE = "253.29346.138" + const val JCEF = "com.intellij.modules.jcef" + val SINCE_BUILD = Regex("""\bsinceBuild\s*=\s*(?:"([^"]+)"|(\w+))""") } } diff --git a/src/test/kotlin/dev/lain/claudejb/view/jcef/PageAssetsContractTest.kt b/src/test/kotlin/dev/lain/claudejb/view/jcef/PageAssetsContractTest.kt index 7d7a6c15..2fbcbebb 100644 --- a/src/test/kotlin/dev/lain/claudejb/view/jcef/PageAssetsContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/view/jcef/PageAssetsContractTest.kt @@ -6,18 +6,29 @@ import org.junit.jupiter.api.Test class PageAssetsContractTest { - private fun present(resource: String): Boolean = PageAssembly::class.java.getResource(resource) != null + private val assembly: Class<*> = Class.forName(PAGE_ASSEMBLY, true, javaClass.classLoader) + + private val instance: Any = assembly.getField("INSTANCE").get(null) + + private val appNames: List = names("getAppNames") + + private val cssParts: List = names("getCSS_PARTS") + + private fun names(getter: String): List = + (assembly.getMethod(getter).invoke(instance) as List<*>).map { it.toString() } + + private fun present(resource: String): Boolean = assembly.getResource(resource) != null @Test fun `the page declares a full set of scripts and stylesheets`() { - assertTrue(PageAssembly.appNames.size >= MIN_SCRIPTS) { "only ${PageAssembly.appNames.size} scripts declared" } - assertTrue(PageAssembly.CSS_PARTS.size >= MIN_STYLES) { "only ${PageAssembly.CSS_PARTS.size} stylesheets declared" } + assertTrue(appNames.size >= MIN_SCRIPTS) { "only ${appNames.size} scripts declared" } + assertTrue(cssParts.size >= MIN_STYLES) { "only ${cssParts.size} stylesheets declared" } } @Test fun `every declared script and stylesheet is on the classpath the page is built from`() { - val missing = PageAssembly.appNames.filterNot { present("/jcef/$it") } + - PageAssembly.CSS_PARTS.filterNot { present("/jcef/css/$it") } + + val missing = appNames.filterNot { present("/jcef/$it") } + + cssParts.filterNot { present("/jcef/css/$it") } + listOf("shell.html").filterNot { present("/jcef/$it") } assertEquals(emptyList(), missing) { "A declared asset is not in the resources the jar is built from. A TypeScript module that did not " + @@ -28,5 +39,6 @@ class PageAssetsContractTest { private companion object { const val MIN_SCRIPTS = 30 const val MIN_STYLES = 8 + const val PAGE_ASSEMBLY = "dev.lain.claudejb.frontend.jcef.PageAssembly" } } diff --git a/src/test/kotlin/dev/lain/claudejb/view/jcef/RemoteDevApiContractTest.kt b/src/test/kotlin/dev/lain/claudejb/view/jcef/RemoteDevApiContractTest.kt index b9a0d4f3..ce2afbc5 100644 --- a/src/test/kotlin/dev/lain/claudejb/view/jcef/RemoteDevApiContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/view/jcef/RemoteDevApiContractTest.kt @@ -1,5 +1,6 @@ package dev.lain.claudejb.view.jcef +import dev.lain.claudejb.SourceLayout import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import java.io.File @@ -18,6 +19,11 @@ class RemoteDevApiContractTest { private val bannedProperty = "idea.platform.prefix" + @Test + fun `the scan sees the sources`() { + assertTrue(ktFiles().size >= MIN_SOURCES) { "only ${ktFiles().size} sources found; this contract would pass vacuously" } + } + @Test fun `no source file asks the platform whether this is remote development`() { val banned = Regex("""\b(${bannedTypes.joinToString("|")})\b""") @@ -48,11 +54,9 @@ class RemoteDevApiContractTest { } } - private fun ktFiles(): List = - sourceRoot().walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() + private fun ktFiles(): List = SourceLayout.kotlinFiles() - private fun sourceRoot(): File = - sequenceOf(File("src/main/kotlin"), File("../src/main/kotlin")) - .firstOrNull { it.isDirectory } - ?: error("could not locate src/main/kotlin from ${File("").absolutePath}") + private companion object { + const val MIN_SOURCES = 100 + } } diff --git a/src/test/kotlin/dev/lain/claudejb/view/log/LogViewWiringContractTest.kt b/src/test/kotlin/dev/lain/claudejb/view/log/LogViewWiringContractTest.kt index aca79f27..59b587d2 100644 --- a/src/test/kotlin/dev/lain/claudejb/view/log/LogViewWiringContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/view/log/LogViewWiringContractTest.kt @@ -1,5 +1,6 @@ package dev.lain.claudejb.view.log +import dev.lain.claudejb.SourceLayout import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -10,11 +11,11 @@ class LogViewWiringContractTest { @Test fun `the page has exactly one emitter of the log payload`() { val emitters = kotlinFiles() - .filter { it.readText().contains("window.cc.log(") } + .filter { LOG_PUSH.containsMatchIn(it.readText()) } .map { it.name } .sorted() - assertEquals(listOf("LogFeed.kt"), emitters) { "window.cc.log is emitted from more than one place: $emitters" } + assertEquals(listOf("LogFeed.kt"), emitters) { "the page's log push is emitted from more than one place: $emitters" } } @Test @@ -24,8 +25,11 @@ class LogViewWiringContractTest { assertTrue(feed.contains("executeOnPooledThread")) { "LogFeed reads the ring and builds the report on whatever thread asked; that is the EDT when the page asks." } - assertTrue(feed.contains("edt(panel.project)")) { "LogFeed does not come back to the EDT to draw." } - assertTrue(feed.contains("CopyPasteManager")) { "the Copy button reaches no clipboard" } + assertTrue(BACK_ON_EDT.containsMatchIn(feed)) { "LogFeed does not come back to the EDT to draw." } + assertTrue(feed.contains("copyToClient(")) { "the Copy button hands its report to no clipboard" } + assertTrue(source("ClientClipboard.kt").readText().contains("CopyPasteManager")) { + "the client's clipboard no longer reaches CopyPasteManager, so the Copy button reaches no clipboard" + } } @Test @@ -43,8 +47,11 @@ class LogViewWiringContractTest { @Test fun `the page's own console reaches the log the view reads`() { - assertTrue(source("view/jcef/JcefHost.kt").readText().contains("onConsoleMessage")) { - "a CSP rejection or a script error in the page never reaches the ring, so the Log view cannot show it" + val relays = kotlinFiles().filter { it.readText().contains("override fun onConsoleMessage(") } + val host = source("view/jcef/JcefHost.kt").readText() + assertTrue(relays.any { it.nameWithoutExtension == "JcefHost" || host.contains("${it.nameWithoutExtension}(") }) { + "a CSP rejection or a script error in the page never reaches the log, so the Log view cannot show it: " + + "JcefHost installs none of the console handlers $relays" } } @@ -63,20 +70,16 @@ class LogViewWiringContractTest { } } - private fun kotlinFiles(): List = - File(mainRoot(), "dev/lain/claudejb").walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() - - private fun source(relative: String) = File(mainRoot(), "dev/lain/claudejb/$relative").also { - assertTrue(it.isFile) { "missing source file: $it" } - } + private fun kotlinFiles(): List = SourceLayout.kotlinFiles() - private fun mainRoot(): File = resolve("src/main/kotlin") + private fun source(relative: String): File = SourceLayout.source(relative) - private fun jcefRoot(): File = resolve("src/main/resources/jcef") + private fun jcefRoot(): File = SourceLayout.mainDir("resources/jcef") - private fun tsRoot(): File = resolve("src/main/ts/jcef") + private fun tsRoot(): File = SourceLayout.mainDir("ts/jcef") - private fun resolve(path: String): File = - sequenceOf(File(path), File("../$path")).firstOrNull { it.isDirectory } - ?: error("could not locate $path from ${File("").absolutePath}") + private companion object { + val LOG_PUSH = Regex("""window\.cc\.log\(|\b(?:exec|execBuilt|PagePush)\(\s*"log"""") + val BACK_ON_EDT = Regex("""\bedt\(\w+\.project\)""") + } } diff --git a/src/test/kotlin/dev/lain/claudejb/view/window/InitOrderContractTest.kt b/src/test/kotlin/dev/lain/claudejb/view/window/InitOrderContractTest.kt index a83245d4..97e0f78b 100644 --- a/src/test/kotlin/dev/lain/claudejb/view/window/InitOrderContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/view/window/InitOrderContractTest.kt @@ -1,8 +1,8 @@ package dev.lain.claudejb.view.window +import dev.lain.claudejb.SourceLayout import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -import java.io.File class InitOrderContractTest { @@ -12,8 +12,9 @@ class InitOrderContractTest { @Test fun `no class-body property is declared after the init block that could use it`() { val offenders = mutableListOf() + assertTrue(SourceLayout.kotlinFiles().size >= MIN_SOURCES) { "the scan sees no source tree; it would pass vacuously" } - sourceRoot().walkTopDown().filter { it.isFile && it.extension == "kt" }.forEach { file -> + SourceLayout.kotlinFiles().forEach { file -> val lines = file.readLines() val initAt = lines.indexOfFirst { classBodyInit.containsMatchIn(it) } if (initAt < 0) return@forEach @@ -30,8 +31,7 @@ class InitOrderContractTest { } } - private fun sourceRoot(): File = - sequenceOf(File("src/main/kotlin"), File("../src/main/kotlin")) - .firstOrNull { it.isDirectory } - ?: error("could not locate src/main/kotlin from ${File("").absolutePath}") + private companion object { + const val MIN_SOURCES = 100 + } } diff --git a/src/test/kotlin/dev/lain/claudejb/view/window/ToolWindowWiringContractTest.kt b/src/test/kotlin/dev/lain/claudejb/view/window/ToolWindowWiringContractTest.kt index 999b023e..55d523f8 100644 --- a/src/test/kotlin/dev/lain/claudejb/view/window/ToolWindowWiringContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/view/window/ToolWindowWiringContractTest.kt @@ -1,67 +1,61 @@ package dev.lain.claudejb.view.window import dev.lain.claudejb.MainSources +import dev.lain.claudejb.SourceLayout import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -import java.io.File class ToolWindowWiringContractTest { @Test - fun `the strip is resolved by type, never by which content is selected`() { - val body = bodyOf(codeOf("view/window/ClaudeToolWindowFactory.kt"), "private fun tabsPanel(") + fun `the strip is held by the factory, never resolved by which content is selected`() { + val code = codeOf(FACTORY) - assertTrue(body.none { it.contains("selectedContent") }) { - "tabsPanel() is back to reading the SELECTED content. There is one content and it is the strip, " + - "so a manager with nothing selected makes activePanel/chatTabs/contextComponent/newChat/" + - "showGitView all answer null — and every one of those ends in a `?.` that fails silently." + assertTrue(code.none { it.contains("selectedContent") }) { + "ClaudeToolWindowFactory is back to reading the SELECTED content. There is one content and it is the strip, " + + "so a manager with nothing selected answers null — and every caller ends in a `?.` that fails silently." } - assertTrue(body.any { it.contains(".contents") }) { - "tabsPanel() no longer searches the contents for the strip:\n" + body.joinToString("\n") + assertTrue(code.any { it.contains("createContent(tabs") }) { + "the factory no longer makes the strip its one content:\n" + code.joinToString("\n") } } @Test - fun `the tab commands are published before anything is restored`() { - val code = codeOf("view/window/ClaudeToolWindowFactory.kt") - val published = code.indexOfFirst { it.contains("tabs.commands = commands") } + fun `the tab commands exist before anything is restored`() { + val code = codeOf(REGISTRY) + val published = code.indexOfFirst { it.contains("val commands = TabSessionCommands(") } val restored = code.indexOfFirst { it.contains("restoreOrCreate()") } assertTrue(published >= 0 && restored >= 0) { - "createToolWindowContent no longer publishes the commands or no longer restores — this contract " + - "needs rewriting, not deleting (published at $published, restored at $restored)." + "ChatRegistry no longer owns the commands or no longer restores — this contract needs rewriting, not " + + "deleting (published at $published, restored at $restored)." } assertTrue(published < restored) { - "The commands are published AFTER the restore. For as long as that runs, ChatTabsPanel.commands " + - "is null, so a *New chat* pressed during it and the replacement chat that closing the last " + - "one owes the user both go through `commands?.` and do nothing at all." + "The commands are created AFTER the restore. For as long as that runs, a *New chat* pressed during it and " + + "the replacement chat that closing the last one owes the user go nowhere." } } @Test fun `a new chat is shown only once its page can draw`() { - val code = codeOf("view/window/ClaudeToolWindowFactory.kt") - val open = code.indexOfFirst { it.contains("private fun openChat(") } - assertTrue(open >= 0) { "ClaudeToolWindowFactory no longer has an openChat" } - - val body = code.drop(open) - val deferred = body.indexOfFirst { it.contains("whenWebReady") } - val select = body.indexOfFirst { it.contains("tabs.select(tab)") } - - assertTrue(select >= 0) { "openChat no longer selects the tab it opened" } - assertTrue(deferred in 0 until select) { - "openChat selects the new tab without waiting for its page. The browser is empty at that moment, " + - "so the user watches the whole UI build itself on screen — which is what gets reported as " + - "\"opening a chat reloads the plugin\"." + val body = bodyOf(codeOf(STRIP), "private fun opened(") + val selecting = body.singleOrNull { it.trim().startsWith("select ->") } + + assertTrue(selecting != null) { "ChatTabsPanel.opened no longer has a `select ->` branch:\n" + body.joinToString("\n") } + val deferred = selecting.orEmpty().indexOf("whenReady") + assertTrue(deferred >= 0 && deferred < selecting.orEmpty().indexOf("show(")) { + "opened selects the new tab without waiting for its page. The browser is empty at that moment, so the user " + + "watches the whole UI build itself on screen — which is what gets reported as \"opening a chat reloads " + + "the plugin\": `$selecting`" } } @Test fun `the wait for the page is bounded, and the deadline runs the block`() { - val code = codeOf("view/jcef/JcefHost.kt") + val code = codeOf(HOST) val signature = code.firstOrNull { it.contains("fun whenWebReady(") } - assertTrue(signature != null) { "JcefHost no longer offers whenWebReady — see ClaudeToolWindowFactory.openChat" } + assertTrue(signature != null) { "JcefHost no longer offers whenWebReady — see ChatTabsPanel.opened" } assertTrue(signature.orEmpty().contains("WEB_READY_TIMEOUT_MS")) { "whenWebReady no longer defaults to a named ceiling: `$signature`. Without one, a page that never " + "announces leaves everything it deferred unrun, for good." @@ -78,43 +72,37 @@ class ToolWindowWiringContractTest { } @Test - fun `only one place builds a chat panel, so no two tabs can hold one session`() { - val built = MainSources.files() + fun `only one place builds a chat presenter, so no two tabs can hold one session`() { + val built = SourceLayout.kotlinFiles() .flatMap { file -> MainSources.codeOf(file).map { file.name to it } } - .filter { (_, line) -> line.contains("JcefChatPanel(") && !line.contains("class JcefChatPanel(") } + .filter { (_, line) -> line.contains("ChatPresenter(") && !line.contains("class ChatPresenter(") } assertTrue(built.size == 1) { - "A chat panel is constructed in ${built.size} places: ${built.map { it.first }}. Exactly one is " + - "the contract. A second `JcefChatPanel(project, session)` over a session that already has a " + - "tab puts two tabs on one `claude` process, and ClaudeToolWindowFactory's `closed` handler " + - "disposes it without asking — so closing either tab kills the other's session, leaves it " + - "painting a dead process and drops it from the restorable set." + "A chat presenter is constructed in ${built.size} places: ${built.map { it.first }}. Exactly one is " + + "the contract. A second presenter over a session that already has a tab puts two tabs on one `claude` " + + "process, and closing either one disposes the session under the other." } - assertTrue(built.single().first == "ClaudeToolWindowFactory.kt") { - "The one chat panel is now built in ${built.single().first} rather than in openChat, which is " + - "where the tab is added in the same breath. Whatever builds it owns the one-tab-per-session " + - "invariant, and the assertion above is only meaningful while those are the same place." + assertTrue(built.single().first == REGISTRY) { + "The one chat presenter is now built in ${built.single().first} rather than in ChatRegistry.open, which is " + + "where it joins the deck in the same breath. Whatever builds it owns the one-tab-per-session invariant." } } @Test - fun `the strip has no second view of a chat, and the close handler has nothing to ask`() { - val strip = codeOf("view/window/ChatTabsPanel.kt") + fun `the strip has no second view of a chat, and closing a chat has nothing to ask`() { + val strip = codeOf(STRIP) val revived = strip.filter { it.contains("isPinnedView") || it.contains("pinnedAgent") || it.contains("fun pin(") } assertTrue(revived.isEmpty()) { "ChatTabsPanel can hold a second view of a chat again:\n" + revived.joinToString("\n") + - "\nA pinned view is a second tab over one session, which is the state ClaudeToolWindowFactory's " + - "unconditional `closed` handler is safe only in the absence of." + "\nA pinned view is a second tab over one session, which is the state an unconditional close is safe " + + "only in the absence of." } - val handler = codeOf("view/window/ClaudeToolWindowFactory.kt").filter { it.contains("closed = { tab") } - assertTrue(handler.size == 1) { "no single `closed = { tab …` in the factory: $handler" } - assertTrue(handler.single().contains("manager.remove")) { - "the tool window's close handler no longer removes the session: `${handler.single()}`" - } - assertTrue(!handler.single().contains("if ")) { - "the close handler is conditional again: `${handler.single()}`. It disposes a `claude` process, " + - "and the only reason it ever needed a condition was a tab that did not own its session." + val removal = bodyOf(codeOf(REGISTRY), "fun close(").filter { it.contains(".remove(presenter.session)") } + assertTrue(removal.size == 1) { "ChatRegistry.close no longer removes the session exactly once: $removal" } + assertTrue(!removal.single().contains("if ")) { + "closing a chat is conditional again: `${removal.single()}`. It disposes a `claude` process, and the only " + + "reason it ever needed a condition was a tab that did not own its session." } } @@ -126,9 +114,12 @@ class ToolWindowWiringContractTest { return listOf(code[start]) + if (end >= 0) rest.take(end) else rest } - private fun codeOf(relative: String): List { - val file = File(MainSources.root("src/main/kotlin"), "dev/lain/claudejb/$relative") - assertTrue(file.isFile) { "missing source file: $file" } - return MainSources.codeOf(file) + private fun codeOf(name: String): List = MainSources.codeOf(SourceLayout.source(name)) + + private companion object { + const val FACTORY = "ClaudeToolWindowFactory.kt" + const val STRIP = "ChatTabsPanel.kt" + const val HOST = "JcefHost.kt" + const val REGISTRY = "ChatRegistry.kt" } } From c26e260aad6efde3f789c8b592a8348b2814fcd5 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:11:51 +0200 Subject: [PATCH 063/155] test(contract): keep the frontend and the backend apart --- .../CoroutineContainmentContractTest.kt | 48 ++++++++ .../claudejb/ModuleBoundaryContractTest.kt | 107 ++++++++++++++++++ .../claudejb/ModuleDescriptorContractTest.kt | 69 +++++++++++ .../dev/lain/claudejb/RpcContractTest.kt | 70 ++++++++++++ 4 files changed, 294 insertions(+) create mode 100644 src/test/kotlin/dev/lain/claudejb/CoroutineContainmentContractTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/ModuleBoundaryContractTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/ModuleDescriptorContractTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/RpcContractTest.kt diff --git a/src/test/kotlin/dev/lain/claudejb/CoroutineContainmentContractTest.kt b/src/test/kotlin/dev/lain/claudejb/CoroutineContainmentContractTest.kt new file mode 100644 index 00000000..9f393377 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/CoroutineContainmentContractTest.kt @@ -0,0 +1,48 @@ +package dev.lain.claudejb + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +class CoroutineContainmentContractTest { + + private val sources: List = SourceLayout.kotlinFiles() + + @Test + fun `the scan sees the sources and the coroutines it allows`() { + assertTrue(sources.size >= MIN_SOURCES) { "only ${sources.size} sources found" } + assertTrue(sources.filter { allowed(it) }.any { file -> hits(file).isNotEmpty() }) { + "no coroutine found even in the MCP packages; the pattern has stopped matching" + } + } + + @Test + fun `coroutines live only in the MCP code and the RPC seam`() { + val offenders = sources.filterNot { allowed(it) }.flatMap { file -> + hits(file).map { (index, line) -> "${SourceLayout.pathInRoot(file)}:${index + 1}: ${line.trim()}" } + } + assertEquals(emptyList(), offenders) { + "Coroutines belong to model/mcp, controller/mcp and the rpc packages, the platform's own RPC seam. " + + "The rest of the plugin keeps AppExecutorUtil, ReadAction.compute, WriteCommandAction and the single edt {}." + } + } + + private fun hits(file: File): List> = + MainSources.codeOf(file).withIndex().filter { (_, line) -> COROUTINE.containsMatchIn(line) } + + private fun allowed(file: File): Boolean { + val pkg = "${SourceLayout.packagePath(file)}/" + return ALLOWED_PACKAGES.any { pkg.startsWith("$it/") } || SourceLayout.pathInRoot(file) in ALLOWED_FILES + } + + private companion object { + const val MIN_SOURCES = 100 + + val ALLOWED_PACKAGES = listOf("model/mcp", "controller/mcp", "rpc", "frontend/rpc") + + val ALLOWED_FILES = setOf("dev/lain/claudejb/controller/github/GitHubGateway.kt") + + val COROUTINE = Regex("""\bkotlinx\.coroutines\b|\bsuspend\s+fun\b|:\s*suspend\b|\brunBlocking\b""") + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/ModuleBoundaryContractTest.kt b/src/test/kotlin/dev/lain/claudejb/ModuleBoundaryContractTest.kt new file mode 100644 index 00000000..8dff7875 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/ModuleBoundaryContractTest.kt @@ -0,0 +1,107 @@ +package dev.lain.claudejb + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +class ModuleBoundaryContractTest { + + private val modules: Map> = mapOf( + SHARED to SourceLayout.files(SourceLayout.rootsOf(SHARED, "kotlin"), JVM), + FRONTEND to SourceLayout.files(SourceLayout.rootsOf(FRONTEND, "kotlin"), JVM), + BACKEND to SourceLayout.files( + listOf(BACKEND, "").flatMap { SourceLayout.rootsOf(it, "kotlin") + SourceLayout.rootsOf(it, "java") }, + JVM, + ), + ) + + private val owners: Map> = modules + .flatMap { (module, files) -> files.map { fqn(it) to module } } + .groupBy({ it.first }, { it.second }) + .mapValues { it.value.toSet() } + + @Test + fun `the scan sees all three modules`() { + modules.forEach { (module, files) -> assertTrue(files.isNotEmpty()) { "no sources found for the $module module" } } + } + + @Test + fun `every package lives in exactly one module, and the module it is named for`() { + val split = owners.filterValues { it.size > 1 }.map { (pkg, where) -> "$pkg: $where" } + assertEquals(emptyList(), split) { "a package split across modules loads twice, from two classloaders" } + val misplaced = modules.flatMap { (module, files) -> + files.filterNot { homeOf(SourceLayout.packagePath(it)) == module }.map { "$module: ${SourceLayout.pathInRoot(it)}" } + } + assertEquals(emptyList(), misplaced) { + "dev.lain.claudejb.rpc is the shared contract, dev.lain.claudejb.frontend the frontend, everything else the backend." + } + } + + @Test + fun `each module reaches only itself and the shared contract`() { + val offenders = modules.flatMap { (module, files) -> + files.flatMap { file -> + references(file).filterNot { it.second in ALLOWED.getValue(module) } + .map { (ref, owner) -> "$module ${SourceLayout.pathInRoot(file)} -> $ref ($owner)" } + } + } + assertEquals(emptyList(), offenders) { + "The frontend and the backend meet only through dev.lain.claudejb.rpc. A type that crosses any other way is a " + + "class the other side of a remote session does not have." + } + } + + @Test + fun `the backend never touches the browser`() { + val offenders = modules.getValue(BACKEND).flatMap { file -> + MainSources.codeOf(file).withIndex().filter { (_, line) -> BROWSER.containsMatchIn(line) } + .map { (index, line) -> "${SourceLayout.pathInRoot(file)}:${index + 1}: ${line.trim()}" } + } + assertEquals(emptyList(), offenders) { + "JCEF lives in the frontend only; on a remote host the backend has no browser to reach." + } + } + + private fun references(file: File): List> = + MainSources.codeOf(file) + .filterNot { it.trimStart().startsWith("package ") } + .flatMap { line -> REFERENCE.findAll(line).map { it.value }.toList() } + .distinct() + .map { ref -> ref to ownerOf(ref) } + + private fun ownerOf(ref: String): String = + owners.keys.filter { ref == it || ref.startsWith("$it.") }.maxByOrNull { it.length } + ?.let { owners.getValue(it).sorted().joinToString("+") } + ?: UNKNOWN + + private fun fqn(file: File): String = + listOf(ROOT_PACKAGE, SourceLayout.packagePath(file).replace('/', '.')).filter { it.isNotEmpty() }.joinToString(".") + + private fun homeOf(packagePath: String): String = when { + packagePath == "rpc/backend" || packagePath.startsWith("rpc/backend/") -> BACKEND + packagePath == "rpc" || packagePath.startsWith("rpc/") -> SHARED + packagePath == "frontend" || packagePath.startsWith("frontend/") -> FRONTEND + else -> BACKEND + } + + private companion object { + const val SHARED = "shared" + const val FRONTEND = "frontend" + const val BACKEND = "backend" + const val UNKNOWN = "no module" + const val ROOT_PACKAGE = "dev.lain.claudejb" + + val JVM = setOf("kt", "java") + + val ALLOWED = mapOf( + SHARED to setOf(SHARED), + FRONTEND to setOf(FRONTEND, SHARED), + BACKEND to setOf(BACKEND, SHARED), + ) + + val REFERENCE = Regex("""\bdev\.lain\.claudejb(?:\.[a-z_]\w*)*""") + + val BROWSER = Regex("""\bcom\.intellij\.ui\.jcef\b|\borg\.cef\b""") + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/ModuleDescriptorContractTest.kt b/src/test/kotlin/dev/lain/claudejb/ModuleDescriptorContractTest.kt new file mode 100644 index 00000000..f3ecabcb --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/ModuleDescriptorContractTest.kt @@ -0,0 +1,69 @@ +package dev.lain.claudejb + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +class ModuleDescriptorContractTest { + + @Test + fun `every module has its descriptor where the platform looks for it`() { + val missing = SourceLayout.MODULE_NAMES.keys.map { SourceLayout.moduleDescriptor(it) }.filterNot { it.isFile } + assertEquals(emptyList(), missing) { + "A content module is described by .xml at the root of its resources; without it the platform " + + "refuses the plugin." + } + } + + @Test + fun `the plugin descriptor declares the three modules in its content`() { + val declared = PluginModules.content() + assertEquals(emptyList(), SourceLayout.MODULE_NAMES.values.filterNot { it in declared }) { + "plugin.xml's must name the shared, frontend and backend modules; it names ${declared.keys}" + } + listOf("frontend", "backend").forEach { side -> + val entry = declared.getValue(SourceLayout.MODULE_NAMES.getValue(side)) + assertTrue(Regex("""\brequired-if-available\s*=\s*"intellij\.platform\.$side"""").containsMatchIn(entry)) { + "the $side module must be required-if-available=\"intellij.platform.$side\": each side loads where its " + + "platform half exists, and nowhere else. Found: $entry" + } + } + val shared = declared.getValue(SourceLayout.MODULE_NAMES.getValue("shared")) + assertTrue(REQUIRED.containsMatchIn(shared)) { "the shared contract must load wherever the plugin does. Found: $shared" } + } + + @Test + fun `the frontend and the backend both depend on the shared contract`() { + listOf("frontend", "backend").map { SourceLayout.moduleDescriptor(it) }.forEach { descriptor -> + assertTrue(descriptor.isFile && SHARED in PluginModules.moduleDependencies(descriptor)) { + "${descriptor.path} does not depend on $SHARED, so its classloader " + + "cannot see ChatApi or the DTOs it exchanges." + } + } + } + + @Test + fun `the tool window is registered by the frontend descriptor only`() = registeredOnlyBy("frontend", TOOL_WINDOW) + + @Test + fun `the remote API provider is registered by the backend descriptor only`() = registeredOnlyBy("backend", REMOTE_API) + + private fun registeredOnlyBy(module: String, extension: Regex) { + val owner = SourceLayout.moduleDescriptor(module) + assertTrue(owner.isFile && extension.containsMatchIn(owner.readText())) { "${owner.path} does not register ${extension.pattern}" } + val elsewhere = SourceLayout.descriptors() + .filter { it.canonicalFile != owner.canonicalFile && extension.containsMatchIn(it.readText()) } + assertEquals(emptyList(), elsewhere) { + "${extension.pattern} is registered outside ${owner.path}; a second registration runs on the wrong side of a " + + "remote session, or twice on a local one." + } + } + + private companion object { + const val SHARED = "dev.lain.claudejb.shared" + val REQUIRED = Regex("""\bloading\s*=\s*"required"""") + val TOOL_WINDOW = Regex(""" = CONSTANT.findAll(SourceLayout.source("rpc/FrontendChannel.kt").readText()) + .associate { it.groupValues[1] to it.groupValues[2] } + + private val prefix: String = channel["PREFIX"] ?: error("FrontendChannel no longer declares PREFIX") + + private val pageMethods: Set = SourceLayout.tsFiles().flatMap { definedIn(it.readText()) }.toSet() + + private val pushed: List = backendSources().flatMap { file -> + val text = file.readText() + val literals = listOf(WINDOW_CC, PUSH_LITERAL, SNAPSHOT_LITERAL).flatMap { pattern -> + pattern.findAll(text).map { it.groupValues[1] }.toList() + } + val constants = PUSH_CONSTANT.findAll(text).map { channel[it.groupValues[1]] ?: "FrontendChannel.${it.groupValues[1]}" } + (literals + constants).map { Push(file, it) } + } + + @Test + fun `the scan sees the page's methods and the backend's pushes`() { + assertTrue(pageMethods.size >= MIN_PAGE_METHODS) { "only ${pageMethods.size} window.cc methods found: $pageMethods" } + assertTrue(pushed.size >= MIN_PUSHES) { "only ${pushed.size} pushes found in the backend" } + } + + @Test + fun `every push the backend names is a page method or a frontend channel`() { + val orphans = pushed + .filterNot { it.method in pageMethods || it.method.startsWith(prefix) } + .map { "${SourceLayout.pathInRoot(it.file)}: ${it.method}" } + .distinct() + assertEquals(emptyList(), orphans) { + "The backend pushes a method the page never defines, and it is not addressed to the frontend itself " + + "($prefix…). On the page it is a silent no-op; nothing on either side reports it." + } + } + + private fun definedIn(ts: String): List { + val aliases = ALIAS.findAll(ts).map { Regex.escape(it.groupValues[1]) }.toList() + val owner = (listOf("""window\.cc""") + aliases).joinToString("|") + return Regex("""(? { + val elsewhere = (SourceLayout.rootsOf("frontend", "kotlin") + SourceLayout.rootsOf("shared", "kotlin")) + return SourceLayout.kotlinFiles() + .filter { file -> elsewhere.none { file.startsWith(it) } } + .filterNot { "${SourceLayout.packagePath(it)}/".startsWith("frontend/") } + } + + private class Push(val file: File, val method: String) + + private companion object { + const val MIN_PAGE_METHODS = 30 + const val MIN_PUSHES = 20 + + val CONSTANT = Regex("""const\s+val\s+(\w+)\s*=\s*"([^"]*)"""") + val ALIAS = Regex("""(?:const|let|var)\s+(\w+)\s*=\s*\(?\s*window\.cc\b""") + val WINDOW_CC = Regex("""\bwindow\.cc\.(\w+)""") + val PUSH_LITERAL = Regex("""\b(?:PagePush|exec|execBuilt)\(\s*(?:method\s*=\s*)?"(\w+)"""") + val SNAPSHOT_LITERAL = Regex("""\bsend\(\s*\w+\s*,\s*"(\w+)"""") + val PUSH_CONSTANT = Regex("""\b(?:PagePush|exec|execBuilt)\(\s*(?:method\s*=\s*)?FrontendChannel\.(\w+)""") + } +} From 904367833d67da853cd59fb47624ba53a4ca5c3c Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:13:44 +0200 Subject: [PATCH 064/155] refactor(commands): reach the chats through the chat registry --- .../dev/lain/claudejb/controller/commands/CleanSettings.kt | 5 +++-- .../lain/claudejb/controller/commands/PromptInNewChat.kt | 5 ++--- .../lain/claudejb/controller/commands/SessionDiffAction.kt | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/commands/CleanSettings.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/CleanSettings.kt index fcc7a8bc..f84e33ae 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/commands/CleanSettings.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/CleanSettings.kt @@ -7,6 +7,8 @@ import dev.lain.claudejb.model.settings.ClaudeSettings import dev.lain.claudejb.model.settings.SecretStore import dev.lain.claudejb.model.settings.guard.GuardAlertLog import dev.lain.claudejb.model.settings.guard.SecuritySuspensions +import dev.lain.claudejb.view.window.ChatRegistry +import dev.lain.claudejb.view.window.ChatSnapshots internal object CleanSettings { @@ -57,8 +59,7 @@ internal object CleanSettings { } private fun repaint() { - LivePanels.pushState() - LivePanels.pushSettingsMenu() + ChatRegistry.repaintEverywhere(ChatSnapshots.Kind.META, ChatSnapshots.Kind.STATE, ChatSnapshots.Kind.MENU) } private fun confirm(project: Project, title: String, body: String) = MessageDialogBuilder diff --git a/src/main/kotlin/dev/lain/claudejb/controller/commands/PromptInNewChat.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/PromptInNewChat.kt index 4a2e8a51..55d49d7f 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/commands/PromptInNewChat.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/PromptInNewChat.kt @@ -1,13 +1,12 @@ package dev.lain.claudejb.controller.commands import com.intellij.openapi.project.Project -import dev.lain.claudejb.view.window.ClaudeToolWindowFactory +import dev.lain.claudejb.view.window.ChatRegistry internal object PromptInNewChat { fun open(project: Project, title: String, prompt: String): Boolean { - val commands = ClaudeToolWindowFactory.chatTabs(project)?.commands ?: return false - commands.newChatWith(title, prompt) + ChatRegistry.getInstance(project).commands.newChatWith(title, prompt) return true } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/commands/SessionDiffAction.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/SessionDiffAction.kt index e65b3b45..cd1f1eec 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/commands/SessionDiffAction.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/SessionDiffAction.kt @@ -12,10 +12,10 @@ import dev.lain.claudejb.controller.session.diff.WorkspaceDiffReview import dev.lain.claudejb.model.diff.DiffPresenter import dev.lain.claudejb.util.edt import dev.lain.claudejb.view.diff.DiffEditors -import dev.lain.claudejb.view.window.ChatTabsPanel +import dev.lain.claudejb.view.window.ChatRegistry import java.io.File -internal class SessionDiffAction(private val project: Project, private val tabs: ChatTabsPanel) : +internal class SessionDiffAction(private val project: Project) : AnAction("Review This Session's Changes…", "Diff everything this session has changed, against its base", null) { override fun getActionUpdateThread() = ActionUpdateThread.EDT @@ -73,7 +73,7 @@ internal class SessionDiffAction(private val project: Project, private val tabs: } } - private fun session(): ClaudeSession? = tabs.selectedChat?.session + private fun session(): ClaudeSession? = ChatRegistry.getInstance(project).selected()?.session private fun info(message: String) = Messages.showInfoMessage(project, message, TITLE) From 7590627b5b65ffb19912a5effe8592198e2ba47f Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:13:54 +0200 Subject: [PATCH 065/155] refactor(context): remove the host clipboard helpers and external-link opening the client now owns --- .../controller/context/ClipboardCli.kt | 173 ------------------ .../context/EditorContextProvider.kt | 43 ----- .../controller/context/LinkNavigator.kt | 2 - .../model/context/ImageAttachments.kt | 33 ---- .../controller/context/ClipboardCliTest.kt | 55 ------ .../model/context/ImageAttachmentsTest.kt | 14 -- 6 files changed, 320 deletions(-) delete mode 100644 src/main/kotlin/dev/lain/claudejb/controller/context/ClipboardCli.kt delete mode 100644 src/test/kotlin/dev/lain/claudejb/controller/context/ClipboardCliTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/context/ClipboardCli.kt b/src/main/kotlin/dev/lain/claudejb/controller/context/ClipboardCli.kt deleted file mode 100644 index 7a2c536e..00000000 --- a/src/main/kotlin/dev/lain/claudejb/controller/context/ClipboardCli.kt +++ /dev/null @@ -1,173 +0,0 @@ -package dev.lain.claudejb.controller.context - -import dev.lain.claudejb.model.context.Attachment -import dev.lain.claudejb.model.context.ImageAttachments -import dev.lain.claudejb.util.thisLogger -import java.io.File -import java.util.concurrent.CompletableFuture -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException - -internal object ClipboardCli { - - private val log = thisLogger() - - fun isLinux() = System.getProperty("os.name").orEmpty().lowercase().contains("linux") - - private fun isWindows() = System.getProperty("os.name").orEmpty().lowercase().contains("win") - - fun findExecutable(name: String): String? { - val candidates = LinkedHashSet() - System.getenv("PATH")?.split(File.pathSeparatorChar)?.forEach { if (it.isNotBlank()) candidates.add(it) } - candidates.addAll(COMMON_BIN_DIRS) - val names = if (isWindows()) listOf("$name.exe", name) else listOf(name) - for (dir in candidates) { - for (n in names) { - val f = File(dir, n) - if (f.isFile && f.canExecute()) return f.absolutePath - } - } - return null - } - - private val COMMON_BIN_DIRS: List by lazy { - val home = System.getProperty("user.home").orEmpty() - listOf( - "/usr/bin", "/bin", "/usr/local/bin", "/usr/sbin", "/sbin", - "/run/current-system/sw/bin", - "/var/lib/flatpak/exports/bin", - "/snap/bin", - "/opt/homebrew/bin", "/usr/local/sbin", - "$home/.local/bin", "$home/bin", - ) - } - - fun image(): Attachment.Image? { - if (!isLinux()) return null - val wlPaste = findExecutable("wl-paste") - val xclip = findExecutable("xclip") - if (wlPaste != null) { - val wlType = pickImageType(listOf(wlPaste, "--list-types")) - if (wlType != null) { - val bytes = runProcessBytes(listOf(wlPaste, "-t", wlType)) - val img = if (bytes != null) ImageAttachments.imageOf(bytes, wlType) else null - if (img != null) return img - } - } - if (xclip != null) { - val xType = pickImageType(listOf(xclip, "-selection", "clipboard", "-t", "TARGETS", "-o")) - if (xType != null) { - val bytes = runProcessBytes(listOf(xclip, "-selection", "clipboard", "-t", xType, "-o")) - val img = if (bytes != null) ImageAttachments.imageOf(bytes, xType) else null - if (img != null) return img - } - } - if (wlPaste != null) imageFromUriList(listOf(wlPaste, "-t", "text/uri-list"))?.let { return it } - if (xclip != null) imageFromUriList(listOf(xclip, "-selection", "clipboard", "-t", "text/uri-list", "-o"))?.let { return it } - return null - } - - fun text(): String? { - if (!isLinux()) return null - findExecutable("wl-paste")?.let { wlPaste -> - preferredTextType(listTypes(listOf(wlPaste, "--list-types")))?.let { type -> - runProcessBytes(listOf(wlPaste, "-t", type, "-n"))?.toString(Charsets.UTF_8) - ?.takeIf { it.isNotEmpty() }?.let { return it } - } - } - findExecutable("xclip")?.let { xclip -> - preferredTextType(listTypes(listOf(xclip, "-selection", "clipboard", "-t", "TARGETS", "-o")))?.let { type -> - runProcessBytes(listOf(xclip, "-selection", "clipboard", "-t", type, "-o"))?.toString(Charsets.UTF_8) - ?.takeIf { it.isNotEmpty() }?.let { return it } - } - } - return null - } - - fun textType(): String? { - if (!isLinux()) return null - findExecutable("wl-paste")?.let { wlPaste -> - preferredTextType(listTypes(listOf(wlPaste, "--list-types")))?.let { return it } - } - findExecutable("xclip")?.let { xclip -> - preferredTextType(listTypes(listOf(xclip, "-selection", "clipboard", "-t", "TARGETS", "-o")))?.let { return it } - } - return null - } - - fun preferredTextType(types: List): String? { - fun first(p: (String) -> Boolean) = types.firstOrNull(p) - return first { it.equals("text/plain;charset=utf-8", ignoreCase = true) } - ?: first { it == "UTF8_STRING" } - ?: first { it.equals("text/plain", ignoreCase = true) } - ?: first { it == "STRING" || it == "TEXT" } - ?: first { - it.startsWith("text/", ignoreCase = true) && - !it.equals("text/uri-list", ignoreCase = true) && - !it.startsWith("text/html", ignoreCase = true) - } - } - - fun installHint(): String { - val rel = runCatching { File("/etc/os-release").readText().lowercase() }.getOrDefault("") - return when { - listOf("fedora", "rhel", "centos", "rocky", "alma").any { it in rel } -> "sudo dnf install wl-clipboard" - listOf("debian", "ubuntu", "mint", "pop").any { it in rel } -> "sudo apt install wl-clipboard" - "arch" in rel || "manjaro" in rel -> "sudo pacman -S wl-clipboard" - "opensuse" in rel || "suse" in rel -> "sudo zypper install wl-clipboard" - else -> "install 'wl-clipboard' (or 'xclip') with your package manager" - } - } - - private fun listTypes(listCmd: List): List = - runProcessBytes(listCmd)?.toString(Charsets.UTF_8) - ?.lineSequence()?.map { it.trim() }?.filter { it.isNotEmpty() }?.toList() - ?: emptyList() - - private fun pickImageType(listCmd: List): String? { - val types = listTypes(listCmd) - return types.firstOrNull { it == "image/png" } - ?: types.firstOrNull { it == "image/jpeg" || it == "image/jpg" } - ?: types.firstOrNull { it.startsWith("image/") } - } - - private fun imageFromUriList(cmd: List): Attachment.Image? { - val out = runProcessBytes(cmd)?.toString(Charsets.UTF_8) ?: return null - for (line in out.lineSequence()) { - val s = line.trim() - if (s.isEmpty() || s.startsWith("#")) continue - val path = uriToPath(s) ?: continue - ImageAttachments.imageFromFile(path)?.let { return it } - } - return null - } - - private fun uriToPath(uri: String): String? = runCatching { - when { - uri.startsWith("file://") -> File(java.net.URI(uri)).path - uri.startsWith("/") -> uri - else -> null - } - }.getOrNull() - - private fun runProcessBytes(cmd: List): ByteArray? = runCatching { - val proc = ProcessBuilder(cmd).redirectError(ProcessBuilder.Redirect.DISCARD).start() - val reader = CompletableFuture.supplyAsync { - runCatching { proc.inputStream.readBytes() }.getOrNull() - } - val bytes = try { - reader.get(3, TimeUnit.SECONDS) - } catch (e: TimeoutException) { - log.debug { "Clipboard helper ${cmd.firstOrNull()} timed out after 3s; killing it: $e" } - proc.destroyForcibly() - reader.cancel(true) - return@runCatching null - } - if (!proc.waitFor(1, TimeUnit.SECONDS)) { - proc.destroyForcibly() - return@runCatching null - } - if (proc.exitValue() != 0) return@runCatching null - bytes?.takeIf { it.isNotEmpty() } - }.getOrNull() -} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/context/EditorContextProvider.kt b/src/main/kotlin/dev/lain/claudejb/controller/context/EditorContextProvider.kt index 85ec35f4..dabfc576 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/context/EditorContextProvider.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/context/EditorContextProvider.kt @@ -1,12 +1,8 @@ package dev.lain.claudejb.controller.context import com.intellij.openapi.fileEditor.FileEditorManager -import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.project.Project import dev.lain.claudejb.model.context.Attachment -import dev.lain.claudejb.model.context.ImageAttachments -import java.awt.Toolkit -import java.awt.datatransfer.DataFlavor object EditorContextProvider { @@ -40,45 +36,6 @@ object EditorContextProvider { return Attachment.FileRef(path = path, displayName = path.substringAfterLast('/')) } - fun imageFromClipboard(): Attachment.Image? = awtClipboardImage() ?: ClipboardCli.image() - - fun clipboardHasText(): Boolean { - val awt = runCatching { - Toolkit.getDefaultToolkit().systemClipboard.isDataFlavorAvailable(DataFlavor.stringFlavor) - }.getOrDefault(false) - if (awt) return true - return ClipboardCli.textType() != null - } - - fun clipboardText(): String? { - runCatching { - CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor) - ?.takeIf { it.isNotEmpty() } - ?.let { return it } - } - runCatching { - val cb = Toolkit.getDefaultToolkit().systemClipboard - if (cb.isDataFlavorAvailable(DataFlavor.stringFlavor)) { - (cb.getData(DataFlavor.stringFlavor) as? String)?.takeIf { it.isNotEmpty() }?.let { return it } - } - } - return ClipboardCli.text() - } - - fun clipboardImageHelp(): String? { - if (!ClipboardCli.isLinux()) return null - if (ClipboardCli.findExecutable("wl-paste") != null || ClipboardCli.findExecutable("xclip") != null) return null - return "image paste needs 'wl-clipboard' (Wayland) or 'xclip' (X11): " + ClipboardCli.installHint() - } - - private fun awtClipboardImage(): Attachment.Image? = runCatching { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - if (!clipboard.isDataFlavorAvailable(DataFlavor.imageFlavor)) return null - val image = clipboard.getData(DataFlavor.imageFlavor) as? java.awt.Image ?: return null - val base64 = ImageAttachments.pngBase64(image) ?: return null - Attachment.Image(displayName = "clipboard.png", mediaType = "image/png", base64 = base64) - }.getOrNull() - private val LANG_BY_EXTENSION: Map = buildMap { fun map(lang: String, vararg extensions: String) = extensions.forEach { put(it, lang) } map("kotlin", "kt", "kts") diff --git a/src/main/kotlin/dev/lain/claudejb/controller/context/LinkNavigator.kt b/src/main/kotlin/dev/lain/claudejb/controller/context/LinkNavigator.kt index 1c763450..90d7237c 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/context/LinkNavigator.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/context/LinkNavigator.kt @@ -1,6 +1,5 @@ package dev.lain.claudejb.controller.context -import com.intellij.ide.BrowserUtil import com.intellij.ide.actions.RevealFileAction import com.intellij.ide.highlighter.ArchiveFileType import com.intellij.ide.projectView.ProjectView @@ -24,7 +23,6 @@ internal class LinkNavigator(private val project: Project) { fun open(url: String) { val u = url.trim() when { - u.lowercase().startsWith("https://") -> BrowserUtil.browse(u) u.startsWith(JB) -> openJbLink(u) LinkResolver.isFilePathHref(u) -> openPath(u.substringBefore('#').trim()) } diff --git a/src/main/kotlin/dev/lain/claudejb/model/context/ImageAttachments.kt b/src/main/kotlin/dev/lain/claudejb/model/context/ImageAttachments.kt index 0ed78918..2aaf9235 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/context/ImageAttachments.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/context/ImageAttachments.kt @@ -1,11 +1,7 @@ package dev.lain.claudejb.model.context -import java.awt.image.BufferedImage -import java.awt.image.RenderedImage -import java.io.ByteArrayOutputStream import java.io.File import java.util.Base64 -import javax.imageio.ImageIO internal object ImageAttachments { @@ -60,13 +56,6 @@ internal object ImageAttachments { else -> null } - fun imageOf(bytes: ByteArray, type: String): Attachment.Image? { - if (bytes.size < MIN_IMAGE_BYTES) return null - val mt = if (type == "image/jpg") "image/jpeg" else type - val ext = mt.substringAfter('/').substringBefore('+').ifBlank { "png" } - return Attachment.Image("clipboard.$ext", mt, Base64.getEncoder().encodeToString(bytes)) - } - fun imageFromFile(path: String): Attachment.Image? = runCatching { val file = File(path) val bytes = file.takeIf { it.isFile }?.readBytes() ?: return null @@ -86,26 +75,4 @@ internal object ImageAttachments { "webp" -> "image/webp" else -> null } - - fun pngBase64(image: java.awt.Image): String? = runCatching { - val rendered = image.toRenderedImage() ?: return null - val out = ByteArrayOutputStream() - if (!ImageIO.write(rendered, "png", out)) return null - Base64.getEncoder().encodeToString(out.toByteArray()) - }.getOrNull() - - private fun java.awt.Image.toRenderedImage(): RenderedImage? { - (this as? RenderedImage)?.let { return it } - val width = getWidth(null) - val height = getHeight(null) - if (width <= 0 || height <= 0) return null - val buffered = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) - val g = buffered.createGraphics() - try { - g.drawImage(this, 0, 0, null) - } finally { - g.dispose() - } - return buffered - } } diff --git a/src/test/kotlin/dev/lain/claudejb/controller/context/ClipboardCliTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/context/ClipboardCliTest.kt deleted file mode 100644 index b19985d6..00000000 --- a/src/test/kotlin/dev/lain/claudejb/controller/context/ClipboardCliTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package dev.lain.claudejb.controller.context - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Test - -class ClipboardCliTest { - - @Test - fun `preferredTextType prefers a utf-8 plain-text mime`() { - assertEquals( - "text/plain;charset=utf-8", - ClipboardCli.preferredTextType(listOf("text/html", "text/plain;charset=utf-8", "text/plain")), - ) - } - - @Test - fun `preferredTextType accepts X11 atom targets from xclip`() { - assertEquals( - "UTF8_STRING", - ClipboardCli.preferredTextType(listOf("TARGETS", "STRING", "UTF8_STRING", "TEXT")), - ) - assertEquals("STRING", ClipboardCli.preferredTextType(listOf("TARGETS", "STRING", "TEXT"))) - } - - @Test - fun `preferredTextType falls back to plain then any other text type`() { - assertEquals("text/plain", ClipboardCli.preferredTextType(listOf("text/html", "text/plain"))) - assertEquals("text/markdown", ClipboardCli.preferredTextType(listOf("text/markdown"))) - } - - @Test - fun `preferredTextType returns null for an image-only clipboard (the Wayland leak guard)`() { - val kdeImageTypes = listOf( - "image/png", - "application/x-qt-image", - "x-kde-force-image-copy", - "application/x-kde-suggestedfilename", - "image/avif", - "image/bmp", - ) - assertNull(ClipboardCli.preferredTextType(kdeImageTypes)) - } - - @Test - fun `preferredTextType excludes uri-list and html (copied files and markup are not a plain paste)`() { - assertNull(ClipboardCli.preferredTextType(listOf("text/uri-list"))) - assertNull(ClipboardCli.preferredTextType(listOf("text/html"))) - } - - @Test - fun `preferredTextType returns null for an empty listing`() { - assertNull(ClipboardCli.preferredTextType(emptyList())) - } -} diff --git a/src/test/kotlin/dev/lain/claudejb/model/context/ImageAttachmentsTest.kt b/src/test/kotlin/dev/lain/claudejb/model/context/ImageAttachmentsTest.kt index cf018978..97cd64c2 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/context/ImageAttachmentsTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/context/ImageAttachmentsTest.kt @@ -55,20 +55,6 @@ class ImageAttachmentsTest { assertNull(ImageAttachments.imageFromFile(txt.absolutePath)) } - @Test - fun `imageOf normalizes image-jpg and names the payload by its type`() { - val bytes = ByteArray(16) { 0x42 } - val jpg = ImageAttachments.imageOf(bytes, "image/jpg") - requireNotNull(jpg) { "expected a non-null Image attachment" } - assertEquals("image/jpeg", jpg.mediaType) - assertEquals("clipboard.jpeg", jpg.displayName) - } - - @Test - fun `imageOf rejects a payload shorter than any image signature`() { - assertNull(ImageAttachments.imageOf(ByteArray(4), "image/png")) - } - private val pngMagic = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) private val jpegMagic = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0x00) + ByteArray(8) private val gifMagic = "GIF89a".toByteArray() + ByteArray(4) From c37d811993f7ac288673f0f3b58566cc5b0aec59 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:13:54 +0200 Subject: [PATCH 066/155] feat(session): expose MCP reconnect admission and prewarm to the bridge and view layers --- .../dev/lain/claudejb/controller/session/McpLink.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/session/McpLink.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/McpLink.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/McpLink.kt new file mode 100644 index 00000000..7f99c1de --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/McpLink.kt @@ -0,0 +1,13 @@ +package dev.lain.claudejb.controller.session + +import com.intellij.openapi.project.Project +import dev.lain.claudejb.controller.mcp.IdeMcpService + +object McpLink { + + fun admitReconnect(project: Project, name: String) { + IdeMcpService.getInstance(project).admitReconnect(name) + } + + fun prewarm(project: Project) = IdeMcpService.getInstance(project).prewarm() +} From 26e7562a87ae4b14eaec442af02095a2e25c7a6f Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:13:54 +0200 Subject: [PATCH 067/155] perf(session): poll the quota every 3 s and at each message boundary --- .../controller/session/events/ConversationEvents.kt | 1 + .../lain/claudejb/controller/session/turn/PollSchedule.kt | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/events/ConversationEvents.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/events/ConversationEvents.kt index c3a62cb7..a11178a4 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/events/ConversationEvents.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/events/ConversationEvents.kt @@ -41,6 +41,7 @@ class ConversationEvents( s.tokens.foldIntoSession() s.turn.liveThinkingTokens = 0 s.reconciler.onMessageBoundary() + s.poll.pollQuota() } is ClaudeEvent.LocalCommandOutput -> edt { diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/turn/PollSchedule.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/turn/PollSchedule.kt index 6d8af6cd..63a26997 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/turn/PollSchedule.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/turn/PollSchedule.kt @@ -35,7 +35,7 @@ class PollSchedule( private var quotaPollInFlight = false - private val outputTailTimer = javax.swing.Timer(QUOTA_POLL_MS) { pollLiveOutput() }.apply { isRepeats = true } + private val outputTailTimer = javax.swing.Timer(OUTPUT_TAIL_MS) { pollLiveOutput() }.apply { isRepeats = true } private fun pollLiveOutput() { if (!outputTail.anyTailable()) { @@ -110,7 +110,9 @@ class PollSchedule( } companion object { - const val QUOTA_POLL_MS = 1_000 + const val QUOTA_POLL_MS = 3_000 + + const val OUTPUT_TAIL_MS = 1_000 const val AGENT_REVIVAL_POLL_MS = 5_000 From 00bbdae31c5dd7c839982601689a77b4da60b4d7 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:13:55 +0200 Subject: [PATCH 068/155] feat(session): keep streaming entries running until their message settles --- .../session/transcript/TranscriptEntry.kt | 54 ++++++++++++++++ .../session/transcript/TranscriptModel.kt | 62 +++---------------- .../transcript/TranscriptReconciler.kt | 15 +++-- .../transcript/TranscriptReconcilerTest.kt | 30 +++++++++ 4 files changed, 102 insertions(+), 59 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptEntry.kt diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptEntry.kt b/src/main/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptEntry.kt new file mode 100644 index 00000000..b7ac221f --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptEntry.kt @@ -0,0 +1,54 @@ +package dev.lain.claudejb.model.session.transcript + +enum class Speaker { USER, ASSISTANT, THINKING, TOOL, TOOL_OUTPUT, SYSTEM, ERROR, MEMORY } + +enum class ToolState { LOADING, RUNNING, FINISHED, ERROR } + +class TranscriptEntry( + val id: Long, + val speaker: Speaker, + text: String, + val meta: String? = null, + val toolUseId: String? = null, + val parentToolUseId: String? = null, + toolState: ToolState = ToolState.FINISHED, + val filePath: String? = null, + val commandText: String? = null, + val messageText: String? = null, + val blockedRule: String? = null, + val bypassedRule: String? = null, + val bypassAction: String? = null, + val reviewable: Boolean = false, +) { + private var settled: String? = text + + private var growing: StringBuilder? = null + + var text: String + get() = settled ?: growing.toString().also { settled = it } + internal set(value) { + settled = value + growing = null + } + + internal fun grow(delta: String) { + val builder = growing ?: StringBuilder(settled.orEmpty()).also { growing = it } + builder.append(delta) + settled = null + } + + var toolState: ToolState = toolState + internal set + + var elapsedSeconds: Double = 0.0 + internal set + + var toolTitle: String? = null + internal set + + var places: List = emptyList() + internal set + + var trimmed: Boolean = false + internal set +} diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptModel.kt b/src/main/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptModel.kt index 195a1c55..76f258ac 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptModel.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptModel.kt @@ -3,59 +3,6 @@ package dev.lain.claudejb.model.session.transcript import org.jetbrains.annotations.TestOnly import java.util.concurrent.CopyOnWriteArrayList -enum class Speaker { USER, ASSISTANT, THINKING, TOOL, TOOL_OUTPUT, SYSTEM, ERROR, MEMORY } - -enum class ToolState { LOADING, RUNNING, FINISHED, ERROR } - -class TranscriptEntry( - val id: Long, - val speaker: Speaker, - text: String, - val meta: String? = null, - val toolUseId: String? = null, - val parentToolUseId: String? = null, - toolState: ToolState = ToolState.FINISHED, - val filePath: String? = null, - val commandText: String? = null, - val messageText: String? = null, - val blockedRule: String? = null, - val bypassedRule: String? = null, - val bypassAction: String? = null, - val reviewable: Boolean = false, -) { - private var settled: String? = text - - private var growing: StringBuilder? = null - - var text: String - get() = settled ?: growing.toString().also { settled = it } - internal set(value) { - settled = value - growing = null - } - - internal fun grow(delta: String) { - val builder = growing ?: StringBuilder(settled.orEmpty()).also { growing = it } - builder.append(delta) - settled = null - } - - var toolState: ToolState = toolState - internal set - - var elapsedSeconds: Double = 0.0 - internal set - - var toolTitle: String? = null - internal set - - var places: List = emptyList() - internal set - - var trimmed: Boolean = false - internal set -} - class TranscriptModel { companion object { @@ -202,8 +149,15 @@ class TranscriptModel { listeners.forEach { it.onAppended(entry, delta) } } - fun replaceText(entry: TranscriptEntry, text: String) { + fun replaceText(entry: TranscriptEntry, text: String, state: ToolState = entry.toolState) { entry.text = text + entry.toolState = state + listeners.forEach { it.onUpdated(entry) } + } + + fun settle(entry: TranscriptEntry) { + if (entry.toolState == ToolState.FINISHED) return + entry.toolState = ToolState.FINISHED listeners.forEach { it.onUpdated(entry) } } diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptReconciler.kt b/src/main/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptReconciler.kt index 65e2a099..7d8e9d4a 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptReconciler.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptReconciler.kt @@ -13,12 +13,16 @@ class TranscriptReconciler(private val transcript: TranscriptModel) { private fun live(entry: TranscriptEntry?): TranscriptEntry? = entry?.takeUnless { it.trimmed } + private fun streaming(speaker: Speaker, delta: String): TranscriptEntry = + transcript.add(speaker, delta, toolState = ToolState.RUNNING) + fun appendAssistant(delta: String, parentToolUseId: String? = null) { if (!belongsHere(parentToolUseId)) return + live(liveThinking)?.let(transcript::settle) liveThinking = null val entry = live(liveAssistant) if (entry == null) { - liveAssistant = transcript.add(Speaker.ASSISTANT, delta) + liveAssistant = streaming(Speaker.ASSISTANT, delta) } else { transcript.append(entry, delta) } @@ -27,7 +31,7 @@ class TranscriptReconciler(private val transcript: TranscriptModel) { fun finalizeAssistant(full: String, parentToolUseId: String? = null) { if (!belongsHere(parentToolUseId)) return val entry = live(liveAssistant) - if (entry != null) transcript.replaceText(entry, full) else transcript.add(Speaker.ASSISTANT, full) + if (entry != null) transcript.replaceText(entry, full, ToolState.FINISHED) else transcript.add(Speaker.ASSISTANT, full) liveAssistant = null } @@ -36,7 +40,7 @@ class TranscriptReconciler(private val transcript: TranscriptModel) { val entry = live(liveThinking) if (entry == null) { if (delta.isBlank()) return - liveThinking = transcript.add(Speaker.THINKING, delta) + liveThinking = streaming(Speaker.THINKING, delta) settledThinking = liveThinking } else { transcript.append(entry, delta) @@ -47,8 +51,8 @@ class TranscriptReconciler(private val transcript: TranscriptModel) { if (!belongsHere(parentToolUseId)) return val entry = live(liveThinking) ?: live(settledThinking) when { - full.isBlank() -> Unit - entry != null -> transcript.replaceText(entry, full) + full.isBlank() -> entry?.let(transcript::settle) + entry != null -> transcript.replaceText(entry, full, ToolState.FINISHED) else -> transcript.add(Speaker.THINKING, full) } liveThinking = null @@ -56,6 +60,7 @@ class TranscriptReconciler(private val transcript: TranscriptModel) { } fun onMessageBoundary() { + listOfNotNull(live(liveAssistant), live(liveThinking), live(settledThinking)).forEach(transcript::settle) liveAssistant = null liveThinking = null settledThinking = null diff --git a/src/test/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptReconcilerTest.kt b/src/test/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptReconcilerTest.kt index fb5e6c3f..3e462608 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptReconcilerTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/session/transcript/TranscriptReconcilerTest.kt @@ -25,6 +25,36 @@ class TranscriptReconcilerTest { assertEquals("Hello", model.entries.single().text) } + @Test + fun `a streaming entry runs until its message settles`() { + val model = TranscriptModel() + val reconciler = TranscriptReconciler(model) + + reconciler.appendThinking("hmm") + val thinking = model.entries.single() + assertEquals(ToolState.RUNNING, thinking.toolState) + + reconciler.appendAssistant("Hel") + val answer = model.entries.last() + assertEquals(ToolState.FINISHED, thinking.toolState) + assertEquals(ToolState.RUNNING, answer.toolState) + + reconciler.finalizeAssistant("Hello") + assertEquals(ToolState.FINISHED, answer.toolState) + assertEquals("Hello", answer.text) + } + + @Test + fun `a message boundary settles whatever was still streaming`() { + val model = TranscriptModel() + val reconciler = TranscriptReconciler(model) + reconciler.appendAssistant("cut") + + reconciler.onMessageBoundary() + + assertEquals(ToolState.FINISHED, model.entries.single().toolState) + } + @Test fun `an assistant delta after its entry was trimmed starts a new entry and leaves the dead one alone`() { val model = TranscriptModel() From 6702df8392812e7fc921169ce081a6aeaac7a823 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:14:34 +0200 Subject: [PATCH 069/155] fix(session): read folded batch results so clean items settle as successes --- .../controller/session/events/ToolEvents.kt | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/events/ToolEvents.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/events/ToolEvents.kt index 86931e4d..5a703bd0 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/events/ToolEvents.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/events/ToolEvents.kt @@ -14,6 +14,7 @@ import dev.lain.claudejb.model.session.transcript.ToolState import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.intOrNull class ToolEvents( private val s: ClaudeSession, @@ -123,10 +124,7 @@ class ToolEvents( private fun ownResult(event: ClaudeEvent.ToolResult, own: Own) { val text = event.content.trim() val decoded = if (event.isError) null else OwnTools.decodeResult(text) as? JsonObject - val results: List = when { - !own.batch -> listOf(decoded) - else -> (decoded?.get("items") as? JsonArray)?.map { it as? JsonObject }.orEmpty() - } + val results: List = if (own.batch) batchResults(decoded, own.items.size) else listOf(decoded) own.items.forEachIndexed { index, item -> val result = results.getOrNull(index) val error = result?.let { errorOf(it) } @@ -149,8 +147,16 @@ class ToolEvents( } } + private fun batchResults(decoded: JsonObject?, count: Int): List { + val rows = (decoded?.get("items") as? JsonArray)?.map { it as? JsonObject }.orEmpty() + if (decoded?.get(CLEAN) !is JsonArray) return rows + val byIndex = rows.filterNotNull().associateBy { (it[INDEX] as? JsonPrimitive)?.intOrNull } + return List(count) { index -> byIndex[index]?.let { JsonObject(it - INDEX) } ?: JsonObject(emptyMap()) } + } + private fun ownOutput(call: OwnTools.Call, item: Item, result: JsonObject) { s.transcript.setToolPlaces(item.id, CardPlaces.of(call.argument ?: "", item.args, result)) + if (result.isEmpty()) return val read = if (OwnTools.isRead(call)) OwnTools.readText(result) else null if (read != null) { s.transcript.addToolOutput(item.id, read) @@ -174,6 +180,8 @@ class ToolEvents( const val TOON = "toon" const val DIFF = "diff" const val ERROR = "error" + const val CLEAN = "clean" + const val INDEX = "index" } fun labelAgentCards() { From 222dc7b8415a37ba1edb986e01de10caf45950b2 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:15:48 +0200 Subject: [PATCH 070/155] perf(session): keep background-task output in a ring instead of copying it on every tail --- .../session/agents/BackgroundTaskRegistry.kt | 20 ++++++---- .../model/session/agents/OutputRing.kt | 25 +++++++++++++ .../model/session/agents/OutputRingTest.kt | 37 +++++++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/model/session/agents/OutputRing.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/model/session/agents/OutputRingTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/agents/BackgroundTaskRegistry.kt b/src/main/kotlin/dev/lain/claudejb/model/session/agents/BackgroundTaskRegistry.kt index 223a02d1..1c7c920e 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/session/agents/BackgroundTaskRegistry.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/session/agents/BackgroundTaskRegistry.kt @@ -18,11 +18,14 @@ class BackgroundTaskRegistry( val toolUseId: String? = null, val ownerToolUseId: String? = null, val outputFile: String? = null, - val output: String = "", val command: String? = null, val seenLive: Boolean = false, val completedAtMillis: Long? = null, + val outputVersion: Int = 0, + internal val ring: OutputRing = OutputRing(MAX_OUTPUT), ) { + val output: String get() = ring.text() + fun label(): String = description.ifBlank { command?.lineSequence()?.firstOrNull().orEmpty() } .ifBlank { taskType } @@ -50,7 +53,7 @@ class BackgroundTaskRegistry( toolUseId = r.toolUseId, ownerToolUseId = r.ownerToolUseId, outputFile = r.outputFile, - output = r.output.ifBlank { r.notes }.takeLast(MAX_OUTPUT), + ring = OutputRing(MAX_OUTPUT, r.output.ifBlank { r.notes }), command = r.command, completedAtMillis = runStartedAtMillis, ) @@ -106,11 +109,13 @@ class BackgroundTaskRegistry( val taskId = out.backgroundTaskId ?: return false val chunk = listOfNotNull(out.stdout, out.stderr).filter { it.isNotBlank() }.joinToString("\n") val previous = tasks[taskId] - val next = (previous ?: Task(taskId)).copy( + val task = previous ?: Task(taskId) + if (chunk.isNotBlank()) task.ring.append("$chunk\n") + val next = task.copy( toolUseId = event.toolUseId, ownerToolUseId = previous?.ownerToolUseId ?: event.parentToolUseId, outputFile = previous?.outputFile ?: out.outputFile ?: TaskOutputFile.parse(event.content), - output = (previous?.output.orEmpty() + if (chunk.isBlank()) "" else "$chunk\n").takeLast(MAX_OUTPUT), + outputVersion = task.ring.version, ) if (next == previous) return false if (previous == null) order += taskId @@ -121,9 +126,8 @@ class BackgroundTaskRegistry( fun appendTailedOutput(taskId: String, text: String): Boolean { if (text.isBlank()) return false val previous = tasks[taskId] ?: return false - val merged = (previous.output + text).takeLast(MAX_OUTPUT) - if (merged == previous.output) return false - tasks[taskId] = previous.copy(output = merged) + if (!previous.ring.append(text)) return false + tasks[taskId] = previous.copy(outputVersion = previous.ring.version) return true } @@ -137,6 +141,6 @@ class BackgroundTaskRegistry( private val TERMINAL_STATUSES = setOf("completed", "failed", "cancelled", "stopped", "killed", "error") - private const val MAX_OUTPUT = 200_000 + internal const val MAX_OUTPUT = 200_000 } } diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/agents/OutputRing.kt b/src/main/kotlin/dev/lain/claudejb/model/session/agents/OutputRing.kt new file mode 100644 index 00000000..df00eb77 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/model/session/agents/OutputRing.kt @@ -0,0 +1,25 @@ +package dev.lain.claudejb.model.session.agents + +class OutputRing(private val cap: Int, initial: String = "") { + + private val buffer = StringBuilder(initial.takeLast(cap)) + + private var cached: String? = null + + @Volatile var version = 0 + private set + + @Synchronized + fun append(text: String): Boolean { + if (text.isEmpty()) return false + buffer.append(text) + if (buffer.length > 2 * cap) buffer.delete(0, buffer.length - cap) + cached = null + version++ + return true + } + + @Synchronized + fun text(): String = cached ?: (if (buffer.length > cap) buffer.substring(buffer.length - cap) else buffer.toString()) + .also { cached = it } +} diff --git a/src/test/kotlin/dev/lain/claudejb/model/session/agents/OutputRingTest.kt b/src/test/kotlin/dev/lain/claudejb/model/session/agents/OutputRingTest.kt new file mode 100644 index 00000000..09c392eb --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/model/session/agents/OutputRingTest.kt @@ -0,0 +1,37 @@ +package dev.lain.claudejb.model.session.agents + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class OutputRingTest { + + @Test + fun `the ring keeps the last cap characters however much is appended`() { + val ring = OutputRing(cap = 10, initial = "0123456789abc") + assertEquals("3456789abc", ring.text()) + + repeat(100) { ring.append("x$it,") } + + assertEquals(10, ring.text().length) + assertTrue(ring.text().endsWith("x99,")) + } + + @Test + fun `the version moves only when something was appended`() { + val ring = OutputRing(cap = 10) + assertFalse(ring.append("")) + assertEquals(0, ring.version) + + assertTrue(ring.append("a")) + assertEquals(1, ring.version) + } + + @Test + fun `reading twice without an append returns the same string`() { + val ring = OutputRing(cap = 10, initial = "abc") + assertSame(ring.text(), ring.text()) + } +} From 58c083b0dd4090ee214c5f14664582edc008c769 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:18:04 +0200 Subject: [PATCH 071/155] perf(session): store the agent index compact, debounce its writes and prune sessions that are gone --- .../session/history/AgentIndexCodec.kt | 79 +++++++++++++ .../session/history/PluginAgentIndex.kt | 106 +++++++----------- .../session/history/AgentIndexPrivacyTest.kt | 4 +- .../history/PluginAgentIndexMigrationTest.kt | 49 +++++++- 4 files changed, 166 insertions(+), 72 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/session/history/AgentIndexCodec.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/history/AgentIndexCodec.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/history/AgentIndexCodec.kt new file mode 100644 index 00000000..098c5769 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/history/AgentIndexCodec.kt @@ -0,0 +1,79 @@ +package dev.lain.claudejb.controller.session.history + +import dev.lain.claudejb.controller.session.history.PluginAgentIndex.Index +import dev.lain.claudejb.controller.session.history.PluginAgentIndex.Kind +import dev.lain.claudejb.controller.session.history.PluginAgentIndex.Node +import dev.lain.claudejb.controller.session.history.PluginAgentIndex.Ref +import dev.lain.claudejb.controller.session.history.PluginAgentIndex.SessionRecord +import dev.lain.claudejb.model.session.agents.AgentMeta +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +internal object AgentIndexCodec { + + private val JSON = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + fun encode(sessions: Map): String { + val withChildren = sessions.mapValues { (_, rec) -> + SessionRecord( + rec.nodes.map { node -> + node.copy( + childs = rec.nodes + .filter { it.parent?.id == node.id } + .map { Ref(it.type, it.id) }, + ) + }, + ) + } + return runCatching { JSON.encodeToString(Index(PluginAgentIndex.FORMAT_VERSION, withChildren)) }.getOrDefault("") + } + + fun decode(text: String): LinkedHashMap { + val out = LinkedHashMap() + if (text.isBlank()) return out + runCatching { JSON.decodeFromString(text) }.getOrNull()?.let { index -> + if (index.sessions.isNotEmpty()) { + index.sessions.forEach { (id, rec) -> out[id] = rec.normalised(id) } + return out + } + } + runCatching { JSON.decodeFromString>>(text) }.getOrNull() + ?.forEach { (id, legacy) -> + out[id] = SessionRecord( + legacy.map { + Node( + type = Kind.AGENT, + id = AgentMeta.bareAgentId(it.agentId), + parent = Ref(Kind.CHAT, id), + open = it.open, + closedByUser = it.closedByUser, + ) + }, + ).normalised(id) + } + return out + } + + @Serializable + private data class LegacyRecord( + val agentId: String, + val open: Boolean = true, + val closedByUser: Boolean = false, + ) + + private fun SessionRecord.normalised(sessionId: String): SessionRecord { + val seen = LinkedHashMap() + nodes.forEach { n -> + val id = if (n.type == Kind.TASK) n.id else AgentMeta.bareAgentId(n.id) + val parent = n.parent?.let { + if (it.type == Kind.CHAT) Ref(Kind.CHAT, sessionId) else Ref(it.type, AgentMeta.bareAgentId(it.id)) + } + seen.putIfAbsent(id, n.copy(id = id, parent = parent ?: Ref(Kind.CHAT, sessionId))) + } + return SessionRecord(seen.values.toList()) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/history/PluginAgentIndex.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/history/PluginAgentIndex.kt index a5467b64..fb4cdb66 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/history/PluginAgentIndex.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/history/PluginAgentIndex.kt @@ -1,23 +1,27 @@ package dev.lain.claudejb.controller.session.history +import com.intellij.openapi.Disposable import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project +import com.intellij.util.concurrency.AppExecutorUtil import dev.lain.claudejb.model.session.agents.AgentMeta import dev.lain.claudejb.model.session.agents.AgentNode +import dev.lain.claudejb.model.session.history.SessionStore import dev.lain.claudejb.model.settings.SecretStore import dev.lain.claudejb.model.settings.SettingsScope import dev.lain.claudejb.util.logger import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import org.jetbrains.annotations.TestOnly +import java.util.concurrent.TimeUnit @Service(Service.Level.PROJECT) class PluginAgentIndex internal constructor( private val scope: SettingsScope, private val basePath: String?, -) { + private val later: (Runnable) -> Unit = ::afterQuietPeriod, + private val sessionExists: (String) -> Boolean = SessionStore::exists, +) : Disposable { constructor(project: Project) : this(SettingsScope.of(project), project.basePath) @@ -56,6 +60,8 @@ class PluginAgentIndex internal constructor( private val cache = LinkedHashMap() private var loaded = false + private var dirty = false + private var flushQueued = false @Synchronized fun admit(sessionId: String, node: AgentNode) { @@ -150,84 +156,52 @@ class PluginAgentIndex internal constructor( cache.putAll(decode(body)) loaded = true if (body.isNotBlank() && !body.contains("\"version\":$FORMAT_VERSION")) flush() + val stored = cache.keys.toList() + if (stored.isNotEmpty()) later(Runnable { prune(stored) }) } return cache } + private fun prune(stored: List) { + val gone = stored.filterNot { runCatching { sessionExists(it) }.getOrDefault(true) } + if (gone.isEmpty()) return + synchronized(this) { + gone.forEach(cache::remove) + flush() + } + } + private fun flush() { + dirty = true + if (flushQueued) return + flushQueued = true + later(Runnable { flushNow() }) + } + + @Synchronized + private fun flushNow() { + flushQueued = false + if (!dirty) return + dirty = false runCatching { SecretStore.set(scope.agentIndexName, encode(cache)) } .onFailure { log.warn("could not persist the agent index", it) } } - companion object { - private val JSON = Json { - ignoreUnknownKeys = true - prettyPrint = true - encodeDefaults = true - } + override fun dispose() = flushNow() + companion object { const val FORMAT_VERSION = 3 - fun getInstance(project: Project): PluginAgentIndex = project.service() + private const val QUIET_PERIOD_MS = 2_000L - fun encode(sessions: Map): String { - val withChildren = sessions.mapValues { (_, rec) -> - SessionRecord( - rec.nodes.map { node -> - node.copy( - childs = rec.nodes - .filter { it.parent?.id == node.id } - .map { Ref(it.type, it.id) }, - ) - }, - ) - } - return runCatching { JSON.encodeToString(Index(FORMAT_VERSION, withChildren)) }.getOrDefault("") + private fun afterQuietPeriod(task: Runnable) { + AppExecutorUtil.getAppScheduledExecutorService().schedule(task, QUIET_PERIOD_MS, TimeUnit.MILLISECONDS) } - fun decode(text: String): LinkedHashMap { - val out = LinkedHashMap() - if (text.isBlank()) return out - runCatching { JSON.decodeFromString(text) }.getOrNull()?.let { index -> - if (index.sessions.isNotEmpty()) { - index.sessions.forEach { (id, rec) -> out[id] = rec.normalised(id) } - return out - } - } - runCatching { JSON.decodeFromString>>(text) }.getOrNull() - ?.forEach { (id, legacy) -> - out[id] = SessionRecord( - legacy.map { - Node( - type = Kind.AGENT, - id = AgentMeta.bareAgentId(it.agentId), - parent = Ref(Kind.CHAT, id), - open = it.open, - closedByUser = it.closedByUser, - ) - }, - ).normalised(id) - } - return out - } + fun getInstance(project: Project): PluginAgentIndex = project.service() - @Serializable - private data class LegacyRecord( - val agentId: String, - val open: Boolean = true, - val closedByUser: Boolean = false, - ) - - private fun SessionRecord.normalised(sessionId: String): SessionRecord { - val seen = LinkedHashMap() - nodes.forEach { n -> - val id = if (n.type == Kind.TASK) n.id else AgentMeta.bareAgentId(n.id) - val parent = n.parent?.let { - if (it.type == Kind.CHAT) Ref(Kind.CHAT, sessionId) else Ref(it.type, AgentMeta.bareAgentId(it.id)) - } - seen.putIfAbsent(id, n.copy(id = id, parent = parent ?: Ref(Kind.CHAT, sessionId))) - } - return SessionRecord(seen.values.toList()) - } + fun encode(sessions: Map): String = AgentIndexCodec.encode(sessions) + + fun decode(text: String): LinkedHashMap = AgentIndexCodec.decode(text) } } diff --git a/src/test/kotlin/dev/lain/claudejb/controller/session/history/AgentIndexPrivacyTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/session/history/AgentIndexPrivacyTest.kt index 773dc0f0..3ad8fde2 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/session/history/AgentIndexPrivacyTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/session/history/AgentIndexPrivacyTest.kt @@ -36,8 +36,8 @@ class AgentIndexPrivacyTest { ), ), ) - assertTrue(encoded.contains("\"type\": \"subagent\""), encoded) - assertTrue(encoded.contains("\"type\": \"backgroundtask\""), encoded) + assertTrue(encoded.contains("\"type\":\"subagent\""), encoded) + assertTrue(encoded.contains("\"type\":\"backgroundtask\""), encoded) assertTrue(encoded.contains("\"parent\"")) assertTrue(encoded.contains("\"childs\"")) setOf("description", "prompt", "transcript", "summary", "stdout") diff --git a/src/test/kotlin/dev/lain/claudejb/controller/session/history/PluginAgentIndexMigrationTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/session/history/PluginAgentIndexMigrationTest.kt index 78072cb7..98f7626a 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/session/history/PluginAgentIndexMigrationTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/session/history/PluginAgentIndexMigrationTest.kt @@ -28,7 +28,10 @@ class PluginAgentIndexMigrationTest { SecretStore.storeOverride = null } - private fun index() = PluginAgentIndex(scope, basePath = null) + private val existing = mutableSetOf("s1") + + private fun index(scope: SettingsScope = this.scope) = + PluginAgentIndex(scope, basePath = null, later = { it.run() }, sessionExists = { it in existing }) private fun seed(json: String) { safe[scope.agentIndexName] = json @@ -55,8 +58,9 @@ class PluginAgentIndexMigrationTest { seed("""{"s1":[{"agentId":"agent-abc","open":true,"closedByUser":false}]}""") index().admittedAgents("s1") val body = stored() - assertTrue(body.contains("\"version\": ${PluginAgentIndex.FORMAT_VERSION}"), body) - assertTrue(body.contains("\"id\": \"abc\""), body) + assertTrue(body.contains("\"version\":${PluginAgentIndex.FORMAT_VERSION}"), body) + assertTrue(body.contains("\"id\":\"abc\""), body) + assertFalse(body.contains("\n"), "the index is stored compact: $body") assertFalse(body.contains("agent-abc"), "the legacy id shape must not survive the rewrite: $body") } @@ -126,10 +130,47 @@ class PluginAgentIndexMigrationTest { ) } + @Test + fun `sessions whose transcript is gone are pruned when the index loads`() { + index().apply { + admit("s1", node("a1")) + admit("gone", node("a2")) + } + + val reloaded = index() + + assertEquals(listOf("a1"), reloaded.admittedAgents("s1")) + assertFalse(stored().contains("gone"), stored()) + } + + @Test + fun `writes wait for the quiet period and land together`() { + val queued = mutableListOf() + val index = PluginAgentIndex(scope, basePath = null, later = { queued += it }, sessionExists = { true }) + index.admit("s1", node("a1")) + index.admit("s1", node("a2")) + assertFalse(safe.containsKey(scope.agentIndexName)) + assertEquals(1, queued.size) + + queued.single().run() + + assertTrue(stored().contains("a2")) + } + + @Test + fun `closing the project writes what is still pending`() { + val index = PluginAgentIndex(scope, basePath = null, later = { }, sessionExists = { true }) + index.admit("s1", node("a1")) + + index.dispose() + + assertTrue(stored().contains("a1")) + } + @Test fun `one project's index is not another's`() { index().admit("s1", node("a1")) - val other = PluginAgentIndex(SettingsScope("a-different-project"), basePath = null) + val other = index(SettingsScope("a-different-project")) assertTrue(other.admittedAgents("s1").isEmpty()) assertEquals(listOf("a1"), index().admittedAgents("s1")) From 1422fd5e1293e143044cd886c290b74dc5950962 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:18:59 +0200 Subject: [PATCH 072/155] fix(git): release a prompted Git action when no turn starts and guard its state map --- .../controller/commands/git/GitIntegration.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitIntegration.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitIntegration.kt index d4d356c1..5836805f 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitIntegration.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitIntegration.kt @@ -18,13 +18,14 @@ import dev.lain.claudejb.util.edt import dev.lain.claudejb.util.logger import dev.lain.claudejb.view.git.JcefGitData import dev.lain.claudejb.view.git.JcefGitData.ActionState +import java.util.concurrent.ConcurrentHashMap @Service(Service.Level.PROJECT) internal class GitIntegration(private val project: Project) { private var snapshot: JcefGitData.Snapshot? = null - private val states = mutableMapOf() + private val states = ConcurrentHashMap() private var inFlight: List<() -> Unit>? = null private val queued = ArrayList<() -> Unit>() @@ -127,8 +128,10 @@ internal class GitIntegration(private val project: Project) { val session = chat() states[id] = ActionState.RUNNING onChanged() - session.addListener(TurnWatch(id, session, onChanged)) + val watch = TurnWatch(id, session, onChanged) + session.addListener(watch) session.send(text) + watch.onStateChanged() } private fun subject(): GitActionCatalog.PromptSubject? { @@ -152,6 +155,10 @@ internal class GitIntegration(private val project: Project) { override fun onStateChanged() { if (session.turn.active) started = true + if (started || session.isRunning() || session.lifecycle.isStarting()) return + session.removeListener(this) + states.remove(id) + onChanged() } override fun onAttention(reason: AttentionReason, landing: AttentionLanding) { From ef729d9878975b0042dee823ea6fe93469149a16 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:18:59 +0200 Subject: [PATCH 073/155] fix(session): always complete control requests, even on a bad reply or a long run --- .../session/control/SessionControlClient.kt | 24 +++++++++----- .../control/SessionControlClientTest.kt | 33 +++++++++++++++++-- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/control/SessionControlClient.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/control/SessionControlClient.kt index bb332fb6..9cd3bafc 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/control/SessionControlClient.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/control/SessionControlClient.kt @@ -31,7 +31,7 @@ class SessionControlClient( } } - private class Pending(val watchdog: Cancellable, val onOutcome: (ClaudeEvent.ControlResult) -> Unit) + private class Pending(@Volatile var watchdog: Cancellable, val onOutcome: (ClaudeEvent.ControlResult) -> Unit) private val pending = ConcurrentHashMap() @@ -39,6 +39,7 @@ class SessionControlClient( private companion object { const val DEFAULT_TIMEOUT_SECONDS = 30L + const val LONG_RUNNING_FACTOR = 20L } private fun requestSubtype(line: String): String = @@ -48,7 +49,12 @@ class SessionControlClient( buildRequest: (requestId: String) -> String, onResult: (T?) -> Unit, decode: (JsonObject?) -> T?, - ) = send(buildRequest) { res -> onResult(decode(res.payload)) } + ) = send(buildRequest) { res -> + val decoded = runCatching { decode(res.payload) } + .onFailure { log.warn("a control reply could not be decoded; answering with nothing", it) } + .getOrNull() + onResult(decoded) + } fun send( buildRequest: (requestId: String) -> String, @@ -56,10 +62,7 @@ class SessionControlClient( ) { val id = newRequestId() val requestLine = buildRequest(id) - val watchdog = scheduler.schedule(timeoutSeconds) { - settle(id, ClaudeEvent.ControlResult(requestId = id, success = false, payload = null, error = "control request timed out")) - } - pending[id] = Pending(watchdog) { res -> + pending[id] = Pending(watchdog(id, timeoutSeconds)) { res -> log.debug { "control reply ${requestSubtype(requestLine)} id=$id success=${res.success}" + " err=${res.error ?: "-"} payload=${res.payload ?: "null"}" @@ -75,7 +78,12 @@ class SessionControlClient( fun onProgress(requestId: String) { val entry = pending[requestId] ?: return entry.watchdog.cancel() - log.debug { "control request $requestId is long-running; its watchdog is off" } + entry.watchdog = watchdog(requestId, timeoutSeconds * LONG_RUNNING_FACTOR) + log.debug { "control request $requestId is long-running; its watchdog is extended" } + } + + private fun watchdog(id: String, seconds: Long): Cancellable = scheduler.schedule(seconds) { + settle(id, ClaudeEvent.ControlResult(requestId = id, success = false, payload = null, error = "control request timed out")) } fun failAll(reason: String) { @@ -87,6 +95,6 @@ class SessionControlClient( private fun settle(id: String, result: ClaudeEvent.ControlResult) { val entry = pending.remove(id) ?: return entry.watchdog.cancel() - entry.onOutcome(result) + runCatching { entry.onOutcome(result) }.onFailure { log.warn("a control reply handler failed", it) } } } diff --git a/src/test/kotlin/dev/lain/claudejb/controller/session/control/SessionControlClientTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/session/control/SessionControlClientTest.kt index be03db8e..89456c9d 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/session/control/SessionControlClientTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/session/control/SessionControlClientTest.kt @@ -120,7 +120,7 @@ class SessionControlClientTest { } @Test - fun `a request the binary reports as started is long-running, so its watchdog is dropped and the late answer lands`() { + fun `a request the binary reports as started gets a longer watchdog and the late answer lands`() { val sent = mutableListOf() val scheduler = FakeScheduler() val client = client(sent, scheduler, listOf("req_1").iterator()) @@ -134,7 +134,7 @@ class SessionControlClientTest { client.onProgress("req_1") assertEquals(1, scheduler.cancelled) - scheduler.fireAll() + assertEquals(1, scheduler.tasks.size) assertEquals("unset", captured) val payload = buildJsonObject { put("response", "forty-two") } @@ -142,6 +142,35 @@ class SessionControlClientTest { assertEquals("forty-two", captured) } + @Test + fun `a long-running request that never answers still completes`() { + val scheduler = FakeScheduler() + val client = client(mutableListOf(), scheduler, listOf("req_1").iterator()) + var calls = 0 + client.query(buildRequest = { "line" }, onResult = { _: String? -> calls++ }, decode = { it?.str("x") }) + + client.onProgress("req_1") + scheduler.fireAll() + + assertEquals(1, calls) + } + + @Test + fun `a reply that cannot be decoded still answers, with nothing`() { + val scheduler = FakeScheduler() + val client = client(mutableListOf(), scheduler, listOf("req_1").iterator()) + var captured: String? = "unset" + client.query( + buildRequest = { "line" }, + onResult = { v: String? -> captured = v }, + decode = { error("malformed") }, + ) + + client.onControlResult(ClaudeEvent.ControlResult("req_1", success = true, payload = buildJsonObject {}, error = null)) + + assertNull(captured) + } + @Test fun `failAll resolves all pending with null and empties the map`() { val sent = mutableListOf() From 27196f6c7f862a562de06b324393684dddd7b2e2 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:19:02 +0200 Subject: [PATCH 074/155] perf(page): patch the composer strip only when it changes and cycle vibe colours cheaply --- src/main/ts/jcef/core/theme.ts | 38 +++++++++++++++---- src/main/ts/jcef/types/core.d.ts | 1 + .../ts/jcef/views/composer/readout/mini.ts | 7 +++- .../ts/jcef/views/composer/readout/readout.ts | 28 +++++++++++++- src/test/frontend/core/theme-vibe.test.js | 25 ++++++++++++ 5 files changed, 89 insertions(+), 10 deletions(-) create mode 100644 src/test/frontend/core/theme-vibe.test.js diff --git a/src/main/ts/jcef/core/theme.ts b/src/main/ts/jcef/core/theme.ts index e4a58c41..62e8b95c 100644 --- a/src/main/ts/jcef/core/theme.ts +++ b/src/main/ts/jcef/core/theme.ts @@ -44,6 +44,7 @@ if (!body) return; body.classList.toggle('reduced-motion', !!on); CC.reducedMotion = !!on; + syncVibeTimer(); } CC.diagnostics = function (): void { @@ -85,9 +86,11 @@ return 'hsla(' + Math.round(((h % 360) + 360) % 360) + ',' + s + '%,' + l + '%,' + a + ')'; } + const VIBE_TICK_MS = 120; + function vibeStep(): void { - if (!vibeOn) return; - vibeHue = (vibeHue + 6) % 360; + if (!vibeOn || document.hidden) return; + vibeHue = (vibeHue + 24) % 360; const s = document.documentElement.style; const h = vibeHue; s.setProperty('--accent', hsl(h, 90, 60)); @@ -99,19 +102,40 @@ s.setProperty('--warning', hsl(h + 260, 80, 64)); } + function syncVibeTimer(): void { + const cycling = vibeOn && !CC.reducedMotion; + if (cycling && !vibeTimer) vibeTimer = window.setInterval(vibeStep, VIBE_TICK_MS); + if (!cycling && vibeTimer) { + window.clearInterval(vibeTimer); + vibeTimer = 0; + } + } + + function ensureNyanSymbol(): void { + if (document.getElementById('cc-nyan')) return; + const holder = document.createElement('div'); + holder.hidden = true; + holder.innerHTML = CC.nyanSvg() + .replace(' diff --git a/src/test/kotlin/dev/lain/claudejb/SourceLayout.kt b/src/test/kotlin/dev/lain/claudejb/SourceLayout.kt index f380bbdf..b28ca8b9 100644 --- a/src/test/kotlin/dev/lain/claudejb/SourceLayout.kt +++ b/src/test/kotlin/dev/lain/claudejb/SourceLayout.kt @@ -10,9 +10,14 @@ internal object SourceLayout { "shared" to "dev.lain.claudejb.shared", "frontend" to "dev.lain.claudejb.frontend", "backend" to "dev.lain.claudejb.backend", + "git" to "dev.lain.claudejb.git", + "github" to "dev.lain.claudejb.github", + "java" to "dev.lain.claudejb.java", + "intellilang" to "dev.lain.claudejb.intellilang", + "terminal" to "dev.lain.claudejb.terminal", ) - private val MODULES = listOf("backend", "frontend", "shared", "") + private val MODULES = listOf("backend", "frontend", "shared", "git", "github", "java", "intellilang", "terminal", "") fun roots(kind: String): List = MODULES.mapNotNull { dir("${prefix(it)}src/main/$kind") } diff --git a/terminal/build.gradle.kts b/terminal/build.gradle.kts new file mode 100644 index 00000000..2c62734a --- /dev/null +++ b/terminal/build.gradle.kts @@ -0,0 +1,7 @@ +dependencies { + intellijPlatform { + bundledModule("intellij.platform.frontend") + bundledPlugin("org.jetbrains.plugins.terminal") + } + implementation(project(":frontend")) +} From 268c9a8d91af0843a69e7431ced6023ca456da92 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:51:58 +0200 Subject: [PATCH 101/155] refactor(git): reach Git4Idea through the optional Git content module --- .../claudejb/controller/git/GitGateway.kt | 45 +++++++++---------- .../controller/mcp/tools/vcs/GitCommands.kt | 41 ++++++++--------- .../main/resources/dev.lain.claudejb.git.xml | 13 ++++++ .../lain/claudejb/controller/git/GitAccess.kt | 32 +++++++++++++ .../controller/git/GitHistoryService.kt | 43 +++++++++--------- .../controller/mcp/tools/vcs/ChangesTools.kt | 3 +- .../controller/mcp/tools/vcs/GitWriteTools.kt | 3 +- .../controller/mcp/tools/vcs/GitWrites.kt | 44 ++++++++++++++++++ .../controller/mcp/tools/vcs/HistoryTools.kt | 3 +- .../controller/mcp/tools/vcs/LogOpsTools.kt | 7 +-- .../controller/git/GitReadOnlyContractTest.kt | 10 ++--- .../vcs/GitCommandsGatewayContractTest.kt | 2 +- .../mcp/tools/vcs/LogOpsToolSpecsTest.kt | 2 +- 13 files changed, 169 insertions(+), 79 deletions(-) rename {src => git/src}/main/kotlin/dev/lain/claudejb/controller/git/GitGateway.kt (80%) rename {src => git/src}/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitCommands.kt (87%) create mode 100644 git/src/main/resources/dev.lain.claudejb.git.xml create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/git/GitAccess.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitWrites.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/git/GitGateway.kt b/git/src/main/kotlin/dev/lain/claudejb/controller/git/GitGateway.kt similarity index 80% rename from src/main/kotlin/dev/lain/claudejb/controller/git/GitGateway.kt rename to git/src/main/kotlin/dev/lain/claudejb/controller/git/GitGateway.kt index e2ccd5e9..39b2d64e 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/git/GitGateway.kt +++ b/git/src/main/kotlin/dev/lain/claudejb/controller/git/GitGateway.kt @@ -21,15 +21,15 @@ import git4idea.repo.GitRepository import git4idea.repo.GitRepositoryChangeListener import git4idea.repo.GitRepositoryManager -internal object GitGateway { +internal class GitGateway : GitAccess { - fun repositoryRoots(project: Project): List = repositories(project).map { it.root } + override fun repositoryRoots(project: Project): List = repositories(project).map { it.root } - fun currentBranchName(project: Project, root: VirtualFile): String? = repositoryAt(project, root)?.currentBranchName + override fun currentBranchName(project: Project, root: VirtualFile): String? = repositoryAt(project, root)?.currentBranchName - fun currentRevision(project: Project, root: VirtualFile): String? = repositoryAt(project, root)?.currentRevision + override fun currentRevision(project: Project, root: VirtualFile): String? = repositoryAt(project, root)?.currentRevision - fun refs(project: Project, root: VirtualFile): List { + override fun refs(project: Project, root: VirtualFile): List { val repository = repositoryAt(project, root) ?: return emptyList() val branches: GitBranchesCollection = repository.branches val head = repository.currentBranchName @@ -52,26 +52,19 @@ internal object GitGateway { .sortedWith(compareByDescending { it.current }.thenBy { it.kind }.thenBy { it.name }) } - private const val DETACHED_HEAD = "HEAD" - @Throws(VcsException::class) - fun recentCommits( - project: Project, - root: VirtualFile, - limit: Int, - scope: GitLogScope = GitLogScope.CURRENT_BRANCH, - ): List { + override fun recentCommits(project: Project, root: VirtualFile, limit: Int, scope: GitLogScope): List { @Suppress("SpreadOperator") val commits = GitHistoryUtils.history(project, root, *revisionsOf(scope), "--topo-order", "-n", limit.toString()) return commits.map { commit -> toInfo(commit, root.path) } } @Throws(VcsException::class) - fun commit(project: Project, root: VirtualFile, hash: String): GitCommitInfo? = + override fun commit(project: Project, root: VirtualFile, hash: String): GitCommitInfo? = GitHistoryUtils.history(project, root, hash, "-n", "1").firstOrNull()?.let { toInfo(it, root.path) } @Throws(VcsException::class) - fun fileHistory(project: Project, root: VirtualFile, relativePath: String, limit: Int): List { + override fun fileHistory(project: Project, root: VirtualFile, relativePath: String, limit: Int): List { val path = VcsUtil.getFilePath(root.path + "/" + relativePath, false) val hashes = GitFileHistory.collectHistory(project, path, "-n", limit.toString()).map { it.revisionNumber.asString() } if (hashes.isEmpty()) return emptyList() @@ -86,7 +79,7 @@ internal object GitGateway { } @Throws(VcsException::class) - fun branchTopology(project: Project, root: VirtualFile): GitBranchTopology { + override fun branchTopology(project: Project, root: VirtualFile): GitBranchTopology { val repository = repositoryAt(project, root) ?: return GitBranchTopology.NONE val branch = repository.currentBranchName ?: return GitBranchTopology.NONE val track: GitBranchTrackInfo? = repository.getBranchTrackInfo(branch) @@ -122,24 +115,28 @@ internal object GitGateway { ) } - fun onRepositoryChanged(project: Project, parent: Disposable, onChanged: () -> Unit) { + override fun onRepositoryChanged(project: Project, parent: Disposable, onChanged: () -> Unit) { project.messageBus.connect(parent).subscribe( GitRepository.GIT_REPO_CHANGE, GitRepositoryChangeListener { onChanged() }, ) } - fun midOperation(project: Project, root: VirtualFile): Boolean = + override fun midOperation(project: Project, root: VirtualFile): Boolean = repositoryAt(project, root)?.state in RESOLVING_STATES - private val RESOLVING_STATES = setOf( - Repository.State.MERGING, - Repository.State.REBASING, - Repository.State.GRAFTING, - ) - private fun repositories(project: Project): List = GitRepositoryManager.getInstance(project).repositories private fun repositoryAt(project: Project, root: VirtualFile): GitRepository? = repositories(project).firstOrNull { it.root == root } + + private companion object { + const val DETACHED_HEAD = "HEAD" + + val RESOLVING_STATES = setOf( + Repository.State.MERGING, + Repository.State.REBASING, + Repository.State.GRAFTING, + ) + } } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitCommands.kt b/git/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitCommands.kt similarity index 87% rename from src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitCommands.kt rename to git/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitCommands.kt index 225faf73..ac74d4c6 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitCommands.kt +++ b/git/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitCommands.kt @@ -20,13 +20,13 @@ import git4idea.repo.GitRepository import git4idea.repo.GitRepositoryManager import git4idea.util.GitFileUtils -internal class GitCommands(private val project: Project) { +internal class GitCommands(private val project: Project) : GitWrites { private fun requireGit() { - if (!GitAvailability.isGitPluginEnabled()) throw ToolException("the Git plugin (Git4Idea) is disabled in this IDE") + if (!GitAvailability.isGitPluginEnabled()) throw ToolException(GitWrites.MISSING) } - fun stage(paths: List) { + override fun stage(paths: List) { requireGit() val repository = repository() add(repository, paths) @@ -41,14 +41,14 @@ internal class GitCommands(private val project: Project) { checked(Git.getInstance().runCommand(handler)) } - fun unstage(paths: List) { + override fun unstage(paths: List) { requireGit() val repository = repository() vcs { GitFileUtils.resetPaths(project, repository.root, paths) } refresh(repository, worktree = false) } - fun commit(message: String, paths: List, amend: Boolean) { + override fun commit(message: String, paths: List, amend: Boolean) { requireGit() val repository = repository() if (paths.isNotEmpty()) add(repository, paths) @@ -64,21 +64,21 @@ internal class GitCommands(private val project: Project) { refresh(repository, worktree = false) } - fun createBranch(name: String, startPoint: String) { + override fun createBranch(name: String, startPoint: String) { requireGit() val repository = repository() checked(Git.getInstance().branchCreate(repository, name, startPoint)) refresh(repository, worktree = false) } - fun checkout(reference: String, newBranch: String?) { + override fun checkout(reference: String, newBranch: String?) { requireGit() val repository = repository() checked(Git.getInstance().checkout(repository, reference, newBranch, false, false)) refresh(repository, worktree = true) } - fun fetch(remoteName: String?): String { + override fun fetch(remoteName: String?): String { requireGit() val repository = repository() val remote = remote(repository, remoteName) @@ -87,7 +87,7 @@ internal class GitCommands(private val project: Project) { return remote.name } - fun pull(remoteName: String?, branch: String): String { + override fun pull(remoteName: String?, branch: String): String { requireGit() val repository = repository() val remote = remote(repository, remoteName) @@ -99,7 +99,7 @@ internal class GitCommands(private val project: Project) { return remote.name } - fun push(remoteName: String?, branch: String): String { + override fun push(remoteName: String?, branch: String): String { requireGit() val repository = repository() val remote = remote(repository, remoteName) @@ -109,7 +109,7 @@ internal class GitCommands(private val project: Project) { return remote.name } - fun stash(action: String, message: String?): List { + override fun stash(action: String, message: String?): List { requireGit() val repository = repository() val params = when (action) { @@ -122,7 +122,7 @@ internal class GitCommands(private val project: Project) { return output } - fun worktrees(action: String, path: String?, branch: String?): List { + override fun worktrees(action: String, path: String?, branch: String?): List { requireGit() val repository = repository() val params = when (action) { @@ -136,7 +136,7 @@ internal class GitCommands(private val project: Project) { return output } - fun remotes(action: String, name: String?, url: String?): List { + override fun remotes(action: String, name: String?, url: String?): List { requireGit() val repository = repository() val params = when (action) { @@ -153,12 +153,12 @@ internal class GitCommands(private val project: Project) { private fun needed(action: String, key: String, value: String?): String = value ?: throw ToolException("action=$action needs $key") - fun show(reference: String, path: String): List { + override fun show(reference: String, path: String): List { requireGit() return run(repository(), GitCommand.SHOW, listOf("$reference:$path")) } - fun branchOp(action: String, reference: String, target: String?) { + override fun branchOp(action: String, reference: String, target: String?) { requireGit() val repository = repository() val repositories = listOf(repository) @@ -175,7 +175,7 @@ internal class GitCommands(private val project: Project) { "checkout" -> brancher.checkout(reference, false, repositories, null) "checkout_as_new" -> brancher.checkoutNewBranchStartingFrom(other(), reference, repositories, null) "new_tag" -> brancher.createNewTag(other(), reference, repositories, null) - else -> throw ToolException("action must be one of $BRANCH_ACTIONS") + else -> throw ToolException("action must be one of ${GitWrites.BRANCH_ACTIONS}") } } @@ -228,11 +228,8 @@ internal class GitCommands(private val project: Project) { if (worktree) VfsUtil.markDirtyAndRefresh(false, true, false, repository.root) } - companion object { - private const val CONFLICT = "CONFLICT" - private const val CONFLICT_HINT = "resolve the conflicts in the IDE with vcs_action(action=resolve_conflicts)" - - const val BRANCH_ACTIONS = - "merge, rebase, rebase_onto, compare, diff_with_local, rename, delete, checkout, checkout_as_new or new_tag" + private companion object { + const val CONFLICT = "CONFLICT" + const val CONFLICT_HINT = "resolve the conflicts in the IDE with vcs_action(action=resolve_conflicts)" } } diff --git a/git/src/main/resources/dev.lain.claudejb.git.xml b/git/src/main/resources/dev.lain.claudejb.git.xml new file mode 100644 index 00000000..130fb6de --- /dev/null +++ b/git/src/main/resources/dev.lain.claudejb.git.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/src/main/kotlin/dev/lain/claudejb/controller/git/GitAccess.kt b/src/main/kotlin/dev/lain/claudejb/controller/git/GitAccess.kt new file mode 100644 index 00000000..71dd100a --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/git/GitAccess.kt @@ -0,0 +1,32 @@ +package dev.lain.claudejb.controller.git + +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import dev.lain.claudejb.model.git.GitBranchTopology +import dev.lain.claudejb.model.git.GitCommitInfo +import dev.lain.claudejb.model.git.GitLogScope +import dev.lain.claudejb.model.git.GitRefInfo + +interface GitAccess { + + fun repositoryRoots(project: Project): List + + fun currentBranchName(project: Project, root: VirtualFile): String? + + fun currentRevision(project: Project, root: VirtualFile): String? + + fun refs(project: Project, root: VirtualFile): List + + fun recentCommits(project: Project, root: VirtualFile, limit: Int, scope: GitLogScope): List + + fun commit(project: Project, root: VirtualFile, hash: String): GitCommitInfo? + + fun fileHistory(project: Project, root: VirtualFile, relativePath: String, limit: Int): List + + fun branchTopology(project: Project, root: VirtualFile): GitBranchTopology + + fun onRepositoryChanged(project: Project, parent: Disposable, onChanged: () -> Unit) + + fun midOperation(project: Project, root: VirtualFile): Boolean +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/git/GitHistoryService.kt b/src/main/kotlin/dev/lain/claudejb/controller/git/GitHistoryService.kt index 74a081e0..7e0eee3a 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/git/GitHistoryService.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/git/GitHistoryService.kt @@ -3,6 +3,7 @@ package dev.lain.claudejb.controller.git import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service +import com.intellij.openapi.components.serviceOrNull import com.intellij.openapi.project.Project import com.intellij.openapi.vcs.FileStatus import com.intellij.openapi.vcs.VcsException @@ -17,7 +18,7 @@ import dev.lain.claudejb.util.logger @Service(Service.Level.PROJECT) class GitHistoryService(private val project: Project) { - fun repositoryRoots(): List = readGit(emptyList()) { GitGateway.repositoryRoots(project).map { it.path } } + fun repositoryRoots(): List = readGit(emptyList()) { git -> git.repositoryRoots(project).map { it.path } } fun isAvailable(): Boolean = repositoryRoots().isNotEmpty() @@ -27,9 +28,9 @@ class GitHistoryService(private val project: Project) { return roots.filter { base == it || base.startsWith("$it/") }.maxByOrNull { it.length } ?: roots.firstOrNull() } - fun currentBranch(): String? = withPrimaryRoot { root -> GitGateway.currentBranchName(project, root) } + fun currentBranch(): String? = withPrimaryRoot { git, root -> git.currentBranchName(project, root) } - fun headRevision(): String? = withPrimaryRoot { root -> GitGateway.currentRevision(project, root) } + fun headRevision(): String? = withPrimaryRoot { git, root -> git.currentRevision(project, root) } fun recentCommits( limit: Int = DEFAULT_COMMIT_LIMIT, @@ -37,25 +38,25 @@ class GitHistoryService(private val project: Project) { ): List { if (limit <= 0) return emptyList() if (refusedOnEdt("recentCommits()", "git log")) return emptyList() - return withPrimaryRoot(emptyList()) { root -> GitGateway.recentCommits(project, root, limit, scope) } + return withPrimaryRoot(emptyList()) { git, root -> git.recentCommits(project, root, limit, scope) } } fun commit(hash: String): GitCommitInfo? { if (refusedOnEdt("commit()", "git log -n 1")) return null - return withPrimaryRoot(null) { root -> GitGateway.commit(project, root, hash) } + return withPrimaryRoot(null) { git, root -> git.commit(project, root, hash) } } fun fileHistory(relativePath: String, limit: Int): List { if (limit <= 0 || refusedOnEdt("fileHistory()", "git log --follow")) return emptyList() - return withPrimaryRoot(emptyList()) { root -> GitGateway.fileHistory(project, root, relativePath, limit) } + return withPrimaryRoot(emptyList()) { git, root -> git.fileHistory(project, root, relativePath, limit) } } fun branchTopology(): GitBranchTopology { if (refusedOnEdt("branchTopology()", "git rev-list / git merge-base")) return GitBranchTopology.NONE - return withPrimaryRoot(GitBranchTopology.NONE) { root -> GitGateway.branchTopology(project, root) } + return withPrimaryRoot(GitBranchTopology.NONE) { git, root -> git.branchTopology(project, root) } } - fun refs(): List = withPrimaryRoot(emptyList()) { root -> GitGateway.refs(project, root) } + fun refs(): List = withPrimaryRoot(emptyList()) { git, root -> git.refs(project, root) } fun workingTreeChanges(): List { val root = primaryRepositoryRoot() ?: return emptyList() @@ -70,12 +71,12 @@ class GitHistoryService(private val project: Project) { if (ChangeListManager.getInstance(project).allChanges.any { it.fileStatus == FileStatus.MERGED_WITH_CONFLICTS }) { return true } - return withPrimaryRoot(false) { root -> GitGateway.midOperation(project, root) } + return withPrimaryRoot(false) { git, root -> git.midOperation(project, root) } } fun onRepositoryChanged(parent: Disposable, onChanged: () -> Unit) { - if (!GitAvailability.isGitPluginEnabled()) return - runCatching { GitGateway.onRepositoryChanged(project, parent, onChanged) } + val git = gitAccess() ?: return + runCatching { git.onRepositoryChanged(project, parent, onChanged) } .onFailure { LOG.warn("Could not subscribe to Git repository changes for ${project.name}", it) } } @@ -85,28 +86,30 @@ class GitHistoryService(private val project: Project) { return true } - private fun withPrimaryRoot(fallback: T, block: (VirtualFile) -> T): T = - readGit(fallback) { + private fun withPrimaryRoot(fallback: T, block: (GitAccess, VirtualFile) -> T): T = + readGit(fallback) { git -> val wanted = primaryRepositoryRoot() ?: return@readGit fallback - val root = GitGateway.repositoryRoots(project).firstOrNull { it.path == wanted } ?: return@readGit fallback - block(root) + val root = git.repositoryRoots(project).firstOrNull { it.path == wanted } ?: return@readGit fallback + block(git, root) } - private fun withPrimaryRoot(block: (VirtualFile) -> String?): String? = withPrimaryRoot(null, block) + private fun withPrimaryRoot(block: (GitAccess, VirtualFile) -> String?): String? = withPrimaryRoot(null, block) - private fun readGit(fallback: T, block: () -> T): T { - if (!GitAvailability.isGitPluginEnabled()) return fallback + private fun readGit(fallback: T, block: (GitAccess) -> T): T { + val git = gitAccess() ?: return fallback return try { - block() + block(git) } catch (e: VcsException) { LOG.warn("Git query failed for ${project.name}", e) fallback } catch (e: LinkageError) { - LOG.warn("Git4Idea classes are not on this plugin's classpath; the Git surface stays off", e) + LOG.warn("The Git module no longer links against this IDE's Git4Idea; the Git surface stays off", e) fallback } } + private fun gitAccess(): GitAccess? = if (GitAvailability.isGitPluginEnabled()) serviceOrNull() else null + companion object { const val DEFAULT_COMMIT_LIMIT = 20 diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ChangesTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ChangesTools.kt index ac3fdb17..810d0a0a 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ChangesTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ChangesTools.kt @@ -39,10 +39,11 @@ internal class ChangesTools( private val project: Project, private val actions: IdeActions, private val reveal: Reveal, - private val git: GitCommands = GitCommands(project), private val io: CoroutineDispatcher = Dispatchers.IO, ) { + private val git: GitWrites by lazy { GitWrites.of(project) } + fun domain(): ToolDomain = ToolDomain( "changes", "The uncommitted work as the Git menu and the Commit window handle it: stash, shelve, patches and rollback", diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitWriteTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitWriteTools.kt index 8d1ac325..06bdfe11 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitWriteTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitWriteTools.kt @@ -23,10 +23,11 @@ import kotlinx.serialization.json.put internal class GitWriteTools( private val project: Project, - private val git: GitCommands = GitCommands(project), private val io: CoroutineDispatcher = Dispatchers.IO, ) { + private val git: GitWrites by lazy { GitWrites.of(project) } + private val history: GitHistoryService get() = project.service() fun domain(): ToolDomain = ToolDomain( diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitWrites.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitWrites.kt new file mode 100644 index 00000000..eda02968 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitWrites.kt @@ -0,0 +1,44 @@ +package dev.lain.claudejb.controller.mcp.tools.vcs + +import com.intellij.openapi.components.serviceOrNull +import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.FilePath +import dev.lain.claudejb.model.mcp.ToolException + +interface GitWrites { + + fun stage(paths: List) + + fun unstage(paths: List) + + fun commit(message: String, paths: List, amend: Boolean) + + fun createBranch(name: String, startPoint: String) + + fun checkout(reference: String, newBranch: String?) + + fun fetch(remoteName: String?): String + + fun pull(remoteName: String?, branch: String): String + + fun push(remoteName: String?, branch: String): String + + fun stash(action: String, message: String?): List + + fun worktrees(action: String, path: String?, branch: String?): List + + fun remotes(action: String, name: String?, url: String?): List + + fun show(reference: String, path: String): List + + fun branchOp(action: String, reference: String, target: String?) + + companion object { + const val MISSING = "the Git plugin (Git4Idea) is disabled in this IDE" + + const val BRANCH_ACTIONS = + "merge, rebase, rebase_onto, compare, diff_with_local, rename, delete, checkout, checkout_as_new or new_tag" + + fun of(project: Project): GitWrites = project.serviceOrNull() ?: throw ToolException(MISSING) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/HistoryTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/HistoryTools.kt index 68bace8a..1295e620 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/HistoryTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/HistoryTools.kt @@ -38,10 +38,11 @@ internal class HistoryTools( private val project: Project, private val actions: IdeActions, private val reveal: Reveal, - private val git: GitCommands = GitCommands(project), private val io: CoroutineDispatcher = Dispatchers.IO, ) { + private val git: GitWrites by lazy { GitWrites.of(project) } + private val labels = LinkedHashMap() fun domain(): ToolDomain = ToolDomain( diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/LogOpsTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/LogOpsTools.kt index f3c1b364..7935b719 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/LogOpsTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/LogOpsTools.kt @@ -24,10 +24,11 @@ import kotlinx.serialization.json.put internal class LogOpsTools( private val project: Project, private val actions: IdeActions, - private val git: GitCommands = GitCommands(project), private val io: CoroutineDispatcher = Dispatchers.IO, ) { + private val git: GitWrites by lazy { GitWrites.of(project) } + fun domain(): ToolDomain = ToolDomain( "log_ops", "What the Git Log's commit menu and the Branches popup offer: an action on a commit, an operation on a branch, " + @@ -152,10 +153,10 @@ internal class LogOpsTools( val BRANCH_OP = ToolSpec( "branch_op", "One operation of the Branches popup on a branch, through the IDE's own branch machinery with its progress, " + - "smart checkout and conflict resolution: " + GitCommands.BRANCH_ACTIONS + ". target is the other name " + + "smart checkout and conflict resolution: " + GitWrites.BRANCH_ACTIONS + ". target is the other name " + "where one is needed: the upstream for rebase_onto, the new name for rename, checkout_as_new and new_tag.", listOf( - Param("action", GitCommands.BRANCH_ACTIONS), + Param("action", GitWrites.BRANCH_ACTIONS), Param("ref", "The branch, or for new_tag the reference the tag points at"), Param("target", "The other name the action needs (see above)", required = false), ), diff --git a/src/test/kotlin/dev/lain/claudejb/controller/git/GitReadOnlyContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/git/GitReadOnlyContractTest.kt index 89ac7f00..93dd40db 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/git/GitReadOnlyContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/git/GitReadOnlyContractTest.kt @@ -1,5 +1,6 @@ package dev.lain.claudejb.controller.git +import dev.lain.claudejb.SourceLayout import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -7,15 +8,14 @@ import java.io.File class GitReadOnlyContractTest { - private val sources: List = File("src/main/kotlin/dev/lain/claudejb/controller/git") - .walkTopDown() - .filter { it.isFile && it.extension == "kt" } - .toList() + private val sources: List = SourceLayout.roots("kotlin") + .map { File(it, "${SourceLayout.PACKAGE_ROOT}/controller/git") } + .flatMap { dir -> dir.walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() } .sortedBy { it.name } @Test fun `the package exists and this test is actually looking at it`() { - assertTrue(sources.isNotEmpty(), "No Kotlin sources found under src/main/kotlin/dev/lain/claudejb/controller/git") + assertTrue(sources.isNotEmpty(), "No Kotlin sources found under any module's dev/lain/claudejb/controller/git") assertTrue(sources.any { it.name == GATEWAY }, "$GATEWAY is missing; the containment this test pins is gone") } diff --git a/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitCommandsGatewayContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitCommandsGatewayContractTest.kt index ee7a1e62..4eb37ea2 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitCommandsGatewayContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitCommandsGatewayContractTest.kt @@ -85,7 +85,7 @@ class GitCommandsGatewayContractTest { const val GUARD_NAME = "requireGit" val GUARD = Regex("""private fun $GUARD_NAME\(\) \{""") - val ENTRY = Regex("""^ {4}fun \w+\(.*""") + val ENTRY = Regex("""^ {4}(override )?fun \w+\(.*""") val COMMAND_SYMBOLS = listOf( "GitLineHandler", diff --git a/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/LogOpsToolSpecsTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/LogOpsToolSpecsTest.kt index d42c9d12..892d9f7c 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/LogOpsToolSpecsTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/LogOpsToolSpecsTest.kt @@ -55,7 +55,7 @@ class LogOpsToolSpecsTest { @Test fun `branch_op names every branch action of the write gateway`() { listOf("merge", "rebase", "rebase_onto", "compare", "diff_with_local", "rename", "delete", "checkout", "checkout_as_new", "new_tag") - .forEach { assertTrue(it in GitCommands.BRANCH_ACTIONS && it in LogOpsTools.BRANCH_OP.description, it) } + .forEach { assertTrue(it in GitWrites.BRANCH_ACTIONS && it in LogOpsTools.BRANCH_OP.description, it) } } @Test From 3fff4e4c6cdd32c72d2984f6c5515a04ae9ef239 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:52:07 +0200 Subject: [PATCH 102/155] refactor(github): reach the GitHub plugin through the optional GitHub content module --- .../mcp/tools/vcs}/GitHubGateway.kt | 39 ++++++++--------- .../resources/dev.lain.claudejb.github.xml | 11 +++++ .../controller/github/GitHubAvailability.kt | 2 +- .../controller/mcp/tools/vcs/ForgeTools.kt | 7 ++-- .../controller/mcp/tools/vcs/GitHubAccess.kt | 42 +++++++++++++++++++ .../mcp/tools/vcs/PullRequestOpsTools.kt | 9 ++-- .../controller/mcp/tools/vcs/ReleaseTools.kt | 5 +-- .../github/GitHubGatewayContractTest.kt | 2 +- 8 files changed, 81 insertions(+), 36 deletions(-) rename {src/main/kotlin/dev/lain/claudejb/controller/github => github/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs}/GitHubGateway.kt (81%) create mode 100644 github/src/main/resources/dev.lain.claudejb.github.xml create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitHubAccess.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/github/GitHubGateway.kt b/github/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitHubGateway.kt similarity index 81% rename from src/main/kotlin/dev/lain/claudejb/controller/github/GitHubGateway.kt rename to github/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitHubGateway.kt index 3b5106a1..b62af66f 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/github/GitHubGateway.kt +++ b/github/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitHubGateway.kt @@ -1,8 +1,15 @@ -package dev.lain.claudejb.controller.github +package dev.lain.claudejb.controller.mcp.tools.vcs import com.intellij.collaboration.api.data.GraphQLRequestPagination import com.intellij.openapi.components.service import com.intellij.openapi.project.Project +import dev.lain.claudejb.controller.github.GitHubAvailability +import dev.lain.claudejb.controller.mcp.tools.vcs.GitHubAccess.Branches +import dev.lain.claudejb.controller.mcp.tools.vcs.GitHubAccess.Check +import dev.lain.claudejb.controller.mcp.tools.vcs.GitHubAccess.Head +import dev.lain.claudejb.controller.mcp.tools.vcs.GitHubAccess.Mergeability +import dev.lain.claudejb.controller.mcp.tools.vcs.GitHubAccess.Repository +import dev.lain.claudejb.controller.mcp.tools.vcs.GitHubAccess.Request import dev.lain.claudejb.model.mcp.ToolException import org.jetbrains.plugins.github.api.GHGQLRequests import org.jetbrains.plugins.github.api.GHRepositoryCoordinates @@ -16,32 +23,20 @@ import org.jetbrains.plugins.github.authentication.accounts.GHAccountManager import org.jetbrains.plugins.github.util.GHHostedRepositoriesManager import java.io.IOException -internal class GitHubGateway(private val project: Project) { - - class Repository(val server: String, val owner: String, val name: String) - - class Head(val number: Long, val title: String, val state: String, val draft: Boolean, val author: String, val url: String) - - class Request(val head: Head, val updatedAt: String, val branches: Branches? = null) - - class Branches(val base: String, val head: String, val body: String, val reviewDecision: String, val headSha: String = "") - - class Check(val name: String, val state: String, val required: Boolean, val url: String) - - class Mergeability(val mergeable: String, val mergeState: String, val canMerge: Boolean, val headSha: String, val checks: List) +internal class GitHubGateway(private val project: Project) : GitHubAccess { private fun requireGitHub() { GitHubAvailability.require() } - fun repository(): Repository { + override fun repository(): Repository { requireGitHub() val coordinates = mapping().repository val path = coordinates.repositoryPath return Repository(coordinates.serverPath.toString(), path.owner, path.repository) } - suspend fun pullRequests(state: String, max: Int): List { + override suspend fun pullRequests(state: String, max: Int): List { requireGitHub() val (executor, coordinates) = client() val query = "repo:${slug(coordinates)} type:pr $state sort:updated-desc" @@ -51,7 +46,7 @@ internal class GitHubGateway(private val project: Project) { return response.nodes.map { Request(head(it), it.updatedAt.toInstant().toString()) } } - suspend fun pullRequest(number: Long): Request { + override suspend fun pullRequest(number: Long): Request { requireGitHub() val (executor, coordinates) = client() val pr = api { executor.executeSuspend(GHGQLRequests.PullRequest.findOne(coordinates, number)) } @@ -60,7 +55,7 @@ internal class GitHubGateway(private val project: Project) { return Request(head(pr), pr.updatedAt.toInstant().toString(), branches) } - suspend fun createPullRequest(base: String, head: String, title: String, body: String, draft: Boolean): Request { + override suspend fun createPullRequest(base: String, head: String, title: String, body: String, draft: Boolean): Request { requireGitHub() val (executor, coordinates) = client() val repository = api { executor.executeSuspend(GHGQLRequests.Repo.find(coordinates)) } @@ -70,13 +65,13 @@ internal class GitHubGateway(private val project: Project) { return Request(head(created), created.updatedAt.toInstant().toString()) } - suspend fun comment(number: Long, body: String): String { + override suspend fun comment(number: Long, body: String): String { requireGitHub() val (executor, coordinates) = client() return api { executor.executeSuspend(GithubApiRequests.Repos.Issues.Comments.create(coordinates, number, body)) }.htmlUrl } - suspend fun mergeability(number: Long): Mergeability { + override suspend fun mergeability(number: Long): Mergeability { requireGitHub() val (executor, coordinates) = client() val data = api { executor.executeSuspend(GHGQLRequests.PullRequest.mergeabilityData(coordinates, number)) } @@ -93,7 +88,7 @@ internal class GitHubGateway(private val project: Project) { return Mergeability(data.mergeable.name.lowercase(), state.name.lowercase(), state.canMerge(), head, statuses + runs) } - suspend fun merge(number: Long, subject: String, body: String, headSha: String) { + override suspend fun merge(number: Long, subject: String, body: String, headSha: String) { requireGitHub() val (executor, coordinates) = client() val path = coordinates.repositoryPath @@ -101,7 +96,7 @@ internal class GitHubGateway(private val project: Project) { api { executor.executeSuspend(request) } } - suspend fun getJson(path: String): Any? { + override suspend fun getJson(path: String): Any? { requireGitHub() val (executor, coordinates) = client() val url = GithubApiRequests.getUrl(coordinates.serverPath, "/repos/" + slug(coordinates) + path) diff --git a/github/src/main/resources/dev.lain.claudejb.github.xml b/github/src/main/resources/dev.lain.claudejb.github.xml new file mode 100644 index 00000000..ad94d26e --- /dev/null +++ b/github/src/main/resources/dev.lain.claudejb.github.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/src/main/kotlin/dev/lain/claudejb/controller/github/GitHubAvailability.kt b/src/main/kotlin/dev/lain/claudejb/controller/github/GitHubAvailability.kt index e0d32580..017a4518 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/github/GitHubAvailability.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/github/GitHubAvailability.kt @@ -3,7 +3,7 @@ package dev.lain.claudejb.controller.github import dev.lain.claudejb.model.mcp.ToolException import dev.lain.claudejb.util.InstalledPlugins -internal object GitHubAvailability { +object GitHubAvailability { const val PLUGIN_ID = "org.jetbrains.plugins.github" diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ForgeTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ForgeTools.kt index ccd57b2e..831d593a 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ForgeTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ForgeTools.kt @@ -6,7 +6,6 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindowId import dev.lain.claudejb.controller.git.ForgeViewNavigator import dev.lain.claudejb.controller.github.GitHubAvailability -import dev.lain.claudejb.controller.github.GitHubGateway import dev.lain.claudejb.controller.mcp.IdeActions import dev.lain.claudejb.controller.mcp.Reveal import dev.lain.claudejb.controller.mcp.TargetContext @@ -28,10 +27,10 @@ internal class ForgeTools( private val project: Project, private val actions: IdeActions, private val reveal: Reveal, - gateway: () -> GitHubGateway = { GitHubGateway(project) }, + gateway: () -> GitHubAccess = { GitHubAccess.of(project) }, ) { - private val github: GitHubGateway by lazy { GitHubAvailability.require().let { gateway() } } + private val github: GitHubAccess by lazy { GitHubAvailability.require().let { gateway() } } fun domain(): ToolDomain = ToolDomain( "forge", @@ -90,7 +89,7 @@ internal class ForgeTools( return "none" } - private fun row(request: GitHubGateway.Request): JsonObject = buildJsonObject { + private fun row(request: GitHubAccess.Request): JsonObject = buildJsonObject { put("number", request.head.number) put("title", request.head.title) put("state", request.head.state) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitHubAccess.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitHubAccess.kt new file mode 100644 index 00000000..e67f86db --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitHubAccess.kt @@ -0,0 +1,42 @@ +package dev.lain.claudejb.controller.mcp.tools.vcs + +import com.intellij.openapi.components.serviceOrNull +import com.intellij.openapi.project.Project +import dev.lain.claudejb.controller.github.GitHubAvailability +import dev.lain.claudejb.model.mcp.ToolException + +interface GitHubAccess { + + class Repository(val server: String, val owner: String, val name: String) + + class Head(val number: Long, val title: String, val state: String, val draft: Boolean, val author: String, val url: String) + + class Request(val head: Head, val updatedAt: String, val branches: Branches? = null) + + class Branches(val base: String, val head: String, val body: String, val reviewDecision: String, val headSha: String = "") + + class Check(val name: String, val state: String, val required: Boolean, val url: String) + + class Mergeability(val mergeable: String, val mergeState: String, val canMerge: Boolean, val headSha: String, val checks: List) + + fun repository(): Repository + + suspend fun pullRequests(state: String, max: Int): List + + suspend fun pullRequest(number: Long): Request + + suspend fun createPullRequest(base: String, head: String, title: String, body: String, draft: Boolean): Request + + suspend fun comment(number: Long, body: String): String + + suspend fun mergeability(number: Long): Mergeability + + suspend fun merge(number: Long, subject: String, body: String, headSha: String) + + suspend fun getJson(path: String): Any? + + companion object { + fun of(project: Project): GitHubAccess = + project.serviceOrNull() ?: throw ToolException(GitHubAvailability.MISSING) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/PullRequestOpsTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/PullRequestOpsTools.kt index 139251b2..e27f0a39 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/PullRequestOpsTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/PullRequestOpsTools.kt @@ -2,7 +2,6 @@ package dev.lain.claudejb.controller.mcp.tools.vcs import com.intellij.openapi.project.Project import dev.lain.claudejb.controller.github.GitHubAvailability -import dev.lain.claudejb.controller.github.GitHubGateway import dev.lain.claudejb.controller.mcp.Reveal import dev.lain.claudejb.controller.mcp.tools.run.Jobs import dev.lain.claudejb.model.mcp.Param @@ -21,11 +20,11 @@ import kotlinx.serialization.json.put internal class PullRequestOpsTools( private val project: Project, private val reveal: Reveal, - gateway: () -> GitHubGateway = { GitHubGateway(project) }, + gateway: () -> GitHubAccess = { GitHubAccess.of(project) }, private val pollMillis: Long = POLL_MILLIS, ) { - private val github: GitHubGateway by lazy { GitHubAvailability.require().let { gateway() } } + private val github: GitHubAccess by lazy { GitHubAvailability.require().let { gateway() } } fun domain(): ToolDomain = ToolDomain( "pull_request_ops", @@ -101,7 +100,7 @@ internal class PullRequestOpsTools( ) } - private fun settled(state: GitHubGateway.Mergeability): Boolean = + private fun settled(state: GitHubAccess.Mergeability): Boolean = state.mergeState != UNKNOWN && state.checks.none { it.state in PENDING } private fun number(args: ToolArgs): Long { @@ -110,7 +109,7 @@ internal class PullRequestOpsTools( return number } - private fun mergeabilityJson(number: Long, state: GitHubGateway.Mergeability): JsonObject = buildJsonObject { + private fun mergeabilityJson(number: Long, state: GitHubAccess.Mergeability): JsonObject = buildJsonObject { put("number", number) put("settled", settled(state)) put("mergeable", state.mergeable) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ReleaseTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ReleaseTools.kt index 4b058897..f3ad5357 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ReleaseTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ReleaseTools.kt @@ -2,7 +2,6 @@ package dev.lain.claudejb.controller.mcp.tools.vcs import com.intellij.openapi.project.Project import dev.lain.claudejb.controller.github.GitHubAvailability -import dev.lain.claudejb.controller.github.GitHubGateway import dev.lain.claudejb.controller.github.MarketplaceGateway import dev.lain.claudejb.model.mcp.Param import dev.lain.claudejb.model.mcp.Tool @@ -19,9 +18,9 @@ import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put -internal class ReleaseTools(private val project: Project, gateway: () -> GitHubGateway = { GitHubGateway(project) }) { +internal class ReleaseTools(private val project: Project, gateway: () -> GitHubAccess = { GitHubAccess.of(project) }) { - private val github: GitHubGateway by lazy { GitHubAvailability.require().let { gateway() } } + private val github: GitHubAccess by lazy { GitHubAvailability.require().let { gateway() } } fun domain(): ToolDomain = ToolDomain( "release", diff --git a/src/test/kotlin/dev/lain/claudejb/controller/github/GitHubGatewayContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/github/GitHubGatewayContractTest.kt index b4d676cf..2dd2bf00 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/github/GitHubGatewayContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/github/GitHubGatewayContractTest.kt @@ -53,7 +53,7 @@ class GitHubGatewayContractTest { const val GUARD = "requireGitHub" val GITHUB_TYPE = Regex("""\borg\.jetbrains\.plugins\.github\.[A-Za-z]""") - val ENTRY = Regex("""^ {4}(suspend )?fun \w+\(.*""") + val ENTRY = Regex("""^ {4}(override )?(suspend )?fun \w+\(.*""") const val PLUGIN = "org.jetbrains.plugins.github" val BUNDLED = Regex("""bundledPlugins?\(([^)]*)\)""") From 7c5dd0d77e01d8b5e21e144f48bb838d055be41c Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:52:16 +0200 Subject: [PATCH 103/155] refactor(mcp): serve UAST and IntelliLang injection from optional content modules --- .../mcp/tools/code/IntelliLangGateway.kt | 26 +++++++++++++++ .../dev.lain.claudejb.intellilang.xml | 11 +++++++ .../controller/mcp/tools/code/UastTools.kt | 8 ++--- .../main/resources/dev.lain.claudejb.java.xml | 12 +++++++ .../claudejb/controller/mcp/IdeToolCatalog.kt | 6 ++-- .../mcp/tools/code/JavaAvailability.kt | 10 ------ .../mcp/tools/code/LanguageInjection.kt | 12 +++++++ .../mcp/tools/code/LanguageTools.kt | 33 ++----------------- .../controller/mcp/tools/code/Locations.kt | 4 +-- .../controller/mcp/tools/code/UastDomain.kt | 8 +++++ .../mcp/tools/code/RoadmapToolSpecsTest.kt | 31 ++++++++++------- 11 files changed, 100 insertions(+), 61 deletions(-) create mode 100644 intellilang/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/IntelliLangGateway.kt create mode 100644 intellilang/src/main/resources/dev.lain.claudejb.intellilang.xml rename {src => java/src}/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/UastTools.kt (95%) create mode 100644 java/src/main/resources/dev.lain.claudejb.java.xml delete mode 100644 src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/JavaAvailability.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageInjection.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/UastDomain.kt diff --git a/intellilang/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/IntelliLangGateway.kt b/intellilang/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/IntelliLangGateway.kt new file mode 100644 index 00000000..0c25f3be --- /dev/null +++ b/intellilang/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/IntelliLangGateway.kt @@ -0,0 +1,26 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.openapi.command.writeCommandAction +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiLanguageInjectionHost +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.util.InstalledPlugins +import org.intellij.plugins.intelliLang.inject.InjectedLanguage +import org.intellij.plugins.intelliLang.inject.TemporaryPlacesRegistry + +internal class IntelliLangGateway(private val project: Project) : LanguageInjection { + + override suspend fun inject(host: PsiLanguageInjectionHost, languageId: String): Boolean { + if (!InstalledPlugins.isEnabled(PLUGIN_ID)) throw ToolException(LanguageInjection.MISSING) + val language = InjectedLanguage.create(languageId) ?: throw ToolException("IntelliLang knows no language with id $languageId") + return writeCommandAction(project, "Claude: inject $languageId") { + runCatching { TemporaryPlacesRegistry.getInstance(project).addHostWithUndo(host, language) } + .getOrElse { throw ToolException("IntelliLang refused the injection: ${it.message}", it) } + true + } + } + + private companion object { + const val PLUGIN_ID = "org.intellij.intelliLang" + } +} diff --git a/intellilang/src/main/resources/dev.lain.claudejb.intellilang.xml b/intellilang/src/main/resources/dev.lain.claudejb.intellilang.xml new file mode 100644 index 00000000..8f8e80ec --- /dev/null +++ b/intellilang/src/main/resources/dev.lain.claudejb.intellilang.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/UastTools.kt b/java/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/UastTools.kt similarity index 95% rename from src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/UastTools.kt rename to java/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/UastTools.kt index 45c33895..a227feed 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/UastTools.kt +++ b/java/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/UastTools.kt @@ -19,9 +19,9 @@ import org.jetbrains.uast.UFile import org.jetbrains.uast.UastFacade import org.jetbrains.uast.visitor.UastVisitor -internal class UastTools(private val project: Project) { +internal class UastTools(private val project: Project) : UastDomain { - fun domain(): ToolDomain = ToolDomain( + override fun domain(): ToolDomain = ToolDomain( "uast", "The unified AST (UAST) the IDE shares across Java, Kotlin, Scala and Groovy: the tree of a file to a depth, and " + "the element at a position with its parents", @@ -31,7 +31,7 @@ internal class UastTools(private val project: Project) { private suspend fun tree(args: ToolArgs): ToolResult { val path = args.string("path") val depth = args.int("depth", DEFAULT_DEPTH) - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val rows = smartReadAction(project) { val psiFile = Locations.psiFile(project, path) val file = UastFacade.convertElementWithParent(psiFile, UFile::class.java) as? UFile @@ -106,7 +106,7 @@ internal class UastTools(private val project: Project) { listOf( Param("path", "File path, absolute or relative to the project root"), Param("depth", "Levels below the file (default $DEFAULT_DEPTH)", type = "integer", required = false), - Param("max", "Maximum nodes (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("nodes", DEFAULT_MAX), ), ) diff --git a/java/src/main/resources/dev.lain.claudejb.java.xml b/java/src/main/resources/dev.lain.claudejb.java.xml new file mode 100644 index 00000000..24a25270 --- /dev/null +++ b/java/src/main/resources/dev.lain.claudejb.java.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdeToolCatalog.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdeToolCatalog.kt index da65dff3..3b25c26f 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdeToolCatalog.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdeToolCatalog.kt @@ -1,5 +1,6 @@ package dev.lain.claudejb.controller.mcp +import com.intellij.openapi.components.serviceOrNull import com.intellij.openapi.project.Project import com.intellij.openapi.util.Key import dev.lain.claudejb.controller.git.GitAvailability @@ -15,7 +16,6 @@ import dev.lain.claudejb.controller.mcp.tools.code.FormatTools import dev.lain.claudejb.controller.mcp.tools.code.HierarchyTools import dev.lain.claudejb.controller.mcp.tools.code.IndexTools import dev.lain.claudejb.controller.mcp.tools.code.InspectTools -import dev.lain.claudejb.controller.mcp.tools.code.JavaAvailability import dev.lain.claudejb.controller.mcp.tools.code.LanguageTools import dev.lain.claudejb.controller.mcp.tools.code.MarkupTools import dev.lain.claudejb.controller.mcp.tools.code.NavigateTools @@ -28,7 +28,7 @@ import dev.lain.claudejb.controller.mcp.tools.code.RefactorOpsTools import dev.lain.claudejb.controller.mcp.tools.code.RefactorTools import dev.lain.claudejb.controller.mcp.tools.code.SearchTools import dev.lain.claudejb.controller.mcp.tools.code.TemplateTools -import dev.lain.claudejb.controller.mcp.tools.code.UastTools +import dev.lain.claudejb.controller.mcp.tools.code.UastDomain import dev.lain.claudejb.controller.mcp.tools.code.ViewTools import dev.lain.claudejb.controller.mcp.tools.code.WorkspaceTools import dev.lain.claudejb.controller.mcp.tools.ops.ActionTools @@ -98,7 +98,7 @@ internal object IdeToolCatalog { { k -> BookmarkTools(k.p, k.reveal).domain() }, { k -> PsiTools(k.p, k.reveal).domain() }, { k -> IndexTools(k.p).domain() }, - { k -> if (JavaAvailability.isEnabled()) UastTools(k.p).domain() else null }, + { k -> k.p.serviceOrNull()?.domain() }, { k -> WorkspaceTools(k.p).domain() }, { k -> MarkupTools(k.p, TargetContext(k.p), k.reveal).domain() }, { k -> PresenceTools(k.p, k.reveal).domain() }, diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/JavaAvailability.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/JavaAvailability.kt deleted file mode 100644 index fb924812..00000000 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/JavaAvailability.kt +++ /dev/null @@ -1,10 +0,0 @@ -package dev.lain.claudejb.controller.mcp.tools.code - -import dev.lain.claudejb.util.InstalledPlugins - -internal object JavaAvailability { - - const val PLUGIN_ID = "com.intellij.java" - - fun isEnabled(): Boolean = InstalledPlugins.isEnabled(PLUGIN_ID) -} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageInjection.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageInjection.kt new file mode 100644 index 00000000..d074532f --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageInjection.kt @@ -0,0 +1,12 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.psi.PsiLanguageInjectionHost + +interface LanguageInjection { + + suspend fun inject(host: PsiLanguageInjectionHost, languageId: String): Boolean + + companion object { + const val MISSING = "the IntelliLang plugin (org.intellij.intelliLang) is not installed or is disabled, so nothing injects languages" + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageTools.kt index f01387a8..02f7774f 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageTools.kt @@ -3,7 +3,7 @@ package dev.lain.claudejb.controller.mcp.tools.code import com.intellij.lang.Language import com.intellij.lang.injection.InjectedLanguageManager import com.intellij.openapi.application.smartReadAction -import com.intellij.openapi.command.writeCommandAction +import com.intellij.openapi.components.serviceOrNull import com.intellij.openapi.project.Project import com.intellij.psi.PsiLanguageInjectionHost import com.intellij.psi.util.PsiTreeUtil @@ -16,7 +16,6 @@ import dev.lain.claudejb.model.mcp.ToolDomain import dev.lain.claudejb.model.mcp.ToolException import dev.lain.claudejb.model.mcp.ToolResult import dev.lain.claudejb.model.mcp.ToolSpec -import dev.lain.claudejb.util.InstalledPlugins import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject @@ -69,7 +68,8 @@ internal class LanguageTools(private val project: Project, private val actions: PsiTreeUtil.getParentOfType(position.psiFile.findElementAt(position.offset), PsiLanguageInjectionHost::class.java, false) ?: throw ToolException("nothing at that position can host an injection (a string literal can)") } - val injected = IntelliLangGateway(project).inject(host, language.id) + val injection = project.serviceOrNull() ?: throw ToolException(LanguageInjection.MISSING) + val injected = injection.inject(host, language.id) return ToolResult.toon( buildJsonObject { put("language", language.id) @@ -135,30 +135,3 @@ internal class LanguageTools(private val project: Project, private val actions: ) } } - -internal class IntelliLangGateway(private val project: Project) { - - suspend fun inject(host: PsiLanguageInjectionHost, languageId: String): Boolean { - if (!InstalledPlugins.isEnabled(PLUGIN_ID)) { - throw ToolException("the IntelliLang plugin ($PLUGIN_ID) is not installed or is disabled, so nothing injects languages") - } - val loader = javaClass.classLoader - return writeCommandAction(project, "Claude: inject $languageId") { - runCatching { - val registryClass = Class.forName(REGISTRY, true, loader) - val registry = registryClass.getMethod("getInstance", Project::class.java).invoke(null, project) - val injectedClass = Class.forName(INJECTED_LANGUAGE, true, loader) - val language = injectedClass.getMethod("create", String::class.java).invoke(null, languageId) - val add = registryClass.getMethod("addHostWithUndo", PsiLanguageInjectionHost::class.java, injectedClass) - add.invoke(registry, host, language) - true - }.getOrElse { throw ToolException("IntelliLang refused the injection: ${it.message}", it) } - } - } - - private companion object { - const val PLUGIN_ID = "org.intellij.intelliLang" - const val REGISTRY = "org.intellij.plugins.intelliLang.inject.TemporaryPlacesRegistry" - const val INJECTED_LANGUAGE = "org.intellij.plugins.intelliLang.inject.InjectedLanguage" - } -} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/Locations.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/Locations.kt index 9d21f210..72e6f007 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/Locations.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/Locations.kt @@ -20,9 +20,9 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import java.nio.file.Path -internal class Located(val psiFile: PsiFile, val document: Document, val offset: Int) +class Located(val psiFile: PsiFile, val document: Document, val offset: Int) -internal object Locations { +object Locations { val POSITION = listOf( Param("path", "File path, absolute or relative to the project root"), diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/UastDomain.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/UastDomain.kt new file mode 100644 index 00000000..9e25ac24 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/UastDomain.kt @@ -0,0 +1,8 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import dev.lain.claudejb.model.mcp.ToolDomain + +interface UastDomain { + + fun domain(): ToolDomain +} diff --git a/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RoadmapToolSpecsTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RoadmapToolSpecsTest.kt index ed4a0274..2af3f837 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RoadmapToolSpecsTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RoadmapToolSpecsTest.kt @@ -1,10 +1,11 @@ package dev.lain.claudejb.controller.mcp.tools.code +import dev.lain.claudejb.PluginModules +import dev.lain.claudejb.SourceLayout import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -import java.io.File class RoadmapToolSpecsTest { @@ -39,17 +40,23 @@ class RoadmapToolSpecsTest { } @Test - fun `UAST rides on the Java plugin, declared optional with its config file, and only UastTools names it`() { - val descriptor = File("src/main/resources/META-INF/plugin.xml").readText() - val match = Regex("""]*>com\.intellij\.modules\.java""").find(descriptor) - assertTrue(match != null && "optional=\"true\"" in match.value, "the Java module dependency must be optional") - val configFile = Regex("""config-file="([^"]+)"""").find(match!!.value)?.groupValues?.get(1) - assertTrue(configFile != null && File("src/main/resources/META-INF/$configFile").isFile, "config-file $configFile is missing") - assertTrue(Regex("""bundledPlugin\(\s*"com\.intellij\.java"\s*\)""").containsMatchIn(File("build.gradle.kts").readText())) - val users = File("src/main/kotlin").walkTopDown() - .filter { it.isFile && it.extension == "kt" && "org.jetbrains.uast" in it.readText() } - .map { it.name } - .toList() + fun `UAST rides on the Java plugin, declared by an optional content module, and only UastTools names it`() { + val declaring = PluginModules.declaring(JAVA_PLUGIN) + assertTrue(declaring.isNotEmpty(), "No descriptor declares ") + declaring.forEach { descriptor -> + val entry = PluginModules.content()[PluginModules.moduleName(descriptor)] + assertTrue(entry != null && PluginModules.isOptional(entry), "the Java dependency must be optional: ${descriptor.path} is $entry") + } + assertTrue( + SourceLayout.buildScripts().any { script -> + Regex("""bundledPlugins?\(([^)]*)\)""").findAll(script.readText()).any { "\"$JAVA_PLUGIN\"" in it.groupValues[1] } + }, + ) + val users = SourceLayout.kotlinFiles().filter { "org.jetbrains.uast" in it.readText() }.map { it.name } assertEquals(listOf("UastTools.kt"), users) } + + private companion object { + const val JAVA_PLUGIN = "com.intellij.java" + } } From 8699e4641922cc16a34d0477717819f64bf02716 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:52:22 +0200 Subject: [PATCH 104/155] fix(terminal): open the sign-in and install terminal on the client through a frontend push --- .../resources/dev.lain.claudejb.backend.xml | 2 ++ .../lain/claudejb/frontend/rpc/ChatClient.kt | 2 +- .../lain/claudejb/frontend/window/ChatView.kt | 12 ++++++++- .../frontend/window/TerminalOpener.kt | 8 ++++++ .../kotlin/dev/lain/claudejb/rpc/ChatDtos.kt | 3 +++ .../dev/lain/claudejb/rpc/FrontendChannel.kt | 1 + .../controller/process/TerminalLauncher.kt | 25 +++++------------ .../claudejb/view/window/ChatTerminalHost.kt | 17 ++++++++++++ .../process/TerminalLauncherTest.kt | 4 +-- .../frontend/terminal/ClientTerminal.kt | 27 +++++++++++++++++++ .../resources/dev.lain.claudejb.terminal.xml | 12 +++++++++ 11 files changed, 91 insertions(+), 22 deletions(-) create mode 100644 frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/TerminalOpener.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/view/window/ChatTerminalHost.kt create mode 100644 terminal/src/main/kotlin/dev/lain/claudejb/frontend/terminal/ClientTerminal.kt create mode 100644 terminal/src/main/resources/dev.lain.claudejb.terminal.xml diff --git a/backend/src/main/resources/dev.lain.claudejb.backend.xml b/backend/src/main/resources/dev.lain.claudejb.backend.xml index fac583d9..882be9bb 100644 --- a/backend/src/main/resources/dev.lain.claudejb.backend.xml +++ b/backend/src/main/resources/dev.lain.claudejb.backend.xml @@ -10,6 +10,8 @@ + Unit, ) : ChatCard { @@ -72,11 +75,18 @@ internal class ChatView( FrontendChannel.BROWSE -> PageMessage.string(push.json)?.takeIf(PageMessage::isWebLink)?.let { BrowserUtil.browse(it) } FrontendChannel.VIBE -> ChatTheme.setVibeMode(push.json.trim() == "true") FrontendChannel.FOCUS -> edtNow(onFocusRequest) + FrontendChannel.TERMINAL -> openTerminal(Json.decodeFromString(TerminalLaunch.serializer(), push.json)) FrontendChannel.ACTION -> PageMessage.string(push.json)?.let { id -> edtNow { IdeActionRunner.run(component, id) } } else -> log.warn("Claude Code host sent a frontend push this client does not know: ${push.method}") } } + private fun openTerminal(launch: TerminalLaunch) { + val terminal = client.project.serviceOrNull() + ?: return log.warn("Claude Code could not open '${launch.tabName}': this client has no Terminal plugin") + edtNow { terminal.open(launch) } + } + private fun paste(type: String) { ApplicationManager.getApplication().executeOnPooledThread { val image = ClientClipboard.pngBase64() diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/TerminalOpener.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/TerminalOpener.kt new file mode 100644 index 00000000..ce990501 --- /dev/null +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/TerminalOpener.kt @@ -0,0 +1,8 @@ +package dev.lain.claudejb.frontend.window + +import dev.lain.claudejb.rpc.TerminalLaunch + +interface TerminalOpener { + + fun open(launch: TerminalLaunch) +} diff --git a/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatDtos.kt b/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatDtos.kt index 0dbbfd92..02c908b1 100644 --- a/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatDtos.kt +++ b/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatDtos.kt @@ -11,6 +11,9 @@ data class ChatRef(val id: ChatId, val title: String) @Serializable data class PagePush(val method: String, val json: String) +@Serializable +data class TerminalLaunch(val workingDirectory: String?, val tabName: String, val command: String) + @Serializable sealed interface ChatEvent { @Serializable diff --git a/shared/src/main/kotlin/dev/lain/claudejb/rpc/FrontendChannel.kt b/shared/src/main/kotlin/dev/lain/claudejb/rpc/FrontendChannel.kt index d5c87ed9..2ced668a 100644 --- a/shared/src/main/kotlin/dev/lain/claudejb/rpc/FrontendChannel.kt +++ b/shared/src/main/kotlin/dev/lain/claudejb/rpc/FrontendChannel.kt @@ -6,6 +6,7 @@ object FrontendChannel { const val BROWSE = "frontend.browse" const val VIBE = "frontend.vibe" const val FOCUS = "frontend.focus" + const val TERMINAL = "frontend.terminal" const val ACTION = "frontend.action" const val ATTACH_IMAGE = "attachImageData" diff --git a/src/main/kotlin/dev/lain/claudejb/controller/process/TerminalLauncher.kt b/src/main/kotlin/dev/lain/claudejb/controller/process/TerminalLauncher.kt index e2e19649..25e8e0fe 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/process/TerminalLauncher.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/process/TerminalLauncher.kt @@ -1,14 +1,16 @@ package dev.lain.claudejb.controller.process +import com.intellij.openapi.components.serviceOrNull import com.intellij.openapi.project.Project import com.intellij.openapi.util.SystemInfo -import com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTabsManager import dev.lain.claudejb.util.InstalledPlugins -import dev.lain.claudejb.util.thisLogger object TerminalLauncher { - private val log = thisLogger() + fun interface Host { + fun open(workingDirectory: String?, tabName: String, command: String): Boolean + } + private const val TERMINAL_PLUGIN_ID = "org.jetbrains.plugins.terminal" fun isAvailable(): Boolean = InstalledPlugins.isEnabled(TERMINAL_PLUGIN_ID) @@ -31,20 +33,7 @@ object TerminalLauncher { fun openAndRunCommand(project: Project, argv: List, tabName: String): Boolean { if (!isAvailable()) return false if (argv.isEmpty()) return false - return runCatching { openWithShellCommand(project, argv, tabName) } - .onFailure { log.warn("Failed to open IDE terminal for: $tabName", it) } - .getOrDefault(false) - } - - private fun openWithShellCommand(project: Project, argv: List, tabName: String): Boolean { - val tab = TerminalToolWindowTabsManager.getInstance(project) - .createTabBuilder() - .workingDirectory(project.basePath) - .tabName(tabName) - .requestFocus(true) - .deferSessionStartUntilUiShown(false) - .createTab() - tab.view.createSendTextBuilder().shouldExecute().send(commandLine(argv.first(), argv.drop(1))) - return true + val host = project.serviceOrNull() ?: return false + return host.open(project.basePath, tabName, commandLine(argv.first(), argv.drop(1))) } } diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/ChatTerminalHost.kt b/src/main/kotlin/dev/lain/claudejb/view/window/ChatTerminalHost.kt new file mode 100644 index 00000000..56766769 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/view/window/ChatTerminalHost.kt @@ -0,0 +1,17 @@ +package dev.lain.claudejb.view.window + +import com.intellij.openapi.project.Project +import dev.lain.claudejb.controller.process.TerminalLauncher +import dev.lain.claudejb.rpc.FrontendChannel +import dev.lain.claudejb.rpc.TerminalLaunch +import kotlinx.serialization.json.Json + +internal class ChatTerminalHost(private val project: Project) : TerminalLauncher.Host { + + override fun open(workingDirectory: String?, tabName: String, command: String): Boolean { + val chat = ChatRegistry.getInstance(project).selected() ?: return false + val launch = TerminalLaunch(workingDirectory, tabName, command) + chat.exec(FrontendChannel.TERMINAL, Json.encodeToString(TerminalLaunch.serializer(), launch)) + return true + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/controller/process/TerminalLauncherTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/process/TerminalLauncherTest.kt index fd360833..e53e39c1 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/process/TerminalLauncherTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/process/TerminalLauncherTest.kt @@ -77,8 +77,8 @@ class TerminalApiContractTest { @Test fun `the launcher reaches the terminal without reflection`() { val source = sequenceOf( - java.io.File("src/main/kotlin/dev/lain/claudejb/controller/process/TerminalLauncher.kt"), - java.io.File("../src/main/kotlin/dev/lain/claudejb/controller/process/TerminalLauncher.kt"), + java.io.File("terminal/src/main/kotlin/dev/lain/claudejb/frontend/terminal/ClientTerminal.kt"), + java.io.File("../terminal/src/main/kotlin/dev/lain/claudejb/frontend/terminal/ClientTerminal.kt"), ).first { it.isFile }.readText() assertEquals(0, Regex("""\bgetMethod\(""").findAll(source).count()) { diff --git a/terminal/src/main/kotlin/dev/lain/claudejb/frontend/terminal/ClientTerminal.kt b/terminal/src/main/kotlin/dev/lain/claudejb/frontend/terminal/ClientTerminal.kt new file mode 100644 index 00000000..04b7da5c --- /dev/null +++ b/terminal/src/main/kotlin/dev/lain/claudejb/frontend/terminal/ClientTerminal.kt @@ -0,0 +1,27 @@ +package dev.lain.claudejb.frontend.terminal + +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTabsManager +import dev.lain.claudejb.frontend.window.TerminalOpener +import dev.lain.claudejb.rpc.TerminalLaunch + +internal class ClientTerminal(private val project: Project) : TerminalOpener { + + override fun open(launch: TerminalLaunch) { + runCatching { + val tab = TerminalToolWindowTabsManager.getInstance(project) + .createTabBuilder() + .workingDirectory(launch.workingDirectory) + .tabName(launch.tabName) + .requestFocus(true) + .deferSessionStartUntilUiShown(false) + .createTab() + tab.view.createSendTextBuilder().shouldExecute().send(launch.command) + }.onFailure { log.warn("Failed to open IDE terminal for: ${launch.tabName}", it) } + } + + private companion object { + private val log = logger() + } +} diff --git a/terminal/src/main/resources/dev.lain.claudejb.terminal.xml b/terminal/src/main/resources/dev.lain.claudejb.terminal.xml new file mode 100644 index 00000000..d40a2618 --- /dev/null +++ b/terminal/src/main/resources/dev.lain.claudejb.terminal.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + From 992d03877b8c0abb1a8c1025aaabb141313d95fa Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:53:32 +0200 Subject: [PATCH 105/155] perf(mcp): put a ceiling on the max of the remaining tools --- .../controller/mcp/tools/code/AnalyzeTools.kt | 4 ++-- .../controller/mcp/tools/code/HierarchyTools.kt | 4 ++-- .../claudejb/controller/mcp/tools/code/IndexTools.kt | 12 ++++++------ .../controller/mcp/tools/code/InspectTools.kt | 8 ++++---- .../controller/mcp/tools/code/LanguageTools.kt | 4 ++-- .../controller/mcp/tools/code/NavigateTools.kt | 12 ++++++------ .../claudejb/controller/mcp/tools/code/PsiTools.kt | 4 ++-- .../controller/mcp/tools/code/RecentTools.kt | 4 ++-- .../controller/mcp/tools/code/RefactorTools.kt | 8 ++++---- .../controller/mcp/tools/code/TemplateTools.kt | 8 ++++---- .../controller/mcp/tools/code/WorkspaceTools.kt | 4 ++-- .../claudejb/controller/mcp/tools/ops/ActionTools.kt | 4 ++-- .../claudejb/controller/mcp/tools/ops/HttpTools.kt | 4 ++-- .../claudejb/controller/mcp/tools/ops/IdeTools.kt | 8 ++++---- .../controller/mcp/tools/ops/ProjectTools.kt | 8 ++++---- .../claudejb/controller/mcp/tools/ops/RemoteTools.kt | 6 +++--- .../controller/mcp/tools/ops/ServiceViewTools.kt | 4 ++-- .../claudejb/controller/mcp/tools/ops/SshTools.kt | 4 ++-- .../controller/mcp/tools/run/BreakpointTools.kt | 4 ++-- .../claudejb/controller/mcp/tools/run/BuildTools.kt | 4 ++-- .../claudejb/controller/mcp/tools/run/DebugSpecs.kt | 4 ++-- .../claudejb/controller/mcp/tools/run/DebugTools.kt | 4 ++-- .../claudejb/controller/mcp/tools/run/RunTools.kt | 8 ++++---- .../claudejb/controller/mcp/tools/run/TestTools.kt | 8 ++++---- .../claudejb/controller/mcp/tools/vcs/ForgeTools.kt | 4 ++-- .../controller/mcp/tools/vcs/HistoryTools.kt | 4 ++-- 26 files changed, 75 insertions(+), 75 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/AnalyzeTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/AnalyzeTools.kt index aa7980b0..55da5eff 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/AnalyzeTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/AnalyzeTools.kt @@ -76,7 +76,7 @@ internal class AnalyzeTools(private val project: Project, private val actions: I private suspend fun dependencies(args: ToolArgs): ToolResult { val direction = args.optionalString("direction") ?: "forward" - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) if (direction == "backward") { actions.dispatch(BACKWARD_DEPENDENCIES, TargetContext.target(args)) return ToolResult.toon( @@ -197,7 +197,7 @@ internal class AnalyzeTools(private val project: Project, private val actions: I type = "integer", required = false, ), - Param("max", "Maximum files to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("files", DEFAULT_MAX), ), ) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/HierarchyTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/HierarchyTools.kt index 7a523694..ed5afdda 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/HierarchyTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/HierarchyTools.kt @@ -36,7 +36,7 @@ internal class HierarchyTools(private val project: Project) { val kind = args.string("kind") if (kind != CALLERS && kind != CALLEES) throw ToolException("kind must be $CALLERS or $CALLEES") val depth = args.int("depth", 1).coerceIn(1, MAX_DEPTH) - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val (root, rows) = try { readAction { val target = Locations.declarationAt(project, args) @@ -132,7 +132,7 @@ internal class HierarchyTools(private val project: Project) { Locations.POSITION + listOf( Param("kind", "$CALLERS or $CALLEES"), Param("depth", "Levels to follow, 1 to $MAX_DEPTH (default 1)", type = "integer", required = false), - Param("max", "Maximum rows to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("rows", DEFAULT_MAX), ), ) } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/IndexTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/IndexTools.kt index ec778795..b93c6280 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/IndexTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/IndexTools.kt @@ -36,7 +36,7 @@ internal class IndexTools(private val project: Project) { private suspend fun keys(args: ToolArgs): ToolResult { val name = args.string("index") - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val keys = smartReadAction(project) { FileBasedIndex.getInstance().getAllKeys(index(name), project).map { it.toString() }.sorted() } return ToolResult.toon( buildJsonObject { @@ -51,7 +51,7 @@ internal class IndexTools(private val project: Project) { private suspend fun query(args: ToolArgs): ToolResult { val name = args.string("index") val key = args.string("key") - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val files = smartReadAction(project) { val id = index(name) val scope = GlobalSearchScope.projectScope(project) @@ -73,7 +73,7 @@ internal class IndexTools(private val project: Project) { private suspend fun stubQuery(args: ToolArgs): ToolResult { val name = args.string("index") val key = args.optionalString("key") - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val indexKey = stubIndex(name) val rows = smartReadAction(project) { val stubs = StubIndex.getInstance() @@ -121,7 +121,7 @@ internal class IndexTools(private val project: Project) { "plugin's), as the IDE holds them for this project.", listOf( Param("index", "The index id as registered by its ID.create name"), - Param("max", "Maximum keys (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("keys", DEFAULT_MAX), ), ) @@ -131,7 +131,7 @@ internal class IndexTools(private val project: Project) { listOf( Param("index", "The index id"), Param("key", "The key, as index_keys prints it"), - Param("max", "Maximum files (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("files", DEFAULT_MAX), ), ) @@ -142,7 +142,7 @@ internal class IndexTools(private val project: Project) { listOf( Param("index", "The stub index key name"), Param("key", "The key to look up (default: list the keys)", required = false), - Param("max", "Maximum rows (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("rows", DEFAULT_MAX), ), ) } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/InspectTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/InspectTools.kt index 61ae7e12..93ab048e 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/InspectTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/InspectTools.kt @@ -37,7 +37,7 @@ internal class InspectTools(private val project: Project) { private suspend fun inspections(args: ToolArgs): ToolResult { val query = args.optionalString("query").orEmpty() - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val rows = readAction { val profile = InspectionProfileManager.getInstance(project).currentProfile profile.getInspectionTools(null) @@ -65,7 +65,7 @@ internal class InspectTools(private val project: Project) { val path = args.string("path") val only = args.optionalString("inspection") val minimum = Severities.minimum(args) - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val psiFile = readAction { Locations.psiFile(project, path) } val tools = readAction { applicable(psiFile, only, minimum) } if (tools.isEmpty()) throw ToolException(missing(only, path)) @@ -133,7 +133,7 @@ internal class InspectTools(private val project: Project) { "filtered by a query.", listOf( Param("query", "Part of an inspection id or name, case-insensitive (default: all)", required = false), - Param("max", "Maximum inspections to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("inspections", DEFAULT_MAX), ), ) @@ -146,7 +146,7 @@ internal class InspectTools(private val project: Project) { Batch.paths("one result each"), Param("inspection", "Run only this inspection id (default: every enabled inspection)", required = false), Severities.PARAM, - Param("max", "Maximum findings to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("findings", DEFAULT_MAX), ), ) } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageTools.kt index 02f7774f..103234b5 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageTools.kt @@ -32,7 +32,7 @@ internal class LanguageTools(private val project: Project, private val actions: private suspend fun injections(args: ToolArgs): ToolResult { val path = args.string("path") - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val rows = smartReadAction(project) { val psiFile = Locations.psiFile(project, path) val document = psiFile.viewProvider.document @@ -106,7 +106,7 @@ internal class LanguageTools(private val project: Project, private val actions: "injected language and fragment text.", listOf( Param("path", "File path, absolute or relative to the project root"), - Param("max", "Maximum fragments (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("fragments", DEFAULT_MAX), ), ) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/NavigateTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/NavigateTools.kt index ca3d4ec7..4cd38a9b 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/NavigateTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/NavigateTools.kt @@ -40,7 +40,7 @@ internal class NavigateTools(private val project: Project) { private suspend fun findOne(args: ToolArgs): JsonObject { val query = args.string("query") - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val libraries = args.boolean("libraries", false) val (outcome, rows) = indexed { val scope = if (libraries) GlobalSearchScope.allScope(project) else GlobalSearchScope.projectScope(project) @@ -70,7 +70,7 @@ internal class NavigateTools(private val project: Project) { } private suspend fun referencesOne(args: ToolArgs): JsonObject { - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val rows = indexed { val rows = ArrayList() ReferencesSearch.search(resolved(args)).forEach { reference -> @@ -83,7 +83,7 @@ internal class NavigateTools(private val project: Project) { } private suspend fun implementationsOne(args: ToolArgs): JsonObject { - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val rows = indexed { val rows = ArrayList() DefinitionsScopedSearch.search(resolved(args)).forEach { element -> @@ -148,7 +148,7 @@ internal class NavigateTools(private val project: Project) { Param("query", "Part of the symbol name, case-insensitive", required = false), Batch.param(Batch.QUERIES, "Several queries at once, one result per query; the other arguments apply to each"), Param("libraries", "true to include library symbols (default false)", type = "boolean", required = false), - Param("max", "Maximum symbols to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("symbols", DEFAULT_MAX), ), ) @@ -163,14 +163,14 @@ internal class NavigateTools(private val project: Project) { "references", "Lists the places that reference the symbol at a position; several positions at once with positions.", Locations.OPTIONAL_POSITION + Batch.positions("the references of each") + - Param("max", "Maximum references to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("references", DEFAULT_MAX), ) val IMPLEMENTATIONS = ToolSpec( "implementations", "Lists the implementations or overrides of the symbol at a position; several positions at once with positions.", Locations.OPTIONAL_POSITION + Batch.positions("the implementations of each") + - Param("max", "Maximum results to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("results", DEFAULT_MAX), ) } } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PsiTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PsiTools.kt index 1c21df89..433eeab6 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PsiTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PsiTools.kt @@ -35,7 +35,7 @@ internal class PsiTools(private val project: Project, private val reveal: Reveal private suspend fun tree(args: ToolArgs): ToolResult { val path = args.string("path") val depth = args.int("depth", DEFAULT_DEPTH) - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val rows = smartReadAction(project) { val psiFile = Locations.psiFile(project, path) val root = args.optionalString("line")?.let { elementAt(psiFile, args) } ?: psiFile @@ -178,7 +178,7 @@ internal class PsiTools(private val project: Project, private val reveal: Reveal Param("line", "1-based line whose element is the root (default: the whole file)", type = "integer", required = false), COLUMN, Param("depth", "Levels below the root (default $DEFAULT_DEPTH)", type = "integer", required = false), - Param("max", "Maximum elements (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("elements", DEFAULT_MAX), ), ) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RecentTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RecentTools.kt index a5f62dd3..7b3d0474 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RecentTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RecentTools.kt @@ -34,7 +34,7 @@ internal class RecentTools(private val project: Project, private val actions: Id private suspend fun recent(args: ToolArgs): ToolResult { val kind = args.optionalString("kind") ?: "files" - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val files = withContext(Dispatchers.EDT) { when (kind) { "files" -> EditorHistoryManager.getInstance(project).fileList.asReversed() @@ -123,7 +123,7 @@ internal class RecentTools(private val project: Project, private val actions: Id "recently in this session (kind=changed_files), newest first.", listOf( Param("kind", "files (default) or changed_files", required = false), - Param("max", "Maximum files to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("files", DEFAULT_MAX), ), ) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RefactorTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RefactorTools.kt index e8c349f0..484f49d9 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RefactorTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RefactorTools.kt @@ -44,7 +44,7 @@ internal class RefactorTools(private val project: Project, private val refactori private suspend fun rename(args: ToolArgs): ToolResult { val newName = args.string("new_name") - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val (element, from, refactoring) = readAction { val element = refactorings.target(args) val from = refactorings.name(element) @@ -122,7 +122,7 @@ internal class RefactorTools(private val project: Project, private val refactori } private suspend fun safeDelete(args: ToolArgs): ToolResult { - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val (target, refactoring) = readAction { val element = refactorings.target(args) if (!SafeDeleteProcessor.validElement(element)) { @@ -164,7 +164,7 @@ internal class RefactorTools(private val project: Project, private val refactori "Renames the symbol at a position, or the file when no line is given, updating every reference as the IDE's " + "Rename does. Fails, changing nothing, when the new name is invalid or would conflict.", listOf(Param("path", PATH), Param("new_name", "The new name")) + SYMBOL + - Param("max", "Maximum changed usages to list (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("changed usages to list", DEFAULT_MAX), mutates = true, ) @@ -181,7 +181,7 @@ internal class RefactorTools(private val project: Project, private val refactori "Deletes the symbol at a position, or the file when no line is given, only when nothing else uses it; " + "otherwise deletes nothing and lists the usages that block it.", listOf(Param("path", PATH)) + SYMBOL + - Param("max", "Maximum blocking usages to list (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("blocking usages to list", DEFAULT_MAX), mutates = true, ) } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/TemplateTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/TemplateTools.kt index ca3d7377..c50fe90e 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/TemplateTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/TemplateTools.kt @@ -52,7 +52,7 @@ internal class TemplateTools(private val project: Project, private val targets: private suspend fun templates(args: ToolArgs): ToolResult { val query = args.optionalString("query").orEmpty() - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val all = TemplateSettings.getInstance().templates .filter { !it.isDeactivated } .filter { query.isEmpty() || listOf(it.key, it.groupName, it.description.orEmpty()).any { s -> s.contains(query, true) } } @@ -119,7 +119,7 @@ internal class TemplateTools(private val project: Project, private val targets: private suspend fun fileTemplates(args: ToolArgs): ToolResult { val query = args.optionalString("query").orEmpty() - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val manager = FileTemplateManager.getInstance(project) val all = (manager.allTemplates.toList() + manager.internalTemplates.toList()) .filter { query.isEmpty() || it.name.contains(query, true) || it.extension.contains(query, true) } @@ -193,7 +193,7 @@ internal class TemplateTools(private val project: Project, private val targets: "by a fragment; use it before template_apply.", listOf( Param("query", "Fragment of the key, group or description (default: all)", required = false), - Param("max", "Maximum templates (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("templates", DEFAULT_MAX), ), ) @@ -217,7 +217,7 @@ internal class TemplateTools(private val project: Project, private val targets: "a fragment; use it before file_from_template.", listOf( Param("query", "Fragment of the name or extension (default: all)", required = false), - Param("max", "Maximum templates (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("templates", DEFAULT_MAX), ), ) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/WorkspaceTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/WorkspaceTools.kt index 34ae944f..e5d2ddd4 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/WorkspaceTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/WorkspaceTools.kt @@ -32,7 +32,7 @@ internal class WorkspaceTools(private val project: Project) { private suspend fun workspace(args: ToolArgs): ToolResult { val kind = args.optionalString("entity_type") ?: "module" - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val type = ENTITY_TYPES[kind] ?: throw ToolException("entity_type must be one of ${ENTITY_TYPES.keys.joinToString()}") val rows = readAction { WorkspaceModel.getInstance(project).currentSnapshot.entities(type).map(::row).toList() @@ -106,7 +106,7 @@ internal class WorkspaceTools(private val project: Project) { "the user's edits).", listOf( Param("entity_type", "module (default), content_root, source_root, library or sdk", required = false), - Param("max", "Maximum entities (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("entities", DEFAULT_MAX), ), ) } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ActionTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ActionTools.kt index 4d0041cc..1938105a 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ActionTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ActionTools.kt @@ -40,7 +40,7 @@ internal class ActionTools(private val project: Project, private val actions: Id private suspend fun list(args: ToolArgs): ToolResult { val query = args.optionalString("query").orEmpty() - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val rows = withContext(Dispatchers.EDT) { val manager = ActionManager.getInstance() val context = TargetContext(project).project() @@ -189,7 +189,7 @@ internal class ActionTools(private val project: Project, private val actions: Id "Use it to find the id for ide_action when no named tool covers a menu entry.", listOf( Param("query", "Case-insensitive fragment of the action id or its menu text (default: all)", required = false), - Param("max", "Maximum actions to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("actions", DEFAULT_MAX), ), ) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/HttpTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/HttpTools.kt index b89a08d0..f31573c7 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/HttpTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/HttpTools.kt @@ -52,7 +52,7 @@ internal class HttpTools(private val project: Project, scope: CoroutineScope, pr } private suspend fun files(args: ToolArgs): ToolResult { - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val paths = smartReadAction(project) { val scope = GlobalSearchScope.projectScope(project) EXTENSIONS.flatMap { FilenameIndex.getAllFilesByExt(project, it, scope) }.map { Locations.relative(project, it) }.sorted() @@ -113,7 +113,7 @@ internal class HttpTools(private val project: Project, scope: CoroutineScope, pr val HTTP_FILES = ToolSpec( "http_files", "Lists the project's HTTP Client request files (.http and .rest) as the IDE indexes them, relative to the project root.", - listOf(Param("max", "Maximum paths to return (default $DEFAULT_MAX)", type = "integer", required = false)), + listOf(Param.max("paths", DEFAULT_MAX)), ) val HTTP_RUN = ToolSpec( diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/IdeTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/IdeTools.kt index 0f80cc12..e61bcf48 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/IdeTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/IdeTools.kt @@ -54,7 +54,7 @@ internal class IdeTools(private val project: Project, private val actions: IdeAc private suspend fun toolWindow(args: ToolArgs): ToolResult { val action = args.string("action") val id = args.optionalString("id").orEmpty() - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) if (action !in TOOL_WINDOW_ACTIONS) throw ToolException("action must be ${TOOL_WINDOW_ACTIONS.joinToString()}") if (action != "list" && id.isEmpty()) throw ToolException("action=$action needs id; action=list shows the ids") val rows = withContext(Dispatchers.EDT) { @@ -108,7 +108,7 @@ internal class IdeTools(private val project: Project, private val actions: IdeAc } private suspend fun plugins(args: ToolArgs): ToolResult { - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val filter = args.optionalString("filter").orEmpty() val matching = PluginDetailsGateway.activePlugins() .filter { filter.isEmpty() || matches(it, filter) } @@ -161,7 +161,7 @@ internal class IdeTools(private val project: Project, private val actions: IdeAc listOf( Param("action", "open, close or list"), Param("id", "The tool window id, matched case-insensitively (action=open and close)", required = false), - Param("max", "Maximum windows to list (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("windows to list", DEFAULT_MAX), ), mutates = true, ) @@ -182,7 +182,7 @@ internal class IdeTools(private val project: Project, private val actions: IdeAc "Refused on IDEs older than 2026.2, which have no public plugin listing.", listOf( Param("filter", "Only plugins whose id or name contains this text, case-insensitive", required = false), - Param("max", "Maximum plugins to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("plugins", DEFAULT_MAX), ), ) } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ProjectTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ProjectTools.kt index ed7825d8..f09d745c 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ProjectTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ProjectTools.kt @@ -66,7 +66,7 @@ internal class ProjectTools(private val project: Project) { } private suspend fun modules(args: ToolArgs): ToolResult { - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val (rows, total) = readAction { val all = ModuleManager.getInstance(project).modules all.take(max).map(::moduleRow) to all.size @@ -84,7 +84,7 @@ internal class ProjectTools(private val project: Project) { private suspend fun dependencies(args: ToolArgs): ToolResult { val name = args.string("module") - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val (rows, total) = readAction { val entries = ModuleRootManager.getInstance(module(name)).orderEntries entries.take(max).map(::dependencyRow) to entries.size @@ -169,7 +169,7 @@ internal class ProjectTools(private val project: Project) { "modules", "Lists the project's modules as Project Structure shows them: name, module type id, number of content roots and " + "the module SDK (empty when inherited from the project).", - listOf(Param("max", "Maximum modules to return (default $DEFAULT_MAX)", type = "integer", required = false)), + listOf(Param.max("modules", DEFAULT_MAX)), ) val DEPENDENCIES = ToolSpec( @@ -178,7 +178,7 @@ internal class ProjectTools(private val project: Project) { "own sources, each with its scope (compile, test, runtime, provided) and whether it is exported.", listOf( Param("module", "Module name as modules lists it"), - Param("max", "Maximum entries to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("entries", DEFAULT_MAX), ), ) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/RemoteTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/RemoteTools.kt index 68bdd2de..70d3553c 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/RemoteTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/RemoteTools.kt @@ -84,7 +84,7 @@ internal class RemoteTools(private val project: Project, private val actions: Id } private suspend fun problems(group: String, tab: String, args: ToolArgs): ToolResult { - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val (rows, tabId) = withContext(Dispatchers.EDT) { val collector = ProblemsCollector.getInstance(project) val all = collector.getProblemFiles().flatMap { collector.getFileProblems(it) } + collector.getOtherProblems() @@ -174,7 +174,7 @@ internal class RemoteTools(private val project: Project, private val actions: Id "Problems view; run and open fire the Qodana plugin's own actions, discovered on this IDE.", listOf( Param("action", "results (default), run or open", required = false), - Param("max", "Maximum problems (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("problems", DEFAULT_MAX), ), mutates = true, ) @@ -183,7 +183,7 @@ internal class RemoteTools(private val project: Project, private val actions: Id "vulnerable_dependencies", "The vulnerable dependencies the Package Checker found, as the Problems view's Vulnerable Dependencies tab lists " + "them; the tab is shown without focus.", - listOf(Param("max", "Maximum problems (default $DEFAULT_MAX)", type = "integer", required = false)), + listOf(Param.max("problems", DEFAULT_MAX)), ) } } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ServiceViewTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ServiceViewTools.kt index e9ba6ceb..6623356f 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ServiceViewTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/ServiceViewTools.kt @@ -118,7 +118,7 @@ internal class ServiceViewTools(private val project: Project, scope: CoroutineSc private suspend fun events(args: ToolArgs): ToolResult { val since = args.int("since", 0).toLong() - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val rows = synchronized(events) { events.filter { it.seq > since } } return ToolResult.toon( buildJsonObject { @@ -175,7 +175,7 @@ internal class ServiceViewTools(private val project: Project, scope: CoroutineSc "sequence number; pass since to get only what happened after the last one you saw.", listOf( Param("since", "Sequence number of the last event already seen (default 0)", type = "integer", required = false), - Param("max", "Maximum events (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("events", DEFAULT_MAX), ), ) } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/SshTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/SshTools.kt index 3709d7a6..1e917645 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/SshTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/SshTools.kt @@ -29,7 +29,7 @@ internal class SshTools(private val project: Project) { ) private suspend fun hosts(args: ToolArgs): ToolResult { - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val rows = mutableListOf() val errors = mutableListOf() for (provider in SshCredentialProvider.EP_NAME.extensionList) { @@ -71,7 +71,7 @@ internal class SshTools(private val project: Project) { "Lists the SSH hosts configured in the IDE (remote interpreters, SDKs, deployment servers) with host, port, user and " + "the authentication kind: password, key or agent. Passwords, passphrases and key files are never returned, and " + "nothing is connected to; run ssh yourself to reach a host.", - listOf(Param("max", "Maximum hosts to return (default $DEFAULT_MAX)", type = "integer", required = false)), + listOf(Param.max("hosts", DEFAULT_MAX)), ) } } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/BreakpointTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/BreakpointTools.kt index 92684140..918cec05 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/BreakpointTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/BreakpointTools.kt @@ -36,7 +36,7 @@ internal class BreakpointTools(private val project: Project) { private suspend fun breakpoint(args: ToolArgs): ToolResult = when (val action = args.string("action")) { "add" -> add(args) "remove" -> remove(args) - "list" -> list(args.int("max", DEFAULT_MAX)) + "list" -> list(args.max(DEFAULT_MAX, Param.MAX_CEILING)) else -> throw ToolException("action must be add, remove or list, not $action") } @@ -125,7 +125,7 @@ internal class BreakpointTools(private val project: Project) { Param("line", "1-based line (add, remove)", type = "integer", required = false), Param("condition", "Expression that must be true for the breakpoint to stop (add)", required = false), Param("temporary", "true to remove the breakpoint once it is hit (add, default false)", type = "boolean", required = false), - Param("max", "Maximum breakpoints to list (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("breakpoints to list", DEFAULT_MAX), ), mutates = true, ) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/BuildTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/BuildTools.kt index 451fe94a..be578d0f 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/BuildTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/BuildTools.kt @@ -57,7 +57,7 @@ internal class BuildTools(private val project: Project, scope: CoroutineScope) { private suspend fun build(args: ToolArgs): ToolResult { val kind = args.optionalString("kind") ?: "build" if (kind !in KINDS) throw ToolException("kind must be one of ${KINDS.joinToString()}") - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val tailLines = OutputTail.lines(args) val scope = scope(kind, args) val job = args.optionalString("job")?.let { jobs.find(it) } @@ -197,7 +197,7 @@ internal class BuildTools(private val project: Project, scope: CoroutineScope) { Param("path", "The file to recompile, relative to the project root (kind=file)", required = false), Jobs.WAIT, OutputTail.TAIL, - Param("max", "Maximum errors to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("errors", DEFAULT_MAX), Jobs.JOB, ), mutates = true, diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/DebugSpecs.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/DebugSpecs.kt index 58a48475..fdd0d49e 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/DebugSpecs.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/DebugSpecs.kt @@ -69,7 +69,7 @@ internal object DebugSpecs { listOf( Param("thread", "Thread index (default: the active thread)", type = "integer", required = false), Param("frame", "Frame index to select as current", type = "integer", required = false), - Param("max", "Maximum frames and threads to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("frames and threads", DEFAULT_MAX), SESSION_NAME, ), ) @@ -84,7 +84,7 @@ internal object DebugSpecs { Param("name", "Variable to set (set)", required = false), Param("value", "New value as an expression (set)", required = false), Param("frame", "Frame index (default: the current frame)", type = "integer", required = false), - Param("max", "Maximum variables to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("variables", DEFAULT_MAX), ), mutates = true, ) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/DebugTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/DebugTools.kt index 029a8b74..501eb301 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/DebugTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/DebugTools.kt @@ -113,7 +113,7 @@ internal class DebugTools(private val project: Project) { private suspend fun frames(args: ToolArgs): ToolResult { val session = sessions.resolve(args.optionalString("name")) - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val context = session.suspendContext?.takeIf { session.isSuspended } ?: throw ToolException(DebugSpecs.RUNNING) val stacks = context.executionStacks val active = stacks.indexOfFirst { it === context.activeExecutionStack }.coerceAtLeast(0) @@ -149,7 +149,7 @@ internal class DebugTools(private val project: Project) { private suspend fun values(args: ToolArgs): ToolResult { val session = sessions.resolve(null) - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val frame = frameOf(session, args.int("frame", -1)) return when (val action = args.string("action")) { "list" -> listValues(frame, max) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/RunTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/RunTools.kt index e53f6d43..9cc65761 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/RunTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/RunTools.kt @@ -44,7 +44,7 @@ internal class RunTools(private val project: Project, scope: CoroutineScope) { } private suspend fun runConfigurations(args: ToolArgs): ToolResult { - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val (rows, total) = readAction { val manager = RunManager.getInstance(project) val selected = manager.selectedConfiguration?.uniqueID @@ -92,7 +92,7 @@ internal class RunTools(private val project: Project, scope: CoroutineScope) { ?: throw ToolException("executor must be one of ${EXECUTORS.keys.joinToString()}") private suspend fun processes(args: ToolArgs): ToolResult = when (val action = args.optionalString("action") ?: "list") { - "list" -> list(args.int("max", DEFAULT_MAX)) + "list" -> list(args.max(DEFAULT_MAX, Param.MAX_CEILING)) "stop" -> stop(args.string("name")) else -> throw ToolException("action must be list or stop, not $action") } @@ -145,7 +145,7 @@ internal class RunTools(private val project: Project, scope: CoroutineScope) { "run_configurations", "Lists the project's run configurations as the Run/Debug combo shows them: name, type, folder, whether temporary " + "and which one is selected.", - listOf(Param("max", "Maximum configurations to return (default $DEFAULT_MAX)", type = "integer", required = false)), + listOf(Param.max("configurations", DEFAULT_MAX)), ) val RUN_CONFIGURATION = ToolSpec( @@ -171,7 +171,7 @@ internal class RunTools(private val project: Project, scope: CoroutineScope) { listOf( Param("action", "list (default) or stop", required = false), Param("name", "The tab name to stop, as list shows it", required = false), - Param("max", "Maximum processes to list (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("processes to list", DEFAULT_MAX), ), mutates = true, ) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TestTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TestTools.kt index 55438600..acccc05b 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TestTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/TestTools.kt @@ -51,7 +51,7 @@ internal class TestTools(private val project: Project, scope: CoroutineScope) { private suspend fun runOne(args: ToolArgs, deadline: Deadline): JsonObject { val tailLines = OutputTail.lines(args) - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val job = args.optionalString("job")?.let { jobs.find(it) } ?: start(args, deadline) val outcome = jobs.await(job, deadline.remaining()) val failures = outcome?.failures.orEmpty() @@ -131,7 +131,7 @@ internal class TestTools(private val project: Project, scope: CoroutineScope) { private suspend fun tests(args: ToolArgs): ToolResult { val path = args.string("path") - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val (rows, total) = indexed { val psiFile = Locations.psiFile(project, path) val document = psiFile.viewProvider.document @@ -201,7 +201,7 @@ internal class TestTools(private val project: Project, scope: CoroutineScope) { Param("name", "A run configuration name, instead of path", required = false), Jobs.WAIT, OutputTail.TAIL, - Param("max", "Maximum failures to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("failures", DEFAULT_MAX), Jobs.JOB, ), mutates = true, @@ -212,7 +212,7 @@ internal class TestTools(private val project: Project, scope: CoroutineScope) { "Lists the test classes and test methods the IDE's test frameworks recognise in one file, with their lines.", listOf( Param("path", "File path, absolute or relative to the project root"), - Param("max", "Maximum tests to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("tests", DEFAULT_MAX), ), ) } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ForgeTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ForgeTools.kt index 831d593a..8220089e 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ForgeTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ForgeTools.kt @@ -41,7 +41,7 @@ internal class ForgeTools( private suspend fun pullRequests(args: ToolArgs): ToolResult { val state = args.optionalString("state") ?: "open" - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val filter = STATES[state] ?: throw ToolException("state must be one of ${STATES.keys.joinToString()}") val repository = github.repository() val requests = github.pullRequests(filter, max) @@ -163,7 +163,7 @@ internal class ForgeTools( "Requests view is shown. Refused when the GitHub plugin, an account or a GitHub remote is missing.", listOf( Param("state", "open (default), closed, merged or all", required = false), - Param("max", "Maximum pull requests (default $DEFAULT_MAX)", type = "integer", required = false), + Param.max("pull requests", DEFAULT_MAX), ), ) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/HistoryTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/HistoryTools.kt index 1295e620..fc1cfd43 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/HistoryTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/HistoryTools.kt @@ -86,7 +86,7 @@ internal class HistoryTools( private suspend fun fileHistory(args: ToolArgs): ToolResult { val path = args.string("path") - val max = args.int("max", DEFAULT_MAX) + val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) val file = readAction { ReadTools.resolveFile(project, path) } val relative = Locations.relative(project, file) val commits = withContext(io) { project.service().fileHistory(relative, max) } @@ -184,7 +184,7 @@ internal class HistoryTools( "file_history", "The commits that touched a file, renames followed, newest first, as Git ▸ Show History lists them; the " + "history tab is shown.", - listOf(PATH, Param("max", "Maximum commits (default $DEFAULT_MAX)", type = "integer", required = false)), + listOf(PATH, Param.max("commits", DEFAULT_MAX)), ) val LOCAL_HISTORY = ToolSpec( From 51b97bef9a08ab2ed5360770034a35ab1262b394 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:53:42 +0200 Subject: [PATCH 106/155] build: bump the Gradle version to 6.5.0 and say the Code With Me chat is the host's --- build.gradle.kts | 2 +- src/main/resources/META-INF/plugin.xml | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 524be06f..a1eb7f4a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,7 +15,7 @@ plugins { } group = "dev.lain" -version = "6.0.1" +version = "6.5.0" val platformBuild = "262.8665.258" val serialization = "1.9.0" diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index ce266d18..ecac5950 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -12,7 +12,7 @@

        Works in any JetBrains IDE on the 2026.2 platform or newer — IntelliJ IDEA, PyCharm, WebStorm, PhpStorm, GoLand, RubyMine, CLion, Rider, DataGrip, DataSpell and RustRover — locally, - in Remote Development and in Code With Me.

        + in Remote Development, and for the host of a Code With Me session.

        Unofficial, community-built, open-source plugin. Not affiliated with, sponsored by, or endorsed by Anthropic or JetBrains. It requires the user's own separately-installed @@ -121,7 +121,8 @@

        • JetBrains IDE 2026.2 or newer (build 262.8665+). Earlier releases are not supported: the plugin is split into a frontend and a backend so that it runs in Remote - Development and Code With Me, and 2026.2 is the platform that plugin model targets
        • + Development, and 2026.2 is the platform that plugin model targets. In Code With Me the + chat is the host's; guests do not get it
        • claude CLI installed and on PATH or ~/.local/bin/ (npm install -g @anthropic-ai/claude-code)
        • A Claude subscription (Pro / Max / Team / Enterprise) or an ANTHROPIC_API_KEY
        • From 41804e304de85e3eebc18c71384de7175a24de98 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 21:54:57 +0200 Subject: [PATCH 107/155] build: register tasks without the delegates Gradle 9.6 deprecated --- build.gradle.kts | 4 ++-- frontend/build.gradle.kts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index a1eb7f4a..f99d0eea 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -97,7 +97,7 @@ val npm = if (System.getProperty("os.name").startsWith("Windows")) "npm.cmd" els val fakeClaude: String = file("bin/fake-claude").absolutePath tasks { - val frontendTest by registering(Exec::class) { + val frontendTest = register("frontendTest") { dependsOn(":frontend:compileWeb") inputs.dir("src/test/frontend") inputs.dir("frontend/src/main/resources/jcef") @@ -125,7 +125,7 @@ tasks { } } -val bench by intellijPlatformTesting.testIde.registering { +intellijPlatformTesting.testIde.register("bench") { testFrameworks(TestFrameworkType.Platform) task { group = "verification" diff --git a/frontend/build.gradle.kts b/frontend/build.gradle.kts index 6dc3d0b5..64e74ce7 100644 --- a/frontend/build.gradle.kts +++ b/frontend/build.gradle.kts @@ -8,14 +8,14 @@ dependencies { implementation(project(":shared")) } -val npmInstall by tasks.registering(Exec::class) { +val npmInstall = tasks.register("npmInstall") { workingDir(rootDir) inputs.files(rootProject.file("package.json"), rootProject.file("package-lock.json")) outputs.file(rootProject.file("node_modules/.package-lock.json")) commandLine(npm, "ci") } -val compileWeb by tasks.registering(Exec::class) { +val compileWeb = tasks.register("compileWeb") { dependsOn(npmInstall) workingDir(rootDir) inputs.dir("src/main/ts/jcef") From 07caefd064b38469352147342abeecc0d6abc6c2 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 22:00:04 +0200 Subject: [PATCH 108/155] fix(build): compile against 2026.2 where the first build broke --- build.gradle.kts | 2 +- .../main/kotlin/dev/lain/claudejb/frontend/theme/JcefTheme.kt | 4 ++-- gradle/release-notes.gradle.kts | 2 +- intellilang/build.gradle.kts | 2 +- .../src/main/resources/dev.lain.claudejb.intellilang.xml | 2 +- .../dev/lain/claudejb/controller/mcp/tools/run/DebugTools.kt | 1 + src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt | 1 + src/main/kotlin/dev/lain/claudejb/view/guard/GuardFeed.kt | 2 +- src/main/kotlin/dev/lain/claudejb/view/log/LogFeed.kt | 2 +- 9 files changed, 10 insertions(+), 8 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index f99d0eea..8f34ea8e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -182,7 +182,7 @@ intellijPlatform { sinceBuild = platformBuild untilBuild = "263.*" } - changeNotes = provider { extra["changeNotesHtml"] as String } + changeNotes = provider { project.extra["changeNotesHtml"] as String } } publishing { token = providers.environmentVariable("PUBLISH_TOKEN") diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/theme/JcefTheme.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/theme/JcefTheme.kt index 3cd29c55..2f5ed081 100644 --- a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/theme/JcefTheme.kt +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/theme/JcefTheme.kt @@ -44,8 +44,8 @@ object JcefTheme { val link = JBUI.CurrentTheme.Link.Foreground.ENABLED val labelFont = UIUtil.getLabelFont() - val monoFamily = scheme.getFont(EditorFontType.PLAIN)?.family - ?.takeIf { it.isNotBlank() } + val monoFamily = scheme.getFont(EditorFontType.PLAIN).family + .takeIf { it.isNotBlank() } ?: "JetBrains Mono" return buildJsonObject { diff --git a/gradle/release-notes.gradle.kts b/gradle/release-notes.gradle.kts index 9c2337b0..a02a93d9 100644 --- a/gradle/release-notes.gradle.kts +++ b/gradle/release-notes.gradle.kts @@ -50,4 +50,4 @@ fun releaseNotesHtml(lines: List): String { val notes = file("RELEASE_NOTES.md") val section = if (notes.exists()) latestSection(notes.readLines()) else emptyList() -extra["changeNotesHtml"] = if (section.isEmpty()) "See RELEASE_NOTES.md." else releaseNotesHtml(section) +project.extra["changeNotesHtml"] = if (section.isEmpty()) "See RELEASE_NOTES.md." else releaseNotesHtml(section) diff --git a/intellilang/build.gradle.kts b/intellilang/build.gradle.kts index 7095817f..975c2b2a 100644 --- a/intellilang/build.gradle.kts +++ b/intellilang/build.gradle.kts @@ -1,7 +1,7 @@ dependencies { intellijPlatform { bundledModule("intellij.platform.backend") - bundledPlugin("org.intellij.intelliLang") + bundledModule("org.intellij.intelliLang") } implementation(project(":backend")) } diff --git a/intellilang/src/main/resources/dev.lain.claudejb.intellilang.xml b/intellilang/src/main/resources/dev.lain.claudejb.intellilang.xml index 8f8e80ec..8d1cfdd3 100644 --- a/intellilang/src/main/resources/dev.lain.claudejb.intellilang.xml +++ b/intellilang/src/main/resources/dev.lain.claudejb.intellilang.xml @@ -1,6 +1,6 @@ - + diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/DebugTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/DebugTools.kt index 501eb301..68a3071d 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/DebugTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/run/DebugTools.kt @@ -16,6 +16,7 @@ import dev.lain.claudejb.controller.mcp.tools.run.DebugSpecs.DEFAULT_MAX import dev.lain.claudejb.controller.mcp.tools.run.DebugSpecs.DEFAULT_STEP_WAIT import dev.lain.claudejb.controller.mcp.tools.run.DebugSpecs.DEFAULT_VARIABLES import dev.lain.claudejb.controller.mcp.tools.run.DebugSpecs.STEP_FRAMES +import dev.lain.claudejb.model.mcp.Param import dev.lain.claudejb.model.mcp.Tool import dev.lain.claudejb.model.mcp.ToolArgs import dev.lain.claudejb.model.mcp.ToolDomain diff --git a/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt b/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt index b83531f8..0aa91f9d 100644 --- a/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt +++ b/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt @@ -13,6 +13,7 @@ import dev.lain.claudejb.view.window.ChatPresenter import dev.lain.claudejb.view.window.ChatRegistry import dev.lain.claudejb.view.window.PushSink import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.completeWith import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow diff --git a/src/main/kotlin/dev/lain/claudejb/view/guard/GuardFeed.kt b/src/main/kotlin/dev/lain/claudejb/view/guard/GuardFeed.kt index d34aed9e..729a3f29 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/guard/GuardFeed.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/guard/GuardFeed.kt @@ -26,7 +26,7 @@ internal class GuardFeed(private val presenter: ChatPresenter) { recording = !SecretStore.inert(), max = GuardAlertLog.MAX_ENTRIES, ) - edt(presenter.project) { presenter.exec("guard", json) } + edt(presenter.project) { presenter.exec("guard", json.toString()) } } } diff --git a/src/main/kotlin/dev/lain/claudejb/view/log/LogFeed.kt b/src/main/kotlin/dev/lain/claudejb/view/log/LogFeed.kt index 23df5163..739d080a 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/log/LogFeed.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/log/LogFeed.kt @@ -14,7 +14,7 @@ internal class LogFeed(private val presenter: ChatPresenter) { fun push(since: Long) = offEdt { val json = JcefLogData.logJson(LogRing.since(since), PluginLog.debugOn) - edt(presenter.project) { presenter.exec("log", json) } + edt(presenter.project) { presenter.exec("log", json.toString()) } } fun setDebug(on: Boolean) = PluginLog.setDebug(on) From 07f55c6dfed60319f7261cb75cff730d0b31ad87 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 22:16:21 +0200 Subject: [PATCH 109/155] test(permission): drop the null checks Kotlin 2.4 proves redundant --- .../model/permission/broker/PermissionBrokerTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/test/kotlin/dev/lain/claudejb/model/permission/broker/PermissionBrokerTest.kt b/src/test/kotlin/dev/lain/claudejb/model/permission/broker/PermissionBrokerTest.kt index f5362d7b..b072189f 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/permission/broker/PermissionBrokerTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/permission/broker/PermissionBrokerTest.kt @@ -102,9 +102,9 @@ class PermissionBrokerTest { assertNotNull(captured) assertTrue(captured!!.isPlan) - assertEquals("1. do this\n2. then that", captured?.planText) - assertFalse(captured!!.reviewable) - assertEquals("tu_plan", captured?.toolUseId) + assertEquals("1. do this\n2. then that", captured.planText) + assertFalse(captured.reviewable) + assertEquals("tu_plan", captured.toolUseId) } @Test @@ -115,7 +115,7 @@ class PermissionBrokerTest { broker(mode = "default", present = { captured = it }).handle("req-plan2", request) assertTrue(captured!!.isPlan) - assertNull(captured?.planText) + assertNull(captured.planText) } @Test From 70d72c83b47536c851cd5163e5df880879664c1a Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 22:16:23 +0200 Subject: [PATCH 110/155] fix(build): declare the 2026.2 platform modules the backend and the Git module load --- backend/build.gradle.kts | 5 +++++ backend/src/main/resources/dev.lain.claudejb.backend.xml | 6 ++++++ build.gradle.kts | 1 + git/build.gradle.kts | 8 +++++++- git/src/main/resources/dev.lain.claudejb.git.xml | 4 ++++ .../claudejb/controller/mcp/tools/vcs/GitHubGateway.kt | 1 + .../lain/claudejb/controller/mcp/tools/code/IndexTools.kt | 6 ++---- .../dev/lain/claudejb/model/session/agents/AgentEnding.kt | 6 +++--- .../claudejb/model/session/agents/AgentTranscriptTail.kt | 5 ++++- .../dev/lain/claudejb/view/payload/panel/JcefUsageData.kt | 2 +- terminal/build.gradle.kts | 1 + .../src/main/resources/dev.lain.claudejb.terminal.xml | 1 + 12 files changed, 36 insertions(+), 10 deletions(-) diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index f84520ec..099fc3a1 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -11,6 +11,11 @@ dependencies { "intellij.platform.backend", "intellij.platform.kernel.backend", "intellij.platform.rpc.backend", + "intellij.platform.vcs.impl", + "intellij.platform.vcs.log", + "intellij.platform.vcs.log.impl", + "intellij.platform.smRunner", + "intellij.platform.bookmarks", ) bundledPlugin("Git4Idea") } diff --git a/backend/src/main/resources/dev.lain.claudejb.backend.xml b/backend/src/main/resources/dev.lain.claudejb.backend.xml index 882be9bb..5be2ea4a 100644 --- a/backend/src/main/resources/dev.lain.claudejb.backend.xml +++ b/backend/src/main/resources/dev.lain.claudejb.backend.xml @@ -3,6 +3,11 @@ + + + + + @@ -32,6 +37,7 @@ + libs.asFileTree } }) } processResources { from(file("THIRD-PARTY-NOTICES.md")) { into("META-INF") } diff --git a/git/build.gradle.kts b/git/build.gradle.kts index 1dc7375f..ecfbddcb 100644 --- a/git/build.gradle.kts +++ b/git/build.gradle.kts @@ -1,6 +1,12 @@ dependencies { intellijPlatform { - bundledModule("intellij.platform.backend") + bundledModules( + "intellij.platform.backend", + "intellij.platform.vcs.impl", + "intellij.platform.vcs.dvcs.impl", + "intellij.platform.vcs.log", + "intellij.platform.vcs.log.impl", + ) bundledPlugin("Git4Idea") } implementation(project(":backend")) diff --git a/git/src/main/resources/dev.lain.claudejb.git.xml b/git/src/main/resources/dev.lain.claudejb.git.xml index 130fb6de..93fc7fcc 100644 --- a/git/src/main/resources/dev.lain.claudejb.git.xml +++ b/git/src/main/resources/dev.lain.claudejb.git.xml @@ -1,6 +1,10 @@ + + + + diff --git a/github/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitHubGateway.kt b/github/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitHubGateway.kt index b62af66f..9953e579 100644 --- a/github/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitHubGateway.kt +++ b/github/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitHubGateway.kt @@ -112,6 +112,7 @@ internal class GitHubGateway(private val project: Project) : GitHubAccess { return GithubApiRequestExecutor.Factory.getInstance().create(account.server, token) to coordinates } + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) private fun mapping() = project.service().knownRepositoriesState.value.firstOrNull() ?: throw ToolException("no remote of this project points at a GitHub repository the IDE knows") diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/IndexTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/IndexTools.kt index b93c6280..ec0b2377 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/IndexTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/IndexTools.kt @@ -29,10 +29,8 @@ internal class IndexTools(private val project: Project) { listOf(Tool(INDEX_KEYS, ::keys), Tool(INDEX_QUERY, ::query), Tool(STUB_QUERY, ::stubQuery)), ) - private fun index(name: String): ID { - @Suppress("UNCHECKED_CAST") - return ID.findByName(name) as ID? ?: throw ToolException("no file-based index named $name") - } + private fun index(name: String): ID = + ID.findByName(name) ?: throw ToolException("no file-based index named $name") private suspend fun keys(args: ToolArgs): ToolResult { val name = args.string("index") diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentEnding.kt b/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentEnding.kt index ff0a7695..38b54d5f 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentEnding.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentEnding.kt @@ -20,7 +20,7 @@ internal object AgentEnding { fun of(records: List, finishedEarlier: Boolean = false): Ending? { if (records.isEmpty()) return null if (records.last().isAbortMarker()) return Ending.ABORTED - val lastFinished = records.indexOfLast { it.endsTurn() } + val lastFinished = records.indexOfLast { it.finishesTurn() } return when { lastFinished == records.lastIndex -> Ending.COMPLETED records.last().isFinalAnswer() -> Ending.COMPLETED @@ -29,9 +29,9 @@ internal object AgentEnding { } } - fun endsTurn(record: JsonObject): Boolean = record.endsTurn() + fun endsTurn(record: JsonObject): Boolean = record.finishesTurn() - private fun JsonObject.endsTurn(): Boolean { + private fun JsonObject.finishesTurn(): Boolean { if ((this[TOOL_ENDS_TURN] as? JsonPrimitive)?.contentOrNull == "true") return true val message = this["message"] as? JsonObject ?: return false return (message["stop_reason"] as? JsonPrimitive)?.contentOrNull == "end_turn" diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentTranscriptTail.kt b/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentTranscriptTail.kt index a97ab373..cf801c3f 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentTranscriptTail.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentTranscriptTail.kt @@ -77,7 +77,10 @@ internal class AgentTranscriptTail(private val file: Path) { private fun readAt(position: Long, length: Int): ByteArray = FileChannel.open(file, StandardOpenOption.READ).use { channel -> val buffer = ByteBuffer.allocate(length) - while (buffer.hasRemaining() && channel.read(buffer, position + buffer.position()) > 0) Unit + var read: Int + do { + read = channel.read(buffer, position + buffer.position()) + } while (buffer.hasRemaining() && read > 0) buffer.array().copyOf(buffer.position()) } diff --git a/src/main/kotlin/dev/lain/claudejb/view/payload/panel/JcefUsageData.kt b/src/main/kotlin/dev/lain/claudejb/view/payload/panel/JcefUsageData.kt index c44e64db..b5b5d52b 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/payload/panel/JcefUsageData.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/payload/panel/JcefUsageData.kt @@ -26,7 +26,7 @@ internal object JcefUsageData { val windows = fromReport + fromEvents if (windows.isEmpty() && report?.extra == null) return null return buildJsonObject { - put("plan", report?.subscriptionType ?: session.catalog.account.subscriptionType?.ifBlank { null }) + put("plan", report?.subscriptionType ?: session.catalog.account.subscriptionType.ifBlank { null }) put( "windows", buildJsonArray { diff --git a/terminal/build.gradle.kts b/terminal/build.gradle.kts index 2c62734a..6fa793b6 100644 --- a/terminal/build.gradle.kts +++ b/terminal/build.gradle.kts @@ -4,4 +4,5 @@ dependencies { bundledPlugin("org.jetbrains.plugins.terminal") } implementation(project(":frontend")) + implementation(project(":shared")) } diff --git a/terminal/src/main/resources/dev.lain.claudejb.terminal.xml b/terminal/src/main/resources/dev.lain.claudejb.terminal.xml index d40a2618..041dcd70 100644 --- a/terminal/src/main/resources/dev.lain.claudejb.terminal.xml +++ b/terminal/src/main/resources/dev.lain.claudejb.terminal.xml @@ -3,6 +3,7 @@ + From 650974e19ff85ae0f39ef6e0601c07df3a5a3f87 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 22:24:11 +0200 Subject: [PATCH 111/155] fix(session): drop orphan outputs from a restore window read from the end --- .../history/SessionTranscriptReader.kt | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReader.kt b/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReader.kt index 6df23394..5321533c 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReader.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReader.kt @@ -41,15 +41,19 @@ object SessionTranscriptReader { private fun tailEntries(newestFirst: Sequence, maxEntries: Int, projectRoot: String?): List { val chunks = ArrayList>() var count = 0 + var truncated = false for (line in newestFirst) { - if (count >= maxEntries) break + if (count >= maxEntries) { + truncated = true + break + } val record = parseRecord(line) ?: continue val entries = entriesOf(record, projectRoot) if (entries.isEmpty()) continue chunks += entries count += entries.size } - return settle(chunks.asReversed().flatten(), maxEntries) + return settle(chunks.asReversed().flatten(), maxEntries, truncated) } fun parseRecord(line: String): JsonObject? = @@ -80,8 +84,8 @@ object SessionTranscriptReader { } } - private fun settle(entries: List, maxEntries: Int?): List = - decodeOwnOutputs(capTail(markInFlight(tagCommandOutputs(entries)), maxEntries)) + private fun settle(entries: List, maxEntries: Int?, truncated: Boolean = false): List = + decodeOwnOutputs(capTail(markInFlight(tagCommandOutputs(entries)), maxEntries, truncated)) private fun decodeOwnOutputs(entries: List): List { val ownCalls = entries.asSequence() @@ -126,9 +130,10 @@ object SessionTranscriptReader { } } - private fun capTail(entries: List, maxEntries: Int?): List { - if (maxEntries == null || maxEntries <= 0 || entries.size <= maxEntries) return entries - val window = entries.subList(entries.size - maxEntries, entries.size) + private fun capTail(entries: List, maxEntries: Int?, truncated: Boolean): List { + if (maxEntries == null || maxEntries <= 0) return entries + if (!truncated && entries.size <= maxEntries) return entries + val window = entries.takeLast(maxEntries) val seenToolIds = HashSet() for (e in window) if (e.speaker == "TOOL") seenToolIds += e.toolUseId return window.filterNot { e -> From af97b81827d6876a0ecfdf1c87d60beeaa218edb Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 22:24:12 +0200 Subject: [PATCH 112/155] fix(process): close the dead CLI's stdin through the one teardown helper the contract names --- .../dev/lain/claudejb/controller/process/ClaudeProcess.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/process/ClaudeProcess.kt b/src/main/kotlin/dev/lain/claudejb/controller/process/ClaudeProcess.kt index 44efb6be..a8071238 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/process/ClaudeProcess.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/process/ClaudeProcess.kt @@ -141,8 +141,9 @@ class ClaudeProcess( } } + @Suppress("UNNECESSARY_SAFE_CALL") private fun endProcess(dying: KillableProcessHandler) { dying.destroyProcess() - runCatching { dying.processInput.close() } + runCatching { dying.processInput?.close() } } } From 501cbd5e0a5ff052f0ee27b325355ee78d77907f Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 22:24:12 +0200 Subject: [PATCH 113/155] refactor(context): remove the unreachable image-from-file reader --- .../model/context/ImageAttachments.kt | 13 -------- .../model/context/ImageAttachmentsTest.kt | 31 ------------------- 2 files changed, 44 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/model/context/ImageAttachments.kt b/src/main/kotlin/dev/lain/claudejb/model/context/ImageAttachments.kt index 2aaf9235..7f64f14f 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/context/ImageAttachments.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/context/ImageAttachments.kt @@ -1,6 +1,5 @@ package dev.lain.claudejb.model.context -import java.io.File import java.util.Base64 internal object ImageAttachments { @@ -56,18 +55,6 @@ internal object ImageAttachments { else -> null } - fun imageFromFile(path: String): Attachment.Image? = runCatching { - val file = File(path) - val bytes = file.takeIf { it.isFile }?.readBytes() ?: return null - if (bytes.isEmpty()) return null - val mediaType = mediaTypeForExtension(file.extension.lowercase()) ?: return null - Attachment.Image( - displayName = file.name, - mediaType = mediaType, - base64 = Base64.getEncoder().encodeToString(bytes), - ) - }.getOrNull() - fun mediaTypeForExtension(ext: String): String? = when (ext) { "png" -> "image/png" "jpg", "jpeg" -> "image/jpeg" diff --git a/src/test/kotlin/dev/lain/claudejb/model/context/ImageAttachmentsTest.kt b/src/test/kotlin/dev/lain/claudejb/model/context/ImageAttachmentsTest.kt index 97cd64c2..21510fc1 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/context/ImageAttachmentsTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/context/ImageAttachmentsTest.kt @@ -5,12 +5,7 @@ import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.awt.image.BufferedImage -import java.io.File -import java.nio.file.Path import java.util.Base64 -import javax.imageio.ImageIO class ImageAttachmentsTest { @@ -29,32 +24,6 @@ class ImageAttachmentsTest { assertNull(ImageAttachments.mediaTypeForExtension("")) } - @Test - fun `imageFromFile reads a PNG and base64-encodes it`(@TempDir dir: Path) { - val png = File(dir.toFile(), "pic.png") - val image = BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB) - image.setRGB(0, 0, 0xFFFF0000.toInt()) - ImageIO.write(image, "png", png) - - val attachment = ImageAttachments.imageFromFile(png.absolutePath) - requireNotNull(attachment) { "expected a non-null Image attachment" } - assertEquals("pic.png", attachment.displayName) - assertEquals("image/png", attachment.mediaType) - assertTrue(attachment.base64.isNotEmpty(), "base64 must not be empty") - assertTrue(Base64.getDecoder().decode(attachment.base64).contentEquals(png.readBytes())) - } - - @Test - fun `imageFromFile returns null for a missing file`(@TempDir dir: Path) { - assertNull(ImageAttachments.imageFromFile(File(dir.toFile(), "nope.png").absolutePath)) - } - - @Test - fun `imageFromFile returns null for an unsupported extension`(@TempDir dir: Path) { - val txt = File(dir.toFile(), "data.txt").apply { writeText("not an image") } - assertNull(ImageAttachments.imageFromFile(txt.absolutePath)) - } - private val pngMagic = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) private val jpegMagic = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0x00) + ByteArray(8) private val gifMagic = "GIF89a".toByteArray() + ByteArray(4) From cbda70af322b4965ddd4d60fa430f5ee1e4f28b4 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 22:24:28 +0200 Subject: [PATCH 114/155] test(guard): follow the guard view's wiring into the chat presenter and the frontend --- .../view/guard/GuardViewWiringContractTest.kt | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/test/kotlin/dev/lain/claudejb/view/guard/GuardViewWiringContractTest.kt b/src/test/kotlin/dev/lain/claudejb/view/guard/GuardViewWiringContractTest.kt index b76040f6..062f9867 100644 --- a/src/test/kotlin/dev/lain/claudejb/view/guard/GuardViewWiringContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/view/guard/GuardViewWiringContractTest.kt @@ -21,7 +21,7 @@ class GuardViewWiringContractTest { @Test fun `the page has exactly one emitter of the guard payload`() { val emitters = kotlinFiles() - .filter { it.readText().contains("window.cc.guard(") } + .filter { it.readText().let { text -> text.contains("window.cc.guard(") || text.contains("exec(\"guard\"") } } .map { it.name } .sorted() @@ -38,19 +38,19 @@ class GuardViewWiringContractTest { "GuardFeed reads the alert log on whatever thread asked for it. That read decodes the whole " + "stored array, and every UI mutation here is on the EDT." } - assertTrue(feed.contains("edt(panel.project)")) { + assertTrue(feed.contains("edt(presenter.project)")) { "GuardFeed does not come back to the EDT to draw." } } @Test fun `the gear menu reaches the guard view, and so does the chat's own view row`() { - val factory = source("view/window/ClaudeToolWindowFactory.kt").readText() + val gear = source("view/window/ChatGearGroup.kt").readText() - assertTrue(factory.contains("showGuardView")) { + assertTrue(gear.contains("openGuardView")) { "the tool window's gear has no entry for the guard log" } - assertTrue(source("view/feed/SecurityViews.kt").readText().contains("window.cc.openGuardView")) { + assertTrue(source("view/feed/SecurityViews.kt").readText().contains("exec(\"openGuardView\"")) { "nothing on the host side can open the guard view, so the gear entry lands nowhere" } assertTrue(File(tsRoot(), "models/panel/state.ts").readText().contains("guard: {")) { @@ -66,7 +66,7 @@ class GuardViewWiringContractTest { assertTrue(start >= 0) { "SecurityViews no longer opens the guard view" } val body = lines.drop(start).take(BODY_LINES) val push = body.indexOfFirst { it.contains("pushGuard()") } - val open = body.indexOfFirst { it.contains("window.cc.openGuardView") } + val open = body.indexOfFirst { it.contains("exec(\"openGuardView\"") } assertTrue(push in 0 until open) { "openGuardView shows the view before refreshing it: ${body.joinToString("\n")}" @@ -113,7 +113,7 @@ class GuardViewWiringContractTest { @Test fun `the module and the stylesheet are declared, or the page silently does not serve them`() { - val assembly = source("view/jcef/PageAssembly.kt").readText() + val assembly = File(resolve("frontend/src/main/kotlin"), "dev/lain/claudejb/frontend/jcef/PageAssembly.kt").readText() assertTrue(assembly.contains("\"views/guard/guard.js\"")) { "views/guard/guard.js is not in PageAssembly.appNames, so it is not served and cc.guard does not exist" @@ -126,16 +126,20 @@ class GuardViewWiringContractTest { } private fun readyBranch(): List { - val lines = source("controller/bridge/BridgeLifecycle.kt").readLines() - val start = lines.indexOfFirst { it.contains("Msg.Ready ->") } - assertTrue(start >= 0) { "BridgeLifecycle no longer handles Msg.Ready" } - val length = lines.drop(start + 1).indexOfFirst { it == " }" } - assertTrue(length >= 0) { "could not find the end of the Msg.Ready branch" } - return lines.subList(start, start + 1 + length) + val ready = source("controller/bridge/BridgeLifecycle.kt").readLines().firstOrNull { it.contains("Msg.Ready ->") } + assertTrue(ready != null) { "BridgeLifecycle no longer handles Msg.Ready" } + assertTrue(ready!!.contains("presenter.refreshPage()")) { "Msg.Ready no longer refreshes the page: $ready" } + val lines = source("view/window/ChatPresenter.kt").readLines() + val start = lines.indexOfFirst { it.contains("fun refreshPage()") } + assertTrue(start >= 0) { "ChatPresenter no longer refreshes the page" } + val length = lines.drop(start + 1).indexOfFirst { it == " }" } + assertTrue(length >= 0) { "could not find the end of refreshPage" } + return listOf(ready) + lines.subList(start, start + 1 + length) } private fun kotlinFiles(): List = - File(mainRoot(), "dev/lain/claudejb").walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() + listOf(mainRoot(), resolve("frontend/src/main/kotlin")) + .flatMap { root -> File(root, "dev/lain/claudejb").walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() } private fun source(relative: String) = File(mainRoot(), "dev/lain/claudejb/$relative").also { assertTrue(it.isFile) { "missing source file: $it" } From 67e72ef3b41cfd1a5ff40509a717f06216e3eb65 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 22:24:28 +0200 Subject: [PATCH 115/155] test: read the content module descriptors and the frontend's page sources --- src/test/frontend/contracts/source-size.test.js | 4 ++-- src/test/frontend/helpers/load.js | 6 +++--- .../claudejb/view/diff/DiffTabCleanupWiringTest.kt | 10 +++++++++- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/test/frontend/contracts/source-size.test.js b/src/test/frontend/contracts/source-size.test.js index b7c9f2b5..db73dab8 100644 --- a/src/test/frontend/contracts/source-size.test.js +++ b/src/test/frontend/contracts/source-size.test.js @@ -2,8 +2,8 @@ const fs = require('node:fs'); const path = require('node:path'); const CEILING = 250; -const TS_ROOT = path.resolve(__dirname, '../../../main/ts/jcef'); -const CSS_ROOT = path.resolve(__dirname, '../../../main/resources/jcef/css'); +const TS_ROOT = path.resolve(__dirname, '../../../../frontend/src/main/ts/jcef'); +const CSS_ROOT = path.resolve(__dirname, '../../../../frontend/src/main/resources/jcef/css'); const OVERSIZE = new Set([]); diff --git a/src/test/frontend/helpers/load.js b/src/test/frontend/helpers/load.js index 1125cec7..6cb84d5d 100644 --- a/src/test/frontend/helpers/load.js +++ b/src/test/frontend/helpers/load.js @@ -2,8 +2,8 @@ const fs = require('node:fs'); const path = require('node:path'); const { JSDOM } = require('jsdom'); -const JCEF = path.resolve(__dirname, '../../../main/resources/jcef'); -const EMIT = path.resolve(__dirname, '../../../../build/web/jcef'); +const JCEF = path.resolve(__dirname, '../../../../frontend/src/main/resources/jcef'); +const EMIT = path.resolve(__dirname, '../../../../frontend/build/web/jcef'); function appDir(name) { return fs.existsSync(path.join(EMIT, name)) ? EMIT : JCEF; @@ -88,7 +88,7 @@ function loadFrontend(files = [], { vendor = true } = {}) { return window; } -const SOURCES = path.resolve(__dirname, '../../../main/ts/jcef'); +const SOURCES = path.resolve(__dirname, '../../../../frontend/src/main/ts/jcef'); function modulesUnder(root, prefix = '') { if (!fs.existsSync(root)) return []; diff --git a/src/test/kotlin/dev/lain/claudejb/view/diff/DiffTabCleanupWiringTest.kt b/src/test/kotlin/dev/lain/claudejb/view/diff/DiffTabCleanupWiringTest.kt index b67b2d3f..8dba2932 100644 --- a/src/test/kotlin/dev/lain/claudejb/view/diff/DiffTabCleanupWiringTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/view/diff/DiffTabCleanupWiringTest.kt @@ -32,7 +32,15 @@ class DiffTabCleanupWiringTest { check(ours.isNotEmpty()) { "No plugin.xml with id '$PLUGIN_ID' on the test classpath (scanned ${urls.size} descriptors)" } - return ours + return ours + ours.flatMap(::contentModules) + } + + private fun contentModules(root: Element): List { + val modules = root.getElementsByTagName("module") + return (0 until modules.length) + .map { (modules.item(it) as Element).getAttribute("name") } + .mapNotNull { name -> javaClass.classLoader.getResource("$name.xml") } + .map { url -> url.openStream().use { parser().parse(it).documentElement } } } private fun listeners(tag: String): List = ourDescriptors().flatMap { descriptor -> From c00489519b47f09479d8cc3887a84e623b44c0bc Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 22:24:28 +0200 Subject: [PATCH 116/155] build: compile the root tests with the serialization plugin --- build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle.kts b/build.gradle.kts index 42631e2b..7fb70996 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,6 +9,7 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension plugins { id("org.jetbrains.intellij.platform") id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.kotlin.plugin.serialization") id("org.jetbrains.kotlinx.kover") id("dev.detekt") id("com.diffplug.spotless") From 0e57e41323018c9d514126cc1bab690f9a8d4624 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 22:24:58 +0200 Subject: [PATCH 117/155] refactor(process): declare the terminal host as a plain interface --- .../dev/lain/claudejb/controller/process/TerminalLauncher.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/process/TerminalLauncher.kt b/src/main/kotlin/dev/lain/claudejb/controller/process/TerminalLauncher.kt index 25e8e0fe..8d75eac0 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/process/TerminalLauncher.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/process/TerminalLauncher.kt @@ -7,7 +7,7 @@ import dev.lain.claudejb.util.InstalledPlugins object TerminalLauncher { - fun interface Host { + interface Host { fun open(workingDirectory: String?, tabName: String, command: String): Boolean } From 4a1e8f07857959f8c26a83759b8f55bfcb1e483a Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 22:29:30 +0200 Subject: [PATCH 118/155] fix(page): link a read at the start of its range and align the page contract tests with the split --- .../src/main/ts/jcef/views/chat/tools/toon.ts | 3 ++ src/test/frontend/chat/toon-shapes.test.js | 7 +++ .../chat/transcript-streaming.test.js | 10 ++-- .../contracts/bridge-contract.test.js | 7 +++ .../frontend/contracts/bridge-inbound.test.js | 52 ++++++++++++++++--- 5 files changed, 68 insertions(+), 11 deletions(-) diff --git a/frontend/src/main/ts/jcef/views/chat/tools/toon.ts b/frontend/src/main/ts/jcef/views/chat/tools/toon.ts index 341018bf..cdfeae6f 100644 --- a/frontend/src/main/ts/jcef/views/chat/tools/toon.ts +++ b/frontend/src/main/ts/jcef/views/chat/tools/toon.ts @@ -33,6 +33,9 @@ function lineOf(row: Row): unknown { if (row.line != null) return row.line; + if ('from' in row || 'to' in row || 'text' in row) { + return typeof row.from === 'number' && row.from > 1 ? row.from : null; + } if (typeof row.lines === 'number') return row.lines; if (typeof row.lines !== 'string') return null; const first = row.lines.trim().split(/\s+/)[0]; diff --git a/src/test/frontend/chat/toon-shapes.test.js b/src/test/frontend/chat/toon-shapes.test.js index 0d289b67..eb05bed7 100644 --- a/src/test/frontend/chat/toon-shapes.test.js +++ b/src/test/frontend/chat/toon-shapes.test.js @@ -26,6 +26,13 @@ describe('the search_text shape: files with their matching lines', () => { expect(out.querySelector('td.toon-file a.jb-link').textContent).toBe('src/B.kt:5'); }); + it('a read opens where its range starts, and its line count is never taken for a line', () => { + const out = draw({ items: [{ path: 'src/C.kt', lines: 40, from: 12, to: 51, text: 'a\nb' }] }); + expect(out.querySelector('summary a.jb-link').getAttribute('href')).toBe('jb://open?file=src%2FC.kt&line=12'); + const top = draw({ items: [{ path: 'src/D.kt', lines: 2, from: 1, to: 2, text: 'a\nb' }] }); + expect(top.querySelector('summary a.jb-link').getAttribute('href')).toBe('jb://open?file=src%2FD.kt'); + }); + it('draws a folded batch: the clean list and the rows that carry an index', () => { const out = draw({ clean: ['a.kt', 'b.kt'], items: [{ index: 2, path: 'c.kt', error: 'missing' }] }); expect(out.querySelector('.toon-clean').textContent).toContain('a.kt'); diff --git a/src/test/frontend/chat/transcript-streaming.test.js b/src/test/frontend/chat/transcript-streaming.test.js index 5feb5e59..103a8030 100644 --- a/src/test/frontend/chat/transcript-streaming.test.js +++ b/src/test/frontend/chat/transcript-streaming.test.js @@ -56,10 +56,14 @@ describe('cc.append', () => { const frames = []; win.requestAnimationFrame = (fn) => frames.push(fn); win.cc.batch([row(1, 0, 'ASSISTANT', 'Hello', { state: 'RUNNING' })]); + frames.length = 0; + const render = vi.spyOn(win.CC.transcript, 'setBody'); win.cc.append({ id: 1, delta: ', wor' }); win.cc.append({ id: 1, delta: 'ld' }); expect(frames.length).toBe(1); - frames[0](); + expect(render).not.toHaveBeenCalled(); + frames.shift()(); + expect(render).toHaveBeenCalledTimes(1); expect(body(win).textContent).toBe('Hello, world'); }); @@ -69,7 +73,7 @@ describe('cc.append', () => { win.cc.batch([row(1, 0, 'ASSISTANT', 'final')]); win.cc.append({ id: 1, delta: ' extra' }); win.cc.append({ id: 9, delta: 'x' }); - expect(body(win).textContent).toBe('final'); + expect(body(win).textContent.trim()).toBe('final'); }); it('settles with full markdown when the batch that closes it carries the same text', () => { @@ -94,7 +98,7 @@ describe('cc.batch', () => { }, }); win.cc.batch([row(1, 0, 'USER', 'first'), bad, row(3, 2, 'USER', 'third')]); - const bodies = [...win.document.querySelectorAll('.msg.user .body')].map((b) => b.textContent); + const bodies = [...win.document.querySelectorAll('.msg.user .body')].map((b) => b.textContent.trim()); expect(bodies).toEqual(['first', 'third']); expect(sent.some((m) => m.type === 'diag' && /boom/.test(m.report))).toBe(true); }); diff --git a/src/test/frontend/contracts/bridge-contract.test.js b/src/test/frontend/contracts/bridge-contract.test.js index 206d876f..eb66631a 100644 --- a/src/test/frontend/contracts/bridge-contract.test.js +++ b/src/test/frontend/contracts/bridge-contract.test.js @@ -21,6 +21,7 @@ function scanCalls(sources) { code.split('\n').forEach((line, index) => { for (const match of line.matchAll(BRIDGE_CALL)) { const name = match[1] || match[2]; + if (name.charAt(0) === '$') continue; if (!calls.has(name)) calls.set(name, new Set()); calls.get(name).add(`${file}:${index + 1}`); } @@ -89,6 +90,12 @@ describe('Kotlin↔JS bridge — every host call reaches a real implementation', expect(fallbacks.filter((m) => !isNoOp(m[2])).map((m) => `cc.${m[1]}`)).toEqual([]); }); + it('skips a Kotlin string template, which names no method', () => { + const calls = scanCalls([{ file: 'Host.kt', text: 'exec("window.cc.$method && window.cc.$method($json)")' }]); + + expect([...calls.keys()]).toEqual([]); + }); + it('reads a push by method name with strict JSON as a call too', () => { const calls = scanCalls([{ file: 'Push.kt', text: 'exec("append", json)\nemit(PagePush("setGitSubView", x))' }]); diff --git a/src/test/frontend/contracts/bridge-inbound.test.js b/src/test/frontend/contracts/bridge-inbound.test.js index 302ae7f2..1c550816 100644 --- a/src/test/frontend/contracts/bridge-inbound.test.js +++ b/src/test/frontend/contracts/bridge-inbound.test.js @@ -5,8 +5,12 @@ const { stripComments } = require('../helpers/source'); const KOTLIN_SRC = path.resolve(__dirname, '../../../main/kotlin'); const FRONTEND_KOTLIN_SRC = path.resolve(__dirname, '../../../../frontend/src/main/kotlin'); +const SHARED_KOTLIN_SRC = path.resolve(__dirname, '../../../../shared/src/main/kotlin'); const BRIDGE_KT = path.join(KOTLIN_SRC, 'dev/lain/claudejb/model/bridge/JcefBridge.kt'); +const KOTLIN_CONST = /const\s+val\s+([A-Z_][A-Z0-9_]*)\s*=\s*"([^"]+)"/g; +const KOTLIN_TYPE_PUT = /put\(\s*"type"\s*,\s*(?:"([^"]+)"|(?:[A-Za-z_][\w]*\.)?([A-Z_][A-Z0-9_]*))\s*\)/g; + const PARSED_TYPE = /^\s*"([A-Za-z_][A-Za-z0-9_]*)"\s*->/gm; const TYPE_KEY = /\btype\s*:\s*([^,\n}]+)/g; @@ -30,12 +34,8 @@ function pageTypeStrings(sources = pageSources()) { return strings; } -function unsent(parsed, sent) { - return parsed.filter((type) => !sent.has(type)).map((type) => '"' + type + '"'); -} - -function kotlinText() { - return [KOTLIN_SRC, FRONTEND_KOTLIN_SRC] +function kotlinUnder(roots) { + return roots .filter((root) => fs.existsSync(root)) .flatMap((root) => fs @@ -46,6 +46,31 @@ function kotlinText() { .join('\n'); } +function frontendTypeStrings( + frontend = kotlinUnder([FRONTEND_KOTLIN_SRC]), + shared = kotlinUnder([SHARED_KOTLIN_SRC]) +) { + const consts = new Map([...(frontend + '\n' + shared).matchAll(KOTLIN_CONST)].map((m) => [m[1], m[2]])); + const strings = new Set(); + for (const m of frontend.matchAll(KOTLIN_TYPE_PUT)) { + const value = m[1] || consts.get(m[2]); + if (value) strings.add(value); + } + return strings; +} + +function union(a, b) { + return new Set([...a, ...b]); +} + +function unsent(parsed, sent) { + return parsed.filter((type) => !sent.has(type)).map((type) => '"' + type + '"'); +} + +function kotlinText() { + return kotlinUnder([KOTLIN_SRC, FRONTEND_KOTLIN_SRC]); +} + function pageCallsTo(name, sources) { const call = new RegExp('\\.' + name + '\\s*\\('); return sources.some((text) => call.test(stripComments(text))); @@ -65,8 +90,19 @@ describe('Kotlin↔JS bridge — page→host', () => { expect(parsedTypes().length).toBeGreaterThan(20); }); - it('has no parsed message type the page never sends', () => { - expect(unsent(parsedTypes(), pageTypeStrings())).toEqual([]); + it('has no parsed message type that neither the page nor the frontend on its behalf sends', () => { + expect(unsent(parsedTypes(), union(pageTypeStrings(), frontendTypeStrings()))).toEqual([]); + }); + + it('counts a type the frontend builds only through a literal or a named constant', () => { + const frontend = [ + 'fun image() = buildJsonObject { put("type", Channel.ATTACH) }', + 'fun open() = buildJsonObject { put("type", "openUrl") }', + 'val label = "notAType"', + ].join('\n'); + const shared = 'object Channel { const val ATTACH = "attachImageData" }'; + + expect([...frontendTypeStrings(frontend, shared)].sort()).toEqual(['attachImageData', 'openUrl']); }); it('reports a parsed type nothing sends', () => { From f4f374983b740c04c4881864ed7d6f3a6b41f213 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 22:45:28 +0200 Subject: [PATCH 119/155] fix(build): name the module jars after their content modules and load line bookmarks from an optional module --- .github/workflows/ci.yml | 6 +++--- .gitignore | 1 + backend/build.gradle.kts | 1 - .../main/resources/dev.lain.claudejb.backend.xml | 1 - bookmarks/build.gradle.kts | 9 +++++++++ .../mcp/tools/code/ProviderLineBookmarks.kt | 11 +++++++++++ .../main/resources/dev.lain.claudejb.bookmarks.xml | 11 +++++++++++ build.gradle.kts | 3 ++- settings.gradle.kts | 4 ++-- .../controller/mcp/tools/code/BookmarkTools.kt | 3 +-- .../controller/mcp/tools/code/LineBookmarks.kt | 14 ++++++++++++++ src/main/resources/META-INF/plugin.xml | 1 + src/test/kotlin/dev/lain/claudejb/SourceLayout.kt | 3 ++- 13 files changed, 57 insertions(+), 11 deletions(-) create mode 100644 bookmarks/build.gradle.kts create mode 100644 bookmarks/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProviderLineBookmarks.kt create mode 100644 bookmarks/src/main/resources/dev.lain.claudejb.bookmarks.xml create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LineBookmarks.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c99f71be..c9cfca85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -522,7 +522,7 @@ jobs: run: | zip=$(ls build/distributions/*.zip) unzip -o -q "$zip" -d /tmp/artifact - ls /tmp/artifact/*/lib/claude-code-native-*.jar /tmp/artifact/*/lib/modules/dev.lain.claudejb.*.jar 2>/dev/null \ + ls /tmp/artifact/*/lib/dev.lain.claudejb-*.jar /tmp/artifact/*/lib/modules/dev.lain.claudejb.*.jar 2>/dev/null \ | grep -v searchableOptions > /tmp/jars.txt || true if [ "$(wc -l < /tmp/jars.txt)" -lt 4 ]; then echo "::error::expected the plugin jar and the shared, frontend and backend module jars" @@ -534,7 +534,7 @@ jobs: unzip -Z -1 "$jar" | grep -v '/$' \ | grep -vE '^dev/lain/claudejb/.*\.class$' \ | grep -vE '^META-INF/' \ - | grep -vE '^dev\.lain\.claudejb\.(shared|frontend|backend)\.xml$' \ + | grep -vE '^dev\.lain\.claudejb\.(shared|frontend|backend|git|github|java|intellilang|terminal|bookmarks)\.xml$' \ | grep -vE '^icons/[^/]+\.svg$' \ | grep -vE '^jcef/(.+\.(js|html)|css/.+\.css)$' >> /tmp/unexpected.txt || true done < /tmp/jars.txt @@ -565,7 +565,7 @@ jobs: zip=$(ls build/distributions/*.zip) unzip -o -q "$zip" -d /tmp/dist - jar=$(ls /tmp/dist/*/lib/claude-code-native-*.jar | grep -v searchableOptions | head -1) + jar=$(ls /tmp/dist/*/lib/dev.lain.claudejb-*.jar | grep -v searchableOptions | head -1) # ONE listing, read by every assertion below. `-Z1` prints one entry name per line, so names are # matched WHOLE (`grep -qxF`) instead of as substrings of `unzip -l`'s formatted table — where diff --git a/.gitignore b/.gitignore index fabab3de..039f8287 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ !/github/** !/java/** !/intellilang/** +!/bookmarks/** !/terminal/** !/bin/** !/gradle/** diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 099fc3a1..fbdf7b83 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -15,7 +15,6 @@ dependencies { "intellij.platform.vcs.log", "intellij.platform.vcs.log.impl", "intellij.platform.smRunner", - "intellij.platform.bookmarks", ) bundledPlugin("Git4Idea") } diff --git a/backend/src/main/resources/dev.lain.claudejb.backend.xml b/backend/src/main/resources/dev.lain.claudejb.backend.xml index 5be2ea4a..62e0041f 100644 --- a/backend/src/main/resources/dev.lain.claudejb.backend.xml +++ b/backend/src/main/resources/dev.lain.claudejb.backend.xml @@ -7,7 +7,6 @@ - diff --git a/bookmarks/build.gradle.kts b/bookmarks/build.gradle.kts new file mode 100644 index 00000000..dadc7058 --- /dev/null +++ b/bookmarks/build.gradle.kts @@ -0,0 +1,9 @@ +dependencies { + intellijPlatform { + bundledModules( + "intellij.platform.backend", + "intellij.platform.bookmarks", + ) + } + implementation(project(":backend")) +} diff --git a/bookmarks/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProviderLineBookmarks.kt b/bookmarks/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProviderLineBookmarks.kt new file mode 100644 index 00000000..1d7d6b71 --- /dev/null +++ b/bookmarks/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProviderLineBookmarks.kt @@ -0,0 +1,11 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.ide.bookmark.Bookmark +import com.intellij.ide.bookmark.providers.LineBookmarkProvider +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile + +internal class ProviderLineBookmarks(private val project: Project) : LineBookmarks { + override fun create(file: VirtualFile, line: Int): Bookmark? = + LineBookmarkProvider.Util.find(project)?.createBookmark(file, line) +} diff --git a/bookmarks/src/main/resources/dev.lain.claudejb.bookmarks.xml b/bookmarks/src/main/resources/dev.lain.claudejb.bookmarks.xml new file mode 100644 index 00000000..af404812 --- /dev/null +++ b/bookmarks/src/main/resources/dev.lain.claudejb.bookmarks.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts index 7fb70996..0d2bee3b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,7 +20,7 @@ version = "6.5.0" val platformBuild = "262.8665.258" val serialization = "1.9.0" -val pluginModules = listOf("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal") +val pluginModules = listOf("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks") allprojects { plugins.withId("org.jetbrains.kotlin.jvm") { @@ -178,6 +178,7 @@ intellijPlatformTesting { } intellijPlatform { + projectName = "claude-code-native" pluginInstallationTarget = SplitModeAware.PluginInstallationTarget.BOTH pluginConfiguration { ideaVersion { diff --git a/settings.gradle.kts b/settings.gradle.kts index 8a6f8407..93beded3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,6 +1,6 @@ import org.jetbrains.intellij.platform.gradle.extensions.intellijPlatform -rootProject.name = "claude-code-native" +rootProject.name = "dev.lain.claudejb" pluginManagement { repositories { @@ -29,4 +29,4 @@ dependencyResolutionManagement { } } -include("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal") +include("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks") diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/BookmarkTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/BookmarkTools.kt index 7893a47e..c0a66db2 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/BookmarkTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/BookmarkTools.kt @@ -5,7 +5,6 @@ import com.intellij.ide.bookmark.BookmarkType import com.intellij.ide.bookmark.BookmarksManager import com.intellij.ide.bookmark.FileBookmark import com.intellij.ide.bookmark.LineBookmark -import com.intellij.ide.bookmark.providers.LineBookmarkProvider import com.intellij.ide.projectView.ProjectView import com.intellij.openapi.application.EDT import com.intellij.openapi.application.readAction @@ -91,7 +90,7 @@ internal class BookmarkTools(private val project: Project, private val reveal: R } private fun bookmark(file: VirtualFile, line: Int): Bookmark? = - LineBookmarkProvider.Util.find(project)?.createBookmark(file, line - 1) ?: manager().createBookmark(file) + LineBookmarks.of(project)?.create(file, line - 1) ?: manager().createBookmark(file) private suspend fun remove(args: ToolArgs): ToolResult { val path = args.string("path") diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LineBookmarks.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LineBookmarks.kt new file mode 100644 index 00000000..17e82372 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LineBookmarks.kt @@ -0,0 +1,14 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.ide.bookmark.Bookmark +import com.intellij.openapi.components.serviceOrNull +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile + +interface LineBookmarks { + fun create(file: VirtualFile, line: Int): Bookmark? + + companion object { + fun of(project: Project): LineBookmarks? = project.serviceOrNull() + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index ecac5950..88aa5407 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -143,5 +143,6 @@ + diff --git a/src/test/kotlin/dev/lain/claudejb/SourceLayout.kt b/src/test/kotlin/dev/lain/claudejb/SourceLayout.kt index b28ca8b9..2d3c7e84 100644 --- a/src/test/kotlin/dev/lain/claudejb/SourceLayout.kt +++ b/src/test/kotlin/dev/lain/claudejb/SourceLayout.kt @@ -15,9 +15,10 @@ internal object SourceLayout { "java" to "dev.lain.claudejb.java", "intellilang" to "dev.lain.claudejb.intellilang", "terminal" to "dev.lain.claudejb.terminal", + "bookmarks" to "dev.lain.claudejb.bookmarks", ) - private val MODULES = listOf("backend", "frontend", "shared", "git", "github", "java", "intellilang", "terminal", "") + private val MODULES = listOf("backend", "frontend", "shared", "git", "github", "java", "intellilang", "terminal", "bookmarks", "") fun roots(kind: String): List = MODULES.mapNotNull { dir("${prefix(it)}src/main/$kind") } From b2ac36cf85858bc99a8d3fcf86559b308f8847a3 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 22:56:39 +0200 Subject: [PATCH 120/155] style: apply the project formatter --- build.gradle.kts | 28 ++++++++++------ frontend/build.gradle.kts | 32 ++++++++++--------- .../lain/claudejb/frontend/window/ChatView.kt | 2 ++ .../controller/mcp/tools/code/ReadTools.kt | 2 +- .../lain/claudejb/rpc/backend/ChatApiImpl.kt | 2 +- .../claudejb/view/feed/TranscriptDeltas.kt | 3 ++ .../claudejb/view/window/ChatSnapshots.kt | 5 +++ .../lain/claudejb/view/window/PushStream.kt | 8 ++++- .../claudejb/bench/StdoutParseBenchTest.kt | 2 +- .../protocol/parse/ProtocolParserTest.kt | 1 + .../model/session/launch/HelperJarTest.kt | 7 +++- .../model/session/turn/StreamBufferTest.kt | 2 +- 12 files changed, 63 insertions(+), 31 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 0d2bee3b..4401a06b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -98,19 +98,27 @@ val npm = if (System.getProperty("os.name").startsWith("Windows")) "npm.cmd" els val fakeClaude: String = file("bin/fake-claude").absolutePath tasks { - val frontendTest = register("frontendTest") { - dependsOn(":frontend:compileWeb") - inputs.dir("src/test/frontend") - inputs.dir("frontend/src/main/resources/jcef") - inputs.dir("frontend/src/main/ts/jcef") - outputs.dir(layout.buildDirectory.dir("reports/frontend")) - environment("CI", "true") - commandLine(npm, "test") - } + val frontendTest = + register("frontendTest") { + dependsOn(":frontend:compileWeb") + inputs.dir("src/test/frontend") + inputs.dir("frontend/src/main/resources/jcef") + inputs.dir("frontend/src/main/ts/jcef") + outputs.dir(layout.buildDirectory.dir("reports/frontend")) + environment("CI", "true") + commandLine(npm, "test") + } check { dependsOn(frontendTest) } compileTestKotlin { friendPaths.from(pluginModules.map { project(":$it").layout.buildDirectory.dir("classes/kotlin/main") }) - friendPaths.from(pluginModules.map { project(":$it").layout.buildDirectory.dir("libs").map { libs -> libs.asFileTree } }) + friendPaths.from( + pluginModules.map { + project(":$it") + .layout.buildDirectory + .dir("libs") + .map { libs -> libs.asFileTree } + }, + ) } processResources { from(file("THIRD-PARTY-NOTICES.md")) { into("META-INF") } diff --git a/frontend/build.gradle.kts b/frontend/build.gradle.kts index 64e74ce7..6dfc3211 100644 --- a/frontend/build.gradle.kts +++ b/frontend/build.gradle.kts @@ -8,22 +8,24 @@ dependencies { implementation(project(":shared")) } -val npmInstall = tasks.register("npmInstall") { - workingDir(rootDir) - inputs.files(rootProject.file("package.json"), rootProject.file("package-lock.json")) - outputs.file(rootProject.file("node_modules/.package-lock.json")) - commandLine(npm, "ci") -} +val npmInstall = + tasks.register("npmInstall") { + workingDir(rootDir) + inputs.files(rootProject.file("package.json"), rootProject.file("package-lock.json")) + outputs.file(rootProject.file("node_modules/.package-lock.json")) + commandLine(npm, "ci") + } -val compileWeb = tasks.register("compileWeb") { - dependsOn(npmInstall) - workingDir(rootDir) - inputs.dir("src/main/ts/jcef") - inputs.file(rootProject.file("tsconfig.json")) - outputs.dir(layout.buildDirectory.dir("web")) - doFirst { delete(layout.buildDirectory.dir("web")) } - commandLine(npm, "run", "build") -} +val compileWeb = + tasks.register("compileWeb") { + dependsOn(npmInstall) + workingDir(rootDir) + inputs.dir("src/main/ts/jcef") + inputs.file(rootProject.file("tsconfig.json")) + outputs.dir(layout.buildDirectory.dir("web")) + doFirst { delete(layout.buildDirectory.dir("web")) } + commandLine(npm, "run", "build") + } tasks.processResources { from(compileWeb) diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatView.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatView.kt index b73d735d..491b1c6e 100644 --- a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatView.kt +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatView.kt @@ -47,7 +47,9 @@ internal class ChatView( val message = PageMessage.parse(json) when { message.type in FrontendChannel.clientMessages -> paste(message.type) + message.type == PageMessage.OPEN && PageMessage.isSecureLink(message.url) -> BrowserUtil.browse(message.url.trim()) + else -> { link.post(json) if (message.type == PageMessage.READY) pageReady() diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadTools.kt index 0a5694d1..76484024 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadTools.kt @@ -10,12 +10,12 @@ import dev.lain.claudejb.controller.mcp.Reveal import dev.lain.claudejb.model.mcp.Batch import dev.lain.claudejb.model.mcp.OutputBudget import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.TextWindow import dev.lain.claudejb.model.mcp.Tool import dev.lain.claudejb.model.mcp.ToolArgs import dev.lain.claudejb.model.mcp.ToolDomain import dev.lain.claudejb.model.mcp.ToolException import dev.lain.claudejb.model.mcp.ToolResult -import dev.lain.claudejb.model.mcp.TextWindow import dev.lain.claudejb.model.mcp.ToolSpec import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject diff --git a/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt b/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt index 0aa91f9d..d33c5546 100644 --- a/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt +++ b/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt @@ -13,9 +13,9 @@ import dev.lain.claudejb.view.window.ChatPresenter import dev.lain.claudejb.view.window.ChatRegistry import dev.lain.claudejb.view.window.PushSink import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.completeWith import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.completeWith import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.callbackFlow diff --git a/src/main/kotlin/dev/lain/claudejb/view/feed/TranscriptDeltas.kt b/src/main/kotlin/dev/lain/claudejb/view/feed/TranscriptDeltas.kt index 9baf93dd..585e3ed2 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/feed/TranscriptDeltas.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/feed/TranscriptDeltas.kt @@ -25,9 +25,12 @@ internal class TranscriptDeltas { val previous = sent[entry.id] when { previous == null || previous.rest != rest -> rows += row + text.contentEquals(previous.text) -> Unit + entry.toolState == ToolState.RUNNING && text.length > previous.text.length && text.startsWith(previous.text) -> appends += Append(entry.id, text.substring(previous.text.length)) + else -> rows += row } sent[entry.id] = Sent(StringBuilder(text), rest) diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/ChatSnapshots.kt b/src/main/kotlin/dev/lain/claudejb/view/window/ChatSnapshots.kt index 8dc9791e..ff8fb7f2 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/window/ChatSnapshots.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/window/ChatSnapshots.kt @@ -44,15 +44,20 @@ internal class ChatSnapshots(private val presenter: ChatPresenter) { private fun build(kind: Kind) { when (kind) { Kind.META -> send(kind, "meta", JcefState.metaJson(session)) + Kind.STATE -> send(kind, "state", JcefState.stateJson(session, presenter.feed.usage)) + Kind.SESSION -> send(kind, "session", sessionJson()) + Kind.MENU -> { send(kind, "settingsMenu", menuJson()) build(Kind.THEME) } Kind.THEME -> send(kind, "theme", themeJson()) + Kind.PERMISSIONS -> presenter.exec("permissions", permissionsJson()) + Kind.TABS -> presenter.agentTabs.render() } } diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/PushStream.kt b/src/main/kotlin/dev/lain/claudejb/view/window/PushStream.kt index a463a3db..9596080b 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/window/PushStream.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/window/PushStream.kt @@ -117,7 +117,13 @@ internal class PushStream( ) val TRANSCRIPT_METHODS: Set = setOf( - "clear", "batch", "append", "trimRows", "revealAgentTab", "revealTaskTab", "clearAgentSelection", + "clear", + "batch", + "append", + "trimRows", + "revealAgentTab", + "revealTaskTab", + "clearAgentSelection", ) fun kindOf(method: String): Kind = when (method) { diff --git a/src/test/kotlin/dev/lain/claudejb/bench/StdoutParseBenchTest.kt b/src/test/kotlin/dev/lain/claudejb/bench/StdoutParseBenchTest.kt index ab26ca9e..4319a66d 100644 --- a/src/test/kotlin/dev/lain/claudejb/bench/StdoutParseBenchTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/bench/StdoutParseBenchTest.kt @@ -41,7 +41,7 @@ class StdoutParseBenchTest { while (length < LINE_CHARS) append(BenchText.word(random)).append(' ') } return "{\"type\":\"assistant\",\"session_id\":\"bench\",\"message\":{\"id\":\"msg_bench\"," + - "\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"$text\"}]}}\n" + "\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"$text\"}]}}\n" } private companion object { diff --git a/src/test/kotlin/dev/lain/claudejb/model/protocol/parse/ProtocolParserTest.kt b/src/test/kotlin/dev/lain/claudejb/model/protocol/parse/ProtocolParserTest.kt index cb8e094c..c6389760 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/protocol/parse/ProtocolParserTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/protocol/parse/ProtocolParserTest.kt @@ -297,6 +297,7 @@ class ProtocolParserTest { ProtocolParser.parse("""{"type":"stream_event","event":{"type":"content_block_stop","index":0}}"""), ) } + @Test fun `result end of turn carries cost and session`() { val line = """{"type":"result","subtype":"success","result":"ok","total_cost_usd":0.12,"session_id":"s9"}""" diff --git a/src/test/kotlin/dev/lain/claudejb/model/session/launch/HelperJarTest.kt b/src/test/kotlin/dev/lain/claudejb/model/session/launch/HelperJarTest.kt index 7d3eb2ae..23ee8e2a 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/session/launch/HelperJarTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/session/launch/HelperJarTest.kt @@ -16,7 +16,12 @@ class HelperJarTest { lateinit var dir: Path private fun jar(name: String, vararg entries: String): File = File(dir.toFile(), name).apply { - JarOutputStream(outputStream()).use { out -> entries.forEach { out.putNextEntry(JarEntry(it)); out.closeEntry() } } + JarOutputStream(outputStream()).use { out -> + entries.forEach { + out.putNextEntry(JarEntry(it)) + out.closeEntry() + } + } } @Test diff --git a/src/test/kotlin/dev/lain/claudejb/model/session/turn/StreamBufferTest.kt b/src/test/kotlin/dev/lain/claudejb/model/session/turn/StreamBufferTest.kt index c231bad0..36e469bb 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/session/turn/StreamBufferTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/session/turn/StreamBufferTest.kt @@ -3,8 +3,8 @@ package dev.lain.claudejb.model.session.turn import dev.lain.claudejb.model.protocol.ClaudeEvent import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class StreamBufferTest { From 8580240d52297f49d99bcf1e55d45cd988d080b4 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 23:05:05 +0200 Subject: [PATCH 121/155] build(detekt): migrate the configuration to detekt 2.0 --- config/detekt/detekt.yml | 840 +------------------------------------- gradle/quality.gradle.kts | 4 +- 2 files changed, 15 insertions(+), 829 deletions(-) diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 06ea4ead..78182c79 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -1,869 +1,55 @@ -build: - maxIssues: 0 - excludeCorrectable: false - weights: - # complexity: 2 - # LongParameterList: 1 - # style: 1 - # comments: 1 - -config: - validation: true - warningsAsErrors: false - checkExhaustiveness: false - # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' - excludes: '' - -processors: - active: true - exclude: - - 'DetektProgressListener' - # - 'KtFileCountProcessor' - # - 'PackageCountProcessor' - # - 'ClassCountProcessor' - # - 'FunctionCountProcessor' - # - 'PropertyCountProcessor' - # - 'ProjectComplexityProcessor' - # - 'ProjectCognitiveComplexityProcessor' - # - 'ProjectLLOCProcessor' - # - 'ProjectCLOCProcessor' - # - 'ProjectLOCProcessor' - # - 'ProjectSLOCProcessor' - # - 'LicenseHeaderLoaderExtension' - -console-reports: - active: true - exclude: - - 'ProjectStatisticsReport' - - 'ComplexityReport' - - 'NotificationReport' - - 'FindingsReport' - - 'FileBasedFindingsReport' - # - 'LiteFindingsReport' - -output-reports: - active: true - exclude: - # - 'TxtOutputReport' - # - 'XmlOutputReport' - # - 'HtmlOutputReport' - # - 'MdOutputReport' - # - 'SarifOutputReport' - -comments: - active: true - AbsentOrWrongFileLicense: - active: false - licenseTemplateFile: 'license.template' - licenseTemplateIsRegex: false - CommentOverPrivateFunction: - active: false - CommentOverPrivateProperty: - active: false - DeprecatedBlockTag: - active: false - EndOfSentenceFormat: - active: false - endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' - KDocReferencesNonPublicProperty: - active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - OutdatedDocumentation: - active: false - matchTypeParameters: true - matchDeclarationsOrder: true - allowParamOnConstructorProperties: false - UndocumentedPublicClass: - active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - searchInNestedClass: true - searchInInnerClass: true - searchInInnerObject: true - searchInInnerInterface: true - searchInProtectedClass: false - ignoreDefaultCompanionObject: false - UndocumentedPublicFunction: - active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - searchProtectedFunction: false - UndocumentedPublicProperty: - active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - searchProtectedProperty: false - complexity: - active: true - CognitiveComplexMethod: - active: false - threshold: 15 - ComplexCondition: - active: true - threshold: 4 - ComplexInterface: - active: false - threshold: 10 - includeStaticDeclarations: false - includePrivateDeclarations: false - ignoreOverloaded: false - CyclomaticComplexMethod: - active: true - threshold: 15 - ignoreSingleWhenExpression: false - ignoreSimpleWhenEntries: false - ignoreNestingFunctions: false - nestingFunctions: - - 'also' - - 'apply' - - 'forEach' - - 'isNotNull' - - 'ifNull' - - 'let' - - 'run' - - 'use' - - 'with' - LabeledExpression: - active: false - ignoredLabels: [] LargeClass: - active: true - threshold: 600 + allowedLines: 599 LongMethod: - active: true - threshold: 60 + allowedLines: 59 LongParameterList: - active: true - functionThreshold: 6 - constructorThreshold: 7 - # ignoreDefaultParameters: true — count only the parameters a caller MUST supply. - # The problem this rule exists to catch is a call site nobody can read: a row of positional arguments whose - # meaning depends on counting commas. A parameter with a default is not part of that problem — it is absent - # from the call unless someone opts in, by name. Under this setting TranscriptEntry counts 3 (not 9), - # PermissionBroker 5 (not 9), and the launch options 4 (not 14), which matches what those call sites - # actually look like. The thresholds stay at detekt's defaults for the parameters that are mandatory, where - # a long list really does mean the callee is doing too much. ignoreDefaultParameters: true - ignoreDataClasses: true - ignoreAnnotatedParameter: [] - MethodOverloading: - active: false - threshold: 6 - NamedArguments: - active: false - threshold: 3 - ignoreArgumentsMatchingNames: false NestedBlockDepth: - active: true - threshold: 4 - NestedScopeFunctions: - active: false - threshold: 1 - functions: - - 'kotlin.apply' - - 'kotlin.run' - - 'kotlin.with' - - 'kotlin.let' - - 'kotlin.also' - ReplaceSafeCallChainWithRun: - active: false - StringLiteralDuplication: - active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - threshold: 3 - ignoreAnnotation: true - excludeStringsWithLessThan5Characters: true - ignoreStringsRegex: '$^' + allowedDepth: 3 TooManyFunctions: - active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - # Objects here are NAMESPACES, not god-objects: ControlProtocol is one named builder per control message - # (34 of them, because the protocol has 34), SensitiveGuard is a set of pure predicates, ControlProtocol's - # sibling parsers likewise. Splitting a module into ControlProtocolA/ControlProtocolB to satisfy a counter - # makes it strictly harder to find things. Classes are where a high count is a real signal, because a class - # has state and each method is another thing that can touch it — so classes keep a much tighter bound. - # ClaudeSession, at 47, is exactly the case the rule is for, and it is being fixed rather than exempted. - thresholdInFiles: 40 - thresholdInClasses: 20 - thresholdInInterfaces: 11 - thresholdInObjects: 40 - thresholdInEnums: 11 - ignoreDeprecated: false - # ignorePrivate: true. With the default (count everything) this rule is in DIRECT CONFLICT with - # CyclomaticComplexMethod and LongMethod: the prescribed fix for a long method is to break it into named - # private helpers, and every helper you extract pushes the class closer to failing this rule. Optimising for - # both at once means one inlined 80-line method — which is the thing we were trying to avoid. - # What the rule claims to measure is "does this type do too much", and the honest proxy for that is its - # PUBLIC surface: how many things can call into it. A parser object with 4 entry points and 15 private - # helpers is a well-decomposed one, not a god object. Counting only the public API keeps the rule pointed at - # ClaudeSession-shaped problems (many callers, many responsibilities) instead of at good decomposition. + allowedFunctionsPerFile: 39 + allowedFunctionsPerClass: 19 + allowedFunctionsPerInterface: 10 + allowedFunctionsPerObject: 39 + allowedFunctionsPerEnum: 10 ignorePrivate: true - ignoreOverridden: false - ignoreAnnotatedFunctions: [] - -coroutines: - active: true - GlobalCoroutineUsage: - active: false - InjectDispatcher: - active: true - dispatcherNames: - - 'IO' - - 'Default' - - 'Unconfined' - RedundantSuspendModifier: - active: true - SleepInsteadOfDelay: - active: true - SuspendFunSwallowedCancellation: - active: false - SuspendFunWithCoroutineScopeReceiver: - active: false - SuspendFunWithFlowReturnType: - active: true - -empty-blocks: - active: true - EmptyCatchBlock: - active: true - allowedExceptionNameRegex: '_|(ignore|expected).*' - EmptyClassBlock: - active: true - EmptyDefaultConstructor: - active: true - EmptyDoWhileBlock: - active: true - EmptyElseBlock: - active: true - EmptyFinallyBlock: - active: true - EmptyForBlock: - active: true - EmptyFunctionBlock: - active: true - ignoreOverridden: false - EmptyIfBlock: - active: true - EmptyInitBlock: - active: true - EmptyKtFile: - active: true - EmptySecondaryConstructor: - active: true - EmptyTryBlock: - active: true - EmptyWhenBlock: - active: true - EmptyWhileBlock: - active: true - -exceptions: - active: true - ExceptionRaisedInUnexpectedLocation: - active: true - methodNames: - - 'equals' - - 'finalize' - - 'hashCode' - - 'toString' - InstanceOfCheckForException: - active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - NotImplementedDeclaration: - active: false - ObjectExtendsThrowable: - active: false - PrintStackTrace: - active: true - RethrowCaughtException: - active: true - ReturnFromFinally: - active: true - ignoreLabeled: false - SwallowedException: - active: true - ignoredExceptionTypes: - - 'InterruptedException' - - 'MalformedURLException' - - 'NumberFormatException' - - 'ParseException' - allowedExceptionNameRegex: '_|(ignore|expected).*' - ThrowingExceptionFromFinally: - active: true - ThrowingExceptionInMain: - active: false - ThrowingExceptionsWithoutMessageOrCause: - active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - exceptions: - - 'ArrayIndexOutOfBoundsException' - - 'Exception' - - 'IllegalArgumentException' - - 'IllegalMonitorStateException' - - 'IllegalStateException' - - 'IndexOutOfBoundsException' - - 'NullPointerException' - - 'RuntimeException' - - 'Throwable' - ThrowingNewInstanceOfSameException: - active: true - TooGenericExceptionCaught: - active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - exceptionNames: - - 'ArrayIndexOutOfBoundsException' - - 'Error' - - 'Exception' - - 'IllegalMonitorStateException' - - 'IndexOutOfBoundsException' - - 'NullPointerException' - - 'RuntimeException' - - 'Throwable' - allowedExceptionNameRegex: '_|(ignore|expected).*' - TooGenericExceptionThrown: - active: true - exceptionNames: - - 'Error' - - 'Exception' - - 'RuntimeException' - - 'Throwable' - -naming: - active: true - BooleanPropertyNaming: - active: false - allowedPattern: '^(is|has|are)' - ClassNaming: - active: true - classPattern: '[A-Z][a-zA-Z0-9]*' - ConstructorParameterNaming: - active: true - parameterPattern: '[a-z][A-Za-z0-9]*' - privateParameterPattern: '[a-z][A-Za-z0-9]*' - excludeClassPattern: '$^' - EnumNaming: - active: true - enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' - ForbiddenClassName: - active: false - forbiddenName: [] - FunctionMaxLength: - active: false - maximumFunctionNameLength: 30 - FunctionMinLength: - active: false - minimumFunctionNameLength: 3 - FunctionNaming: - active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - functionPattern: '[a-z][a-zA-Z0-9]*' - excludeClassPattern: '$^' - FunctionParameterNaming: - active: true - parameterPattern: '[a-z][A-Za-z0-9]*' - excludeClassPattern: '$^' - InvalidPackageDeclaration: - active: true - rootPackage: '' - requireRootInDeclaration: false - LambdaParameterNaming: - active: false - parameterPattern: '[a-z][A-Za-z0-9]*|_' - MatchingDeclarationName: - active: true - mustBeFirst: true - multiplatformTargets: - - 'ios' - - 'android' - - 'js' - - 'jvm' - - 'native' - - 'iosArm64' - - 'iosX64' - - 'macosX64' - - 'mingwX64' - - 'linuxX64' - MemberNameEqualsClassName: - active: true - ignoreOverridden: true - NoNameShadowing: - active: true - NonBooleanPropertyPrefixedWithIs: - active: false - ObjectPropertyNaming: - active: true - constantPattern: '[A-Za-z][_A-Za-z0-9]*' - propertyPattern: '[A-Za-z][_A-Za-z0-9]*' - privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' - PackageNaming: - active: true - packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' - TopLevelPropertyNaming: - active: true - constantPattern: '[A-Z][_A-Z0-9]*' - propertyPattern: '[A-Za-z][_A-Za-z0-9]*' - privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' - VariableMaxLength: - active: false - maximumVariableNameLength: 64 - VariableMinLength: - active: false - minimumVariableNameLength: 1 - VariableNaming: - active: true - variablePattern: '[a-z][A-Za-z0-9]*' - privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' - excludeClassPattern: '$^' - -performance: - active: true - ArrayPrimitive: - active: true - CouldBeSequence: - active: false - threshold: 3 - ForEachOnRange: - active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - SpreadOperator: - active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - UnnecessaryPartOfBinaryExpression: - active: false - UnnecessaryTemporaryInstantiation: - active: true potential-bugs: - active: true - AvoidReferentialEquality: - active: true - forbiddenTypePatterns: - - 'kotlin.String' - CastNullableToNonNullableType: - active: false - CastToNullableType: - active: false - Deprecation: - active: false - DontDowncastCollectionTypes: - active: false - DoubleMutabilityForCollection: - active: true - mutableTypes: - - 'kotlin.collections.MutableList' - - 'kotlin.collections.MutableMap' - - 'kotlin.collections.MutableSet' - - 'java.util.ArrayList' - - 'java.util.LinkedHashSet' - - 'java.util.HashSet' - - 'java.util.LinkedHashMap' - - 'java.util.HashMap' - ElseCaseInsteadOfExhaustiveWhen: - active: false - ignoredSubjectTypes: [] - EqualsAlwaysReturnsTrueOrFalse: - active: true - EqualsWithHashCodeExist: - active: true - ExitOutsideMain: - active: false - ExplicitGarbageCollectionCall: - active: true - HasPlatformType: - active: true IgnoredReturnValue: - active: true - restrictToConfig: true - returnValueAnnotations: - - 'CheckResult' - - '*.CheckResult' - - 'CheckReturnValue' - - '*.CheckReturnValue' - ignoreReturnValueAnnotations: - - 'CanIgnoreReturnValue' - - '*.CanIgnoreReturnValue' returnValueTypes: - 'kotlin.sequences.Sequence' - 'kotlinx.coroutines.flow.*Flow' - 'java.util.stream.*Stream' - ignoreFunctionCall: [] - ImplicitDefaultLocale: - active: true - ImplicitUnitReturnType: - active: false - allowExplicitReturnType: true - InvalidRange: - active: true - IteratorHasNextCallsNextMethod: - active: true - IteratorNotThrowingNoSuchElementException: - active: true - LateinitUsage: - active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - ignoreOnClassesPattern: '' - MapGetWithNotNullAssertionOperator: - active: true - MissingPackageDeclaration: - active: false - excludes: ['**/*.kts'] - NullCheckOnMutableProperty: - active: false - NullableToStringCall: - active: false - PropertyUsedBeforeDeclaration: - active: false - UnconditionalJumpStatementInLoop: - active: false - UnnecessaryNotNullCheck: - active: false - UnnecessaryNotNullOperator: - active: true - UnnecessarySafeCall: - active: true - UnreachableCatchBlock: - active: true - UnreachableCode: - active: true - UnsafeCallOnNullableType: - active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - UnsafeCast: - active: true - UnusedUnaryOperator: - active: true - UselessPostfixExpression: - active: true - WrongEqualsTypeParameter: - active: true style: - active: true - AlsoCouldBeApply: - active: false - BracesOnIfStatements: - active: false - singleLine: 'never' - multiLine: 'always' - BracesOnWhenStatements: - active: false - singleLine: 'necessary' - multiLine: 'consistent' - CanBeNonNullable: - active: false - CascadingCallWrapping: - active: false - includeElvis: true - ClassOrdering: - active: false - CollapsibleIfStatements: - active: false - DataClassContainsFunctions: - active: false - conversionFunctionPrefix: - - 'to' - allowOperators: false - DataClassShouldBeImmutable: - active: false - DestructuringDeclarationWithTooManyEntries: - active: true - maxDestructuringEntries: 3 - DoubleNegativeLambda: - active: false - negativeFunctions: - - reason: 'Use `takeIf` instead.' - value: 'takeUnless' - - reason: 'Use `all` instead.' - value: 'none' - negativeFunctionNameParts: - - 'not' - - 'non' - EqualsNullCall: - active: true - EqualsOnSignatureLine: - active: false - ExplicitCollectionElementAccessMethod: - active: false - ExplicitItLambdaParameter: - active: true - ExpressionBodySyntax: - active: false - includeLineWrapping: false - ForbiddenAnnotation: - active: false - annotations: - - reason: 'it is a java annotation. Use `Suppress` instead.' - value: 'java.lang.SuppressWarnings' - - reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.' - value: 'java.lang.Deprecated' - - reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.' - value: 'java.lang.annotation.Documented' - - reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.' - value: 'java.lang.annotation.Target' - - reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.' - value: 'java.lang.annotation.Retention' - - reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.' - value: 'java.lang.annotation.Repeatable' - - reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265' - value: 'java.lang.annotation.Inherited' - ForbiddenComment: - active: true - comments: - - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' - value: 'FIXME:' - - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' - value: 'STOPSHIP:' - - reason: 'Forbidden TODO todo marker in comment, please do the changes.' - value: 'TODO:' - allowedPatterns: '' - ForbiddenImport: - active: false - imports: [] - forbiddenPatterns: '' - ForbiddenMethodCall: - active: false - methods: - - reason: 'print does not allow you to configure the output stream. Use a logger instead.' - value: 'kotlin.io.print' - - reason: 'println does not allow you to configure the output stream. Use a logger instead.' - value: 'kotlin.io.println' - ForbiddenSuppress: - active: false - rules: [] - ForbiddenVoid: - active: true - ignoreOverridden: false - ignoreUsageInGenerics: false - FunctionOnlyReturningConstant: - active: true - ignoreOverridableFunction: true - ignoreActualFunction: true - excludedFunctions: [] LoopWithTooManyJumpStatements: - active: true - # 2 rather than 1. The parsers and the PTY/stdout readers legitimately need "skip this item" plus - # "stop reading" in the same loop; forcing that into one jump means a flag variable and a wider - # condition, which is harder to follow, not easier. maxJumpCount: 2 MagicNumber: - active: true - # ChatTheme.kt and JcefTheme.kt ARE the colour palette. A 0xRRGGBB literal inside a property named - # DIFF_ADDED_BG is not an unexplained number — it is the definition of that name, and wrapping it in - # DIFF_ADDED_BG_LIGHT = 0xE6FFEC adds a line and explains nothing. The behavioural constants in those two - # files (timer period, hue step, luminance coefficients, surface nudge) are named anyway, because those - # genuinely encode decisions someone might revisit — the exclusion buys silence on the palette, not on them. - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts', '**/view/feed/ChatTheme.kt', '**/view/payload/JcefTheme.kt'] + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts', '**/frontend/theme/ChatTheme.kt', '**/frontend/theme/JcefTheme.kt'] ignoreNumbers: - '-1' - '0' - '1' - '2' - '3' - - '100' - - '1000' - # The 4-px spacing grid this UI is laid out on. A number earns a name when the name carries information - # the number does not: `86400` hides "a day" and gets one; the `8` in `JBUI.Borders.empty(8, 8, 4, 8)` - # hides nothing, because the call's own parameter order (top, left, bottom, right) already says what each - # position means. Renaming those to PAD_M makes the line longer and tells the reader strictly less. - '4' - '6' - '8' - '10' - '12' - '16' - # A named constant is worth it when the number encodes a DECISION someone might revisit. It is noise - # when the number is the arithmetic itself: `n / 1000.0` to render "1.5k", `* 100` for a percentage, - # a UI inset of 8. These exclusions keep the rule pointed at thresholds, timeouts and limits — where - # an unexplained literal really is a question with no answer in the code. - ignoreHashCodeFunction: true + - '100' + - '1000' ignorePropertyDeclaration: false ignoreLocalVariableDeclaration: false - ignoreConstantDeclaration: true - ignoreCompanionObjectPropertyDeclaration: true - ignoreAnnotation: false - ignoreNamedArgument: true - ignoreEnums: false - ignoreRanges: false - ignoreExtensionFunctions: true - MandatoryBracesLoops: - active: false - MaxChainedCallsOnSameLine: - active: false - maxChainedCalls: 5 MaxLineLength: - active: true - # Tests are excluded, and the reason is specific rather than a blanket pass. 26 of the 28 over-length lines - # in the test tree are single-line raw-string PROTOCOL FIXTURES — one NDJSON frame each, exactly as the - # binary emits it. `excludeRawStrings` below is meant for precisely this, but it only skips lines *inside* a - # multi-line raw string, so the one-line form (`val line = """{...}"""`) still trips the rule. Splitting - # those fixtures across concatenated pieces would make them harder to read and easier to typo, and would - # misrepresent the thing under test: the protocol is one line per frame. The two genuine over-length code - # lines in tests were wrapped rather than left to hide behind this. excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - # 140, matching Spotless/ktlint and the width this codebase has been written at since the start. - # Reflowing 13k lines to detekt's 120 default would produce a huge diff, poison `git blame`, and buy - # nothing: the argument for a limit is readability, and these lines are already readable. maxLineLength: 140 - excludePackageStatements: true - excludeImportStatements: true - excludeCommentStatements: false - excludeRawStrings: true - MayBeConst: - active: true - ModifierOrder: - active: true - MultilineLambdaItParameter: - active: false - MultilineRawStringIndentation: - active: false - indentSize: 4 - trimmingMethods: - - 'trimIndent' - - 'trimMargin' - NestedClassesVisibility: - active: true - NewLineAtEndOfFile: - active: true - NoTabs: - active: false - NullableBooleanCheck: - active: false - ObjectLiteralToLambda: - active: true - OptionalAbstractKeyword: - active: true - OptionalUnit: - active: false - PreferToOverPairSyntax: - active: false - ProtectedMemberInFinalClass: - active: true - RedundantExplicitType: - active: false - RedundantHigherOrderMapUsage: - active: true - RedundantVisibilityModifierRule: - active: false ReturnCount: - active: true - # excludeGuardClauses: true, and this is the important one. The default (count every return, max 2) - # is in direct conflict with a stated project value — CLAUDE.md asks for "guard clauses frente a - # anidación profunda" — and with idiomatic Kotlin. Early returns that validate and bail are the thing - # that keeps SensitiveGuard and the protocol parsers flat and readable; penalising them would push the - # code toward nested ifs, which is worse by every measure the rule claims to care about. - # - # max: 6, because `excludeGuardClauses` is narrower than the concept it is named after. detekt only treats - # returns as guard clauses while they form an unbroken PREFIX of the function body; the first `val` binding - # ends the prefix and every later early-out is counted in full. So the shape this codebase actually uses — - # val hits = lookUp(name) - # if (hits.size != 1) return null - # val psi = hits.first() as? PsiElement ?: return null - # … - # scores 5 despite being guard clauses end to end. 6 is the observed ceiling for that shape here; a - # seventh exit is a genuine signal that the function is doing two jobs. max: 6 - excludedFunctions: - - 'equals' - excludeLabeled: false - excludeReturnFromLambda: true excludeGuardClauses: true - SafeCast: - active: true - SerialVersionUIDInSerializableClass: - active: true - SpacingBetweenPackageAndImports: - active: false - StringShouldBeRawString: - active: false - maxEscapedCharacterCount: 2 - ignoredCharacters: [] - ThrowsCount: - active: true - max: 2 - excludeGuardClauses: false - TrailingWhitespace: - active: false - TrimMultilineRawString: - active: false - trimmingMethods: - - 'trimIndent' - - 'trimMargin' - UnderscoresInNumericLiterals: - active: false - acceptableLength: 4 - allowNonStandardGrouping: false - UnnecessaryAbstractClass: - active: true - UnnecessaryAnnotationUseSiteTarget: - active: false - UnnecessaryApply: - active: true - UnnecessaryBackticks: - active: false - UnnecessaryBracesAroundTrailingLambda: - active: false - UnnecessaryFilter: - active: true - UnnecessaryInheritance: - active: true - UnnecessaryInnerClass: - active: false - UnnecessaryLet: - active: false - UnnecessaryParentheses: - active: false - allowForUnclearPrecedence: false - UntilInsteadOfRangeTo: - active: false - UnusedImports: - active: false - UnusedParameter: - active: true - allowedNames: 'ignored|expected' - UnusedPrivateClass: - active: true - UnusedPrivateMember: - active: true - allowedNames: '' UnusedPrivateProperty: - active: true allowedNames: '_|ignored|expected|serialVersionUID' - UseAnyOrNoneInsteadOfFind: - active: true - UseArrayLiteralsInAnnotations: - active: true - UseCheckNotNull: - active: true - UseCheckOrError: - active: true - UseDataClass: - active: false - allowVars: false - UseEmptyCounterpart: - active: false - UseIfEmptyOrIfBlank: - active: false - UseIfInsteadOfWhen: - active: false - ignoreWhenContainingVariableDeclaration: false - UseIsNullOrEmpty: - active: true - UseLet: - active: false - UseOrEmpty: - active: true - UseRequire: - active: true - UseRequireNotNull: - active: true - UseSumOfInsteadOfFlatMapSize: - active: false - UselessCallOnNotNull: - active: true - UtilityClassWithPublicConstructor: - active: true - VarCouldBeVal: - active: true - ignoreLateinitVar: false - WildcardImport: - active: true - excludeImports: - - 'java.util.*' + UnusedVariable: + allowedNames: '_|ignored|expected|serialVersionUID' diff --git a/gradle/quality.gradle.kts b/gradle/quality.gradle.kts index 1703dfb0..e252bdc6 100644 --- a/gradle/quality.gradle.kts +++ b/gradle/quality.gradle.kts @@ -3,13 +3,13 @@ import dev.detekt.gradle.Detekt import dev.detekt.gradle.DetektCreateBaselineTask import dev.detekt.gradle.extensions.DetektExtension -val pluginModules = listOf("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal") +val pluginModules = listOf("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks") configure { buildUponDefaultConfig.set(true) config.setFrom(files("config/detekt/detekt.yml")) baseline.set(file("config/detekt/baseline.xml")) - source.setFrom(pluginModules.map { "$it/src/main/kotlin" } + "src/test/kotlin") + source.setFrom(pluginModules.map { "$it/src/main/kotlin" } + "src/main/kotlin" + "src/test/kotlin") parallel.set(true) } From 91057e8b559ff17899274f4741fa1cde5a10cb01 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 23:16:33 +0200 Subject: [PATCH 122/155] refactor(mcp): catch frame and batch-item failures without a generic catch --- .../claudejb/controller/mcp/ServerEndpoint.kt | 44 ++++++++++--------- .../dev/lain/claudejb/model/mcp/BatchRows.kt | 19 ++++---- 2 files changed, 32 insertions(+), 31 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/ServerEndpoint.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/ServerEndpoint.kt index 5eb33f2b..7dd4ef7f 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/ServerEndpoint.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/ServerEndpoint.kt @@ -71,10 +71,11 @@ internal class ServerEndpoint( private suspend fun attend(client: SocketChannel) { try { - if (admit()) Connection(client).serve() - } catch (e: Exception) { - rethrowIfCancelled(e) - log.warn("${server.key}: a connection failed", e) + runCatching { if (admit()) Connection(client).serve() }.onFailure { cause -> + if (cause !is Exception) throw cause + rethrowIfCancelled(cause) + log.warn("${server.key}: a connection failed", cause) + } } finally { release(client) } @@ -109,15 +110,16 @@ internal class ServerEndpoint( send(JsonRpc.error(null, JsonRpc.PARSE_ERROR, "Parse error")) return } - try { + runCatching { when (val parsed = JsonRpc.parse(message)) { is JsonRpc.Request -> request(parsed, message) is JsonRpc.Notification -> notification(parsed, message) else -> mcp.handle(message)?.let { send(it) } } - } catch (e: Exception) { - rethrowIfCancelled(e) - log.warn("${server.key}: a frame could not be handled", e) + }.onFailure { cause -> + if (cause !is Exception) throw cause + rethrowIfCancelled(cause) + log.warn("${server.key}: a frame could not be handled", cause) idOf(message)?.let { send(JsonRpc.error(it, JsonRpc.INTERNAL_ERROR, FAILED)) } } } @@ -144,13 +146,13 @@ internal class ServerEndpoint( job.start() } - private suspend fun answer(id: JsonElement, message: JsonElement): JsonObject? = try { - mcp.handle(message) - } catch (e: Exception) { - rethrowIfCancelled(e) - log.warn("${server.key}: a request failed", e) - JsonRpc.error(id, JsonRpc.INTERNAL_ERROR, FAILED) - } + private suspend fun answer(id: JsonElement, message: JsonElement): JsonObject? = + runCatching { mcp.handle(message) }.getOrElse { cause -> + if (cause !is Exception) throw cause + rethrowIfCancelled(cause) + log.warn("${server.key}: a request failed", cause) + JsonRpc.error(id, JsonRpc.INTERNAL_ERROR, FAILED) + } private suspend fun notification(notification: JsonRpc.Notification, message: JsonElement) { if (!authorized(notification.params)) return @@ -171,12 +173,12 @@ internal class ServerEndpoint( writing.withLock { withContext(io) { runCatching { Frames.write(output, text) } } } } - private fun encoded(reply: JsonObject): String = try { - Toon.encode(reply) - } catch (e: RuntimeException) { - log.warn("${server.key}: a reply could not be encoded", e) - Toon.encode(JsonRpc.error(reply["id"], JsonRpc.INTERNAL_ERROR, UNENCODABLE)) - } + private fun encoded(reply: JsonObject): String = + runCatching { Toon.encode(reply) }.getOrElse { cause -> + if (cause !is RuntimeException) throw cause + log.warn("${server.key}: a reply could not be encoded", cause) + Toon.encode(JsonRpc.error(reply["id"], JsonRpc.INTERNAL_ERROR, UNENCODABLE)) + } } companion object { diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/BatchRows.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/BatchRows.kt index 32a58a00..cfdaf557 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/mcp/BatchRows.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/BatchRows.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -65,22 +66,20 @@ internal object BatchRows { private suspend fun row(item: ToolArgs, identity: String, one: suspend (ToolArgs) -> JsonObject): JsonObject { val id = (item.json[identity] as? JsonPrimitive)?.takeUnless { it is JsonNull }?.content - val result = try { - one(item) - } catch (e: Exception) { - rethrowIfCancelled(e) - return failed(identity, id, e) - } catch (e: LinkageError) { - return failed(identity, id, e) + val result = runCatching { one(item) }.getOrElse { cause -> + if (cause !is Exception && cause !is LinkageError) throw cause + rethrowIfCancelled(cause) + return failed(identity, id, cause) } return buildJsonObject { if (id != null) put(identity, id) - result.forEach { (key, value) -> - if ((key != identity || id == null) && !(key == TRUNCATED && value == FALSE)) put(key, value) - } + result.forEach { (key, value) -> if (kept(key, value, identity, id)) put(key, value) } } } + private fun kept(key: String, value: JsonElement, identity: String, id: String?): Boolean = + (key != identity || id == null) && !(key == TRUNCATED && value == FALSE) + private fun failed(identity: String, id: String?, cause: Throwable): JsonObject = buildJsonObject { put(identity, id ?: "") put(ERROR, if (cause is ToolException) cause.message ?: "failed" else "${cause::class.simpleName}: ${cause.message}") From 429cd4cbf6b9fcd3cadc15b7cd212b07435dddd2 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 23:16:39 +0200 Subject: [PATCH 123/155] refactor(rpc): catch chat host failures without a generic catch --- .../lain/claudejb/frontend/rpc/ChatClient.kt | 31 ++++++++----------- .../lain/claudejb/frontend/rpc/ChatLink.kt | 9 +++--- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatClient.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatClient.kt index d4227640..944dd1ad 100644 --- a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatClient.kt +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatClient.kt @@ -35,12 +35,9 @@ class ChatClient(val project: Project, private val scope: CoroutineScope) { fun select(chatId: ChatId) { scope.launch { - try { - ChatApi.getInstance().select(project.projectId(), chatId) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - log.warn("Claude Code could not tell the host which chat is on screen", e) + runCatching { ChatApi.getInstance().select(project.projectId(), chatId) }.onFailure { cause -> + if (cause !is Exception || cause is CancellationException) throw cause + log.warn("Claude Code could not tell the host which chat is on screen", cause) } } } @@ -53,18 +50,16 @@ class ChatClient(val project: Project, private val scope: CoroutineScope) { ): ChatLink = ChatLink(scope, project.projectId(), chatId, onPush, isWebReady).also { Disposer.register(parent, it) } private suspend fun reachable(): Boolean = - try { - withTimeout(CONNECT_TIMEOUT_MS) { ChatApi.getInstance().chats(project.projectId()) } - true - } catch (_: TimeoutCancellationException) { - log.warn("Claude Code chat host did not answer within ${CONNECT_TIMEOUT_MS}ms") - false - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - log.warn("Claude Code chat host is not reachable from this client", e) - false - } + runCatching { withTimeout(CONNECT_TIMEOUT_MS) { ChatApi.getInstance().chats(project.projectId()) } } + .map { true } + .getOrElse { cause -> + when (cause) { + is TimeoutCancellationException -> log.warn("Claude Code chat host did not answer within ${CONNECT_TIMEOUT_MS}ms") + !is Exception, is CancellationException -> throw cause + else -> log.warn("Claude Code chat host is not reachable from this client", cause) + } + false + } private suspend fun follow(listener: ChatListener) { val projectId = project.projectId() diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatLink.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatLink.kt index 1fcf3da0..b349ee6b 100644 --- a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatLink.kt +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatLink.kt @@ -53,16 +53,15 @@ class ChatLink internal constructor( } private suspend fun deliver(item: Outgoing) { - try { + runCatching { val api = ChatApi.getInstance() when (item) { is Outgoing.Post -> api.post(projectId, chatId, item.json) Outgoing.Ready -> api.ready(projectId, chatId) } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - log.warn("Claude Code could not hand a chat page message to the host", e) + }.onFailure { cause -> + if (cause !is Exception || cause is CancellationException) throw cause + log.warn("Claude Code could not hand a chat page message to the host", cause) } } From dfd75b37173cef57bf655fddd812280348a160de Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 23:16:40 +0200 Subject: [PATCH 124/155] refactor(language): resolve the injection target in its own step --- .../controller/mcp/tools/code/IntelliLangGateway.kt | 8 ++++++-- .../controller/mcp/tools/code/LanguageInjection.kt | 3 ++- .../controller/mcp/tools/code/LanguageTools.kt | 13 +++++++------ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/intellilang/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/IntelliLangGateway.kt b/intellilang/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/IntelliLangGateway.kt index 0c25f3be..96f6f531 100644 --- a/intellilang/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/IntelliLangGateway.kt +++ b/intellilang/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/IntelliLangGateway.kt @@ -11,8 +11,7 @@ import org.intellij.plugins.intelliLang.inject.TemporaryPlacesRegistry internal class IntelliLangGateway(private val project: Project) : LanguageInjection { override suspend fun inject(host: PsiLanguageInjectionHost, languageId: String): Boolean { - if (!InstalledPlugins.isEnabled(PLUGIN_ID)) throw ToolException(LanguageInjection.MISSING) - val language = InjectedLanguage.create(languageId) ?: throw ToolException("IntelliLang knows no language with id $languageId") + val language = injectable(languageId) return writeCommandAction(project, "Claude: inject $languageId") { runCatching { TemporaryPlacesRegistry.getInstance(project).addHostWithUndo(host, language) } .getOrElse { throw ToolException("IntelliLang refused the injection: ${it.message}", it) } @@ -20,6 +19,11 @@ internal class IntelliLangGateway(private val project: Project) : LanguageInject } } + private fun injectable(languageId: String): InjectedLanguage { + if (!InstalledPlugins.isEnabled(PLUGIN_ID)) throw ToolException(LanguageInjection.MISSING) + return InjectedLanguage.create(languageId) ?: throw ToolException("IntelliLang knows no language with id $languageId") + } + private companion object { const val PLUGIN_ID = "org.intellij.intelliLang" } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageInjection.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageInjection.kt index d074532f..a474fe6f 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageInjection.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageInjection.kt @@ -7,6 +7,7 @@ interface LanguageInjection { suspend fun inject(host: PsiLanguageInjectionHost, languageId: String): Boolean companion object { - const val MISSING = "the IntelliLang plugin (org.intellij.intelliLang) is not installed or is disabled, so nothing injects languages" + const val MISSING = + "the IntelliLang plugin (org.intellij.intelliLang) is not installed or is disabled, so nothing injects languages" } } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageTools.kt index 103234b5..90705c03 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageTools.kt @@ -16,7 +16,6 @@ import dev.lain.claudejb.model.mcp.ToolDomain import dev.lain.claudejb.model.mcp.ToolException import dev.lain.claudejb.model.mcp.ToolResult import dev.lain.claudejb.model.mcp.ToolSpec -import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -63,11 +62,7 @@ internal class LanguageTools(private val project: Project, private val actions: private suspend fun injectAt(args: ToolArgs): ToolResult { val languageId = args.string("language") val language = Language.findLanguageByID(languageId) ?: throw ToolException("this IDE has no language with id $languageId") - val host = smartReadAction(project) { - val position = Locations.locate(project, args) - PsiTreeUtil.getParentOfType(position.psiFile.findElementAt(position.offset), PsiLanguageInjectionHost::class.java, false) - ?: throw ToolException("nothing at that position can host an injection (a string literal can)") - } + val host = injectionHost(args) val injection = project.serviceOrNull() ?: throw ToolException(LanguageInjection.MISSING) val injected = injection.inject(host, language.id) return ToolResult.toon( @@ -80,6 +75,12 @@ internal class LanguageTools(private val project: Project, private val actions: ) } + private suspend fun injectionHost(args: ToolArgs): PsiLanguageInjectionHost = smartReadAction(project) { + val position = Locations.locate(project, args) + PsiTreeUtil.getParentOfType(position.psiFile.findElementAt(position.offset), PsiLanguageInjectionHost::class.java, false) + ?: throw ToolException("nothing at that position can host an injection (a string literal can)") + } + private suspend fun docs(args: ToolArgs): ToolResult { val target = TargetContext.target(args) if (target.path == null) throw ToolException("docs needs path, line and column") From 39d4cdbd704f376c787458ef09b71b2e01c4b78c Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 23:16:42 +0200 Subject: [PATCH 125/155] refactor(vcs): split the git write gateway into working-copy and repository interfaces --- .../claudejb/controller/mcp/tools/vcs/ChangesTools.kt | 2 +- .../claudejb/controller/mcp/tools/vcs/GitWriteTools.kt | 2 +- .../lain/claudejb/controller/mcp/tools/vcs/GitWrites.kt | 8 +++++++- .../claudejb/controller/mcp/tools/vcs/HistoryTools.kt | 2 +- .../lain/claudejb/controller/mcp/tools/vcs/LogOpsTools.kt | 2 +- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ChangesTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ChangesTools.kt index 810d0a0a..b729a300 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ChangesTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/ChangesTools.kt @@ -42,7 +42,7 @@ internal class ChangesTools( private val io: CoroutineDispatcher = Dispatchers.IO, ) { - private val git: GitWrites by lazy { GitWrites.of(project) } + private val git: GitRepositoryOps by lazy { GitWrites.of(project) } fun domain(): ToolDomain = ToolDomain( "changes", diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitWriteTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitWriteTools.kt index 06bdfe11..9fe25cb8 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitWriteTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitWriteTools.kt @@ -26,7 +26,7 @@ internal class GitWriteTools( private val io: CoroutineDispatcher = Dispatchers.IO, ) { - private val git: GitWrites by lazy { GitWrites.of(project) } + private val git: GitWorkingCopy by lazy { GitWrites.of(project) } private val history: GitHistoryService get() = project.service() diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitWrites.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitWrites.kt index eda02968..0c57d6d1 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitWrites.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/GitWrites.kt @@ -5,7 +5,7 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.vcs.FilePath import dev.lain.claudejb.model.mcp.ToolException -interface GitWrites { +interface GitWorkingCopy { fun stage(paths: List) @@ -22,6 +22,9 @@ interface GitWrites { fun pull(remoteName: String?, branch: String): String fun push(remoteName: String?, branch: String): String +} + +interface GitRepositoryOps { fun stash(action: String, message: String?): List @@ -32,6 +35,9 @@ interface GitWrites { fun show(reference: String, path: String): List fun branchOp(action: String, reference: String, target: String?) +} + +interface GitWrites : GitWorkingCopy, GitRepositoryOps { companion object { const val MISSING = "the Git plugin (Git4Idea) is disabled in this IDE" diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/HistoryTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/HistoryTools.kt index fc1cfd43..6cfbfdef 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/HistoryTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/HistoryTools.kt @@ -41,7 +41,7 @@ internal class HistoryTools( private val io: CoroutineDispatcher = Dispatchers.IO, ) { - private val git: GitWrites by lazy { GitWrites.of(project) } + private val git: GitRepositoryOps by lazy { GitWrites.of(project) } private val labels = LinkedHashMap() diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/LogOpsTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/LogOpsTools.kt index 7935b719..c34d6836 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/LogOpsTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/vcs/LogOpsTools.kt @@ -27,7 +27,7 @@ internal class LogOpsTools( private val io: CoroutineDispatcher = Dispatchers.IO, ) { - private val git: GitWrites by lazy { GitWrites.of(project) } + private val git: GitRepositoryOps by lazy { GitWrites.of(project) } fun domain(): ToolDomain = ToolDomain( "log_ops", From 72c09952af9bd3094f8883267d979cf4897a55a4 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 23:16:44 +0200 Subject: [PATCH 126/155] refactor(window): move the frontend channel pushes out of the chat presenter --- .../controller/actions/ExplainSelectionAction.kt | 2 +- .../claudejb/controller/bridge/BridgePrompting.kt | 2 +- .../controller/commands/git/IdeActionInvoker.kt | 2 +- .../kotlin/dev/lain/claudejb/view/log/LogFeed.kt | 2 +- .../lain/claudejb/view/window/ChatPresenter.kt | 14 ++++---------- .../dev/lain/claudejb/view/window/ChatRegistry.kt | 4 ++-- .../lain/claudejb/view/window/FrontendPushes.kt | 15 +++++++++++++++ 7 files changed, 25 insertions(+), 16 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/view/window/FrontendPushes.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/actions/ExplainSelectionAction.kt b/src/main/kotlin/dev/lain/claudejb/controller/actions/ExplainSelectionAction.kt index 4f3ad1aa..008d3c8b 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/actions/ExplainSelectionAction.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/actions/ExplainSelectionAction.kt @@ -29,7 +29,7 @@ class ExplainSelectionAction : AnAction() { val registry = ChatRegistry.getInstance(project) val chat = registry.selectedOrNew() chat.session.send(prompt) - chat.focusInput() + chat.frontend.focusInput() registry.showToolWindow() } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgePrompting.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgePrompting.kt index a434ac0f..3610dc16 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgePrompting.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgePrompting.kt @@ -17,7 +17,7 @@ internal class BridgePrompting(private val presenter: ChatPresenter) { is Msg.RemoveQueued -> session.prompts.remove(m.index) - is Msg.Copy -> presenter.copyToClient(m.text) + is Msg.Copy -> presenter.frontend.copyToClient(m.text) } } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/commands/git/IdeActionInvoker.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/IdeActionInvoker.kt index e48d2e8c..668fffae 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/commands/git/IdeActionInvoker.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/IdeActionInvoker.kt @@ -14,7 +14,7 @@ internal object IdeActionInvoker { LOG.warn("No chat is open to run '$actionId' for the Git view's '$gitActionId' button") return JcefGitData.ActionState.FAILED } - presenter.runIdeAction(actionId) + presenter.frontend.runIdeAction(actionId) return JcefGitData.ActionState.COMPLETED } } diff --git a/src/main/kotlin/dev/lain/claudejb/view/log/LogFeed.kt b/src/main/kotlin/dev/lain/claudejb/view/log/LogFeed.kt index 739d080a..90bad765 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/log/LogFeed.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/log/LogFeed.kt @@ -24,7 +24,7 @@ internal class LogFeed(private val presenter: ChatPresenter) { offEdt { val lines = LogRing.snapshot(JcefLogData.levelOf(level)) val text = JcefLogData.reportText(lines, header(binary, level, lines.size, LogRing.since(-1).dropped)) - edt(presenter.project) { presenter.copyToClient(text) } + edt(presenter.project) { presenter.frontend.copyToClient(text) } } } diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/ChatPresenter.kt b/src/main/kotlin/dev/lain/claudejb/view/window/ChatPresenter.kt index a39212e7..c98fb9fc 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/window/ChatPresenter.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/window/ChatPresenter.kt @@ -44,7 +44,9 @@ internal class ChatPresenter( val transcript = ChatTranscriptView(session, ::emit) - val tray = AttachmentTray(project, ::emit, ::focusInput) + val frontend = FrontendPushes(::exec) + + val tray = AttachmentTray(project, ::emit, frontend::focusInput) val edits = ChatEditReview(project, session, tray::notify) @@ -82,7 +84,7 @@ internal class ChatPresenter( session.addListener(this) session.login.attachUi(onboarding) - pushVibe() + frontend.pushVibe() snapshots.now(*Kind.entries.toTypedArray()) tray.push() security.pushVuln() @@ -168,8 +170,6 @@ internal class ChatPresenter( fun cardSession(scope: String): ClaudeSession = if (scope == JcefBridge.SCOPE_GIT) gitChat.session() else session - fun pushVibe() = exec(FrontendChannel.VIBE, ChatRegistry.vibe.toString()) - fun openDashboard() { snapshots.now(Kind.SESSION) security.pushVuln() @@ -184,12 +184,6 @@ internal class ChatPresenter( exec("showGitView", PushStream.NO_ARGS) } - fun focusInput() = exec(FrontendChannel.FOCUS, PushStream.NO_ARGS) - - fun copyToClient(text: String) = exec(FrontendChannel.COPY, JcefBridge.jsString(text)) - - fun runIdeAction(actionId: String) = exec(FrontendChannel.ACTION, JcefBridge.jsString(actionId)) - fun mentionCurrentFile() = tray.addCurrentFile() fun addAttachment(attachment: Attachment) = tray.add(attachment) diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/ChatRegistry.kt b/src/main/kotlin/dev/lain/claudejb/view/window/ChatRegistry.kt index 770998bd..ddcf02d2 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/window/ChatRegistry.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/window/ChatRegistry.kt @@ -91,7 +91,7 @@ internal class ChatRegistry(private val project: Project) : Disposable { runCatching { presenter.transcript.showTranscript(null) } .onFailure { LOG.warn("Claude Code: showing '${presenter.session.title}' failed to reset its transcript", it) } repaint(Kind.TABS) - presenter.focusInput() + presenter.frontend.focusInput() } fun reveal(presenter: ChatPresenter) { @@ -181,7 +181,7 @@ internal class ChatRegistry(private val project: Project) : Disposable { fun changeVibe(on: Boolean) { vibe = on - everywhere(ChatPresenter::pushVibe) + everywhere { it.frontend.pushVibe() } } fun tabTitle(title: String): String = diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/FrontendPushes.kt b/src/main/kotlin/dev/lain/claudejb/view/window/FrontendPushes.kt new file mode 100644 index 00000000..1b131091 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/view/window/FrontendPushes.kt @@ -0,0 +1,15 @@ +package dev.lain.claudejb.view.window + +import dev.lain.claudejb.model.bridge.JcefBridge +import dev.lain.claudejb.rpc.FrontendChannel + +internal class FrontendPushes(private val exec: (String, String) -> Unit) { + + fun pushVibe() = exec(FrontendChannel.VIBE, ChatRegistry.vibe.toString()) + + fun focusInput() = exec(FrontendChannel.FOCUS, PushStream.NO_ARGS) + + fun copyToClient(text: String) = exec(FrontendChannel.COPY, JcefBridge.jsString(text)) + + fun runIdeAction(actionId: String) = exec(FrontendChannel.ACTION, JcefBridge.jsString(actionId)) +} From b23159aaea307104c115a9e454ebad2c7e3cabc1 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 23:16:46 +0200 Subject: [PATCH 127/155] refactor: clear the line length, loop jump and magic number findings --- .../lain/claudejb/model/session/agents/AgentTranscriptTail.kt | 4 +++- .../claudejb/model/session/history/SessionTranscriptReader.kt | 4 +--- .../kotlin/dev/lain/claudejb/view/window/PageScriptCalls.kt | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentTranscriptTail.kt b/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentTranscriptTail.kt index cf801c3f..148238d5 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentTranscriptTail.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/session/agents/AgentTranscriptTail.kt @@ -97,7 +97,9 @@ internal class AgentTranscriptTail(private val file: Path) { offset = channel.position() - pending.size() } } - if (head.size < HEAD_BYTES && offset > head.size) head = runCatching { readAt(0, minOf(offset, HEAD_BYTES.toLong()).toInt()) }.getOrDefault(head) + if (head.size < HEAD_BYTES && offset > head.size) { + head = runCatching { readAt(0, minOf(offset, HEAD_BYTES.toLong()).toInt()) }.getOrDefault(head) + } trailing = if (pending.size() > 0) SessionTranscriptReader.parseRecord(pending.toString(Charsets.UTF_8)) else null } diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReader.kt b/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReader.kt index 5321533c..033f4a0e 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReader.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReader.kt @@ -47,9 +47,7 @@ object SessionTranscriptReader { truncated = true break } - val record = parseRecord(line) ?: continue - val entries = entriesOf(record, projectRoot) - if (entries.isEmpty()) continue + val entries = parseRecord(line)?.let { entriesOf(it, projectRoot) }.orEmpty() chunks += entries count += entries.size } diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/PageScriptCalls.kt b/src/main/kotlin/dev/lain/claudejb/view/window/PageScriptCalls.kt index 4b07d1ce..9e644229 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/window/PageScriptCalls.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/window/PageScriptCalls.kt @@ -7,6 +7,8 @@ internal object PageScriptCalls { private val LOG = logger() + private const val DROPPED_PREVIEW_CHARS = 80 + private val CALL = Regex("""^window\.cc\.(\w+) && window\.cc\.\1\((.*)\)$""", RegexOption.DOT_MATCHES_ALL) fun parse(script: String): PagePush? = @@ -14,6 +16,6 @@ internal object PageScriptCalls { fun into(emit: (PagePush) -> Unit): (String) -> Unit = { script -> val push = parse(script) - if (push == null) LOG.warn("A page call is not a guarded window.cc call and was dropped: ${script.take(80)}") else emit(push) + if (push == null) LOG.warn("A page call is not a guarded window.cc call and was dropped: ${script.take(DROPPED_PREVIEW_CHARS)}") else emit(push) } } From 4fe0bf9079cf8298deb1dd7f5b98979409b49f9b Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 23:19:33 +0200 Subject: [PATCH 128/155] style(window): wrap the dropped page call warning within the line limit --- .../kotlin/dev/lain/claudejb/view/window/PageScriptCalls.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/PageScriptCalls.kt b/src/main/kotlin/dev/lain/claudejb/view/window/PageScriptCalls.kt index 9e644229..a8bec78c 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/window/PageScriptCalls.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/window/PageScriptCalls.kt @@ -16,6 +16,10 @@ internal object PageScriptCalls { fun into(emit: (PagePush) -> Unit): (String) -> Unit = { script -> val push = parse(script) - if (push == null) LOG.warn("A page call is not a guarded window.cc call and was dropped: ${script.take(DROPPED_PREVIEW_CHARS)}") else emit(push) + if (push == null) { + LOG.warn("A page call is not a guarded window.cc call and was dropped: ${script.take(DROPPED_PREVIEW_CHARS)}") + } else { + emit(push) + } } } From 222f7e35cda302608df9418b981fdab5b5118dbd Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 23:29:51 +0200 Subject: [PATCH 129/155] build(coverage): measure with JaCoCo 0.8.15, which reads the Java 25 bytecode 2026.2 requires --- build.gradle.kts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 4401a06b..4ea2e552 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,6 +32,11 @@ allprojects { } } } + plugins.withId("org.jetbrains.kotlinx.kover") { + extensions.configure { + useJacoco("0.8.15") + } + } tasks.withType().configureEach { exclude("**/PROJECTMAP.md") } From 0450d689934e426c3755091b9e32aa79175f7887 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 23:37:30 +0200 Subject: [PATCH 130/155] build(coverage): record platform-loaded plugin classes with JaCoCo and feed them to the Kover gate --- gradle/coverage.gradle.kts | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/gradle/coverage.gradle.kts b/gradle/coverage.gradle.kts index 1a9bab24..932b1dee 100644 --- a/gradle/coverage.gradle.kts +++ b/gradle/coverage.gradle.kts @@ -2,20 +2,43 @@ import kotlinx.kover.gradle.plugin.dsl.CoverageUnit import kotlinx.kover.gradle.plugin.dsl.GroupingEntityType import kotlinx.kover.gradle.plugin.dsl.KoverProjectExtension +apply(plugin = "jacoco") + +configure { + toolVersion = "0.8.15" +} + +val platformCoverage = layout.buildDirectory.file("jacoco/test.exec") + +tasks.named("test") { + configure { + isIncludeNoLocationClasses = true + excludes = listOf("jdk.internal.*") + setDestinationFile(platformCoverage.map { it.asFile }) + } +} + +tasks.matching { it.name.startsWith("kover") && (it.name.contains("Report") || it.name.contains("Verify")) }.configureEach { + dependsOn("test") +} + dependencies { - listOf("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal").forEach { "kover"(project(":$it")) } + listOf("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks").forEach { "kover"(project(":$it")) } } configure { currentProject { instrumentation { - disabledForTestTasks.addAll("checkDrift", "uiTest", "bench") + disabledForTestTasks.addAll("checkDrift", "uiTest", "bench", "test") } sources { excludedSourceSets.add("uiTest") } } reports { + total { + additionalBinaryReports.add(platformCoverage.map { it.asFile }) + } filters { excludes { classes( From bc91091983de8940f4da6bf0ab8f63ace758c7a4 Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 23:40:56 +0200 Subject: [PATCH 131/155] test(rpc): cover the chat contract and the backend chat service --- .../dev/lain/claudejb/rpc/ChatContractTest.kt | 58 +++++++++++++++++ .../rpc/backend/ChatApiImplHeadlessTest.kt | 63 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 src/test/kotlin/dev/lain/claudejb/rpc/ChatContractTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImplHeadlessTest.kt diff --git a/src/test/kotlin/dev/lain/claudejb/rpc/ChatContractTest.kt b/src/test/kotlin/dev/lain/claudejb/rpc/ChatContractTest.kt new file mode 100644 index 00000000..805291e2 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/rpc/ChatContractTest.kt @@ -0,0 +1,58 @@ +package dev.lain.claudejb.rpc + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ChatContractTest { + + private val chat = ChatRef(ChatId("c-1"), "First chat") + + private fun roundTrip(serializer: KSerializer, value: T) { + assertEquals(value, Json.decodeFromString(serializer, Json.encodeToString(serializer, value))) + } + + @Test + fun `every value the contract carries survives the wire unchanged`() { + roundTrip(ChatId.serializer(), ChatId("c-1")) + roundTrip(ChatRef.serializer(), chat) + roundTrip(PagePush.serializer(), PagePush("batch", "[{\"id\":1}]")) + roundTrip(TerminalLaunch.serializer(), TerminalLaunch("/work", "Claude login", "claude auth login")) + roundTrip(TerminalLaunch.serializer(), TerminalLaunch(null, "Claude", "claude")) + } + + @Test + fun `every chat event keeps its kind across the wire`() { + val events = listOf( + ChatEvent.Opened(chat, select = true), + ChatEvent.Opened(chat, select = false), + ChatEvent.Closed(chat.id), + ChatEvent.Selected(chat.id), + ChatEvent.Renamed(ChatRef(chat.id, "Renamed")), + ) + events.forEach { roundTrip(ChatEvent.serializer(), it) } + } + + @Test + fun `the frontend keeps its own pushes and forwards everything else to the page`() { + listOf( + FrontendChannel.COPY, + FrontendChannel.BROWSE, + FrontendChannel.VIBE, + FrontendChannel.FOCUS, + FrontendChannel.TERMINAL, + FrontendChannel.ACTION, + ).forEach { assertTrue(FrontendChannel.isFrontendPush(it), it) } + listOf("batch", "append", "theme", FrontendChannel.ATTACH_IMAGE, "").forEach { + assertFalse(FrontendChannel.isFrontendPush(it), it) + } + } + + @Test + fun `only the clipboard reads stay on the client`() { + assertEquals(setOf("pasteClipboard", "pasteClipboardImage"), FrontendChannel.clientMessages) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImplHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImplHeadlessTest.kt new file mode 100644 index 00000000..5debf112 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImplHeadlessTest.kt @@ -0,0 +1,63 @@ +package dev.lain.claudejb.rpc.backend + +import com.intellij.platform.project.projectId +import dev.lain.claudejb.integration.FakeClaudeTestBase +import dev.lain.claudejb.rpc.ChatEvent +import dev.lain.claudejb.rpc.ChatId +import dev.lain.claudejb.view.window.ChatRegistry +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking + +class ChatApiImplHeadlessTest : FakeClaudeTestBase() { + + private val api = ChatApiImpl() + + private val registry get() = ChatRegistry.getInstance(project) + + override fun setUp() { + super.setUp() + manager.remove(newSessionWith(FIXTURE)) + } + + override fun tearDown() { + try { + registry.all().map { it.id }.forEach(registry::close) + } finally { + super.tearDown() + } + } + + fun `test a new chat is announced, selectable, pushes its page state and closes`() = runBlocking { + val id = project.projectId() + val events = api.events(id) + val chat = api.newChat(id) + assertEquals(chat, (events.first() as ChatEvent.Opened).chat) + assertTrue(api.chats(id).contains(chat)) + + api.select(id, chat.id) + assertSame(registry.presenter(chat.id), registry.selected()) + + val pushes = api.pushes(id, chat.id) + api.ready(id, chat.id) + assertTrue(pushes.first().method.isNotBlank()) + + api.post(id, chat.id, """{"type":"diag","report":"from the page"}""") + api.close(id, chat.id) + waitUntil("the closed chat leaves the registry") { registry.presenter(chat.id) == null } + } + + fun `test a chat that is not open is ignored rather than failing the call`() = runBlocking { + val id = project.projectId() + val stranger = ChatId("not-a-chat") + api.select(id, stranger) + api.close(id, stranger) + api.post(id, stranger, "{}") + api.ready(id, stranger) + assertEquals(emptyList(), api.pushes(id, stranger).toList()) + } + + private companion object { + const val FIXTURE = "multi_message.jsonl" + } +} From c5292bd13f2c80467cf950e7bc00a35e7a6f2e7b Mon Sep 17 00:00:00 2001 From: Lain Date: Wed, 23 Sep 2026 23:40:57 +0200 Subject: [PATCH 132/155] style: apply the project formatter --- gradle/coverage.gradle.kts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gradle/coverage.gradle.kts b/gradle/coverage.gradle.kts index 932b1dee..b00f3c4c 100644 --- a/gradle/coverage.gradle.kts +++ b/gradle/coverage.gradle.kts @@ -23,7 +23,9 @@ tasks.matching { it.name.startsWith("kover") && (it.name.contains("Report") || i } dependencies { - listOf("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks").forEach { "kover"(project(":$it")) } + listOf("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks").forEach { + "kover"(project(":$it")) + } } configure { From d9d8cd099dab0c05dfd703e1d916555a4d2cd8e6 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 00:11:46 +0200 Subject: [PATCH 133/155] fix(frontend): depend on the intellij.platform.ui.jcef content module that holds JBCefApp --- frontend/build.gradle.kts | 1 + frontend/src/main/resources/dev.lain.claudejb.frontend.xml | 1 + 2 files changed, 2 insertions(+) diff --git a/frontend/build.gradle.kts b/frontend/build.gradle.kts index 6dfc3211..19c2d3f0 100644 --- a/frontend/build.gradle.kts +++ b/frontend/build.gradle.kts @@ -3,6 +3,7 @@ val npm = if (System.getProperty("os.name").startsWith("Windows")) "npm.cmd" els dependencies { intellijPlatform { bundledModule("intellij.platform.frontend") + bundledModule("intellij.platform.ui.jcef") bundledPlugin("com.intellij.modules.jcef") } implementation(project(":shared")) diff --git a/frontend/src/main/resources/dev.lain.claudejb.frontend.xml b/frontend/src/main/resources/dev.lain.claudejb.frontend.xml index be160ad9..eda88aaf 100644 --- a/frontend/src/main/resources/dev.lain.claudejb.frontend.xml +++ b/frontend/src/main/resources/dev.lain.claudejb.frontend.xml @@ -3,6 +3,7 @@ + From 92f87d510fee470664fff1e1222ca2a7d89db71f Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 08:49:14 +0200 Subject: [PATCH 134/155] fix(window): serve the chat options menu from the host over ChatApi so it shows in Remote Development --- .../resources/dev.lain.claudejb.backend.xml | 1 - .../lain/claudejb/frontend/rpc/ChatClient.kt | 31 +++++++++ .../window/ClaudeToolWindowFactory.kt | 8 +-- .../frontend/window/GearMenuActions.kt | 34 ++++++++++ .../frontend/window/HostOptionsAction.kt | 40 ++++++++++++ .../kotlin/dev/lain/claudejb/rpc/ChatApi.kt | 4 ++ .../kotlin/dev/lain/claudejb/rpc/ChatDtos.kt | 9 +++ .../controller/commands/GearChildren.kt | 8 +++ .../controller/commands/git/GitIdeMenu.kt | 16 +++-- .../lain/claudejb/rpc/backend/ChatApiImpl.kt | 18 +++++- .../claudejb/view/window/ChatGearGroup.kt | 19 ++---- .../dev/lain/claudejb/view/window/GearMenu.kt | 64 +++++++++++++++++++ .../dev/lain/claudejb/rpc/ChatContractTest.kt | 12 ++++ .../rpc/backend/ChatApiImplHeadlessTest.kt | 19 +++++- 14 files changed, 253 insertions(+), 30 deletions(-) create mode 100644 frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/GearMenuActions.kt create mode 100644 frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/HostOptionsAction.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/commands/GearChildren.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/view/window/GearMenu.kt diff --git a/backend/src/main/resources/dev.lain.claudejb.backend.xml b/backend/src/main/resources/dev.lain.claudejb.backend.xml index 62e0041f..d99cf58d 100644 --- a/backend/src/main/resources/dev.lain.claudejb.backend.xml +++ b/backend/src/main/resources/dev.lain.claudejb.backend.xml @@ -36,7 +36,6 @@ - ) -> Unit) { + scope.launch { + val items = fetchGear() + onEdt { onItems(items) } + } + } + + fun runGear(path: List) { + scope.launch { + runCatching { ChatApi.getInstance().runGear(project.projectId(), path) }.onFailure { cause -> + if (cause !is Exception || cause is CancellationException) throw cause + log.warn("Claude Code could not run the host's menu entry", cause) + } + } + } + fun link( chatId: ChatId, parent: Disposable, @@ -61,6 +79,17 @@ class ChatClient(val project: Project, private val scope: CoroutineScope) { false } + private suspend fun fetchGear(): List = + runCatching { withTimeout(GEAR_TIMEOUT) { ChatApi.getInstance().gear(project.projectId()) } } + .getOrElse { cause -> + when (cause) { + is TimeoutCancellationException -> log.warn("Claude Code chat host did not list its menu within $GEAR_TIMEOUT") + !is Exception, is CancellationException -> throw cause + else -> log.warn("Claude Code could not read the host's menu", cause) + } + emptyList() + } + private suspend fun follow(listener: ChatListener) { val projectId = project.projectId() durable { @@ -83,5 +112,7 @@ class ChatClient(val project: Project, private val scope: CoroutineScope) { private val log = logger() private const val CONNECT_TIMEOUT_MS = 30_000L + + private val GEAR_TIMEOUT = 10.seconds } } diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClaudeToolWindowFactory.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClaudeToolWindowFactory.kt index 59a2db65..83dc48a1 100644 --- a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClaudeToolWindowFactory.kt +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClaudeToolWindowFactory.kt @@ -1,7 +1,5 @@ package dev.lain.claudejb.frontend.window -import com.intellij.openapi.actionSystem.ActionGroup -import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.service import com.intellij.openapi.project.DumbAware @@ -27,11 +25,7 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { content.setPreferredFocusedComponent { tabs.focusTarget() } content.setDisposer(tabs) toolWindow.contentManager.addContent(content) - (ActionManager.getInstance().getAction(GEAR_GROUP) as? ActionGroup)?.let(toolWindow::setAdditionalGearActions) + toolWindow.setTitleActions(listOf(HostOptionsAction(client))) client.connect(tabs, tabs) } - - private companion object { - const val GEAR_GROUP = "ClaudeCode.ToolWindowGear" - } } diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/GearMenuActions.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/GearMenuActions.kt new file mode 100644 index 00000000..1388d1cd --- /dev/null +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/GearMenuActions.kt @@ -0,0 +1,34 @@ +package dev.lain.claudejb.frontend.window + +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.Separator +import com.intellij.openapi.project.DumbAware +import dev.lain.claudejb.rpc.GearItem + +internal object GearMenuActions { + + fun group(items: List, run: (List) -> Unit): DefaultActionGroup = + DefaultActionGroup(items.map { action(it, run) }) + + private fun action(item: GearItem, run: (List) -> Unit): AnAction = when { + item.separator -> Separator.getInstance() + item.children.isNotEmpty() -> DefaultActionGroup(item.text, true).apply { addAll(item.children.map { action(it, run) }) } + else -> Entry(item, run) + } + + private class Entry(private val item: GearItem, private val run: (List) -> Unit) : + AnAction(item.text), + DumbAware { + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = item.enabled + } + + override fun actionPerformed(e: AnActionEvent) = run(item.path) + } +} diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/HostOptionsAction.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/HostOptionsAction.kt new file mode 100644 index 00000000..4e9eb955 --- /dev/null +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/HostOptionsAction.kt @@ -0,0 +1,40 @@ +package dev.lain.claudejb.frontend.window + +import com.intellij.icons.AllIcons +import com.intellij.ide.DataManager +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.impl.SimpleDataContext +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.ui.popup.JBPopupFactory +import dev.lain.claudejb.frontend.rpc.ChatClient +import dev.lain.claudejb.rpc.GearItem +import java.awt.Component + +internal class HostOptionsAction(private val client: ChatClient) : + AnAction("Claude Code Options", "Session, Git and settings entries of the Claude Code host", AllIcons.General.GearPlain), + DumbAware { + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun actionPerformed(e: AnActionEvent) { + val anchor = e.inputEvent?.component + client.gear { items -> show(anchor, items) } + } + + private fun show(anchor: Component?, items: List) { + if (client.project.isDisposed) return + val group = GearMenuActions.group(items.ifEmpty { listOf(UNREACHABLE) }, client::runGear) + val context = anchor?.let { DataManager.getInstance().getDataContext(it) } + ?: SimpleDataContext.getProjectContext(client.project) + val popup = JBPopupFactory.getInstance() + .createActionGroupPopup(null, group, context, JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, true) + val shown = anchor?.takeIf { it.isShowing } + if (shown != null) popup.showUnderneathOf(shown) else popup.showCenteredInCurrentWindow(client.project) + } + + private companion object { + val UNREACHABLE = GearItem(emptyList(), "The Claude Code host did not answer", enabled = false) + } +} diff --git a/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatApi.kt b/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatApi.kt index a4a83306..3f219b53 100644 --- a/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatApi.kt +++ b/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatApi.kt @@ -26,6 +26,10 @@ interface ChatApi : RemoteApi { suspend fun ready(projectId: ProjectId, chatId: ChatId) + suspend fun gear(projectId: ProjectId): List + + suspend fun runGear(projectId: ProjectId, path: List) + companion object { suspend fun getInstance(): ChatApi = RemoteApiProviderService.resolve(remoteApiDescriptor()) } diff --git a/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatDtos.kt b/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatDtos.kt index 02c908b1..d4db9177 100644 --- a/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatDtos.kt +++ b/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatDtos.kt @@ -14,6 +14,15 @@ data class PagePush(val method: String, val json: String) @Serializable data class TerminalLaunch(val workingDirectory: String?, val tabName: String, val command: String) +@Serializable +data class GearItem( + val path: List, + val text: String, + val enabled: Boolean, + val separator: Boolean = false, + val children: List = emptyList(), +) + @Serializable sealed interface ChatEvent { @Serializable diff --git a/src/main/kotlin/dev/lain/claudejb/controller/commands/GearChildren.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/GearChildren.kt new file mode 100644 index 00000000..9537c729 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/GearChildren.kt @@ -0,0 +1,8 @@ +package dev.lain.claudejb.controller.commands + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.project.Project + +internal interface GearChildren { + fun children(project: Project): List +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitIdeMenu.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitIdeMenu.kt index c94500c1..2a842a0a 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitIdeMenu.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitIdeMenu.kt @@ -6,20 +6,28 @@ import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.Separator +import com.intellij.openapi.project.Project +import dev.lain.claudejb.controller.commands.GearChildren import dev.lain.claudejb.controller.git.GitAvailability internal object GitIdeMenu { fun gearEntry(): AnAction = IdeGitGroup() - private class IdeGitGroup : ActionGroup("Git Operations", "The IDE's own Git actions", null) { + private class IdeGitGroup : + ActionGroup("Git Operations", "The IDE's own Git actions", null), + GearChildren { init { isPopup = true } - override fun getChildren(e: AnActionEvent?): Array { - if (!GitAvailability.isGitPluginEnabled()) return EMPTY_ARRAY + override fun getChildren(e: AnActionEvent?): Array = entries().toTypedArray() + + override fun children(project: Project): List = entries() + + private fun entries(): List { + if (!GitAvailability.isGitPluginEnabled()) return emptyList() val actions = ActionManager.getInstance() val blocks = mutableListOf>() for (entry in GitActionCatalog.ideActions()) { @@ -32,7 +40,7 @@ internal object GitIdeMenu { if (isNotEmpty()) add(Separator.getInstance()) addAll(block) } - }.toTypedArray() + } } override fun update(e: AnActionEvent) { diff --git a/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt b/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt index d33c5546..9be63462 100644 --- a/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt +++ b/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt @@ -2,15 +2,18 @@ package dev.lain.claudejb.rpc.backend import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.project.Project import com.intellij.platform.project.ProjectId import com.intellij.platform.project.findProjectOrNull import dev.lain.claudejb.rpc.ChatApi import dev.lain.claudejb.rpc.ChatEvent import dev.lain.claudejb.rpc.ChatId import dev.lain.claudejb.rpc.ChatRef +import dev.lain.claudejb.rpc.GearItem import dev.lain.claudejb.rpc.PagePush import dev.lain.claudejb.view.window.ChatPresenter import dev.lain.claudejb.view.window.ChatRegistry +import dev.lain.claudejb.view.window.GearMenu import dev.lain.claudejb.view.window.PushSink import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.Channel @@ -79,8 +82,19 @@ internal class ChatApiImpl : ChatApi { presenter.replay() } - private fun registry(projectId: ProjectId): ChatRegistry? = - projectId.findProjectOrNull()?.takeUnless { it.isDisposed }?.let(ChatRegistry::getInstance) + override suspend fun gear(projectId: ProjectId): List { + val project = project(projectId) ?: return emptyList() + return onEdt { GearMenu.items(project) } + } + + override suspend fun runGear(projectId: ProjectId, path: List) { + val project = project(projectId) ?: return + ApplicationManager.getApplication().invokeLater { if (!project.isDisposed) GearMenu.run(project, path) } + } + + private fun project(projectId: ProjectId): Project? = projectId.findProjectOrNull()?.takeUnless { it.isDisposed } + + private fun registry(projectId: ProjectId): ChatRegistry? = project(projectId)?.let(ChatRegistry::getInstance) private fun presenter(projectId: ProjectId, chatId: ChatId): ChatPresenter? = registry(projectId)?.presenter(chatId) diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/ChatGearGroup.kt b/src/main/kotlin/dev/lain/claudejb/view/window/ChatGearGroup.kt index e365658b..7de4742b 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/window/ChatGearGroup.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/window/ChatGearGroup.kt @@ -1,13 +1,11 @@ package dev.lain.claudejb.view.window -import com.intellij.openapi.actionSystem.ActionGroup -import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.Separator import com.intellij.openapi.options.ShowSettingsUtil -import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project +import dev.lain.claudejb.controller.commands.GearChildren import dev.lain.claudejb.controller.commands.SessionDiffAction import dev.lain.claudejb.controller.commands.git.GitContextActions import dev.lain.claudejb.controller.commands.git.GitIdeMenu @@ -15,15 +13,10 @@ import dev.lain.claudejb.controller.commands.git.GitPromptedActions import dev.lain.claudejb.controller.session.ChatSessionManager import dev.lain.claudejb.view.settings.ClaudeSettingsConfigurable -class ChatGearGroup : ActionGroup(), DumbAware { +internal class ChatGearGroup : GearChildren { - override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT - - override fun getChildren(e: AnActionEvent?): Array { - val project = e?.project ?: return EMPTY_ARRAY - if (project.isDisposed) return EMPTY_ARRAY - return entries(project, ChatRegistry.getInstance(project)).toTypedArray() - } + override fun children(project: Project): List = + if (project.isDisposed) emptyList() else entries(project, ChatRegistry.getInstance(project)) private fun entries(project: Project, registry: ChatRegistry): List = buildList { val selected = { registry.selected() } @@ -58,8 +51,4 @@ class ChatGearGroup : ActionGroup(), DumbAware { private fun simple(text: String, action: () -> Unit): AnAction = object : AnAction(text) { override fun actionPerformed(e: AnActionEvent) = action() } - - companion object { - const val ID = "ClaudeCode.ToolWindowGear" - } } diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/GearMenu.kt b/src/main/kotlin/dev/lain/claudejb/view/window/GearMenu.kt new file mode 100644 index 00000000..cf2a9484 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/view/window/GearMenu.kt @@ -0,0 +1,64 @@ +package dev.lain.claudejb.view.window + +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.ActionUiKind +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.Separator +import com.intellij.openapi.actionSystem.ex.ActionUtil +import com.intellij.openapi.actionSystem.impl.SimpleDataContext +import com.intellij.openapi.project.Project +import dev.lain.claudejb.controller.commands.GearChildren +import dev.lain.claudejb.rpc.GearItem +import dev.lain.claudejb.util.logger + +internal object GearMenu { + + private val log = logger() + + fun items(project: Project): List = describe(project, ChatGearGroup(), emptyList()) + + private fun describe(project: Project, group: GearChildren, prefix: List): List = + group.children(project).mapIndexedNotNull { index, action -> item(project, action, prefix + index) } + + fun run(project: Project, path: List) { + val action = find(project, path) ?: return log.warn("Claude Code gear: no entry at $path any more") + val event = event(project, action) + ActionUtil.updateAction(action, event) + if (!event.presentation.isEnabledAndVisible) { + return log.warn("Claude Code gear: '${event.presentation.text}' is not available now") + } + ActionUtil.performAction(action, event) + } + + private fun item(project: Project, action: AnAction, path: List): GearItem? { + if (action is Separator) return GearItem(path, "", enabled = false, separator = true) + val event = event(project, action) + ActionUtil.updateAction(action, event) + val presentation = event.presentation + if (!presentation.isVisible) return null + val children = (action as? GearChildren)?.let { describe(project, it, path) }.orEmpty() + return GearItem(path, presentation.text.orEmpty(), presentation.isEnabled, children = children) + } + + private fun find(project: Project, path: List): AnAction? { + if (path.isEmpty()) return null + var group: GearChildren = ChatGearGroup() + var action: AnAction? = null + for (index in path) { + action = group.children(project).getOrNull(index) ?: return null + group = action as? GearChildren ?: continue + } + return action + } + + private fun event(project: Project, action: AnAction): AnActionEvent = + AnActionEvent.createEvent( + action, + SimpleDataContext.getProjectContext(project), + null, + ActionPlaces.TOOLWINDOW_POPUP, + ActionUiKind.POPUP, + null, + ) +} diff --git a/src/test/kotlin/dev/lain/claudejb/rpc/ChatContractTest.kt b/src/test/kotlin/dev/lain/claudejb/rpc/ChatContractTest.kt index 805291e2..ca635ce2 100644 --- a/src/test/kotlin/dev/lain/claudejb/rpc/ChatContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/rpc/ChatContractTest.kt @@ -22,6 +22,18 @@ class ChatContractTest { roundTrip(PagePush.serializer(), PagePush("batch", "[{\"id\":1}]")) roundTrip(TerminalLaunch.serializer(), TerminalLaunch("/work", "Claude login", "claude auth login")) roundTrip(TerminalLaunch.serializer(), TerminalLaunch(null, "Claude", "claude")) + roundTrip( + GearItem.serializer(), + GearItem( + listOf(3), + "Git Operations", + enabled = true, + children = listOf( + GearItem(listOf(3, 0), "Pull", enabled = false), + GearItem(listOf(3, 1), "", enabled = false, separator = true), + ), + ), + ) } @Test diff --git a/src/test/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImplHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImplHeadlessTest.kt index 5debf112..b20a66dd 100644 --- a/src/test/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImplHeadlessTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImplHeadlessTest.kt @@ -5,9 +5,11 @@ import dev.lain.claudejb.integration.FakeClaudeTestBase import dev.lain.claudejb.rpc.ChatEvent import dev.lain.claudejb.rpc.ChatId import dev.lain.claudejb.view.window.ChatRegistry +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout class ChatApiImplHeadlessTest : FakeClaudeTestBase() { @@ -32,7 +34,8 @@ class ChatApiImplHeadlessTest : FakeClaudeTestBase() { val id = project.projectId() val events = api.events(id) val chat = api.newChat(id) - assertEquals(chat, (events.first() as ChatEvent.Opened).chat) + val opened = withTimeout(EVENT_TIMEOUT_MS) { events.filterIsInstance().first { it.chat.id == chat.id } } + assertEquals(chat, opened.chat) assertTrue(api.chats(id).contains(chat)) api.select(id, chat.id) @@ -57,7 +60,21 @@ class ChatApiImplHeadlessTest : FakeClaudeTestBase() { assertEquals(emptyList(), api.pushes(id, stranger).toList()) } + fun `test the host describes its gear menu and ignores an entry that is gone`() = runBlocking { + val id = project.projectId() + api.newChat(id) + val items = api.gear(id) + val settings = items.single { it.text == "Settings…" } + assertTrue(settings.enabled) + assertTrue(items.any { it.separator }) + assertEquals(items.size, items.map { it.path }.distinct().size) + api.runGear(id, listOf(Int.MAX_VALUE)) + api.runGear(id, emptyList()) + } + private companion object { const val FIXTURE = "multi_message.jsonl" + + const val EVENT_TIMEOUT_MS = 10_000L } } From 86fb2d8af4eb066617fbdd699c37d7af344e1eed Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 08:49:17 +0200 Subject: [PATCH 135/155] fix(session): select the open tab instead of duplicating a saved session on reopen or restore --- .../controller/commands/TabSessionCommands.kt | 46 ++++++++----- .../window/ChatRegistryCloseHeadlessTest.kt | 66 +++++++++++++++++++ 2 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 src/test/kotlin/dev/lain/claudejb/view/window/ChatRegistryCloseHeadlessTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/commands/TabSessionCommands.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/TabSessionCommands.kt index 79a233de..16108dcc 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/commands/TabSessionCommands.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/TabSessionCommands.kt @@ -45,6 +45,7 @@ internal class TabSessionCommands( return@thenAcceptAsync } val ids = SessionHistory.getInstance(project).openSessions() + .distinct() .filter { SessionStore.exists(it) } .ifEmpty { listOfNotNull(SessionListing.list(project).firstOrNull()?.sessionId) } if (ids.isEmpty()) { @@ -60,6 +61,7 @@ internal class TabSessionCommands( } private fun restore(manager: ChatSessionManager, id: String, title: String?, entries: List, select: Boolean) { + if (opened(id) != null) return val s = manager.create() s.title = title ?: s.title s.persistence.restore(id, entries) @@ -115,22 +117,7 @@ internal class TabSessionCommands( .createPopupChooserBuilder(refs) .setTitle("Open Previous Session") .setRenderer(SessionRefRenderer()) - .setItemChosenCallback { ref -> - ApplicationManager.getApplication().executeOnPooledThread { - val entries = SessionTranscriptReader.readEntries( - ref.sessionId, - SessionTranscriptReader.DEFAULT_RESTORE_CAP, - project.basePath, - ) - edt { - val manager = ChatSessionManager.getInstance(project) - val s = manager.create() - s.title = ref.title - s.persistence.restore(ref.sessionId, entries) - openChat(s) - } - } - } + .setItemChosenCallback(::reopen) .setRequestFocus(true) .createPopup() .showCenteredInCurrentWindow(project) @@ -138,6 +125,33 @@ internal class TabSessionCommands( } } + fun reopen(ref: SessionRef) { + if (revealOpened(ref.sessionId)) return + ApplicationManager.getApplication().executeOnPooledThread { + val entries = SessionTranscriptReader.readEntries( + ref.sessionId, + SessionTranscriptReader.DEFAULT_RESTORE_CAP, + project.basePath, + ) + edt(project) { + if (revealOpened(ref.sessionId)) return@edt + val s = ChatSessionManager.getInstance(project).create() + s.title = ref.title + s.persistence.restore(ref.sessionId, entries) + openChat(s) + } + } + } + + private fun revealOpened(sessionId: String): Boolean { + val tab = opened(sessionId) ?: return false + registry.reveal(tab) + return true + } + + private fun opened(sessionId: String) = + registry.all().firstOrNull { it.session.sessionId == sessionId && !it.session.launch.fork } + private class SessionRefRenderer : SimpleListCellRenderer() { override fun customize( list: JList, diff --git a/src/test/kotlin/dev/lain/claudejb/view/window/ChatRegistryCloseHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/view/window/ChatRegistryCloseHeadlessTest.kt new file mode 100644 index 00000000..dfb49270 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/view/window/ChatRegistryCloseHeadlessTest.kt @@ -0,0 +1,66 @@ +package dev.lain.claudejb.view.window + +import dev.lain.claudejb.integration.FakeClaudeTestBase +import dev.lain.claudejb.model.session.transcript.SessionRef + +class ChatRegistryCloseHeadlessTest : FakeClaudeTestBase() { + + private val registry get() = ChatRegistry.getInstance(project) + + override fun setUp() { + super.setUp() + manager.remove(newSessionWith(FIXTURE)) + } + + override fun tearDown() { + try { + registry.all().map { it.id }.forEach(registry::close) + } finally { + super.tearDown() + } + } + + fun `test closing one of two tabs on the same saved session leaves the other open and alive`() { + val first = restored() + val second = restored() + keepOnly(first, second) + registry.close(first.id) + waitUntil("the closed tab leaves the registry") { registry.presenter(first.id) == null } + assertEquals(listOf(second.id), registry.all().map { it.id }) + assertTrue(manager.all().contains(second.session)) + } + + fun `test closing the last tab opens a fresh one`() { + val only = restored() + keepOnly(only) + registry.close(only.id) + waitUntil("a fresh chat replaces the last one") { registry.all().any { it.id != only.id } } + assertEquals(1, registry.all().size) + } + + fun `test reopening a saved session that is already open selects its tab instead of duplicating it`() { + val opened = restored() + registry.newChat() + val before = registry.all().map { it.id } + registry.commands.reopen(SessionRef(SAVED_SESSION, "Saved", 0L)) + waitUntil("the open tab is selected") { registry.selected() === opened } + assertEquals(before, registry.all().map { it.id }) + } + + private fun restored(): ChatPresenter { + val session = manager.create() + session.persistence.restore(SAVED_SESSION, emptyList()) + return registry.open(session, true) + } + + private fun keepOnly(vararg keep: ChatPresenter) { + registry.all().filterNot { it in keep }.forEach { registry.close(it.id) } + waitUntil("only the tabs under test are open") { registry.all().toSet() == keep.toSet() } + } + + private companion object { + const val FIXTURE = "multi_message.jsonl" + + const val SAVED_SESSION = "11111111-2222-3333-4444-555555555555" + } +} From fa8b6dce0d2164aa8b33333714fb7fb097741cda Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 09:14:02 +0200 Subject: [PATCH 136/155] fix(window): keep the client's tabs, focus and pushes in step with the host's chats --- .../lain/claudejb/frontend/jcef/JcefHost.kt | 5 +- .../lain/claudejb/frontend/rpc/ChatClient.kt | 86 ++++++++----- .../lain/claudejb/frontend/rpc/ChatLink.kt | 60 ++++----- .../claudejb/frontend/rpc/ChatListener.kt | 3 - .../frontend/rpc/ChatWindowVisibility.kt | 20 +++ .../claudejb/frontend/rpc/ChatWindowWatch.kt | 9 ++ .../claudejb/frontend/window/ChatTabsPanel.kt | 47 ++++--- .../window/ClaudeToolWindowFactory.kt | 4 +- .../resources/dev.lain.claudejb.frontend.xml | 1 + .../kotlin/dev/lain/claudejb/rpc/ChatApi.kt | 6 - .../kotlin/dev/lain/claudejb/rpc/ChatDtos.kt | 3 + .../dev/lain/claudejb/rpc/HostWindowApi.kt | 24 ++++ .../controller/commands/TabSessionCommands.kt | 40 +++--- .../lain/claudejb/rpc/backend/ChatApiImpl.kt | 43 +------ .../claudejb/rpc/backend/ChatApiProvider.kt | 2 + .../claudejb/rpc/backend/HostWindowApiImpl.kt | 43 +++++++ .../lain/claudejb/rpc/backend/RpcProjects.kt | 29 +++++ .../claudejb/view/feed/ChatTranscriptView.kt | 5 +- .../dev/lain/claudejb/view/window/ChatDeck.kt | 2 +- .../claudejb/view/window/ChatPresenter.kt | 6 +- .../lain/claudejb/view/window/ChatRegistry.kt | 23 ++-- .../claudejb/view/window/ChatTerminalHost.kt | 1 + .../claudejb/view/window/ChatWindowState.kt | 18 +++ .../lain/claudejb/view/window/PushStream.kt | 25 ++-- .../frontend/window/ChatTabsPanelTest.kt | 115 +++++++++++------- .../dev/lain/claudejb/rpc/ChatContractTest.kt | 2 + .../rpc/backend/ChatApiImplHeadlessTest.kt | 17 +-- .../backend/ChatWindowMirrorHeadlessTest.kt | 104 ++++++++++++++++ .../lain/claudejb/view/window/ChatDeckTest.kt | 7 +- .../window/ChatRegistryCloseHeadlessTest.kt | 2 + .../claudejb/view/window/PushStreamTest.kt | 55 +++++++-- 31 files changed, 548 insertions(+), 259 deletions(-) create mode 100644 frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatWindowVisibility.kt create mode 100644 frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatWindowWatch.kt create mode 100644 shared/src/main/kotlin/dev/lain/claudejb/rpc/HostWindowApi.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/rpc/backend/HostWindowApiImpl.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/rpc/backend/RpcProjects.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/view/window/ChatWindowState.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/rpc/backend/ChatWindowMirrorHeadlessTest.kt diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/JcefHost.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/JcefHost.kt index e914a02c..f51604fc 100644 --- a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/JcefHost.kt +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/jcef/JcefHost.kt @@ -10,6 +10,7 @@ import com.intellij.ui.jcef.JBCefBrowser import com.intellij.ui.jcef.JBCefBrowserBase import com.intellij.ui.jcef.JBCefJSQuery import com.intellij.util.Alarm +import dev.lain.claudejb.rpc.FrontendChannel import javax.swing.JComponent import javax.swing.border.EmptyBorder @@ -105,7 +106,7 @@ class JcefHost( log.warn("Claude Code chat page was asked to run a method that is not a name: $method") return } - exec("window.cc.$method && window.cc.$method($json)", method.takeIf { it in SNAPSHOT_METHODS }) + exec("window.cc.$method && window.cc.$method($json)", method.takeIf { it in FrontendChannel.SNAPSHOT_METHODS }) } fun exec(js: String, snapshotKey: String? = null) { @@ -211,7 +212,5 @@ class JcefHost( private const val WEB_READY_TIMEOUT_MS = 5_000L private val METHOD_NAME = Regex("[A-Za-z_$][A-Za-z0-9_$]*") - - private val SNAPSHOT_METHODS = setOf("session", "meta", "state", "settingsMenu", "theme", "permissions", "tabs") } } diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatClient.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatClient.kt index 9b70823d..04be5c88 100644 --- a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatClient.kt +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatClient.kt @@ -8,18 +8,21 @@ import com.intellij.openapi.components.Service import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.openapi.wm.ex.ToolWindowManagerListener import com.intellij.platform.project.projectId import dev.lain.claudejb.rpc.ChatApi +import dev.lain.claudejb.rpc.ChatEvent import dev.lain.claudejb.rpc.ChatId import dev.lain.claudejb.rpc.GearItem +import dev.lain.claudejb.rpc.HostWindowApi import dev.lain.claudejb.rpc.PagePush import fleet.rpc.client.durable import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout @@ -28,19 +31,29 @@ import kotlin.time.Duration.Companion.seconds @Service(Service.Level.PROJECT) class ChatClient(val project: Project, private val scope: CoroutineScope) { + private var listener: ChatListener? = null + + private var following: Job? = null + fun connect(parent: Disposable, listener: ChatListener) { - val job = scope.launch { - if (reachable()) follow(listener) else onEdt { listener.unavailable() } + this.listener = listener + following = scope.launch { if (reachable()) follow(listener) else onEdt { listener.unavailable() } } + Disposer.register(parent) { + following?.cancel() + this.listener = null } - Disposer.register(parent) { job.cancel() } } - fun select(chatId: ChatId) { + fun resync() { + val current = listener ?: return + following?.cancel() + following = scope.launch { follow(current) } + } + + fun watchWindow() { + project.messageBus.connect(scope).subscribe(ToolWindowManagerListener.TOPIC, ChatWindowVisibility(::reportVisible)) scope.launch { - runCatching { ChatApi.getInstance().select(project.projectId(), chatId) }.onFailure { cause -> - if (cause !is Exception || cause is CancellationException) throw cause - log.warn("Claude Code could not tell the host which chat is on screen", cause) - } + durable { HostWindowApi.getInstance().reveals(project.projectId()).collect { onEdt(::revealWindow) } } } } @@ -51,28 +64,36 @@ class ChatClient(val project: Project, private val scope: CoroutineScope) { } } - fun runGear(path: List) { + fun runGear(path: List) = tell("Claude Code could not run the host's menu entry") { + HostWindowApi.getInstance().runGear(project.projectId(), path) + } + + fun link(chatId: ChatId, parent: Disposable, onPush: (PagePush) -> Unit): ChatLink = + ChatLink(scope, project.projectId(), chatId, onPush).also { Disposer.register(parent, it) } + + private fun reportVisible(visible: Boolean) = tell("Claude Code could not tell the host whether its window is visible") { + HostWindowApi.getInstance().windowVisible(project.projectId(), visible) + } + + private fun revealWindow() { + ToolWindowManager.getInstance(project).getToolWindow(ChatWindowVisibility.TOOL_WINDOW_ID)?.activate(null) + } + + private fun tell(failure: String, call: suspend () -> Unit) { scope.launch { - runCatching { ChatApi.getInstance().runGear(project.projectId(), path) }.onFailure { cause -> + runCatching { call() }.onFailure { cause -> if (cause !is Exception || cause is CancellationException) throw cause - log.warn("Claude Code could not run the host's menu entry", cause) + log.warn(failure, cause) } } } - fun link( - chatId: ChatId, - parent: Disposable, - onPush: (PagePush) -> Unit, - isWebReady: () -> Boolean, - ): ChatLink = ChatLink(scope, project.projectId(), chatId, onPush, isWebReady).also { Disposer.register(parent, it) } - private suspend fun reachable(): Boolean = - runCatching { withTimeout(CONNECT_TIMEOUT_MS) { ChatApi.getInstance().chats(project.projectId()) } } + runCatching { withTimeout(CONNECT_TIMEOUT) { ChatApi.getInstance().chats(project.projectId()) } } .map { true } .getOrElse { cause -> when (cause) { - is TimeoutCancellationException -> log.warn("Claude Code chat host did not answer within ${CONNECT_TIMEOUT_MS}ms") + is TimeoutCancellationException -> log.warn("Claude Code chat host did not answer within $CONNECT_TIMEOUT") !is Exception, is CancellationException -> throw cause else -> log.warn("Claude Code chat host is not reachable from this client", cause) } @@ -80,7 +101,7 @@ class ChatClient(val project: Project, private val scope: CoroutineScope) { } private suspend fun fetchGear(): List = - runCatching { withTimeout(GEAR_TIMEOUT) { ChatApi.getInstance().gear(project.projectId()) } } + runCatching { withTimeout(GEAR_TIMEOUT) { HostWindowApi.getInstance().gear(project.projectId()) } } .getOrElse { cause -> when (cause) { is TimeoutCancellationException -> log.warn("Claude Code chat host did not list its menu within $GEAR_TIMEOUT") @@ -93,15 +114,14 @@ class ChatClient(val project: Project, private val scope: CoroutineScope) { private suspend fun follow(listener: ChatListener) { val projectId = project.projectId() durable { - val api = ChatApi.getInstance() - val events = api.events(projectId) - coroutineScope { - launch(start = CoroutineStart.UNDISPATCHED) { - events.collect { event -> onEdt { listener.event(event) } } - } - val chats = api.chats(projectId).ifEmpty { listOf(api.newChat(projectId)) } - onEdt { listener.sync(chats) } - } + ChatApi.getInstance().events(projectId).collect { event -> onEdt { deliver(listener, event) } } + } + } + + private fun deliver(listener: ChatListener, event: ChatEvent) { + runCatching { listener.event(event) }.onFailure { cause -> + if (cause !is Exception || cause is CancellationException) throw cause + log.error("Claude Code could not apply a chat event to this window: $event", cause) } } @@ -111,7 +131,7 @@ class ChatClient(val project: Project, private val scope: CoroutineScope) { private companion object { private val log = logger() - private const val CONNECT_TIMEOUT_MS = 30_000L + private val CONNECT_TIMEOUT = 30.seconds private val GEAR_TIMEOUT = 10.seconds } diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatLink.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatLink.kt index b349ee6b..ea387808 100644 --- a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatLink.kt +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatLink.kt @@ -9,71 +9,57 @@ import dev.lain.claudejb.rpc.PagePush import fleet.rpc.client.durable import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch class ChatLink internal constructor( - scope: CoroutineScope, + private val scope: CoroutineScope, private val projectId: ProjectId, private val chatId: ChatId, private val onPush: (PagePush) -> Unit, - private val isWebReady: () -> Boolean, ) : Disposable { - private sealed interface Outgoing { - class Post(val json: String) : Outgoing + private val outgoing = Channel(Channel.UNLIMITED) - data object Ready : Outgoing - } + private val sending = scope.launch { for (json in outgoing) deliver(json) } - private val outgoing = Channel(Channel.UNLIMITED) + private val lock = Any() - private val job = scope.launch { - launch { send() } - launch { receive() } - } + private var disposed = false + + private var receiving: Job = scope.launch { receive() } fun post(json: String) { - outgoing.trySend(Outgoing.Post(json)) + outgoing.trySend(json) } fun ready() { - outgoing.trySend(Outgoing.Ready) + synchronized(lock) { + if (disposed) return + receiving.cancel() + receiving = scope.launch { receive() } + } } override fun dispose() { - outgoing.close() - job.cancel() - } - - private suspend fun send() { - for (item in outgoing) deliver(item) + synchronized(lock) { + disposed = true + outgoing.close() + sending.cancel() + receiving.cancel() + } } - private suspend fun deliver(item: Outgoing) { - runCatching { - val api = ChatApi.getInstance() - when (item) { - is Outgoing.Post -> api.post(projectId, chatId, item.json) - Outgoing.Ready -> api.ready(projectId, chatId) - } - }.onFailure { cause -> + private suspend fun deliver(json: String) { + runCatching { ChatApi.getInstance().post(projectId, chatId, json) }.onFailure { cause -> if (cause !is Exception || cause is CancellationException) throw cause log.warn("Claude Code could not hand a chat page message to the host", cause) } } private suspend fun receive() { - durable { - val api = ChatApi.getInstance() - val pushes = api.pushes(projectId, chatId) - coroutineScope { - launch(start = CoroutineStart.UNDISPATCHED) { pushes.collect { onPush(it) } } - if (isWebReady()) api.ready(projectId, chatId) - } - } + durable { ChatApi.getInstance().pushes(projectId, chatId).collect { onPush(it) } } } private companion object { diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatListener.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatListener.kt index 693cda9d..b703a0e8 100644 --- a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatListener.kt +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatListener.kt @@ -1,12 +1,9 @@ package dev.lain.claudejb.frontend.rpc import dev.lain.claudejb.rpc.ChatEvent -import dev.lain.claudejb.rpc.ChatRef interface ChatListener { fun unavailable() - fun sync(chats: List) - fun event(event: ChatEvent) } diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatWindowVisibility.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatWindowVisibility.kt new file mode 100644 index 00000000..318137e8 --- /dev/null +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatWindowVisibility.kt @@ -0,0 +1,20 @@ +package dev.lain.claudejb.frontend.rpc + +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.openapi.wm.ex.ToolWindowManagerListener + +internal class ChatWindowVisibility(private val report: (Boolean) -> Unit) : ToolWindowManagerListener { + + private var last: Boolean? = null + + override fun stateChanged(toolWindowManager: ToolWindowManager) { + val visible = toolWindowManager.getToolWindow(TOOL_WINDOW_ID)?.isVisible == true + if (visible == last) return + last = visible + report(visible) + } + + companion object { + const val TOOL_WINDOW_ID = "Claude Code" + } +} diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatWindowWatch.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatWindowWatch.kt new file mode 100644 index 00000000..91a36bfc --- /dev/null +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/rpc/ChatWindowWatch.kt @@ -0,0 +1,9 @@ +package dev.lain.claudejb.frontend.rpc + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity + +class ChatWindowWatch : ProjectActivity { + override suspend fun execute(project: Project) = project.service().watchWindow() +} diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatTabsPanel.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatTabsPanel.kt index b3148c5a..796f7bc2 100644 --- a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatTabsPanel.kt +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatTabsPanel.kt @@ -1,6 +1,7 @@ package dev.lain.claudejb.frontend.window import com.intellij.openapi.Disposable +import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBPanel @@ -15,7 +16,7 @@ import javax.swing.JComponent import javax.swing.JPanel internal class ChatTabsPanel( - private val onSelect: (ChatId) -> Unit, + private val onDesync: () -> Unit, private val newCard: (ChatId) -> ChatCard, ) : JBPanel(BorderLayout()), Disposable, ChatListener { @@ -28,6 +29,8 @@ internal class ChatTabsPanel( var selected: ChatId? = null private set + private var awaited: ChatId? = null + init { add(deck, BorderLayout.CENTER) } @@ -43,35 +46,39 @@ internal class ChatTabsPanel( repaint() } - override fun sync(chats: List) { - val listed = chats.mapTo(HashSet()) { it.id } - open.keys.filterNot { it in listed }.forEach(::remove) - chats.forEach { add(it.id) } - if (selected == null) chats.lastOrNull()?.let { if (show(it.id, focus = false)) onSelect(it.id) } - } - override fun event(event: ChatEvent) { when (event) { + is ChatEvent.Listed -> listed(event.chats, event.selected) is ChatEvent.Opened -> opened(event.chat.id, event.select) is ChatEvent.Closed -> remove(event.id) - is ChatEvent.Selected -> show(event.id, focus = true) + is ChatEvent.Selected -> selectedByHost(event.id) is ChatEvent.Renamed -> Unit } } - fun select(id: ChatId) { - if (show(id, focus = true)) onSelect(id) + fun focusIfSelected(id: ChatId) { + if (selected == id) open[id]?.focus() } - fun focus(id: ChatId) { - show(id, focus = true) + private fun selectedByHost(id: ChatId) { + awaited = null + if (!show(id, focus = true)) desync(id) + } + + private fun listed(chats: List, shown: ChatId?) { + awaited = null + val listed = chats.mapTo(HashSet()) { it.id } + open.keys.filterNot { it in listed }.forEach(::remove) + chats.forEach { add(it.id) } + (shown ?: chats.lastOrNull()?.id)?.let { show(it, focus = false) } } private fun opened(id: ChatId, select: Boolean) { add(id) + if (select) awaited = id when { selected == null -> show(id, focus = select) - select -> open[id]?.whenReady { if (open.keys.lastOrNull() == id) show(id, focus = true) } + select -> open[id]?.whenReady { if (awaited == id) show(id, focus = true) } } } @@ -95,10 +102,7 @@ internal class ChatTabsPanel( private fun remove(id: ChatId) { val card = open.remove(id) ?: return - if (selected == id) { - selected = null - open.keys.lastOrNull()?.let(::select) - } + if (selected == id) selected = null card.component.isVisible = false deck.remove(card.component) Disposer.dispose(card) @@ -106,9 +110,16 @@ internal class ChatTabsPanel( deck.repaint() } + private fun desync(id: ChatId) { + log.warn("Claude Code host selected chat '${id.value}', which this window does not have; asking for the full list again") + onDesync() + } + override fun dispose() = Unit private companion object { + private val log = logger() + const val UNAVAILABLE = "The Claude Code chat runs on the host IDE. Install and enable the Claude Code plugin there " + "to use it from this client." diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClaudeToolWindowFactory.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClaudeToolWindowFactory.kt index 83dc48a1..f210b1c5 100644 --- a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClaudeToolWindowFactory.kt +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClaudeToolWindowFactory.kt @@ -17,9 +17,7 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { ApplicationManager.getApplication().executeOnPooledThread(Callable { PageAssembly.page }) val client = project.service() lateinit var tabs: ChatTabsPanel - tabs = ChatTabsPanel(client::select) { id -> - ChatView(id, client) { toolWindow.show { tabs.focus(id) } } - } + tabs = ChatTabsPanel(client::resync) { id -> ChatView(id, client) { tabs.focusIfSelected(id) } } val content = ContentFactory.getInstance().createContent(tabs, "", false) content.isCloseable = false content.setPreferredFocusedComponent { tabs.focusTarget() } diff --git a/frontend/src/main/resources/dev.lain.claudejb.frontend.xml b/frontend/src/main/resources/dev.lain.claudejb.frontend.xml index eda88aaf..0b09802d 100644 --- a/frontend/src/main/resources/dev.lain.claudejb.frontend.xml +++ b/frontend/src/main/resources/dev.lain.claudejb.frontend.xml @@ -7,6 +7,7 @@ + { suspend fun pushes(projectId: ProjectId, chatId: ChatId): Flow - suspend fun ready(projectId: ProjectId, chatId: ChatId) - - suspend fun gear(projectId: ProjectId): List - - suspend fun runGear(projectId: ProjectId, path: List) - companion object { suspend fun getInstance(): ChatApi = RemoteApiProviderService.resolve(remoteApiDescriptor()) } diff --git a/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatDtos.kt b/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatDtos.kt index d4db9177..fd672e05 100644 --- a/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatDtos.kt +++ b/shared/src/main/kotlin/dev/lain/claudejb/rpc/ChatDtos.kt @@ -25,6 +25,9 @@ data class GearItem( @Serializable sealed interface ChatEvent { + @Serializable + data class Listed(val chats: List, val selected: ChatId?) : ChatEvent + @Serializable data class Opened(val chat: ChatRef, val select: Boolean) : ChatEvent diff --git a/shared/src/main/kotlin/dev/lain/claudejb/rpc/HostWindowApi.kt b/shared/src/main/kotlin/dev/lain/claudejb/rpc/HostWindowApi.kt new file mode 100644 index 00000000..4472f4a6 --- /dev/null +++ b/shared/src/main/kotlin/dev/lain/claudejb/rpc/HostWindowApi.kt @@ -0,0 +1,24 @@ +package dev.lain.claudejb.rpc + +import com.intellij.platform.project.ProjectId +import com.intellij.platform.rpc.RemoteApiProviderService +import fleet.rpc.RemoteApi +import fleet.rpc.Rpc +import fleet.rpc.remoteApiDescriptor +import kotlinx.coroutines.flow.Flow + +@Rpc +@Suppress("SuspendFunWithFlowReturnType") +interface HostWindowApi : RemoteApi { + suspend fun reveals(projectId: ProjectId): Flow + + suspend fun windowVisible(projectId: ProjectId, visible: Boolean) + + suspend fun gear(projectId: ProjectId): List + + suspend fun runGear(projectId: ProjectId, path: List) + + companion object { + suspend fun getInstance(): HostWindowApi = RemoteApiProviderService.resolve(remoteApiDescriptor()) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/commands/TabSessionCommands.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/TabSessionCommands.kt index 16108dcc..187f6330 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/commands/TabSessionCommands.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/TabSessionCommands.kt @@ -1,6 +1,9 @@ package dev.lain.claudejb.controller.commands +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState import com.intellij.openapi.project.Project import com.intellij.openapi.ui.Messages import com.intellij.openapi.ui.popup.JBPopupFactory @@ -16,6 +19,7 @@ import dev.lain.claudejb.model.session.history.SessionTranscriptReader import dev.lain.claudejb.model.session.transcript.EntryDTO import dev.lain.claudejb.model.session.transcript.SessionRef import dev.lain.claudejb.model.settings.ClaudeSettings +import dev.lain.claudejb.util.PluginIdentity import dev.lain.claudejb.util.edt import dev.lain.claudejb.view.window.ChatRegistry import javax.swing.JList @@ -108,23 +112,25 @@ internal class TabSessionCommands( fun openPreviousSession() { ApplicationManager.getApplication().executeOnPooledThread { val refs = SessionListing.list(project) - edt { - if (refs.isEmpty()) { - Messages.showInfoMessage(project, "No previous sessions have been saved yet.", "Claude Code") - return@edt - } - JBPopupFactory.getInstance() - .createPopupChooserBuilder(refs) - .setTitle("Open Previous Session") - .setRenderer(SessionRefRenderer()) - .setItemChosenCallback(::reopen) - .setRequestFocus(true) - .createPopup() - .showCenteredInCurrentWindow(project) - } + ApplicationManager.getApplication().invokeLater({ if (!project.isDisposed) choosePrevious(refs) }, ModalityState.nonModal()) } } + private fun choosePrevious(refs: List) { + if (refs.isEmpty()) { + Messages.showInfoMessage(project, "No previous sessions have been saved yet.", "Claude Code") + return + } + JBPopupFactory.getInstance() + .createPopupChooserBuilder(refs) + .setTitle("Open Previous Session") + .setRenderer(SessionRefRenderer()) + .setItemChosenCallback(::reopen) + .setRequestFocus(true) + .createPopup() + .showCenteredInCurrentWindow(project) + } + fun reopen(ref: SessionRef) { if (revealOpened(ref.sessionId)) return ApplicationManager.getApplication().executeOnPooledThread { @@ -145,10 +151,16 @@ internal class TabSessionCommands( private fun revealOpened(sessionId: String): Boolean { val tab = opened(sessionId) ?: return false + if (registry.selected() === tab) alreadyOpen(tab.session.title) registry.reveal(tab) return true } + private fun alreadyOpen(title: String) = + NotificationGroupManager.getInstance().getNotificationGroup(PluginIdentity.NOTIFICATION_GROUP) + .createNotification("\"$title\" is already open in the chat on screen.", NotificationType.INFORMATION) + .notify(project) + private fun opened(sessionId: String) = registry.all().firstOrNull { it.session.sessionId == sessionId && !it.session.launch.fork } diff --git a/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt b/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt index 9be63462..28ca7de0 100644 --- a/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt +++ b/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImpl.kt @@ -1,24 +1,18 @@ package dev.lain.claudejb.rpc.backend import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.project.Project import com.intellij.platform.project.ProjectId -import com.intellij.platform.project.findProjectOrNull import dev.lain.claudejb.rpc.ChatApi import dev.lain.claudejb.rpc.ChatEvent import dev.lain.claudejb.rpc.ChatId import dev.lain.claudejb.rpc.ChatRef -import dev.lain.claudejb.rpc.GearItem import dev.lain.claudejb.rpc.PagePush -import dev.lain.claudejb.view.window.ChatPresenter -import dev.lain.claudejb.view.window.ChatRegistry -import dev.lain.claudejb.view.window.GearMenu +import dev.lain.claudejb.rpc.backend.RpcProjects.onEdt +import dev.lain.claudejb.rpc.backend.RpcProjects.presenter +import dev.lain.claudejb.rpc.backend.RpcProjects.registry import dev.lain.claudejb.view.window.PushSink -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.completeWith import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.callbackFlow @@ -46,7 +40,7 @@ internal class ChatApiImpl : ChatApi { override suspend fun select(projectId: ProjectId, chatId: ChatId) { val registry = registry(projectId) ?: return - onEdt { registry.select(chatId) } + onEdt { registry.select(chatId, focus = false) } } override suspend fun close(projectId: ProjectId, chatId: ChatId) { @@ -76,33 +70,4 @@ internal class ChatApiImpl : ChatApi { awaitClose(detach) }.buffer(Channel.UNLIMITED) } - - override suspend fun ready(projectId: ProjectId, chatId: ChatId) { - val presenter = presenter(projectId, chatId) ?: return - presenter.replay() - } - - override suspend fun gear(projectId: ProjectId): List { - val project = project(projectId) ?: return emptyList() - return onEdt { GearMenu.items(project) } - } - - override suspend fun runGear(projectId: ProjectId, path: List) { - val project = project(projectId) ?: return - ApplicationManager.getApplication().invokeLater { if (!project.isDisposed) GearMenu.run(project, path) } - } - - private fun project(projectId: ProjectId): Project? = projectId.findProjectOrNull()?.takeUnless { it.isDisposed } - - private fun registry(projectId: ProjectId): ChatRegistry? = project(projectId)?.let(ChatRegistry::getInstance) - - private fun presenter(projectId: ProjectId, chatId: ChatId): ChatPresenter? = registry(projectId)?.presenter(chatId) - - private suspend fun onEdt(block: () -> T): T { - val app = ApplicationManager.getApplication() - if (app.isDispatchThread) return block() - val result = CompletableDeferred() - app.invokeLater({ result.completeWith(runCatching(block)) }, ModalityState.any()) - return result.await() - } } diff --git a/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiProvider.kt b/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiProvider.kt index 33619692..e3307dd3 100644 --- a/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiProvider.kt +++ b/src/main/kotlin/dev/lain/claudejb/rpc/backend/ChatApiProvider.kt @@ -2,11 +2,13 @@ package dev.lain.claudejb.rpc.backend import com.intellij.platform.rpc.backend.RemoteApiProvider import dev.lain.claudejb.rpc.ChatApi +import dev.lain.claudejb.rpc.HostWindowApi import fleet.rpc.remoteApiDescriptor internal class ChatApiProvider : RemoteApiProvider { override fun RemoteApiProvider.Sink.remoteApis() { remoteApi(remoteApiDescriptor()) { ChatApiImpl() } + remoteApi(remoteApiDescriptor()) { HostWindowApiImpl() } } } diff --git a/src/main/kotlin/dev/lain/claudejb/rpc/backend/HostWindowApiImpl.kt b/src/main/kotlin/dev/lain/claudejb/rpc/backend/HostWindowApiImpl.kt new file mode 100644 index 00000000..ed3f4f72 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/rpc/backend/HostWindowApiImpl.kt @@ -0,0 +1,43 @@ +package dev.lain.claudejb.rpc.backend + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.platform.project.ProjectId +import dev.lain.claudejb.rpc.GearItem +import dev.lain.claudejb.rpc.HostWindowApi +import dev.lain.claudejb.rpc.backend.RpcProjects.onEdt +import dev.lain.claudejb.rpc.backend.RpcProjects.project +import dev.lain.claudejb.rpc.backend.RpcProjects.registry +import dev.lain.claudejb.view.window.GearMenu +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.emptyFlow +import java.util.concurrent.atomic.AtomicInteger + +internal class HostWindowApiImpl : HostWindowApi { + + override suspend fun reveals(projectId: ProjectId): Flow { + val registry = registry(projectId) ?: return emptyFlow() + return callbackFlow { + val count = AtomicInteger() + val stop = registry.window.onReveal { trySend(count.incrementAndGet()) } + awaitClose(stop) + }.buffer(Channel.CONFLATED) + } + + override suspend fun windowVisible(projectId: ProjectId, visible: Boolean) { + registry(projectId)?.window?.visible = visible + } + + override suspend fun gear(projectId: ProjectId): List { + val project = project(projectId) ?: return emptyList() + return onEdt { GearMenu.items(project) } + } + + override suspend fun runGear(projectId: ProjectId, path: List) { + val project = project(projectId) ?: return + ApplicationManager.getApplication().invokeLater { if (!project.isDisposed) GearMenu.run(project, path) } + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/rpc/backend/RpcProjects.kt b/src/main/kotlin/dev/lain/claudejb/rpc/backend/RpcProjects.kt new file mode 100644 index 00000000..06b6050e --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/rpc/backend/RpcProjects.kt @@ -0,0 +1,29 @@ +package dev.lain.claudejb.rpc.backend + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.project.Project +import com.intellij.platform.project.ProjectId +import com.intellij.platform.project.findProjectOrNull +import dev.lain.claudejb.rpc.ChatId +import dev.lain.claudejb.view.window.ChatPresenter +import dev.lain.claudejb.view.window.ChatRegistry +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.completeWith + +internal object RpcProjects { + + fun project(projectId: ProjectId): Project? = projectId.findProjectOrNull()?.takeUnless { it.isDisposed } + + fun registry(projectId: ProjectId): ChatRegistry? = project(projectId)?.let(ChatRegistry::getInstance) + + fun presenter(projectId: ProjectId, chatId: ChatId): ChatPresenter? = registry(projectId)?.presenter(chatId) + + suspend fun onEdt(block: () -> T): T { + val app = ApplicationManager.getApplication() + if (app.isDispatchThread) return block() + val result = CompletableDeferred() + app.invokeLater({ result.completeWith(runCatching(block)) }, ModalityState.any()) + return result.await() + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/view/feed/ChatTranscriptView.kt b/src/main/kotlin/dev/lain/claudejb/view/feed/ChatTranscriptView.kt index 9b84eea4..e0560eaa 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/feed/ChatTranscriptView.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/feed/ChatTranscriptView.kt @@ -123,9 +123,7 @@ internal class ChatTranscriptView( val rows = JcefTranscriptPayload.agentRowsJson(entries, titles, running, expanded, ownerRunning) if (rows == lastRows) return if (rows.size < lastRows.size) { - lastRows = rows - exec("clear", NO_ARGS) - if (rows.isNotEmpty()) exec("batch", "[${rows.joinToString(",")}]") + resync() return } val changed = rows.filterIndexed { index, row -> index >= lastRows.size || lastRows[index] != row } @@ -151,6 +149,7 @@ internal class ChatTranscriptView( } override fun onCleared() { + if (!showsChat) return resync() dirty.clear() appended.clear() structural = false diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/ChatDeck.kt b/src/main/kotlin/dev/lain/claudejb/view/window/ChatDeck.kt index 346f2d7b..1852ae8d 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/window/ChatDeck.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/window/ChatDeck.kt @@ -54,7 +54,7 @@ internal class ChatDeck( } fun subscribe(listener: (ChatEvent) -> Unit): () -> Unit { - items.forEach { listener(ChatEvent.Opened(refOf(it), it === selected)) } + listener(ChatEvent.Listed(items.map(refOf), selected?.let(idOf))) listeners += listener return { listeners -= listener } } diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/ChatPresenter.kt b/src/main/kotlin/dev/lain/claudejb/view/window/ChatPresenter.kt index c98fb9fc..b56348f7 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/window/ChatPresenter.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/window/ChatPresenter.kt @@ -36,7 +36,7 @@ internal class ChatPresenter( val registry: ChatRegistry, ) : Disposable, SessionListener { - val pushes = PushStream { edt(project) { transcript.resync() } } + val pushes: PushStream = PushStream { edt(project) { transcript.resync() } } val router = ChatBridgeRouter(this) @@ -96,7 +96,7 @@ internal class ChatPresenter( fun ref(): ChatRef = ChatRef(id, session.title) - fun emit(push: PagePush) = pushes.emit(push) + fun emit(push: PagePush): Unit = pushes.emit(push) fun exec(method: String, json: String) = emit(PagePush(method, json)) @@ -109,8 +109,6 @@ internal class ChatPresenter( } } - fun replay() = pushes.replay() - fun refreshPage() { feed.requestMcp() feed.requestVersion() diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/ChatRegistry.kt b/src/main/kotlin/dev/lain/claudejb/view/window/ChatRegistry.kt index ddcf02d2..a1fa3e3e 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/window/ChatRegistry.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/window/ChatRegistry.kt @@ -9,7 +9,6 @@ import com.intellij.openapi.components.serviceIfCreated import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.util.Disposer -import com.intellij.openapi.wm.ToolWindowManager import dev.lain.claudejb.controller.commands.TabSessionCommands import dev.lain.claudejb.controller.session.ChatSessionManager import dev.lain.claudejb.controller.session.ClaudeSession @@ -43,6 +42,8 @@ internal class ChatRegistry(private val project: Project) : Disposable { val attention = ChatAttention(project, this) + val window = ChatWindowState() + fun all(): List = deck.all() fun isEmpty(): Boolean = deck.isEmpty() @@ -80,18 +81,18 @@ internal class ChatRegistry(private val project: Project) : Disposable { fun selectedOrNew(): ChatPresenter = selected() ?: newChat() - fun select(id: ChatId) { + fun select(id: ChatId, focus: Boolean = true) { val presenter = deck.select(id) ?: return LOG.warn(unknown("select", id)) - onSelected(presenter) + onSelected(presenter, focus) } - private fun onSelected(presenter: ChatPresenter) { + private fun onSelected(presenter: ChatPresenter, focus: Boolean) { presenter.attention = false ChatSessionManager.getInstance(project).setActive(presenter.session) runCatching { presenter.transcript.showTranscript(null) } .onFailure { LOG.warn("Claude Code: showing '${presenter.session.title}' failed to reset its transcript", it) } repaint(Kind.TABS) - presenter.frontend.focusInput() + if (focus) presenter.frontend.focusInput() } fun reveal(presenter: ChatPresenter) { @@ -99,19 +100,15 @@ internal class ChatRegistry(private val project: Project) : Disposable { showToolWindow() } - fun showToolWindow() { - toolWindow()?.activate(null) - } - - fun onScreen(presenter: ChatPresenter): Boolean = toolWindow()?.isVisible == true && deck.selected === presenter + fun showToolWindow() = window.reveal() - private fun toolWindow() = ToolWindowManager.getInstance(project).getToolWindow(TOOL_WINDOW_ID) + fun onScreen(presenter: ChatPresenter): Boolean = window.visible && deck.selected === presenter fun close(id: ChatId) { val wasSelected = deck.selected?.id == id val presenter = deck.remove(id) ?: return LOG.warn(unknown("close", id)) try { - if (wasSelected) deck.selected?.let(::onSelected) + if (wasSelected) deck.selected?.let { onSelected(it, focus = true) } repaint(Kind.TABS) ChatSessionManager.getInstance(project).remove(presenter.session) } finally { @@ -159,8 +156,6 @@ internal class ChatRegistry(private val project: Project) : Disposable { } companion object { - const val TOOL_WINDOW_ID = "Claude Code" - private const val TAB_TITLE_MAX = 22 private val LOG = logger() diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/ChatTerminalHost.kt b/src/main/kotlin/dev/lain/claudejb/view/window/ChatTerminalHost.kt index 56766769..86b07058 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/window/ChatTerminalHost.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/window/ChatTerminalHost.kt @@ -10,6 +10,7 @@ internal class ChatTerminalHost(private val project: Project) : TerminalLauncher override fun open(workingDirectory: String?, tabName: String, command: String): Boolean { val chat = ChatRegistry.getInstance(project).selected() ?: return false + if (!chat.pushes.hasSinks()) return false val launch = TerminalLaunch(workingDirectory, tabName, command) chat.exec(FrontendChannel.TERMINAL, Json.encodeToString(TerminalLaunch.serializer(), launch)) return true diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/ChatWindowState.kt b/src/main/kotlin/dev/lain/claudejb/view/window/ChatWindowState.kt new file mode 100644 index 00000000..70513523 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/view/window/ChatWindowState.kt @@ -0,0 +1,18 @@ +package dev.lain.claudejb.view.window + +import java.util.concurrent.CopyOnWriteArrayList + +internal class ChatWindowState { + + private val watchers = CopyOnWriteArrayList<() -> Unit>() + + @Volatile + var visible: Boolean = false + + fun reveal() = watchers.forEach { it() } + + fun onReveal(watcher: () -> Unit): () -> Unit { + watchers += watcher + return { watchers -= watcher } + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/PushStream.kt b/src/main/kotlin/dev/lain/claudejb/view/window/PushStream.kt index 9596080b..b22273ae 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/window/PushStream.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/window/PushStream.kt @@ -2,6 +2,7 @@ package dev.lain.claudejb.view.window import dev.lain.claudejb.rpc.FrontendChannel import dev.lain.claudejb.rpc.PagePush +import dev.lain.claudejb.util.logger internal interface PushSink { fun push(push: PagePush) @@ -32,6 +33,8 @@ internal class PushStream( } } + fun hasSinks(): Boolean = synchronized(lock) { sinks.isNotEmpty() } + fun attach(sink: PushSink): () -> Unit { val resync = synchronized(lock) { if (closed) { @@ -47,15 +50,6 @@ internal class PushStream( return { synchronized(lock) { sinks -= sink } } } - fun replay() { - val resync = synchronized(lock) { - if (closed) return - sinks.forEach { replayTo(it) } - stale - } - if (resync) onStale() - } - fun close() { synchronized(lock) { if (closed) return @@ -100,6 +94,10 @@ internal class PushStream( } private fun hold(push: PagePush) { + if (FrontendChannel.isFrontendPush(push.method) && push.method != FrontendChannel.FOCUS) { + LOG.warn("Claude Code dropped '${push.method}': no chat window is listening, and it must not run later") + return + } if (pending.size >= cap) pending.removeFirst() pending.addLast(push) } @@ -109,12 +107,9 @@ internal class PushStream( const val DEFAULT_CAP = 4096 - private val CLEAR = PagePush("clear", NO_ARGS) + private val LOG = logger() - val SNAPSHOT_METHODS: Set = setOf( - "meta", "state", "session", "settingsMenu", "permissions", "tabs", "mcp", "guard", "log", - "authState", "attachments", "attachData", "gitChat", "vulnInventory", "theme", FrontendChannel.VIBE, - ) + private val CLEAR = PagePush("clear", NO_ARGS) val TRANSCRIPT_METHODS: Set = setOf( "clear", @@ -127,7 +122,7 @@ internal class PushStream( ) fun kindOf(method: String): Kind = when (method) { - in SNAPSHOT_METHODS -> Kind.SNAPSHOT + in FrontendChannel.SNAPSHOT_METHODS -> Kind.SNAPSHOT in TRANSCRIPT_METHODS -> Kind.TRANSCRIPT else -> Kind.TRANSIENT } diff --git a/src/test/kotlin/dev/lain/claudejb/frontend/window/ChatTabsPanelTest.kt b/src/test/kotlin/dev/lain/claudejb/frontend/window/ChatTabsPanelTest.kt index e61b8cbb..167f503a 100644 --- a/src/test/kotlin/dev/lain/claudejb/frontend/window/ChatTabsPanelTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/frontend/window/ChatTabsPanelTest.kt @@ -41,105 +41,130 @@ class ChatTabsPanelTest { private val built = LinkedHashMap() - private val toldHost = mutableListOf() + private var resyncs = 0 - private val tabs = ChatTabsPanel({ toldHost += it.value }) { id -> FakeCard().also { built[id.value] = it } } + private val tabs = ChatTabsPanel({ resyncs++ }) { id -> FakeCard().also { built[id.value] = it } } private fun ref(id: String) = ChatRef(ChatId(id), id) + private fun listed(vararg ids: String, selected: String? = null) = + tabs.event(ChatEvent.Listed(ids.map(::ref), selected?.let(::ChatId))) + private fun open() = tabs.chats().map { it.value } @AfterEach fun tearDown() = Disposer.dispose(tabs) @Test - fun `the first sync opens every chat and shows the last without taking the focus`() { - tabs.sync(listOf(ref("a"), ref("b"))) + fun `a listing opens every chat and shows the one the host selected without taking the focus`() { + listed("a", "b", selected = "a") assertEquals(listOf("a", "b"), open()) - assertEquals("b", tabs.selected?.value) - assertEquals(listOf("b"), toldHost) - assertEquals(0, built.getValue("b").focused) + assertEquals("a", tabs.selected?.value) + assertEquals(0, built.getValue("a").focused) } @Test - fun `closing the last chat shows its replacement even when the host does not ask to select it`() { - tabs.sync(listOf(ref("a"))) + fun `a new listing drops the chats the host no longer has and never builds one twice`() { + listed("a", "b", selected = "a") - tabs.event(ChatEvent.Closed(ChatId("a"))) - assertNull(tabs.selected) - tabs.event(ChatEvent.Opened(ref("b"), select = false)) + listed("b", "c", selected = "c") - assertEquals(listOf("b"), open()) - assertEquals("b", tabs.selected?.value) - assertTrue(built.getValue("b").component.isVisible) + assertEquals(listOf("b", "c"), open()) + assertEquals(listOf("a", "b", "c"), built.keys.toList()) + assertTrue(built.getValue("a").disposed) + assertEquals("c", tabs.selected?.value) } @Test - fun `a chat opened beside the one on screen is shown only once its page can draw`() { - tabs.sync(listOf(ref("a"))) + fun `closing the chat on screen follows the host's choice of survivor and closes nothing else`() { + listed("a", "b", "c", selected = "c") - tabs.event(ChatEvent.Opened(ref("b"), select = true)) - assertEquals("a", tabs.selected?.value) + tabs.event(ChatEvent.Selected(ChatId("a"))) + tabs.event(ChatEvent.Closed(ChatId("c"))) - built.getValue("b").pageReady() - assertEquals("b", tabs.selected?.value) - assertEquals(1, built.getValue("b").focused) + assertEquals(listOf("a", "b"), open()) + assertEquals("a", tabs.selected?.value) + assertTrue(built.getValue("a").component.isVisible) + assertFalse(built.getValue("b").disposed) } @Test - fun `a closed chat is hidden, out of the deck and disposed`() { - tabs.sync(listOf(ref("a"), ref("b"))) + fun `closing a chat that is not on screen leaves the one on screen alone`() { + listed("a", "b", selected = "b") tabs.event(ChatEvent.Closed(ChatId("a"))) + assertEquals(listOf("b"), open()) + assertEquals("b", tabs.selected?.value) val closed = built.getValue("a") assertTrue(closed.disposed) assertNull(closed.component.parent) - assertFalse(closed.component.isVisible) } @Test - fun `closing the chat on screen shows a survivor and tells the host`() { - tabs.sync(listOf(ref("a"), ref("b"))) + fun `closing the last chat shows the replacement the host opens`() { + listed("a", selected = "a") + + tabs.event(ChatEvent.Closed(ChatId("a"))) + assertNull(tabs.selected) + tabs.event(ChatEvent.Opened(ref("b"), select = true)) - tabs.event(ChatEvent.Closed(ChatId("b"))) + assertEquals(listOf("b"), open()) + assertEquals("b", tabs.selected?.value) + } + @Test + fun `a chat opened beside the one on screen is shown only once its page can draw`() { + listed("a", selected = "a") + + tabs.event(ChatEvent.Opened(ref("b"), select = true)) assertEquals("a", tabs.selected?.value) - assertEquals(listOf("b", "a"), toldHost) - assertTrue(built.getValue("a").component.isVisible) + + built.getValue("b").pageReady() + assertEquals("b", tabs.selected?.value) + assertEquals(1, built.getValue("b").focused) } @Test - fun `a resync drops the chats the host no longer has and never builds one twice`() { - tabs.sync(listOf(ref("a"), ref("b"))) - tabs.event(ChatEvent.Opened(ref("b"), select = false)) + fun `a chat still drawing does not take the screen once the host has selected another`() { + listed("a", "c", selected = "a") - tabs.sync(listOf(ref("b"), ref("c"))) + tabs.event(ChatEvent.Opened(ref("b"), select = true)) + tabs.event(ChatEvent.Selected(ChatId("c"))) + built.getValue("b").pageReady() - assertEquals(listOf("b", "c"), open()) - assertEquals(listOf("a", "b", "c"), built.keys.toList()) - assertTrue(built.getValue("a").disposed) + assertEquals("c", tabs.selected?.value) } @Test - fun `the host selecting a chat shows it and focuses its input`() { - tabs.sync(listOf(ref("a"), ref("b"))) + fun `a chat restored in the background does not take the screen`() { + listed("a", selected = "a") - tabs.event(ChatEvent.Selected(ChatId("a"))) + tabs.event(ChatEvent.Opened(ref("b"), select = false)) assertEquals("a", tabs.selected?.value) - assertEquals(1, built.getValue("a").focused) } @Test - fun `an event for a chat that is not open changes nothing`() { - tabs.sync(listOf(ref("a"))) + fun `the host selecting a chat this window lacks asks for the full list again`() { + listed("a", selected = "a") tabs.event(ChatEvent.Selected(ChatId("ghost"))) - tabs.event(ChatEvent.Closed(ChatId("ghost"))) - assertEquals(listOf("a"), open()) + assertEquals(1, resyncs) assertEquals("a", tabs.selected?.value) } + + @Test + fun `focus reaches only the chat on screen`() { + listed("a", "b", selected = "b") + + tabs.focusIfSelected(ChatId("a")) + tabs.focusIfSelected(ChatId("b")) + + assertEquals(0, built.getValue("a").focused) + assertEquals(1, built.getValue("b").focused) + assertEquals("b", tabs.selected?.value) + } } diff --git a/src/test/kotlin/dev/lain/claudejb/rpc/ChatContractTest.kt b/src/test/kotlin/dev/lain/claudejb/rpc/ChatContractTest.kt index ca635ce2..bade044c 100644 --- a/src/test/kotlin/dev/lain/claudejb/rpc/ChatContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/rpc/ChatContractTest.kt @@ -39,6 +39,8 @@ class ChatContractTest { @Test fun `every chat event keeps its kind across the wire`() { val events = listOf( + ChatEvent.Listed(listOf(chat), chat.id), + ChatEvent.Listed(emptyList(), null), ChatEvent.Opened(chat, select = true), ChatEvent.Opened(chat, select = false), ChatEvent.Closed(chat.id), diff --git a/src/test/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImplHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImplHeadlessTest.kt index b20a66dd..7655233b 100644 --- a/src/test/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImplHeadlessTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/rpc/backend/ChatApiImplHeadlessTest.kt @@ -1,11 +1,11 @@ package dev.lain.claudejb.rpc.backend import com.intellij.platform.project.projectId +import com.intellij.testFramework.PlatformTestUtil import dev.lain.claudejb.integration.FakeClaudeTestBase import dev.lain.claudejb.rpc.ChatEvent import dev.lain.claudejb.rpc.ChatId import dev.lain.claudejb.view.window.ChatRegistry -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking @@ -25,6 +25,7 @@ class ChatApiImplHeadlessTest : FakeClaudeTestBase() { override fun tearDown() { try { registry.all().map { it.id }.forEach(registry::close) + PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue() } finally { super.tearDown() } @@ -34,15 +35,16 @@ class ChatApiImplHeadlessTest : FakeClaudeTestBase() { val id = project.projectId() val events = api.events(id) val chat = api.newChat(id) - val opened = withTimeout(EVENT_TIMEOUT_MS) { events.filterIsInstance().first { it.chat.id == chat.id } } - assertEquals(chat, opened.chat) + val announced = withTimeout(EVENT_TIMEOUT_MS) { + events.first { (it is ChatEvent.Opened && it.chat == chat) || (it is ChatEvent.Listed && chat in it.chats) } + } + assertTrue(announced is ChatEvent.Opened || announced is ChatEvent.Listed) assertTrue(api.chats(id).contains(chat)) api.select(id, chat.id) assertSame(registry.presenter(chat.id), registry.selected()) val pushes = api.pushes(id, chat.id) - api.ready(id, chat.id) assertTrue(pushes.first().method.isNotBlank()) api.post(id, chat.id, """{"type":"diag","report":"from the page"}""") @@ -56,20 +58,19 @@ class ChatApiImplHeadlessTest : FakeClaudeTestBase() { api.select(id, stranger) api.close(id, stranger) api.post(id, stranger, "{}") - api.ready(id, stranger) assertEquals(emptyList(), api.pushes(id, stranger).toList()) } fun `test the host describes its gear menu and ignores an entry that is gone`() = runBlocking { val id = project.projectId() api.newChat(id) - val items = api.gear(id) + val items = HostWindowApiImpl().gear(id) val settings = items.single { it.text == "Settings…" } assertTrue(settings.enabled) assertTrue(items.any { it.separator }) assertEquals(items.size, items.map { it.path }.distinct().size) - api.runGear(id, listOf(Int.MAX_VALUE)) - api.runGear(id, emptyList()) + HostWindowApiImpl().runGear(id, listOf(Int.MAX_VALUE)) + HostWindowApiImpl().runGear(id, emptyList()) } private companion object { diff --git a/src/test/kotlin/dev/lain/claudejb/rpc/backend/ChatWindowMirrorHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/rpc/backend/ChatWindowMirrorHeadlessTest.kt new file mode 100644 index 00000000..7e8b7d01 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/rpc/backend/ChatWindowMirrorHeadlessTest.kt @@ -0,0 +1,104 @@ +package dev.lain.claudejb.rpc.backend + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.Disposer +import com.intellij.platform.project.projectId +import com.intellij.testFramework.PlatformTestUtil +import dev.lain.claudejb.frontend.window.ChatCard +import dev.lain.claudejb.frontend.window.ChatTabsPanel +import dev.lain.claudejb.integration.FakeClaudeTestBase +import dev.lain.claudejb.model.session.transcript.SessionRef +import dev.lain.claudejb.view.window.ChatRegistry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import javax.swing.JComponent +import javax.swing.JPanel + +class ChatWindowMirrorHeadlessTest : FakeClaudeTestBase() { + + private class QuietCard : ChatCard { + override val component: JComponent = JPanel() + + override fun focus() = Unit + + override fun focusTarget(): JComponent = component + + override fun whenReady(block: () -> Unit) = block() + + override fun dispose() = Unit + } + + private val api = ChatApiImpl() + + private val registry get() = ChatRegistry.getInstance(project) + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private lateinit var window: ChatTabsPanel + + override fun setUp() { + super.setUp() + manager.remove(newSessionWith(FIXTURE)) + window = ChatTabsPanel({}) { QuietCard() } + scope.launch { + api.events(project.projectId()).collect { event -> + ApplicationManager.getApplication().invokeLater { window.event(event) } + } + } + } + + override fun tearDown() { + try { + scope.cancel() + Disposer.dispose(window) + registry.all().map { it.id }.forEach(registry::close) + PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue() + } finally { + super.tearDown() + } + } + + fun `test the window keeps every chat and the selection of the host through opening and closing`() { + val first = registry.newChat() + registry.newChat() + val third = registry.newChat() + mirrors("three chats opened") + + registry.close(third.id) + mirrors("the chat on screen closed") + + registry.close(first.id) + mirrors("a chat off screen closed") + + registry.all().map { it.id }.forEach(registry::close) + waitUntil("a fresh chat replaces the last one") { registry.all().isNotEmpty() } + mirrors("every chat closed and replaced") + } + + fun `test reopening a saved session keeps the window on the host's chats`() { + val saved = manager.create() + saved.persistence.restore(SAVED_SESSION, emptyList()) + registry.open(saved, select = false) + registry.newChat() + mirrors("a saved session and a new chat") + + registry.commands.reopen(SessionRef(SAVED_SESSION, "Saved", 0L)) + mirrors("the saved session reopened") + assertEquals(SAVED_SESSION, registry.selected()?.session?.sessionId) + } + + private fun mirrors(step: String) { + waitUntil("the window matches the host after: $step") { + window.chats().toSet() == registry.all().map { it.id }.toSet() && window.selected == registry.selected()?.id + } + } + + private companion object { + const val FIXTURE = "multi_message.jsonl" + + const val SAVED_SESSION = "22222222-3333-4444-5555-666666666666" + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/view/window/ChatDeckTest.kt b/src/test/kotlin/dev/lain/claudejb/view/window/ChatDeckTest.kt index 5a1ae7f1..7e775aa6 100644 --- a/src/test/kotlin/dev/lain/claudejb/view/window/ChatDeckTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/view/window/ChatDeckTest.kt @@ -24,7 +24,7 @@ class ChatDeckTest { assertTrue(deck.add(a, select = false)) assertEquals(a, deck.selected) - assertEquals(listOf(ChatEvent.Opened(a, true)), events) + assertEquals(listOf(ChatEvent.Listed(emptyList(), null), ChatEvent.Opened(a, true)), events) } @Test @@ -74,7 +74,7 @@ class ChatDeckTest { } @Test - fun `a new subscriber is told about every open chat and which one is shown`() { + fun `a new subscriber is told every open chat and which one is shown in one listing`() { val a = chat("a") val b = chat("b") deck.add(a, select = true) @@ -82,13 +82,14 @@ class ChatDeckTest { deck.subscribe { events += it } - assertEquals(listOf(ChatEvent.Opened(a, true), ChatEvent.Opened(b, false)), events) + assertEquals(listOf(ChatEvent.Listed(listOf(a, b), a.id)), events) } @Test fun `an unsubscribed listener hears nothing more`() { val stop = deck.subscribe { events += it } stop() + events.clear() deck.add(chat("a"), select = true) diff --git a/src/test/kotlin/dev/lain/claudejb/view/window/ChatRegistryCloseHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/view/window/ChatRegistryCloseHeadlessTest.kt index dfb49270..def1e3ad 100644 --- a/src/test/kotlin/dev/lain/claudejb/view/window/ChatRegistryCloseHeadlessTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/view/window/ChatRegistryCloseHeadlessTest.kt @@ -1,5 +1,6 @@ package dev.lain.claudejb.view.window +import com.intellij.testFramework.PlatformTestUtil import dev.lain.claudejb.integration.FakeClaudeTestBase import dev.lain.claudejb.model.session.transcript.SessionRef @@ -15,6 +16,7 @@ class ChatRegistryCloseHeadlessTest : FakeClaudeTestBase() { override fun tearDown() { try { registry.all().map { it.id }.forEach(registry::close) + PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue() } finally { super.tearDown() } diff --git a/src/test/kotlin/dev/lain/claudejb/view/window/PushStreamTest.kt b/src/test/kotlin/dev/lain/claudejb/view/window/PushStreamTest.kt index d23b0171..3fb37d03 100644 --- a/src/test/kotlin/dev/lain/claudejb/view/window/PushStreamTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/view/window/PushStreamTest.kt @@ -3,6 +3,7 @@ package dev.lain.claudejb.view.window import dev.lain.claudejb.rpc.FrontendChannel import dev.lain.claudejb.rpc.PagePush import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -71,29 +72,63 @@ class PushStreamTest { } @Test - fun `a one-shot command sent while a page listens is never replayed`() { + fun `a side effect sent while no window listens is dropped instead of running later`() { val stream = stream() + listOf(FrontendChannel.COPY, FrontendChannel.BROWSE, FrontendChannel.TERMINAL, FrontendChannel.ACTION).forEach { + stream.emit(PagePush(it, "\"x\"")) + } + stream.emit(PagePush("openDashboard", PushStream.NO_ARGS)) + val sink = Recorder() stream.attach(sink) + + assertEquals(listOf("clear", "openDashboard"), sink.methods()) + } + + @Test + fun `a one-shot command sent while a page listens is never replayed to the page that attaches again`() { + val stream = stream() + val detach = stream.attach(Recorder()) stream.emit(PagePush(FrontendChannel.COPY, "\"text\"")) - sink.pushes.clear() + stream.emit(PagePush("session", "{}")) + detach() - stream.replay() + val again = Recorder() + stream.attach(again) - assertTrue(FrontendChannel.COPY !in sink.methods()) + assertEquals(listOf("session", "clear"), again.methods()) } @Test - fun `ready replays to every collector already attached`() { + fun `a page that attaches again gets the replay and the others do not`() { val stream = stream() - val sink = Recorder() - stream.attach(sink) + val other = Recorder() + stream.attach(other) + val detach = stream.attach(Recorder()) stream.emit(PagePush("session", "{}")) - sink.pushes.clear() + other.pushes.clear() + detach() + + stream.attach(Recorder()) + + assertTrue(other.pushes.isEmpty()) + } - stream.replay() + @Test + fun `the stream knows whether any window listens`() { + val stream = stream() + assertFalse(stream.hasSinks()) - assertEquals(listOf("session", "clear"), sink.methods()) + val detach = stream.attach(Recorder()) + assertTrue(stream.hasSinks()) + + detach() + assertFalse(stream.hasSinks()) + } + + @Test + fun `the host keeps every method the client keys as a snapshot`() { + FrontendChannel.SNAPSHOT_METHODS.forEach { assertEquals(PushStream.Kind.SNAPSHOT, PushStream.kindOf(it), it) } } @Test From 6f5e6b75796d8e2d8d2465f7eb479819ec3066aa Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 09:14:07 +0200 Subject: [PATCH 137/155] fix(clipboard): read the clipboard on the client with the wl-paste and xclip fallback, and notice when it is empty --- .../lain/claudejb/frontend/window/ChatView.kt | 23 ++- .../frontend/window/ClientClipboard.kt | 29 +++- .../claudejb/frontend/window/ClipboardCli.kt | 153 ++++++++++++++++++ .../claudejb/frontend/window/PageMessage.kt | 18 ++- .../dev/lain/claudejb/rpc/FrontendChannel.kt | 6 + .../controller/bridge/BridgeAttachments.kt | 11 ++ .../lain/claudejb/model/bridge/JcefBridge.kt | 3 + .../dev/lain/claudejb/model/bridge/Msg.kt | 1 + .../claudejb/view/payload/chat/JcefState.kt | 6 - .../frontend/window/ClipboardCliTest.kt | 47 ++++++ 10 files changed, 275 insertions(+), 22 deletions(-) create mode 100644 frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClipboardCli.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/frontend/window/ClipboardCliTest.kt diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatView.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatView.kt index 491b1c6e..d6e40185 100644 --- a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatView.kt +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ChatView.kt @@ -25,7 +25,7 @@ internal class ChatView( private val host = JcefHost(this, ::onPageMessage, onResync = ::resync) - private val link = client.link(chatId, this, ::onPush) { host.isWebReady } + private val link = client.link(chatId, this, ::onPush) override val component: JComponent get() = host.component @@ -46,7 +46,7 @@ internal class ChatView( private fun onPageMessage(json: String) { val message = PageMessage.parse(json) when { - message.type in FrontendChannel.clientMessages -> paste(message.type) + message.type in FrontendChannel.clientMessages -> paste(message) message.type == PageMessage.OPEN && PageMessage.isSecureLink(message.url) -> BrowserUtil.browse(message.url.trim()) @@ -60,6 +60,7 @@ internal class ChatView( private fun pageReady() { host.markWebReady() pushTheme() + host.exec("window.CC && window.CC.composer && (window.CC.composer.hostClipboard = ${ClientClipboard.preferHost})") link.ready() } @@ -89,16 +90,24 @@ internal class ChatView( edtNow { terminal.open(launch) } } - private fun paste(type: String) { + private fun paste(message: PageMessage) { ApplicationManager.getApplication().executeOnPooledThread { - val image = ClientClipboard.pngBase64() - when { - image != null -> link.post(PageMessage.imageAttachment(image)) - type == PageMessage.PASTE_TEXT -> ClientClipboard.text()?.let { host.call(INSERT_TEXT, PageMessage.quote(it)) } + val image = ClientClipboard.image() + if (image != null) { + link.post(PageMessage.imageAttachment(image)) + return@executeOnPooledThread + } + if (message.type == PageMessage.PASTE_TEXT) { + val text = ClientClipboard.text() + if (text != null) host.call(INSERT_TEXT, PageMessage.quote(text)) else emptyClipboard(image = false) + } else if (message.notify || !ClientClipboard.hasText()) { + emptyClipboard(image = true) } } } + private fun emptyClipboard(image: Boolean) = link.post(PageMessage.clipboardEmpty(image, ClientClipboard.imageHelp())) + private fun pushTheme() = edtNow { host.exec("window.cc.theme && window.cc.theme(" + JcefTheme.vars() + ")", THEME_KEY) } diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClientClipboard.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClientClipboard.kt index ce933b42..fe851ec6 100644 --- a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClientClipboard.kt +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClientClipboard.kt @@ -3,26 +3,47 @@ package dev.lain.claudejb.frontend.window import com.intellij.openapi.ide.CopyPasteManager import com.intellij.util.ui.ImageUtil import java.awt.Image +import java.awt.Toolkit import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.StringSelection import java.io.ByteArrayOutputStream -import java.util.Base64 import javax.imageio.ImageIO internal object ClientClipboard { + val preferHost: Boolean by lazy { + runCatching { Toolkit.getDefaultToolkit().javaClass.name == WAYLAND_TOOLKIT }.getOrDefault(false) + } + + fun image(): ClipImage? = awtImage() ?: ClipboardCli.image() + fun text(): String? = runCatching { CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor) } .getOrNull() ?.takeIf { it.isNotEmpty() } + ?: ClipboardCli.text() + + fun hasText(): Boolean { + val awt = runCatching { CopyPasteManager.getInstance().areDataFlavorsAvailable(DataFlavor.stringFlavor) } + .getOrDefault(false) + return awt || ClipboardCli.textType() != null + } - fun pngBase64(): String? { + fun imageHelp(): String? { + if (!ClipboardCli.isLinux()) return null + if (ClipboardCli.findExecutable("wl-paste") != null || ClipboardCli.findExecutable("xclip") != null) return null + return "image paste needs 'wl-clipboard' (Wayland) or 'xclip' (X11): " + ClipboardCli.installHint() + } + + fun copy(text: String) = CopyPasteManager.getInstance().setContents(StringSelection(text)) + + private fun awtImage(): ClipImage? { val image = runCatching { CopyPasteManager.getInstance().getContents(DataFlavor.imageFlavor) } .getOrNull() ?: return null val out = ByteArrayOutputStream() val written = runCatching { ImageIO.write(ImageUtil.toBufferedImage(image), "png", out) }.getOrDefault(false) - return if (written) Base64.getEncoder().encodeToString(out.toByteArray()) else null + return if (written) ClipImage("image/png", out.toByteArray()) else null } - fun copy(text: String) = CopyPasteManager.getInstance().setContents(StringSelection(text)) + private const val WAYLAND_TOOLKIT = "sun.awt.wl.WLToolkit" } diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClipboardCli.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClipboardCli.kt new file mode 100644 index 00000000..f93b7084 --- /dev/null +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/ClipboardCli.kt @@ -0,0 +1,153 @@ +package dev.lain.claudejb.frontend.window + +import com.intellij.openapi.diagnostic.logger +import java.io.File +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +internal class ClipImage(val mime: String, val bytes: ByteArray) + +internal object ClipboardCli { + + private val log = logger() + + fun isLinux() = System.getProperty("os.name").orEmpty().lowercase().contains("linux") + + fun findExecutable(name: String): String? { + val dirs = LinkedHashSet() + System.getenv("PATH")?.split(File.pathSeparatorChar)?.forEach { if (it.isNotBlank()) dirs.add(it) } + dirs.addAll(COMMON_BIN_DIRS) + return dirs.map { File(it, name) }.firstOrNull { it.isFile && it.canExecute() }?.absolutePath + } + + fun image(): ClipImage? { + if (!isLinux()) return null + val wlPaste = findExecutable("wl-paste") + val xclip = findExecutable("xclip") + wlPaste?.let { typed(listOf(it, "--list-types")) { type -> listOf(it, "-t", type) } }?.let { return it } + xclip?.let { x -> + typed(listOf(x, "-selection", "clipboard", "-t", "TARGETS", "-o")) { type -> + listOf(x, "-selection", "clipboard", "-t", type, "-o") + } + }?.let { return it } + wlPaste?.let { fromUriList(listOf(it, "-t", "text/uri-list")) }?.let { return it } + return xclip?.let { fromUriList(listOf(it, "-selection", "clipboard", "-t", "text/uri-list", "-o")) } + } + + fun text(): String? { + val type = textType() ?: return null + val command = textCommand(type) ?: return null + return run(command)?.toString(Charsets.UTF_8)?.takeIf { it.isNotEmpty() } + } + + fun textType(): String? { + if (!isLinux()) return null + findExecutable("wl-paste")?.let { preferredTextType(listTypes(listOf(it, "--list-types")))?.let { t -> return t } } + return findExecutable("xclip")?.let { preferredTextType(listTypes(listOf(it, "-selection", "clipboard", "-t", "TARGETS", "-o"))) } + } + + fun preferredTextType(types: List): String? { + fun first(p: (String) -> Boolean) = types.firstOrNull(p) + return first { it.equals("text/plain;charset=utf-8", ignoreCase = true) } + ?: first { it == "UTF8_STRING" } + ?: first { it.equals("text/plain", ignoreCase = true) } + ?: first { it == "STRING" || it == "TEXT" } + ?: first { + it.startsWith("text/", ignoreCase = true) && + !it.equals("text/uri-list", ignoreCase = true) && + !it.startsWith("text/html", ignoreCase = true) + } + } + + fun installHint(): String { + val release = runCatching { File("/etc/os-release").readText().lowercase() }.getOrDefault("") + return when { + listOf("fedora", "rhel", "centos", "rocky", "alma").any { it in release } -> "sudo dnf install wl-clipboard" + listOf("debian", "ubuntu", "mint", "pop").any { it in release } -> "sudo apt install wl-clipboard" + "arch" in release || "manjaro" in release -> "sudo pacman -S wl-clipboard" + "opensuse" in release || "suse" in release -> "sudo zypper install wl-clipboard" + else -> "install 'wl-clipboard' (or 'xclip') with your package manager" + } + } + + fun imageType(types: List): String? = + types.firstOrNull { it == "image/png" } + ?: types.firstOrNull { it == "image/jpeg" || it == "image/jpg" } + ?: types.firstOrNull { it.startsWith("image/") } + + fun mimeOfPath(path: String): String? = when (path.substringAfterLast('.', "").lowercase()) { + "png" -> "image/png" + "jpg", "jpeg" -> "image/jpeg" + "gif" -> "image/gif" + "webp" -> "image/webp" + else -> null + } + + private fun textCommand(type: String): List? = + findExecutable("wl-paste")?.let { listOf(it, "-t", type, "-n") } + ?: findExecutable("xclip")?.let { listOf(it, "-selection", "clipboard", "-t", type, "-o") } + + private fun typed(listCommand: List, readCommand: (String) -> List): ClipImage? { + val type = imageType(listTypes(listCommand)) ?: return null + val bytes = run(readCommand(type)) ?: return null + return ClipImage(if (type == "image/jpg") "image/jpeg" else type, bytes) + } + + private fun fromUriList(command: List): ClipImage? { + val listing = run(command)?.toString(Charsets.UTF_8) ?: return null + return listing.lineSequence().map { it.trim() } + .filter { it.isNotEmpty() && !it.startsWith("#") } + .mapNotNull(::pathOf) + .firstNotNullOfOrNull(::imageFile) + } + + private fun imageFile(path: String): ClipImage? { + val mime = mimeOfPath(path) ?: return null + val file = File(path).takeIf { it.isFile && it.length() in 1..MAX_FILE_BYTES } ?: return null + return runCatching { ClipImage(mime, file.readBytes()) }.getOrNull() + } + + private fun pathOf(uri: String): String? = runCatching { + when { + uri.startsWith("file://") -> File(java.net.URI(uri)).path + uri.startsWith("/") -> uri + else -> null + } + }.getOrNull() + + private fun listTypes(command: List): List = + run(command)?.toString(Charsets.UTF_8)?.lineSequence()?.map { it.trim() }?.filter { it.isNotEmpty() }?.toList() + .orEmpty() + + private fun run(command: List): ByteArray? = runCatching { + val process = ProcessBuilder(command).redirectError(ProcessBuilder.Redirect.DISCARD).start() + val reader = CompletableFuture.supplyAsync { runCatching { process.inputStream.readBytes() }.getOrNull() } + val bytes = try { + reader.get(READ_SECONDS, TimeUnit.SECONDS) + } catch (e: TimeoutException) { + log.debug("Clipboard helper ${command.firstOrNull()} timed out; killing it", e) + process.destroyForcibly() + reader.cancel(true) + return@runCatching null + } + if (!process.waitFor(1, TimeUnit.SECONDS)) { + process.destroyForcibly() + return@runCatching null + } + bytes?.takeIf { process.exitValue() == 0 && it.isNotEmpty() } + }.getOrNull() + + private const val READ_SECONDS = 3L + + private const val MAX_FILE_BYTES = 20L * 1024 * 1024 + + private val COMMON_BIN_DIRS: List by lazy { + val home = System.getProperty("user.home").orEmpty() + listOf( + "/usr/bin", "/bin", "/usr/local/bin", "/usr/sbin", "/sbin", + "/run/current-system/sw/bin", "/var/lib/flatpak/exports/bin", "/snap/bin", + "/opt/homebrew/bin", "/usr/local/sbin", "$home/.local/bin", "$home/bin", + ) + } +} diff --git a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/PageMessage.kt b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/PageMessage.kt index da366af4..3e48e69b 100644 --- a/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/PageMessage.kt +++ b/frontend/src/main/kotlin/dev/lain/claudejb/frontend/window/PageMessage.kt @@ -4,13 +4,15 @@ import dev.lain.claudejb.rpc.FrontendChannel import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put +import java.util.Base64 -internal class PageMessage(val type: String, val url: String) { +internal class PageMessage(val type: String, val url: String, val notify: Boolean = false) { companion object { const val READY = "ready" @@ -24,7 +26,7 @@ internal class PageMessage(val type: String, val url: String) { fun parse(text: String): PageMessage { val obj = runCatching { lenient.parseToJsonElement(text).jsonObject }.getOrNull() - return PageMessage(obj.field("type"), obj.field("url")) + return PageMessage(obj.field("type"), obj.field("url"), (obj?.get("notify") as? JsonPrimitive)?.booleanOrNull == true) } fun string(json: String): String? = @@ -32,10 +34,16 @@ internal class PageMessage(val type: String, val url: String) { fun quote(text: String): String = JsonPrimitive(text).toString() - fun imageAttachment(base64: String): String = buildJsonObject { + fun imageAttachment(image: ClipImage): String = buildJsonObject { put("type", FrontendChannel.ATTACH_IMAGE) - put("mime", "image/png") - put("base64", base64) + put("mime", image.mime) + put("base64", Base64.getEncoder().encodeToString(image.bytes)) + }.toString() + + fun clipboardEmpty(image: Boolean, help: String?): String = buildJsonObject { + put("type", FrontendChannel.CLIPBOARD_EMPTY) + put("image", image) + put("help", help.orEmpty()) }.toString() fun isSecureLink(url: String): Boolean = url.trim().startsWith("https://", ignoreCase = true) diff --git a/shared/src/main/kotlin/dev/lain/claudejb/rpc/FrontendChannel.kt b/shared/src/main/kotlin/dev/lain/claudejb/rpc/FrontendChannel.kt index 2ced668a..f042cd4b 100644 --- a/shared/src/main/kotlin/dev/lain/claudejb/rpc/FrontendChannel.kt +++ b/shared/src/main/kotlin/dev/lain/claudejb/rpc/FrontendChannel.kt @@ -9,8 +9,14 @@ object FrontendChannel { const val TERMINAL = "frontend.terminal" const val ACTION = "frontend.action" const val ATTACH_IMAGE = "attachImageData" + const val CLIPBOARD_EMPTY = "clipboardEmpty" val clientMessages: Set = setOf("pasteClipboard", "pasteClipboardImage") + val SNAPSHOT_METHODS: Set = setOf( + "meta", "state", "session", "settingsMenu", "permissions", "tabs", "mcp", "guard", "log", + "authState", "attachments", "attachData", "gitChat", "vulnInventory", "theme", VIBE, + ) + fun isFrontendPush(method: String): Boolean = method.startsWith(PREFIX) } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeAttachments.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeAttachments.kt index d839adee..a0ec4d54 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeAttachments.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeAttachments.kt @@ -27,6 +27,17 @@ internal class BridgeAttachments(private val presenter: ChatPresenter) { is Msg.AttachPaths -> attachPaths(m.paths) is Msg.Attach -> attachImage(m.name, m.mediaType, m.base64) is Msg.AttachImageData -> attachImage(PASTED_NAME + m.mime.substringAfter('/'), m.mime, m.base64) + is Msg.ClipboardEmpty -> tray.notify(clipboardNotice(m)) + } + } + + private fun clipboardNotice(m: Msg.ClipboardEmpty): String { + val help = m.help.ifBlank { null } + return when { + m.image && help != null -> "Couldn't read an image from the clipboard — $help" + m.image -> "No image found in the clipboard." + help != null -> "Couldn't read the clipboard — $help" + else -> "Clipboard is empty or unreadable." } } diff --git a/src/main/kotlin/dev/lain/claudejb/model/bridge/JcefBridge.kt b/src/main/kotlin/dev/lain/claudejb/model/bridge/JcefBridge.kt index cbef8d18..f7bb6cf1 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/bridge/JcefBridge.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/bridge/JcefBridge.kt @@ -20,6 +20,8 @@ object JcefBridge { const val SCOPE_GIT = "git" + private const val HELP_MAX = 200 + private class Fields(val obj: JsonObject) { fun str(key: String): String? = (obj[key] as? JsonPrimitive)?.contentOrNull fun text(key: String): String = str(key).orEmpty() @@ -115,6 +117,7 @@ object JcefBridge { "attachCurrentFile" -> Msg.AttachCurrentFile "attach" -> Msg.Attach(f.text("name"), f.text("mediaType"), f.text("base64")) "attachImageData" -> Msg.AttachImageData(f.text("mime"), f.text("base64")) + "clipboardEmpty" -> Msg.ClipboardEmpty(f.bool("image"), f.text("help").take(HELP_MAX)) "treeChildren" -> Msg.TreeChildren(f.text("path"), f.text("mode")) "treeExpand" -> Msg.TreeExpand(f.text("path"), f.text("mode")) "attachPaths" -> Msg.AttachPaths(f.strings("paths")) diff --git a/src/main/kotlin/dev/lain/claudejb/model/bridge/Msg.kt b/src/main/kotlin/dev/lain/claudejb/model/bridge/Msg.kt index d7043088..3e4127cb 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/bridge/Msg.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/bridge/Msg.kt @@ -86,6 +86,7 @@ sealed interface Msg { object AttachCurrentFile : Attachments data class Attach(val name: String, val mediaType: String, val base64: String) : Attachments data class AttachImageData(val mime: String, val base64: String) : Attachments + data class ClipboardEmpty(val image: Boolean, val help: String) : Attachments object McpRefresh : SessionControl data class McpReconnect(val name: String) : SessionControl diff --git a/src/main/kotlin/dev/lain/claudejb/view/payload/chat/JcefState.kt b/src/main/kotlin/dev/lain/claudejb/view/payload/chat/JcefState.kt index a6eea3eb..02851a1a 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/payload/chat/JcefState.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/payload/chat/JcefState.kt @@ -125,7 +125,6 @@ object JcefState { } }, ) - put("hostClipboard", hostClipboardPreferred) put("gitIntegration", session.gitIntegration) put( "installMethods", @@ -143,9 +142,4 @@ object JcefState { } return obj.toString() } - - private val hostClipboardPreferred: Boolean by lazy { - runCatching { java.awt.Toolkit.getDefaultToolkit().javaClass.name == "sun.awt.wl.WLToolkit" } - .getOrDefault(false) - } } diff --git a/src/test/kotlin/dev/lain/claudejb/frontend/window/ClipboardCliTest.kt b/src/test/kotlin/dev/lain/claudejb/frontend/window/ClipboardCliTest.kt new file mode 100644 index 00000000..b503283c --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/frontend/window/ClipboardCliTest.kt @@ -0,0 +1,47 @@ +package dev.lain.claudejb.frontend.window + +import dev.lain.claudejb.model.bridge.JcefBridge +import dev.lain.claudejb.model.bridge.Msg +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class ClipboardCliTest { + + @Test + fun `plain utf-8 text wins over html and file lists`() { + val types = listOf("text/html", "text/uri-list", "TEXT", "text/plain;charset=utf-8") + + assertEquals("text/plain;charset=utf-8", ClipboardCli.preferredTextType(types)) + assertEquals("UTF8_STRING", ClipboardCli.preferredTextType(listOf("STRING", "UTF8_STRING"))) + assertNull(ClipboardCli.preferredTextType(listOf("text/html", "text/uri-list", "image/png"))) + } + + @Test + fun `png is read before any other image the clipboard offers`() { + assertEquals("image/png", ClipboardCli.imageType(listOf("image/bmp", "image/jpeg", "image/png"))) + assertEquals("image/jpeg", ClipboardCli.imageType(listOf("text/plain", "image/jpeg"))) + assertNull(ClipboardCli.imageType(listOf("text/plain"))) + } + + @Test + fun `a copied file is attached only when it is an image the host accepts`() { + assertEquals("image/jpeg", ClipboardCli.mimeOfPath("/tmp/shot.JPG")) + assertEquals("image/webp", ClipboardCli.mimeOfPath("/tmp/shot.webp")) + assertNull(ClipboardCli.mimeOfPath("/tmp/notes.txt")) + } + + @Test + fun `an empty clipboard reaches the host as a notice it knows how to word`() { + val parsed = JcefBridge.parse(PageMessage.clipboardEmpty(image = true, help = "install xclip")) + + assertEquals(Msg.ClipboardEmpty(image = true, help = "install xclip"), parsed) + assertEquals(Msg.ClipboardEmpty(image = false, help = ""), JcefBridge.parse(PageMessage.clipboardEmpty(false, null))) + } + + @Test + fun `the image menu asks the client to say so when nothing is found`() { + assertEquals(true, PageMessage.parse("""{"type":"pasteClipboardImage","notify":true}""").notify) + assertEquals(false, PageMessage.parse("""{"type":"pasteClipboard"}""").notify) + } +} From 62297721dc369584413d685e6aae2accf2d20b54 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 09:14:10 +0200 Subject: [PATCH 138/155] fix(modules): load the IntelliLang module through intellij.platform.langInjection and give the database tools their own optional module --- .github/workflows/ci.yml | 2 +- .gitignore | 1 + build.gradle.kts | 5 ++- database/build.gradle.kts | 6 +++ .../lain/claudejb/controller/db/DbGateway.kt | 43 ++++++------------- .../resources/dev.lain.claudejb.database.xml | 11 +++++ gradle/coverage.gradle.kts | 2 +- gradle/quality.gradle.kts | 3 +- .../dev.lain.claudejb.intellilang.xml | 2 +- settings.gradle.kts | 2 +- .../lain/claudejb/controller/db/DbAccess.kt | 27 ++++++++++++ .../controller/mcp/tools/ops/DbTools.kt | 14 +++--- src/main/resources/META-INF/plugin.xml | 1 + .../kotlin/dev/lain/claudejb/SourceLayout.kt | 4 +- .../controller/db/DbGatewayContractTest.kt | 2 +- .../headless/OptionalModulesHeadlessTest.kt | 36 ++++++++++++++++ 16 files changed, 118 insertions(+), 43 deletions(-) create mode 100644 database/build.gradle.kts rename {src => database/src}/main/kotlin/dev/lain/claudejb/controller/db/DbGateway.kt (80%) create mode 100644 database/src/main/resources/dev.lain.claudejb.database.xml create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/db/DbAccess.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/headless/OptionalModulesHeadlessTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9cfca85..0e91f55e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -534,7 +534,7 @@ jobs: unzip -Z -1 "$jar" | grep -v '/$' \ | grep -vE '^dev/lain/claudejb/.*\.class$' \ | grep -vE '^META-INF/' \ - | grep -vE '^dev\.lain\.claudejb\.(shared|frontend|backend|git|github|java|intellilang|terminal|bookmarks)\.xml$' \ + | grep -vE '^dev\.lain\.claudejb\.(shared|frontend|backend|git|github|java|intellilang|terminal|bookmarks|database)\.xml$' \ | grep -vE '^icons/[^/]+\.svg$' \ | grep -vE '^jcef/(.+\.(js|html)|css/.+\.css)$' >> /tmp/unexpected.txt || true done < /tmp/jars.txt diff --git a/.gitignore b/.gitignore index 039f8287..da19c62e 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ !/java/** !/intellilang/** !/bookmarks/** +!/database/** !/terminal/** !/bin/** !/gradle/** diff --git a/build.gradle.kts b/build.gradle.kts index 4ea2e552..3dd98e0e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,7 +20,8 @@ version = "6.5.0" val platformBuild = "262.8665.258" val serialization = "1.9.0" -val pluginModules = listOf("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks") +val pluginModules = + listOf("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks", "database") allprojects { plugins.withId("org.jetbrains.kotlin.jvm") { @@ -87,7 +88,9 @@ dependencies { "Git4Idea", "org.jetbrains.plugins.github", "com.intellij.java", + "com.intellij.database", ) + bundledModule("org.intellij.intelliLang") testFramework(TestFrameworkType.Platform) } pluginModules.forEach { testImplementation(project(":$it")) } diff --git a/database/build.gradle.kts b/database/build.gradle.kts new file mode 100644 index 00000000..6ddce77f --- /dev/null +++ b/database/build.gradle.kts @@ -0,0 +1,6 @@ +dependencies { + intellijPlatform { + bundledModule("intellij.platform.backend") + } + implementation(project(":backend")) +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/db/DbGateway.kt b/database/src/main/kotlin/dev/lain/claudejb/controller/db/DbGateway.kt similarity index 80% rename from src/main/kotlin/dev/lain/claudejb/controller/db/DbGateway.kt rename to database/src/main/kotlin/dev/lain/claudejb/controller/db/DbGateway.kt index 5973090b..0b083578 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/db/DbGateway.kt +++ b/database/src/main/kotlin/dev/lain/claudejb/controller/db/DbGateway.kt @@ -1,6 +1,5 @@ package dev.lain.claudejb.controller.db -import com.intellij.execution.services.ServiceViewContributor import com.intellij.ide.plugins.PluginManagerCore import com.intellij.openapi.project.Project import dev.lain.claudejb.model.mcp.ToolException @@ -9,41 +8,33 @@ import java.lang.reflect.InvocationTargetException import java.lang.reflect.Method import java.lang.reflect.Modifier -internal class DbGateway(private val project: Project) { - - class Connection(val name: String, val kind: String, val url: String) - - class Table(val schema: String, val name: String, val kind: String, val columns: Int) - - class Column(val name: String, val type: String, val nullable: Boolean, val primary: Boolean) - - class Rows(val columns: List, val rows: List>, val updated: Int, val truncated: Boolean) +internal class DbGateway(private val project: Project) : DbAccess { private fun requireDatabase() { if (!isAvailable()) throw ToolException(MISSING) } - fun connections(): List { + override fun connections(): List { requireDatabase() return dataSources().map { source -> - Connection(name(source), text { call(source, "getDbms") }, redacted(text { call(delegate(source), "getUrl") })) + DbAccess.Connection(name(source), text { call(source, "getDbms") }, redacted(text { call(delegate(source), "getUrl") })) } } - fun tables(connection: String): List
    { + override fun tables(connection: String): List { requireDatabase() return tablesOf(dataSource(connection)).map { table -> - Table(schema(table), name(table), text { call(table, "getKind") }.lowercase(), columnsOf(table).size) + DbAccess.Table(schema(table), name(table), text { call(table, "getKind") }.lowercase(), columnsOf(table).size) } } - fun columns(connection: String, table: String): List { + override fun columns(connection: String, table: String): List { requireDatabase() val found = tablesOf(dataSource(connection)).filter { matches(it, table) } if (found.isEmpty()) throw ToolException("no table named $table in $connection; db_schema without table lists them") if (found.size > 1) throw ToolException("$table is ambiguous; qualify it: ${found.joinToString { qualified(it) }}") return columnsOf(found.single()).map { column -> - Column( + DbAccess.Column( name(column), text { typeName(call(column, "getDataType")) }, !flag { call(column, "isNotNull") }, @@ -52,7 +43,7 @@ internal class DbGateway(private val project: Project) { } } - fun query(connection: String, sql: String, maxRows: Int, timeoutSeconds: Int): Rows { + override fun query(connection: String, sql: String, maxRows: Int, timeoutSeconds: Int): DbAccess.Rows { requireDatabase() val source = dataSource(connection) val builder = call(static(type(CONNECTION_MANAGER), "getInstance"), "build", project, delegate(source)) @@ -74,9 +65,10 @@ internal class DbGateway(private val project: Project) { } } - private fun updated(statement: Any?): Rows = Rows(emptyList(), emptyList(), (call(statement, "getUpdateCount") as? Int) ?: 0, false) + private fun updated(statement: Any?): DbAccess.Rows = + DbAccess.Rows(emptyList(), emptyList(), (call(statement, "getUpdateCount") as? Int) ?: 0, false) - private fun rows(resultSet: Any?, maxRows: Int): Rows { + private fun rows(resultSet: Any?, maxRows: Int): DbAccess.Rows { try { val meta = call(resultSet, "getMetaData") val width = (call(meta, "getColumnCount") as? Int) ?: 0 @@ -85,7 +77,7 @@ internal class DbGateway(private val project: Project) { while (collected.size <= maxRows && call(resultSet, "next") == true) { collected += (1..width).map { text { call(resultSet, "getString", it) } } } - return Rows(labels, collected.take(maxRows), 0, collected.size > maxRows) + return DbAccess.Rows(labels, collected.take(maxRows), 0, collected.size > maxRows) } finally { runCatching { call(resultSet, "close") } } @@ -135,15 +127,7 @@ internal class DbGateway(private val project: Project) { private fun typeName(dataType: Any?): Any? = dataType?.javaClass?.getField("typeName")?.get(dataType) private fun type(name: String): Class<*> = - loaders().firstNotNullOfOrNull { loader -> runCatching { loader.loadClass(name) }.getOrNull() } - ?: throw ToolException(notExposed(name)) - - private fun loaders(): List { - val modules = ServiceViewContributor.CONTRIBUTOR_EP_NAME.extensionList - .filter { it.javaClass.name.startsWith(PACKAGE) } - .map { it.javaClass.classLoader } - return (listOf(javaClass.classLoader) + modules).distinct() - } + runCatching { javaClass.classLoader.loadClass(name) }.getOrNull() ?: throw ToolException(notExposed(name)) private fun static(type: Class<*>, name: String, vararg args: Any?): Any? { val method = type.methods.firstOrNull { it.name == name && Modifier.isStatic(it.modifiers) && accepts(it, args) } @@ -178,7 +162,6 @@ internal class DbGateway(private val project: Project) { companion object { const val PLUGIN_ID = "com.intellij.database" - private const val PACKAGE = "com.intellij.database." private const val FACADE = "com.intellij.database.psi.DbPsiFacade" private const val DAS_UTIL = "com.intellij.database.util.DasUtil" diff --git a/database/src/main/resources/dev.lain.claudejb.database.xml b/database/src/main/resources/dev.lain.claudejb.database.xml new file mode 100644 index 00000000..293e6e75 --- /dev/null +++ b/database/src/main/resources/dev.lain.claudejb.database.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/gradle/coverage.gradle.kts b/gradle/coverage.gradle.kts index b00f3c4c..5e1925e2 100644 --- a/gradle/coverage.gradle.kts +++ b/gradle/coverage.gradle.kts @@ -23,7 +23,7 @@ tasks.matching { it.name.startsWith("kover") && (it.name.contains("Report") || i } dependencies { - listOf("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks").forEach { + listOf("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks", "database").forEach { "kover"(project(":$it")) } } diff --git a/gradle/quality.gradle.kts b/gradle/quality.gradle.kts index e252bdc6..f80cdac2 100644 --- a/gradle/quality.gradle.kts +++ b/gradle/quality.gradle.kts @@ -3,7 +3,8 @@ import dev.detekt.gradle.Detekt import dev.detekt.gradle.DetektCreateBaselineTask import dev.detekt.gradle.extensions.DetektExtension -val pluginModules = listOf("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks") +val pluginModules = + listOf("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks", "database") configure { buildUponDefaultConfig.set(true) diff --git a/intellilang/src/main/resources/dev.lain.claudejb.intellilang.xml b/intellilang/src/main/resources/dev.lain.claudejb.intellilang.xml index 8d1cfdd3..588a28ca 100644 --- a/intellilang/src/main/resources/dev.lain.claudejb.intellilang.xml +++ b/intellilang/src/main/resources/dev.lain.claudejb.intellilang.xml @@ -1,6 +1,6 @@ - + diff --git a/settings.gradle.kts b/settings.gradle.kts index 93beded3..6e3d27b9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,4 +29,4 @@ dependencyResolutionManagement { } } -include("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks") +include("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks", "database") diff --git a/src/main/kotlin/dev/lain/claudejb/controller/db/DbAccess.kt b/src/main/kotlin/dev/lain/claudejb/controller/db/DbAccess.kt new file mode 100644 index 00000000..1889f3ed --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/db/DbAccess.kt @@ -0,0 +1,27 @@ +package dev.lain.claudejb.controller.db + +import com.intellij.openapi.components.serviceOrNull +import com.intellij.openapi.project.Project + +interface DbAccess { + + class Connection(val name: String, val kind: String, val url: String) + + class Table(val schema: String, val name: String, val kind: String, val columns: Int) + + class Column(val name: String, val type: String, val nullable: Boolean, val primary: Boolean) + + class Rows(val columns: List, val rows: List>, val updated: Int, val truncated: Boolean) + + fun connections(): List + + fun tables(connection: String): List
    + + fun columns(connection: String, table: String): List + + fun query(connection: String, sql: String, maxRows: Int, timeoutSeconds: Int): Rows + + companion object { + fun of(project: Project): DbAccess? = project.serviceOrNull() + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/DbTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/DbTools.kt index 2fe319a4..abbdfd10 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/DbTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/DbTools.kt @@ -2,12 +2,13 @@ package dev.lain.claudejb.controller.mcp.tools.ops import com.intellij.openapi.application.readAction import com.intellij.openapi.project.Project -import dev.lain.claudejb.controller.db.DbGateway +import dev.lain.claudejb.controller.db.DbAccess import dev.lain.claudejb.model.mcp.Batch import dev.lain.claudejb.model.mcp.Param import dev.lain.claudejb.model.mcp.Tool import dev.lain.claudejb.model.mcp.ToolArgs import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException import dev.lain.claudejb.model.mcp.ToolResult import dev.lain.claudejb.model.mcp.ToolSpec import kotlinx.coroutines.Dispatchers @@ -18,9 +19,11 @@ import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put -internal class DbTools(project: Project, private val gateway: DbGateway = DbGateway(project)) { +internal class DbTools(project: Project, private val access: DbAccess? = DbAccess.of(project)) { - fun domain(): ToolDomain? = if (DbGateway.isAvailable()) { + private val gateway: DbAccess get() = access ?: throw ToolException(MISSING) + + fun domain(): ToolDomain? = if (access != null) { ToolDomain( "db", "The data sources of the Database tool window: list them, read the schema the IDE introspected, run SQL over its connection", @@ -77,14 +80,14 @@ internal class DbTools(project: Project, private val gateway: DbGateway = DbGate ) } - private fun tableRow(table: DbGateway.Table): JsonObject = buildJsonObject { + private fun tableRow(table: DbAccess.Table): JsonObject = buildJsonObject { put("schema", table.schema) put("name", table.name) put("kind", table.kind) put("columns", table.columns) } - private fun columnRow(column: DbGateway.Column): JsonObject = buildJsonObject { + private fun columnRow(column: DbAccess.Column): JsonObject = buildJsonObject { put("name", column.name) put("type", column.type) put("nullable", column.nullable) @@ -118,6 +121,7 @@ internal class DbTools(project: Project, private val gateway: DbGateway = DbGate companion object { private const val DEFAULT_MAX = 100 + private const val MISSING = "the Database plugin is not loaded in this IDE, so the db tools are unavailable" private const val MAX_ROWS = 1000 private const val CELL_CHARS = 200 private const val MILLIS = 1000L diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 88aa5407..cd069cd8 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -144,5 +144,6 @@ + diff --git a/src/test/kotlin/dev/lain/claudejb/SourceLayout.kt b/src/test/kotlin/dev/lain/claudejb/SourceLayout.kt index 2d3c7e84..20cf721a 100644 --- a/src/test/kotlin/dev/lain/claudejb/SourceLayout.kt +++ b/src/test/kotlin/dev/lain/claudejb/SourceLayout.kt @@ -16,9 +16,11 @@ internal object SourceLayout { "intellilang" to "dev.lain.claudejb.intellilang", "terminal" to "dev.lain.claudejb.terminal", "bookmarks" to "dev.lain.claudejb.bookmarks", + "database" to "dev.lain.claudejb.database", ) - private val MODULES = listOf("backend", "frontend", "shared", "git", "github", "java", "intellilang", "terminal", "bookmarks", "") + private val MODULES = + listOf("backend", "frontend", "shared", "git", "github", "java", "intellilang", "terminal", "bookmarks", "database", "") fun roots(kind: String): List = MODULES.mapNotNull { dir("${prefix(it)}src/main/$kind") } diff --git a/src/test/kotlin/dev/lain/claudejb/controller/db/DbGatewayContractTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/db/DbGatewayContractTest.kt index 69f1a2db..2a0f86c5 100644 --- a/src/test/kotlin/dev/lain/claudejb/controller/db/DbGatewayContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/controller/db/DbGatewayContractTest.kt @@ -81,6 +81,6 @@ class DbGatewayContractTest { val GUARD = Regex("""private fun $GUARD_NAME\(\) \{""") val AVAILABLE_DECLARATION = Regex("""fun $AVAILABLE\(\): Boolean""") - val ENTRY = Regex("""^ {4}fun \w+\(.*""") + val ENTRY = Regex("""^ {4}(override )?fun \w+\(.*""") } } diff --git a/src/test/kotlin/dev/lain/claudejb/headless/OptionalModulesHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/headless/OptionalModulesHeadlessTest.kt new file mode 100644 index 00000000..4696a65e --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/headless/OptionalModulesHeadlessTest.kt @@ -0,0 +1,36 @@ +package dev.lain.claudejb.headless + +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.serviceOrNull +import com.intellij.openapi.extensions.PluginId +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import dev.lain.claudejb.controller.db.DbAccess +import dev.lain.claudejb.controller.git.GitAccess +import dev.lain.claudejb.controller.mcp.tools.code.LanguageInjection +import dev.lain.claudejb.controller.mcp.tools.vcs.GitHubAccess + +class OptionalModulesHeadlessTest : BasePlatformTestCase() { + + private fun loaded(plugin: String) = PluginManagerCore.isLoaded(PluginId.getId(plugin)) + + private val modules: Map Any?> + get() = mapOf( + "com.intellij.database" to { project.serviceOrNull() }, + "Git4Idea" to { ApplicationManager.getApplication().serviceOrNull() }, + "org.jetbrains.plugins.github" to { project.serviceOrNull() }, + ) + + fun `test the IDE under test has the plugins the optional modules need`() { + assertEquals(modules.keys.toList(), modules.keys.filter(::loaded)) + } + + fun `test the language injection module loads, since injection is part of the platform`() { + assertNotNull("dev.lain.claudejb.intellilang did not load", project.serviceOrNull()) + } + + fun `test every optional module loads its service where the IDE has its plugin`() { + val silent = modules.filter { (plugin, lookup) -> loaded(plugin) && lookup() == null }.keys + assertEquals("optional modules that did not load although their plugin is there", emptySet(), silent) + } +} From 64c9f8acef15bbdbcb9c1394258e0d6efdd58c23 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 09:14:11 +0200 Subject: [PATCH 139/155] fix(session): read the dashboard's account off the EDT --- .../view/payload/panel/JcefSessionData.kt | 4 +- .../claudejb/view/window/ChatSnapshots.kt | 54 +++++++++++++++---- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/view/payload/panel/JcefSessionData.kt b/src/main/kotlin/dev/lain/claudejb/view/payload/panel/JcefSessionData.kt index d72b03b9..54de42a8 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/payload/panel/JcefSessionData.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/payload/panel/JcefSessionData.kt @@ -8,6 +8,7 @@ import dev.lain.claudejb.view.git.JcefGitData import dev.lain.claudejb.view.payload.JcefVulnData import dev.lain.claudejb.view.payload.composer.JcefModelLabels import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -29,6 +30,7 @@ object JcefSessionData { plan: dev.lain.claudejb.controller.session.control.PlanInfo? = null, git: JcefGitData.Snapshot? = null, vuln: VulnSnapshot? = null, + account: JsonObject? = null, ): String { val shown = JcefWorkloadData.visible(session, windowMinutes, nowMillis) val obj = buildJsonObject { @@ -38,7 +40,7 @@ object JcefSessionData { put("vuln", JcefVulnData.vulnJson(vuln) ?: JsonNull) put("context", JcefCostData.contextJson(session) ?: JsonNull) put("cost", JcefCostData.costJson(session) ?: JsonNull) - put("account", JcefAccountData.accountJson(session) ?: JsonNull) + put("account", account ?: JsonNull) put("backgroundTasks", JcefWorkloadData.backgroundTasksJson(session, shown)) put("agentTree", JcefWorkloadData.agentTreeJson(session, shown)) put("workloads", JcefWorkloadData.workloadsJson(workloads, windowMinutes, nowMillis)) diff --git a/src/main/kotlin/dev/lain/claudejb/view/window/ChatSnapshots.kt b/src/main/kotlin/dev/lain/claudejb/view/window/ChatSnapshots.kt index ff8fb7f2..d3469731 100644 --- a/src/main/kotlin/dev/lain/claudejb/view/window/ChatSnapshots.kt +++ b/src/main/kotlin/dev/lain/claudejb/view/window/ChatSnapshots.kt @@ -1,5 +1,6 @@ package dev.lain.claudejb.view.window +import com.intellij.openapi.application.ApplicationManager import dev.lain.claudejb.controller.commands.git.GitIntegration import dev.lain.claudejb.controller.vuln.VulnService import dev.lain.claudejb.model.settings.ClaudeSettings @@ -7,7 +8,9 @@ import dev.lain.claudejb.util.edt import dev.lain.claudejb.view.payload.chat.JcefCardPayload import dev.lain.claudejb.view.payload.chat.JcefState import dev.lain.claudejb.view.payload.menu.SettingsMenuRows +import dev.lain.claudejb.view.payload.panel.JcefAccountData import dev.lain.claudejb.view.payload.panel.JcefSessionData +import kotlinx.serialization.json.JsonObject import java.util.EnumMap import java.util.EnumSet @@ -19,6 +22,13 @@ internal class ChatSnapshots(private val presenter: ChatPresenter) { private var scheduled = false private val last = EnumMap(Kind::class.java) + @Volatile + private var account: JsonObject? = null + + private var accountReadAt = 0L + + private var accountReading = false + private val session get() = presenter.session private val project get() = presenter.project @@ -68,16 +78,36 @@ internal class ChatSnapshots(private val presenter: ChatPresenter) { presenter.exec(method, json) } - private fun sessionJson(): String = JcefSessionData.sessionJson( - session, - windowMinutes = ClaudeSettings.getInstance(project).workloadWindowMinutes, - nowMillis = System.currentTimeMillis(), - usage = presenter.feed.usage, - workloads = presenter.registry.workloads(), - plan = presenter.feed.plan, - git = GitIntegration.getInstance(project).snapshot(), - vuln = VulnService.getInstance(project).snapshot(), - ) + private fun sessionJson(): String { + readAccountSoon() + return JcefSessionData.sessionJson( + session, + windowMinutes = ClaudeSettings.getInstance(project).workloadWindowMinutes, + nowMillis = System.currentTimeMillis(), + usage = presenter.feed.usage, + workloads = presenter.registry.workloads(), + plan = presenter.feed.plan, + git = GitIntegration.getInstance(project).snapshot(), + vuln = VulnService.getInstance(project).snapshot(), + account = account, + ) + } + + private fun readAccountSoon() { + val now = System.currentTimeMillis() + if (accountReading || now - accountReadAt < ACCOUNT_REFRESH_MS) return + accountReading = true + accountReadAt = now + ApplicationManager.getApplication().executeOnPooledThread { + val fresh = runCatching { JcefAccountData.accountJson(session) }.getOrNull() + edt(project) { + accountReading = false + if (fresh == account) return@edt + account = fresh + mark(Kind.SESSION) + } + } + } private fun menuJson(): String { val settings = ClaudeSettings.getInstance(project) @@ -93,4 +123,8 @@ internal class ChatSnapshots(private val presenter: ChatPresenter) { presenter.gitChat.permissionGroup() return JcefCardPayload.permissionsJson(groups) } + + private companion object { + const val ACCOUNT_REFRESH_MS = 5_000L + } } From 8fd1aa8119d3267270e6c50be698c74554b7663a Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 11:47:59 +0200 Subject: [PATCH 140/155] fix(database): depend on the intellij.database content modules that hold the classes the gateway loads --- database/src/main/resources/dev.lain.claudejb.database.xml | 2 ++ .../lain/claudejb/headless/OptionalModulesHeadlessTest.kt | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/database/src/main/resources/dev.lain.claudejb.database.xml b/database/src/main/resources/dev.lain.claudejb.database.xml index 293e6e75..ce025b41 100644 --- a/database/src/main/resources/dev.lain.claudejb.database.xml +++ b/database/src/main/resources/dev.lain.claudejb.database.xml @@ -1,6 +1,8 @@ + + diff --git a/src/test/kotlin/dev/lain/claudejb/headless/OptionalModulesHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/headless/OptionalModulesHeadlessTest.kt index 4696a65e..eb662268 100644 --- a/src/test/kotlin/dev/lain/claudejb/headless/OptionalModulesHeadlessTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/headless/OptionalModulesHeadlessTest.kt @@ -29,6 +29,11 @@ class OptionalModulesHeadlessTest : BasePlatformTestCase() { assertNotNull("dev.lain.claudejb.intellilang did not load", project.serviceOrNull()) } + fun `test the database module reaches the database plugin's classes, not only its own service`() { + val db = project.serviceOrNull() ?: return fail("the database module did not load") + assertEquals(emptyList(), db.connections()) + } + fun `test every optional module loads its service where the IDE has its plugin`() { val silent = modules.filter { (plugin, lookup) -> loaded(plugin) && lookup() == null }.keys assertEquals("optional modules that did not load although their plugin is there", emptySet(), silent) From e773e95d1f0d578652dd6071ca1ec43d5a70d141 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 11:48:01 +0200 Subject: [PATCH 141/155] fix(mcp): offer the Problems view tools only where this IDE lets the plugin load the Problems view --- .../controller/mcp/tools/code/DiagnosticsTools.kt | 6 +++--- .../controller/mcp/tools/code/ProblemsViewApi.kt | 15 +++++++++++++++ .../controller/mcp/tools/ops/RemoteTools.kt | 10 +++++++--- 3 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProblemsViewApi.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/DiagnosticsTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/DiagnosticsTools.kt index 6d405ba3..60920625 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/DiagnosticsTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/DiagnosticsTools.kt @@ -31,10 +31,10 @@ internal class DiagnosticsTools( fun domain(): ToolDomain = ToolDomain( "diagnostics", "What the IDE's own analysis flags: the highlights of one file, the Problems view for the whole project, and its tabs", - listOf( + listOfNotNull( Tool(PROBLEMS) { ToolResult.toon(Batch.run(it, Batch.PATHS, ::problemsOne)) }, - Tool(PROJECT_PROBLEMS, ::projectProblems), - Tool(PROBLEMS_VIEW, ::problemsView), + Tool(PROJECT_PROBLEMS, ::projectProblems).takeIf { ProblemsViewApi.available }, + Tool(PROBLEMS_VIEW, ::problemsView).takeIf { ProblemsViewApi.available }, ), ) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProblemsViewApi.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProblemsViewApi.kt new file mode 100644 index 00000000..45026687 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProblemsViewApi.kt @@ -0,0 +1,15 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +internal object ProblemsViewApi { + + const val MISSING = "this IDE build does not expose the Problems view to plugins, so its problems cannot be read here" + + val available: Boolean by lazy { + CLASSES.all { name -> runCatching { Class.forName(name, false, ProblemsViewApi::class.java.classLoader) }.isSuccess } + } + + private val CLASSES = listOf( + "com.intellij.analysis.problemsView.ProblemsCollector", + "com.intellij.analysis.problemsView.toolWindow.ProblemsViewToolWindowUtils", + ) +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/RemoteTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/RemoteTools.kt index 70d3553c..2ded4d95 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/RemoteTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/RemoteTools.kt @@ -9,6 +9,7 @@ import com.intellij.openapi.project.Project import dev.lain.claudejb.controller.mcp.IdeActions import dev.lain.claudejb.controller.mcp.Reveal import dev.lain.claudejb.controller.mcp.TargetContext +import dev.lain.claudejb.controller.mcp.tools.code.ProblemsViewApi import dev.lain.claudejb.model.mcp.Param import dev.lain.claudejb.model.mcp.Tool import dev.lain.claudejb.model.mcp.ToolArgs @@ -31,11 +32,11 @@ internal class RemoteTools(private val project: Project, private val actions: Id "remote", "The closed-source Tools menu entries the IDE may have: Deployment, SSH sessions, Qodana and the vulnerable " + "dependencies of the Problems view; each is reached through the actions the plugin registers on this IDE", - listOf( + listOfNotNull( Tool(DEPLOYMENT, ::deployment), Tool(SSH_SESSION, ::sshSession), Tool(QODANA, ::qodana), - Tool(VULNERABLE_DEPENDENCIES, ::vulnerable), + Tool(VULNERABLE_DEPENDENCIES, ::vulnerable).takeIf { ProblemsViewApi.available }, ), ) @@ -45,7 +46,10 @@ internal class RemoteTools(private val project: Project, private val actions: Id private suspend fun qodana(args: ToolArgs): ToolResult { val action = args.optionalString("action") ?: "results" - if (action == "results") return problems(QODANA_GROUP, QODANA_TAB, args) + if (action == "results") { + if (!ProblemsViewApi.available) throw ToolException(ProblemsViewApi.MISSING) + return problems(QODANA_GROUP, QODANA_TAB, args) + } return fire(QODANA_PLUGIN, action, args) } From 01768c87c213d5a6e6c10f1ff1b0c54e46a98b7d Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 12:01:02 +0200 Subject: [PATCH 142/155] fix(guard): let the IDE's scratch folder pass the outside-project rule and nothing else --- .../model/permission/GuardClassifier.kt | 10 +++- .../model/permission/SensitiveGuard.kt | 1 + .../settings/guard/SettingsSensitivePolicy.kt | 6 +++ .../model/permission/ScratchRootGuardTest.kt | 53 +++++++++++++++++++ 4 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 src/test/kotlin/dev/lain/claudejb/model/permission/ScratchRootGuardTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/model/permission/GuardClassifier.kt b/src/main/kotlin/dev/lain/claudejb/model/permission/GuardClassifier.kt index 3ef832c0..e950e353 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/permission/GuardClassifier.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/permission/GuardClassifier.kt @@ -138,14 +138,20 @@ internal object GuardClassifier { } } - if (projRoot == null) return null + return projRoot?.let { outsideProjectHit(input, it, policy) } + } + + private fun outsideProjectHit(input: JsonObject, projRoot: String, policy: Policy): Hit? { + val scratches = policy.scratchRoot?.let { GuardPaths.fold(GuardPaths.normalize(it, policy.home)) } val certain = policy.pathProbe == null || commitsToDisk(input) val mounted = mountHosts(input, projRoot, policy) return ToolInputScanner.locationCandidates(input, policy.home, policy.envValues) .mapNotNull { GuardPaths.absoluteForm(it, projRoot) } .filterNot { ScriptExecution.inSystemBinDir(it) || SystemDevices.isDeviceNode(it) } .firstOrNull { - !GuardPaths.under(it, projRoot, policy.caseInsensitivePaths) && (certain || it in mounted || present(it, policy)) + !GuardPaths.under(it, projRoot, policy.caseInsensitivePaths) && + (scratches == null || !GuardPaths.under(it, scratches, policy.caseInsensitivePaths)) && + (certain || it in mounted || present(it, policy)) } ?.let { Hit(SecurityRule.OUTSIDE_PROJECT, "reaches outside the project: $it") } } diff --git a/src/main/kotlin/dev/lain/claudejb/model/permission/SensitiveGuard.kt b/src/main/kotlin/dev/lain/claudejb/model/permission/SensitiveGuard.kt index 058e11ca..51a8b4d2 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/permission/SensitiveGuard.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/permission/SensitiveGuard.kt @@ -22,6 +22,7 @@ object SensitiveGuard { val wslHost: Boolean = false, val caseInsensitivePaths: Boolean = false, val projectRoot: String? = null, + val scratchRoot: String? = null, val pathResolver: ((String) -> String?)? = null, val pathProbe: ((String) -> PathPresence?)? = null, val envValues: Map = emptyMap(), diff --git a/src/main/kotlin/dev/lain/claudejb/model/settings/guard/SettingsSensitivePolicy.kt b/src/main/kotlin/dev/lain/claudejb/model/settings/guard/SettingsSensitivePolicy.kt index d751396d..f9bd79cb 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/settings/guard/SettingsSensitivePolicy.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/settings/guard/SettingsSensitivePolicy.kt @@ -1,5 +1,7 @@ package dev.lain.claudejb.model.settings.guard +import com.intellij.ide.scratch.ScratchFileService +import com.intellij.ide.scratch.ScratchRootType import com.intellij.openapi.util.SystemInfo import dev.lain.claudejb.model.permission.SensitiveGuard import dev.lain.claudejb.model.permission.paths.CredentialPaths @@ -47,6 +49,7 @@ fun ClaudeSettings.sensitivePolicy(projectRoot: String?): SensitiveGuard.Policy wslHost = snap.isWsl, caseInsensitivePaths = SystemInfo.isWindows || SystemInfo.isMac, projectRoot = projectRoot, + scratchRoot = scratchesRoot(), pathResolver = { raw -> runCatching { java.io.File(raw).canonicalPath }.getOrNull() }, pathProbe = ::probePresence, envValues = launchEnvValues(env), @@ -75,6 +78,9 @@ internal fun ClaudeSettings.extraBlockedDomains(): List = internal fun ClaudeSettings.commandWhitelist(): List = GuardWhitelists.commands(state.securityCommandWhitelist) +private fun scratchesRoot(): String? = + runCatching { ScratchFileService.getInstance().getRootPath(ScratchRootType.getInstance()) }.getOrNull() + private fun launchEnvValues(settingsEnv: Map): Map = System.getenv() + settingsEnv diff --git a/src/test/kotlin/dev/lain/claudejb/model/permission/ScratchRootGuardTest.kt b/src/test/kotlin/dev/lain/claudejb/model/permission/ScratchRootGuardTest.kt new file mode 100644 index 00000000..97abbbc7 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/model/permission/ScratchRootGuardTest.kt @@ -0,0 +1,53 @@ +package dev.lain.claudejb.model.permission + +import dev.lain.claudejb.model.permission.SensitiveGuard.Verdict +import dev.lain.claudejb.model.permission.vocab.SecurityRule +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test + +class ScratchRootGuardTest : GuardProbe(GuardFixture.basePolicy().copy(scratchRoot = SCRATCHES)) { + + private fun tool(path: String) = buildJsonObject { put("path", path) } + + @Test + fun `a scratch file of the IDE is not outside the project for the code tools`() { + assertEquals(Verdict.ALLOW, v(tool("$SCRATCHES/verify_inject.kt"))) + assertEquals(Verdict.ALLOW, v(read("$SCRATCHES/notes/plan.md"))) + } + + @Test + fun `without the scratch root the same path is still outside the project`() { + val unaware = policy.copy(scratchRoot = null) + + assertEquals(Verdict.DENY, v(tool("$SCRATCHES/verify_inject.kt"), unaware)) + assertEquals(SecurityRule.OUTSIDE_PROJECT, rule(tool("$SCRATCHES/verify_inject.kt"), unaware)) + } + + @Test + fun `a sibling that only shares the prefix stays outside the project`() { + assertEquals(SecurityRule.OUTSIDE_PROJECT, rule(tool("$SCRATCHES-evil/x.kt"))) + assertEquals(SecurityRule.OUTSIDE_PROJECT, rule(tool("$SCRATCHES/../options/other.xml"))) + } + + @Test + fun `key material in the scratch folder is still refused as credentials`() { + val verdict = SensitiveGuard.evaluate(read("$SCRATCHES/id_rsa"), policy) + + assertEquals(Verdict.DENY, verdict.verdict) + assertEquals(SecurityRule.CREDENTIALS, verdict.rule) + } + + @Test + fun `the exemption covers only the outside-project rule, never another user's home`() { + val foreign = policy.copy(scratchRoot = "/home/bob/.config/JetBrains/IDE/scratches") + + assertNotEquals(Verdict.ALLOW, v(tool("/home/bob/.config/JetBrains/IDE/scratches/x.kt"), foreign)) + } + + private companion object { + const val SCRATCHES = "/home/me/.config/JetBrains/PyCharm2026.3/scratches" + } +} From ee72d8505c75cd6ca20db2dcd957e7daffa75868 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 12:01:24 +0200 Subject: [PATCH 143/155] fix(mcp): read the Problems view through the problemView plugin's modules where 2026.3 moved it --- .github/workflows/ci.yml | 2 +- .gitignore | 1 + build.gradle.kts | 2 +- gradle/coverage.gradle.kts | 14 +++++++- gradle/quality.gradle.kts | 2 +- problems/build.gradle.kts | 6 ++++ .../mcp/tools/code/PluginProblemsView.kt | 32 +++++++++++++++++++ .../resources/dev.lain.claudejb.problems.xml | 12 +++++++ settings.gradle.kts | 2 +- .../mcp/tools/code/CoreProblemsView.kt | 32 +++++++++++++++++++ .../mcp/tools/code/DiagnosticsTools.kt | 20 ++++++------ .../mcp/tools/code/ProblemsViewAccess.kt | 31 ++++++++++++++++++ .../mcp/tools/code/ProjectProblems.kt | 30 +++++++---------- .../controller/mcp/tools/ops/RemoteTools.kt | 21 ++++-------- src/main/resources/META-INF/plugin.xml | 1 + .../kotlin/dev/lain/claudejb/SourceLayout.kt | 3 +- 16 files changed, 160 insertions(+), 51 deletions(-) create mode 100644 problems/build.gradle.kts create mode 100644 problems/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PluginProblemsView.kt create mode 100644 problems/src/main/resources/dev.lain.claudejb.problems.xml create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/CoreProblemsView.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProblemsViewAccess.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e91f55e..e4de57aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -534,7 +534,7 @@ jobs: unzip -Z -1 "$jar" | grep -v '/$' \ | grep -vE '^dev/lain/claudejb/.*\.class$' \ | grep -vE '^META-INF/' \ - | grep -vE '^dev\.lain\.claudejb\.(shared|frontend|backend|git|github|java|intellilang|terminal|bookmarks|database)\.xml$' \ + | grep -vE '^dev\.lain\.claudejb\.(shared|frontend|backend|git|github|java|intellilang|terminal|bookmarks|database|problems)\.xml$' \ | grep -vE '^icons/[^/]+\.svg$' \ | grep -vE '^jcef/(.+\.(js|html)|css/.+\.css)$' >> /tmp/unexpected.txt || true done < /tmp/jars.txt diff --git a/.gitignore b/.gitignore index da19c62e..2c414cd9 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ !/intellilang/** !/bookmarks/** !/database/** +!/problems/** !/terminal/** !/bin/** !/gradle/** diff --git a/build.gradle.kts b/build.gradle.kts index 3dd98e0e..4ff06e46 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,7 +21,7 @@ version = "6.5.0" val platformBuild = "262.8665.258" val serialization = "1.9.0" val pluginModules = - listOf("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks", "database") + listOf("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks", "database", "problems") allprojects { plugins.withId("org.jetbrains.kotlin.jvm") { diff --git a/gradle/coverage.gradle.kts b/gradle/coverage.gradle.kts index 5e1925e2..3ce92e8b 100644 --- a/gradle/coverage.gradle.kts +++ b/gradle/coverage.gradle.kts @@ -23,7 +23,19 @@ tasks.matching { it.name.startsWith("kover") && (it.name.contains("Report") || i } dependencies { - listOf("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks", "database").forEach { + listOf( + "shared", + "frontend", + "backend", + "git", + "github", + "java", + "intellilang", + "terminal", + "bookmarks", + "database", + "problems", + ).forEach { "kover"(project(":$it")) } } diff --git a/gradle/quality.gradle.kts b/gradle/quality.gradle.kts index f80cdac2..3b32d38d 100644 --- a/gradle/quality.gradle.kts +++ b/gradle/quality.gradle.kts @@ -4,7 +4,7 @@ import dev.detekt.gradle.DetektCreateBaselineTask import dev.detekt.gradle.extensions.DetektExtension val pluginModules = - listOf("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks", "database") + listOf("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks", "database", "problems") configure { buildUponDefaultConfig.set(true) diff --git a/problems/build.gradle.kts b/problems/build.gradle.kts new file mode 100644 index 00000000..6ddce77f --- /dev/null +++ b/problems/build.gradle.kts @@ -0,0 +1,6 @@ +dependencies { + intellijPlatform { + bundledModule("intellij.platform.backend") + } + implementation(project(":backend")) +} diff --git a/problems/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PluginProblemsView.kt b/problems/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PluginProblemsView.kt new file mode 100644 index 00000000..4e16d71b --- /dev/null +++ b/problems/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PluginProblemsView.kt @@ -0,0 +1,32 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.analysis.problemsView.FileProblem +import com.intellij.analysis.problemsView.ProblemsCollector +import com.intellij.analysis.problemsView.toolWindow.ProblemsViewTab +import com.intellij.analysis.problemsView.toolWindow.ProblemsViewToolWindowUtils +import com.intellij.openapi.project.Project + +internal class PluginProblemsView(private val project: Project) : ProblemsViewAccess { + + override fun entries(): List { + val collector = ProblemsCollector.getInstance(project) + val inFiles = collector.getProblemFiles().flatMap { file -> + collector.getFileProblems(file).map { problem -> + val line = (problem as? FileProblem)?.line ?: -1 + ProblemsViewAccess.Entry(file, line, problem.text, problem.group, problem.description) + } + } + val other = collector.getOtherProblems().map { ProblemsViewAccess.Entry(null, -1, it.text, it.group, it.description) } + return inFiles + other + } + + override fun tabs(): List? { + val window = ProblemsViewToolWindowUtils.getToolWindow(project) ?: return null + return window.contentManager.contents.mapNotNull { content -> + val tab = content.component as? ProblemsViewTab ?: return@mapNotNull null + ProblemsViewAccess.Tab(tab.getTabId(), content.displayName ?: tab.getName(0)) + } + } + + override fun selectedTab(): String? = ProblemsViewToolWindowUtils.getSelectedTab(project)?.getTabId() +} diff --git a/problems/src/main/resources/dev.lain.claudejb.problems.xml b/problems/src/main/resources/dev.lain.claudejb.problems.xml new file mode 100644 index 00000000..e678028a --- /dev/null +++ b/problems/src/main/resources/dev.lain.claudejb.problems.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 6e3d27b9..6df88e66 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,4 +29,4 @@ dependencyResolutionManagement { } } -include("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks", "database") +include("shared", "frontend", "backend", "git", "github", "java", "intellilang", "terminal", "bookmarks", "database", "problems") diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/CoreProblemsView.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/CoreProblemsView.kt new file mode 100644 index 00000000..ebb69192 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/CoreProblemsView.kt @@ -0,0 +1,32 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.analysis.problemsView.FileProblem +import com.intellij.analysis.problemsView.ProblemsCollector +import com.intellij.analysis.problemsView.toolWindow.ProblemsViewTab +import com.intellij.analysis.problemsView.toolWindow.ProblemsViewToolWindowUtils +import com.intellij.openapi.project.Project + +internal class CoreProblemsView(private val project: Project) : ProblemsViewAccess { + + override fun entries(): List { + val collector = ProblemsCollector.getInstance(project) + val inFiles = collector.getProblemFiles().flatMap { file -> + collector.getFileProblems(file).map { problem -> + val line = (problem as? FileProblem)?.line ?: -1 + ProblemsViewAccess.Entry(file, line, problem.text, problem.group, problem.description) + } + } + val other = collector.getOtherProblems().map { ProblemsViewAccess.Entry(null, -1, it.text, it.group, it.description) } + return inFiles + other + } + + override fun tabs(): List? { + val window = ProblemsViewToolWindowUtils.getToolWindow(project) ?: return null + return window.contentManager.contents.mapNotNull { content -> + val tab = content.component as? ProblemsViewTab ?: return@mapNotNull null + ProblemsViewAccess.Tab(tab.getTabId(), content.displayName ?: tab.getName(0)) + } + } + + override fun selectedTab(): String? = ProblemsViewToolWindowUtils.getSelectedTab(project)?.getTabId() +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/DiagnosticsTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/DiagnosticsTools.kt index 60920625..afa41578 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/DiagnosticsTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/DiagnosticsTools.kt @@ -1,7 +1,5 @@ package dev.lain.claudejb.controller.mcp.tools.code -import com.intellij.analysis.problemsView.toolWindow.ProblemsViewTab -import com.intellij.analysis.problemsView.toolWindow.ProblemsViewToolWindowUtils import com.intellij.openapi.application.EDT import com.intellij.openapi.application.readAction import com.intellij.openapi.project.Project @@ -33,8 +31,8 @@ internal class DiagnosticsTools( "What the IDE's own analysis flags: the highlights of one file, the Problems view for the whole project, and its tabs", listOfNotNull( Tool(PROBLEMS) { ToolResult.toon(Batch.run(it, Batch.PATHS, ::problemsOne)) }, - Tool(PROJECT_PROBLEMS, ::projectProblems).takeIf { ProblemsViewApi.available }, - Tool(PROBLEMS_VIEW, ::problemsView).takeIf { ProblemsViewApi.available }, + Tool(PROJECT_PROBLEMS, ::projectProblems).takeIf { view() != null }, + Tool(PROBLEMS_VIEW, ::problemsView).takeIf { view() != null }, ), ) @@ -74,7 +72,8 @@ internal class DiagnosticsTools( private suspend fun projectProblems(args: ToolArgs): ToolResult { val max = args.max(DEFAULT_MAX, MAX_PROBLEMS) val group = args.optionalString("group") - val snapshot = readAction { ProjectProblems.snapshot(project, group, max) } + val access = view() ?: throw ToolException(ProblemsViewAccess.MISSING) + val snapshot = readAction { ProjectProblems.snapshot(project, access.entries(), group, max) } if (reveal.mirroring) reveal.problems("") return ToolResult.toon(snapshot.toJson(max)) } @@ -84,7 +83,7 @@ internal class DiagnosticsTools( val tabs = withContext(Dispatchers.EDT) { tabs() } val chosen = wanted?.let { name -> tab(tabs, name) } chosen?.let { reveal.problems(it.id) } - val selected = withContext(Dispatchers.EDT) { ProblemsViewToolWindowUtils.getSelectedTab(project)?.getTabId() ?: "" } + val selected = withContext(Dispatchers.EDT) { view()?.selectedTab().orEmpty() } return ToolResult.toon( buildJsonObject { put("selected", selected) @@ -110,13 +109,12 @@ internal class DiagnosticsTools( private class ProblemsTab(val id: String, val name: String) private fun tabs(): List { - val toolWindow = ProblemsViewToolWindowUtils.getToolWindow(project) ?: throw ToolException("this IDE has no Problems tool window") - return toolWindow.contentManager.contents.mapNotNull { content -> - val tab = content.component as? ProblemsViewTab ?: return@mapNotNull null - ProblemsTab(tab.getTabId(), plain(content.displayName ?: tab.getName(0))) - } + val tabs = view()?.tabs() ?: throw ToolException("this IDE has no Problems tool window") + return tabs.map { ProblemsTab(it.id, plain(it.title)) } } + private fun view(): ProblemsViewAccess? = ProblemsViewAccess.of(project) + private fun plain(title: String): String = StringUtil.removeHtmlTags(title).replace(WHITESPACE, " ").trim() private fun tab(tabs: List, name: String): ProblemsTab = diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProblemsViewAccess.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProblemsViewAccess.kt new file mode 100644 index 00000000..3e40952c --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProblemsViewAccess.kt @@ -0,0 +1,31 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.openapi.components.serviceOrNull +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile + +interface ProblemsViewAccess { + + class Entry(val file: VirtualFile?, val line: Int, val text: String, val group: String?, val description: String?) + + class Tab(val id: String, val title: String) + + fun entries(): List + + fun tabs(): List? + + fun selectedTab(): String? + + companion object { + const val MISSING = "this IDE build does not expose the Problems view to plugins, so its problems cannot be read here" + + fun of(project: Project): ProblemsViewAccess? = + project.serviceOrNull() ?: CoreProblemsView(project).takeIf { coreLoads } + + private val coreLoads: Boolean by lazy { + runCatching { Class.forName(CORE_CLASS, false, ProblemsViewAccess::class.java.classLoader) }.isSuccess + } + + private const val CORE_CLASS = "com.intellij.analysis.problemsView.ProblemsCollector" + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProjectProblems.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProjectProblems.kt index 14ea96bf..8fe2e881 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProjectProblems.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProjectProblems.kt @@ -1,8 +1,5 @@ package dev.lain.claudejb.controller.mcp.tools.code -import com.intellij.analysis.problemsView.FileProblem -import com.intellij.analysis.problemsView.Problem -import com.intellij.analysis.problemsView.ProblemsCollector import com.intellij.openapi.project.Project import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -37,30 +34,25 @@ internal class ProjectProblems private constructor( companion object { - fun snapshot(project: Project, group: String?, max: Int): ProjectProblems { - val collector = ProblemsCollector.getInstance(project) + fun snapshot(project: Project, entries: List, group: String?, max: Int): ProjectProblems { val files = LinkedHashMap>() val other = ArrayList() var total = 0 - for (file in collector.getProblemFiles()) { - for (problem in collector.getFileProblems(file)) { - if (!wanted(problem, group)) continue - if (total++ < max) files.getOrPut(Locations.relative(project, file)) { ArrayList() } += row(problem) - } - } - for (problem in collector.getOtherProblems()) { - if (!wanted(problem, group)) continue - if (total++ < max) other += problem.text + for (entry in entries) { + if (!wanted(entry, group)) continue + if (total++ >= max) continue + val file = entry.file + if (file == null) other += entry.text else files.getOrPut(Locations.relative(project, file)) { ArrayList() } += row(entry) } return ProjectProblems(files, other, total) } - private fun wanted(problem: Problem, group: String?): Boolean = - group == null || problem.group?.contains(group, ignoreCase = true) == true + private fun wanted(entry: ProblemsViewAccess.Entry, group: String?): Boolean = + group == null || entry.group?.contains(group, ignoreCase = true) == true - private fun row(problem: Problem): JsonObject = buildJsonObject { - put("line", (problem as? FileProblem)?.line?.takeIf { it >= 0 }?.plus(1) ?: 0) - put("message", problem.text) + private fun row(entry: ProblemsViewAccess.Entry): JsonObject = buildJsonObject { + put("line", entry.line.takeIf { it >= 0 }?.plus(1) ?: 0) + put("message", entry.text) } } } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/RemoteTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/RemoteTools.kt index 2ded4d95..179590b8 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/RemoteTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/RemoteTools.kt @@ -1,15 +1,12 @@ package dev.lain.claudejb.controller.mcp.tools.ops -import com.intellij.analysis.problemsView.ProblemsCollector -import com.intellij.analysis.problemsView.toolWindow.ProblemsViewTab -import com.intellij.analysis.problemsView.toolWindow.ProblemsViewToolWindowUtils import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.application.EDT import com.intellij.openapi.project.Project import dev.lain.claudejb.controller.mcp.IdeActions import dev.lain.claudejb.controller.mcp.Reveal import dev.lain.claudejb.controller.mcp.TargetContext -import dev.lain.claudejb.controller.mcp.tools.code.ProblemsViewApi +import dev.lain.claudejb.controller.mcp.tools.code.ProblemsViewAccess import dev.lain.claudejb.model.mcp.Param import dev.lain.claudejb.model.mcp.Tool import dev.lain.claudejb.model.mcp.ToolArgs @@ -36,7 +33,7 @@ internal class RemoteTools(private val project: Project, private val actions: Id Tool(DEPLOYMENT, ::deployment), Tool(SSH_SESSION, ::sshSession), Tool(QODANA, ::qodana), - Tool(VULNERABLE_DEPENDENCIES, ::vulnerable).takeIf { ProblemsViewApi.available }, + Tool(VULNERABLE_DEPENDENCIES, ::vulnerable).takeIf { ProblemsViewAccess.of(project) != null }, ), ) @@ -46,10 +43,7 @@ internal class RemoteTools(private val project: Project, private val actions: Id private suspend fun qodana(args: ToolArgs): ToolResult { val action = args.optionalString("action") ?: "results" - if (action == "results") { - if (!ProblemsViewApi.available) throw ToolException(ProblemsViewApi.MISSING) - return problems(QODANA_GROUP, QODANA_TAB, args) - } + if (action == "results") return problems(QODANA_GROUP, QODANA_TAB, args) return fire(QODANA_PLUGIN, action, args) } @@ -89,13 +83,10 @@ internal class RemoteTools(private val project: Project, private val actions: Id private suspend fun problems(group: String, tab: String, args: ToolArgs): ToolResult { val max = args.max(DEFAULT_MAX, Param.MAX_CEILING) + val access = ProblemsViewAccess.of(project) ?: throw ToolException(ProblemsViewAccess.MISSING) val (rows, tabId) = withContext(Dispatchers.EDT) { - val collector = ProblemsCollector.getInstance(project) - val all = collector.getProblemFiles().flatMap { collector.getFileProblems(it) } + collector.getOtherProblems() - val matching = all.filter { it.group?.contains(group, ignoreCase = true) == true } - val window = ProblemsViewToolWindowUtils.getToolWindow(project) - val content = window?.contentManager?.contents?.firstOrNull { it.displayName?.contains(tab, ignoreCase = true) == true } - val id = (content?.component as? ProblemsViewTab)?.getTabId() + val matching = access.entries().filter { it.group?.contains(group, ignoreCase = true) == true } + val id = access.tabs()?.firstOrNull { it.title.contains(tab, ignoreCase = true) }?.id matching.map { problem -> buildJsonObject { put("text", problem.text) diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index cd069cd8..1237d50b 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -145,5 +145,6 @@ + diff --git a/src/test/kotlin/dev/lain/claudejb/SourceLayout.kt b/src/test/kotlin/dev/lain/claudejb/SourceLayout.kt index 20cf721a..d66220dc 100644 --- a/src/test/kotlin/dev/lain/claudejb/SourceLayout.kt +++ b/src/test/kotlin/dev/lain/claudejb/SourceLayout.kt @@ -17,10 +17,11 @@ internal object SourceLayout { "terminal" to "dev.lain.claudejb.terminal", "bookmarks" to "dev.lain.claudejb.bookmarks", "database" to "dev.lain.claudejb.database", + "problems" to "dev.lain.claudejb.problems", ) private val MODULES = - listOf("backend", "frontend", "shared", "git", "github", "java", "intellilang", "terminal", "bookmarks", "database", "") + listOf("backend", "frontend", "shared", "git", "github", "java", "intellilang", "terminal", "bookmarks", "database", "problems", "") fun roots(kind: String): List = MODULES.mapNotNull { dir("${prefix(it)}src/main/$kind") } From c30b433ca949c0e4756863999cebb7e3095aa269 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 12:01:56 +0200 Subject: [PATCH 144/155] refactor(mcp): drop ProblemsViewApi, which ProblemsViewAccess replaces --- .../controller/mcp/tools/code/ProblemsViewApi.kt | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProblemsViewApi.kt diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProblemsViewApi.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProblemsViewApi.kt deleted file mode 100644 index 45026687..00000000 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProblemsViewApi.kt +++ /dev/null @@ -1,15 +0,0 @@ -package dev.lain.claudejb.controller.mcp.tools.code - -internal object ProblemsViewApi { - - const val MISSING = "this IDE build does not expose the Problems view to plugins, so its problems cannot be read here" - - val available: Boolean by lazy { - CLASSES.all { name -> runCatching { Class.forName(name, false, ProblemsViewApi::class.java.classLoader) }.isSuccess } - } - - private val CLASSES = listOf( - "com.intellij.analysis.problemsView.ProblemsCollector", - "com.intellij.analysis.problemsView.toolWindow.ProblemsViewToolWindowUtils", - ) -} From b942ce444bfad7d0c1a32baf3bb78862fd8e413d Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 12:23:17 +0200 Subject: [PATCH 145/155] fix(mcp): reach the Problems tool window through ProblemsViewAccess so project_problems runs on 2026.3 --- .../mcp/tools/code/PluginProblemsView.kt | 8 +++- .../lain/claudejb/controller/mcp/IdePlaces.kt | 8 ++-- .../lain/claudejb/controller/mcp/Reveal.kt | 7 +-- .../mcp/tools/code/CoreProblemsView.kt | 8 +++- .../mcp/tools/code/ProblemsViewAccess.kt | 15 +++++-- .../headless/ProjectProblemsHeadlessTest.kt | 45 +++++++++++++++++++ 6 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 src/test/kotlin/dev/lain/claudejb/headless/ProjectProblemsHeadlessTest.kt diff --git a/problems/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PluginProblemsView.kt b/problems/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PluginProblemsView.kt index 4e16d71b..ec7bb461 100644 --- a/problems/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PluginProblemsView.kt +++ b/problems/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PluginProblemsView.kt @@ -5,6 +5,8 @@ import com.intellij.analysis.problemsView.ProblemsCollector import com.intellij.analysis.problemsView.toolWindow.ProblemsViewTab import com.intellij.analysis.problemsView.toolWindow.ProblemsViewToolWindowUtils import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.ui.content.Content internal class PluginProblemsView(private val project: Project) : ProblemsViewAccess { @@ -21,7 +23,7 @@ internal class PluginProblemsView(private val project: Project) : ProblemsViewAc } override fun tabs(): List? { - val window = ProblemsViewToolWindowUtils.getToolWindow(project) ?: return null + val window = window() ?: return null return window.contentManager.contents.mapNotNull { content -> val tab = content.component as? ProblemsViewTab ?: return@mapNotNull null ProblemsViewAccess.Tab(tab.getTabId(), content.displayName ?: tab.getName(0)) @@ -29,4 +31,8 @@ internal class PluginProblemsView(private val project: Project) : ProblemsViewAc } override fun selectedTab(): String? = ProblemsViewToolWindowUtils.getSelectedTab(project)?.getTabId() + + override fun window(): ToolWindow? = ProblemsViewToolWindowUtils.getToolWindow(project) + + override fun content(tabId: String): Content? = ProblemsViewToolWindowUtils.getContentById(project, tabId) } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdePlaces.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdePlaces.kt index 6f9b4511..8c4013a9 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdePlaces.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdePlaces.kt @@ -1,6 +1,5 @@ package dev.lain.claudejb.controller.mcp -import com.intellij.analysis.problemsView.toolWindow.ProblemsViewToolWindowUtils import com.intellij.build.BuildContentManager import com.intellij.execution.executors.DefaultRunExecutor import com.intellij.execution.ui.RunContentManager @@ -12,6 +11,7 @@ import com.intellij.openapi.vcs.changes.actions.diff.ShowDiffAction import com.intellij.openapi.wm.ToolWindowId import com.intellij.openapi.wm.ToolWindowManager import dev.lain.claudejb.controller.git.GitLogNavigator +import dev.lain.claudejb.controller.mcp.tools.code.ProblemsViewAccess import dev.lain.claudejb.model.diff.DiffPresenter import java.nio.file.Path @@ -64,8 +64,10 @@ internal class IdePlaces(private val project: Project) { } private fun problems(tab: String): Boolean { - val window = ProblemsViewToolWindowUtils.getToolWindow(project) ?: return false - if (tab.isEmpty()) window.activate(null, true) else ProblemsViewToolWindowUtils.selectTab(project, tab) + val view = ProblemsViewAccess.of(project) ?: return false + val window = view.window() ?: return false + val content = if (tab.isEmpty()) null else view.content(tab) ?: return false + window.activate({ if (content != null) window.contentManager.setSelectedContent(content, true) }, true) return true } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/Reveal.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/Reveal.kt index ee57d79c..49abee05 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/Reveal.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/Reveal.kt @@ -1,6 +1,5 @@ package dev.lain.claudejb.controller.mcp -import com.intellij.analysis.problemsView.toolWindow.ProblemsViewToolWindowUtils import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.OpenFileDescriptor import com.intellij.openapi.project.Project @@ -9,6 +8,7 @@ import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowManager import dev.lain.claudejb.controller.git.ForgeViewNavigator import dev.lain.claudejb.controller.git.GitLogNavigator +import dev.lain.claudejb.controller.mcp.tools.code.ProblemsViewAccess import dev.lain.claudejb.model.settings.ClaudeSettings internal class Reveal(private val project: Project) { @@ -46,8 +46,9 @@ internal class Reveal(private val project: Project) { suspend fun requests(): Boolean = FocusKeeper.keep(project) { ForgeViewNavigator.open(project, focus = false) } suspend fun problems(tab: String): Boolean = FocusKeeper.keep(project) { - val window = ProblemsViewToolWindowUtils.getToolWindow(project) ?: return@keep false - val content = if (tab.isEmpty()) null else ProblemsViewToolWindowUtils.getContentById(project, tab) ?: return@keep false + val view = ProblemsViewAccess.of(project) ?: return@keep false + val window = view.window() ?: return@keep false + val content = if (tab.isEmpty()) null else view.content(tab) ?: return@keep false window.activate({ if (content != null) window.contentManager.setSelectedContent(content, false) }, false) true } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/CoreProblemsView.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/CoreProblemsView.kt index ebb69192..bab631f3 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/CoreProblemsView.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/CoreProblemsView.kt @@ -5,6 +5,8 @@ import com.intellij.analysis.problemsView.ProblemsCollector import com.intellij.analysis.problemsView.toolWindow.ProblemsViewTab import com.intellij.analysis.problemsView.toolWindow.ProblemsViewToolWindowUtils import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.ui.content.Content internal class CoreProblemsView(private val project: Project) : ProblemsViewAccess { @@ -21,7 +23,7 @@ internal class CoreProblemsView(private val project: Project) : ProblemsViewAcce } override fun tabs(): List? { - val window = ProblemsViewToolWindowUtils.getToolWindow(project) ?: return null + val window = window() ?: return null return window.contentManager.contents.mapNotNull { content -> val tab = content.component as? ProblemsViewTab ?: return@mapNotNull null ProblemsViewAccess.Tab(tab.getTabId(), content.displayName ?: tab.getName(0)) @@ -29,4 +31,8 @@ internal class CoreProblemsView(private val project: Project) : ProblemsViewAcce } override fun selectedTab(): String? = ProblemsViewToolWindowUtils.getSelectedTab(project)?.getTabId() + + override fun window(): ToolWindow? = ProblemsViewToolWindowUtils.getToolWindow(project) + + override fun content(tabId: String): Content? = ProblemsViewToolWindowUtils.getContentById(project, tabId) } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProblemsViewAccess.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProblemsViewAccess.kt index 3e40952c..4b2fa467 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProblemsViewAccess.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ProblemsViewAccess.kt @@ -3,6 +3,8 @@ package dev.lain.claudejb.controller.mcp.tools.code import com.intellij.openapi.components.serviceOrNull import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.wm.ToolWindow +import com.intellij.ui.content.Content interface ProblemsViewAccess { @@ -16,16 +18,23 @@ interface ProblemsViewAccess { fun selectedTab(): String? + fun window(): ToolWindow? + + fun content(tabId: String): Content? + companion object { const val MISSING = "this IDE build does not expose the Problems view to plugins, so its problems cannot be read here" fun of(project: Project): ProblemsViewAccess? = - project.serviceOrNull() ?: CoreProblemsView(project).takeIf { coreLoads } + project.serviceOrNull() ?: if (coreLoads) CoreProblemsView(project) else null private val coreLoads: Boolean by lazy { - runCatching { Class.forName(CORE_CLASS, false, ProblemsViewAccess::class.java.classLoader) }.isSuccess + CORE_CLASSES.all { runCatching { Class.forName(it, false, ProblemsViewAccess::class.java.classLoader) }.isSuccess } } - private const val CORE_CLASS = "com.intellij.analysis.problemsView.ProblemsCollector" + private val CORE_CLASSES = listOf( + "com.intellij.analysis.problemsView.ProblemsCollector", + "com.intellij.analysis.problemsView.toolWindow.ProblemsViewToolWindowUtils", + ) } } diff --git a/src/test/kotlin/dev/lain/claudejb/headless/ProjectProblemsHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/headless/ProjectProblemsHeadlessTest.kt new file mode 100644 index 00000000..450e92d8 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/headless/ProjectProblemsHeadlessTest.kt @@ -0,0 +1,45 @@ +package dev.lain.claudejb.headless + +import com.intellij.analysis.problemsView.FileProblem +import com.intellij.analysis.problemsView.ProblemsCollector +import com.intellij.analysis.problemsView.ProblemsProvider +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import dev.lain.claudejb.controller.mcp.Reveal +import dev.lain.claudejb.controller.mcp.tools.code.DiagnosticsTools +import dev.lain.claudejb.model.mcp.ToolArgs +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonObject + +class ProjectProblemsHeadlessTest : BasePlatformTestCase() { + + private class KnownError(override val project: Project) : ProblemsProvider + + private class Unresolved(override val provider: ProblemsProvider, override val file: VirtualFile) : FileProblem { + override val text: String = MESSAGE + override val line: Int = 2 + } + + override fun runInDispatchThread(): Boolean = false + + fun `test project_problems reports the error the Problems view holds for a file`() { + val file = myFixture.addFileToProject("src/Broken.kt", "fun main() {\n\n brokenCall()\n}\n").virtualFile + val problem = Unresolved(KnownError(project), file) + val collector = ProblemsCollector.getInstance(project) + collector.problemAppeared(problem) + try { + val tool = DiagnosticsTools(project, Reveal(project)).domain().tools.single { it.spec.name == "project_problems" } + val answer = runBlocking { tool.run(ToolArgs(JsonObject(emptyMap()))) } + assertFalse(answer.text, answer.isError) + assertTrue(answer.text, answer.text.contains("src/Broken.kt")) + assertTrue(answer.text, answer.text.contains("3,$MESSAGE")) + } finally { + collector.problemDisappeared(problem) + } + } + + private companion object { + const val MESSAGE = "Unresolved reference brokenCall" + } +} From 4fc35d5ba9a35c553e1ad8ab7381dc834611861f Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 12:23:18 +0200 Subject: [PATCH 146/155] fix(mcp): tell plugins callers that IDE modules such as IntelliLang are not listed --- .../dev/lain/claudejb/controller/mcp/tools/ops/IdeTools.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/IdeTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/IdeTools.kt index e61bcf48..ca55a381 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/IdeTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/ops/IdeTools.kt @@ -179,7 +179,9 @@ internal class IdeTools(private val project: Project, private val actions: IdeAc "plugins", "Lists the plugins loaded in this IDE, with id, name, version, vendor and whether each is enabled. Use it before " + "relying on a plugin's tool window, action or file type; filter by a fragment of the id or the name. " + - "Refused on IDEs older than 2026.2, which have no public plugin listing.", + "Parts of the IDE that ship as modules rather than plugins are not listed, so an absent entry does not mean " + + "the feature is missing: language injection (org.intellij.intelliLang) is a module since 2026.2, and " + + "inject_at and injections work without it appearing here.", listOf( Param("filter", "Only plugins whose id or name contains this text, case-insensitive", required = false), Param.max("plugins", DEFAULT_MAX), From 0329a371ef55becc6d78f935892a012262698b42 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 12:23:27 +0200 Subject: [PATCH 147/155] fix(mcp): create a scratch under its exact name unless that exact name is taken --- .../controller/mcp/tools/code/PresenceTools.kt | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PresenceTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PresenceTools.kt index 4ff6e9c8..f57f6594 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PresenceTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PresenceTools.kt @@ -1,10 +1,12 @@ package dev.lain.claudejb.controller.mcp.tools.code +import com.intellij.ide.scratch.ScratchFileService import com.intellij.ide.scratch.ScratchRootType import com.intellij.lang.Language import com.intellij.openapi.application.EDT import com.intellij.openapi.application.readAction import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.wm.WindowManager import dev.lain.claudejb.controller.mcp.Reveal import dev.lain.claudejb.model.mcp.Items @@ -81,7 +83,7 @@ internal class PresenceTools(private val project: Project, private val reveal: R val content = args.optionalString("content").orEmpty() val languageId = args.optionalString("language") val language = languageId?.let { Language.findLanguageByID(it) ?: throw ToolException("this IDE has no language with id $it") } - val file = withContext(Dispatchers.EDT) { ScratchRootType.getInstance().createScratchFile(project, name, language, content) } + val file = withContext(Dispatchers.EDT) { createScratch(name, language, content) } ?: throw ToolException("the IDE did not create the scratch file $name") reveal.file(file) return ToolResult.toon( @@ -94,8 +96,18 @@ internal class PresenceTools(private val project: Project, private val reveal: R ) } + private fun createScratch(name: String, language: Language?, content: String): VirtualFile? { + val root = ScratchRootType.getInstance() + val taken = ScratchFileService.getInstance().findFile(root, name, ScratchFileService.Option.existing_only) != null + val merged = name.substringAfterLast('/').startsWith(MERGED_PREFIX) + val option = if (taken || merged) ScratchFileService.Option.create_new_always else ScratchFileService.Option.create_if_missing + return root.createScratchFile(project, name, language, content, option) + } + companion object { + private const val MERGED_PREFIX = "buffer" + val BANNER_SHOW = ToolSpec( "banner_show", "Shows a notification banner over a file's editor, as the IDE does for its own notices, with a text and " + @@ -127,7 +139,8 @@ internal class PresenceTools(private val project: Project, private val reveal: R val SCRATCH_CREATE = ToolSpec( "scratch_create", "Creates a scratch file (Scratches and Consoles) with a name, an optional language for highlighting and initial " + - "content, and opens it in the editor without focus; it lives outside the project and is never committed.", + "content, and opens it in the editor without focus; it lives outside the project and is never committed. The file " + + "gets exactly the name given unless a scratch with that exact name exists, in which case the IDE numbers it.", listOf( Param("name", "File name with extension, e.g. notes.md, query.sql"), Param("language", "Language id for highlighting (default: from the extension)", required = false), From 8497382384c5f06356b77338197d70b3bc4ed05e Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 12:23:40 +0200 Subject: [PATCH 148/155] fix(mcp): report the language the IDE resolved for a new scratch, or none --- .../claudejb/controller/mcp/tools/code/PresenceTools.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PresenceTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PresenceTools.kt index f57f6594..cfcb7de6 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PresenceTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PresenceTools.kt @@ -3,6 +3,7 @@ package dev.lain.claudejb.controller.mcp.tools.code import com.intellij.ide.scratch.ScratchFileService import com.intellij.ide.scratch.ScratchRootType import com.intellij.lang.Language +import com.intellij.lang.LanguageUtil import com.intellij.openapi.application.EDT import com.intellij.openapi.application.readAction import com.intellij.openapi.project.Project @@ -86,11 +87,12 @@ internal class PresenceTools(private val project: Project, private val reveal: R val file = withContext(Dispatchers.EDT) { createScratch(name, language, content) } ?: throw ToolException("the IDE did not create the scratch file $name") reveal.file(file) + val resolved = readAction { LanguageUtil.getFileLanguage(file)?.id } return ToolResult.toon( buildJsonObject { put("name", file.name) put("path", file.path) - put("language", language?.id ?: "") + resolved?.let { put("language", it) } put("created", true) }, ) @@ -140,7 +142,8 @@ internal class PresenceTools(private val project: Project, private val reveal: R "scratch_create", "Creates a scratch file (Scratches and Consoles) with a name, an optional language for highlighting and initial " + "content, and opens it in the editor without focus; it lives outside the project and is never committed. The file " + - "gets exactly the name given unless a scratch with that exact name exists, in which case the IDE numbers it.", + "gets exactly the name given unless a scratch with that exact name exists, in which case the IDE numbers it; " + + "the answer carries the name used and the language the IDE resolved.", listOf( Param("name", "File name with extension, e.g. notes.md, query.sql"), Param("language", "Language id for highlighting (default: from the extension)", required = false), From 030fc405b73eb58804e631ae7baac129984e0486 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 12:33:51 +0200 Subject: [PATCH 149/155] docs: complete the 6.5.0 changelog and release notes and stamp them for today --- CHANGELOG.md | 13 +++++++++++-- RELEASE_NOTES.md | 8 +++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 090f6b1a..faec9ed2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [6.5.0] — 2026-09-23 +## [6.5.0] — 2026-09-24 **The chat works in Remote Development.** The plugin is split into a frontend, drawn where the UI runs, and a backend, beside the project, joined by the platform's RPC. **This release needs IntelliJ Platform 2026.2 @@ -21,7 +21,7 @@ backend, beside the project, joined by the platform's RPC. **This release needs - **Code With Me, for the host.** The host's chat works as in a local IDE. Guests get no chat: JetBrains is retiring Code With Me, and 2026.1 was the last release with official support. - **Optional modules for the IDE plugins the tools reach** — Git, GitHub, Java, Terminal, IntelliLang, - Database. Each loads only when its plugin is present. + Database, line bookmarks and, on 2026.3, the Problems view. Each loads only when its plugin is present. ### Changed - **The floor is 2026.2 (build 262.8665.258); the range runs to 263.\*.** 2025.3 and 2026.1 are no longer @@ -36,6 +36,10 @@ backend, beside the project, joined by the platform's RPC. **This release needs - **Smaller MCP answers.** `read_file` splits its budget between the files of a batch; `search_text` and `project_problems` group by file; every `max` a tool takes has a ceiling; commit rows are abbreviated and working-tree diffs built file by file; the services listing is trimmed. +- **`project_problems` and `problems_view` are offered only where the IDE lets a plugin read the Problems + view.** On 2026.3, where the Problems view is its own plugin, they reach it through that plugin's modules. +- **`plugins` says that parts of the IDE shipped as modules, such as IntelliLang, are not listed**, so an + absent entry is not read as a missing feature. - **Libraries.** DOMPurify 3.4.16, highlight.js 11.12.0, marked 18.0.14. Build: Kotlin 2.4, a JDK 25 toolchain, Gradle 9.7.1, IntelliJ Platform Gradle Plugin 2.19.0, detekt 2.0.0-alpha.6, Spotless 8.10.2, JUnit 6.1.3. kotlinx-serialization is no longer bundled; the platform's copy is used. The npm toolchain @@ -67,6 +71,9 @@ backend, beside the project, joined by the platform's RPC. **This release needs toggles expose their state with `aria-pressed`; Enter while an IME is composing no longer sends; focus and current data survive dashboard and tab redraws; a settings row shows as pending until the host confirms it. - **Closing the last chat tab shows the new one.** +- **`scratch_create` keeps the name it is given.** A scratch with the same name and another extension no + longer makes the IDE number the new one; only an exact match does. The answer carries the language the IDE + resolved, or none, instead of an empty string. ### Security - **A renewed credential is kept when the keyring refuses the write**, instead of being wiped. @@ -78,6 +85,8 @@ backend, beside the project, joined by the platform's RPC. **This release needs withdrawn in order, behind the decision it cancels. - **Each guard alert is announced once.** - **Only http(s), `jb://` and relative links are forwarded from the page**, middle clicks included. +- **The guard's outside-project rule lets the IDE's own scratch folder through, and nothing else there.** + Every other rule still judges a scratch file. ## [6.0.1] — 2026-09-22 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d939acf4..f3bd0900 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,4 +1,4 @@ -## v6.5.0 — 2026-09-23 +## v6.5.0 — 2026-09-24 **The chat works in Remote Development.** Until now, in a remote setup the plugin loaded only on the host, where the chat page cannot be drawn. The plugin is now split in two: the tool window and the embedded browser run @@ -29,7 +29,8 @@ A CLI that crashes on start is no longer restarted forever. Closed chat tabs no IDE no longer freezes when `claude` stops reading its input. A prompt with attachments is no longer lost when the session cannot start. One malformed message no longer takes an IDE server down. `run_configuration` reports the exit code of the run it started, and a batch no longer applies half its items. Closing the last -chat tab shows the new one. +chat tab shows the new one. A scratch file keeps the name you give it, even when one with the same name and +another extension exists. **The chat page works from the keyboard and with a screen reader.** Tool cards open and close from the keyboard, menus and the attach menu can be driven without a mouse, toggles announce their state, pressing @@ -45,7 +46,8 @@ announced once, and the page forwards only web, `jb://` and relative links. **Under the hood.** DOMPurify 3.4.16, highlight.js 11.12.0 and marked 18.0.14 in the page; Kotlin 2.4 on a JDK 25 toolchain, Gradle 9.7.1 and the IntelliJ Platform Gradle Plugin 2.19.0 in the build. The plugin no longer bundles its own kotlinx-serialization and uses the platform's. The code that reaches Git, GitHub, Java, -Terminal, IntelliLang and Database lives in optional modules that load only when those plugins are present. +Terminal, IntelliLang, Database, line bookmarks and the 2026.3 Problems view lives in optional modules that +load only when those plugins are present. ## v6.0.1 — 2026-09-22 From bc3102c2c8f27e0b215f636d1e61ed543ec8ef93 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 12:50:07 +0200 Subject: [PATCH 150/155] build(prettier): ignore the vendored page bundles where the split moved them --- .prettierignore | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.prettierignore b/.prettierignore index fd5a03d1..7ee234be 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,7 +1,7 @@ # Vendored third-party bundles — redistributed unmodified; reformatting them would fork a dependency. -src/main/resources/jcef/marked.min.js -src/main/resources/jcef/purify.min.js -src/main/resources/jcef/highlight.min.js +frontend/src/main/resources/jcef/marked.min.js +frontend/src/main/resources/jcef/purify.min.js +frontend/src/main/resources/jcef/highlight.min.js build/ node_modules/ From c70066622f6ede1e0e41a3b07d1bee363cab3511 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 12:50:11 +0200 Subject: [PATCH 151/155] style(page): apply Prettier to the page sources and tests --- frontend/src/main/ts/jcef/core/theme.ts | 5 ++++- frontend/src/main/ts/jcef/views/chat/find-bar.ts | 7 ++++++- .../src/main/ts/jcef/views/composer/attach/menu.ts | 4 +++- .../src/main/ts/jcef/views/composer/readout/mini.ts | 10 +++++++++- .../main/ts/jcef/views/permissions/cards/question.ts | 6 +----- .../src/main/ts/jcef/views/permissions/cards/tool.ts | 5 ++++- src/test/frontend/chat/markdown-fragment.test.js | 5 ++++- src/test/frontend/chat/toon-shapes.test.js | 4 +++- src/test/frontend/composer/settings-pending.test.js | 4 +++- src/test/frontend/contracts/bridge-contract.test.js | 11 ++++++++--- src/test/frontend/guard/guard-announce.test.js | 4 +++- .../frontend/permissions/permission-updates.test.js | 7 +++++-- 12 files changed, 53 insertions(+), 19 deletions(-) diff --git a/frontend/src/main/ts/jcef/core/theme.ts b/frontend/src/main/ts/jcef/core/theme.ts index 62e8b95c..f88f40bb 100644 --- a/frontend/src/main/ts/jcef/core/theme.ts +++ b/frontend/src/main/ts/jcef/core/theme.ts @@ -116,7 +116,10 @@ const holder = document.createElement('div'); holder.hidden = true; holder.innerHTML = CC.nyanSvg() - .replace('/* a\nb */ x'); - expect(lines).toEqual(['/* a', 'b */ x']); + expect(lines).toEqual([ + '/* a', + 'b */ x', + ]); }); }); diff --git a/src/test/frontend/chat/toon-shapes.test.js b/src/test/frontend/chat/toon-shapes.test.js index eb05bed7..4a8af1ac 100644 --- a/src/test/frontend/chat/toon-shapes.test.js +++ b/src/test/frontend/chat/toon-shapes.test.js @@ -28,7 +28,9 @@ describe('the search_text shape: files with their matching lines', () => { it('a read opens where its range starts, and its line count is never taken for a line', () => { const out = draw({ items: [{ path: 'src/C.kt', lines: 40, from: 12, to: 51, text: 'a\nb' }] }); - expect(out.querySelector('summary a.jb-link').getAttribute('href')).toBe('jb://open?file=src%2FC.kt&line=12'); + expect(out.querySelector('summary a.jb-link').getAttribute('href')).toBe( + 'jb://open?file=src%2FC.kt&line=12' + ); const top = draw({ items: [{ path: 'src/D.kt', lines: 2, from: 1, to: 2, text: 'a\nb' }] }); expect(top.querySelector('summary a.jb-link').getAttribute('href')).toBe('jb://open?file=src%2FD.kt'); }); diff --git a/src/test/frontend/composer/settings-pending.test.js b/src/test/frontend/composer/settings-pending.test.js index b4ca823d..9fe0d8e5 100644 --- a/src/test/frontend/composer/settings-pending.test.js +++ b/src/test/frontend/composer/settings-pending.test.js @@ -15,7 +15,9 @@ describe('a settings row the host has not confirmed yet', () => { ); entry.click(); const row = () => - Array.from(win.document.querySelectorAll('.settings-item')).find((r) => r.textContent === 'Sandbox commands'); + Array.from(win.document.querySelectorAll('.settings-item')).find( + (r) => r.textContent === 'Sandbox commands' + ); row().click(); expect(row().classList.contains('pending')).toBe(true); expect(row().getAttribute('aria-busy')).toBe('true'); diff --git a/src/test/frontend/contracts/bridge-contract.test.js b/src/test/frontend/contracts/bridge-contract.test.js index eb66631a..6f244292 100644 --- a/src/test/frontend/contracts/bridge-contract.test.js +++ b/src/test/frontend/contracts/bridge-contract.test.js @@ -8,7 +8,8 @@ const KOTLIN_ROOTS = [ path.resolve(__dirname, '../../../../frontend/src/main/kotlin'), ]; -const BRIDGE_CALL = /window\.cc\.([A-Za-z_$][A-Za-z0-9_$]*)|\b(?:exec|PagePush)\(\s*"([A-Za-z_$][A-Za-z0-9_$]*)"\s*,/g; +const BRIDGE_CALL = + /window\.cc\.([A-Za-z_$][A-Za-z0-9_$]*)|\b(?:exec|PagePush)\(\s*"([A-Za-z_$][A-Za-z0-9_$]*)"\s*,/g; const NO_OP = /^function\s*[A-Za-z0-9_$]*\s*\(\s*\)\s*\{\s*\}$/; @@ -91,13 +92,17 @@ describe('Kotlin↔JS bridge — every host call reaches a real implementation', }); it('skips a Kotlin string template, which names no method', () => { - const calls = scanCalls([{ file: 'Host.kt', text: 'exec("window.cc.$method && window.cc.$method($json)")' }]); + const calls = scanCalls([ + { file: 'Host.kt', text: 'exec("window.cc.$method && window.cc.$method($json)")' }, + ]); expect([...calls.keys()]).toEqual([]); }); it('reads a push by method name with strict JSON as a call too', () => { - const calls = scanCalls([{ file: 'Push.kt', text: 'exec("append", json)\nemit(PagePush("setGitSubView", x))' }]); + const calls = scanCalls([ + { file: 'Push.kt', text: 'exec("append", json)\nemit(PagePush("setGitSubView", x))' }, + ]); expect([...calls.keys()]).toEqual(['append', 'setGitSubView']); }); diff --git a/src/test/frontend/guard/guard-announce.test.js b/src/test/frontend/guard/guard-announce.test.js index ed33335e..3b79f06a 100644 --- a/src/test/frontend/guard/guard-announce.test.js +++ b/src/test/frontend/guard/guard-announce.test.js @@ -96,7 +96,9 @@ describe('a guard filter menu never outlives its card', () => { it('closes an open menu when the host repaints the view', async () => { const win = open(); win.cc.guard(PAYLOAD()); - win.document.querySelector('.guard-filter-trigger').dispatchEvent(new win.MouseEvent('click', { bubbles: true })); + win.document + .querySelector('.guard-filter-trigger') + .dispatchEvent(new win.MouseEvent('click', { bubbles: true })); const first = openMenus(win)[0]; win.cc.guard(PAYLOAD()); diff --git a/src/test/frontend/permissions/permission-updates.test.js b/src/test/frontend/permissions/permission-updates.test.js index 84137f5e..b6fbe128 100644 --- a/src/test/frontend/permissions/permission-updates.test.js +++ b/src/test/frontend/permissions/permission-updates.test.js @@ -19,7 +19,9 @@ describe('a permission card updated in place', () => { win.cc.permissions([bash({ guard: { rule: 'CREDENTIALS', label: 'Block credential files' } })]); expect(region.querySelectorAll('[data-card-id="r1"]').length).toBe(1); - expect(region.querySelector('.perm-guard .perm-guard-rule').textContent).toContain('Block credential files'); + expect(region.querySelector('.perm-guard .perm-guard-rule').textContent).toContain( + 'Block credential files' + ); }); it('shows a blocked path and a decision reason that arrive later', () => { @@ -92,7 +94,8 @@ describe('a permission card takes one resolution', () => { const sent = []; win.CC.send = (m) => sent.push(m); win.cc.permissions([bash()]); - const byText = (t) => [...win.CC.els.permissions.querySelectorAll('button')].find((b) => b.textContent === t); + const byText = (t) => + [...win.CC.els.permissions.querySelectorAll('button')].find((b) => b.textContent === t); byText('Reject').click(); byText('Always allow').click(); From c64e2ddb79947651926b4c03202ab7da0be63ff3 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 24 Sep 2026 16:53:22 +0200 Subject: [PATCH 152/155] feat(chat): show a shell call's command as its own copyable block, apart from its other args --- .../main/ts/jcef/views/chat/tools/tools.ts | 2 +- .../controller/session/events/ToolEvents.kt | 3 ++- .../dev/lain/claudejb/model/mcp/OwnTools.kt | 8 +++++++ .../history/SessionTranscriptReader.kt | 4 ++-- src/test/frontend/chat/transcript.test.js | 24 +++++++++++++++++++ .../lain/claudejb/model/mcp/OwnToolsTest.kt | 17 +++++++++++++ .../history/SessionTranscriptReaderTest.kt | 12 ++++++++++ 7 files changed, 66 insertions(+), 4 deletions(-) diff --git a/frontend/src/main/ts/jcef/views/chat/tools/tools.ts b/frontend/src/main/ts/jcef/views/chat/tools/tools.ts index 644837c9..6d5817d2 100644 --- a/frontend/src/main/ts/jcef/views/chat/tools/tools.ts +++ b/frontend/src/main/ts/jcef/views/chat/tools/tools.ts @@ -96,8 +96,8 @@ places.hidden = true; node.appendChild(head); node.appendChild(places); - node.appendChild(msg); node.appendChild(cmd); + node.appendChild(msg); node.appendChild(out); node.appendChild(children); if (entry && entry.message) { diff --git a/src/main/kotlin/dev/lain/claudejb/controller/session/events/ToolEvents.kt b/src/main/kotlin/dev/lain/claudejb/controller/session/events/ToolEvents.kt index 5a703bd0..ced22798 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/session/events/ToolEvents.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/session/events/ToolEvents.kt @@ -79,7 +79,8 @@ class ToolEvents( parentToolUseId = event.parentToolUseId, toolState = ToolState.LOADING, filePath = OwnTools.path(item.args), - messageText = if (item.review == null) OwnTools.argsToon(item.args) else null, + commandText = OwnTools.command(call, item.args), + messageText = if (item.review == null) OwnTools.detailsToon(call, item.args) else null, reviewable = item.review != null, ) liveOutput.flushEarly(item.id) diff --git a/src/main/kotlin/dev/lain/claudejb/model/mcp/OwnTools.kt b/src/main/kotlin/dev/lain/claudejb/model/mcp/OwnTools.kt index e3ca068d..e77c75bb 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/mcp/OwnTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/mcp/OwnTools.kt @@ -19,6 +19,8 @@ object OwnTools { const val TOOL_USE_ID_KEY = "claudecode/toolUseId" const val READ_FILE = "read_file" const val INSERT = "InsertText" + const val SHELL = "shell" + private const val COMMAND = "command" private val META_TOOL = Regex("^mcp__([a-z]+)__(domains|tools|run)$") private val EDITS = setOf("replace_text", "insert_text", "create_file", "write_file") @@ -63,6 +65,12 @@ object OwnTools { fun argsToon(args: JsonObject): String? = args.takeIf { it.isNotEmpty() }?.let { Toon.encode(it) } + fun command(call: Call, args: JsonObject): String? = + if (call.meta == MetaTools.RUN.name && call.argument == SHELL) text(args, COMMAND)?.takeIf { it.isNotBlank() } else null + + fun detailsToon(call: Call, args: JsonObject): String? = + argsToon(if (command(call, args) == null) args else JsonObject(args - COMMAND)) + fun reviewAs(call: Call, args: JsonObject, projectRoot: String?): Review? { if (!isEdit(call)) return null val path = text(args, "path") ?: return null diff --git a/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReader.kt b/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReader.kt index 033f4a0e..28f04c16 100644 --- a/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReader.kt +++ b/src/main/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReader.kt @@ -246,8 +246,8 @@ object SessionTranscriptReader { parentToolUseId = origin.parent, atMillis = origin.atMillis, filePath = if (own != null) OwnTools.path(args) else ToolNaming.toolFilePath(name, input, projectRoot), - commandText = ToolInputScanner.commandText(input), - messageText = if (own != null) OwnTools.argsToon(args) else ToolInputScanner.messageText(input), + commandText = if (own != null) OwnTools.command(own, args) else ToolInputScanner.commandText(input), + messageText = if (own != null) OwnTools.detailsToon(own, args) else ToolInputScanner.messageText(input), ) } diff --git a/src/test/frontend/chat/transcript.test.js b/src/test/frontend/chat/transcript.test.js index f746ceec..57c7786d 100644 --- a/src/test/frontend/chat/transcript.test.js +++ b/src/test/frontend/chat/transcript.test.js @@ -307,6 +307,30 @@ describe('transcript — the executed command renders as its own always-visible expect(win.document.querySelectorAll('.tool.cmd-tool').length).toBe(2); }); + it('an own shell card shows its command first, copied alone, and its other args in a block apart', () => { + const win = loadFrontend(['app-transcript.js']); + const sent = []; + win.CC.send = (m) => sent.push(m); + win.cc.batch([ + row(22, 0, 'TOOL', 'run ▸ shell', { + meta: 'mcp__run__run', + toolUseId: 'tu-shell-args', + command: 'podman push img:v1', + message: 'tail: 15', + }), + ]); + const card = win.document.querySelector('.tool'); + const blocks = Array.from(card.querySelectorAll('pre.command-src, pre.message-src')).map( + (b) => b.className + ); + expect(blocks).toEqual(['command-src', 'message-src']); + expect(card.querySelector('pre.message-src code').textContent).toBe('tail: 15'); + card.querySelector('pre.command-src .copy').click(); + const copied = sent.filter((m) => m.type === 'copy').map((m) => m.text); + expect(copied.length).toBeGreaterThan(0); + expect(copied.every((t) => t === 'podman push img:v1')).toBe(true); + }); + it('a tool without entry.command (e.g. Read) gets no command-src block, no cmd-tool class, full label', () => { const win = loadFrontend(['app-transcript.js']); win.cc.batch([ diff --git a/src/test/kotlin/dev/lain/claudejb/model/mcp/OwnToolsTest.kt b/src/test/kotlin/dev/lain/claudejb/model/mcp/OwnToolsTest.kt index 55cc94ab..fe428edc 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/mcp/OwnToolsTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/mcp/OwnToolsTest.kt @@ -34,6 +34,23 @@ class OwnToolsTest { assertNull(OwnTools.argsToon(args("""{"tool":"x"}"""))) } + @Test + fun `a shell call carries its command apart from the rest of its args, and no other call has a command`() { + val shell = input("""{"tool":"shell","args":{"command":"podman push img:v1","tail":15}}""") + val call = OwnTools.parse("mcp__run__run", shell)!! + assertEquals("podman push img:v1", OwnTools.command(call, OwnTools.argsOf(shell))) + assertEquals("tail: 15", OwnTools.detailsToon(call, OwnTools.argsOf(shell))) + val only = input("""{"tool":"shell","args":{"command":"git status"}}""") + assertNull(OwnTools.detailsToon(call, OwnTools.argsOf(only))) + val job = input("""{"tool":"shell","args":{"job":"shell-3"}}""") + assertNull(OwnTools.command(call, OwnTools.argsOf(job))) + assertEquals("job: shell-3", OwnTools.detailsToon(call, OwnTools.argsOf(job))) + val other = input("""{"tool":"run_configuration","args":{"command":"x","name":"Tests"}}""") + val otherCall = OwnTools.parse("mcp__run__run", other)!! + assertNull(OwnTools.command(otherCall, OwnTools.argsOf(other))) + assertEquals(OwnTools.argsToon(OwnTools.argsOf(other)), OwnTools.detailsToon(otherCall, OwnTools.argsOf(other))) + } + @Test fun `the guard sees a run call flat, with the tool name kept and every argument at the top level`() { val flat = OwnTools.guardInput(input("""{"tool":"write_file","args":{"path":"/home/u/.bash_aliases","content":"alias"}}""")) diff --git a/src/test/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReaderTest.kt b/src/test/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReaderTest.kt index daf046b0..9bbe4e02 100644 --- a/src/test/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReaderTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/model/session/history/SessionTranscriptReaderTest.kt @@ -67,6 +67,18 @@ class SessionTranscriptReaderTest { assertTrue(result.text.contains("\"path\":\"a.kt\""), result.text) } + @Test + fun `a restored own shell call carries its command as the command block and only the rest as args`() { + val lines = listOf( + """{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_sh",""" + + """"name":"mcp__run__run","input":{"tool":"shell","args":{"command":"git status","tail":15}}}]}}""", + ) + val call = SessionTranscriptReader.parseEntries(lines).single() + assertEquals("run ▸ shell", call.text) + assertEquals("git status", call.commandText) + assertEquals("tail: 15", call.messageText) + } + @Test fun `a restored file tool keeps the project-relative path and its jump-to-code link`() { val root = "/home/u/proj" From 68af35da8090873cb900ecbaa31c6d950364a93a Mon Sep 17 00:00:00 2001 From: Lain Date: Fri, 2 Oct 2026 08:35:14 +0200 Subject: [PATCH 153/155] docs: recommend the skills and settings repository, describe the IDE servers and the shell card, and point links at claude-code-native --- .github/ISSUE_TEMPLATE/config.yml | 4 ++-- CHANGELOG.md | 6 +++++ README.md | 33 ++++++++++++++++++++++---- RELEASE_NOTES.md | 9 ++++++- SECURITY.md | 6 ++--- docs/FAQ.md | 12 +++++++++- docs/PLATFORM_API_POLICY.md | 14 ++++++++++- docs/RELEASE_PROCEDURE.md | 2 +- src/main/resources/META-INF/plugin.xml | 27 +++++++++++++++++---- 9 files changed, 96 insertions(+), 17 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index a2a15be0..3db9256c 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -9,9 +9,9 @@ contact_links: # TODO: replace with the GitHub Discussions URL once enabled for the repo. - name: GitHub Discussions - url: https://github.com/serialexperimentslainnnn/claude-code-for-jetbrains/discussions + url: https://github.com/serialexperimentslainnnn/claude-code-native/discussions about: Ask a question, share a workflow, or discuss ideas before filing an issue. - name: Security vulnerability - url: https://github.com/serialexperimentslainnnn/claude-code-for-jetbrains/security/advisories/new + url: https://github.com/serialexperimentslainnnn/claude-code-native/security/advisories/new about: Do NOT open a public issue. Report it privately here; see SECURITY.md. diff --git a/CHANGELOG.md b/CHANGELOG.md index faec9ed2..25d7202d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ backend, beside the project, joined by the platform's RPC. **This release needs retiring Code With Me, and 2026.1 was the last release with official support. - **Optional modules for the IDE plugins the tools reach** — Git, GitHub, Java, Terminal, IntelliLang, Database, line bookmarks and, on 2026.3, the Problems view. Each loads only when its plugin is present. +- **The README and the plugin page recommend + [Claude Code Native — Skills and Settings](https://github.com/serialexperimentslainnnn/claude-code-native-skills-and-settings)**, + the `~/.claude` configuration that tells Claude how to use the IDE's tools and keeps it using them. ### Changed - **The floor is 2026.2 (build 262.8665.258); the range runs to 263.\*.** 2025.3 and 2026.1 are no longer @@ -40,6 +43,9 @@ backend, beside the project, joined by the platform's RPC. **This release needs view.** On 2026.3, where the Problems view is its own plugin, they reach it through that plugin's modules. - **`plugins` says that parts of the IDE shipped as modules, such as IntelliLang, are not listed**, so an absent entry is not read as a missing feature. +- **A `run ▸ shell` card shows its command as its own code block**, first and visible while the card is + collapsed, whose Copy copies the command alone; the call's other arguments are a block apart. A restored + session draws it the same way. - **Libraries.** DOMPurify 3.4.16, highlight.js 11.12.0, marked 18.0.14. Build: Kotlin 2.4, a JDK 25 toolchain, Gradle 9.7.1, IntelliJ Platform Gradle Plugin 2.19.0, detekt 2.0.0-alpha.6, Spotless 8.10.2, JUnit 6.1.3. kotlinx-serialization is no longer bundled; the platform's copy is used. The npm toolchain diff --git a/README.md b/README.md index 83c59898..9bc4f0e4 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ JetBrains. It needs your own `claude` CLI and your own Claude subscription or AP ## Contents - [Requirements](#requirements) · [Installation](#installation) · [First run](#first-run) +- [Recommended: skills and settings](#recommended-skills-and-settings) - [What you can ask for](#what-you-can-ask-for) — the manual - [Open it, show me, take me there](#open-it-show-me-take-me-there) - [Ask about what is on screen](#ask-about-what-is-on-screen) @@ -66,7 +67,7 @@ Kubernetes, SSH, Deployment, Qodana, Package Checker. Everything else needs noth 1. **Settings ▸ Plugins ▸ Marketplace**, search **Claude Code Native**, install, restart. 2. Or install a signed archive from the - [GitHub releases](https://github.com/serialexperimentslainnnn/claude-code-for-jetbrains/releases) with + [GitHub releases](https://github.com/serialexperimentslainnnn/claude-code-native/releases) with **Settings ▸ Plugins ▸ ⚙ ▸ Install Plugin from Disk**. The **Claude Code** tool window appears on the right. @@ -101,6 +102,29 @@ transcript. Settings live in the same safe, one document per IDE installation pe The IDE integration is **on by default** — the flame in the chat bar is lit: all four servers, every rule. There is nothing to configure before you start asking. +## Recommended: skills and settings + +The plugin gives Claude the IDE's tools; **[Claude Code Native — Skills and +Settings](https://github.com/serialexperimentslainnnn/claude-code-native-skills-and-settings)** tells Claude +how to use them, and keeps it using them as a conversation grows. It installs onto `~/.claude`: + +- a working method injected on **every prompt** by a `UserPromptSubmit` hook — the IDE's servers are the + tools, every call batched, everything written first and built and tested once, plain commands, every answer + read; +- `ide-tools-standards`, the skill that names which IDE tool does each job and how the guard answers; +- a catalogue of engineering-standards skills, loaded only when a task touches their domain, and + orchestration workflows installed as slash commands. + +```bash +git clone https://github.com/serialexperimentslainnnn/claude-code-native-skills-and-settings.git +cd claude-code-native-skills-and-settings +./install.sh --dry-run +./install.sh +``` + +On Windows, `pwsh -File .\install.ps1 -WhatIf`, then `pwsh -File .\install.ps1`. Both installers back up +what they replace and take `--uninstall` / `-Uninstall`. + ## What you can ask for This is the manual. Every chapter is a kind of request in your own words, what Claude does with it and what @@ -235,7 +259,8 @@ shows the execution point as it goes; the gutter shows the breakpoints. A command runs in the IDE's Terminal window, in a tab named **Claude**, and comes back with its exit code and output; several commands go in one call. The tab is shown but never focused and never switched while you are in the Terminal, and the output stays there when the tab is reused, so *View in terminal* on the -card lands on it. Claude's own `Bash` is retired while the IDE serves: a new process would cost a guard +card lands on it. The card shows the command as its own code block, whose Copy copies the command alone, and +the call's other arguments in a block apart. Claude's own `Bash` is retired while the IDE serves: a new process would cost a guard pass and a permission, and the IDE already has a shell. ### Git @@ -455,7 +480,7 @@ token and run the same tools under the same guard. The bundled stdio bridge and | The chat never loads, or the window is blank | Below 2026.2 this version does not run; otherwise JCEF is unavailable: check `ide.browser.jcef.enabled` in the Registry (in Remote Development, on the client) | | "Claude Code was not found" with the binary installed | It is somewhere the plugin does not look, or the IDE did not inherit your `PATH`; paste the path into the card | | A tool call is refused with no card to override | The guard blocked it; the message names the rule and the Settings path. Foreign-territory blocks are absolute by design | -| Claude uses `Bash` or `grep` although the IDE tools exist | The flame is off, or a rule is: turn God Mode on, or the rule in Settings ▸ Claude Code ▸ Claude IDE Integration | +| Claude uses `Bash` or `grep` although the IDE tools exist | The flame is off, or a rule is: turn God Mode on, or the rule in Settings ▸ Claude Code ▸ Claude IDE Integration. In long sessions, the [skills and settings](#recommended-skills-and-settings) hook repeats the method on every prompt | | A domain is missing from the servers | The IDE plugin behind it is not installed or disabled (Git, GitHub, Java, Database, Terminal…) | | A commit or Services action answers "not enabled here" | The view had not been shown yet; ask again, the view is now open, or open it yourself | | Signed out after a restart | The credential could not be renewed; sign in again, and check the IDE reaches your keychain | @@ -490,7 +515,7 @@ comments, one gateway file per external plugin, the guard off limits — are in GPL-3.0 — see [`LICENSE`](LICENSE) and [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md). *Claude* and *Claude Code* are trademarks of Anthropic, PBC; *JetBrains* and the IDE names are trademarks of JetBrains s.r.o. This project is not affiliated with, sponsored by, or endorsed by either. The upstream repository is -[serialexperimentslainnnn/claude-code-for-jetbrains](https://github.com/serialexperimentslainnnn/claude-code-for-jetbrains). +[serialexperimentslainnnn/claude-code-native](https://github.com/serialexperimentslainnnn/claude-code-native). ## Disclaimer diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f3bd0900..f280d072 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -22,7 +22,14 @@ reach the CLI off the UI thread. **Smaller answers from the IDE tools, so a session spends fewer tokens.** A batch of `read_file` calls shares one budget, search results and project problems come grouped by file, and every limit a tool takes has a -ceiling. +ceiling. A command Claude runs in the IDE terminal now shows on its card as its own code block, ready to +copy, with the rest of the call's arguments apart. + +**Get the most out of the IDE tools.** Install +[Claude Code Native — Skills and Settings](https://github.com/serialexperimentslainnnn/claude-code-native-skills-and-settings) +into `~/.claude`. The plugin gives Claude the IDE's tools; that configuration tells Claude how to use them and +keeps it using them as a conversation grows: a working method repeated on every prompt, a skill that maps each +job to its IDE tool, a catalogue of engineering-standards skills and a set of orchestration workflows. **A round of bugs, gone.** The trust dialog no longer comes back every three seconds after you press *Cancel*. A CLI that crashes on start is no longer restarted forever. Closed chat tabs no longer hold on to memory. The diff --git a/SECURITY.md b/SECURITY.md index d03d1f5e..9c9ebac1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,8 +8,8 @@ seriously and follow responsible disclosure. | Version | Supported | |---------|--------------| -| 5.x | Yes (active) | -| < 5.0 | No | +| 6.x | Yes (active) | +| < 6.0 | No | Only the latest release of the current major receives security fixes. There is no backporting to earlier majors: the plugin ships through the JetBrains @@ -22,7 +22,7 @@ Please **do not** open a public GitHub issue, discussion, or Marketplace review for security problems. **Use GitHub's private vulnerability reporting:** -[Report a vulnerability](https://github.com/serialexperimentslainnnn/claude-code-for-jetbrains/security/advisories/new) +[Report a vulnerability](https://github.com/serialexperimentslainnnn/claude-code-native/security/advisories/new) (repository → **Security** → **Report a vulnerability**). This replaces the email address that used to be published here, and it is the diff --git a/docs/FAQ.md b/docs/FAQ.md index 6cc55ed1..ece26345 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -7,7 +7,7 @@ Short answers to the questions we get most. For deeper diagnostics see Settings → Plugins → Marketplace → search **Claude Code Native** → Install → restart the IDE. Or install from disk using the zip from -[GitHub Releases](https://github.com/serialexperimentslainnnn/claude-code-for-jetbrains/releases). +[GitHub Releases](https://github.com/serialexperimentslainnnn/claude-code-native/releases). After install, a "Claude Code" tool window appears on the right. @@ -173,6 +173,16 @@ its copy from the Marketplace through plugin sync. get no chat. JetBrains is retiring Code With Me, and 2026.1 was the last release with official support. +## How do I keep Claude on the IDE's tools in a long session? + +Install [Claude Code Native — Skills and +Settings](https://github.com/serialexperimentslainnnn/claude-code-native-skills-and-settings) into +`~/.claude`. The rules God Mode puts in the system prompt say which tool replaces which; that configuration +adds the working method on **every prompt** through a `UserPromptSubmit` hook, so it never sinks as the +conversation grows, plus a skill that maps each job to its IDE tool and a catalogue of engineering-standards +skills. Clone it and run `./install.sh` (`install.ps1` on Windows); both back up what they replace and can +uninstall. + ## Why does each agent get its own tab now? Because one transcript could not hold them. A session running agents under diff --git a/docs/PLATFORM_API_POLICY.md b/docs/PLATFORM_API_POLICY.md index a46d2256..a72d9406 100644 --- a/docs/PLATFORM_API_POLICY.md +++ b/docs/PLATFORM_API_POLICY.md @@ -6,7 +6,9 @@ Two things enforce it. The compiler, because warnings are errors and a deprecate experimental one does not, so the rule needs no list. And a contract test, because an internal API produces no warning at all — the compiler cannot see the difference. -Everything below was read from the platform source at `idea/253.29346.138`. The verification method matters: +Everything below was read from the platform source of a build in the range it was written for: +`idea/253.29346.138` for the original table, `idea/262.8665.258` and `pycharm/263.5153.49` for what the +2026.2 floor added (the entries marked *since 261, 262 or 263*). The verification method matters: fetch the file contents through the forge API and decode them. **A summarising fetch is not acceptable for code**, because it loses signatures, and an approximate signature is indistinguishable from an invented one. @@ -96,3 +98,13 @@ utilities all live in implementation modules even though they are not annotated. - Pattern matching is a predicate for extension points, not a way to find elements, and its own javadoc asks that it not be overused. - Language server integration is not in Community at all, so a domain built on it would not start. +- **The Problems view moved out of the core in 263.** In 262 its collector and tool window utilities are in + the language implementation; in 263 they belong to the `intellij.problemView.plugin` plugin, whose content + modules a plugin must declare to see them. A plugin reaches both through one interface: the core + implementation where the classes load, an optional content module's otherwise. +- **The problems collector stores what it is handed, not what is broadcast.** Producers call its + problem-appeared directly; the problems listener topic is where it notifies the view afterwards, so a + problem published only on the topic is stored by nobody. +- **The scratch service's create-new-always numbers a name ignoring its extension**: a `.py` scratch becomes + `_1` because a `.kt` of the same stem exists. Create-if-missing keeps the exact name, except for names + starting with `buffer`, which it matches ignoring the extension and renames. diff --git a/docs/RELEASE_PROCEDURE.md b/docs/RELEASE_PROCEDURE.md index 5dec9691..b3cc22cd 100644 --- a/docs/RELEASE_PROCEDURE.md +++ b/docs/RELEASE_PROCEDURE.md @@ -123,7 +123,7 @@ but it means merging or closing those PRs is a release step, not an afterthought ### 2. Run the full local verification ```bash -JAVA_HOME=~/.jdks/jbr-21.0.11 \ +JAVA_HOME= \ ./gradlew test koverVerify detekt spotlessCheck verifyPlugin buildPlugin npm ci && npm test && npm run lint && npm run format:check npm audit --omit=dev --audit-level=low diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 1237d50b..a4a0f2e9 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -1,7 +1,7 @@ dev.lain.claude-code-for-jetbrains Claude Code Native - lain + lain Claude Code in your IDE, with a real GUI — an AI coding agent for JetBrains, not a terminal @@ -78,7 +78,8 @@
  • File paths, directories and symbols in Claude's replies become clickable links — but only after the IDE confirms them, so a link is never dead
  • Syntax highlighting for diffs and file output, in your IDE's own colours
  • -
  • Executed shell commands render as their own copyable code block
  • +
  • Executed shell commands render as their own copyable code block, apart from the call's other + arguments
  • Sessions, usage and MCP

    @@ -90,10 +91,28 @@ each one its own transcript instead of interleaving them all into the chat
  • Session dashboard: context breakdown, token usage and cost, your plan's usage limits with their reset times, account, and a Workloads view of every agent and background task
  • -
  • Optional MCP support — let Claude query the IDE through JetBrains' MCP server, or register - your own MCP servers
  • +
  • Register your own MCP servers alongside the plugin's
  • +

    The IDE as Claude's toolbox

    +
      +
    • Four MCP servers of the plugin's own — code, run, vcs, + ops — hand Claude the IDE: the index, the refactorings, run configurations, tests and + the debugger, Git and GitHub through the IDE's account, the Services panel, any registered action
    • +
    • Everything Claude opens is shown in the IDE without taking your focus, and every call passes the + security layer first
    • +
    • One call carries a whole list and answers in compact tables, so a session spends fewer tokens + than with the native tools it replaces
    • +
    + +

    Get the most out of it

    +

    Install Claude + Code Native — Skills and Settings into ~/.claude. The plugin gives Claude the IDE's + tools; that configuration tells Claude how to use them and keeps it using them as a conversation grows: + a working method injected on every prompt, a skill that maps each job to its IDE tool, a catalogue of + engineering-standards skills, and orchestration workflows. Clone it and run ./install.sh + (install.ps1 on Windows).

    +

    Native diff review for file edits

    • When the agent requests to write or edit a file, a diff opens inline in the editor area
    • From 17937cb54cbde458f77894be4ef4ca06467d648d Mon Sep 17 00:00:00 2001 From: Lain Date: Fri, 2 Oct 2026 08:35:14 +0200 Subject: [PATCH 154/155] fix: name the claude-code-native repository in the plugin's user agent --- src/main/kotlin/dev/lain/claudejb/util/PluginIdentity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/dev/lain/claudejb/util/PluginIdentity.kt b/src/main/kotlin/dev/lain/claudejb/util/PluginIdentity.kt index ec578c94..8cede89b 100644 --- a/src/main/kotlin/dev/lain/claudejb/util/PluginIdentity.kt +++ b/src/main/kotlin/dev/lain/claudejb/util/PluginIdentity.kt @@ -10,7 +10,7 @@ object PluginIdentity { const val MARKETPLACE_ID = 31965 - private const val PROJECT_URL = "https://github.com/serialexperimentslainnnn/claude-code-for-jetbrains" + private const val PROJECT_URL = "https://github.com/serialexperimentslainnnn/claude-code-native" const val USER_AGENT = "ClaudeCodeNative/$PLUGIN_VERSION (+$PROJECT_URL)" } From 7026d19ac341de33b451e42aa6ccc0c271149362 Mon Sep 17 00:00:00 2001 From: Lain Date: Fri, 2 Oct 2026 08:59:23 +0200 Subject: [PATCH 155/155] docs: stamp the 6.5.0 changelog and release notes for today --- CHANGELOG.md | 2 +- RELEASE_NOTES.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25d7202d..45cf6dd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [6.5.0] — 2026-09-24 +## [6.5.0] — 2026-10-02 **The chat works in Remote Development.** The plugin is split into a frontend, drawn where the UI runs, and a backend, beside the project, joined by the platform's RPC. **This release needs IntelliJ Platform 2026.2 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f280d072..f9eca228 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,4 +1,4 @@ -## v6.5.0 — 2026-09-24 +## v6.5.0 — 2026-10-02 **The chat works in Remote Development.** Until now, in a remote setup the plugin loaded only on the host, where the chat page cannot be drawn. The plugin is now split in two: the tool window and the embedded browser run