Compare commits
36 Commits
1.4.0-beta
...
main
Author | SHA1 | Date | |
---|---|---|---|
|
edcefd7a38 | ||
|
01b0827057 | ||
|
db1467c354 | ||
|
07e98dae10 | ||
|
644f33ff2c | ||
|
3128341b93 | ||
|
1ef596ad3c | ||
|
4cc87a7289 | ||
|
fcecd0380a | ||
|
fc2098781b | ||
|
ce694ed44b | ||
|
e5899d02d6 | ||
|
169ed5af3f | ||
|
712e513c2e | ||
|
02a1b47eeb | ||
|
28a465441e | ||
|
aab0e01a30 | ||
|
fb542c9677 | ||
|
e9c3e25d79 | ||
|
4423e47019 | ||
|
1b4b75d75b | ||
|
8b17d68db4 | ||
|
87399eccf4 | ||
|
c92ccf1f6d | ||
|
ef367e1d89 | ||
|
0f5e4248b9 | ||
|
e38746cd0e | ||
|
dc5efb25aa | ||
|
e5a84705e9 | ||
|
51bcedc828 | ||
|
03e94d0ce9 | ||
|
7b5290aef8 | ||
|
1af4542c7c | ||
|
355cbc3f79 | ||
|
a9a35b304a | ||
|
422b431fdf |
78
.github/workflows/release.yml
vendored
78
.github/workflows/release.yml
vendored
@ -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 }}
|
@ -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
|
||||
|
@ -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"
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
kotlin.code.style=official
|
||||
isLinuxAarch64=false
|
||||
useCross=false
|
||||
isRustRelease=false
|
||||
isRustRelease=true
|
@ -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"
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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"
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -1,3 +0,0 @@
|
||||
package com.jetpackduba.gitnuro.exceptions
|
||||
|
||||
class ConflictsException(message: String) : GitnuroException(message)
|
@ -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"
|
||||
|
@ -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
|
||||
}
|
@ -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()
|
||||
|
||||
|
@ -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
|
||||
}
|
@ -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,
|
||||
}
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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),
|
||||
|
@ -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
|
||||
|
@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
},
|
||||
)
|
||||
|
@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
@ -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",
|
||||
|
@ -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) },
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
}
|
||||
},
|
||||
|
@ -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())
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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(
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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}\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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()
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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 }
|
||||
|
Loading…
Reference in New Issue
Block a user