Skip to content

SFTP Host Files Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Extend the SFTP server to expose node host filesystems via virtual /hosts/<nodeID>/ paths, add SFTP credentials to the NodeDetail web page, and add a NodeFilesScreen to the Android terminal app.

Architecture: The existing SFTP server (port 2022) already handles /instances/<uuid>/... by tunneling to the agent's SFTP subsystem. We add parallel support for /hosts/<nodeID>/... using the same tunnel mechanism with direct path passthrough (no volume translation). The Android app mirrors the existing InstanceFilesScreen pattern using the REST API (/v1/nodes/{id}/files* which already exists).

Tech Stack: Go (pkg/sftp, golang.org/x/crypto/ssh, tunnel.Manager), React/TypeScript (lucide-react, i18next), Kotlin/Jetpack Compose (Retrofit, Hilt, Navigation Compose)


Task 1: SFTP Server — resolveRemoteHost function

Files:

  • Modify: internal/controlplane/sftpserver/server.go

Step 1: Add resolveRemoteHost to fileSystem

Add after resolveRemote (around line 627). This handles paths like /hosts/<nodeID>/etc/nginx:

go
// resolveRemoteHost checks if a virtual path refers to a remote node host filesystem.
// Virtual path: /hosts/<nodeID>/path/on/host
// Remote path:  /path/on/host (direct passthrough, no volume translation)
// Returns nil if path is not a /hosts/ path or tunnel is unavailable.
func (fs *fileSystem) resolveRemoteHost(virtualPath string) *remoteContext {
	if fs.server == nil || fs.server.tunnelMgr == nil || fs.server.nodeRepo == nil {
		return nil
	}

	// Parse /hosts/<nodeID>/...
	parts := strings.Split(strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+virtualPath)), "/"), "/")
	if len(parts) < 2 || parts[0] != "hosts" {
		return nil
	}

	nodeID, err := uuid.Parse(parts[1])
	if err != nil {
		return nil
	}

	// Reconstruct the remote path
	remotePath := "/"
	if len(parts) > 2 {
		remotePath = "/" + strings.Join(parts[2:], "/")
	}

	sftpClient, session := fs.getOrCreateRemoteClient(nodeID)
	if sftpClient == nil {
		return nil
	}

	return &remoteContext{
		client:     sftpClient,
		session:    session,
		remotePath: remotePath,
		instance:   nil, // host path, not an instance
	}
}

Step 2: Run existing tests to confirm no regression

bash
cd /home/caiolombello/shardlyn
go test ./internal/controlplane/sftpserver/... -v 2>&1 | tail -20

Expected: all tests pass (or same as before).

Step 3: Commit

bash
cd /home/caiolombello/shardlyn
git add internal/controlplane/sftpserver/server.go
git commit -m "feat(sftp): add resolveRemoteHost for /hosts/<nodeID>/ virtual paths"

Task 2: SFTP Server — wire resolveRemoteHost into file operations

Files:

  • Modify: internal/controlplane/sftpserver/server.go

Step 1: Update Fileread to check hosts

Find Fileread (around line 800) and add host check right after the instance check:

