Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
81df219
ADFA-5446: Confirm template uninstall/replace instead of failing sile…
yaturner Sep 16, 2026
e2e0888
ADFA-5446: Restore collision logging and clarify the overwrite-delete…
yaturner Sep 16, 2026
6588402
ADFA-5446: Add KDoc to the uninstall-confirmation public declarations
yaturner Sep 17, 2026
b6844f0
Merge branch 'stage' into ADFA-5446-Extension-Manager-uninstall-broken
jimturner-adfa Oct 6, 2026
ef897b2
Merge branch 'stage' into ADFA-5446-Extension-Manager-uninstall-broken
jimturner-adfa Oct 15, 2026
362b6ca
ADFA-5446: Keep the Downloads file safe when an uninstall replace fails
yaturner Oct 15, 2026
8691b0b
Merge branch 'stage' into ADFA-5446-Extension-Manager-uninstall-broken
jimturner-adfa Oct 16, 2026
9843039
ADFA-5446: Replace the Downloads file with Files.move, not renameTo
jimturner-adfa Oct 16, 2026
c8f9bac
ADFA-5446: Only replace a Downloads file when overwrite was confirmed
jimturner-adfa Oct 16, 2026
ccae028
Merge branch 'stage' into ADFA-5446-Extension-Manager-uninstall-broken
jimturner-adfa Nov 3, 2026
bbd0897
ADFA-5446: Drop restore: install copies, uninstall deletes
jimturner-adfa Nov 3, 2026
9a4b516
ADFA-5446: Confirm before uninstalling a template
jimturner-adfa Nov 3, 2026
68d8748
ADFA-5446: Remove a partial copy when installing a template fails
jimturner-adfa Nov 3, 2026
0dc2017
Merge branch 'stage' into ADFA-5446-Extension-Manager-uninstall-broken
jimturner-adfa Nov 6, 2026
0f1196d
ADFA-5446: Delete only an install destination this install created
yaturner Nov 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@ interface TemplateRepository {
*/
suspend fun listTemplateFiles(): Result<List<CgtFileItem>>

/** Moves [item]'s file from Downloads into the templates directory and reloads templates. */
/**
* Copies [item]'s file from Downloads into the templates directory and reloads templates. The
* Downloads original is left in place; removing it is the caller's choice ([deleteDownloadFile]).
*/
suspend fun installTemplate(item: CgtFileItem): Result<Unit>

/** Restores a copy of [item]'s file to Downloads, removes it from the templates directory, and reloads templates. */
/**
* Deletes [item] from the templates directory and reloads templates. There is no restore: the
* user re-installs from the original `.cgt`, or re-installs the plugin that provided it.
*/
suspend fun uninstallTemplate(item: CgtFileItem): Result<Unit>

/** Deletes a not-installed [item]'s file from Downloads. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import org.adfa.constants.TEMPLATE_CORE_ARCHIVE
import org.json.JSONException
import org.slf4j.LoggerFactory
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.nio.file.Files
import java.nio.file.StandardOpenOption

/**
* Implementation of [TemplateRepository].
Expand Down Expand Up @@ -47,11 +50,11 @@ class TemplateRepositoryImpl(
}

/**
* One card per archive, not per file on disk. The same `.cgt` name can exist in both
* directories at once - a copy dropped into Downloads by hand, or the picker-install path,
* which copies rather than moves. Both rows would then render identically apart from the
* status line, and the Downloads twin is a dead end: [installTemplate] refuses to overwrite,
* so its Install can only ever fail. The installed copy wins.
* One card per archive, not per file on disk. The same `.cgt` name normally exists in both
* directories at once, since [installTemplate] copies and leaves the Downloads original
* behind. The Downloads twin is a dead end while the installed copy exists ([installTemplate]
* refuses to overwrite), so the installed copy wins; after [uninstallTemplate] the twin is
* listed again, which is how the user re-installs.
*
* Names are compared case-insensitively, matching the stricter of the two install paths
* (`TemplateCollectionRepository.findExistingCollision`), so any row still listed as
Expand Down Expand Up @@ -120,11 +123,7 @@ class TemplateRepositoryImpl(
check(!item.installed) { "'${item.name}' is already installed" }
val dest = File(templatesDir, item.file.name)
check(!dest.exists()) { "A template named '${dest.name}' already exists in $templatesDir" }
item.file.copyTo(dest, overwrite = false)
if (!item.file.delete()) {
dest.delete()
throw IOException("Failed to delete source file after copying: ${item.file.absolutePath}")
}
copyIntoNewFile(item.file, dest)
ITemplateProvider.getInstance(reload = true)
Result.success(Unit)
} catch (e: CancellationException) {
Expand All @@ -138,22 +137,36 @@ class TemplateRepositoryImpl(
}
}

/**
* Copies [source] to [dest], which must not exist yet. A copy that fails partway (e.g. storage
* full) is deleted: a truncated archive never lists, so it can't be uninstalled, and it blocks
* every retry. Only a [dest] this call created is deleted: the source is opened first, and
* CREATE_NEW creates [dest] atomically, failing if another writer got there first.
*/
private fun copyIntoNewFile(
source: File,
dest: File,
) {
FileInputStream(source).use { input ->
val output = Files.newOutputStream(dest.toPath(), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)
try {
output.use { input.copyTo(it) }
} catch (e: IOException) {
dest.delete()
throw e
}
}
}

override suspend fun uninstallTemplate(item: CgtFileItem): Result<Unit> =
withContext(Dispatchers.IO) {
try {
check(item.installed) { "'${item.name}' is not installed" }
// Guarded here as well as hidden by scanTemplates: once moved out, core.cgt is never
// Guarded here as well as hidden by scanTemplates: once deleted, core.cgt is never
// listed again, so there would be no in-app way to put it back.
check(!isCoreArchive(item.name)) { "Cannot uninstall the bundled template" }

// Restore a copy to Downloads BEFORE removing it from the store: if the restore
// throws, the store copy below is never touched, so the user's only copy survives.
val restored = File(downloadDir, item.file.name)
check(!restored.exists()) { "A download named '${restored.name}' already exists in $downloadDir" }
item.file.copyTo(restored, overwrite = false)
if (!item.file.delete()) {
restored.delete()
throw IOException("Failed to delete source file after copying: ${item.file.absolutePath}")
throw IOException("Failed to delete ${item.file.absolutePath}")
}
ITemplateProvider.getInstance(reload = true)
Result.success(Unit)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.itsaky.androidide.ui.compose.templates

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
Expand All @@ -11,10 +12,16 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.res.stringResource
import androidx.compose.ui.unit.dp
Expand All @@ -26,6 +33,74 @@ import com.itsaky.androidide.templates.manager.models.effectiveDisplayName
import com.itsaky.androidide.templates.manager.models.primaryTemplate
import com.itsaky.androidide.templates.manager.models.versionLabel

/** Same "delete after install" choice as the Plugins tab's install dialog. */
@Composable
fun InstallTemplateConfirmationDialog(
item: CgtFileItem,
onConfirm: (deleteSourceAfterInstall: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
var deleteSource by rememberSaveable { mutableStateOf(false) }

AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.title_install_template_collection)) },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(
stringResource(
R.string.msg_template_install_confirm,
item.displayName,
item.templates.joinToString(", ") { it.name },
),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 8.dp),
) {
Checkbox(checked = deleteSource, onCheckedChange = { deleteSource = it })
Text(
stringResource(R.string.checkbox_delete_source_after_install),
modifier = Modifier.weight(1f),
)
}
}
},
confirmButton = {
TextButton(onClick = { onConfirm(deleteSource) }) { Text(stringResource(R.string.btn_install)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) }
},
)
}

