Compare commits

..

36 Commits

Author SHA1 Message Date
Abdelilah El Aissaoui
edcefd7a38 Changed version to 1.4.1 2024-11-03 13:23:32 +01:00
Abdelilah El Aissaoui
01b0827057 Fixed commit message disappear if pre-commit hook fails
Fixes #248
2024-11-02 23:53:07 +01:00
Abdelilah El Aissaoui
db1467c354
Having "cache" or "store" in credentials manager config does not longer throw an error 2024-10-31 01:37:49 +01:00
Abdelilah El Aissaoui
07e98dae10
Errors dialog can now be closed with ESC button 2024-10-31 01:37:22 +01:00
Abdelilah El Aissaoui
644f33ff2c
Fixed gitconfig not updating symlink origin instead of replacing symlink file
Fixes #245
2024-10-31 01:36:22 +01:00
Abdelilah El Aissaoui
3128341b93
Fixed crash when watch init has failed 2024-10-22 21:15:56 +02:00
Abdelilah El Aissaoui
1ef596ad3c
Updated latest.json for v1.4.0 2024-10-06 02:44:32 +02:00
Abdelilah El Aissaoui
4cc87a7289
Bumped version to 1.4.0 2024-10-01 20:35:09 +02:00
Abdelilah El Aissaoui
fcecd0380a
Merge pull request #239 from grizeldi/patch-1
Mention cargo-kotars in the development guide
2024-09-30 21:37:01 +02:00
grizeldi
fc2098781b Added automatic cargo-kotars install command to documentation 2024-09-28 20:21:05 +02:00
grizeldi
ce694ed44b
Mention cargo-kotars in the development guide 2024-09-27 23:32:20 +02:00
Abdelilah El Aissaoui
e5899d02d6
Fixed pull result not being shown properly
Also fixed pull from specific branch using  merge by default instead of the user's configuration
2024-09-19 00:29:53 +02:00
Abdelilah El Aissaoui
169ed5af3f
Added warning when rebase has conflicts or has stopped 2024-09-17 23:40:24 +02:00
Abdelilah El Aissaoui
712e513c2e
Changed gitnuro version to 1.4.0-rc01 2024-09-14 20:01:00 +02:00
Abdelilah El Aissaoui
02a1b47eeb
Disabled MacOS build until fixed (+ out of CI minutes...) 2024-09-14 19:59:43 +02:00
Abdelilah El Aissaoui
28a465441e
Changed rust build to be release by default 2024-09-14 19:57:39 +02:00
Abdelilah El Aissaoui
aab0e01a30
Open repository keybinding can also be used in welcome page to open file explorer 2024-09-14 15:50:08 +02:00
Abdelilah El Aissaoui
fb542c9677
Welcome page focus is set to the whole page if there aren't recent repositories 2024-09-14 15:49:44 +02:00
Abdelilah El Aissaoui
e9c3e25d79
Fixed settings label 2024-09-14 15:26:04 +02:00
Abdelilah El Aissaoui
4423e47019
Added tabs management keybindings 2024-09-14 15:25:04 +02:00
Abdelilah El Aissaoui
1b4b75d75b
Refactored RepositoryOpenViewModel to fix file system watch not working 2024-09-14 15:04:24 +02:00
Abdelilah El Aissaoui
8b17d68db4
Refactored getting author info 2024-09-14 15:03:20 +02:00
Abdelilah El Aissaoui
87399eccf4
Added set the tracking branch when pushing a new branch 2024-09-14 01:19:13 +02:00
Abdelilah El Aissaoui
c92ccf1f6d
Added keybinding to show open dialog 2024-09-12 11:11:06 +02:00
Abdelilah El Aissaoui
ef367e1d89
Fixed theme not being changed properly until recomposition + reduced themes saturation 2024-09-12 10:02:59 +02:00
Abdelilah El Aissaoui
0f5e4248b9
Added focus restoration when closing commit changes search 2024-09-11 10:44:23 +02:00
Abdelilah El Aissaoui
e38746cd0e
Moved compose to 1.7.0-beta02 2024-09-11 09:56:34 +02:00
Abdelilah El Aissaoui
dc5efb25aa
Fixed double click lambda not being refreshed after changing tabs
The issue would be specially present when clicking on the side panel and changing tabs, if the user tried to change a branch, it would be changed in the repository of the previous.
2024-09-10 18:57:37 +02:00
Abdelilah El Aissaoui
e5a84705e9
Improved focus handling in the whole tab 2024-09-09 01:49:26 +02:00
Abdelilah El Aissaoui
51bcedc828
Moved to compose 1.7.0-beta01 2024-09-09 00:39:48 +02:00
Abdelilah El Aissaoui
03e94d0ce9
Fixed refresh not working after changing tabs 2024-09-09 00:36:45 +02:00
Abdelilah El Aissaoui
7b5290aef8
Fixed crash when changing tabs 2024-09-09 00:32:06 +02:00
Abdelilah El Aissaoui
1af4542c7c
Implemented close view with ESC 2024-09-09 00:32:06 +02:00
Abdelilah El Aissaoui
355cbc3f79
Code refactoring 2024-09-09 00:32:06 +02:00
Abdelilah El Aissaoui
a9a35b304a
Removed unused parameter 2024-09-09 00:32:06 +02:00
Abdelilah El Aissaoui
422b431fdf Fixed tree view on windows
Fixes #235
2024-09-06 13:33:07 +02:00
58 changed files with 1417 additions and 868 deletions

View File

@ -124,42 +124,42 @@ jobs:
Output/Gitnuro*.zip
token: ${{ secrets.CUSTOM_GITHUB_TOKEN }}
build_macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: nightly
- run: cargo install cargo-kotars --git https://github.com/JetpackDuba/kotars
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'corretto'
architecture: x64
- name: Build with Gradle
uses: gradle/gradle-build-action@67421db6bd0bf253fb4bd25b31ebb98943c375e1
with:
arguments: createDistributable
- name: Create output directory
run: mkdir Output
- name: MacOS DMG
working-directory: build/compose/binaries/main/app/
run: zip -r ../../../../../Output/Gitnuro_macos_${{github.ref_name}}.zip .
- name: Generate SHA256 Checksum
working-directory: ./Output/
run: find . -type f -exec bash -c "shasum -a 256 {} > {}.sum " \;
- name: Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
body: "Beta release"
prerelease: true
draft: true
repository: JetpackDuba/Gitnuro
with:
files: |
Output/Gitnuro*.zip
Output/Gitnuro*.sum
token: ${{ secrets.CUSTOM_GITHUB_TOKEN }}
# build_macos:
# runs-on: macos-latest
# steps:
# - uses: actions/checkout@v3
# - uses: dtolnay/rust-toolchain@stable
# with:
# toolchain: nightly
# - run: cargo install cargo-kotars --git https://github.com/JetpackDuba/kotars
# - name: Set up JDK 17
# uses: actions/setup-java@v3
# with:
# java-version: '17'
# distribution: 'corretto'
# architecture: x64
# - name: Build with Gradle
# uses: gradle/gradle-build-action@67421db6bd0bf253fb4bd25b31ebb98943c375e1
# with:
# arguments: createDistributable
# - name: Create output directory
# run: mkdir Output
# - name: MacOS DMG
# working-directory: build/compose/binaries/main/app/
# run: zip -r ../../../../../Output/Gitnuro_macos_${{github.ref_name}}.zip .
# - name: Generate SHA256 Checksum
# working-directory: ./Output/
# run: find . -type f -exec bash -c "shasum -a 256 {} > {}.sum " \;
# - name: Release
# uses: softprops/action-gh-release@v2
# if: startsWith(github.ref, 'refs/tags/')
# with:
# body: "Beta release"
# prerelease: true
# draft: true
# repository: JetpackDuba/Gitnuro
# with:
# files: |
# Output/Gitnuro*.zip
# Output/Gitnuro*.sum
# token: ${{ secrets.CUSTOM_GITHUB_TOKEN }}

View File

