Skip to content

Commit c89e897

Browse files
committed
feat: add DeleteSessionAction and RenameSessionAction
refactor: replace JPopupMenu with ActionManager popup for session and file lists refactor: extract jumpToSourceAction and openDiffAction as AnActions with toolbar buttons refactor: add removeSession and refreshSessionHierarchy to OpenCodeCoreService refactor: migrate OpenCodeHttpTransport from HttpURLConnection to java.net.http.HttpClient fix: use class reference instead of hardcoded string for action ids
1 parent 23e4d8e commit c89e897

9 files changed

Lines changed: 319 additions & 150 deletions

File tree

src/main/kotlin/com/ashotn/opencode/companion/actions/ActionStrings.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,16 @@ enum class ActionStrings(
7272
description = "View and toggle MCP server connections",
7373
disabledDescription = "OpenCode must be running to manage MCP servers",
7474
),
75+
DELETE_SESSION(
76+
text = "Delete Session",
77+
disabledText = "Delete Session (no session selected)",
78+
description = "Delete the selected session",
79+
disabledDescription = "Select a session to delete it",
80+
),
81+
RENAME_SESSION(
82+
text = "Rename Session",
83+
disabledText = "Rename Session (no session selected)",
84+
description = "Rename the selected session",
85+
disabledDescription = "Select a session to rename it",
86+
),
7587
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.ashotn.opencode.companion.actions
2+
3+
import com.ashotn.opencode.companion.tui.OpenCodeTuiClient
4+
import com.ashotn.opencode.companion.util.applyStrings
5+
import com.intellij.icons.AllIcons
6+
import com.intellij.openapi.actionSystem.ActionUpdateThread
7+
import com.intellij.openapi.actionSystem.AnAction
8+
import com.intellij.openapi.actionSystem.AnActionEvent
9+
import com.intellij.openapi.application.ApplicationManager
10+
import com.intellij.openapi.project.Project
11+
import com.intellij.openapi.ui.Messages
12+
import com.intellij.ui.AnimatedIcon
13+
import java.util.concurrent.atomic.AtomicBoolean
14+
15+
/**
16+
* Deletes the currently selected session.
17+
*
18+
* [selectedSessionId] is a lambda that returns the session ID currently selected in the
19+
* session list, or null if nothing is selected. This allows the toolbar button and the
20+
* keyboard shortcut to share the same action implementation.
21+
*/
22+
class DeleteSessionAction(
23+
private val project: Project,
24+
private val selectedSessionId: () -> String?,
25+
) : AnAction() {
26+
27+
private val isPending = AtomicBoolean(false)
28+
29+
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
30+
31+
override fun update(e: AnActionEvent) {
32+
if (isPending.get()) {
33+
e.presentation.icon = AnimatedIcon.Default.INSTANCE
34+
e.presentation.isEnabled = false
35+
e.presentation.text = "Deleting…"
36+
return
37+
}
38+
e.presentation.icon = AllIcons.Actions.GC
39+
e.applyStrings(ActionStrings.DELETE_SESSION, selectedSessionId() != null)
40+
}
41+
42+
override fun actionPerformed(e: AnActionEvent) = perform()
43+
44+
fun perform() {
45+
if (!isPending.compareAndSet(false, true)) return
46+
val sessionId = selectedSessionId()
47+
if (sessionId == null) {
48+
isPending.set(false)
49+
return
50+
}
51+
OpenCodeTuiClient.getInstance(project).deleteSession(sessionId) { success, error ->
52+
isPending.set(false)
53+
if (!success) {
54+
ApplicationManager.getApplication().invokeLater {
55+
Messages.showMessageDialog(
56+
project,
57+
"Failed to delete session: $error",
58+
"Error",
59+
Messages.getErrorIcon(),
60+
)
61+
}
62+
}
63+
}
64+
}
65+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.ashotn.opencode.companion.actions
2+
3+
import com.ashotn.opencode.companion.tui.OpenCodeTuiClient
4+
import com.ashotn.opencode.companion.util.applyStrings
5+
import com.intellij.icons.AllIcons
6+
import com.intellij.openapi.actionSystem.ActionUpdateThread
7+
import com.intellij.openapi.actionSystem.AnAction
8+
import com.intellij.openapi.actionSystem.AnActionEvent
9+
import com.intellij.openapi.application.ApplicationManager
10+
import com.intellij.openapi.project.Project
11+
import com.intellij.openapi.ui.Messages
12+
import com.intellij.ui.AnimatedIcon
13+
import java.util.concurrent.atomic.AtomicBoolean
14+
15+
/**
16+
* Renames the currently selected session.
17+
*
18+
* [selectedSession] is a lambda that returns a (sessionId, currentTitle) pair for the session
19+
* currently selected in the session list, or null if nothing is selected.
20+
*/
21+
class RenameSessionAction(
22+
private val project: Project,
23+
private val selectedSession: () -> Pair<String, String>?,
24+
) : AnAction() {
25+
26+
private val isPending = AtomicBoolean(false)
27+
28+
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
29+
30+
override fun update(e: AnActionEvent) {
31+
if (isPending.get()) {
32+
e.presentation.icon = AnimatedIcon.Default.INSTANCE
33+
e.presentation.isEnabled = false
34+
e.presentation.text = "Renaming…"
35+
return
36+
}
37+
e.presentation.icon = AllIcons.Actions.Edit
38+
e.applyStrings(ActionStrings.RENAME_SESSION, selectedSession() != null)
39+
}
40+
41+
override fun actionPerformed(e: AnActionEvent) = perform()
42+
43+
fun perform() {
44+
if (isPending.get()) return
45+
val (sessionId, currentTitle) = selectedSession() ?: return
46+
47+
// Show the input dialog first — it's modal so no need to be in pending state yet.
48+
val newTitle = Messages.showInputDialog(
49+
project,
50+
"Enter new session name:",
51+
"Rename Session",
52+
null,
53+
currentTitle,
54+
null,
55+
)
56+
if (newTitle.isNullOrBlank() || newTitle == currentTitle) return
57+
58+
// User confirmed — now mark pending for the async HTTP call.
59+
if (!isPending.compareAndSet(false, true)) return
60+
OpenCodeTuiClient.getInstance(project).renameSession(sessionId, newTitle) { success, error ->
61+
isPending.set(false)
62+
if (!success) {
63+
ApplicationManager.getApplication().invokeLater {
64+
Messages.showMessageDialog(
65+
project,
66+
"Failed to rename session: $error",
67+
"Error",
68+
Messages.getErrorIcon(),
69+
)
70+
}
71+
}
72+
}
73+
}
74+
}

src/main/kotlin/com/ashotn/opencode/companion/api/transport/OpenCodeHttpTransport.kt

Lines changed: 32 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,21 @@ package com.ashotn.opencode.companion.api.transport
33
import com.google.gson.JsonElement
44
import com.google.gson.JsonObject
55
import com.google.gson.JsonParser
6-
import java.io.IOException
7-
import java.net.HttpURLConnection
8-
import java.net.SocketTimeoutException
96
import java.net.URI
7+
import java.net.http.HttpClient
8+
import java.net.http.HttpRequest
9+
import java.net.http.HttpResponse
10+
import java.time.Duration
1011

1112
class OpenCodeHttpTransport(
12-
private val defaultConnectTimeoutMs: Int = 3_000,
13+
defaultConnectTimeoutMs: Int = 3_000,
1314
private val defaultReadTimeoutMs: Int = 5_000,
1415
) {
15-
data class Timeouts(
16-
val connectTimeoutMs: Int,
17-
val readTimeoutMs: Int,
18-
)
16+
data class Timeouts(val readTimeoutMs: Int)
17+
18+
private val httpClient = HttpClient.newBuilder()
19+
.connectTimeout(Duration.ofMillis(defaultConnectTimeoutMs.toLong()))
20+
.build()
1921

2022
fun get(
2123
port: Int,
@@ -115,59 +117,45 @@ class OpenCodeHttpTransport(
115117
accept: String,
116118
timeouts: Timeouts?,
117119
): ApiResult<String?> {
118-
val resolvedTimeouts = timeouts ?: Timeouts(defaultConnectTimeoutMs, defaultReadTimeoutMs)
120+
val resolvedTimeouts = timeouts ?: Timeouts(defaultReadTimeoutMs)
121+
val normalizedPath = if (path.startsWith('/')) path else "/$path"
119122

120-
val connection = try {
121-
openConnection(port, path)
123+
val uri = try {
124+
URI("http://localhost:$port$normalizedPath")
122125
} catch (e: IllegalArgumentException) {
123126
return ApiResult.Failure(ApiError.NetworkError("Invalid request URL", e))
124-
} catch (e: IOException) {
125-
return ApiResult.Failure(ApiError.NetworkError(e.message ?: "Network I/O error", e))
126127
}
127128

128-
return try {
129-
connection.requestMethod = method
130-
connection.connectTimeout = resolvedTimeouts.connectTimeoutMs
131-
connection.readTimeout = resolvedTimeouts.readTimeoutMs
132-
connection.setRequestProperty("Accept", accept)
133-
134-
if (payload != null) {
135-
connection.doOutput = true
136-
if (contentType != null) {
137-
connection.setRequestProperty("Content-Type", contentType)
138-
}
139-
connection.outputStream.use { out ->
140-
out.write(payload.toByteArray(Charsets.UTF_8))
141-
}
129+
val bodyPublisher = if (payload != null)
130+
HttpRequest.BodyPublishers.ofString(payload, Charsets.UTF_8)
131+
else
132+
HttpRequest.BodyPublishers.noBody()
133+
134+
val request = HttpRequest.newBuilder(uri)
135+
.method(method, bodyPublisher)
136+
.apply {
137+
header("Accept", accept)
138+
if (payload != null && contentType != null) header("Content-Type", contentType)
139+
timeout(Duration.ofMillis(resolvedTimeouts.readTimeoutMs.toLong()))
142140
}
141+
.build()
143142

144-
val statusCode = connection.responseCode
145-
val body = readBody(connection, statusCode)
143+
return try {
144+
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8))
145+
val statusCode = response.statusCode()
146+
val body = response.body().takeIf { it.isNotEmpty() }
146147
if (statusCode in 200..299) {
147148
ApiResult.Success(body)
148149
} else {
149150
ApiResult.Failure(ApiError.HttpError(statusCode, body))
150151
}
151-
} catch (e: SocketTimeoutException) {
152+
} catch (e: java.net.http.HttpTimeoutException) {
152153
ApiResult.Failure(ApiError.NetworkError("Request timed out", e))
153-
} catch (e: IOException) {
154+
} catch (e: java.io.IOException) {
154155
ApiResult.Failure(ApiError.NetworkError(e.message ?: "Network I/O error", e))
155-
} finally {
156-
connection.disconnect()
157156
}
158157
}
159158

160-
private fun openConnection(port: Int, path: String): HttpURLConnection {
161-
val normalizedPath = if (path.startsWith('/')) path else "/$path"
162-
val url = URI("http://localhost:$port$normalizedPath").toURL()
163-
return url.openConnection() as HttpURLConnection
164-
}
165-
166-
private fun readBody(connection: HttpURLConnection, statusCode: Int): String? {
167-
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
168-
return stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
169-
}
170-
171159
companion object {
172160
const val APPLICATION_JSON = "application/json"
173161
}

src/main/kotlin/com/ashotn/opencode/companion/core/OpenCodeCoreService.kt

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,10 +539,49 @@ class OpenCodeCoreService(private val project: Project) : Disposable {
539539
fun updateSessionState(session: Session) {
540540
synchronized(stateLock) {
541541
sessionById[session.id] = session
542+
// Bump the local timestamp so the session sorts to the top immediately,
543+
// before the next hierarchy refresh arrives with the server's timestamp.
544+
stateStore.updatedAtBySession[session.id] = System.currentTimeMillis()
542545
}
543546
publishService.publishSessionStateChanged()
544547
}
545548

549+
/**
550+
* Removes a session from all local state (metadata, diff state, busy flags, etc.)
551+
* and publishes a state change so the UI list updates immediately.
552+
*
553+
* If the deleted session was selected, clears the selection.
554+
*/
555+
fun removeSession(sessionId: String) {
556+
val previousVisibleFiles = synchronized(stateLock) {
557+
val visible = visibleFilesLocked()
558+
559+
sessionById.remove(sessionId)
560+
stateStore.busyBySession.remove(sessionId)
561+
stateStore.updatedAtBySession.remove(sessionId)
562+
stateStore.hunksBySessionAndFile.remove(sessionId)
563+
stateStore.liveHunksBySessionAndFile.remove(sessionId)
564+
stateStore.deletedBySession.remove(sessionId)
565+
stateStore.addedBySession.remove(sessionId)
566+
stateStore.baselineBeforeBySessionAndFile.remove(sessionId)
567+
stateStore.lastAfterBySessionAndFile.remove(sessionId)
568+
stateStore.serverAfterBySessionAndFile.remove(sessionId)
569+
stateStore.pendingTurnFilesBySession.remove(sessionId)
570+
historicalDiffLoadedSessions.remove(sessionId)
571+
572+
if (stateStore.selectedSessionId == sessionId) {
573+
stateStore.selectedSessionId = null
574+
}
575+
576+
visible
577+
}
578+
579+
previousVisibleFiles.forEach { publishService.publishChanged(it) }
580+
publishService.publishSessionStateChanged()
581+
}
582+
583+
fun refreshSessionHierarchy() = refreshSessionHierarchyAsync()
584+
546585
fun listSessions(): List<SessionInfo> = synchronized(stateLock) {
547586
queryService.listSessions(
548587
knownSessionIds = knownSessionIdsLocked(),

src/main/kotlin/com/ashotn/opencode/companion/terminal/NewSessionTerminalAllowedActionsProvider.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
package com.ashotn.opencode.companion.terminal
44

5+
import com.ashotn.opencode.companion.actions.NewSessionAction
56
import com.intellij.terminal.frontend.view.TerminalAllowedActionsProvider
67

78
// The terminal's "Override IDE shortcuts" feature blocks global IDE actions from firing when the
@@ -11,6 +12,6 @@ import com.intellij.terminal.frontend.view.TerminalAllowedActionsProvider
1112
// terminal is focused.
1213
class NewSessionTerminalAllowedActionsProvider : TerminalAllowedActionsProvider {
1314
override fun getActionIds(): List<String> {
14-
return listOf("com.ashotn.opencode.companion.actions.NewSessionAction")
15+
return listOf(NewSessionAction::class.java.name)
1516
}
1617
}

0 commit comments

Comments
 (0)