/** Same confirm-before-uninstall step as the Plugins tab. Nothing is restored to Downloads. */
@Composable
fun UninstallTemplateConfirmationDialog(
item: CgtFileItem,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.title_uninstall_template)) },
// AlertDialog's text slot does not scroll; a long file name at 2x font scale would clip.
text = {
Text(
stringResource(R.string.msg_uninstall_template_confirm, item.displayName),
modifier = Modifier.verticalScroll(rememberScrollState()),
)
},
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_uninstall_template)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) }
},
)
}

@Composable
fun DeleteTemplateConfirmationDialog(
item: CgtFileItem,
Expand All @@ -35,7 +110,12 @@ fun DeleteTemplateConfirmationDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.title_delete_template)) },
text = { Text(stringResource(R.string.msg_delete_template_confirm, item.displayName)) },
text = {
Text(
stringResource(R.string.msg_delete_template_confirm, item.displayName),
modifier = Modifier.verticalScroll(rememberScrollState()),
)
},
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete_template)) }
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ private sealed interface TemplateManagerDialogState : Parcelable {
@Parcelize
data object None : TemplateManagerDialogState

@Parcelize
data class InstallConfirm(
val path: String,
) : TemplateManagerDialogState

@Parcelize
data class UninstallConfirm(
val path: String,
) : TemplateManagerDialogState

@Parcelize
data class DeleteConfirm(
val path: String,
Expand Down Expand Up @@ -150,6 +160,14 @@ fun TemplateManagerScreen(
)
}

is TemplateManagerUiEffect.ShowInstallConfirmation -> {
dialogState = TemplateManagerDialogState.InstallConfirm(effect.item.file.absolutePath)
}

is TemplateManagerUiEffect.ShowUninstallConfirmation -> {
dialogState = TemplateManagerDialogState.UninstallConfirm(effect.item.file.absolutePath)
}

is TemplateManagerUiEffect.ShowDeleteConfirmation -> {
dialogState = TemplateManagerDialogState.DeleteConfirm(effect.item.file.absolutePath)
}
Expand Down Expand Up @@ -202,6 +220,34 @@ fun TemplateManagerScreen(
when (val dialog = dialogState) {
is TemplateManagerDialogState.None -> {}

is TemplateManagerDialogState.InstallConfirm -> {
val item = uiState.items.firstOrNull { it.file.absolutePath == dialog.path }
if (item != null) {
InstallTemplateConfirmationDialog(
item = item,
onConfirm = { deleteSource ->
viewModel.confirmInstallTemplate(item, deleteSource)
dialogState = TemplateManagerDialogState.None
},
onDismiss = { dialogState = TemplateManagerDialogState.None },
)
}
}

is TemplateManagerDialogState.UninstallConfirm -> {
val item = uiState.items.firstOrNull { it.file.absolutePath == dialog.path }
if (item != null) {
UninstallTemplateConfirmationDialog(
item = item,
onConfirm = {
viewModel.confirmUninstallTemplate(item)
dialogState = TemplateManagerDialogState.None
},
onDismiss = { dialogState = TemplateManagerDialogState.None },
)
}
}

is TemplateManagerDialogState.DeleteConfirm -> {
val item = uiState.items.firstOrNull { it.file.absolutePath == dialog.path }
if (item != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ sealed class TemplateManagerUiEffect {
val formatArgs: List<Any> = emptyList(),
) : TemplateManagerUiEffect()

data class ShowInstallConfirmation(
val item: CgtFileItem,
) : TemplateManagerUiEffect()

data class ShowUninstallConfirmation(
val item: CgtFileItem,
) : TemplateManagerUiEffect()

data class ShowDeleteConfirmation(
val item: CgtFileItem,
) : TemplateManagerUiEffect()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ class TemplateManagerViewModel(
fun onEvent(event: TemplateManagerUiEvent) {
when (event) {
is TemplateManagerUiEvent.LoadTemplates -> loadTemplates()
is TemplateManagerUiEvent.InstallTemplate -> installTemplate(event.item)
is TemplateManagerUiEvent.UninstallTemplate -> uninstallTemplate(event.item)
is TemplateManagerUiEvent.InstallTemplate -> showInstallConfirmation(event.item)
is TemplateManagerUiEvent.UninstallTemplate -> showUninstallConfirmation(event.item)
is TemplateManagerUiEvent.DeleteDownloadFile -> showDeleteConfirmation(event.item)
is TemplateManagerUiEvent.ShowTemplateDetails -> showTemplateDetails(event.item)
is TemplateManagerUiEvent.ShowTemplateList -> showTemplateList(event.item)
Expand Down Expand Up @@ -71,7 +71,21 @@ class TemplateManagerViewModel(
}
}

private fun installTemplate(item: CgtFileItem) {
private fun showInstallConfirmation(item: CgtFileItem) {
viewModelScope.launch {
_uiEffect.send(TemplateManagerUiEffect.ShowInstallConfirmation(item))
}
}

/**
* Installs a Downloads template (called after confirmation). Install copies, so the Downloads
* original is kept unless [deleteSourceAfterInstall] - the same choice the Plugins tab offers.
* A failed delete is reported but doesn't undo the install, matching the Plugins tab.
*/
fun confirmInstallTemplate(
item: CgtFileItem,
deleteSourceAfterInstall: Boolean,
) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
templateRepository
Expand All @@ -84,6 +98,11 @@ class TemplateManagerViewModel(
listOf(item.displayName),
),
)
if (deleteSourceAfterInstall) {
templateRepository.deleteDownloadFile(item).onFailure {
_uiEffect.send(TemplateManagerUiEffect.ShowError(R.string.msg_source_delete_failed))
}
}
// loadTemplates() clears isLoading once the post-install rescan completes, so the
// indicator stays up continuously across both phases instead of flickering off
// between them.
Expand All @@ -101,7 +120,14 @@ class TemplateManagerViewModel(
}
}

private fun uninstallTemplate(item: CgtFileItem) {
private fun showUninstallConfirmation(item: CgtFileItem) {
viewModelScope.launch {
_uiEffect.send(TemplateManagerUiEffect.ShowUninstallConfirmation(item))
}
}

/** Uninstalls [item] (called after [TemplateManagerUiEffect.ShowUninstallConfirmation] is confirmed). */
fun confirmUninstallTemplate(item: CgtFileItem) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
templateRepository
Expand Down
Loading
Loading