Skip to content

Commit

Permalink
Merge pull request #32 from emanguy/13-project-pinning
Browse files Browse the repository at this point in the history
Add support for project pinning
  • Loading branch information
emanguy authored Mar 27, 2021
2 parents 797d8af + 5fa1a93 commit 11684b5
Show file tree
Hide file tree
Showing 15 changed files with 282 additions and 39 deletions.
Binary file added res/pin_16px.xcf
Binary file not shown.
67 changes: 61 additions & 6 deletions src/main/kotlin/controller/ProjectController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,83 @@ package edu.erittenhouse.gitlabtimetracker.controller

import edu.erittenhouse.gitlabtimetracker.controller.result.ProjectFetchResult
import edu.erittenhouse.gitlabtimetracker.gitlab.GitlabAPI
import edu.erittenhouse.gitlabtimetracker.io.SettingsManager
import edu.erittenhouse.gitlabtimetracker.model.Project
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.javafx.JavaFx
import kotlinx.coroutines.withContext
import tornadofx.Controller
import tornadofx.asObservable
import tornadofx.*

class ProjectController : Controller() {
private val gitlabAPI by inject<GitlabAPI>()
private val credentialController by inject<CredentialController>()
private val settings = SettingsManager(find<StorageConfig>().fileLocation)
val projects = mutableListOf<Project>().asObservable()

private var pinnedProjects = listOf<Project>()
private var unpinnedProjects = listOf<Project>()

/**
* Fetches user's projects from GitLab and
* Fetches user's projects from GitLab and adds them to the projects list
*/
suspend fun fetchProjects(): ProjectFetchResult {
val credentials = credentialController.credentials ?: return ProjectFetchResult.NoCredentials
val fullProjectList = gitlabAPI.project.listUserMemberProjects(credentials).map { Project.fromGitlabDto(it) }
val pinnedProjectIDs = settings.getPinnedProjects()
val (localPinnedProjects, localUnpinnedProjects) = gitlabAPI.project.listUserMemberProjects(credentials).asSequence()
.map { Project.fromGitlabDto(it, isPinned = it.id in pinnedProjectIDs) }
.partition { it.pinned }
pinnedProjects = localPinnedProjects
unpinnedProjects = localUnpinnedProjects

withContext(Dispatchers.JavaFx) {
projects.setAll(fullProjectList)
projects.setAll(localPinnedProjects + localUnpinnedProjects)
}
return ProjectFetchResult.ProjectsRetrieved
}
}

/**
* Marks a project as pinned and moves it to the top of the project list
*/
suspend fun pinProject(projectID: Int) {
val localPinnedProjects = pinnedProjects.toMutableList()
val localUnpinnedProjects = unpinnedProjects.toMutableList()

val projectToPinIdx = localUnpinnedProjects.indexOfFirst { it.id == projectID }
// If we can't find the project in question just return
if (projectToPinIdx == -1) return

localPinnedProjects.add(localUnpinnedProjects.removeAt(projectToPinIdx).copy(pinned = true))

val currentPinnedProjectIDs = settings.getPinnedProjects() + projectID
settings.setPinnedProjects(currentPinnedProjectIDs)
pinnedProjects = localPinnedProjects
unpinnedProjects = localUnpinnedProjects

withContext(Dispatchers.JavaFx) {
projects.setAll(localPinnedProjects + localUnpinnedProjects)
}
}