go
func (fs *fileSystem) Fileread(r *sftp.Request) (io.ReaderAt, error) {
	flags := r.Pflags()
	if !flags.Read {
		return nil, os.ErrInvalid
	}

	if rc := fs.resolveRemote(r.Filepath); rc != nil {
		return rc.client.Open(rc.remotePath)
	}
	// NEW: host check
	if rc := fs.resolveRemoteHost(r.Filepath); rc != nil {
		return rc.client.Open(rc.remotePath)
	}

	path, err := fs.resolvePath(r.Filepath)
	// ... rest unchanged

Step 2: Update Filewrite to check hosts

In Filewrite (around line 818), add after the existing resolveRemote block:

go
	if rc := fs.resolveRemote(r.Filepath); rc != nil {
		// ... existing block unchanged
	}
	// NEW: host check
	if rc := fs.resolveRemoteHost(r.Filepath); rc != nil {
		openFlags := os.O_WRONLY
		if flags.Read { openFlags = os.O_RDWR }
		if flags.Append { openFlags |= os.O_APPEND }
		if flags.Creat { openFlags |= os.O_CREATE }
		if flags.Trunc { openFlags |= os.O_TRUNC }
		if flags.Excl { openFlags |= os.O_EXCL }
		return rc.client.OpenFile(rc.remotePath, openFlags)
	}

Step 3: Update Filecmd to check hosts

In Filecmd (around line 898), add after the resolveRemote block:

go
	if rc := fs.resolveRemote(r.Filepath); rc != nil {
		return fs.remoteFilecmd(rc, r)
	}
	// NEW: host check
	if rc := fs.resolveRemoteHost(r.Filepath); rc != nil {
		return fs.remoteFilecmd(rc, r)
	}

Step 4: Update Filelist to check hosts

In Filelist (around line 974), add after the resolveRemote block:

go
	if rc := fs.resolveRemote(r.Filepath); rc != nil {
		return fs.remoteFilelist(rc, r)
	}
	// NEW: host check
	if rc := fs.resolveRemoteHost(r.Filepath); rc != nil {
		return fs.remoteFilelist(rc, r)
	}

Step 5: Build to verify no compile errors

bash
cd /home/caiolombello/shardlyn
go build ./... 2>&1

Expected: no output (clean build).

Step 6: Run tests

bash
go test ./internal/controlplane/sftpserver/... -v 2>&1 | tail -20

Step 7: Commit

bash
git add internal/controlplane/sftpserver/server.go
git commit -m "feat(sftp): wire host path resolution into Fileread/Filewrite/Filecmd/Filelist"

Task 3: SFTP Server — add test for resolveRemoteHost

Files:

  • Modify: internal/controlplane/sftpserver/server.go (or a _test.go if one exists)
  • Check if internal/controlplane/sftpserver/server_test.go exists first

Step 1: Check for existing test file

bash
ls /home/caiolombello/shardlyn/internal/controlplane/sftpserver/

Step 2: Add unit test for resolveRemoteHost path parsing

If no test file exists, create internal/controlplane/sftpserver/host_resolve_test.go:

go
package sftpserver

import (
	"testing"
)

func TestResolveRemoteHost_NilWhenNoTunnel(t *testing.T) {
	fs := &fileSystem{server: nil}
	if rc := fs.resolveRemoteHost("/hosts/some-id/etc/hosts"); rc != nil {
		t.Fatal("expected nil when server is nil")
	}
}

func TestResolveRemoteHost_NilForNonHostPath(t *testing.T) {
	fs := &fileSystem{server: &Server{}}
	if rc := fs.resolveRemoteHost("/instances/some-id/data"); rc != nil {
		t.Fatal("expected nil for non-hosts path")
	}
}

func TestResolveRemoteHost_NilForInvalidUUID(t *testing.T) {
	fs := &fileSystem{server: &Server{}}
	if rc := fs.resolveRemoteHost("/hosts/not-a-uuid/etc/hosts"); rc != nil {
		t.Fatal("expected nil for invalid UUID")
	}
}

Step 3: Run tests

bash
cd /home/caiolombello/shardlyn
go test ./internal/controlplane/sftpserver/... -run TestResolveRemoteHost -v

Expected: all 3 tests PASS.

Step 4: Commit

bash
git add internal/controlplane/sftpserver/
git commit -m "test(sftp): add unit tests for resolveRemoteHost path parsing"

Task 4: Web — SFTP credentials panel in NodeDetail

Files:

  • Modify: web/src/pages/NodeDetail.tsx

Context: InstanceDetail.tsx has an SFTP panel starting around line 1630. The NodeDetail.tsx already has a files tab with <FileBrowser nodeId={id!} />. We add an SFTP credentials block below the FileBrowser.

Step 1: Add SFTP state/vars near the top of the NodeDetail component

Find where const sftpHost would go — look for where other constants are defined near the component top (after the id param). Add:

typescript
const sftpPort = 2022
const sftpUser = user?.email ?? ''
const sftpPathPrefix = `/hosts/${id}/`

For sftpHost, look at how InstanceDetail builds it (uses window.location.hostname). Add the same logic:

typescript
const sftpHost = (() => {
  const hostname = window.location.hostname
  if (hostname === 'localhost' || hostname === '127.0.0.1') {
    return hostname
  }
  return hostname
})()

Step 2: Add SFTP panel in the files tab

Find the files tab render (around line 449):

typescript
{activeTab === 'files' && (
  <FileBrowser nodeId={id!} />
)}

Replace with:

typescript
{activeTab === 'files' && (
  <div className="space-y-6">
    <FileBrowser nodeId={id!} />
    <div className="card p-4 space-y-3">
      <h3 className="text-sm font-semibold flex items-center gap-2">
        <KeyRound className="w-4 h-4" />
        {t('nodeDetail.files.sftp.title', { defaultValue: 'SFTP Access' })}
      </h3>
      <p className="text-muted text-sm">
        {t('nodeDetail.files.sftp.description', {
          defaultValue: 'Connect with any SFTP client to browse this host\'s filesystem directly.',
        })}
      </p>
      <div className="grid grid-cols-2 gap-2 text-sm font-mono">
        <div className="text-muted">{t('nodeDetail.files.sftp.host', { defaultValue: 'Host' })}</div>
        <div className="font-mono text-[color:var(--text)]">{sftpHost}</div>
        <div className="text-muted">{t('nodeDetail.files.sftp.port', { defaultValue: 'Port' })}</div>
        <div className="font-mono text-[color:var(--text)]">{sftpPort}</div>
        <div className="text-muted">{t('nodeDetail.files.sftp.user', { defaultValue: 'User' })}</div>
        <div className="font-mono text-[color:var(--text)]">{sftpUser}</div>
        <div className="text-muted">{t('nodeDetail.files.sftp.path', { defaultValue: 'Path prefix' })}</div>
        <div className="font-mono text-[color:var(--text)]">{sftpPathPrefix}</div>
      </div>
      <code className="block text-xs bg-surface-2 rounded p-2 text-muted">
        {sftpUser.includes('@')
          ? `sftp -P ${sftpPort} -o User="${sftpUser}" ${sftpHost}`
          : `sftp -P ${sftpPort} ${sftpUser}@${sftpHost}`}
      </code>
    </div>
  </div>
)}

Step 3: Add KeyRound to the lucide-react import in NodeDetail.tsx

Find the lucide import block and add KeyRound if not already present.

Step 4: Typecheck

bash
cd /home/caiolombello/shardlyn/web
npm run typecheck 2>&1

Expected: no errors.

Step 5: Commit

bash
cd /home/caiolombello/shardlyn
git add web/src/pages/NodeDetail.tsx
git commit -m "feat(web): add SFTP credentials panel to node files tab"

Task 5: Android — Node file API endpoints in ShardlynApiService

Files:

  • Modify: /mnt/c/Users/caio/shardlyn-terminal/app/src/main/java/com/shardlyn/terminal/data/api/ShardlynApiService.kt

Step 1: Add node file endpoints

After the instance file endpoints (after downloadInstanceFile), add:

kotlin
    // Node (host) files — mirrors instance file endpoints at /v1/nodes/{id}/files*
    @GET("v1/nodes/{id}/files")
    suspend fun getNodeFiles(
        @Path("id") id: String,
        @Query("path") path: String,
    ): Response<List<InstanceFileEntryDto>>

    @GET("v1/nodes/{id}/files/content")
    suspend fun getNodeFileContent(
        @Path("id") id: String,
        @Query("path") path: String,
    ): Response<InstanceFileContentDto>

    @PUT("v1/nodes/{id}/files/content")
    suspend fun saveNodeFileContent(
        @Path("id") id: String,
        @Body request: SaveInstanceFileContentRequestDto,
    ): Response<Map<String, String>>

    @DELETE("v1/nodes/{id}/files")
    suspend fun deleteNodeFile(
        @Path("id") id: String,
        @Query("path") path: String,
    ): Response<Unit>

    @POST("v1/nodes/{id}/files/mkdir")
    suspend fun createNodeDirectory(
        @Path("id") id: String,
        @Body request: CreateInstanceDirectoryRequestDto,
    ): Response<StatusPathResponseDto>

    @POST("v1/nodes/{id}/files/move")
    suspend fun moveNodeFile(
        @Path("id") id: String,
        @Body request: MoveInstanceFileRequestDto,
    ): Response<Map<String, String>>

    @Multipart
    @POST("v1/nodes/{id}/files/upload")
    suspend fun uploadNodeFile(
        @Path("id") id: String,
        @Part file: MultipartBody.Part,
        @Part("path") path: RequestBody,
    ): Response<StatusPathResponseDto>

    @Streaming
    @GET("v1/nodes/{id}/files/download")
    suspend fun downloadNodeFile(
        @Path("id") id: String,
        @Query("path") path: String,
    ): Response<ResponseBody>

Step 2: Build to verify no compile errors

bash
cd /mnt/c/Users/caio/shardlyn-terminal
./gradlew :app:compileDebugKotlin 2>&1 | tail -30

Expected: BUILD SUCCESSFUL or only pre-existing warnings.

Step 3: Commit

bash
cd /mnt/c/Users/caio/shardlyn-terminal
git add app/src/main/java/com/shardlyn/terminal/data/api/ShardlynApiService.kt
git commit -m "feat(terminal): add node host file API endpoints to ShardlynApiService"

Task 6: Android — Node file methods in ShardlynRepository

Files:

  • Modify: /mnt/c/Users/caio/shardlyn-terminal/app/src/main/java/com/shardlyn/terminal/data/repository/ShardlynRepository.kt

Step 1: Add interface methods

In ShardlynRepository interface, after downloadInstanceFile, add:

kotlin
    // Node (host) file operations
    suspend fun getNodeFiles(nodeId: String, path: String): Result<List<InstanceFileEntryDto>>
    suspend fun getNodeFileContent(nodeId: String, path: String): Result<InstanceFileContentDto>
    suspend fun saveNodeFileContent(nodeId: String, path: String, content: String): Result<Unit>
    suspend fun deleteNodeFile(nodeId: String, path: String): Result<Unit>
    suspend fun createNodeDirectory(nodeId: String, path: String): Result<Unit>
    suspend fun moveNodeFile(nodeId: String, source: String, destination: String): Result<Unit>
    suspend fun uploadNodeFile(nodeId: String, destinationPath: String, file: File, mimeType: String?): Result<Unit>
    suspend fun downloadNodeFile(nodeId: String, path: String): Result<ByteArray>

Step 2: Implement in ShardlynRepositoryImpl

After downloadInstanceFile implementation, add:

kotlin
    override suspend fun getNodeFiles(nodeId: String, path: String): Result<List<InstanceFileEntryDto>> = runCatching {
        val response = api.getNodeFiles(nodeId, path)
        if (response.isSuccessful) response.body().orEmpty() else error("HTTP ${response.code()}")
    }

    override suspend fun getNodeFileContent(nodeId: String, path: String): Result<InstanceFileContentDto> = runCatching {
        val response = api.getNodeFileContent(nodeId, path)
        if (response.isSuccessful) response.body()!! else error("HTTP ${response.code()}")
    }

    override suspend fun saveNodeFileContent(nodeId: String, path: String, content: String): Result<Unit> = runCatching {
        val response = api.saveNodeFileContent(nodeId, SaveInstanceFileContentRequestDto(path = path, content = content))
        if (!response.isSuccessful) error("HTTP ${response.code()}")
    }

    override suspend fun deleteNodeFile(nodeId: String, path: String): Result<Unit> = runCatching {
        val response = api.deleteNodeFile(nodeId, path)
        if (!response.isSuccessful) error("HTTP ${response.code()}")
    }

    override suspend fun createNodeDirectory(nodeId: String, path: String): Result<Unit> = runCatching {
        val response = api.createNodeDirectory(nodeId, CreateInstanceDirectoryRequestDto(path = path))
        if (!response.isSuccessful) error("HTTP ${response.code()}")
    }

    override suspend fun moveNodeFile(nodeId: String, source: String, destination: String): Result<Unit> = runCatching {
        val response = api.moveNodeFile(nodeId, MoveInstanceFileRequestDto(source = source, destination = destination))
        if (!response.isSuccessful) error("HTTP ${response.code()}")
    }

    override suspend fun uploadNodeFile(nodeId: String, destinationPath: String, file: File, mimeType: String?): Result<Unit> = runCatching {
        val mediaType = mimeType?.toMediaTypeOrNull() ?: "application/octet-stream".toMediaTypeOrNull()
        val requestBody = file.asRequestBody(mediaType)
        val filePart = MultipartBody.Part.createFormData("file", file.name, requestBody)
        val pathPart = destinationPath.toRequestBody("text/plain".toMediaTypeOrNull())
        val response = api.uploadNodeFile(nodeId, filePart, pathPart)
        if (!response.isSuccessful) error("HTTP ${response.code()}")
    }

    override suspend fun downloadNodeFile(nodeId: String, path: String): Result<ByteArray> = runCatching {
        val response = api.downloadNodeFile(nodeId, path)
        if (!response.isSuccessful) error("HTTP ${response.code()}")
        response.body()!!.bytes()
    }

Step 3: Build

bash
cd /mnt/c/Users/caio/shardlyn-terminal
./gradlew :app:compileDebugKotlin 2>&1 | tail -30

Expected: BUILD SUCCESSFUL.

Step 4: Commit

bash
git add app/src/main/java/com/shardlyn/terminal/data/repository/ShardlynRepository.kt
git commit -m "feat(terminal): add node file operations to ShardlynRepository"

Task 7: Android — NodeFilesViewModel

Files:

  • Create: /mnt/c/Users/caio/shardlyn-terminal/app/src/main/java/com/shardlyn/terminal/ui/shardlyn/NodeFilesViewModel.kt

Step 1: Create the ViewModel

This is a simplified version of InstanceFilesViewModel — no volume roots, starts at /:

kotlin
package com.shardlyn.terminal.ui.shardlyn

import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shardlyn.terminal.data.api.dto.InstanceFileEntryDto
import com.shardlyn.terminal.data.repository.ShardlynRepository
import com.shardlyn.terminal.ui.sftp.RemoteFileEditorState
import com.shardlyn.terminal.ui.sftp.RemoteFileLanguage
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.io.IOException
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

data class NodeFilesUiState(
    val title: String = "Host Files",
    val currentPath: String? = null,
    val entries: List<InstanceFileEntryDto> = emptyList(),
    val isLoading: Boolean = true,
    val activeOperationLabel: String? = null,
    val error: String? = null,
    val message: String? = null,
    val editorState: RemoteFileEditorState? = null,
)

@HiltViewModel
class NodeFilesViewModel @Inject constructor(
    private val repository: ShardlynRepository,
    @ApplicationContext private val context: Context,
    savedStateHandle: SavedStateHandle,
) : ViewModel() {

    private val nodeId: String = savedStateHandle["nodeId"] ?: ""
    private val nodeNameArg: String = savedStateHandle["nodeName"] ?: "Host"

    private val _state = MutableStateFlow(NodeFilesUiState(title = nodeNameArg))
    val state: StateFlow<NodeFilesUiState> = _state.asStateFlow()

    init {
        refresh("/")
    }

    fun retry() = refresh(_state.value.currentPath ?: "/")

    fun consumeMessage() {
        _state.value = _state.value.copy(message = null)
    }

    fun refresh(targetPath: String? = _state.value.currentPath ?: "/") {
        val path = targetPath ?: "/"
        viewModelScope.launch {
            _state.value = _state.value.copy(isLoading = true, error = null)
            repository.getNodeFiles(nodeId, path)
                .onSuccess { entries ->
                    _state.value = _state.value.copy(
                        currentPath = path,
                        entries = sortEntries(entries),
                        isLoading = false,
                    )
                }
                .onFailure { error ->
                    _state.value = _state.value.copy(
                        isLoading = false,
                        error = error.message ?: "Failed to load files",
                        entries = emptyList(),
                    )
                }
        }
    }

    fun openParentDirectory() {
        val currentPath = _state.value.currentPath ?: return
        if (currentPath == "/") return
        val parent = parentPath(currentPath) ?: "/"
        refresh(parent)
    }

    fun openEntry(entry: InstanceFileEntryDto) {
        if (entry.isDir) refresh(entry.path) else openRemoteFile(entry)
    }

    fun createDirectory(name: String) {
        val currentPath = _state.value.currentPath ?: "/"
        val normalizedName = name.trim()
        if (normalizedName.isEmpty()) {
            _state.value = _state.value.copy(message = "Folder name cannot be empty")
            return
        }
        runMutation("Creating folder") {
            repository.createNodeDirectory(nodeId, joinPath(currentPath, normalizedName)).getOrThrow()
            refresh(currentPath)
            _state.value = _state.value.copy(message = "Created $normalizedName")
        }
    }

    fun renameEntry(entry: InstanceFileEntryDto, newName: String) {
        val normalizedName = newName.trim()
        if (normalizedName.isEmpty()) {
            _state.value = _state.value.copy(message = "Name cannot be empty")
            return
        }
        val targetPath = joinPath(parentPath(entry.path) ?: "/", normalizedName)
        runMutation("Renaming") {
            repository.moveNodeFile(nodeId, entry.path, targetPath).getOrThrow()
            refresh(_state.value.currentPath)
            _state.value = _state.value.copy(message = "Renamed to $normalizedName")
        }
    }

    fun deleteEntry(entry: InstanceFileEntryDto) {
        runMutation("Deleting") {
            repository.deleteNodeFile(nodeId, entry.path).getOrThrow()
            refresh(_state.value.currentPath)
            _state.value = _state.value.copy(message = "Deleted ${entry.name}")
        }
    }

    fun uploadFromUri(uri: Uri) {
        val currentPath = _state.value.currentPath ?: "/"
        runMutation("Uploading") {
            val displayName = queryDisplayName(uri) ?: "upload-${System.currentTimeMillis()}"
            val mimeType = context.contentResolver.getType(uri)
            val tempFile = copyUriToTempFile(uri, displayName)
            try {
                repository.uploadNodeFile(nodeId, joinPath(currentPath, displayName), tempFile, mimeType).getOrThrow()
            } finally {
                tempFile.delete()
            }
            refresh(currentPath)
            _state.value = _state.value.copy(message = "Uploaded $displayName")
        }
    }

    fun downloadToUri(entry: InstanceFileEntryDto, destinationUri: Uri) {
        if (entry.isDir) {
            _state.value = _state.value.copy(message = "Directory download is not supported")
            return
        }
        runMutation("Downloading") {
            val bytes = repository.downloadNodeFile(nodeId, entry.path).getOrThrow()
            writeBytesToUri(bytes, destinationUri)
            _state.value = _state.value.copy(message = "Downloaded ${entry.name}")
        }
    }

    fun updateEditorValue(value: TextFieldValue) {
        val current = _state.value.editorState ?: return
        _state.value = _state.value.copy(editorState = recalculateSearchState(current.copy(value = value)))
    }

    fun updateEditorSearchQuery(query: String) {
        val current = _state.value.editorState ?: return
        _state.value = _state.value.copy(editorState = recalculateSearchState(current.copy(searchQuery = query)))
    }

    fun jumpToNextSearchMatch() = selectSearchMatch(1)
    fun jumpToPreviousSearchMatch() = selectSearchMatch(-1)

    fun closeEditor(force: Boolean = false) {
        val current = _state.value.editorState ?: return
        if (!force && current.hasUnsavedChanges) return
        _state.value = _state.value.copy(editorState = null)
    }

    fun saveEditor() {
        val current = _state.value.editorState ?: return
        viewModelScope.launch {
            _state.value = _state.value.copy(editorState = current.copy(isSaving = true))
            repository.saveNodeFileContent(nodeId, current.path, current.value.text)
                .onSuccess {
                    val updated = recalculateSearchState(current.copy(originalText = current.value.text, isSaving = false))
                    _state.value = _state.value.copy(editorState = updated, message = "Saved ${current.name}")
                    refresh(_state.value.currentPath)
                }
                .onFailure { error ->
                    _state.value = _state.value.copy(
                        editorState = current.copy(isSaving = false),
                        message = error.message ?: "Failed to save file",
                    )
                }
        }
    }

    private fun openRemoteFile(entry: InstanceFileEntryDto) {
        viewModelScope.launch {
            _state.value = _state.value.copy(
                editorState = RemoteFileEditorState(
                    path = entry.path, name = entry.name,
                    language = detectLanguage(entry.name),
                    value = TextFieldValue(""), originalText = "",
                    permissions = null, isLoading = true,
                ),
            )
            repository.getNodeFileContent(nodeId, entry.path)
                .onSuccess { content ->
                    _state.value = _state.value.copy(
                        editorState = recalculateSearchState(
                            RemoteFileEditorState(
                                path = entry.path, name = entry.name,
                                language = detectLanguage(entry.name),
                                value = TextFieldValue(content.content),
                                originalText = content.content,
                                permissions = null,
                            ),
                        ),
                    )
                }
                .onFailure { error ->
                    _state.value = _state.value.copy(editorState = null, message = error.message ?: "Failed to open file")
                }
        }
    }

    private fun runMutation(label: String, block: suspend () -> Unit) {
        viewModelScope.launch {
            _state.value = _state.value.copy(activeOperationLabel = label)
            try {
                block()
            } catch (error: Throwable) {
                _state.value = _state.value.copy(message = error.message ?: "$label failed")
            } finally {
                _state.value = _state.value.copy(activeOperationLabel = null)
            }
        }
    }

    private fun sortEntries(entries: List<InstanceFileEntryDto>): List<InstanceFileEntryDto> =
        entries.sortedWith(compareBy<InstanceFileEntryDto>({ !it.isDir }, { it.name.lowercase() }))

    private fun parentPath(path: String): String? {
        val normalized = if (path.startsWith("/")) path else "/$path"
        if (normalized == "/") return null
        val last = normalized.lastIndexOf('/')
        return if (last <= 0) "/" else normalized.substring(0, last)
    }

    private fun joinPath(dir: String, child: String): String {
        val d = dir.trimEnd('/')
        val c = child.trim().trim('/')
        return if (d.isEmpty()) "/$c" else "$d/$c"
    }

    private suspend fun queryDisplayName(uri: Uri): String? = withContext(Dispatchers.IO) {
        context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
            ?.use { cursor ->
                val col = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
                if (col >= 0 && cursor.moveToFirst()) cursor.getString(col) else null
            }
    }

    private suspend fun copyUriToTempFile(uri: Uri, displayName: String): File = withContext(Dispatchers.IO) {
        val safeName = displayName.ifBlank { "file" }.replace(Regex("[^A-Za-z0-9._-]"), "_")
        val tempFile = File.createTempFile("node-files-upload-", "-$safeName", context.cacheDir)
        context.contentResolver.openInputStream(uri)?.use { input ->
            tempFile.outputStream().use { output -> input.copyTo(output) }
        } ?: throw IOException("Unable to open selected file")
        tempFile
    }

    private suspend fun writeBytesToUri(bytes: ByteArray, uri: Uri) = withContext(Dispatchers.IO) {
        context.contentResolver.openOutputStream(uri, "wt")?.use { output ->
            output.write(bytes)
            output.flush()
        } ?: throw IOException("Unable to write to destination")
    }

    private fun selectSearchMatch(step: Int) {
        val current = _state.value.editorState ?: return
        if (current.searchMatches.isEmpty()) return
        val nextIndex = when {
            current.currentMatchIndex < 0 -> if (step >= 0) 0 else current.searchMatches.lastIndex
            else -> (current.currentMatchIndex + step).mod(current.searchMatches.size)
        }
        _state.value = _state.value.copy(editorState = current.withSelectedMatch(nextIndex))
    }

    private fun recalculateSearchState(state: RemoteFileEditorState): RemoteFileEditorState {
        val query = state.searchQuery.trim()
        if (query.isEmpty()) return state.copy(searchMatches = emptyList(), currentMatchIndex = -1)
        val matches = findMatches(state.value.text, query)
        if (matches.isEmpty()) return state.copy(searchMatches = emptyList(), currentMatchIndex = -1)
        val preferredIndex = state.currentMatchIndex.takeIf { it in matches.indices }
            ?: matches.indexOfFirst { it.first >= state.value.selection.min }.takeIf { it >= 0 } ?: 0
        return state.withSelectedMatch(preferredIndex, matches)
    }

    private fun RemoteFileEditorState.withSelectedMatch(index: Int, matches: List<IntRange> = searchMatches): RemoteFileEditorState {
        val match = matches.getOrNull(index) ?: return copy(searchMatches = matches, currentMatchIndex = -1)
        return copy(value = value.copy(selection = TextRange(match.first, match.last + 1)), searchMatches = matches, currentMatchIndex = index)
    }

    private fun findMatches(text: String, query: String): List<IntRange> {
        if (query.isEmpty()) return emptyList()
        val matches = mutableListOf<IntRange>()
        var startIndex = 0
        while (startIndex < text.length) {
            val foundAt = text.indexOf(query, startIndex, ignoreCase = true)
            if (foundAt < 0) break
            matches += foundAt until (foundAt + query.length)
            startIndex = foundAt + query.length
        }
        return matches
    }

    private fun detectLanguage(fileName: String): RemoteFileLanguage {
        val normalized = fileName.lowercase()
        return when {
            normalized.endsWith(".yaml") || normalized.endsWith(".yml") -> RemoteFileLanguage.Yaml
            normalized.endsWith(".json") -> RemoteFileLanguage.Json
            normalized.endsWith(".conf") || normalized.endsWith(".cfg") || normalized.endsWith(".ini") -> RemoteFileLanguage.Conf
            normalized.endsWith(".sh") || normalized.endsWith(".bash") || normalized.endsWith(".zsh") ||
                normalized == ".bashrc" || normalized == ".zshrc" || normalized == ".profile" -> RemoteFileLanguage.Shell
            else -> RemoteFileLanguage.PlainText
        }
    }
}

Step 2: Build

bash
cd /mnt/c/Users/caio/shardlyn-terminal
./gradlew :app:compileDebugKotlin 2>&1 | tail -30

Expected: BUILD SUCCESSFUL.

Step 3: Commit

bash
git add app/src/main/java/com/shardlyn/terminal/ui/shardlyn/NodeFilesViewModel.kt
git commit -m "feat(terminal): add NodeFilesViewModel for host file browsing"

Task 8: Android — NodeFilesScreen

Files:

  • Create: /mnt/c/Users/caio/shardlyn-terminal/app/src/main/java/com/shardlyn/terminal/ui/screen/NodeFilesScreen.kt

Step 1: Create the screen

This is InstanceFilesScreen with the RootSelector removed (hosts start at /, no volume concept):

kotlin
package com.shardlyn.terminal.ui.screen

import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.shardlyn.terminal.data.api.dto.InstanceFileEntryDto
import com.shardlyn.terminal.ui.sftp.RemoteFileEditor
import com.shardlyn.terminal.ui.shardlyn.NodeFilesViewModel

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NodeFilesScreen(
    onBack: () -> Unit,
    viewModel: NodeFilesViewModel = hiltViewModel(),
) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    val snackbarHostState = remember { SnackbarHostState() }
    var createFolderDialogOpen by rememberSaveable { mutableStateOf(false) }
    var renameTarget by remember { mutableStateOf<InstanceFileEntryDto?>(null) }
    var deleteTarget by remember { mutableStateOf<InstanceFileEntryDto?>(null) }
    var downloadTarget by remember { mutableStateOf<InstanceFileEntryDto?>(null) }

    val uploadLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
        if (uri != null) viewModel.uploadFromUri(uri)
    }
    val downloadLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { uri: Uri? ->
        val target = downloadTarget
        if (uri != null && target != null) viewModel.downloadToUri(target, uri)
        downloadTarget = null
    }

    LaunchedEffect(state.message) {
        val message = state.message ?: return@LaunchedEffect
        snackbarHostState.showSnackbar(message)
        viewModel.consumeMessage()
    }

    state.editorState?.let { editorState ->
        RemoteFileEditor(
            editorState = editorState,
            snackbarHostState = snackbarHostState,
            onValueChange = viewModel::updateEditorValue,
            onSearchQueryChange = viewModel::updateEditorSearchQuery,
            onPreviousMatch = viewModel::jumpToPreviousSearchMatch,
            onNextMatch = viewModel::jumpToNextSearchMatch,
            onSave = viewModel::saveEditor,
            onCloseConfirmed = { viewModel.closeEditor(force = true) },
        )
        return
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Column {
                        Text("Host Files")
                        Text(
                            state.title,
                            style = MaterialTheme.typography.labelSmall,
                            color = MaterialTheme.colorScheme.onSurfaceVariant,
                        )
                    }
                },
                navigationIcon = {
                    IconButton(onClick = onBack) {
                        Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
                    }
                },
                actions = {
                    IconButton(
                        onClick = { uploadLauncher.launch(arrayOf("*/*")) },
                        enabled = state.currentPath != null && state.activeOperationLabel == null,
                    ) {
                        Icon(Icons.Default.Upload, contentDescription = "Upload")
                    }
                    IconButton(
                        onClick = { createFolderDialogOpen = true },
                        enabled = state.currentPath != null && state.activeOperationLabel == null,
                    ) {
                        Icon(Icons.Default.CreateNewFolder, contentDescription = "Create folder")
                    }
                    IconButton(
                        onClick = { viewModel.refresh() },
                        enabled = state.activeOperationLabel == null,
                    ) {
                        Icon(Icons.Default.Refresh, contentDescription = "Refresh")
                    }
                },
            )
        },
        floatingActionButton = {
            if (state.currentPath != null && state.activeOperationLabel == null) {
                FloatingActionButton(onClick = { uploadLauncher.launch(arrayOf("*/*")) }) {
                    Icon(Icons.Default.Upload, contentDescription = "Upload")
                }
            }
        },
        snackbarHost = { SnackbarHost(snackbarHostState) },
    ) { innerPadding ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding),
        ) {
            NodePathBar(
                currentPath = state.currentPath,
                canGoBack = state.currentPath != null && state.currentPath != "/",
                onNavigate = viewModel::refresh,
                onGoBack = viewModel::openParentDirectory,
            )

            state.activeOperationLabel?.let { label ->
                Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
                    Text(text = label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
                    LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
                }
            }

            when {
                state.isLoading -> NodeFullscreenState("Loading files", "Reading host filesystem...", true)
                state.error != null -> NodeErrorState(title = "Files unavailable", body = state.error ?: "Unknown error", onRetry = viewModel::retry)
                state.entries.isEmpty() -> NodeFullscreenState(title = state.currentPath ?: "Empty directory", body = "No files found.")
                else -> {
                    LazyColumn(
                        modifier = Modifier.fillMaxSize(),
                        contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
                        verticalArrangement = Arrangement.spacedBy(8.dp),
                    ) {
                        items(state.entries, key = { it.path }) { entry ->
                            NodeFileEntryCard(
                                entry = entry,
                                onClick = { viewModel.openEntry(entry) },
                                onRename = { renameTarget = entry },
                                onDelete = { deleteTarget = entry },
                                onDownload = {
                                    downloadTarget = entry
                                    downloadLauncher.launch(entry.name)
                                },
                            )
                        }
                    }
                }
            }
        }
    }

    if (createFolderDialogOpen) {
        NodeNameInputDialog(
            title = "New folder",
            label = "Folder name",
            onDismiss = { createFolderDialogOpen = false },
            onConfirm = { name ->
                createFolderDialogOpen = false
                viewModel.createDirectory(name)
            },
        )
    }

    renameTarget?.let { entry ->
        NodeNameInputDialog(
            title = "Rename",
            label = "New name",
            initialValue = entry.name,
            onDismiss = { renameTarget = null },
            onConfirm = { newName ->
                renameTarget = null
                viewModel.renameEntry(entry, newName)
            },
        )
    }

    deleteTarget?.let { entry ->
        AlertDialog(
            onDismissRequest = { deleteTarget = null },
            title = { Text("Delete ${entry.name}?") },
            text = { Text("This will delete the file directly from the host filesystem.") },
            confirmButton = {
                TextButton(onClick = { deleteTarget = null; viewModel.deleteEntry(entry) }) { Text("Delete") }
            },
            dismissButton = {
                TextButton(onClick = { deleteTarget = null }) { Text("Cancel") }
            },
        )
    }
}

