mirror of
https://github.com/TermoraDev/termora.git
synced 2026-01-15 18:02:58 +08:00
feat: support automatic sync (#455)
This commit is contained in:
@@ -6,6 +6,7 @@ import app.termora.keymap.Keymap
|
||||
import app.termora.keymgr.OhKeyPair
|
||||
import app.termora.macro.Macro
|
||||
import app.termora.snippet.Snippet
|
||||
import app.termora.sync.SyncManager
|
||||
import app.termora.sync.SyncType
|
||||
import app.termora.terminal.CursorStyle
|
||||
import jetbrains.exodus.bindings.StringBinding
|
||||
@@ -288,6 +289,18 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
val k = StringBinding.stringToEntry(key)
|
||||
val v = StringBinding.stringToEntry(value)
|
||||
store.put(tx, k, v)
|
||||
|
||||
// 数据变动时触发一次同步
|
||||
if (name == HOST_STORE ||
|
||||
name == KEYMAP_STORE ||
|
||||
name == SNIPPET_STORE ||
|
||||
name == KEYWORD_HIGHLIGHT_STORE ||
|
||||
name == MACRO_STORE ||
|
||||
name == KEY_PAIR_STORE ||
|
||||
name == DELETED_DATA_STORE
|
||||
) {
|
||||
SyncManager.getInstance().triggerOnChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private fun delete(tx: Transaction, name: String, key: String) {
|
||||
@@ -717,6 +730,11 @@ class Database private constructor(private val env: Environment) : Disposable {
|
||||
* 最后同步时间
|
||||
*/
|
||||
var lastSyncTime by LongPropertyDelegate(0L)
|
||||
|
||||
/**
|
||||
* 同步策略,为空就是默认手动
|
||||
*/
|
||||
var policy by StringPropertyDelegate(StringUtils.EMPTY)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
|
||||
@@ -121,7 +121,7 @@ class NewHostTree : SimpleTree() {
|
||||
|
||||
val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus)
|
||||
|
||||
icon = node.getIcon(sel, expanded, hasFocus)
|
||||
icon = node.getIcon(sel, expanded, tree.hasFocus())
|
||||
return c
|
||||
}
|
||||
})
|
||||
|
||||
@@ -18,10 +18,7 @@ import app.termora.native.FileChooser
|
||||
import app.termora.sftp.SFTPTab
|
||||
import app.termora.snippet.Snippet
|
||||
import app.termora.snippet.SnippetManager
|
||||
import app.termora.sync.SyncConfig
|
||||
import app.termora.sync.SyncRange
|
||||
import app.termora.sync.SyncType
|
||||
import app.termora.sync.SyncerProvider
|
||||
import app.termora.sync.*
|
||||
import app.termora.terminal.CursorStyle
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.terminal.panel.FloatingToolbarPanel
|
||||
@@ -596,6 +593,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
val typeComboBox = FlatComboBox<SyncType>()
|
||||
val tokenTextField = OutlinePasswordField(255)
|
||||
val gistTextField = OutlineTextField(255)
|
||||
val policyComboBox = JComboBox<SyncPolicy>()
|
||||
val domainTextField = OutlineTextField(255)
|
||||
val syncConfigButton = JButton(I18n.getString("termora.settings.sync"), Icons.download)
|
||||
val exportConfigButton = JButton(I18n.getString("termora.settings.sync.export"), Icons.export)
|
||||
@@ -618,9 +616,22 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
syncConfigButton.addActionListener {
|
||||
swingCoroutineScope.launch(Dispatchers.IO) { sync() }
|
||||
}
|
||||
syncConfigButton.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
if (typeComboBox.selectedItem == SyncType.WebDAV) {
|
||||
if (tokenTextField.password.isEmpty()) {
|
||||
tokenTextField.outline = FlatClientProperties.OUTLINE_ERROR
|
||||
tokenTextField.requestFocusInWindow()
|
||||
return
|
||||
} else if (gistTextField.text.isEmpty()) {
|
||||
gistTextField.outline = FlatClientProperties.OUTLINE_ERROR
|
||||
gistTextField.requestFocusInWindow()
|
||||
return
|
||||
}
|
||||
}
|
||||
swingCoroutineScope.launch(Dispatchers.IO) { sync() }
|
||||
}
|
||||
})
|
||||
|
||||
typeComboBox.addItemListener {
|
||||
if (it.stateChange == ItemEvent.SELECTED) {
|
||||
@@ -639,6 +650,12 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
policyComboBox.addItemListener {
|
||||
if (it.stateChange == ItemEvent.SELECTED) {
|
||||
sync.policy = (policyComboBox.selectedItem as SyncPolicy).name
|
||||
}
|
||||
}
|
||||
|
||||
tokenTextField.document.addDocumentListener(object : DocumentAdaptor() {
|
||||
override fun changedUpdate(e: DocumentEvent) {
|
||||
sync.token = String(tokenTextField.password)
|
||||
@@ -659,6 +676,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
visitGistBtn.addActionListener {
|
||||
if (typeComboBox.selectedItem == SyncType.GitLab) {
|
||||
if (domainTextField.text.isNotBlank()) {
|
||||
@@ -706,8 +724,14 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
|
||||
private suspend fun sync() {
|
||||
if (!pushOrPull(false)) return
|
||||
if (!pushOrPull(true)) return
|
||||
|
||||
// 如果 gist 为空说明要创建一个 gist
|
||||
if (gistTextField.text.isBlank()) {
|
||||
if (!pushOrPull(true)) return
|
||||
} else {
|
||||
if (!pushOrPull(false)) return
|
||||
if (!pushOrPull(true)) return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
if (hostsCheckBox.isSelected) {
|
||||
@@ -1118,7 +1142,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
// sync
|
||||
val syncResult = kotlin.runCatching {
|
||||
val syncer = SyncerProvider.getInstance().getSyncer(syncConfig.type)
|
||||
val syncer = SyncManager.getInstance()
|
||||
if (push) {
|
||||
syncer.push(syncConfig)
|
||||
} else {
|
||||
@@ -1184,6 +1208,9 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
typeComboBox.addItem(SyncType.Gitee)
|
||||
typeComboBox.addItem(SyncType.WebDAV)
|
||||
|
||||
policyComboBox.addItem(SyncPolicy.Manual)
|
||||
policyComboBox.addItem(SyncPolicy.OnChange)
|
||||
|
||||
hostsCheckBox.isFocusable = false
|
||||
snippetsCheckBox.isFocusable = false
|
||||
keysCheckBox.isFocusable = false
|
||||
@@ -1198,6 +1225,12 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
macrosCheckBox.isSelected = sync.rangeMacros
|
||||
keymapCheckBox.isSelected = sync.rangeKeymap
|
||||
|
||||
if (sync.policy == SyncPolicy.Manual.name) {
|
||||
policyComboBox.selectedItem = SyncPolicy.Manual
|
||||
} else if (sync.policy == SyncPolicy.OnChange.name) {
|
||||
policyComboBox.selectedItem = SyncPolicy.OnChange
|
||||
}
|
||||
|
||||
typeComboBox.selectedItem = sync.type
|
||||
gistTextField.text = sync.gist
|
||||
tokenTextField.text = sync.token
|
||||
@@ -1245,6 +1278,23 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
}
|
||||
}
|
||||
|
||||
policyComboBox.renderer = object : DefaultListCellRenderer() {
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
value: Any?,
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
cellHasFocus: Boolean
|
||||
): Component {
|
||||
var text = value?.toString() ?: StringUtils.EMPTY
|
||||
if (value == SyncPolicy.Manual) {
|
||||
text = I18n.getString("termora.settings.sync.policy.manual")
|
||||
} else if (value == SyncPolicy.OnChange) {
|
||||
text = I18n.getString("termora.settings.sync.policy.on-change")
|
||||
}
|
||||
return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus)
|
||||
}
|
||||
}
|
||||
|
||||
val lastSyncTime = sync.lastSyncTime
|
||||
lastSyncTimeLabel.text = "${I18n.getString("termora.settings.sync.last-sync-time")}: ${
|
||||
@@ -1255,6 +1305,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
|
||||
refreshButtons()
|
||||
|
||||
|
||||
}
|
||||
|
||||
override fun getIcon(isSelected: Boolean): Icon {
|
||||
@@ -1272,7 +1323,7 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
private fun getCenterComponent(): JComponent {
|
||||
val layout = FormLayout(
|
||||
"left:pref, $formMargin, default:grow, 30dlu",
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
val rangeBox = FormBuilder.create()
|
||||
@@ -1322,10 +1373,17 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
gistTextField.trailingComponent = visitGistBtn
|
||||
}
|
||||
|
||||
val syncPolicyBox = Box.createHorizontalBox()
|
||||
syncPolicyBox.add(policyComboBox)
|
||||
syncPolicyBox.add(Box.createHorizontalGlue())
|
||||
syncPolicyBox.add(Box.createHorizontalGlue())
|
||||
|
||||
builder.add("${tokenText}:").xy(1, rows)
|
||||
.add(if (isWebDAV) gistTextField else tokenTextField).xy(3, rows).apply { rows += step }
|
||||
.add("${gistText}:").xy(1, rows)
|
||||
.add(if (isWebDAV) tokenTextField else gistTextField).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.sync.policy")}:").xy(1, rows)
|
||||
.add(syncPolicyBox).xy(3, rows).apply { rows += step }
|
||||
.add("${I18n.getString("termora.settings.sync.range")}:").xy(1, rows)
|
||||
.add(rangeBox).xy(3, rows).apply { rows += step }
|
||||
// Sync buttons
|
||||
|
||||
@@ -44,7 +44,7 @@ open class SimpleTree : JXTree() {
|
||||
): Component {
|
||||
val node = value as SimpleTreeNode<*>
|
||||
val c = super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus)
|
||||
icon = node.getIcon(sel, expanded, hasFocus)
|
||||
icon = node.getIcon(sel, expanded, tree.hasFocus())
|
||||
return c
|
||||
}
|
||||
})
|
||||
|
||||
@@ -201,7 +201,7 @@ class KeywordHighlightDialog(owner: Window) : DialogWrapper(owner) {
|
||||
for (row in rows) {
|
||||
val id = model.getKeywordHighlight(row).id
|
||||
keywordHighlightManager.removeKeywordHighlight(id)
|
||||
model.removeRow(row)
|
||||
model.fireTableRowsDeleted(row, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ enum class SyncType {
|
||||
WebDAV,
|
||||
}
|
||||
|
||||
enum class SyncPolicy {
|
||||
Manual,
|
||||
OnChange,
|
||||
}
|
||||
|
||||
enum class SyncRange {
|
||||
Hosts,
|
||||
KeyPairs,
|
||||
|
||||
173
src/main/kotlin/app/termora/sync/SyncManager.kt
Normal file
173
src/main/kotlin/app/termora/sync/SyncManager.kt
Normal file
@@ -0,0 +1,173 @@
|
||||
package app.termora.sync
|
||||
|
||||
import app.termora.ApplicationScope
|
||||
import app.termora.Database
|
||||
import app.termora.Disposable
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Suppress("DuplicatedCode")
|
||||
class SyncManager private constructor() : Disposable {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(SyncManager::class.java)
|
||||
|
||||
fun getInstance(): SyncManager {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(SyncManager::class) { SyncManager() }
|
||||
}
|
||||
}
|
||||
|
||||
private val sync get() = Database.getDatabase().sync
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var job: Job? = null
|
||||
private var disableTrigger = false
|
||||
|
||||
|
||||
private fun trigger() {
|
||||
trigger(getSyncConfig())
|
||||
}
|
||||
|
||||
fun triggerOnChanged() {
|
||||
if (sync.policy == SyncPolicy.OnChange.name) {
|
||||
trigger()
|
||||
}
|
||||
}
|
||||
|
||||
private fun trigger(config: SyncConfig) {
|
||||
if (disableTrigger) return
|
||||
|
||||
job?.cancel()
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("Automatic synchronisation is interrupted")
|
||||
}
|
||||
|
||||
job = coroutineScope.launch {
|
||||
|
||||
// 因为会频繁调用,等待 10 - 30 秒
|
||||
val seconds = Random.nextInt(10, 30)
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("Trigger synchronisation, which will take place after {} seconds", seconds)
|
||||
}
|
||||
|
||||
delay(seconds.seconds)
|
||||
|
||||
|
||||
if (!disableTrigger) {
|
||||
try {
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("Automatic synchronisation begin")
|
||||
}
|
||||
|
||||
// 如果已经开始,设置为 null
|
||||
// 因为同步的时候会修改数据,避免被中断
|
||||
job = null
|
||||
|
||||
sync(config)
|
||||
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("Automatic synchronisation end")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sync(config: SyncConfig): SyncResponse {
|
||||
return syncImmediately(config)
|
||||
}
|
||||
|
||||
|
||||
private fun getSyncConfig(): SyncConfig {
|
||||
val range = mutableSetOf<SyncRange>()
|
||||
if (sync.rangeHosts) {
|
||||
range.add(SyncRange.Hosts)
|
||||
}
|
||||
if (sync.rangeKeyPairs) {
|
||||
range.add(SyncRange.KeyPairs)
|
||||
}
|
||||
if (sync.rangeKeywordHighlights) {
|
||||
range.add(SyncRange.KeywordHighlights)
|
||||
}
|
||||
if (sync.rangeMacros) {
|
||||
range.add(SyncRange.Macros)
|
||||
}
|
||||
if (sync.rangeKeymap) {
|
||||
range.add(SyncRange.Keymap)
|
||||
}
|
||||
if (sync.rangeSnippets) {
|
||||
range.add(SyncRange.Snippets)
|
||||
}
|
||||
return SyncConfig(
|
||||
type = sync.type,
|
||||
token = sync.token,
|
||||
gistId = sync.gist,
|
||||
options = mapOf("domain" to sync.domain),
|
||||
ranges = range
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private fun syncImmediately(config: SyncConfig): SyncResponse {
|
||||
synchronized(this) {
|
||||
return SyncResponse(pull(config), push(config))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun pull(config: SyncConfig): GistResponse {
|
||||
synchronized(this) {
|
||||
disableTrigger = true
|
||||
try {
|
||||
return SyncerProvider.getInstance().getSyncer(config.type).pull(config)
|
||||
} finally {
|
||||
disableTrigger = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun push(config: SyncConfig): GistResponse {
|
||||
synchronized(this) {
|
||||
try {
|
||||
disableTrigger = true
|
||||
return SyncerProvider.getInstance().getSyncer(config.type).push(config)
|
||||
} finally {
|
||||
disableTrigger = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun dispose() {
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
|
||||
|
||||
private class SyncerProvider private constructor() {
|
||||
companion object {
|
||||
fun getInstance(): SyncerProvider {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(SyncerProvider::class) { SyncerProvider() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun getSyncer(type: SyncType): Syncer {
|
||||
return when (type) {
|
||||
SyncType.GitHub -> GitHubSyncer.getInstance()
|
||||
SyncType.Gitee -> GiteeSyncer.getInstance()
|
||||
SyncType.GitLab -> GitLabSyncer.getInstance()
|
||||
SyncType.WebDAV -> WebDAVSyncer.getInstance()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SyncResponse(val pull: GistResponse, val push: GistResponse)
|
||||
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package app.termora.sync
|
||||
|
||||
import app.termora.ApplicationScope
|
||||
|
||||
class SyncerProvider private constructor() {
|
||||
companion object {
|
||||
fun getInstance(): SyncerProvider {
|
||||
return ApplicationScope.forApplicationScope().getOrCreate(SyncerProvider::class) { SyncerProvider() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun getSyncer(type: SyncType): Syncer {
|
||||
return when (type) {
|
||||
SyncType.GitHub -> GitHubSyncer.getInstance()
|
||||
SyncType.Gitee -> GiteeSyncer.getInstance()
|
||||
SyncType.GitLab -> GitLabSyncer.getInstance()
|
||||
SyncType.WebDAV -> WebDAVSyncer.getInstance()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,9 @@ termora.settings.sync.gist=Gist
|
||||
termora.settings.sync.token=Token
|
||||
termora.settings.sync.type=Type
|
||||
termora.settings.sync.webdav.help=WebDAV storage address, e.g. https://yourhost/webdav/termora.json
|
||||
termora.settings.sync.policy=Sync Policy
|
||||
termora.settings.sync.policy.manual=Manual
|
||||
termora.settings.sync.policy.on-change=On Change
|
||||
|
||||
termora.settings.about=About
|
||||
termora.settings.about.author=Author
|
||||
|
||||
@@ -99,6 +99,9 @@ termora.settings.sync.gist=片段
|
||||
termora.settings.sync.token=令牌
|
||||
termora.settings.sync.type=类型
|
||||
termora.settings.sync.webdav.help=WebDAV 的存储地址,例如:https://yourhost/webdav/termora.json
|
||||
termora.settings.sync.policy=同步策略
|
||||
termora.settings.sync.policy.manual=手动
|
||||
termora.settings.sync.policy.on-change=数据变动时
|
||||
|
||||
termora.settings.about=关于
|
||||
termora.settings.about.author=作者
|
||||
|
||||
@@ -110,6 +110,9 @@ termora.settings.sync.gist=片段
|
||||
termora.settings.sync.token=令牌
|
||||
termora.settings.sync.type=類型
|
||||
termora.settings.sync.webdav.help=WebDAV 的儲存位址,例如:https://yourhost/webdav/termora.json
|
||||
termora.settings.sync.policy=同步策略
|
||||
termora.settings.sync.policy.manual=手動
|
||||
termora.settings.sync.policy.on-change=資料變動時
|
||||
|
||||
termora.settings.about=關於
|
||||
termora.settings.about.author=作者
|
||||
|
||||
Reference in New Issue
Block a user