Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -20,7 +20,11 @@ interface TemplateRepository {
/** Moves [item]'s file from Downloads into the templates directory and reloads templates. */
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. */
/**
* Removes [item] from the templates directory and reloads templates. A user-imported item is
* restored to Downloads first; a plugin-provided item is just deleted, since it never came
* from Downloads in the first place.
*/
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 @@ -143,6 +143,20 @@ class TemplateRepositoryImpl(
check(item.installed) { "'${item.name}' is not installed" }
check(item.provenance != TemplateProvenance.BUNDLED) { "Cannot uninstall the bundled template" }

if (item.provenance == TemplateProvenance.PLUGIN) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Templates come from cgt files, not plugins.

// Plugin templates are extracted straight from the plugin's own bundled
// resources (PluginProjectManager.extractBundledCgtTemplates) - they never
// passed through Downloads, so there's nothing to restore there and no
// collision to check. Uninstalling one just deletes the extracted copy,
// matching how PluginProjectManager/IdeTemplateServiceImpl already remove
// these elsewhere (cleanupPluginTemplates/unregisterTemplate).
if (!item.file.delete()) {
throw IOException("Failed to delete ${item.file.absolutePath}")
}
ITemplateProvider.getInstance(reload = true)
return@withContext Result.success(Unit)
}

// 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,35 @@ class TemplateRepositoryImplTest {
assertThat(existingDownload.readText()).isEqualTo("already in downloads")
}

@Test
fun uninstallTemplate_pluginProvenance_neverChecksOrTouchesDownloads() =
runTest {
val source = File(templatesDir, "plugin_flutter_activity.cgt").apply { writeText("installed") }
val pluginItem =
CgtFileItem(
file = source,
name = source.name,
templates = listOf(TemplateMetadata("T", "d", "1.0")),
installed = true,
provenance = TemplateProvenance.PLUGIN,
)
// A same-named file already sitting in Downloads must not trip a collision check for
// a plugin-provided template (ADFA-5444): it never came from Downloads, so there's
// nothing to restore there. templatesDir is made read-only so the only way to reach
// this specific IOException is the plugin branch's own delete - the old
// restore-to-Downloads path would instead fail earlier with an IllegalStateException
// from the (wrongly reached) "already exists" check.
val existingDownload = File(downloadDir, "plugin_flutter_activity.cgt").apply { writeText("unrelated") }
check(templatesDir.setWritable(false)) { "test setup: could not make templatesDir read-only" }

val result = repository.uninstallTemplate(pluginItem)

assertThat(result.isFailure).isTrue()
assertThat(result.exceptionOrNull()).isInstanceOf(IOException::class.java)
assertThat(source.exists()).isTrue()
assertThat(existingDownload.readText()).isEqualTo("unrelated")
}

@Test
fun uninstallTemplate_deleteFails_rollsBackAndLeavesExactlyOneCopy() =
runTest {
Expand Down
Loading