@Composable
private fun NodePathBar(currentPath: String?, canGoBack: Boolean, onNavigate: (String) -> Unit, onGoBack: () -> Unit) {
    val breadcrumbs = remember(currentPath) { buildNodeBreadcrumbs(currentPath) }
    LazyRow(
        modifier = Modifier.fillMaxWidth(),
        contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        if (canGoBack) {
            item {
                AssistChip(
                    onClick = onGoBack,
                    label = { Text("..") },
                    leadingIcon = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, modifier = Modifier.size(16.dp)) },
                )
            }
        }
        items(breadcrumbs, key = { it.first }) { (path, label) ->
            AssistChip(onClick = { onNavigate(path) }, label = { Text(label) })
        }
    }
}

@Composable
private fun NodeFileEntryCard(entry: InstanceFileEntryDto, onClick: () -> Unit, onRename: () -> Unit, onDelete: () -> Unit, onDownload: () -> Unit) {
    var menuExpanded by rememberSaveable(entry.path) { mutableStateOf(false) }
    Card(
        modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
        colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
    ) {
        Row(
            modifier = Modifier.fillMaxWidth().padding(16.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            Icon(
                imageVector = if (entry.isDir) Icons.Default.Folder else Icons.AutoMirrored.Filled.InsertDriveFile,
                contentDescription = null,
                tint = if (entry.isDir) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
                modifier = Modifier.size(22.dp),
            )
            Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
                Text(text = entry.name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
                Text(
                    text = buildNodeSubtitle(entry),
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis,
                )
            }
            Box {
                IconButton(onClick = { menuExpanded = true }) { Icon(Icons.Default.MoreVert, contentDescription = "More") }
                DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
                    if (!entry.isDir) {
                        DropdownMenuItem(
                            text = { Text("Download") },
                            onClick = { menuExpanded = false; onDownload() },
                            leadingIcon = { Icon(Icons.Default.Download, contentDescription = null) },
                        )
                    }
                    DropdownMenuItem(text = { Text("Rename") }, onClick = { menuExpanded = false; onRename() }, leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) })
                    DropdownMenuItem(text = { Text("Delete") }, onClick = { menuExpanded = false; onDelete() }, leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) })
                }
            }
        }
    }
}

