Implemented treeview in files list

Fixes #22
This commit is contained in:
Abdelilah El Aissaoui 2024-01-23 17:26:09 +01:00
parent 8cde01bc00
commit 8b36537451
No known key found for this signature in database
GPG Key ID: 7587FC860F594869
15 changed files with 1467 additions and 529 deletions

View File

@ -25,6 +25,8 @@ object AppIcons {
const val ERROR = "error.svg" const val ERROR = "error.svg"
const val EXPAND_MORE = "expand_more.svg" const val EXPAND_MORE = "expand_more.svg"
const val FETCH = "fetch.svg" const val FETCH = "fetch.svg"
const val FOLDER = "folder.svg"
const val FOLDER_OPEN = "folder_open.svg"
const val GRADE = "grade.svg" const val GRADE = "grade.svg"
const val HORIZONTAL_SPLIT = "horizontal_split.svg" const val HORIZONTAL_SPLIT = "horizontal_split.svg"
const val HISTORY = "history.svg" const val HISTORY = "history.svg"
@ -59,6 +61,7 @@ object AppIcons {
const val TAG = "tag.svg" const val TAG = "tag.svg"
const val TERMINAL = "terminal.svg" const val TERMINAL = "terminal.svg"
const val TOPIC = "topic.svg" const val TOPIC = "topic.svg"
const val TREE = "tree.svg"
const val UNDO = "undo.svg" const val UNDO = "undo.svg"
const val UNIFIED = "unified.svg" const val UNIFIED = "unified.svg"
const val UPDATE = "update.svg" const val UPDATE = "update.svg"

View File

@ -0,0 +1,15 @@
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
import javax.inject.Inject
class StageByDirectoryUseCase @Inject constructor() {
suspend operator fun invoke(git: Git, dir: String) = withContext(Dispatchers.IO) {
git.add()
.addFilepattern(dir)
.call()
}
}

View File

@ -0,0 +1,15 @@
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
import javax.inject.Inject
class UnstageByDirectoryUseCase @Inject constructor() {
suspend operator fun invoke(git: Git, dir: String) = withContext(Dispatchers.IO) {
git.reset()
.addPath(dir)
.call()
}
}

View File

@ -31,6 +31,7 @@ private const val PREF_DIFF_TYPE = "diffType"
private const val PREF_DIFF_FULL_FILE = "diffFullFile" private const val PREF_DIFF_FULL_FILE = "diffFullFile"
private const val PREF_SWAP_UNCOMMITTED_CHANGES = "inverseUncommittedChanges" private const val PREF_SWAP_UNCOMMITTED_CHANGES = "inverseUncommittedChanges"
private const val PREF_TERMINAL_PATH = "terminalPath" private const val PREF_TERMINAL_PATH = "terminalPath"
private const val PREF_SHOW_CHANGES_AS_TREE = "showChangesAsTree"
private const val PREF_USE_PROXY = "useProxy" private const val PREF_USE_PROXY = "useProxy"
private const val PREF_PROXY_TYPE = "proxyType" private const val PREF_PROXY_TYPE = "proxyType"
private const val PREF_PROXY_HOST_NAME = "proxyHostName" private const val PREF_PROXY_HOST_NAME = "proxyHostName"
@ -50,6 +51,7 @@ private const val PREF_VERIFY_SSL = "verifySsl"
private const val DEFAULT_COMMITS_LIMIT = 1000 private const val DEFAULT_COMMITS_LIMIT = 1000
private const val DEFAULT_COMMITS_LIMIT_ENABLED = true private const val DEFAULT_COMMITS_LIMIT_ENABLED = true
private const val DEFAULT_SWAP_UNCOMMITTED_CHANGES = false private const val DEFAULT_SWAP_UNCOMMITTED_CHANGES = false
private const val DEFAULT_SHOW_CHANGES_AS_TREE = false
private const val DEFAULT_CACHE_CREDENTIALS_IN_MEMORY = true private const val DEFAULT_CACHE_CREDENTIALS_IN_MEMORY = true
private const val DEFAULT_VERIFY_SSL = true private const val DEFAULT_VERIFY_SSL = true
const val DEFAULT_UI_SCALE = -1f const val DEFAULT_UI_SCALE = -1f
@ -67,6 +69,9 @@ class AppSettings @Inject constructor() {
private val _swapUncommittedChangesFlow = MutableStateFlow(swapUncommittedChanges) private val _swapUncommittedChangesFlow = MutableStateFlow(swapUncommittedChanges)
val swapUncommittedChangesFlow = _swapUncommittedChangesFlow.asStateFlow() val swapUncommittedChangesFlow = _swapUncommittedChangesFlow.asStateFlow()
private val _showChangesAsTreeFlow = MutableStateFlow(showChangesAsTree)
val showChangesAsTreeFlow = _showChangesAsTreeFlow.asStateFlow()
private val _cacheCredentialsInMemoryFlow = MutableStateFlow(cacheCredentialsInMemory) private val _cacheCredentialsInMemoryFlow = MutableStateFlow(cacheCredentialsInMemory)
val cacheCredentialsInMemoryFlow = _cacheCredentialsInMemoryFlow.asStateFlow() val cacheCredentialsInMemoryFlow = _cacheCredentialsInMemoryFlow.asStateFlow()
@ -165,6 +170,15 @@ class AppSettings @Inject constructor() {
_swapUncommittedChangesFlow.value = value _swapUncommittedChangesFlow.value = value
} }
var showChangesAsTree: Boolean
get() {
return preferences.getBoolean(PREF_SHOW_CHANGES_AS_TREE, DEFAULT_SHOW_CHANGES_AS_TREE)
}
set(value) {
preferences.putBoolean(PREF_SHOW_CHANGES_AS_TREE, value)
_showChangesAsTreeFlow.value = value
}
var cacheCredentialsInMemory: Boolean var cacheCredentialsInMemory: Boolean
get() { get() {
return preferences.getBoolean(PREF_CACHE_CREDENTIALS_IN_MEMORY, DEFAULT_CACHE_CREDENTIALS_IN_MEMORY) return preferences.getBoolean(PREF_CACHE_CREDENTIALS_IN_MEMORY, DEFAULT_CACHE_CREDENTIALS_IN_MEMORY)
@ -347,18 +361,6 @@ class AppSettings @Inject constructor() {
_customThemeFlow.value = Json.decodeFromString<ColorsScheme>(themeJson) _customThemeFlow.value = Json.decodeFromString<ColorsScheme>(themeJson)
} }
} }
private fun loadProxySettings() {
_proxyFlow.value = ProxySettings(
useProxy,
proxyType,
proxyHostName,
proxyPortNumber,
proxyUseAuth,
proxyHostUser,
proxyHostPassword,
)
}
} }
data class ProxySettings( data class ProxySettings(

View File

@ -1,6 +1,5 @@
package com.jetpackduba.gitnuro.ui package com.jetpackduba.gitnuro.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@ -21,18 +20,17 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
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.AppIcons
import com.jetpackduba.gitnuro.extensions.* import com.jetpackduba.gitnuro.extensions.*
import com.jetpackduba.gitnuro.git.DiffEntryType import com.jetpackduba.gitnuro.git.DiffEntryType
import com.jetpackduba.gitnuro.theme.backgroundSelected
import com.jetpackduba.gitnuro.theme.onBackgroundSecondary import com.jetpackduba.gitnuro.theme.onBackgroundSecondary
import com.jetpackduba.gitnuro.theme.tertiarySurface import com.jetpackduba.gitnuro.theme.tertiarySurface
import com.jetpackduba.gitnuro.ui.components.* import com.jetpackduba.gitnuro.ui.components.*
import com.jetpackduba.gitnuro.ui.context_menu.ContextMenu import com.jetpackduba.gitnuro.ui.context_menu.ContextMenuElement
import com.jetpackduba.gitnuro.ui.context_menu.committedChangesEntriesContextMenuItems import com.jetpackduba.gitnuro.ui.context_menu.committedChangesEntriesContextMenuItems
import com.jetpackduba.gitnuro.viewmodels.CommitChangesState import com.jetpackduba.gitnuro.ui.tree_files.TreeItem
import com.jetpackduba.gitnuro.viewmodels.CommitChangesStateUi
import com.jetpackduba.gitnuro.viewmodels.CommitChangesViewModel import com.jetpackduba.gitnuro.viewmodels.CommitChangesViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -53,28 +51,29 @@ fun CommitChanges(
commitChangesViewModel.loadChanges(selectedItem.revCommit) commitChangesViewModel.loadChanges(selectedItem.revCommit)
} }
val commitChangesStatus = commitChangesViewModel.commitChangesState.collectAsState().value val commitChangesStatus = commitChangesViewModel.commitChangesStateUi.collectAsState().value
val showSearch by commitChangesViewModel.showSearch.collectAsState() val showSearch by commitChangesViewModel.showSearch.collectAsState()
val changesListScroll by commitChangesViewModel.changesLazyListState.collectAsState() val changesListScroll by commitChangesViewModel.changesLazyListState.collectAsState()
val textScroll by commitChangesViewModel.textScroll.collectAsState() val textScroll by commitChangesViewModel.textScroll.collectAsState()
val showAsTree by commitChangesViewModel.showAsTree.collectAsState()
var searchFilter by remember(commitChangesViewModel, showSearch, commitChangesStatus) { var searchFilter by remember(commitChangesViewModel, showSearch, commitChangesStatus) {
mutableStateOf(commitChangesViewModel.searchFilter.value) mutableStateOf(commitChangesViewModel.searchFilter.value)
} }
when (commitChangesStatus) { when (commitChangesStatus) {
CommitChangesState.Loading -> { CommitChangesStateUi.Loading -> {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colors.primaryVariant) LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colors.primaryVariant)
} }
is CommitChangesState.Loaded -> { is CommitChangesStateUi.Loaded -> {
CommitChangesView( CommitChangesView(
diffSelected = diffSelected, diffSelected = diffSelected,
commit = commitChangesStatus.commit, commitChangesStatus = commitChangesStatus,
changes = commitChangesStatus.changesFiltered,
onBlame = onBlame, onBlame = onBlame,
onHistory = onHistory, onHistory = onHistory,
showSearch = showSearch, showSearch = showSearch,
showAsTree = showAsTree,
changesListScroll = changesListScroll, changesListScroll = changesListScroll,
textScroll = textScroll, textScroll = textScroll,
searchFilter = searchFilter, searchFilter = searchFilter,
@ -86,38 +85,37 @@ fun CommitChanges(
searchFilter = filter searchFilter = filter
commitChangesViewModel.onSearchFilterChanged(filter) commitChangesViewModel.onSearchFilterChanged(filter)
}, },
onDirectoryClicked = { commitChangesViewModel.onDirectoryClicked(it.fullPath) },
onAlternateShowAsTree = { commitChangesViewModel.alternateShowAsTree() },
) )
} }
} }
} }
@Composable @Composable
fun CommitChangesView( private fun CommitChangesView(
commit: RevCommit, commitChangesStatus: CommitChangesStateUi.Loaded,
changes: List<DiffEntry>,
diffSelected: DiffEntryType?, diffSelected: DiffEntryType?,
changesListScroll: LazyListState, changesListScroll: LazyListState,
textScroll: ScrollState, textScroll: ScrollState,
showSearch: Boolean, showSearch: Boolean,
showAsTree: Boolean,
searchFilter: TextFieldValue, searchFilter: TextFieldValue,
onBlame: (String) -> Unit, onBlame: (String) -> Unit,
onHistory: (String) -> Unit, onHistory: (String) -> Unit,
onDiffSelected: (DiffEntry) -> Unit, onDiffSelected: (DiffEntry) -> Unit,
onSearchFilterToggled: (Boolean) -> Unit, onSearchFilterToggled: (Boolean) -> Unit,
onSearchFilterChanged: (TextFieldValue) -> Unit, onSearchFilterChanged: (TextFieldValue) -> Unit,
onDirectoryClicked: (TreeItem.Dir) -> Unit,
onAlternateShowAsTree: () -> Unit,
) { ) {
val commit = commitChangesStatus.commit
/**
* State used to prevent the text field from getting the focus when returning from another tab
*/
var requestFocus by remember { mutableStateOf(false) }
Column( Column(
modifier = Modifier modifier = Modifier
.padding(end = 8.dp, bottom = 8.dp) .padding(end = 8.dp, bottom = 8.dp)
.fillMaxSize(), .fillMaxSize(),
) { ) {
val searchFocusRequester = remember { FocusRequester() }
Column( Column(
modifier = Modifier modifier = Modifier
@ -126,6 +124,74 @@ fun CommitChangesView(
.weight(1f, fill = true) .weight(1f, fill = true)
.background(MaterialTheme.colors.background) .background(MaterialTheme.colors.background)
) { ) {
Header(
showSearch,
searchFilter,
onSearchFilterChanged,
onSearchFilterToggled,
showAsTree = showAsTree,
onAlternateShowAsTree = onAlternateShowAsTree,
)
when (commitChangesStatus) {
is CommitChangesStateUi.ListLoaded -> {
val changes = commitChangesStatus.changes
ListCommitLogChanges(
diffSelected = diffSelected,
changesListScroll = changesListScroll,
diffEntries = changes,
onDiffSelected = onDiffSelected,
onGenerateContextMenu = { diffEntry ->
committedChangesEntriesContextMenuItems(
diffEntry,
onBlame = { onBlame(diffEntry.filePath) },
onHistory = { onHistory(diffEntry.filePath) },
)
}
)
}
is CommitChangesStateUi.TreeLoaded -> {
TreeCommitLogChanges(
diffSelected = diffSelected,
changesListScroll = changesListScroll,
treeItems = commitChangesStatus.changes,
onDiffSelected = onDiffSelected,
onGenerateContextMenu = { diffEntry ->
committedChangesEntriesContextMenuItems(
diffEntry,
onBlame = { onBlame(diffEntry.filePath) },
onHistory = { onHistory(diffEntry.filePath) },
)
},
onDirectoryClicked = onDirectoryClicked,
)
}
}
}
MessageAuthorFooter(commit, textScroll)
}
}
@Composable
private fun Header(
showSearch: Boolean,
searchFilter: TextFieldValue,
onSearchFilterChanged: (TextFieldValue) -> Unit,
onSearchFilterToggled: (Boolean) -> Unit,
showAsTree: Boolean,
onAlternateShowAsTree: () -> Unit,
) {
val searchFocusRequester = remember { FocusRequester() }
/**
* State used to prevent the text field from getting the focus when returning from another tab
*/
var requestFocus by remember { mutableStateOf(false) }
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -146,6 +212,20 @@ fun CommitChangesView(
Box(modifier = Modifier.weight(1f)) Box(modifier = Modifier.weight(1f))
IconButton(
onClick = {
onAlternateShowAsTree()
},
modifier = Modifier.handOnHover()
) {
Icon(
painter = painterResource(if (showAsTree) AppIcons.LIST else AppIcons.TREE),
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colors.onBackground,
)
}
IconButton( IconButton(
onClick = { onClick = {
onSearchFilterToggled(!showSearch) onSearchFilterToggled(!showSearch)
@ -179,17 +259,13 @@ fun CommitChangesView(
requestFocus = false requestFocus = false
} }
} }
}
CommitLogChanges( @Composable
diffSelected = diffSelected, private fun MessageAuthorFooter(
changesListScroll = changesListScroll, commit: RevCommit,
diffEntries = changes, textScroll: ScrollState,
onDiffSelected = onDiffSelected, ) {
onBlame = onBlame,
onHistory = onHistory,
)
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -211,7 +287,6 @@ fun CommitChangesView(
Author(commit.shortName, commit.name, commit.authorIdent) Author(commit.shortName, commit.name, commit.authorIdent)
} }
}
} }
@Composable @Composable
@ -300,15 +375,13 @@ fun Author(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun CommitLogChanges( fun ListCommitLogChanges(
diffEntries: List<DiffEntry>, diffEntries: List<DiffEntry>,
diffSelected: DiffEntryType?, diffSelected: DiffEntryType?,
changesListScroll: LazyListState, changesListScroll: LazyListState,
onBlame: (String) -> Unit,
onHistory: (String) -> Unit,
onDiffSelected: (DiffEntry) -> Unit, onDiffSelected: (DiffEntry) -> Unit,
onGenerateContextMenu: (DiffEntry) -> List<ContextMenuElement>,
) { ) {
ScrollableLazyColumn( ScrollableLazyColumn(
modifier = Modifier modifier = Modifier
@ -316,74 +389,96 @@ fun CommitLogChanges(
state = changesListScroll, state = changesListScroll,
) { ) {
items(items = diffEntries) { diffEntry -> items(items = diffEntries) { diffEntry ->
ContextMenu( FileEntry(
items = { icon = diffEntry.icon,
committedChangesEntriesContextMenuItems( iconColor = diffEntry.iconColor,
diffEntry, parentDirectoryPath = diffEntry.parentDirectoryPath,
onBlame = { onBlame(diffEntry.filePath) }, fileName = diffEntry.fileName,
onHistory = { onHistory(diffEntry.filePath) }, isSelected = diffSelected is DiffEntryType.CommitDiff && diffSelected.diffEntry == diffEntry,
onClick = { onDiffSelected(diffEntry) },
onDoubleClick = {},
onGenerateContextMenu = { onGenerateContextMenu(diffEntry) },
trailingAction = null,
) )
} }
) {
Column(
modifier = Modifier
.height(40.dp)
.fillMaxWidth()
.handMouseClickable {
onDiffSelected(diffEntry)
}
.backgroundIf(
condition = diffSelected is DiffEntryType.CommitDiff && diffSelected.diffEntry == diffEntry,
color = MaterialTheme.colors.backgroundSelected,
),
verticalArrangement = Arrangement.Center,
) {
Spacer(modifier = Modifier.weight(2f))
Row {
Icon(
modifier = Modifier
.padding(horizontal = 8.dp)
.size(16.dp),
imageVector = diffEntry.icon,
contentDescription = null,
tint = diffEntry.iconColor,
)
if (diffEntry.parentDirectoryPath.isNotEmpty()) {
Text(
text = diffEntry.parentDirectoryPath.removeSuffix("/"),
modifier = Modifier.weight(1f, fill = false),
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onBackgroundSecondary,
)
Text(
text = "/",
maxLines = 1,
softWrap = false,
style = MaterialTheme.typography.body2,
overflow = TextOverflow.Visible,
color = MaterialTheme.colors.onBackgroundSecondary,
)
}
Text(
text = diffEntry.fileName,
maxLines = 1,
softWrap = false,
modifier = Modifier.padding(end = 16.dp),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onBackground,
)
}
Spacer(modifier = Modifier.weight(2f))
}
}
}
} }
} }
@Composable
fun TreeCommitLogChanges(
treeItems: List<TreeItem<DiffEntry>>,
diffSelected: DiffEntryType?,
changesListScroll: LazyListState,
onDiffSelected: (DiffEntry) -> Unit,
onDirectoryClicked: (TreeItem.Dir) -> Unit,
onGenerateContextMenu: (DiffEntry) -> List<ContextMenuElement>,
) {
ScrollableLazyColumn(
modifier = Modifier
.fillMaxSize(),
state = changesListScroll,
) {
items(items = treeItems) { entry ->
CommitTreeItemEntry(
entry = entry,
isSelected = entry is TreeItem.File &&
diffSelected is DiffEntryType.CommitDiff &&
diffSelected.diffEntry == entry.data,
onFileClick = { onDiffSelected(it) },
onDirectoryClick = { onDirectoryClicked(it) },
onGenerateContextMenu = onGenerateContextMenu,
onGenerateDirectoryContextMenu = { emptyList() },
)
}
}
}
@Composable
private fun CommitTreeItemEntry(
entry: TreeItem<DiffEntry>,
isSelected: Boolean,
onFileClick: (DiffEntry) -> Unit,
onDirectoryClick: (TreeItem.Dir) -> Unit,
onGenerateContextMenu: (DiffEntry) -> List<ContextMenuElement>,
onGenerateDirectoryContextMenu: (TreeItem.Dir) -> List<ContextMenuElement>,
) {
when (entry) {
is TreeItem.File -> CommitFileEntry(
fileEntry = entry,
isSelected = isSelected,
onClick = { onFileClick(entry.data) },
onGenerateContextMenu = onGenerateContextMenu,
)
is TreeItem.Dir -> DirectoryEntry(
dirName = entry.displayName,
onClick = { onDirectoryClick(entry) },
depth = entry.depth,
onGenerateContextMenu = { onGenerateDirectoryContextMenu(entry) },
)
}
}
@Composable
private fun CommitFileEntry(
fileEntry: TreeItem.File<DiffEntry>,
isSelected: Boolean,
onClick: () -> Unit,
onGenerateContextMenu: (DiffEntry) -> List<ContextMenuElement>,
) {
val diffEntry = fileEntry.data
FileEntry(
icon = diffEntry.icon,
iconColor = diffEntry.iconColor,
parentDirectoryPath = "",
fileName = diffEntry.fileName,
isSelected = isSelected,
onClick = onClick,
onDoubleClick = {},
depth = fileEntry.depth,
onGenerateContextMenu = { onGenerateContextMenu(diffEntry) },
trailingAction = null,
)
}

View File

@ -0,0 +1,161 @@
package com.jetpackduba.gitnuro.ui.components
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.*
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.jetpackduba.gitnuro.AppIcons
import com.jetpackduba.gitnuro.extensions.backgroundIf
import com.jetpackduba.gitnuro.extensions.handMouseClickable
import com.jetpackduba.gitnuro.extensions.onDoubleClick
import com.jetpackduba.gitnuro.theme.backgroundSelected
import com.jetpackduba.gitnuro.theme.onBackgroundSecondary
import com.jetpackduba.gitnuro.ui.context_menu.ContextMenu
import com.jetpackduba.gitnuro.ui.context_menu.ContextMenuElement
private const val TREE_START_PADDING = 12
@Composable
fun FileEntry(
icon: ImageVector,
iconColor: Color,
parentDirectoryPath: String,
fileName: String,
isSelected: Boolean,
onClick: () -> Unit,
onDoubleClick: () -> Unit,
depth: Int = 0,
onGenerateContextMenu: () -> List<ContextMenuElement>,
trailingAction: (@Composable BoxScope.(isHovered: Boolean) -> Unit)?,
) {
FileEntry(
icon = rememberVectorPainter(icon),
iconColor = iconColor,
parentDirectoryPath = parentDirectoryPath,
fileName = fileName,
isSelected = isSelected,
onClick = onClick,
onDoubleClick = onDoubleClick,
depth = depth,
onGenerateContextMenu = onGenerateContextMenu,
trailingAction = trailingAction
)
}
@Composable
fun FileEntry(
icon: Painter,
iconColor: Color,
parentDirectoryPath: String,
fileName: String,
isSelected: Boolean,
onClick: () -> Unit,
onDoubleClick: () -> Unit,
depth: Int = 0,
onGenerateContextMenu: () -> List<ContextMenuElement>,
trailingAction: (@Composable BoxScope.(isHovered: Boolean) -> Unit)?,
) {
val hoverInteraction = remember { MutableInteractionSource() }
val isHovered by hoverInteraction.collectIsHoveredAsState()
Box(
modifier = Modifier
.handMouseClickable { onClick() }
.onDoubleClick(onDoubleClick)
.fillMaxWidth()
.hoverable(hoverInteraction)
) {
ContextMenu(
items = {
onGenerateContextMenu()
},
) {
Row(
modifier = Modifier
.height(40.dp)
.fillMaxWidth()
.backgroundIf(isSelected, MaterialTheme.colors.backgroundSelected)
.padding(start = (TREE_START_PADDING * depth).dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = icon,
contentDescription = null,
modifier = Modifier
.padding(horizontal = 8.dp)
.size(16.dp),
tint = iconColor,
)
if (parentDirectoryPath.isNotEmpty()) {
Text(
text = parentDirectoryPath.removeSuffix("/"),
modifier = Modifier.weight(1f, fill = false),
maxLines = 1,
softWrap = false,
style = MaterialTheme.typography.body2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colors.onBackgroundSecondary,
)
Text(
text = "/",
maxLines = 1,
softWrap = false,
style = MaterialTheme.typography.body2,
overflow = TextOverflow.Visible,
color = MaterialTheme.colors.onBackgroundSecondary,
)
}
Text(
text = fileName,
maxLines = 1,
softWrap = false,
modifier = Modifier.padding(end = 16.dp),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onBackground,
)
}
}
trailingAction?.invoke(this, isHovered)
}
}
@Composable
fun DirectoryEntry(
dirName: String,
onClick: () -> Unit,
depth: Int = 0,
onGenerateContextMenu: () -> List<ContextMenuElement>,
) {
FileEntry(
icon = painterResource(AppIcons.FOLDER),
iconColor = MaterialTheme.colors.onBackground,
isSelected = false,
onClick = onClick,
onDoubleClick = {},
parentDirectoryPath = "",
fileName = dirName,
depth = depth,
onGenerateContextMenu = onGenerateContextMenu,
trailingAction = null,
)
}

View File

@ -220,7 +220,7 @@ fun showPopup(x: Int, y: Int, contextMenuElements: List<ContextMenuElement>, onD
fun Separator() { fun Separator() {
Box( Box(
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth() .fillMaxWidth()
.height(1.dp) .height(1.dp)
.background(MaterialTheme.colors.onBackground.copy(alpha = 0.4f)) .background(MaterialTheme.colors.onBackground.copy(alpha = 0.4f))

View File

@ -0,0 +1,40 @@
package com.jetpackduba.gitnuro.ui.context_menu
import androidx.compose.ui.res.painterResource
import com.jetpackduba.gitnuro.AppIcons
fun statusDirEntriesContextMenuItems(
entryType: EntryType,
onStageChanges: () -> Unit,
onDiscardDirectoryChanges: () -> Unit,
): List<ContextMenuElement> {
return mutableListOf<ContextMenuElement>().apply {
val (text, icon) = if (entryType == EntryType.STAGED) {
"Unstage changes in the directory" to AppIcons.REMOVE_DONE
} else {
"Stage changes in the directory" to AppIcons.DONE
}
add(
ContextMenuElement.ContextTextEntry(
label = text,
icon = { painterResource(icon) },
onClick = onStageChanges,
)
)
if (entryType == EntryType.UNSTAGED) {
add(ContextMenuElement.ContextSeparator)
add(
ContextMenuElement.ContextTextEntry(
label = "Discard changes in the directory",
icon = { painterResource(AppIcons.UNDO) },
onClick = onDiscardDirectoryChanges,
)
)
}
}
}

View File

@ -0,0 +1,70 @@
package com.jetpackduba.gitnuro.ui.tree_files
import com.jetpackduba.gitnuro.system.systemSeparator
fun <T> entriesToTreeEntry(
entries: List<T>,
treeContractedDirs: List<String>,
onGetEntryPath: (T) -> String,
): List<TreeItem<T>> {
return entries
.asSequence()
.map { entry ->
val filePath = onGetEntryPath(entry)
val parts = filePath.split(systemSeparator)
parts.mapIndexed { index, partName ->
if (index == parts.lastIndex) {
val isParentContracted = treeContractedDirs.none { contractedDir ->
filePath.startsWith(contractedDir + systemSeparator)
}
if (isParentContracted) {
TreeItem.File(entry, partName, filePath, index)
} else {
null
}
} else {
val dirPath = parts.slice(0..index).joinToString(systemSeparator)
val isParentDirectoryContracted = treeContractedDirs.any { contractedDir ->
dirPath.startsWith(contractedDir + systemSeparator) &&
dirPath != contractedDir
}
val isExactDirectoryContracted = treeContractedDirs.any { contractedDir ->
dirPath == contractedDir
}
when {
isParentDirectoryContracted -> null
isExactDirectoryContracted -> TreeItem.Dir(false, partName, dirPath, index)
else -> TreeItem.Dir(true, partName, dirPath, index)
}
}
}
}
.flatten()
.filterNotNull()
.distinct()
.sortedBy { it.fullPath }
.toList()
}
sealed interface TreeItem<out T> {
val fullPath: String
val displayName: String
val depth: Int
data class Dir(
val isExpanded: Boolean,
override val displayName: String,
override val fullPath: String,
override val depth: Int
) : TreeItem<Nothing>
data class File<T>(
val data: T,
override val displayName: String,
override val fullPath: String,
override val depth: Int
) : TreeItem<T>
}

View File

@ -10,6 +10,9 @@ import com.jetpackduba.gitnuro.extensions.lowercaseContains
import com.jetpackduba.gitnuro.git.RefreshType import com.jetpackduba.gitnuro.git.RefreshType
import com.jetpackduba.gitnuro.git.TabState import com.jetpackduba.gitnuro.git.TabState
import com.jetpackduba.gitnuro.git.diff.GetCommitDiffEntriesUseCase import com.jetpackduba.gitnuro.git.diff.GetCommitDiffEntriesUseCase
import com.jetpackduba.gitnuro.preferences.AppSettings
import com.jetpackduba.gitnuro.ui.tree_files.TreeItem
import com.jetpackduba.gitnuro.ui.tree_files.entriesToTreeEntry
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import org.eclipse.jgit.diff.DiffEntry import org.eclipse.jgit.diff.DiffEntry
@ -21,6 +24,7 @@ private const val MIN_TIME_IN_MS_TO_SHOW_LOAD = 300L
class CommitChangesViewModel @Inject constructor( class CommitChangesViewModel @Inject constructor(
private val tabState: TabState, private val tabState: TabState,
private val getCommitDiffEntriesUseCase: GetCommitDiffEntriesUseCase, private val getCommitDiffEntriesUseCase: GetCommitDiffEntriesUseCase,
private val appSettings: AppSettings,
tabScope: CoroutineScope, tabScope: CoroutineScope,
) { ) {
private val _showSearch = MutableStateFlow(false) private val _showSearch = MutableStateFlow(false)
@ -33,26 +37,48 @@ class CommitChangesViewModel @Inject constructor(
LazyListState(firstVisibleItemIndex = 0, firstVisibleItemScrollOffset = 0) LazyListState(firstVisibleItemIndex = 0, firstVisibleItemScrollOffset = 0)
) )
val textScroll = MutableStateFlow( val textScroll = MutableStateFlow(ScrollState(0))
ScrollState(0)
) val showAsTree = appSettings.showChangesAsTreeFlow
private val treeContractedDirectories = MutableStateFlow(emptyList<String>())
private val _commitChangesState = MutableStateFlow<CommitChangesState>(CommitChangesState.Loading) private val _commitChangesState = MutableStateFlow<CommitChangesState>(CommitChangesState.Loading)
val commitChangesState: StateFlow<CommitChangesState> =
private val commitChangesFiltered =
combine(_commitChangesState, _showSearch, _searchFilter) { state, showSearch, filter -> combine(_commitChangesState, _showSearch, _searchFilter) { state, showSearch, filter ->
if (state is CommitChangesState.Loaded) { if (state is CommitChangesState.Loaded && showSearch && filter.text.isNotBlank()) {
if (showSearch && filter.text.isNotBlank()) { state.copy(changes = state.changes.filter { it.filePath.lowercaseContains(filter.text) })
state.copy(changesFiltered = state.changes.filter { it.filePath.lowercaseContains(filter.text) })
} else { } else {
state state
} }
} else {
state
} }
}.stateIn(
val commitChangesStateUi: StateFlow<CommitChangesStateUi> = combine(
commitChangesFiltered,
showAsTree,
treeContractedDirectories,
) { commitState, showAsTree, contractedDirs ->
when (commitState) {
CommitChangesState.Loading -> CommitChangesStateUi.Loading
is CommitChangesState.Loaded -> {
if (showAsTree) {
CommitChangesStateUi.TreeLoaded(
commit = commitState.commit,
changes = entriesToTreeEntry(commitState.changes, contractedDirs) { it.filePath }
)
} else {
CommitChangesStateUi.ListLoaded(
commit = commitState.commit,
changes = commitState.changes
)
}
}
}
}
.stateIn(
tabScope, tabScope,
SharingStarted.Lazily, SharingStarted.Lazily,
CommitChangesState.Loading CommitChangesStateUi.Loading
) )
@ -92,7 +118,7 @@ class CommitChangesViewModel @Inject constructor(
} }
} }
_commitChangesState.value = CommitChangesState.Loaded(commit, changes, changes) _commitChangesState.value = CommitChangesState.Loaded(commit, changes)
} }
} }
@ -106,6 +132,20 @@ class CommitChangesViewModel @Inject constructor(
} }
} }
fun alternateShowAsTree() {
appSettings.showChangesAsTree = !appSettings.showChangesAsTree
}
fun onDirectoryClicked(directoryPath: String) {
val contractedDirectories = treeContractedDirectories.value
if (contractedDirectories.contains(directoryPath)) {
treeContractedDirectories.value -= directoryPath
} else {
treeContractedDirectories.value += directoryPath
}
}
fun onSearchFilterToggled(visible: Boolean) { fun onSearchFilterToggled(visible: Boolean) {
_showSearch.value = visible _showSearch.value = visible
} }
@ -115,9 +155,22 @@ class CommitChangesViewModel @Inject constructor(
} }
} }
sealed interface CommitChangesState { private sealed interface CommitChangesState {
data object Loading : CommitChangesState data object Loading : CommitChangesState
data class Loaded(val commit: RevCommit, val changes: List<DiffEntry>, val changesFiltered: List<DiffEntry>) : data class Loaded(val commit: RevCommit, val changes: List<DiffEntry>) :
CommitChangesState CommitChangesState
} }
sealed interface CommitChangesStateUi {
data object Loading : CommitChangesStateUi
sealed interface Loaded : CommitChangesStateUi {
val commit: RevCommit
}
data class ListLoaded(override val commit: RevCommit, val changes: List<DiffEntry>) :
Loaded
data class TreeLoaded(override val commit: RevCommit, val changes: List<TreeItem<DiffEntry>>) :
Loaded
}