@ -8,6 +8,8 @@
environment,
please read [its documentation](https://www.rust-lang.org/). `cargo` and `rustc` must be available in the path in
order to build Gitnuro properly.
- **cargo-kotars:** A tool to run autogenerated bindings from Rust to Kotlin. You can install it using
`cargo install cargo-kotars --git https://github.com/JetpackDuba/kotars`
- **Perl:** Perl is required to build openssl (which is required for LibSSH to work).
- **Packages for Linux ARM64/aarch64**: You need to install the `aarch64-linux-gnu-gcc` package to cross compile the
Rust components to ARM from x86_64. You will also need to use `rustup` to add a new

View File

@ -12,17 +12,17 @@ plugins {
kotlin("jvm") version "2.0.0"
kotlin("plugin.serialization") version "2.0.20"
id("com.google.devtools.ksp") version "2.0.20-1.0.24"
id("org.jetbrains.compose") version "1.7.0-beta01+build1790-release-1.7.0-beta01"
id("org.jetbrains.compose") version "1.7.0"
id("org.jetbrains.kotlin.plugin.compose") version "2.0.20"
}
// Remember to update Constants.APP_VERSION when changing this version
val projectVersion = "1.4.0-beta01"
val projectVersion = "1.4.1"
val projectName = "Gitnuro"
// Required for JPackage, as it doesn't accept additional suffixes after the version.
val projectVersionSimplified = "1.4.0"
val projectVersionSimplified = "1.4.1"
val rustGeneratedSource = "${layout.buildDirectory.get()}/generated/source/uniffi/main/com/jetpackduba/gitnuro/java"

View File

@ -1,4 +1,4 @@
kotlin.code.style=official
isLinuxAarch64=false
useCross=false
isRustRelease=false
isRustRelease=true

View File

@ -1,5 +1,5 @@
{
"appVersion": "1.3.1",
"appCode": 11,
"downloadUrl": "https://github.com/JetpackDuba/Gitnuro/releases/tag/v1.3.1"
"appVersion": "1.4.0",
"appCode": 14,
"downloadUrl": "https://github.com/JetpackDuba/Gitnuro/releases/tag/v1.4.0"
}

View File

@ -12,6 +12,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Density
@ -25,6 +26,8 @@ import com.jetpackduba.gitnuro.di.DaggerAppComponent
import com.jetpackduba.gitnuro.extensions.preferenceValue
import com.jetpackduba.gitnuro.extensions.toWindowPlacement
import com.jetpackduba.gitnuro.git.AppGpgSigner
import com.jetpackduba.gitnuro.keybindings.KeybindingOption
import com.jetpackduba.gitnuro.keybindings.matchesBinding
import com.jetpackduba.gitnuro.logging.printError
import com.jetpackduba.gitnuro.managers.AppStateManager
import com.jetpackduba.gitnuro.managers.TempFilesManager
@ -271,7 +274,38 @@ class App {
if (currentTab != null) {
Column(
modifier = Modifier.background(MaterialTheme.colors.background)
modifier = Modifier
.background(MaterialTheme.colors.background)
.onPreviewKeyEvent {
when {
it.matchesBinding(KeybindingOption.OPEN_NEW_TAB) -> {
tabsManager.addNewEmptyTab()
true
}
it.matchesBinding(KeybindingOption.CLOSE_CURRENT_TAB) -> {
tabsManager.closeTab(currentTab)
true
}
it.matchesBinding(KeybindingOption.CHANGE_CURRENT_TAB_LEFT) -> {
val tabToSelect = tabs.getOrNull(tabs.indexOf(currentTab) - 1)
if (tabToSelect != null) {
tabsManager.selectTab(tabToSelect)
}
true
}
it.matchesBinding(KeybindingOption.CHANGE_CURRENT_TAB_RIGHT) -> {
val tabToSelect = tabs.getOrNull(tabs.indexOf(currentTab) + 1)
if (tabToSelect != null) {
tabsManager.selectTab(tabToSelect)
}
true
}
else -> false
}
}
) {
Tabs(
tabsInformationList = tabs,
@ -284,7 +318,7 @@ class App {
}
)
TabContent(tabs, currentTab)
TabContent(currentTab)
}
}
}
@ -337,7 +371,7 @@ class App {
}
@Composable
private fun TabContent(tabs: List<TabInformation>, currentTab: TabInformation?) {
private fun TabContent(currentTab: TabInformation?) {
Box(
modifier = Modifier
.background(MaterialTheme.colors.background)

View File

@ -22,8 +22,8 @@ object AppConstants {
const val APP_NAME = "Gitnuro"
const val APP_DESCRIPTION =
"Gitnuro is a Git client that allows you to manage multiple repositories with a modern experience and live visual representation of your repositories' state."
const val APP_VERSION = "1.4.0-beta01"
const val APP_VERSION_CODE = 12
const val APP_VERSION = "1.4.1"
const val APP_VERSION_CODE = 15
const val VERSION_CHECK_URL = "https://raw.githubusercontent.com/JetpackDuba/Gitnuro/main/latest.json"
}

View File

@ -2,6 +2,7 @@ package com.jetpackduba.gitnuro.credentials
import com.jetpackduba.gitnuro.exceptions.NotSupportedHelper
import com.jetpackduba.gitnuro.git.remote_operations.CredentialsCache
import com.jetpackduba.gitnuro.logging.printError
import com.jetpackduba.gitnuro.logging.printLog
import com.jetpackduba.gitnuro.managers.IShellManager
import com.jetpackduba.gitnuro.repositories.AppSettingsRepository
@ -259,7 +260,8 @@ class HttpCredentialsProvider @AssistedInject constructor(
val credentialHelperPath = uriSpecificCredentialHelper ?: genericCredentialHelper ?: return null
if (credentialHelperPath == "cache" || credentialHelperPath == "store") {
throw NotSupportedHelper("Invalid credentials helper: \"$credentialHelperPath\" is not yet supported")
printError(TAG, "Invalid credentials helper: \"$credentialHelperPath\" is not yet supported")
return null
}
// TODO Try to use "git-credential-manager-core" when "manager-core" is detected. Works for linux but requires testing for mac/windows

View File

@ -1,3 +0,0 @@
package com.jetpackduba.gitnuro.exceptions
class ConflictsException(message: String) : GitnuroException(message)

View File

@ -1,11 +1,6 @@
package com.jetpackduba.gitnuro.exceptions
class WatcherInitException(
code: Int,
message: String = codeToMessage(code),
) : GitnuroException(message)
private fun codeToMessage(code: Int): String {
fun codeToMessage(code: Int): String {
return when (code) {
1 /*is WatcherInitException.Generic*/, 2 /*is WatcherInitException.Io*/ -> "Could not watch directory. Check if it exists and you have read permissions."
3 /*is WatcherInitException.PathNotFound*/ -> "Path not found, check if your repository still exists"

View File

@ -1,9 +0,0 @@
package com.jetpackduba.gitnuro.extensions
import androidx.compose.foundation.lazy.LazyListState
fun LazyListState.observeScrollChanges() {
// When accessing this property, this parent composable is recomposed when the scroll changes
// because LazyListState is marked with @Stable
this.firstVisibleItemScrollOffset
}

View File

@ -66,7 +66,7 @@ fun Modifier.handOnHover(): Modifier {
fun Modifier.onDoubleClick(
onDoubleClick: () -> Unit,
): Modifier {
return this.pointerInput(Unit) {
return this.pointerInput(onDoubleClick) {
coroutineScope {
awaitEachGesture {
// Detect first click without consuming it (other, independent handlers want it).
@ -83,7 +83,9 @@ fun Modifier.onDoubleClick(
} while (change.uptimeMillis < minUptime)
change
} ?: return@awaitEachGesture
secondDown.consume()
val secondUp = waitForUpOrCancellation() ?: return@awaitEachGesture
secondUp.consume()

View File

@ -3,7 +3,6 @@ package com.jetpackduba.gitnuro.git
import FileWatcher
import WatchDirectoryNotifier
import com.jetpackduba.gitnuro.di.TabScope
import com.jetpackduba.gitnuro.exceptions.WatcherInitException
import com.jetpackduba.gitnuro.git.workspace.GetIgnoreRulesUseCase
import com.jetpackduba.gitnuro.system.systemSeparator
import kotlinx.coroutines.CoroutineScope
@ -25,8 +24,8 @@ class FileChangesWatcher @Inject constructor(
private val getIgnoreRulesUseCase: GetIgnoreRulesUseCase,
private val tabScope: CoroutineScope,
) : AutoCloseable {
private val _changesNotifier = MutableSharedFlow<Boolean>()
val changesNotifier: SharedFlow<Boolean> = _changesNotifier
private val _changesNotifier = MutableSharedFlow<WatcherEvent>()
val changesNotifier: SharedFlow<WatcherEvent> = _changesNotifier
private val fileWatcher = FileWatcher.new()
private var shouldKeepLooping = true
@ -71,14 +70,16 @@ class FileChangesWatcher @Inject constructor(
if (!areAllPathsIgnored) {
println("Emitting changes $hasGitIgnoreChanged")
_changesNotifier.emit(hasGitDirChanged)
_changesNotifier.emit(WatcherEvent.RepositoryChanged(hasGitDirChanged))
}
}
}
override fun onError(code: Int) {
throw WatcherInitException(code)
tabScope.launch {
_changesNotifier.emit(WatcherEvent.WatchInitError(code))
}
}
}
@ -90,4 +91,9 @@ class FileChangesWatcher @Inject constructor(
fileWatcher.close()
}
}
sealed interface WatcherEvent {
data class RepositoryChanged(val hasGitDirChanged: Boolean) : WatcherEvent
data class WatchInitError(val code: Int) : WatcherEvent
}

View File

@ -12,6 +12,8 @@ import com.jetpackduba.gitnuro.models.Notification
import com.jetpackduba.gitnuro.ui.SelectedItem
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.lib.ObjectId
import org.eclipse.jgit.lib.Ref
@ -39,6 +41,7 @@ class TabState @Inject constructor(
val selectedItem: StateFlow<SelectedItem> = _selectedItem
private val _taskEvent = MutableSharedFlow<TaskEvent>()
val taskEvent: SharedFlow<TaskEvent> = _taskEvent
var lastOperation: Long = 0
private set
@ -52,8 +55,12 @@ class TabState @Inject constructor(
return unsafeGit
}
private val _refreshData = MutableSharedFlow<RefreshType>()
val refreshData: SharedFlow<RefreshType> = _refreshData
private val refreshData = MutableSharedFlow<RefreshType>()
private val closeableViews = ArrayDeque<CloseableView>()
private val closeableViewsMutex = Mutex()
private val _closeView = MutableSharedFlow<CloseableView>()
val closeViewFlow = _closeView.asSharedFlow()
/**
* Property that indicates if a git operation is running
@ -130,7 +137,7 @@ class TabState @Inject constructor(
lastOperation = System.currentTimeMillis()
if (refreshType != RefreshType.NONE && (!hasProcessFailed || refreshEvenIfCrashes || refreshEvenIfCrashesInteractiveResult)) {
_refreshData.emit(refreshType)
refreshData.emit(refreshType)
}
}
}
@ -228,7 +235,7 @@ class TabState @Inject constructor(
printError(TAG, ex.message.orEmpty(), ex)
} finally {
if (refreshType != RefreshType.NONE && (!hasProcessFailed || refreshEvenIfCrashes))
_refreshData.emit(refreshType)
refreshData.emit(refreshType)
operationRunning = false
lastOperation = System.currentTimeMillis()
@ -236,7 +243,7 @@ class TabState @Inject constructor(
}
suspend fun refreshData(refreshType: RefreshType) {
_refreshData.emit(refreshType)
refreshData.emit(refreshType)
}
suspend fun newSelectedStash(stash: RevCommit) {
@ -307,6 +314,23 @@ class TabState @Inject constructor(
}
}
suspend fun addCloseableView(view: CloseableView): Unit = closeableViewsMutex.withLock {
closeableViews.remove(view) // Remove any previous elements if present
closeableViews.add(view)
}
suspend fun removeCloseableView(view: CloseableView): Unit = closeableViewsMutex.withLock {
closeableViews.remove(view)
}
suspend fun closeLastView(): Unit = closeableViewsMutex.withLock {
val last = closeableViews.removeLastOrNull()
if (last != null) {
_closeView.emit(last)
}
}
fun cancelCurrentTask() {
currentJob?.cancel()
}
@ -324,3 +348,12 @@ enum class RefreshType {
REMOTES,
REBASE_INTERACTIVE_STATE,
}
enum class CloseableView {
DIFF,
LOG_SEARCH,
SIDE_PANEL_SEARCH,
COMMIT_CHANGES_SEARCH,
STAGED_CHANGES_SEARCH,
UNSTAGED_CHANGES_SEARCH,
}

View File

@ -15,9 +15,12 @@ class SaveAuthorUseCase @Inject constructor() {
repoConfig.load()
if (globalConfig is FileBasedConfig) {
globalConfig.setStringProperty("user", null, "name", newAuthorInfo.globalName)
globalConfig.setStringProperty("user", null, "email", newAuthorInfo.globalEmail)
globalConfig.save()
val canonicalConfigFile = globalConfig.file.canonicalFile
val globalRepoConfig = FileBasedConfig(canonicalConfigFile, git.repository.fs)
globalRepoConfig.setStringProperty("user", null, "name", newAuthorInfo.globalName)
globalRepoConfig.setStringProperty("user", null, "email", newAuthorInfo.globalEmail)
globalRepoConfig.save()
}
config.setStringProperty("user", null, "name", newAuthorInfo.name)

View File

@ -1,6 +1,5 @@
package com.jetpackduba.gitnuro.git.branches
import com.jetpackduba.gitnuro.exceptions.ConflictsException
import com.jetpackduba.gitnuro.exceptions.UncommittedChangesDetectedException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

View File

@ -9,19 +9,23 @@ import javax.inject.Inject
class SetTrackingBranchUseCase @Inject constructor() {
operator fun invoke(git: Git, ref: Ref, remoteName: String?, remoteBranch: Ref?) {
invoke(git, ref.simpleName, remoteName, remoteBranch?.simpleName)
}
operator fun invoke(git: Git, refName: String, remoteName: String?, remoteBranchName: String?) {
val repository: Repository = git.repository
val config: StoredConfig = repository.config
if (remoteName == null || remoteBranch == null) {
config.unset("branch", ref.simpleName, "remote")
config.unset("branch", ref.simpleName, "merge")
if (remoteName == null || remoteBranchName == null) {
config.unset("branch", refName, "remote")
config.unset("branch", refName, "merge")
} else {
config.setString("branch", ref.simpleName, "remote", remoteName)
config.setString("branch", refName, "remote", remoteName)
config.setString(
"branch",
ref.simpleName,
refName,
"merge",
BranchesConstants.UPSTREAM_BRANCH_CONFIG_PREFIX + remoteBranch.simpleName
BranchesConstants.UPSTREAM_BRANCH_CONFIG_PREFIX + remoteBranchName
)
}

View File

@ -1,18 +1,18 @@
package com.jetpackduba.gitnuro.git.rebase
import com.jetpackduba.gitnuro.exceptions.ConflictsException
import com.jetpackduba.gitnuro.exceptions.UncommittedChangesDetectedException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.api.MergeResult
import org.eclipse.jgit.api.RebaseCommand
import org.eclipse.jgit.api.RebaseResult
import org.eclipse.jgit.lib.Ref
import javax.inject.Inject
typealias IsMultiStep = Boolean
class RebaseBranchUseCase @Inject constructor() {
suspend operator fun invoke(git: Git, ref: Ref) = withContext(Dispatchers.IO) {
suspend operator fun invoke(git: Git, ref: Ref): IsMultiStep = withContext(Dispatchers.IO) {
val rebaseResult = git.rebase()
.setOperation(RebaseCommand.Operation.BEGIN)
.setUpstream(ref.objectId)
@ -22,10 +22,10 @@ class RebaseBranchUseCase @Inject constructor() {
throw UncommittedChangesDetectedException("Rebase failed, the repository contains uncommitted changes.")
}
when (rebaseResult.status) {
RebaseResult.Status.UNCOMMITTED_CHANGES -> throw UncommittedChangesDetectedException("Merge failed, makes sure you repository doesn't contain uncommitted changes.")
RebaseResult.Status.CONFLICTS -> throw ConflictsException("Rebase produced conflicts, please fix them to continue.")
else -> {}
if (rebaseResult.status == RebaseResult.Status.UNCOMMITTED_CHANGES) {
throw UncommittedChangesDetectedException("Merge failed, makes sure you repository doesn't contain uncommitted changes.")
}
return@withContext rebaseResult.status == RebaseResult.Status.STOPPED || rebaseResult.status == RebaseResult.Status.CONFLICTS
}
}

View File

@ -1,8 +1,8 @@
package com.jetpackduba.gitnuro.git.rebase
sealed interface RebaseInteractiveState {
object None : RebaseInteractiveState
object AwaitingInteraction : RebaseInteractiveState
data object None : RebaseInteractiveState
data object AwaitingInteraction : RebaseInteractiveState
data class ProcessingCommits(val commitToAmendId: String?) : RebaseInteractiveState {
val isCurrentStepAmenable: Boolean = !commitToAmendId.isNullOrBlank()
}

View File

@ -12,7 +12,7 @@ class HandleTransportUseCase @Inject constructor(
private val sessionManager: GSessionManager,
private val httpCredentialsProvider: HttpCredentialsFactory,
) {
suspend operator fun invoke(git: Git?, block: suspend CredentialsHandler.() -> Unit) {
suspend operator fun <R> invoke(git: Git?, block: suspend CredentialsHandler.() -> R): R {
var cache: CredentialsCache? = null
val credentialsHandler = object : CredentialsHandler {
@ -37,8 +37,10 @@ class HandleTransportUseCase @Inject constructor(
}
}
credentialsHandler.block()
val result = credentialsHandler.block()
cache?.cacheCredentialsIfNeeded()
return result
}
}

View File

@ -0,0 +1,34 @@
package com.jetpackduba.gitnuro.git.remote_operations
import org.eclipse.jgit.api.MergeResult
import org.eclipse.jgit.api.PullResult
import org.eclipse.jgit.api.RebaseResult
import javax.inject.Inject
typealias PullHasConflicts = Boolean
class HasPullResultConflictsUseCase @Inject constructor() {
operator fun invoke(isRebase: Boolean, pullResult: PullResult): PullHasConflicts {
if (!pullResult.isSuccessful) {
if (
pullResult.mergeResult?.mergeStatus == MergeResult.MergeStatus.CONFLICTING ||
pullResult.rebaseResult?.status == RebaseResult.Status.CONFLICTS ||
pullResult.rebaseResult?.status == RebaseResult.Status.STOPPED
) {
return true
}
if (isRebase) {
val message = when (pullResult.rebaseResult.status) {
RebaseResult.Status.UNCOMMITTED_CHANGES -> "The pull with rebase has failed because you have got uncommitted changes"
else -> "Pull failed"
}
throw Exception(message)
}
}
return false
}
}

View File

@ -4,15 +4,15 @@ import com.jetpackduba.gitnuro.repositories.AppSettingsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.api.RebaseResult
import org.eclipse.jgit.transport.CredentialsProvider
import javax.inject.Inject
class PullBranchUseCase @Inject constructor(
private val handleTransportUseCase: HandleTransportUseCase,
private val appSettingsRepository: AppSettingsRepository,
private val hasPullResultConflictsUseCase: HasPullResultConflictsUseCase,
) {
suspend operator fun invoke(git: Git, pullType: PullType) = withContext(Dispatchers.IO) {
suspend operator fun invoke(git: Git, pullType: PullType): PullHasConflicts = withContext(Dispatchers.IO) {
val pullWithRebase = when (pullType) {
PullType.REBASE -> true
PullType.MERGE -> false
@ -27,19 +27,7 @@ class PullBranchUseCase @Inject constructor(
.setCredentialsProvider(CredentialsProvider.getDefault())
.call()
if (!pullResult.isSuccessful) {
var message = "Pull failed"
if (pullWithRebase) {
message = when (pullResult.rebaseResult.status) {
RebaseResult.Status.UNCOMMITTED_CHANGES -> "The pull with rebase has failed because you have got uncommitted changes"
RebaseResult.Status.CONFLICTS -> "Pull with rebase has conflicts, fix them to continue"
else -> message
}
}
throw Exception(message)
}
return@handleTransportUseCase hasPullResultConflictsUseCase(pullWithRebase, pullResult)
}
}
}

View File

@ -2,42 +2,34 @@ package com.jetpackduba.gitnuro.git.remote_operations
import com.jetpackduba.gitnuro.extensions.remoteName
import com.jetpackduba.gitnuro.extensions.simpleName
import com.jetpackduba.gitnuro.repositories.AppSettingsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.api.RebaseResult
import org.eclipse.jgit.lib.Ref
import org.eclipse.jgit.transport.CredentialsProvider
import javax.inject.Inject
class PullFromSpecificBranchUseCase @Inject constructor(
private val handleTransportUseCase: HandleTransportUseCase,
private val hasPullResultConflictsUseCase: HasPullResultConflictsUseCase,
private val appSettingsRepository: AppSettingsRepository,
) {
suspend operator fun invoke(git: Git, rebase: Boolean, remoteBranch: Ref) = withContext(Dispatchers.IO) {
handleTransportUseCase(git) {
val pullResult = git
.pull()
.setTransportConfigCallback { handleTransport(it) }
.setRemote(remoteBranch.remoteName)
.setRemoteBranchName(remoteBranch.simpleName)
.setRebase(rebase)
.setCredentialsProvider(CredentialsProvider.getDefault())
.call()
suspend operator fun invoke(git: Git, remoteBranch: Ref): PullHasConflicts =
withContext(Dispatchers.IO) {
val pullWithRebase = appSettingsRepository.pullRebase
if (!pullResult.isSuccessful) {
var message =
"Pull failed" // TODO Remove messages from here and pass the result to a custom exception type
handleTransportUseCase(git) {
val pullResult = git
.pull()
.setTransportConfigCallback { handleTransport(it) }
.setRemote(remoteBranch.remoteName)
.setRemoteBranchName(remoteBranch.simpleName)
.setRebase(pullWithRebase)
.setCredentialsProvider(CredentialsProvider.getDefault())
.call()
if (rebase) {
message = when (pullResult.rebaseResult.status) {
RebaseResult.Status.UNCOMMITTED_CHANGES -> "The pull with rebase has failed because you have got uncommitted changes"
RebaseResult.Status.CONFLICTS -> "Pull with rebase has conflicts, fix them to continue"
else -> message
}
}
throw Exception(message)
return@handleTransportUseCase hasPullResultConflictsUseCase(pullWithRebase, pullResult)
}
}
}
}

View File

@ -1,6 +1,7 @@
package com.jetpackduba.gitnuro.git.remote_operations
import com.jetpackduba.gitnuro.git.branches.GetTrackingBranchUseCase
import com.jetpackduba.gitnuro.git.branches.SetTrackingBranchUseCase
import com.jetpackduba.gitnuro.git.branches.TrackingBranch
import com.jetpackduba.gitnuro.git.isRejected
import com.jetpackduba.gitnuro.git.statusMessage
@ -14,24 +15,37 @@ import org.eclipse.jgit.lib.ProgressMonitor
import org.eclipse.jgit.transport.RefLeaseSpec
import org.eclipse.jgit.transport.RefSpec
import javax.inject.Inject
import kotlin.math.max
class PushBranchUseCase @Inject constructor(
private val handleTransportUseCase: HandleTransportUseCase,
private val getTrackingBranchUseCase: GetTrackingBranchUseCase,
private val setTrackingBranchUseCase: SetTrackingBranchUseCase,
private val appSettingsRepository: AppSettingsRepository,
) {
// TODO This use case should also set the tracking branch to the new remote branch
suspend operator fun invoke(git: Git, force: Boolean, pushTags: Boolean) = withContext(Dispatchers.IO) {
val currentBranch = git.repository.fullBranch
val tracking = getTrackingBranchUseCase(git, git.repository.branch)
val currentBranch = git.repository.branch
val fullCurrentBranch = git.repository.fullBranch
val tracking = getTrackingBranchUseCase(git, currentBranch)
val refSpecStr = if (tracking != null) {
"$currentBranch:${Constants.R_HEADS}${tracking.branch}"
"$fullCurrentBranch:${Constants.R_HEADS}${tracking.branch}"
} else {
currentBranch
fullCurrentBranch
}
handleTransportUseCase(git) {
val remoteRefUpdate = handleTransportUseCase(git) {
push(git, tracking, refSpecStr, force, pushTags)
}
if (tracking == null && remoteRefUpdate != null) {
// [remoteRefUpdate.trackingRefUpdate.localName] should have the following format: refs/remotes/REMOTE_NAME/BRANCH_NAME
val remoteBranchPathSplit = remoteRefUpdate.trackingRefUpdate.localName.split("/")
val remoteName = remoteBranchPathSplit.getOrNull(2)
val remoteBranchName = remoteBranchPathSplit.takeLast(max(0, remoteBranchPathSplit.count() - 3)).joinToString("/")
setTrackingBranchUseCase(git, currentBranch, remoteName, remoteBranchName)
}
}
private suspend fun CredentialsHandler.push(
@ -117,5 +131,7 @@ class PushBranchUseCase @Inject constructor(
throw Exception(error.toString())
}
return@withContext pushResult.firstOrNull()?.remoteUpdates?.firstOrNull()
}
}

View File

@ -1,6 +1,5 @@
package com.jetpackduba.gitnuro.git.workspace
import com.jetpackduba.gitnuro.system.systemSeparator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.eclipse.jgit.api.Git

View File

@ -67,6 +67,31 @@ enum class KeybindingOption {
* Used to pop stash changes to workspace
*/
STASH_POP,
/**
* Used to open a repository
*/
OPEN_REPOSITORY,
/**
* Used to open a new tab
*/
OPEN_NEW_TAB,
/**
* Used to close current tab
*/
CLOSE_CURRENT_TAB,
/**
* Used to change current tab to the one in the left
*/
CHANGE_CURRENT_TAB_LEFT,
/**
* Used to change current tab to the one in the right
*/
CHANGE_CURRENT_TAB_RIGHT,
}
@ -106,6 +131,21 @@ private fun baseKeybindings() = mapOf(
KeybindingOption.STASH_POP to listOf(
Keybinding(key = Key.S, control = true, shift = true),
),
KeybindingOption.OPEN_REPOSITORY to listOf(
Keybinding(key = Key.O, control = true),
),
KeybindingOption.OPEN_NEW_TAB to listOf(
Keybinding(key = Key.T, control = true),
),
KeybindingOption.CLOSE_CURRENT_TAB to listOf(
Keybinding(key = Key.W, control = true),
),
KeybindingOption.CHANGE_CURRENT_TAB_LEFT to listOf(
Keybinding(key = Key.DirectionLeft, alt = true),
),
KeybindingOption.CHANGE_CURRENT_TAB_RIGHT to listOf(
Keybinding(key = Key.DirectionRight, alt = true),
),
)
private fun linuxKeybindings(): Map<KeybindingOption, List<Keybinding>> = baseKeybindings()

View File

@ -43,10 +43,10 @@ val darkBlueTheme = ColorsScheme(
onBackgroundSecondary = Color(0xFFCCCBCB),
error = Color(0xFFc93838),
onError = Color(0xFFFFFFFF),
background = Color(0xFF0E1621),
background = Color(0xFF10161e),
backgroundSelected = Color(0xFF2f3640),
surface = Color(0xFF182533),
secondarySurface = Color(0xFF122C46),
surface = Color(0xff1d2630),
secondarySurface = Color(0xFF1a2b3d),
tertiarySurface = Color(0xFF0a335c),
addFile = Color(0xFF32A852),
deletedFile = Color(0xFFc93838),
@ -64,7 +64,7 @@ val darkBlueTheme = ColorsScheme(
)
val darkGrayTheme = ColorsScheme(
primary = Color(0xFF014F97),
primary = Color(0xFF264e73),
primaryVariant = Color(0xFFCDEAFF),
onPrimary = Color(0xFFFFFFFFF),
secondary = Color(0xFFe9c754),
@ -73,11 +73,11 @@ val darkGrayTheme = ColorsScheme(
onBackgroundSecondary = Color(0xFFCCCBCB),
error = Color(0xFFc93838),
onError = Color(0xFFFFFFFF),
background = Color(0xFF16181F),
background = Color(0xFF17181c),
backgroundSelected = Color(0xFF32373e),
surface = Color(0xFF212731),
secondarySurface = Color(0xFF282d36),
tertiarySurface = Color(0xFF21303d),
surface = Color(0xFF25282d),
secondarySurface = Color(0xFF292d32),
tertiarySurface = Color(0xFF252f37),
addFile = Color(0xFF32A852),
deletedFile = Color(0xFFc93838),
modifiedFile = Color(0xFF0070D8),

View File

@ -9,9 +9,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.jetpackduba.gitnuro.ui.dropdowns.DropDownOption
import kotlinx.coroutines.flow.MutableStateFlow
private val defaultAppTheme: ColorsScheme = darkBlueTheme
private var appTheme: ColorsScheme = defaultAppTheme
private var appTheme: MutableStateFlow<ColorsScheme> = MutableStateFlow(defaultAppTheme)
internal val LocalLinesHeight = compositionLocalOf { spacedLineHeight }
class LinesHeight internal constructor(
@ -60,7 +61,7 @@ fun AppTheme(
LinesHeightType.COMPACT -> compactLineHeight
}
appTheme = theme
appTheme.value = theme
val composeColors = theme.toComposeColors()
val compositionValues = arrayOf(LocalLinesHeight provides lineHeight)
@ -80,57 +81,78 @@ val MaterialTheme.linesHeight: LinesHeight
@ReadOnlyComposable
get() = LocalLinesHeight.current
private val theme: ColorsScheme
@Composable
get() = appTheme.collectAsState().value
val Colors.backgroundSelected: Color
get() = appTheme.backgroundSelected
@Composable
get() = theme.backgroundSelected
val Colors.onBackgroundSecondary: Color
get() = appTheme.onBackgroundSecondary
@Composable
get() = theme.onBackgroundSecondary
val Colors.secondarySurface: Color
get() = appTheme.secondarySurface
@Composable
get() = theme.secondarySurface
val Colors.tertiarySurface: Color
get() = appTheme.tertiarySurface
@Composable
get() = theme.tertiarySurface
val Colors.addFile: Color
get() = appTheme.addFile
@Composable
get() = theme.addFile
val Colors.deleteFile: Color
get() = appTheme.deletedFile
@Composable
get() = theme.deletedFile
val Colors.modifyFile: Color
get() = appTheme.modifiedFile
@Composable
get() = theme.modifiedFile
val Colors.conflictFile: Color
get() = appTheme.conflictingFile
@Composable
get() = theme.conflictingFile
val Colors.abortButton: Color
get() = appTheme.error
@Composable
get() = theme.error
val Colors.scrollbarNormal: Color
get() = appTheme.normalScrollbar
@Composable
get() = theme.normalScrollbar
val Colors.scrollbarHover: Color
get() = appTheme.hoverScrollbar
@Composable
get() = theme.hoverScrollbar
val Colors.dialogOverlay: Color
get() = appTheme.dialogOverlay
@Composable
get() = theme.dialogOverlay
val Colors.diffLineAdded: Color
get() = appTheme.diffLineAdded
@Composable
get() = theme.diffLineAdded
val Colors.diffLineRemoved: Color
get() = appTheme.diffLineRemoved
@Composable
get() = theme.diffLineRemoved
val Colors.diffKeyword: Color
get() = appTheme.diffKeyword
@Composable
get() = theme.diffKeyword
val Colors.diffAnnotation: Color
get() = appTheme.diffAnnotation
@Composable
get() = theme.diffAnnotation
val Colors.diffComment: Color
get() = appTheme.diffComment
@Composable
get() = theme.diffComment
val Colors.isDark: Boolean
get() = !this.isLight

View File

@ -1,34 +1,19 @@
package com.jetpackduba.gitnuro.ui
import androidx.compose.animation.Crossfade
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.jetpackduba.gitnuro.AppIcons
import com.jetpackduba.gitnuro.LoadingRepository
import com.jetpackduba.gitnuro.ProcessingScreen
import com.jetpackduba.gitnuro.credentials.CredentialsAccepted
import com.jetpackduba.gitnuro.credentials.CredentialsRequested
import com.jetpackduba.gitnuro.credentials.CredentialsState
import com.jetpackduba.gitnuro.git.ProcessingState
import com.jetpackduba.gitnuro.models.Notification
import com.jetpackduba.gitnuro.models.NotificationType
import com.jetpackduba.gitnuro.theme.AppTheme
import com.jetpackduba.gitnuro.ui.components.Notification
import com.jetpackduba.gitnuro.ui.dialogs.CloneDialog
import com.jetpackduba.gitnuro.ui.dialogs.GpgPasswordDialog
import com.jetpackduba.gitnuro.ui.dialogs.SshPasswordDialog
import com.jetpackduba.gitnuro.ui.dialogs.UserPasswordDialog
import com.jetpackduba.gitnuro.ui.dialogs.CredentialsDialog
import com.jetpackduba.gitnuro.ui.dialogs.errors.ErrorDialog
import com.jetpackduba.gitnuro.ui.dialogs.settings.SettingsDialog
import com.jetpackduba.gitnuro.viewmodels.RepositorySelectionStatus
@ -109,7 +94,7 @@ fun AppTab(
is RepositorySelectionStatus.Open -> {
RepositoryOpenPage(
tabViewModel = tabViewModel,
repositoryOpenViewModel = repositorySelectionStatusValue.viewModel,
onShowSettingsDialog = { showSettingsDialog = true },
onShowCloneDialog = { showCloneDialog = true },
)
@ -148,119 +133,3 @@ fun AppTab(
}
}
}
@Preview
@Composable
fun NotificationSuccessPreview() {
AppTheme(customTheme = null) {
Notification(NotificationType.Positive, "Hello world!")
}
}
@Composable
fun Notification(notification: Notification) {
val notificationShape = RoundedCornerShape(8.dp)
Row(
modifier = Modifier
.padding(8.dp)
.border(2.dp, MaterialTheme.colors.onBackground.copy(0.2f), notificationShape)
.clip(notificationShape)
.background(MaterialTheme.colors.background)
.height(IntrinsicSize.Max)
.padding(2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val backgroundColor = when (notification.type) {
NotificationType.Positive -> MaterialTheme.colors.primary
NotificationType.Warning -> MaterialTheme.colors.secondary
NotificationType.Error -> MaterialTheme.colors.error
}
val contentColor = when (notification.type) {
NotificationType.Positive -> MaterialTheme.colors.onPrimary
NotificationType.Warning -> MaterialTheme.colors.onSecondary
NotificationType.Error -> MaterialTheme.colors.onError
}
val icon = when (notification.type) {
NotificationType.Positive -> AppIcons.INFO
NotificationType.Warning -> AppIcons.WARNING
NotificationType.Error -> AppIcons.ERROR
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(topStart = 6.dp, bottomStart = 6.dp))
.background(backgroundColor)
.fillMaxHeight()
) {
Icon(
painterResource(icon),
contentDescription = null,
tint = contentColor,
modifier = Modifier
.padding(4.dp)
)
}
Box(
modifier = Modifier
.padding(end = 8.dp)
.fillMaxHeight(),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = notification.text,
modifier = Modifier,
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.body1,
)
}
}
}
@Composable
fun CredentialsDialog(gitManager: TabViewModel) {
val credentialsState = gitManager.credentialsState.collectAsState()
when (val credentialsStateValue = credentialsState.value) {
CredentialsRequested.HttpCredentialsRequested -> {
UserPasswordDialog(
onReject = {
gitManager.credentialsDenied()
},
onAccept = { user, password ->
gitManager.httpCredentialsAccepted(user, password)
}
)
}
CredentialsRequested.SshCredentialsRequested -> {
SshPasswordDialog(
onReject = {
gitManager.credentialsDenied()
},
onAccept = { password ->
gitManager.sshCredentialsAccepted(password)
}
)
}
is CredentialsRequested.GpgCredentialsRequested -> {
GpgPasswordDialog(
gpgCredentialsRequested = credentialsStateValue,
onReject = {
gitManager.credentialsDenied()
},
onAccept = { password ->
gitManager.gpgCredentialsAccepted(password)
}
)
}
is CredentialsAccepted, CredentialsState.None, CredentialsState.CredentialsDenied -> { /* Nothing to do */
}
}
}

View File

@ -33,6 +33,7 @@ import com.jetpackduba.gitnuro.ui.tree_files.TreeItem
import com.jetpackduba.gitnuro.viewmodels.CommitChangesStateUi
import com.jetpackduba.gitnuro.viewmodels.CommitChangesViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.eclipse.jgit.diff.DiffEntry
import org.eclipse.jgit.lib.PersonIdent
@ -47,10 +48,20 @@ fun CommitChanges(
onBlame: (String) -> Unit,
onHistory: (String) -> Unit,
) {
val tabFocusRequester = LocalTabFocusRequester.current
LaunchedEffect(selectedItem) {
commitChangesViewModel.loadChanges(selectedItem.revCommit)
}
LaunchedEffect(commitChangesViewModel) {
commitChangesViewModel.showSearch.collectLatest { show ->
if (!show) {
tabFocusRequester.requestFocus()
}
}
}
val commitChangesStatus = commitChangesViewModel.commitChangesStateUi.collectAsState().value
val showSearch by commitChangesViewModel.showSearch.collectAsState()
val changesListScroll by commitChangesViewModel.changesLazyListState.collectAsState()
@ -81,6 +92,9 @@ fun CommitChanges(
onSearchFilterToggled = { visible ->
commitChangesViewModel.onSearchFilterToggled(visible)
},
onSearchFocused = {
commitChangesViewModel.addSearchToCloseableView()
},
onSearchFilterChanged = { filter ->
searchFilter = filter
commitChangesViewModel.onSearchFilterChanged(filter)
@ -105,6 +119,7 @@ private fun CommitChangesView(
onHistory: (String) -> Unit,
onDiffSelected: (DiffEntry) -> Unit,
onSearchFilterToggled: (Boolean) -> Unit,
onSearchFocused: () -> Unit,
onSearchFilterChanged: (TextFieldValue) -> Unit,
onDirectoryClicked: (TreeItem.Dir) -> Unit,
onAlternateShowAsTree: () -> Unit,
@ -129,6 +144,7 @@ private fun CommitChangesView(
searchFilter,
onSearchFilterChanged,
onSearchFilterToggled,
onSearchFocused,
showAsTree = showAsTree,
onAlternateShowAsTree = onAlternateShowAsTree,
)
@ -182,6 +198,7 @@ private fun Header(
searchFilter: TextFieldValue,
onSearchFilterChanged: (TextFieldValue) -> Unit,
onSearchFilterToggled: (Boolean) -> Unit,
onSearchFocused: () -> Unit,
showAsTree: Boolean,
onAlternateShowAsTree: () -> Unit,
) {
@ -250,6 +267,7 @@ private fun Header(
onSearchFilterChanged = onSearchFilterChanged,
searchFocusRequester = searchFocusRequester,
onClose = { onSearchFilterToggled(false) },
onSearchFocused = onSearchFocused,
)
}

View File

@ -50,12 +50,12 @@ fun Menu(
onStashWithMessage: () -> Unit,
onQuickActions: () -> Unit,
onShowSettingsDialog: () -> Unit,
showOpenPopup: Boolean,
onShowOpenPopupChange: (Boolean) -> Unit,
) {
val isPullWithRebaseDefault by menuViewModel.isPullWithRebaseDefault.collectAsState()
val lastLoadedTabs by menuViewModel.lastLoadedTabs.collectAsState()
val (position, setPosition) = remember { mutableStateOf<LayoutCoordinates?>(null) }
var showOpenPopup by remember { mutableStateOf(false) }
Row(
modifier = modifier,
@ -72,7 +72,7 @@ fun Menu(
.onGloballyPositioned { setPosition(it) },
title = "Open",
icon = painterResource(AppIcons.OPEN),
onClick = { showOpenPopup = true },
onClick = { onShowOpenPopupChange(true) },
)
}
@ -207,7 +207,7 @@ fun Menu(
return IntOffset(boundsInRoot.left.toInt(), boundsInRoot.bottom.toInt())
}
},
onDismissRequest = { showOpenPopup = false },
onDismissRequest = { onShowOpenPopupChange(false) },
properties = PopupProperties(focusable = true),
) {
val searchFocusRequester = remember { FocusRequester() }
@ -223,7 +223,7 @@ fun Menu(
PrimaryButton(
text = "Open a repository",
onClick = {
showOpenPopup = false
onShowOpenPopupChange(false)
onOpenAnotherRepositoryFromPicker()
},
modifier = Modifier
@ -237,7 +237,7 @@ fun Menu(
searchFieldFocusRequester = searchFocusRequester,
onRemoveRepositoryFromRecent = {},
onOpenKnownRepository = {
showOpenPopup = false
onShowOpenPopupChange(false)
onOpenAnotherRepository(it)
},
)

View File

@ -10,7 +10,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
@ -29,24 +28,27 @@ import com.jetpackduba.gitnuro.ui.diff.Diff
import com.jetpackduba.gitnuro.ui.log.Log
import com.jetpackduba.gitnuro.updates.Update
import com.jetpackduba.gitnuro.viewmodels.BlameState
import com.jetpackduba.gitnuro.viewmodels.TabViewModel
import com.jetpackduba.gitnuro.viewmodels.RepositoryOpenViewModel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.eclipse.jgit.lib.RepositoryState
import org.eclipse.jgit.revwalk.RevCommit
val LocalTabFocusRequester = compositionLocalOf { FocusRequester() }
@Composable
fun RepositoryOpenPage(
tabViewModel: TabViewModel,
repositoryOpenViewModel: RepositoryOpenViewModel,
onShowSettingsDialog: () -> Unit,
onShowCloneDialog: () -> Unit,
) {
val repositoryState by tabViewModel.repositoryState.collectAsState()
val diffSelected by tabViewModel.diffSelected.collectAsState()
val selectedItem by tabViewModel.selectedItem.collectAsState()
val blameState by tabViewModel.blameState.collectAsState()
val showHistory by tabViewModel.showHistory.collectAsState()
val showAuthorInfo by tabViewModel.showAuthorInfo.collectAsState()
val repositoryState by repositoryOpenViewModel.repositoryState.collectAsState()
val diffSelected by repositoryOpenViewModel.diffSelected.collectAsState()
val selectedItem by repositoryOpenViewModel.selectedItem.collectAsState()
val blameState by repositoryOpenViewModel.blameState.collectAsState()
val showHistory by repositoryOpenViewModel.showHistory.collectAsState()
val showAuthorInfo by repositoryOpenViewModel.showAuthorInfo.collectAsState()
var showNewBranchDialog by remember { mutableStateOf(false) }
var showStashWithMessageDialog by remember { mutableStateOf(false) }
@ -59,7 +61,7 @@ fun RepositoryOpenPage(
showNewBranchDialog = false
},
onAccept = { branchName ->
tabViewModel.createBranch(branchName)
repositoryOpenViewModel.createBranch(branchName)
showNewBranchDialog = false
}
)
@ -69,17 +71,17 @@ fun RepositoryOpenPage(
showStashWithMessageDialog = false
},
onAccept = { stashMessage ->
tabViewModel.stashWithMessage(stashMessage)
repositoryOpenViewModel.stashWithMessage(stashMessage)
showStashWithMessageDialog = false
}
)
} else if (showAuthorInfo) {
val authorViewModel = tabViewModel.authorViewModel
val authorViewModel = repositoryOpenViewModel.authorViewModel
if (authorViewModel != null) {
AuthorDialog(
authorViewModel = authorViewModel,
onClose = {
tabViewModel.closeAuthorInfoDialog()
repositoryOpenViewModel.closeAuthorInfoDialog()
}
)
}
@ -89,103 +91,113 @@ fun RepositoryOpenPage(
onAction = {
showQuickActionsDialog = false
when (it) {
QuickActionType.OPEN_DIR_IN_FILE_MANAGER -> tabViewModel.openFolderInFileExplorer()
QuickActionType.OPEN_DIR_IN_FILE_MANAGER -> repositoryOpenViewModel.openFolderInFileExplorer()
QuickActionType.CLONE -> onShowCloneDialog()
QuickActionType.REFRESH -> tabViewModel.refreshAll()
QuickActionType.REFRESH -> repositoryOpenViewModel.refreshAll()
QuickActionType.SIGN_OFF -> showSignOffDialog = true
}
},
)
} else if (showSignOffDialog) {
SignOffDialog(
viewModel = tabViewModel.tabViewModelsProvider.signOffDialogViewModel,
viewModel = repositoryOpenViewModel.tabViewModelsProvider.signOffDialogViewModel,
onClose = { showSignOffDialog = false },
)
}
val focusRequester = remember { FocusRequester() }
var showOpenPopup by remember { mutableStateOf(false) }
LaunchedEffect(selectedItem) {
focusRequester.requestFocus()
}
Column (
modifier = Modifier.onPreviewKeyEvent {
println("Key event $it")
when {
it.matchesBinding(KeybindingOption.PULL) -> {
tabViewModel.pull(PullType.DEFAULT)
true
}
it.matchesBinding(KeybindingOption.PUSH) -> {
tabViewModel.push()
true
}
it.matchesBinding(KeybindingOption.BRANCH_CREATE) -> {
if (!showNewBranchDialog) {
showNewBranchDialog = true
Column(
modifier = Modifier
.focusRequester(focusRequester)
.focusable(true)
.onPreviewKeyEvent {
when {
it.matchesBinding(KeybindingOption.PULL) -> {
repositoryOpenViewModel.pull(PullType.DEFAULT)
true
} else {
false
}
}
it.matchesBinding(KeybindingOption.STASH) -> {
tabViewModel.stash()
true
}
it.matchesBinding(KeybindingOption.STASH_POP) -> {
tabViewModel.popStash()
true
}
else -> false
}
}
) {
Row(modifier = Modifier.weight(1f)) {
Column(
modifier = Modifier
.focusRequester(focusRequester)
.focusable()
.onKeyEvent { keyEvent ->
if (keyEvent.matchesBinding(KeybindingOption.REFRESH)) {
tabViewModel.refreshAll()
it.matchesBinding(KeybindingOption.PUSH) -> {
repositoryOpenViewModel.push()
true
}
it.matchesBinding(KeybindingOption.BRANCH_CREATE) -> {
if (!showNewBranchDialog) {
showNewBranchDialog = true
true
} else {
false
}
}
) {
Column(modifier = Modifier.weight(1f)) {
Menu(
menuViewModel = tabViewModel.tabViewModelsProvider.menuViewModel,
modifier = Modifier
.padding(
vertical = 4.dp
)
.fillMaxWidth(),
onCreateBranch = { showNewBranchDialog = true },
onStashWithMessage = { showStashWithMessageDialog = true },
onOpenAnotherRepository = { tabViewModel.openAnotherRepository(it) },
onOpenAnotherRepositoryFromPicker = {
val repoToOpen = tabViewModel.openDirectoryPicker()
if (repoToOpen != null) {
tabViewModel.openAnotherRepository(repoToOpen)
}
},
onQuickActions = { showQuickActionsDialog = true },
onShowSettingsDialog = onShowSettingsDialog
)
it.matchesBinding(KeybindingOption.STASH) -> {
repositoryOpenViewModel.stash()
true
}
RepoContent(
tabViewModel = tabViewModel,
diffSelected = diffSelected,
selectedItem = selectedItem,
repositoryState = repositoryState,
blameState = blameState,
showHistory = showHistory,
)
it.matchesBinding(KeybindingOption.STASH_POP) -> {
repositoryOpenViewModel.popStash()
true
}
it.matchesBinding(KeybindingOption.EXIT) -> {
repositoryOpenViewModel.closeLastView()
true
}
it.matchesBinding(KeybindingOption.REFRESH) -> {
repositoryOpenViewModel.refreshAll()
true
}
it.matchesBinding(KeybindingOption.OPEN_REPOSITORY) -> {
showOpenPopup = true
true
}
else -> false
}
}
) {
CompositionLocalProvider(
LocalTabFocusRequester provides focusRequester
) {
Column(modifier = Modifier.weight(1f)) {
Menu(
menuViewModel = repositoryOpenViewModel.tabViewModelsProvider.menuViewModel,
modifier = Modifier
.padding(
vertical = 4.dp
)
.fillMaxWidth(),
onCreateBranch = { showNewBranchDialog = true },
onStashWithMessage = { showStashWithMessageDialog = true },
onOpenAnotherRepository = { repositoryOpenViewModel.openAnotherRepository(it) },
onOpenAnotherRepositoryFromPicker = {
val repoToOpen = repositoryOpenViewModel.openDirectoryPicker()
if (repoToOpen != null) {
repositoryOpenViewModel.openAnotherRepository(repoToOpen)
}
},
onQuickActions = { showQuickActionsDialog = true },
onShowSettingsDialog = onShowSettingsDialog,
showOpenPopup = showOpenPopup,
onShowOpenPopupChange = { showOpenPopup = it }
)
RepoContent(
repositoryOpenViewModel = repositoryOpenViewModel,
diffSelected = diffSelected,
selectedItem = selectedItem,
repositoryState = repositoryState,
blameState = blameState,
showHistory = showHistory,
)
}
}
@ -197,16 +209,20 @@ fun RepositoryOpenPage(
)
val userInfo by tabViewModel.authorInfoSimple.collectAsState()
val newUpdate = tabViewModel.update.collectAsState().value
val userInfo by repositoryOpenViewModel.authorInfoSimple.collectAsState()
val newUpdate = repositoryOpenViewModel.update.collectAsState().value
BottomInfoBar(
userInfo,
newUpdate,
onOpenUrlInBrowser = { tabViewModel.openUrlInBrowser(it) },
onShowAuthorInfoDialog = { tabViewModel.showAuthorInfoDialog() },
onOpenUrlInBrowser = { repositoryOpenViewModel.openUrlInBrowser(it) },
onShowAuthorInfoDialog = { repositoryOpenViewModel.showAuthorInfoDialog() },
)
}
LaunchedEffect(repositoryOpenViewModel) {
focusRequester.requestFocus()
}
}
@Composable
@ -258,7 +274,7 @@ private fun BottomInfoBar(
@Composable
fun RepoContent(
tabViewModel: TabViewModel,
repositoryOpenViewModel: RepositoryOpenViewModel,
diffSelected: DiffType?,
selectedItem: SelectedItem,
repositoryState: RepositoryState,
@ -266,19 +282,19 @@ fun RepoContent(
showHistory: Boolean,
) {
if (showHistory) {
val historyViewModel = tabViewModel.historyViewModel
val historyViewModel = repositoryOpenViewModel.historyViewModel
if (historyViewModel != null) {
FileHistory(
historyViewModel = historyViewModel,
onClose = {
tabViewModel.closeHistory()
repositoryOpenViewModel.closeHistory()
}
)
}
} else {
MainContentView(
tabViewModel,
repositoryOpenViewModel,
diffSelected,
selectedItem,
repositoryState,
@ -289,25 +305,25 @@ fun RepoContent(
@Composable
fun MainContentView(
tabViewModel: TabViewModel,
repositoryOpenViewModel: RepositoryOpenViewModel,
diffSelected: DiffType?,
selectedItem: SelectedItem,
repositoryState: RepositoryState,
blameState: BlameState,
) {
val rebaseInteractiveState by tabViewModel.rebaseInteractiveState.collectAsState()
val rebaseInteractiveState by repositoryOpenViewModel.rebaseInteractiveState.collectAsState()
val density = LocalDensity.current.density
val scope = rememberCoroutineScope()
// We create 2 mutableStates here because using directly the flow makes compose lose some drag events for some reason
var firstWidth by remember(tabViewModel) { mutableStateOf(tabViewModel.firstPaneWidth.value) }
var thirdWidth by remember(tabViewModel) { mutableStateOf(tabViewModel.thirdPaneWidth.value) }
var firstWidth by remember(repositoryOpenViewModel) { mutableStateOf(repositoryOpenViewModel.firstPaneWidth.value) }
var thirdWidth by remember(repositoryOpenViewModel) { mutableStateOf(repositoryOpenViewModel.thirdPaneWidth.value) }
LaunchedEffect(Unit) {
// Update the pane widths if they have been changed in a different tab
tabViewModel.onPanelsWidthPersisted.collectLatest {
firstWidth = tabViewModel.firstPaneWidth.value
thirdWidth = tabViewModel.thirdPaneWidth.value
repositoryOpenViewModel.onPanelsWidthPersisted.collectLatest {
firstWidth = repositoryOpenViewModel.firstPaneWidth.value
thirdWidth = repositoryOpenViewModel.thirdPaneWidth.value
}
}
@ -317,9 +333,9 @@ fun MainContentView(
thirdWidth = thirdWidth,
first = {
SidePanel(
tabViewModel.tabViewModelsProvider.sidePanelViewModel,
changeDefaultUpstreamBranchViewModel = { tabViewModel.tabViewModelsProvider.changeDefaultUpstreamBranchViewModel },
submoduleDialogViewModel = { tabViewModel.tabViewModelsProvider.submoduleDialogViewModel },
repositoryOpenViewModel.tabViewModelsProvider.sidePanelViewModel,
changeDefaultUpstreamBranchViewModel = { repositoryOpenViewModel.tabViewModelsProvider.changeDefaultUpstreamBranchViewModel },
submoduleDialogViewModel = { repositoryOpenViewModel.tabViewModelsProvider.submoduleDialogViewModel },
)
},
second = {
@ -328,13 +344,13 @@ fun MainContentView(
.fillMaxSize()
) {
if (rebaseInteractiveState == RebaseInteractiveState.AwaitingInteraction && diffSelected == null) {
RebaseInteractive(tabViewModel.tabViewModelsProvider.rebaseInteractiveViewModel)
RebaseInteractive(repositoryOpenViewModel.tabViewModelsProvider.rebaseInteractiveViewModel)
} else if (blameState is BlameState.Loaded && !blameState.isMinimized) {
Blame(
filePath = blameState.filePath,
blameResult = blameState.blameResult,
onClose = { tabViewModel.resetBlameState() },
onSelectCommit = { tabViewModel.selectCommit(it) }
onClose = { repositoryOpenViewModel.resetBlameState() },
onSelectCommit = { repositoryOpenViewModel.selectCommit(it) }
)
} else {
Column {
@ -342,21 +358,23 @@ fun MainContentView(
when (diffSelected) {
null -> {
Log(
logViewModel = tabViewModel.tabViewModelsProvider.logViewModel,
logViewModel = repositoryOpenViewModel.tabViewModelsProvider.logViewModel,
selectedItem = selectedItem,
repositoryState = repositoryState,
changeDefaultUpstreamBranchViewModel = { tabViewModel.tabViewModelsProvider.changeDefaultUpstreamBranchViewModel },
changeDefaultUpstreamBranchViewModel = { repositoryOpenViewModel.tabViewModelsProvider.changeDefaultUpstreamBranchViewModel },
)
}
else -> {
val diffViewModel = tabViewModel.diffViewModel
val diffViewModel = repositoryOpenViewModel.diffViewModel
if (diffViewModel != null) {
val tabFocusRequester = LocalTabFocusRequester.current
Diff(
diffViewModel = diffViewModel,
onCloseDiffView = {
tabViewModel.newDiffSelected = null
repositoryOpenViewModel.newDiffSelected = null
tabFocusRequester.requestFocus()
}
)
}
@ -367,8 +385,8 @@ fun MainContentView(
if (blameState is BlameState.Loaded) { // BlameState.isMinimized is true here
MinimizedBlame(
filePath = blameState.filePath,
onExpand = { tabViewModel.expandBlame() },
onClose = { tabViewModel.resetBlameState() }
onExpand = { repositoryOpenViewModel.expandBlame() },
onClose = { repositoryOpenViewModel.resetBlameState() }
)
}
}
@ -383,13 +401,13 @@ fun MainContentView(
when (selectedItem) {
SelectedItem.UncommittedChanges -> {
UncommittedChanges(
statusViewModel = tabViewModel.tabViewModelsProvider.statusViewModel,
statusViewModel = repositoryOpenViewModel.tabViewModelsProvider.statusViewModel,
selectedEntryType = diffSelected,
repositoryState = repositoryState,
onStagedDiffEntrySelected = { diffEntry ->
tabViewModel.minimizeBlame()
repositoryOpenViewModel.minimizeBlame()
tabViewModel.newDiffSelected = if (diffEntry != null) {
repositoryOpenViewModel.newDiffSelected = if (diffEntry != null) {
if (repositoryState == RepositoryState.SAFE)
DiffType.SafeStagedDiff(diffEntry)
else
@ -399,29 +417,29 @@ fun MainContentView(
}
},
onUnstagedDiffEntrySelected = { diffEntry ->
tabViewModel.minimizeBlame()
repositoryOpenViewModel.minimizeBlame()
if (repositoryState == RepositoryState.SAFE)
tabViewModel.newDiffSelected = DiffType.SafeUnstagedDiff(diffEntry)
repositoryOpenViewModel.newDiffSelected = DiffType.SafeUnstagedDiff(diffEntry)
else
tabViewModel.newDiffSelected = DiffType.UnsafeUnstagedDiff(diffEntry)
repositoryOpenViewModel.newDiffSelected = DiffType.UnsafeUnstagedDiff(diffEntry)
},
onBlameFile = { tabViewModel.blameFile(it) },
onHistoryFile = { tabViewModel.fileHistory(it) }
onBlameFile = { repositoryOpenViewModel.blameFile(it) },
onHistoryFile = { repositoryOpenViewModel.fileHistory(it) }
)
}
is SelectedItem.CommitBasedItem -> {
CommitChanges(
commitChangesViewModel = tabViewModel.tabViewModelsProvider.commitChangesViewModel,
commitChangesViewModel = repositoryOpenViewModel.tabViewModelsProvider.commitChangesViewModel,
selectedItem = selectedItem,
diffSelected = diffSelected,
onDiffSelected = { diffEntry ->
tabViewModel.minimizeBlame()
tabViewModel.newDiffSelected = DiffType.CommitDiff(diffEntry)
repositoryOpenViewModel.minimizeBlame()
repositoryOpenViewModel.newDiffSelected = DiffType.CommitDiff(diffEntry)
},
onBlame = { tabViewModel.blameFile(it) },
onHistory = { tabViewModel.fileHistory(it) },
onBlame = { repositoryOpenViewModel.blameFile(it) },
onHistory = { repositoryOpenViewModel.fileHistory(it) },
)
}
@ -431,19 +449,19 @@ fun MainContentView(
},
onFirstSizeDragStarted = { currentWidth ->
firstWidth = currentWidth
tabViewModel.setFirstPaneWidth(currentWidth)
repositoryOpenViewModel.setFirstPaneWidth(currentWidth)
},
onFirstSizeChange = {
val newWidth = firstWidth + it / density
if (newWidth > 150 && rebaseInteractiveState !is RebaseInteractiveState.AwaitingInteraction) {
firstWidth = newWidth
tabViewModel.setFirstPaneWidth(newWidth)
repositoryOpenViewModel.setFirstPaneWidth(newWidth)
}
},
onFirstSizeDragStopped = {
scope.launch {
tabViewModel.persistFirstPaneWidth()
repositoryOpenViewModel.persistFirstPaneWidth()
}
},
onThirdSizeChange = {
@ -451,16 +469,16 @@ fun MainContentView(
if (newWidth > 150) {
thirdWidth = newWidth
tabViewModel.setThirdPaneWidth(newWidth)
repositoryOpenViewModel.setThirdPaneWidth(newWidth)
}
},
onThirdSizeDragStarted = { currentWidth ->
thirdWidth = currentWidth
tabViewModel.setThirdPaneWidth(currentWidth)
repositoryOpenViewModel.setThirdPaneWidth(currentWidth)
},
onThirdSizeDragStopped = {
scope.launch {
tabViewModel.persistThirdPaneWidth()
repositoryOpenViewModel.persistThirdPaneWidth()
}
},
)

View File

@ -9,6 +9,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@ -32,6 +35,7 @@ import com.jetpackduba.gitnuro.ui.dialogs.AddSubmodulesDialog
import com.jetpackduba.gitnuro.ui.dialogs.SetDefaultUpstreamBranchDialog
import com.jetpackduba.gitnuro.viewmodels.ChangeDefaultUpstreamBranchViewModel
import com.jetpackduba.gitnuro.viewmodels.sidepanel.*
import kotlinx.coroutines.flow.collectLatest
import org.eclipse.jgit.lib.Ref
import org.eclipse.jgit.revwalk.RevCommit
import org.eclipse.jgit.submodule.SubmoduleStatus
@ -47,7 +51,7 @@ fun SidePanel(
stashesViewModel: StashesViewModel = sidePanelViewModel.stashesViewModel,
submodulesViewModel: SubmodulesViewModel = sidePanelViewModel.submodulesViewModel,
) {
var filter by remember(sidePanelViewModel) { mutableStateOf(sidePanelViewModel.filter.value) }
val filter by sidePanelViewModel.filter.collectAsState()
val selectedItem by sidePanelViewModel.selectedItem.collectAsState()
val branchesState by branchesViewModel.branchesState.collectAsState()
@ -59,16 +63,31 @@ fun SidePanel(
val (showAddEditRemote, setShowAddEditRemote) = remember { mutableStateOf<RemoteWrapper?>(null) }
val (branchToChangeUpstream, setBranchToChangeUpstream) = remember { mutableStateOf<Ref?>(null) }
var showEditSubmodulesDialog by remember { mutableStateOf(false) }
val searchFocusRequester = remember { FocusRequester() }
val tabFocusRequester = LocalTabFocusRequester.current
LaunchedEffect(sidePanelViewModel) {
sidePanelViewModel.freeSearchFocusFlow.collectLatest {
tabFocusRequester.requestFocus()
}
}
Column {
FilterTextField(
value = filter,
onValueChange = { newValue ->
filter = newValue
sidePanelViewModel.newFilter(newValue)
},
modifier = Modifier
.padding(start = 8.dp)
.focusRequester(searchFocusRequester)
.onFocusChanged {
if (it.isFocused) {
sidePanelViewModel.addSidePanelSearchToCloseables()
} else {
sidePanelViewModel.removeSidePanelSearchFromCloseables()
}
}
)
ScrollableLazyColumn(
@ -142,7 +161,11 @@ fun SidePanel(
}
@Composable
fun FilterTextField(value: String, onValueChange: (String) -> Unit, modifier: Modifier) {
fun FilterTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier
) {
AdjustableOutlinedTextField(
value = value,
hint = "Search for branches, tags & more",

View File

@ -6,7 +6,10 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.*
@ -20,6 +23,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.compositeOver
@ -49,6 +53,8 @@ import com.jetpackduba.gitnuro.ui.tree_files.TreeItem
import com.jetpackduba.gitnuro.viewmodels.CommitterDataRequestState
import com.jetpackduba.gitnuro.viewmodels.StageStateUi
import com.jetpackduba.gitnuro.viewmodels.StatusViewModel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.eclipse.jgit.lib.RepositoryState
@Composable
@ -83,15 +89,33 @@ fun UncommittedChanges(
val doCommit = {
statusViewModel.commit(commitMessage)
onStagedDiffEntrySelected(null)
setCommitMessage("")
}
val canCommit = commitMessage.isNotEmpty() && stageStateUi.hasStagedFiles
val canAmend = commitMessage.isNotEmpty() && statusViewModel.hasPreviousCommits
val tabFocusRequester = LocalTabFocusRequester.current
LaunchedEffect(statusViewModel) {
statusViewModel.commitMessageChangesFlow.collect { newCommitMessage ->
setCommitMessage(newCommitMessage)
launch {
statusViewModel.commitMessageChangesFlow.collect { newCommitMessage ->
setCommitMessage(newCommitMessage)
}
}
launch {
statusViewModel.showSearchUnstaged.collectLatest { show ->
if (!show) {
tabFocusRequester.requestFocus()
}
}
}
launch {
statusViewModel.showSearchStaged.collectLatest { show ->
if (!show) {
tabFocusRequester.requestFocus()
}
}
}
}
@ -131,6 +155,7 @@ fun UncommittedChanges(
stagedListState,
selectedEntryType,
onSearchFilterToggled = { statusViewModel.onSearchFilterToggledStaged(it) },
onSearchFocused = { statusViewModel.addStagedSearchToCloseableView() },
onDiffEntryOptionSelected = { statusViewModel.unstage(it) },
onDiffEntrySelected = onStagedDiffEntrySelected,
onSearchFilterChanged = { statusViewModel.onSearchFilterChangedStaged(it) },
@ -154,6 +179,7 @@ fun UncommittedChanges(
unstagedListState,
selectedEntryType,
onSearchFilterToggled = { statusViewModel.onSearchFilterToggledUnstaged(it) },
onSearchFocused = { statusViewModel.addUnstagedSearchToCloseableView() },
onDiffEntryOptionSelected = { statusViewModel.stage(it) },
onDiffEntrySelected = onUnstagedDiffEntrySelected,
onSearchFilterChanged = { statusViewModel.onSearchFilterChangedUnstaged(it) },
@ -323,6 +349,7 @@ fun ColumnScope.StagedView(
stagedListState: LazyListState,
selectedEntryType: DiffType?,
onSearchFilterToggled: (Boolean) -> Unit,
onSearchFocused: () -> Unit,
onDiffEntryOptionSelected: (StatusEntry) -> Unit,
onDiffEntrySelected: (StatusEntry) -> Unit,
onSearchFilterChanged: (TextFieldValue) -> Unit,
@ -356,6 +383,7 @@ fun ColumnScope.StagedView(
listState = stagedListState,
selectedEntryType = selectedEntryType,
onSearchFilterToggled = onSearchFilterToggled,
onSearchFocused = onSearchFocused,
onDiffEntryOptionSelected = onDiffEntryOptionSelected,
onDiffEntrySelected = onDiffEntrySelected,
onSearchFilterChanged = onSearchFilterChanged,
@ -381,6 +409,7 @@ fun ColumnScope.UnstagedView(
unstagedListState: LazyListState,
selectedEntryType: DiffType?,
onSearchFilterToggled: (Boolean) -> Unit,
onSearchFocused: () -> Unit,
onDiffEntryOptionSelected: (StatusEntry) -> Unit,
onDiffEntrySelected: (StatusEntry) -> Unit,
onSearchFilterChanged: (TextFieldValue) -> Unit,
@ -414,6 +443,7 @@ fun ColumnScope.UnstagedView(
listState = unstagedListState,
selectedEntryType = selectedEntryType,
onSearchFilterToggled = onSearchFilterToggled,
onSearchFocused = onSearchFocused,
onDiffEntryOptionSelected = onDiffEntryOptionSelected,
onDiffEntrySelected = onDiffEntrySelected,
onSearchFilterChanged = onSearchFilterChanged,
@ -448,6 +478,7 @@ fun ColumnScope.NeutralView(
onTreeEntries: (StageStateUi.TreeLoaded) -> List<TreeItem<StatusEntry>>,
onListEntries: (StageStateUi.ListLoaded) -> List<StatusEntry>,
onSearchFilterToggled: (Boolean) -> Unit,
onSearchFocused: () -> Unit,
onDiffEntryOptionSelected: (StatusEntry) -> Unit,
onDiffEntrySelected: (StatusEntry) -> Unit,
onSearchFilterChanged: (TextFieldValue) -> Unit,
@ -477,6 +508,7 @@ fun ColumnScope.NeutralView(
showSearch = showSearchUnstaged,
searchFilter = searchFilterUnstaged,
onSearchFilterToggled = onSearchFilterToggled,
onSearchFocused = onSearchFocused,
onSearchFilterChanged = onSearchFilterChanged,
statusEntries = onTreeEntries(stageStateUi),
lazyListState = listState,
@ -516,6 +548,7 @@ fun ColumnScope.NeutralView(
showSearch = showSearchUnstaged,
searchFilter = searchFilterUnstaged,
onSearchFilterToggled = onSearchFilterToggled,
onSearchFocused = onSearchFocused,
onSearchFilterChanged = onSearchFilterChanged,
statusEntries = onListEntries(stageStateUi),
lazyListState = listState,
@ -789,6 +822,7 @@ private fun EntriesList(
showSearch: Boolean,
searchFilter: TextFieldValue,
onSearchFilterToggled: (Boolean) -> Unit,
onSearchFocused: () -> Unit,
onSearchFilterChanged: (TextFieldValue) -> Unit,
statusEntries: List<StatusEntry>,
lazyListState: LazyListState,
@ -816,6 +850,7 @@ private fun EntriesList(
onSearchFilterToggled = onSearchFilterToggled,
showAsTree = false,
showSearch = showSearch,
onSearchFocused = onSearchFocused,
)
@ -859,6 +894,7 @@ private fun TreeEntriesList(
showSearch: Boolean,
searchFilter: TextFieldValue,
onSearchFilterToggled: (Boolean) -> Unit,
onSearchFocused: () -> Unit,
onSearchFilterChanged: (TextFieldValue) -> Unit,
statusEntries: List<TreeItem<StatusEntry>>,
lazyListState: LazyListState,
@ -886,6 +922,7 @@ private fun TreeEntriesList(
searchFilter = searchFilter,
onSearchFilterChanged = onSearchFilterChanged,
onSearchFilterToggled = onSearchFilterToggled,
onSearchFocused = onSearchFocused,
showAsTree = true,
showSearch = showSearch,
)
@ -939,6 +976,7 @@ fun EntriesHeader(
onAllAction: () -> Unit,
onAlternateShowAsTree: () -> Unit,
onSearchFilterToggled: (Boolean) -> Unit,
onSearchFocused: () -> Unit,
searchFilter: TextFieldValue,
onSearchFilterChanged: (TextFieldValue) -> Unit,
) {
@ -1022,6 +1060,7 @@ fun EntriesHeader(
searchFilter = searchFilter,
onSearchFilterChanged = onSearchFilterChanged,
searchFocusRequester = searchFocusRequester,
onSearchFocused = onSearchFocused,
onClose = { onSearchFilterToggled(false) },
)
}

View File

@ -25,7 +25,9 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.input.key.*
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@ -119,11 +121,24 @@ fun WelcomeView(
) {
var showAdditionalInfo by remember { mutableStateOf(false) }
val searchFocusRequester = remember { FocusRequester() }
val welcomeViewFocusRequester = remember { FocusRequester() }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.surface),
.focusable(true)
.focusRequester(welcomeViewFocusRequester)
.background(MaterialTheme.colors.surface)
.onPreviewKeyEvent {
when {
it.matchesBinding(KeybindingOption.OPEN_REPOSITORY) -> {
onOpenRepository()
true
}
else -> false
}
},
) {
Column(
@ -156,6 +171,7 @@ fun WelcomeView(
canRepositoriesBeRemoved = true,
onOpenKnownRepository = onOpenKnownRepository,
onRemoveRepositoryFromRecent = onRemoveRepositoryFromRecent,
searchFieldFocusRequester = searchFocusRequester,
)
}
}
@ -173,6 +189,14 @@ fun WelcomeView(
)
}
LaunchedEffect(recentlyOpenedRepositories.isEmpty()) {
if (recentlyOpenedRepositories.isEmpty()) {
welcomeViewFocusRequester.requestFocus()
} else {
searchFocusRequester.requestFocus()
}
}
if (showAdditionalInfo) {
AppInfoDialog(
onClose = { showAdditionalInfo = false },
@ -287,6 +311,7 @@ fun RecentRepositories(
canRepositoriesBeRemoved: Boolean,
onRemoveRepositoryFromRecent: (String) -> Unit,
onOpenKnownRepository: (String) -> Unit,
searchFieldFocusRequester: FocusRequester,
) {
Column(
modifier = Modifier
@ -307,6 +332,7 @@ fun RecentRepositories(
canRepositoriesBeRemoved = canRepositoriesBeRemoved,
onRemoveRepositoryFromRecent = onRemoveRepositoryFromRecent,
onOpenKnownRepository = onOpenKnownRepository,
searchFieldFocusRequester = searchFieldFocusRequester,
)
}
}
@ -316,7 +342,7 @@ fun RecentRepositories(
fun RecentRepositoriesList(
recentlyOpenedRepositories: List<String>,
canRepositoriesBeRemoved: Boolean,
searchFieldFocusRequester: FocusRequester = remember { FocusRequester() },
searchFieldFocusRequester: FocusRequester,
onRemoveRepositoryFromRecent: (String) -> Unit,
onOpenKnownRepository: (String) -> Unit,
) {
@ -346,7 +372,7 @@ fun RecentRepositoriesList(
return@onPreviewKeyEvent false
}
when {
when {
it.matchesBinding(KeybindingOption.DOWN) -> {
if (focusedItemIndex < filteredRepositories.lastIndex) {
focusedItemIndex += 1

View File

@ -0,0 +1,93 @@
package com.jetpackduba.gitnuro.ui.components
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.jetpackduba.gitnuro.AppIcons
import com.jetpackduba.gitnuro.models.Notification
import com.jetpackduba.gitnuro.models.NotificationType
import com.jetpackduba.gitnuro.theme.AppTheme
@Preview
@Composable
fun NotificationSuccessPreview() {
AppTheme(customTheme = null) {
Notification(NotificationType.Positive, "Hello world!")
}
}
@Composable
fun Notification(notification: Notification) {
val notificationShape = RoundedCornerShape(8.dp)
Row(
modifier = Modifier
.padding(8.dp)
.border(2.dp, MaterialTheme.colors.onBackground.copy(0.2f), notificationShape)
.clip(notificationShape)
.background(MaterialTheme.colors.background)
.height(IntrinsicSize.Max)
.padding(2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val backgroundColor = when (notification.type) {
NotificationType.Positive -> MaterialTheme.colors.primary
NotificationType.Warning -> MaterialTheme.colors.secondary
NotificationType.Error -> MaterialTheme.colors.error
}
val contentColor = when (notification.type) {
NotificationType.Positive -> MaterialTheme.colors.onPrimary
NotificationType.Warning -> MaterialTheme.colors.onSecondary
NotificationType.Error -> MaterialTheme.colors.onError
}
val icon = when (notification.type) {
NotificationType.Positive -> AppIcons.INFO
NotificationType.Warning -> AppIcons.WARNING
NotificationType.Error -> AppIcons.ERROR
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(topStart = 6.dp, bottomStart = 6.dp))
.background(backgroundColor)
.fillMaxHeight()
) {
Icon(
painterResource(icon),
contentDescription = null,
tint = contentColor,
modifier = Modifier
.padding(4.dp)
)
}
Box(
modifier = Modifier
.padding(end = 8.dp)
.fillMaxHeight(),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = notification.text,
modifier = Modifier,
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.body1,
)
}
}
}

View File

@ -21,7 +21,6 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.jetpackduba.gitnuro.AppIcons
import com.jetpackduba.gitnuro.di.AppComponent

View File

@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
@ -28,20 +29,14 @@ import com.jetpackduba.gitnuro.keybindings.matchesBinding
fun SearchTextField(
searchFilter: TextFieldValue,
onSearchFilterChanged: (TextFieldValue) -> Unit,
onSearchFocused: () -> Unit,
searchFocusRequester: FocusRequester,
onClose: () -> Unit,
) {
Box(
modifier = Modifier
.background(MaterialTheme.colors.background)
.padding(horizontal = 4.dp, vertical = 4.dp)
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.matchesBinding(KeybindingOption.EXIT) && keyEvent.type == KeyEventType.KeyDown) {
onClose()
true
} else
false
},
.padding(horizontal = 4.dp, vertical = 4.dp),
) {
AdjustableOutlinedTextField(
value = searchFilter,
@ -51,7 +46,8 @@ fun SearchTextField(
hint = "Search files by name or path",
modifier = Modifier.fillMaxWidth()
.focusable()
.focusRequester(searchFocusRequester),
.focusRequester(searchFocusRequester)
.onFocusChanged { if (it.isFocused) onSearchFocused() },
trailingIcon = {
IconButton(
onClick = onClose,

View File

@ -0,0 +1,52 @@
package com.jetpackduba.gitnuro.ui.dialogs
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import com.jetpackduba.gitnuro.credentials.CredentialsAccepted
import com.jetpackduba.gitnuro.credentials.CredentialsRequested
import com.jetpackduba.gitnuro.credentials.CredentialsState
import com.jetpackduba.gitnuro.viewmodels.TabViewModel
@Composable
fun CredentialsDialog(gitManager: TabViewModel) {
val credentialsState = gitManager.credentialsState.collectAsState()
when (val credentialsStateValue = credentialsState.value) {
CredentialsRequested.HttpCredentialsRequested -> {
UserPasswordDialog(
onReject = {
gitManager.credentialsDenied()
},
onAccept = { user, password ->
gitManager.httpCredentialsAccepted(user, password)
}
)
}
CredentialsRequested.SshCredentialsRequested -> {
SshPasswordDialog(
onReject = {
gitManager.credentialsDenied()
},
onAccept = { password ->
gitManager.sshCredentialsAccepted(password)
}
)
}
is CredentialsRequested.GpgCredentialsRequested -> {
GpgPasswordDialog(
gpgCredentialsRequested = credentialsStateValue,
onReject = {
gitManager.credentialsDenied()
},
onAccept = { password ->
gitManager.gpgCredentialsAccepted(password)
}
)
}
is CredentialsAccepted, CredentialsState.None, CredentialsState.CredentialsDenied -> { /* Nothing to do */
}
}
}

View File

@ -32,7 +32,9 @@ fun ErrorDialog(
val clipboard = LocalClipboardManager.current
var showStackTrace by remember { mutableStateOf(false) }
MaterialDialog {
MaterialDialog (
onCloseRequested = onAccept,
) {
Column(
modifier = Modifier
.width(580.dp)

View File

@ -390,7 +390,7 @@ fun Terminal(settingsViewModel: SettingsViewModel) {
fun Logs(settingsViewModel: SettingsViewModel) {
SettingButton(
title = "Logs",
subtitle = "View the logs folder",
subtitle = "Open the logs folder",
buttonText = "Open folder",
onClick = {
settingsViewModel.openLogsFolderInFileExplorer()

View File

@ -20,12 +20,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.res.loadImageBitmap
@ -38,6 +36,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.jetpackduba.gitnuro.AppIcons
import com.jetpackduba.gitnuro.extensions.*
import com.jetpackduba.gitnuro.git.CloseableView
import com.jetpackduba.gitnuro.git.DiffType
import com.jetpackduba.gitnuro.git.EntryContent
import com.jetpackduba.gitnuro.git.animatedImages
@ -47,8 +46,6 @@ import com.jetpackduba.gitnuro.git.diff.Line
import com.jetpackduba.gitnuro.git.diff.LineType
import com.jetpackduba.gitnuro.git.workspace.StatusEntry
import com.jetpackduba.gitnuro.git.workspace.StatusType
import com.jetpackduba.gitnuro.keybindings.KeybindingOption
import com.jetpackduba.gitnuro.keybindings.matchesBinding
import com.jetpackduba.gitnuro.theme.*
import com.jetpackduba.gitnuro.ui.components.PrimaryButton
import com.jetpackduba.gitnuro.ui.components.ScrollableLazyColumn
@ -62,6 +59,7 @@ import com.jetpackduba.gitnuro.viewmodels.DiffViewModel
import com.jetpackduba.gitnuro.viewmodels.TextDiffType
import com.jetpackduba.gitnuro.viewmodels.ViewDiffResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.withContext
import org.eclipse.jgit.diff.DiffEntry
import org.eclipse.jgit.submodule.SubmoduleStatusType
@ -93,18 +91,22 @@ fun Diff(
val viewDiffResult = diffResultState.value ?: return
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
diffViewModel.closeViewFlow.collectLatest {
if (it == CloseableView.DIFF) onCloseDiffView()
}
}
Column(
modifier = Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
.focusable()
.focusable(true)
.focusRequester(focusRequester)
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.matchesBinding(KeybindingOption.EXIT) && keyEvent.type == KeyEventType.KeyDown) {
onCloseDiffView()
true
} else
false
.onFocusChanged {
if (it.isFocused) {
diffViewModel.addToCloseables()
}
}
) {
when (viewDiffResult) {

View File

@ -24,6 +24,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
@ -115,6 +116,7 @@ fun Log(
repositoryState = repositoryState,
changeDefaultUpstreamBranchViewModel = changeDefaultUpstreamBranchViewModel,
)
LogStatus.Loading -> LogLoading()
}
}
@ -204,8 +206,13 @@ private fun LogLoaded(
if (searchFilterValue is LogSearch.SearchResults) {
SearchFilter(logViewModel, searchFilterValue)
SearchFilter(
logViewModel = logViewModel,
searchFilterResults = searchFilterValue,
searchFocused = { logViewModel.addSearchToCloseableView() },
)
}
GraphHeader(
graphWidth = graphWidth,
onPaddingChange = {
@ -353,7 +360,8 @@ suspend fun scrollToUncommittedChanges(
@Composable
fun SearchFilter(
logViewModel: LogViewModel,
searchFilterResults: LogSearch.SearchResults
searchFilterResults: LogSearch.SearchResults,
searchFocused: () -> Unit,
) {
val scope = rememberCoroutineScope()
var searchFilterText by remember { mutableStateOf(logViewModel.savedSearchFilter) }
@ -381,6 +389,11 @@ fun SearchFilter(
modifier = Modifier
.fillMaxSize()
.focusRequester(textFieldFocusRequester)
.onFocusChanged {
if (it.isFocused) {
searchFocused()
}
}
.onPreviewKeyEvent { keyEvent ->
when {
keyEvent.matchesBinding(KeybindingOption.SIMPLE_ACCEPT) -> {
@ -390,11 +403,6 @@ fun SearchFilter(
true
}
keyEvent.matchesBinding(KeybindingOption.EXIT) -> {
logViewModel.closeSearch()
true
}
else -> false
}
},

View File

@ -1,6 +1,5 @@
package com.jetpackduba.gitnuro.ui.tree_files
import com.jetpackduba.gitnuro.system.systemSeparator
import kotlin.math.max
fun <T> entriesToTreeEntry(
@ -18,12 +17,12 @@ fun <T> entriesToTreeEntry(
}
.map { entry ->
val filePath = onGetEntryPath(entry)
val parts = filePath.split(systemSeparator)
val parts = filePath.split("/")
parts.mapIndexed { index, partName ->
if (index == parts.lastIndex) {
val isParentDirectoryContracted = treeContractedDirs.any { contractedDir ->
filePath.startsWith(contractedDir + systemSeparator)
filePath.startsWith(contractedDir + "/")
}
if (isParentDirectoryContracted) {
@ -32,9 +31,9 @@ fun <T> entriesToTreeEntry(
TreeItem.File(entry, partName, filePath, index)
}
} else {
val dirPath = parts.slice(0..index).joinToString(systemSeparator)
val dirPath = parts.slice(0..index).joinToString("/")
val isParentDirectoryContracted = treeContractedDirs.any { contractedDir ->
dirPath.startsWith(contractedDir + systemSeparator) &&
dirPath.startsWith(contractedDir + "/") &&
dirPath != contractedDir
}
val isExactDirectoryContracted = treeContractedDirs.any { contractedDir ->
@ -57,8 +56,8 @@ fun <T> entriesToTreeEntry(
private class PathsComparator : Comparator<String> {
override fun compare(path1: String, path2: String): Int {
val path1Parts = path1.split(systemSeparator)
val path2Parts = path2.split(systemSeparator)
val path1Parts = path1.split("/")
val path2Parts = path2.split("/")
val maxIndex = max(path1Parts.count(), path2Parts.count())

View File

@ -19,7 +19,7 @@ private val updateJson = Json {
class UpdatesRepository @Inject constructor(
private val updatesWebService: UpdatesService,
) {
fun hasUpdatesFlow() = flow {
val hasUpdatesFlow = flow {
val latestReleaseJson = updatesWebService.release(AppConstants.VERSION_CHECK_URL)
while (coroutineContext.isActive) {

View File

@ -7,6 +7,7 @@ import com.jetpackduba.gitnuro.extensions.delayedStateChange
import com.jetpackduba.gitnuro.extensions.filePath
import com.jetpackduba.gitnuro.extensions.fullData
import com.jetpackduba.gitnuro.extensions.lowercaseContains
import com.jetpackduba.gitnuro.git.CloseableView
import com.jetpackduba.gitnuro.git.RefreshType
import com.jetpackduba.gitnuro.git.TabState
import com.jetpackduba.gitnuro.git.diff.GetCommitDiffEntriesUseCase
@ -15,6 +16,7 @@ import com.jetpackduba.gitnuro.ui.tree_files.TreeItem
import com.jetpackduba.gitnuro.ui.tree_files.entriesToTreeEntry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.eclipse.jgit.diff.DiffEntry
import org.eclipse.jgit.revwalk.RevCommit
import javax.inject.Inject
@ -25,7 +27,7 @@ class CommitChangesViewModel @Inject constructor(
private val tabState: TabState,
private val getCommitDiffEntriesUseCase: GetCommitDiffEntriesUseCase,
private val appSettingsRepository: AppSettingsRepository,
tabScope: CoroutineScope,
private val tabScope: CoroutineScope,
) {
private val _showSearch = MutableStateFlow(false)
val showSearch: StateFlow<Boolean> = _showSearch
@ -82,6 +84,26 @@ class CommitChangesViewModel @Inject constructor(
)
init {
tabScope.launch {
_showSearch.collectLatest {
if (it) {
addSearchToCloseableView()
} else {
removeSearchFromCloseableView()
}
}
}
tabScope.launch {
tabState.closeViewFlow.collectLatest {
if (it == CloseableView.COMMIT_CHANGES_SEARCH) {
onSearchFilterToggled(false)
}
}
}
}
fun loadChanges(commit: RevCommit) = tabState.runOperation(
refreshType = RefreshType.NONE,
) { git ->
@ -153,6 +175,14 @@ class CommitChangesViewModel @Inject constructor(
fun onSearchFilterChanged(filter: TextFieldValue) {
_searchFilter.value = filter
}
fun addSearchToCloseableView() = tabScope.launch {
tabState.addCloseableView(CloseableView.COMMIT_CHANGES_SEARCH)
}
private fun removeSearchFromCloseableView() = tabScope.launch {
tabState.removeCloseableView(CloseableView.COMMIT_CHANGES_SEARCH)
}
}
private sealed interface CommitChangesState {
@ -167,6 +197,7 @@ sealed interface CommitChangesStateUi {
sealed interface Loaded : CommitChangesStateUi {
val commit: RevCommit
}
data class ListLoaded(override val commit: RevCommit, val changes: List<DiffEntry>) :
Loaded

View File

@ -3,6 +3,7 @@ package com.jetpackduba.gitnuro.viewmodels
import androidx.compose.foundation.lazy.LazyListState
import com.jetpackduba.gitnuro.exceptions.MissingDiffEntryException
import com.jetpackduba.gitnuro.extensions.delayedStateChange
import com.jetpackduba.gitnuro.git.CloseableView
import com.jetpackduba.gitnuro.git.DiffType
import com.jetpackduba.gitnuro.git.RefreshType
import com.jetpackduba.gitnuro.git.TabState
@ -37,11 +38,13 @@ class DiffViewModel @Inject constructor(
private val generateSplitHunkFromDiffResultUseCase: GenerateSplitHunkFromDiffResultUseCase,
private val discardUnstagedHunkLineUseCase: DiscardUnstagedHunkLineUseCase,
private val tabsManager: TabsManager,
tabScope: CoroutineScope,
) {
private val tabScope: CoroutineScope,
) : AutoCloseable {
private val _diffResult = MutableStateFlow<ViewDiffResult>(ViewDiffResult.Loading(""))
val diffResult: StateFlow<ViewDiffResult?> = _diffResult
val closeViewFlow = tabState.closeViewFlow
val diffTypeFlow = settings.textDiffTypeFlow
val isDisplayFullFile = settings.diffDisplayFullFileFlow
@ -92,6 +95,8 @@ class DiffViewModel @Inject constructor(
)
fun updateDiff(diffType: DiffType) {
addToCloseables()
diffJob = tabState.runOperation(refreshType = RefreshType.NONE) { git ->
this.diffType = diffType
@ -223,6 +228,19 @@ class DiffViewModel @Inject constructor(
fun openSubmodule(path: String) = tabState.runOperation(refreshType = RefreshType.NONE) { git ->
tabsManager.addNewTabFromPath("${git.repository.workTree}/$path", true)
}
fun addToCloseables() = tabScope.launch {
tabState.addCloseableView(CloseableView.DIFF)
}
private fun removeFromCloseables() = tabScope.launch {
tabState.removeCloseableView(CloseableView.DIFF)
}
override fun close() {
cancelRunningJobs()
removeFromCloseables()
}
}
enum class TextDiffType(val value: Int) {

View File

@ -0,0 +1,19 @@
package com.jetpackduba.gitnuro.viewmodels
import com.jetpackduba.gitnuro.models.AuthorInfoSimple
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.eclipse.jgit.api.Git
import javax.inject.Inject
class GetAuthorInfoUseCase @Inject constructor() {
suspend operator fun invoke(git: Git) = withContext(Dispatchers.IO) {
val config = git.repository.config
config.load()
val userName = config.getString("user", null, "name")
val email = config.getString("user", null, "email")
AuthorInfoSimple(userName, email)
}
}

View File

@ -9,10 +9,9 @@ import com.jetpackduba.gitnuro.git.remote_operations.PullType
import com.jetpackduba.gitnuro.git.remote_operations.PushBranchUseCase
import com.jetpackduba.gitnuro.git.stash.PopLastStashUseCase
import com.jetpackduba.gitnuro.git.stash.StashChangesUseCase
import com.jetpackduba.gitnuro.managers.AppStateManager
import com.jetpackduba.gitnuro.models.errorNotification
import com.jetpackduba.gitnuro.models.positiveNotification
import com.jetpackduba.gitnuro.repositories.AppSettingsRepository
import com.jetpackduba.gitnuro.models.warningNotification
import com.jetpackduba.gitnuro.terminal.OpenRepositoryInTerminalUseCase
import kotlinx.coroutines.Job
import javax.inject.Inject
@ -26,7 +25,7 @@ interface IGlobalMenuActionsViewModel {
fun openTerminal(): Job
}
class GlobalMenuActionsViewModel @Inject constructor(
class GlobalMenuActionsViewModel @Inject constructor(
private val tabState: TabState,
private val pullBranchUseCase: PullBranchUseCase,
private val pushBranchUseCase: PushBranchUseCase,
@ -34,8 +33,6 @@ class GlobalMenuActionsViewModel @Inject constructor(
private val popLastStashUseCase: PopLastStashUseCase,
private val stashChangesUseCase: StashChangesUseCase,
private val openRepositoryInTerminalUseCase: OpenRepositoryInTerminalUseCase,
settings: AppSettingsRepository,
appStateManager: AppStateManager,
) : IGlobalMenuActionsViewModel {
override fun pull(pullType: PullType) = tabState.safeProcessing(
refreshType = RefreshType.ALL_DATA,
@ -44,9 +41,11 @@ class GlobalMenuActionsViewModel @Inject constructor(
refreshEvenIfCrashes = true,
taskType = TaskType.PULL,
) { git ->
pullBranchUseCase(git, pullType)
positiveNotification("Pull completed")
if (pullBranchUseCase(git, pullType)) {
warningNotification("Pull produced conflicts, fix them to continue")
} else {
positiveNotification("Pull completed")
}
}
override fun fetchAll() = tabState.safeProcessing(

View File

@ -5,6 +5,7 @@ import androidx.compose.foundation.lazy.LazyListState
import com.jetpackduba.gitnuro.TaskType
import com.jetpackduba.gitnuro.extensions.delayedStateChange
import com.jetpackduba.gitnuro.extensions.shortName
import com.jetpackduba.gitnuro.git.CloseableView
import com.jetpackduba.gitnuro.git.RefreshType
import com.jetpackduba.gitnuro.git.TabState
import com.jetpackduba.gitnuro.git.TaskEvent
@ -58,7 +59,7 @@ class LogViewModel @Inject constructor(
private val startRebaseInteractiveUseCase: StartRebaseInteractiveUseCase,
private val tabState: TabState,
private val appSettingsRepository: AppSettingsRepository,
tabScope: CoroutineScope,
private val tabScope: CoroutineScope,
sharedStashViewModel: SharedStashViewModel,
sharedBranchesViewModel: SharedBranchesViewModel,
sharedRemotesViewModel: SharedRemotesViewModel,
@ -124,6 +125,23 @@ class LogViewModel @Inject constructor(
refresh(tabState.git)
}
}
tabScope.launch {
tabState.closeViewFlow.collectLatest {
if (it == CloseableView.LOG_SEARCH) {
_logSearchFilterResults.value = LogSearch.NotSearching
}
}
}
tabScope.launch {
_logSearchFilterResults.collectLatest {
when (it) {
LogSearch.NotSearching -> removeSearchFromCloseableView()
is LogSearch.SearchResults -> addSearchToCloseableView()
}
}
}
}
@ -325,8 +343,9 @@ class LogViewModel @Inject constructor(
}
_logSearchFilterResults.value = LogSearch.SearchResults(matchingCommits, startingUiIndex)
} else
} else {
_logSearchFilterResults.value = LogSearch.SearchResults(emptyList(), NONE_MATCHING_INDEX)
}
}
suspend fun selectPreviousFilterCommit() {
@ -379,6 +398,14 @@ class LogViewModel @Inject constructor(
_logSearchFilterResults.value = LogSearch.NotSearching
}
fun addSearchToCloseableView() = tabScope.launch {
tabState.addCloseableView(CloseableView.LOG_SEARCH)
}
private fun removeSearchFromCloseableView() = tabScope.launch {
tabState.removeCloseableView(CloseableView.LOG_SEARCH)
}
fun rebaseInteractive(revCommit: RevCommit) = tabState.safeProcessing(
refreshType = RefreshType.REBASE_INTERACTIVE_STATE,
taskType = TaskType.REBASE_INTERACTIVE,

View File

@ -1,25 +1,10 @@
package com.jetpackduba.gitnuro.viewmodels
import com.jetpackduba.gitnuro.TaskType
import com.jetpackduba.gitnuro.git.RefreshType
import com.jetpackduba.gitnuro.git.TabState
import com.jetpackduba.gitnuro.git.remote_operations.FetchAllRemotesUseCase
import com.jetpackduba.gitnuro.git.remote_operations.PullBranchUseCase
import com.jetpackduba.gitnuro.git.remote_operations.PullType
import com.jetpackduba.gitnuro.git.remote_operations.PushBranchUseCase
import com.jetpackduba.gitnuro.git.stash.PopLastStashUseCase
import com.jetpackduba.gitnuro.git.stash.StashChangesUseCase
import com.jetpackduba.gitnuro.git.workspace.StageUntrackedFileUseCase
import com.jetpackduba.gitnuro.managers.AppStateManager
import com.jetpackduba.gitnuro.models.errorNotification
import com.jetpackduba.gitnuro.models.positiveNotification
import com.jetpackduba.gitnuro.models.warningNotification
import com.jetpackduba.gitnuro.repositories.AppSettingsRepository
import com.jetpackduba.gitnuro.terminal.OpenRepositoryInTerminalUseCase
import javax.inject.Inject
class MenuViewModel @Inject constructor(
private val tabState: TabState,
private val globalMenuActionsViewModel: GlobalMenuActionsViewModel,
settings: AppSettingsRepository,
appStateManager: AppStateManager,

View File

@ -0,0 +1,372 @@
package com.jetpackduba.gitnuro.viewmodels
import com.jetpackduba.gitnuro.SharedRepositoryStateManager
import com.jetpackduba.gitnuro.TaskType
import com.jetpackduba.gitnuro.exceptions.codeToMessage
import com.jetpackduba.gitnuro.git.*
import com.jetpackduba.gitnuro.git.branches.CreateBranchUseCase
import com.jetpackduba.gitnuro.git.rebase.RebaseInteractiveState
import com.jetpackduba.gitnuro.git.stash.StashChangesUseCase
import com.jetpackduba.gitnuro.git.workspace.StageUntrackedFileUseCase
import com.jetpackduba.gitnuro.logging.printDebug
import com.jetpackduba.gitnuro.logging.printLog
import com.jetpackduba.gitnuro.managers.AppStateManager
import com.jetpackduba.gitnuro.managers.ErrorsManager
import com.jetpackduba.gitnuro.managers.newErrorNow
import com.jetpackduba.gitnuro.models.AuthorInfoSimple
import com.jetpackduba.gitnuro.models.errorNotification
import com.jetpackduba.gitnuro.models.positiveNotification
import com.jetpackduba.gitnuro.system.OpenFilePickerUseCase
import com.jetpackduba.gitnuro.system.OpenUrlInBrowserUseCase
import com.jetpackduba.gitnuro.system.PickerType
import com.jetpackduba.gitnuro.ui.IVerticalSplitPaneConfig
import com.jetpackduba.gitnuro.ui.SelectedItem
import com.jetpackduba.gitnuro.ui.TabsManager
import com.jetpackduba.gitnuro.ui.VerticalSplitPaneConfig
import com.jetpackduba.gitnuro.updates.Update
import com.jetpackduba.gitnuro.updates.UpdatesRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.api.errors.CheckoutConflictException
import org.eclipse.jgit.blame.BlameResult
import org.eclipse.jgit.lib.RepositoryState
import org.eclipse.jgit.revwalk.RevCommit
import java.awt.Desktop
import javax.inject.Inject
import javax.inject.Provider
private const val MIN_TIME_AFTER_GIT_OPERATION = 2000L
private const val TAG = "TabViewModel"
/**
* Contains all the information related to a tab and its subcomponents (smaller composables like the log, branches,
* commit changes, etc.). It holds a reference to every view model because this class lives as long as the tab is open (survives
* across full app recompositions), therefore, tab's content can be recreated with these view models.
*/
class RepositoryOpenViewModel @Inject constructor(
private val getWorkspacePathUseCase: GetWorkspacePathUseCase,
private val diffViewModelProvider: Provider<DiffViewModel>,
private val historyViewModelProvider: Provider<HistoryViewModel>,
private val authorViewModelProvider: Provider<AuthorViewModel>,
private val tabState: TabState,
val appStateManager: AppStateManager,
private val fileChangesWatcher: FileChangesWatcher,
private val getAuthorInfoUseCase: GetAuthorInfoUseCase,
private val createBranchUseCase: CreateBranchUseCase,
private val stashChangesUseCase: StashChangesUseCase,
private val stageUntrackedFileUseCase: StageUntrackedFileUseCase,
private val openFilePickerUseCase: OpenFilePickerUseCase,
private val openUrlInBrowserUseCase: OpenUrlInBrowserUseCase,
private val tabsManager: TabsManager,
private val tabScope: CoroutineScope,
private val verticalSplitPaneConfig: VerticalSplitPaneConfig,
val tabViewModelsProvider: TabViewModelsProvider,
private val globalMenuActionsViewModel: GlobalMenuActionsViewModel,
sharedRepositoryStateManager: SharedRepositoryStateManager,
updatesRepository: UpdatesRepository,
) : IVerticalSplitPaneConfig by verticalSplitPaneConfig,
IGlobalMenuActionsViewModel by globalMenuActionsViewModel {
private val errorsManager: ErrorsManager = tabState.errorsManager
val selectedItem: StateFlow<SelectedItem> = tabState.selectedItem
var diffViewModel: DiffViewModel? = null
val repositoryState: StateFlow<RepositoryState> = sharedRepositoryStateManager.repositoryState
val rebaseInteractiveState: StateFlow<RebaseInteractiveState> = sharedRepositoryStateManager.rebaseInteractiveState
private val _diffSelected = MutableStateFlow<DiffType?>(null)
val diffSelected: StateFlow<DiffType?> = _diffSelected
var newDiffSelected: DiffType?
get() = diffSelected.value
set(value) {
_diffSelected.value = value
updateDiffEntry()
}
private val _blameState = MutableStateFlow<BlameState>(BlameState.None)
val blameState: StateFlow<BlameState> = _blameState
private val _showHistory = MutableStateFlow(false)
val showHistory: StateFlow<Boolean> = _showHistory
private val _showAuthorInfo = MutableStateFlow(false)
val showAuthorInfo: StateFlow<Boolean> = _showAuthorInfo
private val _authorInfoSimple = MutableStateFlow(AuthorInfoSimple(null, null))
val authorInfoSimple: StateFlow<AuthorInfoSimple> = _authorInfoSimple
var historyViewModel: HistoryViewModel? = null
private set
var authorViewModel: AuthorViewModel? = null
private set
private var hasGitDirChanged = false
init {
tabScope.run {
launch {
tabState.refreshFlowFiltered(RefreshType.ALL_DATA, RefreshType.REPO_STATE) {
loadAuthorInfo(tabState.git)
}
}
launch {
watchRepositoryChanges()
}
}
}
/**
* To make sure the tab opens the new repository with a clean state,
* instead of opening the repo in the same ViewModel we simply create a new tab with a new TabViewModel
* replacing the current tab
*/
fun openAnotherRepository(directory: String) = tabState.runOperation(
showError = true,
refreshType = RefreshType.NONE,
) { git ->
tabsManager.addNewTabFromPath(directory, true, getWorkspacePathUseCase(git))
}
private suspend fun loadAuthorInfo(git: Git) {
_authorInfoSimple.value = getAuthorInfoUseCase(git)
}
fun showAuthorInfoDialog() {
authorViewModel = authorViewModelProvider.get()
authorViewModel?.loadAuthorInfo()
_showAuthorInfo.value = true
}
fun closeAuthorInfoDialog() {
_showAuthorInfo.value = false
authorViewModel = null
}
/**
* Sometimes external apps can run filesystem multiple operations in a fraction of a second.
* To prevent excessive updates, we add a slight delay between updates emission to prevent slowing down
* the app by constantly running "git status" or even full refreshes.
*
*/
private suspend fun watchRepositoryChanges() = tabScope.launch(Dispatchers.IO) {
launch {
fileChangesWatcher.changesNotifier.collect { watcherEvent ->
when (watcherEvent) {
is WatcherEvent.RepositoryChanged -> repositoryChanged(watcherEvent.hasGitDirChanged)
is WatcherEvent.WatchInitError -> {
val message = codeToMessage(watcherEvent.code)
errorsManager.addError(
newErrorNow(
exception = Exception(message),
taskType = TaskType.CHANGES_DETECTION,
),
)
}
}
}
}
}
private suspend fun CoroutineScope.repositoryChanged(hasGitDirChanged: Boolean) {
val isOperationRunning = tabState.operationRunning
if (!isOperationRunning) { // Only update if there isn't any process running
printDebug(TAG, "Detected changes in the repository's directory")
val currentTimeMillis = System.currentTimeMillis()
if (
hasGitDirChanged &&
currentTimeMillis - tabState.lastOperation < MIN_TIME_AFTER_GIT_OPERATION
) {
printDebug(TAG, "Git operation was executed recently, ignoring file system change")
return
}
if (hasGitDirChanged) {
this@RepositoryOpenViewModel.hasGitDirChanged = true
}
if (isActive) {
updateApp(hasGitDirChanged)
}
this@RepositoryOpenViewModel.hasGitDirChanged = false
} else {
printDebug(TAG, "Ignored file events during operation")
}
}
private suspend fun updateApp(hasGitDirChanged: Boolean) {
if (hasGitDirChanged) {
printLog(TAG, "Changes detected in git directory, full refresh")
refreshRepositoryInfo()
} else {
printLog(TAG, "Changes detected, partial refresh")
checkUncommittedChanges()
}
}
private suspend fun checkUncommittedChanges() = tabState.runOperation(
refreshType = RefreshType.NONE,
) {
updateDiffEntry()
tabState.refreshData(RefreshType.UNCOMMITTED_CHANGES_AND_LOG)
}
private suspend fun refreshRepositoryInfo() {
tabState.refreshData(RefreshType.ALL_DATA)
}
private fun updateDiffEntry() {
val diffSelected = diffSelected.value
if (diffSelected != null) {
if (diffViewModel == null) { // Initialize the view model if required
diffViewModel = diffViewModelProvider.get()
}
diffViewModel?.cancelRunningJobs()
diffViewModel?.updateDiff(diffSelected)
} else {
diffViewModel?.close()
diffViewModel = null // Free the view model from the memory if not being used.
}
}
fun openDirectoryPicker(): String? {
val latestDirectoryOpened = appStateManager.latestOpenedRepositoryPath
return openFilePickerUseCase(PickerType.DIRECTORIES, latestDirectoryOpened)
}
val update: StateFlow<Update?> = updatesRepository.hasUpdatesFlow
.stateIn(tabScope, started = SharingStarted.Eagerly, null)
fun blameFile(filePath: String) = tabState.safeProcessing(
refreshType = RefreshType.NONE,
taskType = TaskType.BLAME_FILE,
) { git ->
_blameState.value = BlameState.Loading(filePath)
try {
val result = git.blame()
.setFilePath(filePath)
.setFollowFileRenames(true)
.call() ?: throw Exception("File is no longer present in the workspace and can't be blamed")
_blameState.value = BlameState.Loaded(filePath, result)
} catch (ex: Exception) {
resetBlameState()
throw ex
}
null
}
fun resetBlameState() {
_blameState.value = BlameState.None
}
fun expandBlame() {
val blameState = _blameState.value
if (blameState is BlameState.Loaded && blameState.isMinimized) {
_blameState.value = blameState.copy(isMinimized = false)
}
}
fun minimizeBlame() {
val blameState = _blameState.value
if (blameState is BlameState.Loaded && !blameState.isMinimized) {
_blameState.value = blameState.copy(isMinimized = true)
}
}
fun selectCommit(commit: RevCommit) = tabState.runOperation(
refreshType = RefreshType.NONE,
) {
tabState.newSelectedCommit(commit)
}
fun fileHistory(filePath: String) {
historyViewModel = historyViewModelProvider.get()
historyViewModel?.fileHistory(filePath)
_showHistory.value = true
}
fun closeHistory() {
_showHistory.value = false
historyViewModel = null
}
fun refreshAll() = tabScope.launch {
printLog(TAG, "Manual refresh triggered. IS OPERATION RUNNING ${tabState.operationRunning}")
if (!tabState.operationRunning) {
refreshRepositoryInfo()
}
}
fun createBranch(branchName: String) = tabState.safeProcessing(
refreshType = RefreshType.ALL_DATA,
refreshEvenIfCrashesInteractive = { it is CheckoutConflictException },
taskType = TaskType.CREATE_BRANCH,
) { git ->
createBranchUseCase(git, branchName)
positiveNotification("Branch \"${branchName}\" created")
}
fun stashWithMessage(message: String) = tabState.safeProcessing(
refreshType = RefreshType.UNCOMMITTED_CHANGES_AND_LOG,
taskType = TaskType.STASH,
) { git ->
stageUntrackedFileUseCase(git)
if (stashChangesUseCase(git, message)) {
positiveNotification("Changes stashed")
} else {
errorNotification("There are no changes to stash")
}
}
fun openFolderInFileExplorer() = tabState.runOperation(
showError = true,
refreshType = RefreshType.NONE,
) { git ->
Desktop.getDesktop().open(git.repository.workTree)
}
fun openUrlInBrowser(url: String) {
openUrlInBrowserUseCase(url)
}
fun closeLastView() = tabScope.launch {
tabState.closeLastView()
}
}
sealed interface BlameState {
data class Loading(val filePath: String) : BlameState
data class Loaded(val filePath: String, val blameResult: BlameResult, val isMinimized: Boolean = false) : BlameState
data object None : BlameState
}

View File

@ -74,8 +74,10 @@ class SharedBranchesViewModel @Inject constructor(
taskType = TaskType.REBASE_BRANCH,
refreshEvenIfCrashes = true,
) { git ->
rebaseBranchUseCase(git, ref)
positiveNotification("\"${ref.simpleName}\" rebased")
if (rebaseBranchUseCase(git, ref)) {
warningNotification("Rebase produced conflicts, please fix them to continue.")
} else {
positiveNotification("\"${ref.simpleName}\" rebased")
}
}
}

View File

@ -9,6 +9,7 @@ import com.jetpackduba.gitnuro.git.remote_operations.DeleteRemoteBranchUseCase
import com.jetpackduba.gitnuro.git.remote_operations.PullFromSpecificBranchUseCase
import com.jetpackduba.gitnuro.git.remote_operations.PushToSpecificBranchUseCase
import com.jetpackduba.gitnuro.models.positiveNotification
import com.jetpackduba.gitnuro.models.warningNotification
import kotlinx.coroutines.Job
import org.eclipse.jgit.lib.Ref
import javax.inject.Inject
@ -69,12 +70,10 @@ class SharedRemotesViewModel @Inject constructor(
subtitle = "Pulling changes from ${branch.simpleName} to the current branch",
taskType = TaskType.PULL_FROM_BRANCH,
) { git ->
pullFromSpecificBranchUseCase(
git = git,
rebase = false,
remoteBranch = branch,
)
positiveNotification("Pulled from \"${branch.simpleName}\"")
if (pullFromSpecificBranchUseCase(git = git, remoteBranch = branch)) {
warningNotification("Pull produced conflicts, fix them to continue")
} else {
positiveNotification("Pulled from \"${branch.simpleName}\"")
}
}
}

View File

@ -5,6 +5,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import com.jetpackduba.gitnuro.SharedRepositoryStateManager
import com.jetpackduba.gitnuro.TaskType
import com.jetpackduba.gitnuro.extensions.*
import com.jetpackduba.gitnuro.git.CloseableView
import com.jetpackduba.gitnuro.git.RefreshType
import com.jetpackduba.gitnuro.git.TabState
import com.jetpackduba.gitnuro.git.author.LoadAuthorUseCase
@ -59,7 +60,7 @@ class StatusViewModel @Inject constructor(
private val sharedRepositoryStateManager: SharedRepositoryStateManager,
private val getSpecificCommitMessageUseCase: GetSpecificCommitMessageUseCase,
private val appSettingsRepository: AppSettingsRepository,
tabScope: CoroutineScope,
private val tabScope: CoroutineScope,
) {
private val _showSearchUnstaged = MutableStateFlow(false)
val showSearchUnstaged: StateFlow<Boolean> = _showSearchUnstaged
@ -182,6 +183,36 @@ class StatusViewModel @Inject constructor(
refresh(tabState.git)
}
}
tabScope.launch {
showSearchStaged.collectLatest {
if (it) {
addStagedSearchToCloseableView()
} else {
removeStagedSearchToCloseableView()
}
}
}
tabScope.launch {
showSearchUnstaged.collectLatest {
if (it) {
addUnstagedSearchToCloseableView()
} else {
removeUnstagedSearchToCloseableView()
}
}
}
tabScope.launch {
tabState.closeViewFlow.collectLatest {
if (it == CloseableView.STAGED_CHANGES_SEARCH) {
onSearchFilterToggledStaged(false)
} else if (it == CloseableView.UNSTAGED_CHANGES_SEARCH) {
onSearchFilterToggledUnstaged(false)
}
}
}
}
private fun persistMessage() = tabState.runOperation(
@ -364,7 +395,9 @@ class StatusViewModel @Inject constructor(
val personIdent = getPersonIdent(git)
doCommitUseCase(git, commitMessage, amend, personIdent)
updateCommitMessage("")
_commitMessageChangesFlow.emit("")
_isAmend.value = false
positiveNotification(if (isAmend.value) "Commit amended" else "New commit created")
@ -547,6 +580,30 @@ class StatusViewModel @Inject constructor(
) { git ->
unstageByDirectoryUseCase(git, dir)
}
fun addStagedSearchToCloseableView() {
addSearchToCloseView(CloseableView.STAGED_CHANGES_SEARCH)
}
private fun removeStagedSearchToCloseableView() {
removeSearchFromCloseView(CloseableView.STAGED_CHANGES_SEARCH)
}
fun addUnstagedSearchToCloseableView() {
addSearchToCloseView(CloseableView.UNSTAGED_CHANGES_SEARCH)
}
private fun removeUnstagedSearchToCloseableView() {
removeSearchFromCloseView(CloseableView.UNSTAGED_CHANGES_SEARCH)
}
private fun addSearchToCloseView(view: CloseableView) = tabScope.launch {
tabState.addCloseableView(view)
}
private fun removeSearchFromCloseView(view: CloseableView) = tabScope.launch {
tabState.removeCloseableView(view)
}
}
sealed interface StageState {

View File

@ -1,45 +1,37 @@
package com.jetpackduba.gitnuro.viewmodels
import com.jetpackduba.gitnuro.SharedRepositoryStateManager
import com.jetpackduba.gitnuro.TaskType
import com.jetpackduba.gitnuro.credentials.CredentialsAccepted
import com.jetpackduba.gitnuro.credentials.CredentialsState
import com.jetpackduba.gitnuro.credentials.CredentialsStateManager
import com.jetpackduba.gitnuro.exceptions.WatcherInitException
import com.jetpackduba.gitnuro.git.*
import com.jetpackduba.gitnuro.git.branches.CreateBranchUseCase
import com.jetpackduba.gitnuro.git.rebase.RebaseInteractiveState
import com.jetpackduba.gitnuro.git.FileChangesWatcher
import com.jetpackduba.gitnuro.git.ProcessingState
import com.jetpackduba.gitnuro.git.RefreshType
import com.jetpackduba.gitnuro.git.TabState
import com.jetpackduba.gitnuro.git.repository.InitLocalRepositoryUseCase
import com.jetpackduba.gitnuro.git.repository.OpenRepositoryUseCase
import com.jetpackduba.gitnuro.git.repository.OpenSubmoduleRepositoryUseCase
import com.jetpackduba.gitnuro.git.stash.StashChangesUseCase
import com.jetpackduba.gitnuro.git.workspace.StageUntrackedFileUseCase
import com.jetpackduba.gitnuro.logging.printDebug
import com.jetpackduba.gitnuro.logging.printLog
import com.jetpackduba.gitnuro.managers.AppStateManager
import com.jetpackduba.gitnuro.managers.ErrorsManager
import com.jetpackduba.gitnuro.managers.newErrorNow
import com.jetpackduba.gitnuro.models.AuthorInfoSimple
import com.jetpackduba.gitnuro.models.errorNotification
import com.jetpackduba.gitnuro.models.positiveNotification
import com.jetpackduba.gitnuro.system.OpenFilePickerUseCase
import com.jetpackduba.gitnuro.system.OpenUrlInBrowserUseCase
import com.jetpackduba.gitnuro.system.PickerType
import com.jetpackduba.gitnuro.ui.IVerticalSplitPaneConfig
import com.jetpackduba.gitnuro.ui.SelectedItem
import com.jetpackduba.gitnuro.ui.TabsManager
import com.jetpackduba.gitnuro.ui.VerticalSplitPaneConfig
import com.jetpackduba.gitnuro.updates.Update
import com.jetpackduba.gitnuro.updates.UpdatesRepository
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.api.errors.CheckoutConflictException
import org.eclipse.jgit.blame.BlameResult
import org.eclipse.jgit.lib.Repository
import org.eclipse.jgit.lib.RepositoryState
import org.eclipse.jgit.revwalk.RevCommit
import java.awt.Desktop
import java.io.File
import javax.inject.Inject
import javax.inject.Provider
@ -57,81 +49,36 @@ class TabViewModel @Inject constructor(
private val initLocalRepositoryUseCase: InitLocalRepositoryUseCase,
private val openRepositoryUseCase: OpenRepositoryUseCase,
private val openSubmoduleRepositoryUseCase: OpenSubmoduleRepositoryUseCase,
private val getWorkspacePathUseCase: GetWorkspacePathUseCase,
private val diffViewModelProvider: Provider<DiffViewModel>,
private val historyViewModelProvider: Provider<HistoryViewModel>,
private val authorViewModelProvider: Provider<AuthorViewModel>,
private val tabState: TabState,
val appStateManager: AppStateManager,
private val fileChangesWatcher: FileChangesWatcher,
private val updatesRepository: UpdatesRepository,
private val credentialsStateManager: CredentialsStateManager,
private val createBranchUseCase: CreateBranchUseCase,
private val stashChangesUseCase: StashChangesUseCase,
private val stageUntrackedFileUseCase: StageUntrackedFileUseCase,
private val openFilePickerUseCase: OpenFilePickerUseCase,
private val openUrlInBrowserUseCase: OpenUrlInBrowserUseCase,
private val sharedRepositoryStateManager: SharedRepositoryStateManager,
private val tabsManager: TabsManager,
private val tabScope: CoroutineScope,
private val verticalSplitPaneConfig: VerticalSplitPaneConfig,
val tabViewModelsProvider: TabViewModelsProvider,
private val globalMenuActionsViewModel: GlobalMenuActionsViewModel,
private val repositoryOpenViewModelProvider: Provider<RepositoryOpenViewModel>,
updatesRepository: UpdatesRepository,
) : IVerticalSplitPaneConfig by verticalSplitPaneConfig,
IGlobalMenuActionsViewModel by globalMenuActionsViewModel {
var initialPath: String? = null // Stores the path that should be opened when the tab is selected
val errorsManager: ErrorsManager = tabState.errorsManager
val selectedItem: StateFlow<SelectedItem> = tabState.selectedItem
var diffViewModel: DiffViewModel? = null
private val _repositorySelectionStatus = MutableStateFlow<RepositorySelectionStatus>(RepositorySelectionStatus.None)
val repositorySelectionStatus: StateFlow<RepositorySelectionStatus>
get() = _repositorySelectionStatus
val repositoryState: StateFlow<RepositoryState> = sharedRepositoryStateManager.repositoryState
val rebaseInteractiveState: StateFlow<RebaseInteractiveState> = sharedRepositoryStateManager.rebaseInteractiveState
val processing: StateFlow<ProcessingState> = tabState.processing
val credentialsState: StateFlow<CredentialsState> = credentialsStateManager.credentialsState
private val _diffSelected = MutableStateFlow<DiffType?>(null)
val diffSelected: StateFlow<DiffType?> = _diffSelected
var newDiffSelected: DiffType?
get() = diffSelected.value
set(value) {
_diffSelected.value = value
updateDiffEntry()
}
private val _blameState = MutableStateFlow<BlameState>(BlameState.None)
val blameState: StateFlow<BlameState> = _blameState
private val _showHistory = MutableStateFlow(false)
val showHistory: StateFlow<Boolean> = _showHistory
private val _showAuthorInfo = MutableStateFlow(false)
val showAuthorInfo: StateFlow<Boolean> = _showAuthorInfo
private val _authorInfoSimple = MutableStateFlow(AuthorInfoSimple(null, null))
val authorInfoSimple: StateFlow<AuthorInfoSimple> = _authorInfoSimple
var historyViewModel: HistoryViewModel? = null
private set
var authorViewModel: AuthorViewModel? = null
private set
val showError = MutableStateFlow(false)
init {
tabScope.run {
launch {
tabState.refreshFlowFiltered(RefreshType.ALL_DATA, RefreshType.REPO_STATE) {
loadAuthorInfo(tabState.git)
}
}
launch {
errorsManager.error.collect {
@ -141,19 +88,6 @@ class TabViewModel @Inject constructor(
}
}
/**
* To make sure the tab opens the new repository with a clean state,
* instead of opening the repo in the same ViewModel we simply create a new tab with a new TabViewModel
* replacing the current tab
*/
fun openAnotherRepository(directory: String) = tabState.runOperation(
showError = true,
refreshType = RefreshType.NONE,
) { git ->
tabsManager.addNewTabFromPath(directory, true, getWorkspacePathUseCase(git))
}
fun openRepository(directory: String) {
openRepository(File(directory))
}
@ -171,9 +105,6 @@ class TabViewModel @Inject constructor(
}
repository.workTree // test if repository is valid
_repositorySelectionStatus.value = RepositorySelectionStatus.Open(repository)
val git = Git(repository)
tabState.initGit(git)
val path = if (directory.name == ".git") {
directory.parent
@ -181,11 +112,13 @@ class TabViewModel @Inject constructor(
directory.absolutePath
onRepositoryChanged(path)
tabState.newSelectedItem(selectedItem = SelectedItem.UncommittedChanges)
newDiffSelected = null
refreshRepositoryInfo()
watchRepositoryChanges(git)
val git = Git(repository)
tabState.initGit(git)
_repositorySelectionStatus.value = RepositorySelectionStatus.Open(repositoryOpenViewModelProvider.get())
tabState.refreshData(RefreshType.ALL_DATA)
} catch (ex: Exception) {
onRepositoryChanged(null)
ex.printStackTrace()
@ -200,109 +133,6 @@ class TabViewModel @Inject constructor(
}
}
private fun loadAuthorInfo(git: Git) {
val config = git.repository.config
config.load()
val userName = config.getString("user", null, "name")
val email = config.getString("user", null, "email")
_authorInfoSimple.value = AuthorInfoSimple(userName, email)
}
fun showAuthorInfoDialog() {
authorViewModel = authorViewModelProvider.get()
authorViewModel?.loadAuthorInfo()
_showAuthorInfo.value = true
}
fun closeAuthorInfoDialog() {
_showAuthorInfo.value = false
authorViewModel = null
}
/**
* Sometimes external apps can run filesystem multiple operations in a fraction of a second.
* To prevent excessive updates, we add a slight delay between updates emission to prevent slowing down
* the app by constantly running "git status" or even full refreshes.
*
*/
private suspend fun watchRepositoryChanges(git: Git) = tabScope.launch(Dispatchers.IO) {
var hasGitDirChanged = false
launch {
fileChangesWatcher.changesNotifier.collect { latestUpdateChangedGitDir ->
val isOperationRunning = tabState.operationRunning
if (!isOperationRunning) { // Only update if there isn't any process running
printDebug(TAG, "Detected changes in the repository's directory")
val currentTimeMillis = System.currentTimeMillis()
if (
latestUpdateChangedGitDir &&
currentTimeMillis - tabState.lastOperation < MIN_TIME_AFTER_GIT_OPERATION
) {
printDebug(TAG, "Git operation was executed recently, ignoring file system change")
return@collect
}
if (latestUpdateChangedGitDir) {
hasGitDirChanged = true
}
if (isActive) {
updateApp(hasGitDirChanged)
}
hasGitDirChanged = false
} else {
printDebug(TAG, "Ignored file events during operation")
}
}
}
try {
fileChangesWatcher.watchDirectoryPath(
repository = git.repository,
)
} catch (ex: WatcherInitException) {
val message = ex.message
if (message != null) {
errorsManager.addError(
newErrorNow(
exception = ex,
taskType = TaskType.CHANGES_DETECTION,
// message = message,
),
)
}
}
}
private suspend fun updateApp(hasGitDirChanged: Boolean) {
if (hasGitDirChanged) {
printLog(TAG, "Changes detected in git directory, full refresh")
refreshRepositoryInfo()
} else {
printLog(TAG, "Changes detected, partial refresh")
checkUncommittedChanges()
}
}
private suspend fun checkUncommittedChanges() = tabState.runOperation(
refreshType = RefreshType.NONE,
) {
updateDiffEntry()
tabState.refreshData(RefreshType.UNCOMMITTED_CHANGES_AND_LOG)
}
private suspend fun refreshRepositoryInfo() {
tabState.refreshData(RefreshType.ALL_DATA)
}
fun credentialsDenied() {
credentialsStateManager.updateState(CredentialsState.CredentialsDenied)
}
@ -322,22 +152,6 @@ class TabViewModel @Inject constructor(
tabScope.cancel()
}
private fun updateDiffEntry() {
val diffSelected = diffSelected.value
if (diffSelected != null) {
if (diffViewModel == null) { // Initialize the view model if required
diffViewModel = diffViewModelProvider.get()
}
diffViewModel?.cancelRunningJobs()
diffViewModel?.updateDiff(diffSelected)
} else {
diffViewModel?.cancelRunningJobs()
diffViewModel = null // Free the view model from the memory if not being used.
}
}
fun openDirectoryPicker(): String? {
val latestDirectoryOpened = appStateManager.latestOpenedRepositoryPath
@ -350,105 +164,9 @@ class TabViewModel @Inject constructor(
openRepository(repoDir)
}
val update: StateFlow<Update?> = updatesRepository.hasUpdatesFlow()
.flowOn(Dispatchers.IO)
val update: StateFlow<Update?> = updatesRepository.hasUpdatesFlow
.stateIn(tabScope, started = SharingStarted.Eagerly, null)
fun blameFile(filePath: String) = tabState.safeProcessing(
refreshType = RefreshType.NONE,
taskType = TaskType.BLAME_FILE,
) { git ->
_blameState.value = BlameState.Loading(filePath)
try {
val result = git.blame()
.setFilePath(filePath)
.setFollowFileRenames(true)
.call() ?: throw Exception("File is no longer present in the workspace and can't be blamed")
_blameState.value = BlameState.Loaded(filePath, result)
} catch (ex: Exception) {
resetBlameState()
throw ex
}
null
}
fun resetBlameState() {
_blameState.value = BlameState.None
}
fun expandBlame() {
val blameState = _blameState.value
if (blameState is BlameState.Loaded && blameState.isMinimized) {
_blameState.value = blameState.copy(isMinimized = false)
}
}
fun minimizeBlame() {
val blameState = _blameState.value
if (blameState is BlameState.Loaded && !blameState.isMinimized) {
_blameState.value = blameState.copy(isMinimized = true)
}
}
fun selectCommit(commit: RevCommit) = tabState.runOperation(
refreshType = RefreshType.NONE,
) {
tabState.newSelectedCommit(commit)
}
fun fileHistory(filePath: String) {
historyViewModel = historyViewModelProvider.get()
historyViewModel?.fileHistory(filePath)
_showHistory.value = true
}
fun closeHistory() {
_showHistory.value = false
historyViewModel = null
}
fun refreshAll() = tabScope.launch {
printLog(TAG, "Manual refresh triggered. IS OPERATION RUNNING ${tabState.operationRunning}")
if (!tabState.operationRunning) {
refreshRepositoryInfo()
}
}
fun createBranch(branchName: String) = tabState.safeProcessing(
refreshType = RefreshType.ALL_DATA,
refreshEvenIfCrashesInteractive = { it is CheckoutConflictException },
taskType = TaskType.CREATE_BRANCH,
) { git ->
createBranchUseCase(git, branchName)
positiveNotification("Branch \"${branchName}\" created")
}
fun stashWithMessage(message: String) = tabState.safeProcessing(
refreshType = RefreshType.UNCOMMITTED_CHANGES_AND_LOG,
taskType = TaskType.STASH,
) { git ->
stageUntrackedFileUseCase(git)
if (stashChangesUseCase(git, message)) {
positiveNotification("Changes stashed")
} else {
errorNotification("There are no changes to stash")
}
}
fun openFolderInFileExplorer() = tabState.runOperation(
showError = true,
refreshType = RefreshType.NONE,
) { git ->
Desktop.getDesktop().open(git.repository.workTree)
}
fun gpgCredentialsAccepted(password: String) {
credentialsStateManager.updateState(CredentialsAccepted.GpgCredentialsAccepted(password))
}
@ -468,15 +186,7 @@ class TabViewModel @Inject constructor(
sealed class RepositorySelectionStatus {
object None : RepositorySelectionStatus()
data object None : RepositorySelectionStatus()
data class Opening(val path: String) : RepositorySelectionStatus()
data class Open(val repository: Repository) : RepositorySelectionStatus()
}
sealed interface BlameState {
data class Loading(val filePath: String) : BlameState
data class Loaded(val filePath: String, val blameResult: BlameResult, val isMinimized: Boolean = false) : BlameState
data object None : BlameState
data class Open(val viewModel: RepositoryOpenViewModel) : RepositorySelectionStatus()
}

View File

@ -1,10 +1,13 @@
package com.jetpackduba.gitnuro.viewmodels.sidepanel
import androidx.compose.runtime.collectAsState
import com.jetpackduba.gitnuro.di.factories.*
import com.jetpackduba.gitnuro.git.CloseableView
import com.jetpackduba.gitnuro.git.TabState
import com.jetpackduba.gitnuro.ui.SelectedItem
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
class SidePanelViewModel @Inject constructor(
@ -13,7 +16,8 @@ class SidePanelViewModel @Inject constructor(
tagsViewModelFactory: TagsViewModelFactory,
stashesViewModelFactory: StashesViewModelFactory,
submodulesViewModelFactory: SubmodulesViewModelFactory,
tabState: TabState,
private val tabState: TabState,
private val tabScope: CoroutineScope,
) {
private val _filter = MutableStateFlow("")
val filter: StateFlow<String> = _filter
@ -25,7 +29,29 @@ class SidePanelViewModel @Inject constructor(
val stashesViewModel: StashesViewModel = stashesViewModelFactory.create(filter)
val submodulesViewModel: SubmodulesViewModel = submodulesViewModelFactory.create(filter)
private val _freeSearchFocusFlow = MutableSharedFlow<Unit>()
val freeSearchFocusFlow = _freeSearchFocusFlow.asSharedFlow()
init {
tabScope.launch {
tabState.closeViewFlow.collectLatest {
if (it == CloseableView.SIDE_PANEL_SEARCH) {
newFilter("")
_freeSearchFocusFlow.emit(Unit)
}
}
}
}
fun newFilter(newValue: String) {
_filter.value = newValue
}
fun addSidePanelSearchToCloseables() = tabScope.launch {
tabState.addCloseableView(CloseableView.SIDE_PANEL_SEARCH)
}
fun removeSidePanelSearchFromCloseables() = tabScope.launch {
tabState.removeCloseableView(CloseableView.SIDE_PANEL_SEARCH)
}
}

View File

@ -1,6 +1,5 @@
package com.jetpackduba.gitnuro.ui.tree_files
import com.jetpackduba.gitnuro.system.systemSeparator
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@ -27,19 +26,19 @@ class TreeTest {
@Test
fun `test entriesToTreeEntry with multiple file entries`() {
val entries = listOf(
"dir1${systemSeparator}file1.txt",
"dir2${systemSeparator}file2.txt",
"dir3${systemSeparator}file3.txt",
"dir1/file1.txt",
"dir2/file2.txt",
"dir3/file3.txt",
)
val treeContractedDirs = emptyList<String>()
val result = entriesToTreeEntry(entries, treeContractedDirs) { it }
val expected = listOf(
TreeItem.Dir(true, "dir1", "dir1", 0),
TreeItem.File("dir1${systemSeparator}file1.txt", "file1.txt", "dir1${systemSeparator}file1.txt", 1),
TreeItem.File("dir1/file1.txt", "file1.txt", "dir1/file1.txt", 1),
TreeItem.Dir(true, "dir2", "dir2", 0),
TreeItem.File("dir2${systemSeparator}file2.txt", "file2.txt", "dir2${systemSeparator}file2.txt", 1),
TreeItem.File("dir2/file2.txt", "file2.txt", "dir2/file2.txt", 1),
TreeItem.Dir(true, "dir3", "dir3", 0),
TreeItem.File("dir3${systemSeparator}file3.txt", "file3.txt", "dir3${systemSeparator}file3.txt", 1)
TreeItem.File("dir3/file3.txt", "file3.txt", "dir3/file3.txt", 1)
)
assertEquals(expected, result)
}
@ -47,19 +46,19 @@ class TreeTest {
@Test
fun `test entriesToTreeEntry with similar names`() {
val entries = listOf(
"webpack${systemSeparator}webpack.config2.ts",
"webpack${systemSeparator}webpack.config.ts",
"webpack/webpack.config2.ts",
"webpack/webpack.config.ts",
"webpack-plugin.ts",
"dir1${systemSeparator}file3.txt"
"dir1/file3.txt"
)
val treeContractedDirs = emptyList<String>()
val result = entriesToTreeEntry(entries, treeContractedDirs) { it }
val expected = listOf(
TreeItem.Dir(true, "dir1", "dir1", 0),
TreeItem.File("dir1${systemSeparator}file3.txt", "file3.txt", "dir1${systemSeparator}file3.txt", 1),
TreeItem.File("dir1/file3.txt", "file3.txt", "dir1/file3.txt", 1),
TreeItem.Dir(true, "webpack", "webpack", 0),
TreeItem.File("webpack${systemSeparator}webpack.config.ts", "webpack.config.ts", "webpack${systemSeparator}webpack.config.ts", 1),
TreeItem.File("webpack${systemSeparator}webpack.config2.ts", "webpack.config2.ts", "webpack${systemSeparator}webpack.config2.ts", 1),
TreeItem.File("webpack/webpack.config.ts", "webpack.config.ts", "webpack/webpack.config.ts", 1),
TreeItem.File("webpack/webpack.config2.ts", "webpack.config2.ts", "webpack/webpack.config2.ts", 1),
TreeItem.File("webpack-plugin.ts", "webpack-plugin.ts", "webpack-plugin.ts", 0)
)
assertEquals(expected, result)
@ -68,10 +67,10 @@ class TreeTest {
@Test
fun `test test entriesToTreeEntry with similar names with contracted directories`() {
val entries = listOf(
"webpack${systemSeparator}webpack.config2.ts",
"webpack${systemSeparator}webpack.config.ts",
"webpack/webpack.config2.ts",
"webpack/webpack.config.ts",
"webpack-plugin.ts",
"dir1${systemSeparator}file3.txt"
"dir1/file3.txt"
)
val treeContractedDirs = listOf<String>("webpack", "dir1")
val result = entriesToTreeEntry(entries, treeContractedDirs) { it }