@Composable
private fun NodeFullscreenState(title: String, body: String, loading: Boolean = false) {
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
            if (loading) CircularProgressIndicator()
            Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
            Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
        }
    }
}

@Composable
private fun NodeErrorState(title: String, body: String, onRetry: () -> Unit) {
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
            Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
            Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
            Button(onClick = onRetry) { Text("Retry") }
        }
    }
}

@Composable
private fun NodeNameInputDialog(title: String, label: String, initialValue: String = "", onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
    var value by rememberSaveable(title, initialValue) { mutableStateOf(initialValue) }
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text(title) },
        text = { OutlinedTextField(value = value, onValueChange = { value = it }, label = { Text(label) }, singleLine = true) },
        confirmButton = { TextButton(onClick = { onConfirm(value) }) { Text("Save") } },
        dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
    )
}

private fun buildNodeBreadcrumbs(currentPath: String?): List<Pair<String, String>> {
    if (currentPath.isNullOrBlank() || currentPath == "/") return listOf("/" to "/")
    val segments = currentPath.trim('/').split('/').filter { it.isNotBlank() }
    val breadcrumbs = mutableListOf("/" to "/")
    var running = ""
    segments.forEach { segment ->
        running += "/$segment"
        breadcrumbs += running to segment
    }
    return breadcrumbs
}

