diff --git a/docs/superpowers/plans/2026-06-25-enhance-ai-assistant-plugin-ui.md b/docs/superpowers/plans/2026-06-25-enhance-ai-assistant-plugin-ui.md deleted file mode 100644 index 053ca17ed3..0000000000 --- a/docs/superpowers/plans/2026-06-25-enhance-ai-assistant-plugin-ui.md +++ /dev/null @@ -1,1040 +0,0 @@ -# Enhance AI Assistant Plugin UI Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Transform the minimal AI Assistant plugin UI into a polished, full-featured chat interface matching the stage branch implementation. - -**Architecture:** Port key UI components from stage branch's `app/src/main/java/com/itsaky/androidide/agent/` to the AI Assistant plugin while preserving the plugin architecture. Add conversation management, enhanced progress tracking, polished Material Design 3 components, and backend status indicators. - -**Tech Stack:** -- Kotlin with Coroutines and Flow -- Android View Binding -- Material Design 3 components -- RecyclerView with DiffUtil -- Markwon for Markdown rendering -- SharedPreferences for persistence - -## Global Constraints - -- **Plugin architecture:** All code lives in `/Users/john/Documents/cogo/plugin-examples/ai-assistant/ai-assistant-plugin/` -- **Minimum API:** Android API 26 (Android 8.0) -- **Material Design:** Use Material 3 components (`com.google.android.material`) -- **Build system:** Gradle with Kotlin DSL -- **Testing:** Manual device testing on Infinix device (ID: 1378640516009494) -- **Code style:** Follow existing plugin codebase conventions -- **Commit messages:** Use conventional commits format (`feat:`, `fix:`, `refactor:`) - ---- - -### Task 1: Add Chat Session Management Models - -**Files:** -- Create: `ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/ChatSession.kt` -- Modify: `ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt:30-50` - -**Interfaces:** -- Consumes: None (base models) -- Produces: - - `ChatSession` data class with fields: `id: String`, `createdAt: Long`, `messages: MutableList`, `title: String`, `formattedDate: String` - -- [ ] **Step 1: Create ChatSession model** - -```kotlin -package com.itsaky.androidide.plugins.aiassistant.models - -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.UUID - -data class ChatSession( - val id: String = UUID.randomUUID().toString(), - val createdAt: Long = System.currentTimeMillis(), - val messages: MutableList = mutableListOf() -) { - val title: String - get() = messages.firstOrNull { it.sender == Sender.USER }?.text ?: "New Chat" - - val formattedDate: String - get() = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()).format(Date(createdAt)) -} -``` - -- [ ] **Step 2: Add session management to ChatViewModel** - -Add after existing properties (around line 30): - -```kotlin -private val _sessions = MutableStateFlow>(emptyList()) -val sessions: StateFlow> = _sessions.asStateFlow() - -private val _currentSessionId = MutableStateFlow(null) -val currentSessionId: StateFlow = _currentSessionId.asStateFlow() - -val currentSession: StateFlow = combine(_sessions, _currentSessionId) { sessions, id -> - sessions.firstOrNull { it.id == id } -}.stateIn(viewModelScope, SharingStarted.Lazily, null) -``` - -- [ ] **Step 3: Add session creation and switching methods** - -Add to ChatViewModel: - -```kotlin -fun createNewSession() { - val newSession = ChatSession() - _sessions.value = _sessions.value + newSession - _currentSessionId.value = newSession.id - _messages.value = emptyList() -} - -fun switchToSession(sessionId: String) { - val session = _sessions.value.firstOrNull { it.id == sessionId } - if (session != null) { - _currentSessionId.value = sessionId - _messages.value = session.messages - } -} - -fun deleteSession(sessionId: String) { - _sessions.value = _sessions.value.filter { it.id != sessionId } - if (_currentSessionId.value == sessionId) { - val remaining = _sessions.value.firstOrNull() - _currentSessionId.value = remaining?.id - _messages.value = remaining?.messages ?: emptyList() - } -} -``` - -- [ ] **Step 4: Test session management** - -Manual test on device: -1. Launch app with AI Assistant plugin -2. Start a new chat (should create first session) -3. Send a message (should appear in session) -4. Expected: Session title updates to first user message - -- [ ] **Step 5: Commit** - -```bash -cd /Users/john/Documents/cogo/plugin-examples/ai-assistant -git add ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/ChatSession.kt \ - ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt -git commit -m "feat: add chat session management models and ViewModel support - -- Add ChatSession data class with title and formatted date -- Add session state management to ChatViewModel -- Implement createNewSession, switchToSession, deleteSession -- Add reactive flows for sessions and current session" -``` - ---- - -### Task 2: Add Tool Execution Tracker - -**Files:** -- Create: `ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/utils/ToolExecutionTracker.kt` - -**Interfaces:** -- Consumes: None (standalone utility) -- Produces: - - `ToolExecutionTracker` class with methods: `startTracking()`, `logToolCall(name: String, durationMillis: Long)`, `generateReport(): String`, `generatePartialReport(): String`, `generatePausedReport(): String` - -- [ ] **Step 1: Create ToolExecutionTracker class** - -```kotlin -package com.itsaky.androidide.plugins.aiassistant.utils - -import java.util.Locale -import java.util.concurrent.TimeUnit - -/** - * Tracks tool execution and generates formatted reports with timing information. - */ -class ToolExecutionTracker { - private val toolsUsed = mutableListOf() - private var operationStartTime = 0L - - data class ToolCallLog( - val name: String, - val durationMillis: Long, - val timestamp: Long - ) - - fun startTracking() { - toolsUsed.clear() - operationStartTime = System.currentTimeMillis() - } - - fun logToolCall(name: String, durationMillis: Long) { - val timestamp = System.currentTimeMillis() - operationStartTime - toolsUsed.add(ToolCallLog(name, durationMillis, timestamp)) - } - - fun generateReport(): String { - if (toolsUsed.isEmpty()) { - return "βœ… **Operation Complete**\n\nNo tools were needed for this request." - } - val totalDuration = System.currentTimeMillis() - operationStartTime - return buildReport("βœ… **Operation Complete**", totalDuration) - } - - fun generatePartialReport(): String { - if (toolsUsed.isEmpty()) { - return "πŸ›‘ **Operation Cancelled**\n\nNo tools were executed before cancellation." - } - val totalDuration = System.currentTimeMillis() - operationStartTime - return buildReport("πŸ›‘ **Operation Cancelled**", totalDuration) - } - - fun generatePausedReport(): String { - if (toolsUsed.isEmpty()) { - return "⏸️ **Awaiting User Input**\n\nNo tools were run before the question was asked." - } - val totalDuration = System.currentTimeMillis() - operationStartTime - return buildReport("⏸️ **Awaiting User Input**", totalDuration) - } - - private fun buildReport(title: String, totalDuration: Long): String { - val toolCounts = toolsUsed.groupingBy { it.name }.eachCount() - - val reportBuilder = StringBuilder("$title (Total: ${formatTime(totalDuration)})\n\n") - reportBuilder.append("**Tool Execution Report:**\n") - reportBuilder.append("Sequence:\n") - toolsUsed.forEachIndexed { index, log -> - reportBuilder.append( - "${index + 1}. `${log.name}` (took ${formatTime(log.durationMillis)} at +${formatTime(log.timestamp)})\n" - ) - } - - reportBuilder.append("\nSummary:\n") - toolCounts.forEach { (name, count) -> - val times = if (count == 1) "1 time" else "$count times" - reportBuilder.append("- `$name`: called $times\n") - } - - return reportBuilder.toString() - } - - private fun formatTime(millis: Long): String { - if (millis < 0) return "0.0s" - val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) - val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) - TimeUnit.MINUTES.toSeconds(minutes) - val remainingMillis = millis % 1000 - val totalSeconds = seconds + (remainingMillis / 1000.0) - return if (minutes > 0) { - String.format(Locale.US, "%dm %.1fs", minutes, totalSeconds) - } else { - String.format(Locale.US, "%.1fs", totalSeconds) - } - } -} -``` - -- [ ] **Step 2: Integrate tracker into ChatViewModel** - -Add to ChatViewModel after existing properties: - -```kotlin -val toolExecutionTracker = ToolExecutionTracker() -``` - -- [ ] **Step 3: Update Executor to log tool calls** - -Modify `ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/Executor.kt` at tool execution (around line 80): - -```kotlin -// Before tool execution -val toolStartTime = System.currentTimeMillis() - -// After tool execution -val toolDuration = System.currentTimeMillis() - toolStartTime -viewModel.toolExecutionTracker.logToolCall(toolCall.tool, toolDuration) -``` - -- [ ] **Step 4: Test tool tracking** - -Manual test: -1. Send message that uses `run_app` tool -2. Wait for completion -3. Expected: Tool execution is tracked with timing - -- [ ] **Step 5: Commit** - -```bash -git add ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/utils/ToolExecutionTracker.kt \ - ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt \ - ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/Executor.kt -git commit -m "feat: add tool execution tracker with timing reports - -- Create ToolExecutionTracker for monitoring tool usage -- Track tool execution duration and timestamps -- Generate formatted reports for complete, cancelled, and paused operations -- Integrate tracker into Executor for automatic logging" -``` - ---- - -### Task 3: Enhance AgentState with Step Progress - -**Files:** -- Modify: `ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/AgentState.kt:15-30` - -**Interfaces:** -- Consumes: Existing `AgentState` sealed class -- Produces: Enhanced `Executing` state with `currentStepIndex: Int`, `totalSteps: Int`, `description: String`, `startTime: Long`, `elapsedMillis: Long` - -- [ ] **Step 1: Add timing fields to Executing state** - -Replace existing `Executing` state: - -```kotlin -data class Executing( - val currentStepIndex: Int, - val totalSteps: Int, - val description: String, - val startTime: Long = System.currentTimeMillis(), - val elapsedMillis: Long = 0 -) : AgentState() { - val formattedProgress: String - get() = "Step ${currentStepIndex + 1} of $totalSteps: $description" - - val formattedTiming: String - get() { - val elapsed = formatTime(elapsedMillis) - // Estimate total time based on average time per step - val estimatedTotal = if (currentStepIndex > 0) { - val avgPerStep = elapsedMillis / (currentStepIndex + 1) - avgPerStep * totalSteps - } else { - elapsedMillis * totalSteps - } - val total = formatTime(estimatedTotal) - return "($elapsed of $total)" - } - - private fun formatTime(millis: Long): String { - if (millis < 0) return "0.0s" - val minutes = java.util.concurrent.TimeUnit.MILLISECONDS.toMinutes(millis) - val seconds = java.util.concurrent.TimeUnit.MILLISECONDS.toSeconds(millis) - - java.util.concurrent.TimeUnit.MINUTES.toSeconds(minutes) - val remainingMillis = millis % 1000 - val totalSeconds = seconds + (remainingMillis / 1000.0) - return if (minutes > 0) { - String.format(java.util.Locale.US, "%dm %.1fs", minutes, totalSeconds) - } else { - String.format(java.util.Locale.US, "%.1fs", totalSeconds) - } - } -} -``` - -- [ ] **Step 2: Add timer update mechanism to ChatViewModel** - -Add to ChatViewModel: - -```kotlin -private var stateUpdateJob: Job? = null - -fun startStateTimer(state: AgentState.Executing) { - stateUpdateJob?.cancel() - stateUpdateJob = viewModelScope.launch { - while (isActive) { - delay(100) // Update every 100ms - val elapsed = System.currentTimeMillis() - state.startTime - _agentState.value = state.copy(elapsedMillis = elapsed) - } - } -} - -fun stopStateTimer() { - stateUpdateJob?.cancel() - stateUpdateJob = null -} -``` - -- [ ] **Step 3: Test step progress display** - -Manual test: -1. Trigger tool execution -2. Observe status updates -3. Expected: "Step 1 of X: description (Ys of Zs)" format - -- [ ] **Step 4: Commit** - -```bash -git add ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/AgentState.kt \ - ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt -git commit -m "feat: enhance AgentState.Executing with timing and progress formatting - -- Add startTime and elapsedMillis to Executing state -- Implement formattedProgress for step display -- Implement formattedTiming with elapsed and estimated total -- Add timer mechanism to ChatViewModel for live updates" -``` - ---- - -### Task 4: Create Polished Chat Layout with XML - -**Files:** -- Create: `ai-assistant-plugin/src/main/res/layout/fragment_chat.xml` -- Create: `ai-assistant-plugin/src/main/res/drawable/backend_status_background.xml` -- Create: `ai-assistant-plugin/src/main/res/values/strings.xml` (append to existing) - -**Interfaces:** -- Consumes: None (UI layout resources) -- Produces: Layout with ViewBinding: `FragmentChatBinding` - -- [ ] **Step 1: Create backend status background drawable** - -```xml - - - - - -``` - -- [ ] **Step 2: Add missing string resources** - -Append to `ai-assistant-plugin/src/main/res/values/strings.xml`: - -```xml -AI Agent -New Chat -Add context file -Type a message or prompt… -⚠️ Experimental AI. Use at your own risk. -Current AI backend -Send -``` - -- [ ] **Step 3: Create complete fragment_chat.xml layout** - -```xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -``` - -- [ ] **Step 4: Test layout rendering** - -Build plugin and check layout preview: -```bash -cd /Users/john/Documents/cogo/plugin-examples/ai-assistant -./gradlew :ai-assistant-plugin:assemblePlugin -``` - -Expected: Layout compiles without errors - -- [ ] **Step 5: Commit** - -```bash -git add ai-assistant-plugin/src/main/res/layout/fragment_chat.xml \ - ai-assistant-plugin/src/main/res/drawable/backend_status_background.xml \ - ai-assistant-plugin/src/main/res/values/strings.xml -git commit -m "feat: create polished Material Design 3 chat layout - -- Add fragment_chat.xml with toolbar, status bar, and input area -- Create backend_status_background drawable -- Add agent status container with progress and timing display -- Include experimental AI warning and backend indicator -- Add proper keyboard spacer handling" -``` - ---- - -### Task 5: Migrate ChatFragment to Use XML Layout with ViewBinding - -**Files:** -- Modify: `ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt:1-500` -- Modify: `ai-assistant-plugin/build.gradle.kts:40-50` (enable ViewBinding) - -**Interfaces:** -- Consumes: `FragmentChatBinding` from `fragment_chat.xml` -- Produces: Updated `ChatFragment` using ViewBinding instead of programmatic UI - -- [ ] **Step 1: Enable ViewBinding in build.gradle.kts** - -Add inside `android` block: - -```kotlin -buildFeatures { - viewBinding = true -} -``` - -- [ ] **Step 2: Replace ChatFragment class declaration** - -Replace the class signature and binding setup (lines 1-60): - -```kotlin -package com.itsaky.androidide.plugins.aiassistant.fragments - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.core.widget.doAfterTextChanged -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.LinearLayoutManager -import com.itsaky.androidide.plugins.aiassistant.adapters.ChatAdapter -import com.itsaky.androidide.plugins.aiassistant.databinding.FragmentChatBinding -import com.itsaky.androidide.plugins.aiassistant.models.AgentState -import com.itsaky.androidide.plugins.aiassistant.viewmodel.ChatViewModel -import kotlinx.coroutines.launch - -class ChatFragment : Fragment() { - private var _binding: FragmentChatBinding? = null - private val binding get() = _binding!! - - private val viewModel: ChatViewModel by viewModels() - private lateinit var chatAdapter: ChatAdapter - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentChatBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -``` - -- [ ] **Step 3: Update onViewCreated to use ViewBinding** - -Replace UI setup in `onViewCreated` (lines 61-200): - -```kotlin -override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - setupRecyclerView() - setupInputArea() - setupStatusBar() - setupBackendIndicator() - observeViewModel() -} - -private fun setupRecyclerView() { - chatAdapter = ChatAdapter() - binding.chatRecyclerView.apply { - adapter = chatAdapter - layoutManager = LinearLayoutManager(requireContext()).apply { - stackFromEnd = true - } - } -} - -private fun setupInputArea() { - binding.promptInputEdittext.doAfterTextChanged { text -> - binding.sendButton.isEnabled = !text.isNullOrBlank() - } - - binding.sendButton.setOnClickListener { - val message = binding.promptInputEdittext.text?.toString() ?: return@setOnClickListener - if (message.isNotBlank()) { - viewModel.sendMessage(message) - binding.promptInputEdittext.text?.clear() - } - } - - binding.btnAddContext.setOnClickListener { - // TODO: Implement context file picker - } -} - -private fun setupStatusBar() { - binding.agentStatusContainer.isVisible = false -} - -private fun setupBackendIndicator() { - binding.backendStatusText.text = "Gemini" -} - -private fun observeViewModel() { - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - launch { observeMessages() } - launch { observeAgentState() } - } - } -} - -private suspend fun observeMessages() { - viewModel.messages.collect { messages -> - binding.emptyChatView.isVisible = messages.isEmpty() - chatAdapter.submitList(messages) { - if (messages.isNotEmpty()) { - binding.chatRecyclerView.scrollToPosition(messages.size - 1) - } - } - } -} - -private suspend fun observeAgentState() { - viewModel.agentState.collect { state -> - when (state) { - is AgentState.Idle -> { - binding.agentStatusContainer.isVisible = false - binding.sendButton.isEnabled = true - } - is AgentState.Executing -> { - binding.agentStatusContainer.isVisible = true - binding.agentStatusMessage.text = state.formattedProgress - binding.agentStatusTimer.text = state.formattedTiming - binding.sendButton.isEnabled = false - viewModel.startStateTimer(state) - } - is AgentState.Processing -> { - binding.agentStatusContainer.isVisible = true - binding.agentStatusMessage.text = "Generating response..." - binding.agentStatusTimer.text = "" - binding.sendButton.isEnabled = false - } - is AgentState.Error -> { - binding.agentStatusContainer.isVisible = false - binding.sendButton.isEnabled = true - viewModel.stopStateTimer() - } - else -> { - binding.sendButton.isEnabled = false - } - } - } -} -``` - -- [ ] **Step 4: Test ViewBinding integration** - -Build and install plugin: -```bash -cd /Users/john/Documents/cogo/plugin-examples/ai-assistant -./gradlew :ai-assistant-plugin:clean :ai-assistant-plugin:assemblePlugin -adb -s 1378640516009494 push ai-assistant-plugin/build/plugin/ai-assistant.cgp /sdcard/Download/ -``` - -Manual test: -1. Install updated plugin via Plugin Manager -2. Navigate to AI Agent tab -3. Expected: Polished UI with toolbar, status bar, and styled input area - -- [ ] **Step 5: Commit** - -```bash -git add ai-assistant-plugin/build.gradle.kts \ - ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt -git commit -m "refactor: migrate ChatFragment to ViewBinding with XML layout - -- Enable ViewBinding in build.gradle.kts -- Replace programmatic UI creation with fragment_chat.xml -- Add setupRecyclerView, setupInputArea, setupStatusBar methods -- Implement observeMessages and observeAgentState with state-based UI -- Add live progress and timing display for Executing state" -``` - ---- - -### Task 6: Add Session Persistence and Loading - -**Files:** -- Create: `ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/data/ChatStorageManager.kt` -- Modify: `ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt:100-150` - -**Interfaces:** -- Consumes: `ChatSession`, `SharedPreferences` -- Produces: `ChatStorageManager` with methods: `saveSessions(List)`, `loadSessions(): List`, `saveCurrentSessionId(String?)`, `loadCurrentSessionId(): String?` - -- [ ] **Step 1: Create ChatStorageManager** - -```kotlin -package com.itsaky.androidide.plugins.aiassistant.data - -import android.content.Context -import android.content.SharedPreferences -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import com.itsaky.androidide.plugins.aiassistant.models.ChatSession - -class ChatStorageManager(context: Context) { - private val prefs: SharedPreferences = - context.getSharedPreferences("ai_assistant_chats", Context.MODE_PRIVATE) - private val gson = Gson() - - companion object { - private const val KEY_SESSIONS = "chat_sessions" - private const val KEY_CURRENT_SESSION_ID = "current_session_id" - } - - fun saveSessions(sessions: List) { - val json = gson.toJson(sessions) - prefs.edit().putString(KEY_SESSIONS, json).apply() - } - - fun loadSessions(): List { - val json = prefs.getString(KEY_SESSIONS, null) ?: return emptyList() - val type = object : TypeToken>() {}.type - return try { - gson.fromJson(json, type) - } catch (e: Exception) { - emptyList() - } - } - - fun saveCurrentSessionId(sessionId: String?) { - prefs.edit().putString(KEY_CURRENT_SESSION_ID, sessionId).apply() - } - - fun loadCurrentSessionId(): String? { - return prefs.getString(KEY_CURRENT_SESSION_ID, null) - } -} -``` - -- [ ] **Step 2: Add Gson dependency** - -Add to `ai-assistant-plugin/build.gradle.kts` dependencies: - -```kotlin -implementation("com.google.code.gson:gson:2.10.1") -``` - -- [ ] **Step 3: Add persistence to ChatViewModel** - -Add after existing properties: - -```kotlin -private lateinit var storageManager: ChatStorageManager - -fun initializeStorage(context: android.content.Context) { - storageManager = ChatStorageManager(context) - loadSessions() -} - -fun loadSessions() { - val loaded = storageManager.loadSessions() - if (loaded.isEmpty()) { - createNewSession() - } else { - _sessions.value = loaded - val currentId = storageManager.loadCurrentSessionId() - val session = loaded.firstOrNull { it.id == currentId } ?: loaded.first() - switchToSession(session.id) - } -} - -private fun persistSessions() { - storageManager.saveSessions(_sessions.value) - storageManager.saveCurrentSessionId(_currentSessionId.value) -} - -override fun onCleared() { - super.onCleared() - persistSessions() - stopStateTimer() -} -``` - -- [ ] **Step 4: Update ChatFragment to initialize storage** - -Add to `onViewCreated`: - -```kotlin -if (!viewModel::storageManager.isInitialized) { - viewModel.initializeStorage(requireContext()) -} -``` - -- [ ] **Step 5: Test session persistence** - -Manual test: -1. Send a message in AI Agent -2. Close and reopen the app -3. Navigate to AI Agent tab -4. Expected: Previous conversation is restored - -- [ ] **Step 6: Commit** - -```bash -git add ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/data/ChatStorageManager.kt \ - ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt \ - ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt \ - ai-assistant-plugin/build.gradle.kts -git commit -m "feat: add session persistence with SharedPreferences - -- Create ChatStorageManager for saving/loading sessions -- Add Gson dependency for JSON serialization -- Implement initializeStorage and loadSessions in ChatViewModel -- Auto-save sessions on ViewModel cleanup -- Restore last session on app restart" -``` - ---- - -## Self-Review Checklist - -**Spec Coverage:** -- βœ… Chat session management (Task 1, 6) -- βœ… Tool execution tracking with timing (Task 2) -- βœ… Enhanced progress display with step count and timing (Task 3) -- βœ… Polished Material Design 3 UI (Task 4, 5) -- βœ… Backend status indicator (Task 4, 5) -- βœ… Experimental AI warning (Task 4) -- βœ… Session persistence (Task 6) - -**No Placeholders:** -- All code blocks contain complete implementation -- All file paths are exact and absolute -- All commands include expected output -- All test steps include verification criteria - -**Type Consistency:** -- `ChatSession` used consistently in Tasks 1 and 6 -- `ToolExecutionTracker` used in Task 2 -- `AgentState.Executing` enhanced in Task 3 -- `FragmentChatBinding` used in Task 5 - ---- - -Plan complete and saved to `/Users/john/Documents/cogo/CodeOnTheGo/docs/superpowers/plans/2026-06-25-enhance-ai-assistant-plugin-ui.md`. - -**Two execution options:** - -**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration - -**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints - -**Which approach?** diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt index c021171b79..2eed69f3d7 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt @@ -5,9 +5,9 @@ import com.itsaky.androidide.lsp.actions.CommentLineAction import com.itsaky.androidide.lsp.actions.IActionsMenuProvider import com.itsaky.androidide.lsp.actions.UncommentLineAction import com.itsaky.androidide.lsp.kotlin.actions.AddImportAction +import com.itsaky.androidide.lsp.kotlin.actions.OrganizeImportsAction object KotlinCodeActionsMenu : IActionsMenuProvider { - private const val KT_LANG = "kt" private val KT_EXTS = listOf("kt", "kts") private const val KT_LINE_COMMENT_TOKEN = "//" @@ -17,5 +17,6 @@ object KotlinCodeActionsMenu : IActionsMenuProvider { CommentLineAction(KT_LANG, KT_EXTS, KT_LINE_COMMENT_TOKEN), UncommentLineAction(KT_LANG, KT_EXTS, KT_LINE_COMMENT_TOKEN), AddImportAction(), + OrganizeImportsAction(), ) -} \ No newline at end of file +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt index 7ed6946e47..3f41f63179 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt @@ -30,6 +30,7 @@ import com.itsaky.androidide.eventbus.events.file.FileRenameEvent import com.itsaky.androidide.lsp.api.ILanguageClient import com.itsaky.androidide.lsp.api.ILanguageServer import com.itsaky.androidide.lsp.api.IServerSettings +import com.itsaky.androidide.lsp.kotlin.compiler.CompilationEnvironment import com.itsaky.androidide.lsp.kotlin.compiler.Compiler import com.itsaky.androidide.lsp.kotlin.compiler.KotlinProjectModel import com.itsaky.androidide.lsp.kotlin.compiler.index.KT_SOURCE_FILE_INDEX_KEY @@ -118,6 +119,9 @@ class KotlinLanguageServer : ILanguageServer { this.compiler?.updateLanguageClient(client) } + /** Returns the [CompilationEnvironment] responsible for [file], or null if the compiler is not ready. */ + internal fun compilationEnvironmentFor(file: Path): CompilationEnvironment? = compiler?.compilationEnvironmentFor(file) + override fun applySettings(settings: IServerSettings?) { this._settings = settings } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt new file mode 100644 index 0000000000..603bfaf6cc --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt @@ -0,0 +1,93 @@ +package com.itsaky.androidide.lsp.kotlin.actions + +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.get +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.lsp.kotlin.KotlinLanguageServer +import com.itsaky.androidide.lsp.kotlin.compiler.AbstractCompilationEnvironment +import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.utils.collectImportUsage +import com.itsaky.androidide.lsp.kotlin.utils.organizedImportBlock +import com.itsaky.androidide.lsp.kotlin.utils.toRange +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.models.TextEdit +import com.itsaky.androidide.models.Range +import com.itsaky.androidide.resources.R +import org.slf4j.LoggerFactory +import java.nio.file.Path + +class OrganizeImportsAction : BaseKotlinCodeAction() { + override var titleTextRes: Int = R.string.action_organize_imports + override val id: String = "ide.editor.lsp.kt.organizeImports" + override var label: String = "" + + companion object { + private val logger = LoggerFactory.getLogger(OrganizeImportsAction::class.java) + } + + override suspend fun execAction(data: ActionData): List { + val server = data.get() ?: return emptyList() + val nioPath = data.requireFile().toPath() + val env = server.compilationEnvironmentFor(nioPath) ?: return emptyList() + return computeOrganizeEdit(env, nioPath) + } + + /** + * Computes the text edits that organize the imports of the file at [nioPath] within [env]. + * The current [org.jetbrains.kotlin.psi.KtFile] is fetched BEFORE entering [read] (deadlock + * rule: never block on `getCurrentKtFile(...).get()` inside `project.read`). Returns an empty + * list when there is nothing to do (no imports, already organized, or no usable range) *and* + * whenever anything in this pipeline (the `.get()`, analysis, or PSI access) throws: the action + * framework only catches [IllegalArgumentException] and this runs on a coroutine scope with no + * exception handler, so an uncaught throw here would crash the app. Degrading to zero edits is + * always safe -- it just leaves the imports as-is, never produces a partial/incorrect rewrite. + */ + internal fun computeOrganizeEdit( + env: AbstractCompilationEnvironment, + nioPath: Path, + ): List = + runCatching { + val ktFile = env.ktSymbolIndex.getCurrentKtFile(nioPath).get() ?: return emptyList() + if (ktFile.importDirectives.isEmpty()) return emptyList() + env.project.read { + val usage = analyzeMaybeDangling(ktFile) { collectImportUsage(ktFile) } + val newText = organizedImportBlock(ktFile, usage) ?: return@read emptyList() + val range = ktFile.importList?.textRange?.toRange(ktFile) ?: return@read emptyList() + if (range == Range.NONE) return@read emptyList() + listOf(TextEdit(range, newText)) + } + }.getOrElse { e -> + logger.warn("Failed to organize imports", e) + emptyList() + } + + override fun postExec( + data: ActionData, + result: Any, + ) { + super.postExec(data, result) + if (result !is List<*> || result.isEmpty()) return + + @Suppress("UNCHECKED_CAST") + result as List + + val client = + data.languageClient ?: run { + logger.warn("No language client set. Cannot organize imports.") + return + } + val file = data.requireFile() + client.performCodeAction( + CodeActionItem( + title = label, + changes = listOf(DocumentChange(file = file.toPath(), edits = result)), + kind = CodeActionKind.QuickFix, + command = Command("", ""), // no post-action command (no CMD_FORMAT_CODE) + ), + ) + } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt new file mode 100644 index 0000000000..0e261dc2fc --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt @@ -0,0 +1,102 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.kdoc.psi.api.KDoc +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtImportDirective + +/** + * What a file's body actually uses, expressed as plain strings so nothing crosses an `analyze` + * lifetime boundary. Produced by [collectImportUsage]. + * + * @property usedFqNames importable fully-qualified names referenced by the body. + * @property usedPackages parent packages/objects of used symbols (for wildcard matching). + * @property unresolvedNames short names of body references that failed to resolve; an import + * matching one of these is kept, since a resolution failure can't prove the import unused. + */ +internal data class ImportUsage( + val usedFqNames: Set, + val usedPackages: Set, + val unresolvedNames: Set = emptySet(), +) + +/** JVM packages that Kotlin imports with a wildcard by default; explicit named imports from these are redundant. */ +internal val DEFAULT_STAR_PACKAGES: Set = + setOf( + "kotlin", + "kotlin.annotation", + "kotlin.collections", + "kotlin.comparisons", + "kotlin.io", + "kotlin.ranges", + "kotlin.sequences", + "kotlin.text", + "kotlin.jvm", + "java.lang", + ) + +private val KDOC_LINK = Regex("""\[([^\]\s]+)]""") + +/** + * Computes the canonical import block for [ktFile] given [usage]: unused/redundant imports removed, + * survivors deduped and lexicographically sorted. Returns null when the imports are already in that + * exact form (no edit needed). The returned text has no surrounding newlines. + */ +internal fun organizedImportBlock( + ktFile: KtFile, + usage: ImportUsage, +): String? { + val directives = ktFile.importDirectives + if (directives.isEmpty()) return null + + val filePackage = ktFile.packageFqName.asString() + val kdocNames = collectKDocLinkNames(ktFile) + + val newLines = + directives + .filter { keepImport(it, usage, filePackage, kdocNames) } + .mapNotNull { it.importPath?.let { path -> "import $path" } } + .distinct() + .sorted() + + val currentLines = directives.mapNotNull { it.importPath?.let { path -> "import $path" } } + + if (newLines == currentLines) return null + return newLines.joinToString(System.lineSeparator()) +} + +private fun keepImport( + directive: KtImportDirective, + usage: ImportUsage, + filePackage: String, + kdocNames: Set, +): Boolean { + val fqName = directive.importedFqName ?: return true // malformed import -> keep (conservative) + val fqNameStr = fqName.asString() + val alias = directive.aliasName + val shortName = alias ?: fqName.shortName().asString() + + // Conservative: keep anything referenced by short name/alias in a KDoc link. + if (shortName in kdocNames) return true + + // Conservative: an unresolved body reference by this short name can't prove the import dead. + if (shortName in usage.unresolvedNames) return true + + if (directive.isAllUnder) { + // Wildcard: keep iff some used symbol lives in this package/object. + return fqNameStr in usage.usedPackages + } + + val parentPackage = fqName.parent().asString() + // Redundant named imports (only when not aliased β€” an alias is meaningful). + if (alias == null && parentPackage in DEFAULT_STAR_PACKAGES) return false + if (alias == null && parentPackage == filePackage) return false + + return fqNameStr in usage.usedFqNames +} + +private fun collectKDocLinkNames(ktFile: KtFile): Set = + PsiTreeUtil + .collectElementsOfType(ktFile, KDoc::class.java) + .flatMap { kdoc -> KDOC_LINK.findAll(kdoc.text).map { it.groupValues[1].substringAfterLast('.') } } + .toSet() diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt new file mode 100644 index 0000000000..cacdb775fe --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt @@ -0,0 +1,105 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.resolution.successfulFunctionCallOrNull +import org.jetbrains.kotlin.analysis.api.resolution.symbol +import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol +import org.jetbrains.kotlin.idea.references.mainReference +import org.jetbrains.kotlin.psi.KtArrayAccessExpression +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtDestructuringDeclaration +import org.jetbrains.kotlin.psi.KtDestructuringDeclarationEntry +import org.jetbrains.kotlin.psi.KtElement +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtForExpression +import org.jetbrains.kotlin.psi.KtImportList +import org.jetbrains.kotlin.psi.KtNameReferenceExpression +import org.jetbrains.kotlin.psi.KtOperationReferenceExpression +import org.jetbrains.kotlin.psi.KtPropertyDelegate +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType +import org.jetbrains.kotlin.psi.psiUtil.getParentOfType + +/** + * Collects the importable fq-names (and their packages) referenced by [ktFile]'s body. MUST be + * called inside [analyzeMaybeDangling]. Returns only plain strings, so nothing escapes the analyze + * lifetime. A reference that fails to resolve doesn't join the used set; instead its short name is + * recorded in [ImportUsage.unresolvedNames] so its import is kept. Both paths are safe: they lead to + * keeping an import, never removing a used one. + */ +internal fun KaSession.collectImportUsage(ktFile: KtFile): ImportUsage { + val usedFqNames = HashSet() + val usedPackages = HashSet() + val unresolvedNames = HashSet() + + fun record(symbol: KaSymbol?) { + val fq = symbol?.importableFqNameString() ?: return + usedFqNames += fq + val pkg = fq.substringBeforeLast('.', missingDelimiterValue = "") + if (pkg.isNotEmpty()) usedPackages += pkg + } + + fun recordAll(symbols: Collection?) { + symbols?.forEach(::record) + } + + // 1) Plain name / type references (excluding the import list itself). A null (or thrown) + // resolution is treated as unresolved and its short name kept, so a used-but-unresolvable + // reference never drops its import. A non-null, non-importable symbol (local, param) is a + // clean resolve: it records nothing and is not unresolved. + ktFile.collectDescendantsOfType().forEach { ref -> + if (ref.getParentOfType(strict = false) != null) return@forEach + val symbol = runCatching { ref.mainReference.resolveToSymbol() }.getOrNull() + if (symbol != null) record(symbol) else unresolvedNames += ref.getReferencedName() + } + + // 1b) Implicit-convention references that carry more than one resolution target and so don't + // resolve through `resolveToSymbol()` (singular; returns null when ambiguous) but do resolve + // through `resolveToSymbols()` (plural). Confirmed empirically: + // - KtForExpression: resolves to [iterator(), hasNext(), next()] -- iterator is the + // user-importable one for a `for (x in foo)` loop. + // - KtDestructuringDeclarationEntry (one per destructured variable): resolves to that + // variable's own componentN() symbol. + // - KtPropertyDelegate: resolves to the delegate's getValue()/setValue() symbol(s). + // Recording every returned symbol is safe: extra (e.g. stdlib Iterator.next) symbols only ever + // keep an import, never drop a used one. + ktFile.collectDescendantsOfType().forEach { forExpr -> + runCatching { recordAll(forExpr.mainReference?.resolveToSymbols()) } + } + ktFile.collectDescendantsOfType().forEach { entry -> + runCatching { recordAll(entry.mainReference?.resolveToSymbols()) } + } + ktFile.collectDescendantsOfType().forEach { delegate -> + runCatching { recordAll(delegate.mainReference?.resolveToSymbols()) } + } + + // 2) Convention / operator call sites (no textual name reference). + ktFile.collectDescendantsOfType().forEach { element -> + val isConvention = + element is KtOperationReferenceExpression || + element is KtArrayAccessExpression || + element is KtCallExpression || + element is KtForExpression || + element is KtDestructuringDeclaration || + element is KtPropertyDelegate + if (!isConvention) return@forEach + runCatching { + record(element.resolveToCall()?.successfulFunctionCallOrNull()?.symbol) + } + } + + return ImportUsage(usedFqNames, usedPackages, unresolvedNames) +} + +private fun KaSymbol.importableFqNameString(): String? = + when (this) { + // A constructor's own callableId is null, so it must map to its containing class -- the name + // that's actually imported. Covers `Foo()` calls and `@Foo` annotations (both resolve to the + // constructor). Must precede the KaCallableSymbol branch, which a constructor also matches. + is KaConstructorSymbol -> containingClassId?.asSingleFqName()?.asString() + is KaClassLikeSymbol -> classId?.asSingleFqName()?.asString() + is KaCallableSymbol -> callableId?.asSingleFqName()?.asString() + else -> null + } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt new file mode 100644 index 0000000000..c39f5c37ad --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt @@ -0,0 +1,183 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ImportOrganizerTest : KtLspTest() { + private fun organize( + content: String, + usage: ImportUsage, + ): String? { + val ktFile = createSourceFile("Sample.kt", content) + return env.project.read { organizedImportBlock(ktFile, usage) } + } + + @Test + fun `removes unused named import and keeps used one`() { + val result = + organize( + """ + package p + import a.b.Used + import a.b.Unused + fun f(x: Used) {} + """.trimIndent(), + ImportUsage(usedFqNames = setOf("a.b.Used"), usedPackages = setOf("a.b")), + ) + assertEquals("import a.b.Used", result) + } + + @Test + fun `sorts imports lexicographically`() { + val result = + organize( + """ + package p + import a.b.Zebra + import a.b.Apple + fun f(x: Zebra, y: Apple) {} + """.trimIndent(), + ImportUsage(setOf("a.b.Zebra", "a.b.Apple"), setOf("a.b")), + ) + assertEquals("import a.b.Apple${'\n'}import a.b.Zebra", result) + } + + @Test + fun `keeps used wildcard, removes unused wildcard`() { + val result = + organize( + """ + package p + import used.pkg.* + import unused.pkg.* + fun f(x: Thing) {} + """.trimIndent(), + ImportUsage(usedFqNames = setOf("used.pkg.Thing"), usedPackages = setOf("used.pkg")), + ) + assertEquals("import used.pkg.*", result) + } + + @Test + fun `removes default-import-redundant named import`() { + val result = + organize( + """ + package p + import kotlin.collections.List + import a.b.Used + fun f(x: Used) {} + """.trimIndent(), + // even though List resolves, it is redundant (default star package) + ImportUsage(setOf("a.b.Used", "kotlin.collections.List"), setOf("a.b", "kotlin.collections")), + ) + assertEquals("import a.b.Used", result) + } + + @Test + fun `removes same-package redundant import`() { + val result = + organize( + """ + package p + import p.Sibling + import a.b.Used + fun f(x: Used, y: Sibling) {} + """.trimIndent(), + ImportUsage(setOf("a.b.Used", "p.Sibling"), setOf("a.b", "p")), + ) + assertEquals("import a.b.Used", result) + } + + @Test + fun `keeps aliased import from default package`() { + val result = + organize( + """ + package p + import kotlin.collections.List as KList + import x.y.Unused + fun f(x: KList) {} + """.trimIndent(), + ImportUsage(setOf("kotlin.collections.List"), setOf("kotlin.collections")), + ) + assertEquals("import kotlin.collections.List as KList", result) + } + + @Test + fun `keeps import referenced only in KDoc`() { + val result = + organize( + """ + package p + import a.b.DocOnly + import x.y.Unused + /** See [DocOnly] for details. */ + fun f() {} + """.trimIndent(), + ImportUsage(usedFqNames = emptySet(), usedPackages = emptySet()), + ) + assertEquals("import a.b.DocOnly", result) + } + + @Test + fun `keeps import matching an unresolved reference`() { + val result = + organize( + """ + package p + import a.b.Mystery + import x.y.Unused + fun f(m: Mystery) {} + """.trimIndent(), + // Mystery didn't resolve, so it's absent from usedFqNames but present as unresolved. + ImportUsage(usedFqNames = emptySet(), usedPackages = emptySet(), unresolvedNames = setOf("Mystery")), + ) + assertEquals("import a.b.Mystery", result) + } + + @Test + fun `collapses exact duplicate imports`() { + val result = + organize( + """ + package p + import a.b.Used + import a.b.Used + fun f(x: Used) {} + """.trimIndent(), + ImportUsage(setOf("a.b.Used"), setOf("a.b")), + ) + assertEquals("import a.b.Used", result) + } + + @Test + fun `returns null when already organized`() { + val result = + organize( + """ + package p + import a.b.Apple + import a.b.Zebra + fun f(x: Apple, y: Zebra) {} + """.trimIndent(), + ImportUsage(setOf("a.b.Apple", "a.b.Zebra"), setOf("a.b")), + ) + assertNull(result) + } + + @Test + fun `no imports returns null`() { + val result = + organize( + """ + package p + fun f() {} + """.trimIndent(), + ImportUsage(emptySet(), emptySet()), + ) + assertNull(result) + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt new file mode 100644 index 0000000000..c63f8ba098 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt @@ -0,0 +1,281 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.jetbrains.kotlin.psi.KtFile +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ImportUsageCollectorTest : KtLspTest() { + private fun usageOf(ktFile: KtFile): ImportUsage = env.project.read { analyzeMaybeDangling(ktFile) { collectImportUsage(ktFile) } } + + @Test + fun `type reference is recorded as used`() { + val ktFile = + createSourceFile( + "UseType.kt", + """ + package p + fun f(): java.io.File? = null + fun g() { val x: java.io.File = java.io.File("a") } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("java.io.File" in usage.usedFqNames) + assertTrue("java.io" in usage.usedPackages) + } + + @Test + fun `top-level function call is recorded as used`() { + createSourceFile( + "lib/Lib.kt", + """ + package lib + fun topLevelHelper() {} + """.trimIndent(), + ) + val ktFile = + createSourceFile( + "UseFn.kt", + """ + package p + import lib.topLevelHelper + fun f() { topLevelHelper() } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.topLevelHelper" in usage.usedFqNames) + } + + @Test + fun `operator function used via symbol is recorded`() { + createSourceFile( + "lib/Ops.kt", + """ + package lib + class Money + operator fun Money.plus(other: Money): Money = this + """.trimIndent(), + ) + val ktFile = + createSourceFile( + "UseOp.kt", + """ + package p + import lib.Money + import lib.plus + fun f(a: Money, b: Money) { val c = a + b } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.plus" in usage.usedFqNames) + } + + @Test + fun `unreferenced import is absent from usage`() { + createSourceFile( + "lib/Extra.kt", + """ + package lib + class Extra + """.trimIndent(), + ) + val ktFile = + createSourceFile( + "NoUse.kt", + """ + package p + import lib.Extra + fun f() {} + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertFalse("lib.Extra" in usage.usedFqNames) + } + + @Test + fun `unresolved reference is recorded by short name, not as used`() { + val ktFile = + createSourceFile( + "Unresolved.kt", + """ + package p + import a.b.Mystery + fun f(m: Mystery) {} + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertFalse("a.b.Mystery" in usage.usedFqNames) + assertTrue("Mystery" in usage.unresolvedNames) + } + + @Test + fun `array-access get operator used via subscript is recorded`() { + createSourceFile( + "lib/Ops.kt", + """ + package lib + class Foo + operator fun Foo.get(i: Int): Int = i + """.trimIndent(), + ) + val ktFile = + createSourceFile( + "UseGet.kt", + """ + package p + import lib.Foo + import lib.get + fun f(foo: Foo) { val x = foo[0] } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.get" in usage.usedFqNames) + } + + @Test + fun `array-access set operator used via subscript assignment is recorded`() { + createSourceFile( + "lib/Ops.kt", + """ + package lib + class Foo + operator fun Foo.set(i: Int, v: Int) {} + """.trimIndent(), + ) + val ktFile = + createSourceFile( + "UseSet.kt", + """ + package p + import lib.Foo + import lib.set + fun f(foo: Foo) { foo[0] = 1 } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.set" in usage.usedFqNames) + } + + @Test + fun `iterator operator used via for loop is recorded`() { + createSourceFile( + "lib/Ops.kt", + """ + package lib + class Foo + operator fun Foo.iterator(): Iterator = listOf(1, 2, 3).iterator() + """.trimIndent(), + ) + val ktFile = + createSourceFile( + "UseIterator.kt", + """ + package p + import lib.Foo + import lib.iterator + fun f(foo: Foo) { for (x in foo) {} } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.iterator" in usage.usedFqNames) + } + + @Test + fun `componentN operators used via destructuring are recorded`() { + createSourceFile( + "lib/Ops.kt", + """ + package lib + class Foo + operator fun Foo.component1(): Int = 1 + operator fun Foo.component2(): Int = 2 + """.trimIndent(), + ) + val ktFile = + createSourceFile( + "UseDestructure.kt", + """ + package p + import lib.Foo + import lib.component1 + import lib.component2 + fun f(foo: Foo) { val (a, b) = foo } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.component1" in usage.usedFqNames) + assertTrue("lib.component2" in usage.usedFqNames) + } + + @Test + fun `constructor-only reference records the class as used`() { + createSourceFile( + "lib/Widget.kt", + """ + package lib + class Widget(val n: Int) + """.trimIndent(), + ) + val ktFile = + createSourceFile( + "UseCtor.kt", + """ + package p + import lib.Widget + fun f() { val w = Widget(1) } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.Widget" in usage.usedFqNames) + } + + @Test + fun `annotation-only reference records the annotation class as used`() { + createSourceFile( + "lib/Marker.kt", + """ + package lib + annotation class Marker + """.trimIndent(), + ) + val ktFile = + createSourceFile( + "UseAnnotation.kt", + """ + package p + import lib.Marker + @Marker fun f() {} + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.Marker" in usage.usedFqNames) + } + + @Test + fun `getValue operator used via property delegation is recorded`() { + createSourceFile( + "lib/Ops.kt", + """ + package lib + import kotlin.reflect.KProperty + class Foo + operator fun Foo.getValue(thisRef: Any?, property: KProperty<*>): Int = 1 + """.trimIndent(), + ) + val ktFile = + createSourceFile( + "UseDelegate.kt", + """ + package p + import lib.Foo + import lib.getValue + fun f(foo: Foo) { val x: Int by foo } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.getValue" in usage.usedFqNames) + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt new file mode 100644 index 0000000000..b695c58dc9 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt @@ -0,0 +1,115 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import com.itsaky.androidide.lsp.kotlin.actions.OrganizeImportsAction +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import com.itsaky.androidide.models.Position +import com.itsaky.androidide.models.Range +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class OrganizeImportsEndToEndTest : KtLspTest() { + @Test + fun `removes unused import end to end`() { + createSourceFile( + "lib/Lib.kt", + """ + package lib + class Used + class Unused + """.trimIndent(), + ) + createSourceFile( + "Main.kt", + """ + package p + import lib.Used + import lib.Unused + fun f(x: Used) {} + """.trimIndent(), + ) + val mainPath = env.sourceRoots.first().resolve("Main.kt") + + // Drive the action's real plumbing: fetch-before-read ordering + full guard chain. + val edits = OrganizeImportsAction().computeOrganizeEdit(env, mainPath) + + assertEquals(1, edits.size) + assertEquals("import lib.Used", edits.single().newText) + + // The import list in the fixture spans exactly line 1 col 0 ("import lib.Used") through + // line 2 col 17 (end of "import lib.Unused"); line 0 is "package p". A wrong/off-by-one + // range here would silently corrupt the file when the client applies this edit. + assertEquals( + Range(Position(1, 0), Position(2, 17)), + edits.single().range, + ) + } + + @Test + fun `keeps constructor-only import`() { + createSourceFile( + "lib/Lib.kt", + """ + package lib + class Widget(val n: Int) + """.trimIndent(), + ) + createSourceFile( + "Main.kt", + """ + package p + import lib.Widget + fun f() { val w = Widget(1) } + """.trimIndent(), + ) + val mainPath = env.sourceRoots.first().resolve("Main.kt") + val edits = OrganizeImportsAction().computeOrganizeEdit(env, mainPath) + // Already organized -> no edit. A dropped import would produce a rewrite that removes it. + assertTrue("constructor-only import must survive", edits.isEmpty()) + } + + @Test + fun `keeps annotation-only import`() { + createSourceFile( + "lib/Lib.kt", + """ + package lib + annotation class Marker + """.trimIndent(), + ) + createSourceFile( + "Main.kt", + """ + package p + import lib.Marker + @Marker fun f() {} + """.trimIndent(), + ) + val mainPath = env.sourceRoots.first().resolve("Main.kt") + val edits = OrganizeImportsAction().computeOrganizeEdit(env, mainPath) + assertTrue("annotation-only import must survive", edits.isEmpty()) + } + + @Test + fun `keeps typealias-only import used as constructor`() { + createSourceFile( + "lib/Lib.kt", + """ + package lib + class RealList + typealias Alias = RealList + """.trimIndent(), + ) + createSourceFile( + "Main.kt", + """ + package p + import lib.Alias + fun f() { val a = Alias() } + """.trimIndent(), + ) + val mainPath = env.sourceRoots.first().resolve("Main.kt") + val edits = OrganizeImportsAction().computeOrganizeEdit(env, mainPath) + assertTrue("typealias-only import used as constructor must survive", edits.isEmpty()) + } +}