Open transcript file links in explorer

This commit is contained in:
iris committed 2026-09-10 18:08:05 -04:00
1 parent 3c6e6778fd
commit e3cca97cdd
6 files changed
+137 -20

No files matched your search

@@ -11,6 +11,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
@@ -246,14 +247,19 @@ fun AppRoot(
// A Box so the explorer can be drawn *over* the session rather than instead of it.
// No imePadding here, for the reason above -- the explorer adds its own.
Box {
SessionScreen(
settings = current,
summary = here.summary,
onBack = goToMain,
onFiles = { screen = here.copy(files = it) },
share = share,
onShareTaken = { share = null },
)
val fileLinkHandler = rememberFileLinkHandler { path ->
screen = here.copy(files = here.summary.filesTarget(path))
}
CompositionLocalProvider(LocalFileLinkHandler provides fileLinkHandler) {
SessionScreen(
settings = current,
summary = here.summary,
onBack = goToMain,
onFiles = { screen = here.copy(files = it) },
share = share,
onShareTaken = { share = null },
)
}
// Its own back handler is registered after this screen's, so it is the one the
// platform asks first, and it steps back inside itself before closing.
here.files?.let { target ->
@@ -48,7 +48,22 @@ import kotlinx.coroutines.withContext
* where it was working. That is what makes a second way in -- from the setups tab -- one more
* caller rather than any new code here.
*/
data class FilesTarget(val setup: String, val setupName: String, val start: String)
data class FilesTarget(
val setup: String,
val setupName: String,
val start: String,
/** A document to open immediately; [start] remains the fallback directory. */
val file: String? = null,
)
/** The explorer target for this session's machine, optionally opened on [file]. */
fun SessionSummary.filesTarget(file: String? = null) =
FilesTarget(
setup = setup,
setupName = setupName,
start = cwd?.takeIf { it.isNotBlank() } ?: "~",
file = file,
)
/** Where the explorer is: in a directory, or in one file. */
private sealed class Spot(val path: String) {
@@ -72,7 +87,14 @@ private sealed class Spot(val path: String) {
@Composable
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
val scope = rememberCoroutineScope()
var here by remember { mutableStateOf<Spot>(Spot.Dir(target.start)) }
val initialDirectory =
target.file?.let(::parentOf)?.let { Spot.Dir(it) } ?: Spot.Dir(target.start)
var here by
remember(target) {
mutableStateOf<Spot>(
target.file?.let { Spot.Doc(it, initialDirectory) } ?: initialDirectory
)
}
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
var creating by remember { mutableStateOf(false) }
// Edit mode and whether anything has been typed live here rather than in the pane below,
@@ -32,6 +32,7 @@ import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.utils.getUnescapedTextInNode
import com.mikepenz.markdown.utils.resolveImageAlt
import com.mikepenz.markdown.utils.resolveImageLink
import java.net.URI
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
@@ -89,6 +90,7 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
content.buildMarkdownAnnotatedString(node, style, settings)
}
val uriHandler = LocalUriHandler.current
val fileLinkHandler = LocalFileLinkHandler.current
val onPlainTap = LocalMarkdownTap.current
val layout = remember { Ref<TextLayoutResult>() }
// The renderer's own rule for a style that names no colour: the theme's text colour.
@@ -127,7 +129,7 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
when {
url != null -> {
up.consume()
uriHandler.openUri(url)
if (fileLinkHandler?.invoke(url) != true) uriHandler.openUri(url)
}
onPlainTap != null -> {
up.consume()
@@ -164,6 +166,55 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
*/
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
/**
* Opens a markdown destination inside the current session when it names a file on that session's
* machine. Null outside a session, where every link keeps its ordinary URI behaviour.
*/
val LocalFileLinkHandler = compositionLocalOf<((String) -> Boolean)?> { null }
/**
* A stable markdown link handler whose behaviour follows the latest [onFile]. Keeping its identity
* stable matters: every visible markdown paragraph reads it, and a session recomposes on every
* streamed event.
*/
@Composable
fun rememberFileLinkHandler(onFile: (String) -> Unit): (String) -> Boolean {
val latest = rememberUpdatedState(onFile)
return remember {
{ destination ->
val path = filePathOf(destination)
if (path == null) false
else {
latest.value(path)
true
}
}
}
}
/**
* The path named by a local-file markdown destination.
*
* Only absolute paths and local `file:` URIs are claimed. A relative destination might be a web
* link, and sending one to a machine's filesystem would silently give an ordinary link a different
* meaning. Editors commonly append a line and optional column; the current viewer opens the file
* itself, so those coordinates are removed here.
*/
internal fun filePathOf(destination: String): String? {
val uri = runCatching { URI(destination) }.getOrNull()
val path =
when {
destination.startsWith("/") && !destination.startsWith("//") ->
uri?.path ?: destination.substringBefore('#').substringBefore('?')
uri != null &&
uri.scheme.equals("file", ignoreCase = true) &&
(uri.host.isNullOrEmpty() || uri.host == "localhost") -> uri.path
else -> null
}
if (path.isNullOrEmpty() || !path.startsWith('/')) return null
return path.replace(Regex(":\\d+(?::\\d+)?$"), "")
}
/**
* [onTap] as a stable value to provide for [LocalMarkdownTap]. The identity stays put while the
* behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text
@@ -1411,15 +1411,10 @@ fun SessionScreen(
"Files",
onClick = {
onFiles(
FilesTarget(
setup = summary.setup,
setupName = summary.setupName,
// Where this session works, and the machine's own home when
// it was never given a directory -- resolved there rather
// than guessed at here, since this app does not know that
// home.
start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~",
)
// Where this session works, and the machine's own home when it
// was never given a directory -- resolved there rather than
// guessed at here, since this app does not know that home.
summary.filesTarget()
)
},
)