private fun buildNodeSubtitle(entry: InstanceFileEntryDto): String {
    val details = mutableListOf<String>()
    if (!entry.isDir) details += formatNodeBytes(entry.size)
    if (entry.modifiedAt.isNotBlank()) details += entry.modifiedAt
    return details.joinToString(" | ").ifBlank { entry.path }
}

private fun formatNodeBytes(bytes: Long): String {
    if (bytes < 1024) return "$bytes B"
    val units = listOf("KB", "MB", "GB", "TB")
    var value = bytes.toDouble()
    var index = -1
    while (value >= 1024 && index < units.lastIndex) { value /= 1024; index++ }
    return String.format("%.1f %s", value, units[index])
}

Step 2: Build

bash
cd /mnt/c/Users/caio/shardlyn-terminal
./gradlew :app:compileDebugKotlin 2>&1 | tail -30

Expected: BUILD SUCCESSFUL.

Step 3: Commit

bash
git add app/src/main/java/com/shardlyn/terminal/ui/screen/NodeFilesScreen.kt
git commit -m "feat(terminal): add NodeFilesScreen for host filesystem browsing"

Task 9: Android — Navigation wiring for NodeFilesScreen

Files:

  • Modify: /mnt/c/Users/caio/shardlyn-terminal/app/src/main/java/com/shardlyn/terminal/ui/navigation/AppDestination.kt
  • Modify: /mnt/c/Users/caio/shardlyn-terminal/app/src/main/java/com/shardlyn/terminal/ui/navigation/AppNavigation.kt
  • Modify: /mnt/c/Users/caio/shardlyn-terminal/app/src/main/java/com/shardlyn/terminal/ui/screen/ShardlynScreen.kt