/**
* Marks a project as unpinned and moves it immediately after the project list
*/
suspend fun unpinProject(projectID: Int) {
val localPinnedProjects = pinnedProjects.toMutableList()
val localUnpinnedProjects = unpinnedProjects.toMutableList()

val projectToUnpinIdx = localPinnedProjects.indexOfFirst { it.id == projectID }
// If we couldn't find the specified project, just return
if (projectToUnpinIdx == -1) return

localUnpinnedProjects.add(0, localPinnedProjects.removeAt(projectToUnpinIdx).copy(pinned = false))

val currentPinnedProjectIDs = settings.getPinnedProjects() - projectID
settings.setPinnedProjects(currentPinnedProjectIDs)
pinnedProjects = localPinnedProjects
unpinnedProjects = localUnpinnedProjects

withContext(Dispatchers.JavaFx) {
projects.setAll(localPinnedProjects + localUnpinnedProjects)
}
}
}
6 changes: 3 additions & 3 deletions src/main/kotlin/controller/SlackController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import edu.erittenhouse.gitlabtimetracker.controller.result.SlackLoginResult
import edu.erittenhouse.gitlabtimetracker.io.SettingsManager
import edu.erittenhouse.gitlabtimetracker.model.Issue
import edu.erittenhouse.gitlabtimetracker.model.SlackCredential
import edu.erittenhouse.gitlabtimetracker.model.settings.v1.SlackConfig
import edu.erittenhouse.gitlabtimetracker.model.settings.NewestSlackConfig
import edu.erittenhouse.gitlabtimetracker.slack.SlackAPI
import edu.erittenhouse.gitlabtimetracker.slack.result.LoginResult
import edu.erittenhouse.gitlabtimetracker.ui.util.suspension.SuspendingController
Expand All @@ -22,7 +22,7 @@ class SlackController : SuspendingController() {
private val settingsManager = SettingsManager(find<StorageConfig>().fileLocation)
private val mutableEnabledState = MutableStateFlow(false)

var slackConfig: SlackConfig? = null
var slackConfig: NewestSlackConfig? = null
private set
val enabledState = mutableEnabledState.asStateFlow()

Expand Down Expand Up @@ -68,7 +68,7 @@ class SlackController : SuspendingController() {
* Enable the slack integration, using the provided options
*/
suspend fun enableSlackIntegration(slackCredential: SlackCredential, emoji: String, messageFormat: String) {
val newConfig = SlackConfig(slackCredential, emoji, messageFormat)
val newConfig = NewestSlackConfig(slackCredential, emoji, messageFormat)
settingsManager.setSlackConfig(true, newConfig)
slackConfig = newConfig
mutableEnabledState.value = true
Expand Down
68 changes: 58 additions & 10 deletions src/main/kotlin/io/SettingsManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,26 @@ package edu.erittenhouse.gitlabtimetracker.io
import com.fasterxml.jackson.module.kotlin.readValue
import edu.erittenhouse.gitlabtimetracker.io.error.SettingsErrors
import edu.erittenhouse.gitlabtimetracker.model.GitlabCredential
import edu.erittenhouse.gitlabtimetracker.model.settings.NewestSettings
import edu.erittenhouse.gitlabtimetracker.model.settings.NewestSlackConfig
import edu.erittenhouse.gitlabtimetracker.model.settings.defaultSettings
import edu.erittenhouse.gitlabtimetracker.model.settings.v1.Settings
import edu.erittenhouse.gitlabtimetracker.model.settings.v1.SlackConfig
import edu.erittenhouse.gitlabtimetracker.util.JsonMapper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File

/**
* The settings manager provides an interface for interacting with the settings file in the user's home directory.
* All functions here should be completely main-thread-safe, as they switch to the IO dispatcher whenever file interaction
* is required.
*/
class SettingsManager(private val fileLocation: String = System.getProperty("user.home") + "/.gtt") {
private companion object FileAccess {
private val fileMutexes = mutableMapOf<String, Mutex>()
private val mutexMapMutex = Mutex()
private val statesByFile = mutableMapOf<String, Settings>()
private val statesByFile = mutableMapOf<String, NewestSettings>()

private suspend inline fun <T> lockingSettingsForFile(fileName: String, action: () -> T): T {
val mutexForFile = mutexMapMutex.withLock {
Expand All @@ -27,13 +32,22 @@ class SettingsManager(private val fileLocation: String = System.getProperty("use
return mutexForFile.withLock(action = action)
}

/**
* Clears the in-memory cache for the given file
*/
suspend fun clearCache(fileName: String) {
lockingSettingsForFile(fileName) {
statesByFile.remove(fileName)
}
}

/**
* Fetches the cached settings from memory, falling back on and caching the value from
* disk if not in memory, finally returning null if the settings cannot be retrieved
*
* @throws SettingsErrors.DiskIOError if the data exists on disk but couldn't be read
*/
suspend fun fetchSettings(fileName: String): Settings? {
suspend fun fetchSettings(fileName: String): NewestSettings? {
return lockingSettingsForFile(fileName) {
if (statesByFile[fileName] != null) return statesByFile[fileName]

Expand All @@ -42,7 +56,7 @@ class SettingsManager(private val fileLocation: String = System.getProperty("use
if (!settingsFile.exists()) return@withContext null

val settings = try {
JsonMapper.readValue<Settings>(settingsFile)
JsonMapper.readValue<NewestSettings>(settingsFile)
} catch (e: Exception) {
throw SettingsErrors.DiskIOError(fileName, "Failed to retrieve saved settings.", e)
}
Expand All @@ -58,7 +72,7 @@ class SettingsManager(private val fileLocation: String = System.getProperty("use
*
* @throws SettingsErrors.DiskIOError if persisting the settings to disk fails
*/
suspend fun saveSettings(fileName: String, settings: Settings): Unit = withContext(Dispatchers.IO) {
suspend fun saveSettings(fileName: String, settings: NewestSettings): Unit = withContext(Dispatchers.IO) {
lockingSettingsForFile(fileName) {
statesByFile[fileName] = settings

Expand All @@ -74,14 +88,28 @@ class SettingsManager(private val fileLocation: String = System.getProperty("use
}
}

/**
* Clears the in-memory cache for this settings manager.
*/
suspend fun clearCache() {
FileAccess.clearCache(fileLocation)
}
/**
* Saves gitlab credentials to disk.
*
* @throws SettingsErrors.DiskIOError if the disk operation fails
*/
suspend fun setCredential(credential: GitlabCredential) {
val currentSettings = fetchSettings(fileLocation)
val newSettings = currentSettings?.copy(gitlabCredentials = credential) ?: defaultSettings(credential)

// If the base URL on the credential changed, we should clear out the list of pinned projects
val pinnedProjectIDList = if (currentSettings?.gitlabCredentials?.gitlabBaseURL != credential.gitlabBaseURL) {
emptySet()
} else {
currentSettings.pinnedProjectIDs
}

val newSettings = currentSettings?.copy(gitlabCredentials = credential, pinnedProjectIDs = pinnedProjectIDList) ?: defaultSettings(credential)
saveSettings(fileLocation, newSettings)
}

Expand All @@ -91,12 +119,24 @@ class SettingsManager(private val fileLocation: String = System.getProperty("use
* @throws SettingsErrors.DiskIOError if the disk operation fails
* @throws SettingsErrors.RequiredMissingError if required settings have not yet been set, such as gitlab credentials
*/
suspend fun setSlackConfig(slackEnabled: Boolean, credential: SlackConfig? = null) {
suspend fun setSlackConfig(slackEnabled: Boolean, credential: NewestSlackConfig? = null) {
val currentSettings = fetchSettings(fileLocation) ?: throw SettingsErrors.RequiredMissingError()
val newSettings = currentSettings.copy(slackConfig = credential ?: currentSettings.slackConfig, slackEnabled = slackEnabled)
saveSettings(fileLocation, newSettings)
}

/**
* Saves the current list of pinned projects to disk.
*
* @throws SettingsErrors.DiskIOError if the disk write operation fails
* @throws SettingsErrors.RequiredMissingError if the required settings have not yet been set, such as gitlab credentials
*/
suspend fun setPinnedProjects(pinnedProjects: Set<Int>) {
val currentSettings = fetchSettings(fileLocation) ?: throw SettingsErrors.RequiredMissingError()
val newSettings = currentSettings.copy(pinnedProjectIDs = pinnedProjects)
saveSettings(fileLocation, newSettings)
}

/**
* Tries to retrieve gitlab credentials from disk, or in-memory cache if applicable
*
Expand All @@ -111,12 +151,20 @@ class SettingsManager(private val fileLocation: String = System.getProperty("use
* @return Slack credentials from disk or null if user did not sign in with slack
* @throws SettingsErrors.DiskIOError if data exists on disk but couldn't be read
*/
suspend fun getSlackConfig(): SlackConfig? = fetchSettings(fileLocation)?.slackConfig
suspend fun getSlackConfig(): NewestSlackConfig? = fetchSettings(fileLocation)?.slackConfig

/**
* Tries to retrieve whether or not slack integration is enabled.
* @return True if slack integration is enabled
* @throws SettingsErrors.DiskIOError if data exists on disk but couldn't be read
*/
suspend fun getSlackEnabled(): Boolean = fetchSettings(fileLocation)?.slackEnabled ?: false
}

/**
* Tries to retrieve the list of pinned projects.
*
* @return The list of project IDs that are pinned, if any
* @throws SettingsErrors.DiskIOError if data exists on the disk but couldn't be read
*/
suspend fun getPinnedProjects(): Set<Int> = fetchSettings(fileLocation)?.pinnedProjectIDs ?: emptySet()
}
10 changes: 3 additions & 7 deletions src/main/kotlin/io/SettingsMigrator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@ import com.fasterxml.jackson.module.kotlin.readValue
import edu.erittenhouse.gitlabtimetracker.io.error.SettingsErrors
import edu.erittenhouse.gitlabtimetracker.io.result.FileMigrationResult
import edu.erittenhouse.gitlabtimetracker.io.result.MigrationResult
import edu.erittenhouse.gitlabtimetracker.model.settings.VersionedSettings
import edu.erittenhouse.gitlabtimetracker.model.settings.newestMigrationVersion
import edu.erittenhouse.gitlabtimetracker.model.settings.settingsMigrations
import edu.erittenhouse.gitlabtimetracker.model.settings.v1.Settings
import edu.erittenhouse.gitlabtimetracker.model.settings.versionToSettings
import edu.erittenhouse.gitlabtimetracker.model.settings.*
import edu.erittenhouse.gitlabtimetracker.util.JsonMapper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
Expand Down Expand Up @@ -52,7 +48,7 @@ suspend fun migrateSettingsFile(fileLocation: String): FileMigrationResult = wit
*
* @throws SettingsErrors.ParseError if the settings could not be parsed from the file content
*/
fun migrateSettings(fileContent: ByteArray): MigrationResult<Settings> {
fun migrateSettings(fileContent: ByteArray): MigrationResult<NewestSettings> {
val versionedSettings = try {
JsonMapper.readValue<VersionedSettings>(fileContent)
} catch(e: Exception) {
Expand Down Expand Up @@ -83,7 +79,7 @@ fun migrateSettings(fileContent: ByteArray): MigrationResult<Settings> {
}

// Verify we got the correct settings type
val convertedSettings = currentSettings as? Settings
val convertedSettings = currentSettings as? NewestSettings
?: return MigrationResult.MigrationProducedUnexpectedModel(currentSettings.version)
return MigrationResult.MigrationSucceeded(convertedSettings)
}
15 changes: 8 additions & 7 deletions src/main/kotlin/model/Project.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ package edu.erittenhouse.gitlabtimetracker.model
import edu.erittenhouse.gitlabtimetracker.gitlab.dto.GitlabProject
import io.ktor.http.Url

data class Project(val id: Int, val description: String, val name: String, val gitlabPath: String, val url: Url) {
data class Project(val id: Int, val description: String, val name: String, val gitlabPath: String, val url: Url, val pinned: Boolean = false) {
companion object {
fun fromGitlabDto(dto: GitlabProject) = Project(
dto.id,
dto.description ?: "",
dto.name,
dto.pathWithNamespace,
Url(dto.webURL)
fun fromGitlabDto(dto: GitlabProject, isPinned: Boolean = false) = Project(
id = dto.id,
description = dto.description ?: "",
name = dto.name,
gitlabPath = dto.pathWithNamespace,
url = Url(dto.webURL),
pinned = isPinned,
)
}
}
8 changes: 6 additions & 2 deletions src/main/kotlin/model/settings/Default.kt
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package edu.erittenhouse.gitlabtimetracker.model.settings

import edu.erittenhouse.gitlabtimetracker.model.GitlabCredential
import edu.erittenhouse.gitlabtimetracker.model.settings.v1.Settings
import edu.erittenhouse.gitlabtimetracker.model.settings.v2.Settings
import edu.erittenhouse.gitlabtimetracker.model.settings.v2.SlackConfig

typealias NewestSettings = Settings
typealias NewestSlackConfig = SlackConfig
/**
* Constructs settings with default values using only required settings
*/
fun defaultSettings(gitlabCredentials: GitlabCredential): Settings = Settings(
fun defaultSettings(gitlabCredentials: GitlabCredential): NewestSettings = NewestSettings(
gitlabCredentials = gitlabCredentials,
slackConfig = null,
slackEnabled = false,
pinnedProjectIDs = emptySet(),
)
Loading

0 comments on commit 11684b5

Please sign in to comment.