View File

@ -21,6 +21,8 @@ import com.jetpackduba.gitnuro.git.repository.ResetRepositoryStateUseCase
import com.jetpackduba.gitnuro.git.workspace.* import com.jetpackduba.gitnuro.git.workspace.*
import com.jetpackduba.gitnuro.models.AuthorInfo import com.jetpackduba.gitnuro.models.AuthorInfo
import com.jetpackduba.gitnuro.preferences.AppSettings import com.jetpackduba.gitnuro.preferences.AppSettings
import com.jetpackduba.gitnuro.ui.tree_files.TreeItem
import com.jetpackduba.gitnuro.ui.tree_files.entriesToTreeEntry
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@ -38,6 +40,8 @@ class StatusViewModel @Inject constructor(
private val tabState: TabState, private val tabState: TabState,
private val stageEntryUseCase: StageEntryUseCase, private val stageEntryUseCase: StageEntryUseCase,
private val unstageEntryUseCase: UnstageEntryUseCase, private val unstageEntryUseCase: UnstageEntryUseCase,
private val stageByDirectoryUseCase: StageByDirectoryUseCase,
private val unstageByDirectoryUseCase: UnstageByDirectoryUseCase,
private val resetEntryUseCase: DiscardEntryUseCase, private val resetEntryUseCase: DiscardEntryUseCase,
private val stageAllUseCase: StageAllUseCase, private val stageAllUseCase: StageAllUseCase,
private val unstageAllUseCase: UnstageAllUseCase, private val unstageAllUseCase: UnstageAllUseCase,
@ -55,8 +59,8 @@ class StatusViewModel @Inject constructor(
private val saveAuthorUseCase: SaveAuthorUseCase, private val saveAuthorUseCase: SaveAuthorUseCase,
private val sharedRepositoryStateManager: SharedRepositoryStateManager, private val sharedRepositoryStateManager: SharedRepositoryStateManager,
private val getSpecificCommitMessageUseCase: GetSpecificCommitMessageUseCase, private val getSpecificCommitMessageUseCase: GetSpecificCommitMessageUseCase,
private val appSettings: AppSettings,
tabScope: CoroutineScope, tabScope: CoroutineScope,
appSettings: AppSettings,
) { ) {
private val _showSearchUnstaged = MutableStateFlow(false) private val _showSearchUnstaged = MutableStateFlow(false)
val showSearchUnstaged: StateFlow<Boolean> = _showSearchUnstaged val showSearchUnstaged: StateFlow<Boolean> = _showSearchUnstaged
@ -73,9 +77,11 @@ class StatusViewModel @Inject constructor(
val swapUncommittedChanges = appSettings.swapUncommittedChangesFlow val swapUncommittedChanges = appSettings.swapUncommittedChangesFlow
val rebaseInteractiveState = sharedRepositoryStateManager.rebaseInteractiveState val rebaseInteractiveState = sharedRepositoryStateManager.rebaseInteractiveState
private val treeContractedDirectories = MutableStateFlow(emptyList<String>())
private val showAsTree = appSettings.showChangesAsTreeFlow
private val _stageState = MutableStateFlow<StageState>(StageState.Loading) private val _stageState = MutableStateFlow<StageState>(StageState.Loading)
val stageState: StateFlow<StageState> = combine( private val stageStateFiltered: StateFlow<StageState> = combine(
_stageState, _stageState,
_showSearchStaged, _showSearchStaged,
_searchFilterStaged, _searchFilterStaged,
@ -83,7 +89,6 @@ class StatusViewModel @Inject constructor(
_searchFilterUnstaged, _searchFilterUnstaged,
) { state, showSearchStaged, filterStaged, showSearchUnstaged, filterUnstaged -> ) { state, showSearchStaged, filterStaged, showSearchUnstaged, filterUnstaged ->
if (state is StageState.Loaded) { if (state is StageState.Loaded) {
val unstaged = if (showSearchUnstaged && filterUnstaged.text.isNotBlank()) { val unstaged = if (showSearchUnstaged && filterUnstaged.text.isNotBlank()) {
state.unstaged.filter { it.filePath.lowercaseContains(filterUnstaged.text) } state.unstaged.filter { it.filePath.lowercaseContains(filterUnstaged.text) }
} else { } else {
@ -96,31 +101,49 @@ class StatusViewModel @Inject constructor(
state.staged state.staged
}.prioritizeConflicts() }.prioritizeConflicts()
state.copy(stagedFiltered = staged, unstagedFiltered = unstaged) state.copy(staged = staged, unstaged = unstaged)
} else { } else {
state state
} }
}.stateIn( }
.stateIn(
tabScope, tabScope,
SharingStarted.Lazily, SharingStarted.Lazily,
StageState.Loading StageState.Loading
) )
fun List<StatusEntry>.prioritizeConflicts(): List<StatusEntry> {
return this.groupBy { it.filePath }
.map {
val statusEntries = it.value
return@map if (statusEntries.count() == 1) {
statusEntries.first()
} else {
val conflictingEntry =
statusEntries.firstOrNull { entry -> entry.statusType == StatusType.CONFLICTING }
conflictingEntry ?: statusEntries.first() val stageStateUi: StateFlow<StageStateUi> = combine(
stageStateFiltered,
showAsTree,
treeContractedDirectories,
) { stageStateFiltered, showAsTree, contractedDirectories ->
when (stageStateFiltered) {
is StageState.Loaded -> {
if (showAsTree) {
StageStateUi.TreeLoaded(
staged = entriesToTreeEntry(stageStateFiltered.staged, contractedDirectories) { it.filePath },
unstaged = entriesToTreeEntry(stageStateFiltered.unstaged, contractedDirectories) { it.filePath },
isPartiallyReloading = stageStateFiltered.isPartiallyReloading,
)
} else {
StageStateUi.ListLoaded(
staged = stageStateFiltered.staged,
unstaged = stageStateFiltered.unstaged,
isPartiallyReloading = stageStateFiltered.isPartiallyReloading,
)
} }
} }
StageState.Loading -> StageStateUi.Loading
} }
}
.stateIn(
tabScope,
SharingStarted.Lazily,
StageStateUi.Loading
)
var savedCommitMessage = CommitMessage("", MessageType.NORMAL) var savedCommitMessage = CommitMessage("", MessageType.NORMAL)
@ -248,9 +271,8 @@ class StatusViewModel @Inject constructor(
_stageState.value = StageState.Loaded( _stageState.value = StageState.Loaded(
staged = staged, staged = staged,
stagedFiltered = staged,
unstaged = unstaged, unstaged = unstaged,
unstagedFiltered = unstaged, isPartiallyReloading = false isPartiallyReloading = false,
) )
} }
} catch (ex: Exception) { } catch (ex: Exception) {
@ -259,6 +281,21 @@ class StatusViewModel @Inject constructor(
} }
} }
private fun List<StatusEntry>.prioritizeConflicts(): List<StatusEntry> {
return this.groupBy { it.filePath }
.map {
val statusEntries = it.value
return@map if (statusEntries.count() == 1) {
statusEntries.first()
} else {
val conflictingEntry =
statusEntries.firstOrNull { entry -> entry.statusType == StatusType.CONFLICTING }
conflictingEntry ?: statusEntries.first()
}
}
}
private fun messageByRepoState(git: Git): String { private fun messageByRepoState(git: Git): String {
val message: String? = if ( val message: String? = if (
git.repository.repositoryState.isMerging || git.repository.repositoryState.isMerging ||
@ -449,19 +486,91 @@ class StatusViewModel @Inject constructor(
fun onSearchFilterChangedUnstaged(filter: TextFieldValue) { fun onSearchFilterChangedUnstaged(filter: TextFieldValue) {
_searchFilterUnstaged.value = filter _searchFilterUnstaged.value = filter
} }
fun stagedTreeDirectoryClicked(directoryPath: String) {
val contractedDirectories = treeContractedDirectories.value
if (contractedDirectories.contains(directoryPath)) {
treeContractedDirectories.value -= directoryPath
} else {
treeContractedDirectories.value += directoryPath
}
}
fun alternateShowAsTree() {
appSettings.showChangesAsTree = !appSettings.showChangesAsTree
}
fun stageByDirectory(dir: String) = tabState.runOperation(
refreshType = RefreshType.UNCOMMITTED_CHANGES,
showError = true,
) { git ->
stageByDirectoryUseCase(git, dir)
}
fun unstageByDirectory(dir: String) = tabState.runOperation(
refreshType = RefreshType.UNCOMMITTED_CHANGES,
showError = true,
) { git ->
unstageByDirectoryUseCase(git, dir)
}
} }
sealed interface StageState { sealed interface StageState {
data object Loading : StageState data object Loading : StageState
data class Loaded( data class Loaded(
val staged: List<StatusEntry>, val staged: List<StatusEntry>,
val stagedFiltered: List<StatusEntry>,
val unstaged: List<StatusEntry>, val unstaged: List<StatusEntry>,
val unstagedFiltered: List<StatusEntry>, val isPartiallyReloading: Boolean,
val isPartiallyReloading: Boolean
) : StageState ) : StageState
} }
sealed interface StageStateUi {
val hasStagedFiles: Boolean
val hasUnstagedFiles: Boolean
val isLoading: Boolean
val haveConflictsBeenSolved: Boolean
data object Loading : StageStateUi {
override val hasStagedFiles: Boolean
get() = false
override val hasUnstagedFiles: Boolean
get() = false
override val isLoading: Boolean
get() = true
override val haveConflictsBeenSolved: Boolean
get() = false
}
sealed interface Loaded : StageStateUi
data class TreeLoaded(
val staged: List<TreeItem<StatusEntry>>,
val unstaged: List<TreeItem<StatusEntry>>,
val isPartiallyReloading: Boolean,
) : Loaded {
override val hasStagedFiles: Boolean = staged.isNotEmpty()
override val hasUnstagedFiles: Boolean = unstaged.isNotEmpty()
override val isLoading: Boolean = isPartiallyReloading
override val haveConflictsBeenSolved: Boolean = unstaged.none {
it is TreeItem.File && it.data.statusType == StatusType.CONFLICTING
}
}
data class ListLoaded(
val staged: List<StatusEntry>,
val unstaged: List<StatusEntry>,
val isPartiallyReloading: Boolean,
) : Loaded {
override val hasStagedFiles: Boolean = staged.isNotEmpty()
override val hasUnstagedFiles: Boolean = unstaged.isNotEmpty()
override val isLoading: Boolean = isPartiallyReloading
override val haveConflictsBeenSolved: Boolean = unstaged.none { it.statusType == StatusType.CONFLICTING }
}
}
data class CommitMessage(val message: String, val messageType: MessageType) data class CommitMessage(val message: String, val messageType: MessageType)
enum class MessageType { enum class MessageType {
@ -470,7 +579,7 @@ enum class MessageType {
} }
sealed interface CommitterDataRequestState { sealed interface CommitterDataRequestState {
object None : CommitterDataRequestState data object None : CommitterDataRequestState
data class WaitingInput(val authorInfo: AuthorInfo) : CommitterDataRequestState data class WaitingInput(val authorInfo: AuthorInfo) : CommitterDataRequestState
data class Accepted(val authorInfo: AuthorInfo, val persist: Boolean) : CommitterDataRequestState data class Accepted(val authorInfo: AuthorInfo, val persist: Boolean) : CommitterDataRequestState
object Reject : CommitterDataRequestState object Reject : CommitterDataRequestState

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Zm0-80h640v-400H447l-80-80H160v480Zm0 0v-480 480Z"/></svg>

After

Width:  |  Height:  |  Size: 282 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640H447l-80-80H160v480l96-320h684L837-217q-8 26-29.5 41.5T760-160H160Zm84-80h516l72-240H316l-72 240Zm0 0 72-240-72 240Zm-84-400v-80 80Z"/></svg>

After

Width:  |  Height:  |  Size: 334 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M600-120v-120H440v-400h-80v120H80v-320h280v120h240v-120h280v320H600v-120h-80v320h80v-120h280v320H600ZM160-760v160-160Zm520 400v160-160Zm0-400v160-160Zm0 160h120v-160H680v160Zm0 400h120v-160H680v160ZM160-600h120v-160H160v160Z"/></svg>

After

Width:  |  Height:  |  Size: 330 B