Step 1: Add NodeFiles destination in AppDestination.kt

After the InstanceFiles object, add:

kotlin
    /** Node host files browser backed by Shardlyn node file APIs. */
    data object NodeFiles : AppDestination("node_files/{nodeId}/{nodeName}") {
        const val ROUTE_PATTERN = "node_files/{nodeId}/{nodeName}"
        fun route(nodeId: String, nodeName: String): String =
            "node_files/${Uri.encode(nodeId)}/${Uri.encode(nodeName)}"
    }

Step 2: Add composable in AppNavigation.kt

Add import at top:

kotlin
import com.shardlyn.terminal.ui.screen.NodeFilesScreen

Add the composable after the InstanceFiles composable (around line 202):

kotlin
            composable(
                route = AppDestination.NodeFiles.ROUTE_PATTERN,
                arguments = listOf(
                    navArgument("nodeId") { type = NavType.StringType },
                    navArgument("nodeName") { type = NavType.StringType },
                ),
            ) {
                NodeFilesScreen(onBack = { navController.popBackStack() })
            }

Add onOpenNodeFiles callback in the ShardlynScreen composable call (around line 111):

kotlin
                    onOpenNodeFiles = { nodeId, nodeName ->
                        navController.navigate(
                            AppDestination.NodeFiles.route(nodeId, nodeName),
                        ) { launchSingleTop = true }
                    },

