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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Strategy: **layer-and-subsystem based**, not feature-by-feature. The Gradle buil
|---|---|---|
| Application | `app` | The IDE itself — activities, fragments, services, DI, agent, web server. Wires everything together. |
| Build engine | `subprojects:tooling-api*`, `gradle-plugin*`, `subprojects:projects`, `subprojects:builder-model-impl` | Runs a real Gradle build of the user's project out-of-process and streams events back. |
| Language tooling | `lsp:{api,java,kotlin,xml,indexing,…}`, `lexers`, `editor*`, `editor-treesitter` | Language servers, indexing, the Sora-based editor and highlighting. |
| Language tooling | `lsp:{api,java,kotlin,xml,indexing,refactor-core,ui,…}`, `lexers`, `editor*`, `editor-treesitter` | Language servers, indexing, the Sora-based editor and highlighting. `lsp:refactor-core` holds the language-agnostic half of the refactorings (offset spans, block geometry, rewrite composition, name primitives) so `lsp:java` and `lsp:kotlin` share one copy; `lsp:ui` holds the Compose sheets they share. Neither depends on a language server. |
| UI design tooling | `layouteditor`, `uidesigner`, `xml-inflater`, `vectormaster`, `compose-preview` | Visual/XML design surfaces for the *user's* app. |
| Shell | `termux:{termux-app,termux-shared,termux-view,termux-emulator}` | Embedded Termux shell and terminal. |
| Plugin system | `plugin-api`, `plugin-api:plugin-builder`, `plugin-manager` | In-app plugin SDK + manager — `AndroidManifest.xml` `<meta-data>` contract, permissions, extensions. See [plugin-api.md](docs/plugin-api.md) for the API surface & compatibility policy. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ object TooltipTag {
const val EDITOR_CODE_ACTIONS_UNUSED_IMPORTS = "editor.codeactions.unusedimports"
const val EDITOR_CODE_ACTIONS_ORGANIZE_IMPORTS = "editor.codeactions.organizeimports"
const val EDITOR_CODE_ACTIONS_TRY_CATCH = "editor.codeactions.trycatch"
const val EDITOR_CODE_ACTIONS_EXTRACT_VARIABLE = "editor.codeactions.extractvariable"

// Kotlin code actions. Tags are per-language even where the action exists in both languages,
// so the tooltip can describe the Kotlin behaviour (see ADFA-4730).
Expand Down
2 changes: 2 additions & 0 deletions lsp/java/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ dependencies {
implementation(projects.editorApi)
implementation(projects.resources)
implementation(projects.lsp.api)
implementation(projects.lsp.refactorCore)
implementation(projects.lsp.ui)
implementation(projects.lsp.jvmSymbolIndex)
implementation(projects.subprojects.libjdwp)
implementation(projects.subprojects.javacServices)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package com.itsaky.androidide.lsp.java.actions

import com.itsaky.androidide.actions.ActionData
import com.itsaky.androidide.actions.requireContext
import com.itsaky.androidide.actions.requireEditor
import com.itsaky.androidide.actions.requireFile
import com.itsaky.androidide.idetooltips.TooltipTag
import com.itsaky.androidide.lsp.java.refactor.ExtractionPlan
import com.itsaky.androidide.lsp.java.refactor.JAVA_KEYWORDS
import com.itsaky.androidide.lsp.java.refactor.JAVA_NAME_MESSAGES
import com.itsaky.androidide.lsp.java.refactor.buildExtractVariableRewrite
import com.itsaky.androidide.lsp.java.refactor.buildExtractionPlan
import com.itsaky.androidide.lsp.java.refactor.candidateAndScopeFor
import com.itsaky.androidide.lsp.java.refactor.toCandidateViews
import com.itsaky.androidide.lsp.models.CodeActionItem
import com.itsaky.androidide.lsp.models.CodeActionKind
import com.itsaky.androidide.lsp.models.Command
import com.itsaky.androidide.lsp.models.DocumentChange
import com.itsaky.androidide.lsp.refactor.toTextEdit
import com.itsaky.androidide.lsp.ui.ExtractVariableSelection
import com.itsaky.androidide.lsp.ui.ExtractVariableSheet
import com.itsaky.androidide.lsp.ui.findFragmentActivity
import com.itsaky.androidide.projects.FileManager
import com.itsaky.androidide.resources.R
import com.itsaky.androidide.utils.flashError
import com.itsaky.androidide.utils.flashInfo
import org.slf4j.LoggerFactory
import java.nio.file.Path
import kotlin.coroutines.cancellation.CancellationException

/**
* Extracts the expression at the cursor, or the selected one, into a local variable.
*
* The work is split so nothing heavy touches the UI thread: [execAction] runs one attributed compile
* and returns a plain-data [ExtractionPlan] covering every candidate, then [postExec] shows the shared
* sheet and turns the user's selection into a single text edit with pure offset arithmetic.
*/
class ExtractVariableAction : BaseJavaCodeAction() {
companion object {
const val ID = "ide.editor.lsp.java.extractVariable"

private val log = LoggerFactory.getLogger(ExtractVariableAction::class.java)
}

override val titleTextRes: Int = R.string.action_extract_variable
override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_EXTRACT_VARIABLE

override val id: String = ID
override var label: String = ""

// Deciding whether anything is extractable needs an attributed compile, far too costly for
// prepare() on the UI thread. BaseJavaCodeAction's file-type and module gate is all that applies;
// the action stays visible on any Java file and reports "nothing to extract" instead, as
// OrganizeImportsAction does.
override var requiresUIThread: Boolean = false

override suspend fun execAction(data: ActionData): ExtractionPlan {
val file = data.requireFile().toPath()
val cursor = data.requireEditor().cursor
val selectionStart = minOf(cursor.left, cursor.right)
val selectionEnd = maxOf(cursor.left, cursor.right)
val version = documentVersionOf(file)

// Resolving the compiler and taking its lock can both throw, and neither is inside the planner's
// own guard. DefaultActionsRegistry catches only IllegalArgumentException and this runs on a scope
// with no exception handler, so anything else would crash the app rather than fail the action.
return runCatching {
data.requireCompiler().compile(file).get { task ->
buildExtractionPlan(task, file, selectionStart, selectionEnd, version)
}
}.getOrElse { error ->
if (error is CancellationException) throw error
log.warn("Could not analyse {} for extract variable.", file, error)
ExtractionPlan.empty()
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

override fun postExec(
data: ActionData,
result: Any,
) {
super.postExec(data, result)
if (result !is ExtractionPlan) return

if (result.isEmpty) {
flashInfo(R.string.msg_extract_variable_nothing_to_extract)
return
}

val context = data.requireContext()
val activity =
context.findFragmentActivity()
?: run {
// A wiring problem rather than a user path: the editor is always hosted by one.
log.warn("No FragmentActivity for the editor context. Cannot show the extract sheet.")
flashError(R.string.msg_cannot_perform_fix)
return
}

val shown =
ExtractVariableSheet.show(
activity,
result.toCandidateViews(context),
JAVA_KEYWORDS,
JAVA_NAME_MESSAGES,
) { selection -> applySelection(data, result, selection) }
if (!shown) {
log.warn("Fragment manager unavailable. Cannot show the extract sheet.")
}
}

/**
* Turns the user's selection into one edit and hands it to the language client.
*
* The document version is re-read here rather than trusted from the plan: the editor stays
* reachable while the sheet is open, and applying spans computed against older text would corrupt
* the file. Refusing is always safe; the user can invoke the action again.
*/
private fun applySelection(
data: ActionData,
plan: ExtractionPlan,
selection: ExtractVariableSelection,
) {
val file = data.requireFile().toPath()
// A plan built while the document was closed carries no version to compare, so there is nothing
// to prove the text still matches: refuse rather than apply spans on trust.
if (plan.documentVersion == null || documentVersionOf(file) != plan.documentVersion) {
flashInfo(R.string.msg_extract_variable_file_changed)
return
}

val (candidate, scope) =
plan.candidateAndScopeFor(selection) ?: run {
log.warn("Selection {} does not address the plan it came from.", selection)
flashError(R.string.msg_cannot_perform_fix)
return
}

val rewrite =
buildExtractVariableRewrite(
fileText = plan.fileText,
candidateSpan = candidate.span,
declaredType = candidate.declaredType,
scope = scope,
name = selection.name,
replaceAll = selection.replaceAll,
) ?: run {
log.warn("Could not build an extract-variable rewrite for '{}'", candidate.label)
flashError(R.string.msg_cannot_perform_fix)
return
}

val client =
data.getLanguageClient() ?: run {
log.warn("No language client set. Cannot extract variable.")
return
}

client.performCodeAction(
CodeActionItem(
title = label,
changes = listOf(DocumentChange(file = file, edits = listOf(rewrite.toTextEdit(plan.fileText)))),
kind = CodeActionKind.QuickFix,
// The rewrite is emitted fully indented. Running google-java-format here would reformat
// the whole file into the same undo step as the extraction.
command = Command("", ""),
),
)
}

/** Null when the document is not open, which the confirm guard treats as unverifiable and refuses. */
private fun documentVersionOf(path: Path): Int? = FileManager.getActiveDocument(path)?.version
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,5 +93,6 @@ object JavaCodeActionsMenu : IActionsMenuProvider {
CATCH_BODY,
TooltipTag.EDITOR_CODE_ACTIONS_TRY_CATCH,
),
ExtractVariableAction(),
)
}
Loading
Loading