Step 3: Add onOpenNodeFiles callback to ShardlynScreen

In ShardlynScreen.kt, find the ShardlynScreen composable parameters (around line 79) and add:

kotlin
    onOpenNodeFiles: (nodeId: String, nodeName: String) -> Unit = { _, _ -> },

Pass it through to the inner composable that renders NodeCard. Find where NodeCard is called (around line 216) and the inner composable signature. Look for where onConnect is passed and add:

kotlin
                            onOpenNodeFiles = { onOpenNodeFiles(nodeWithInstances.node.id, nodeWithInstances.node.name) },

Update NodeCard signature to add:

kotlin
    onOpenNodeFiles: () -> Unit = {},

Add a "Files" button in NodeCard next to the terminal connect button — find where onConnect is called (around line 358, near "Open node terminals" text) and add a files button:

kotlin
                    Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                        // ... existing connect button ...
                        OutlinedButton(onClick = onOpenNodeFiles) {
                            Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(16.dp))
                            Spacer(Modifier.width(4.dp))
                            Text("Files")
                        }
                    }

Step 4: Build

bash
cd /mnt/c/Users/caio/shardlyn-terminal
./gradlew :app:compileDebugKotlin 2>&1 | tail -30

Expected: BUILD SUCCESSFUL.

Step 5: Full build

bash
./gradlew :app:assembleDebug 2>&1 | tail -20

Expected: BUILD SUCCESSFUL with APK output path shown.

Step 6: Commit

bash
git add app/src/main/java/com/shardlyn/terminal/ui/navigation/AppDestination.kt \
        app/src/main/java/com/shardlyn/terminal/ui/navigation/AppNavigation.kt \
        app/src/main/java/com/shardlyn/terminal/ui/screen/ShardlynScreen.kt
git commit -m "feat(terminal): wire NodeFilesScreen into navigation and ShardlynScreen"

Task 10: Final validation

Step 1: Backend — full Go build + tests

bash
cd /home/caiolombello/shardlyn
go build ./...
go test ./internal/controlplane/sftpserver/... -v

Step 2: Web — typecheck + lint

bash
cd /home/caiolombello/shardlyn/web
npm run typecheck && npm run lint

Expected: no errors, no warnings.

Step 3: Android — full debug build

bash
cd /mnt/c/Users/caio/shardlyn-terminal
./gradlew :app:assembleDebug 2>&1 | tail -10

Expected: BUILD SUCCESSFUL.

Step 4: Commit plan doc

bash
cd /home/caiolombello/shardlyn
git add docs/plans/2026-03-13-sftp-host-files.md
git commit -m "docs: add SFTP host files implementation plan"

Built for teams that want control of their own infrastructure.