diff --git a/src/main/kotlin/app/termora/Database.kt b/src/main/kotlin/app/termora/Database.kt index e50129c..2395703 100644 --- a/src/main/kotlin/app/termora/Database.kt +++ b/src/main/kotlin/app/termora/Database.kt @@ -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() { diff --git a/src/main/kotlin/app/termora/NewHostTree.kt b/src/main/kotlin/app/termora/NewHostTree.kt index 503daeb..32c99a2 100644 --- a/src/main/kotlin/app/termora/NewHostTree.kt +++ b/src/main/kotlin/app/termora/NewHostTree.kt @@ -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 } }) diff --git a/src/main/kotlin/app/termora/SettingsOptionsPane.kt b/src/main/kotlin/app/termora/SettingsOptionsPane.kt index 9f76988..745470f 100644 --- a/src/main/kotlin/app/termora/SettingsOptionsPane.kt +++ b/src/main/kotlin/app/termora/SettingsOptionsPane.kt @@ -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() val tokenTextField = OutlinePasswordField(255) val gistTextField = OutlineTextField(255) + val policyComboBox = JComboBox() 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 diff --git a/src/main/kotlin/app/termora/SimpleTree.kt b/src/main/kotlin/app/termora/SimpleTree.kt index c3388f4..305a82b 100644 --- a/src/main/kotlin/app/termora/SimpleTree.kt +++ b/src/main/kotlin/app/termora/SimpleTree.kt @@ -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 } }) diff --git a/src/main/kotlin/app/termora/highlight/KeywordHighlightDialog.kt b/src/main/kotlin/app/termora/highlight/KeywordHighlightDialog.kt index f8099cd..81dc8ee 100644 --- a/src/main/kotlin/app/termora/highlight/KeywordHighlightDialog.kt +++ b/src/main/kotlin/app/termora/highlight/KeywordHighlightDialog.kt @@ -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) } } } diff --git a/src/main/kotlin/app/termora/sync/SyncConfig.kt b/src/main/kotlin/app/termora/sync/SyncConfig.kt index b7e8d7a..54a41ae 100644 --- a/src/main/kotlin/app/termora/sync/SyncConfig.kt +++ b/src/main/kotlin/app/termora/sync/SyncConfig.kt @@ -7,6 +7,11 @@ enum class SyncType { WebDAV, } +enum class SyncPolicy { + Manual, + OnChange, +} + enum class SyncRange { Hosts, KeyPairs, diff --git a/src/main/kotlin/app/termora/sync/SyncManager.kt b/src/main/kotlin/app/termora/sync/SyncManager.kt new file mode 100644 index 0000000..1de157f --- /dev/null +++ b/src/main/kotlin/app/termora/sync/SyncManager.kt @@ -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() + 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) + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/sync/SyncerProvider.kt b/src/main/kotlin/app/termora/sync/SyncerProvider.kt deleted file mode 100644 index 8bba071..0000000 --- a/src/main/kotlin/app/termora/sync/SyncerProvider.kt +++ /dev/null @@ -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() - } - } -} \ No newline at end of file diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index de3d0ef..b2e4d60 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -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 diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index 3f5007d..bd0589c 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -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=作者 diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index 68276e9..4236849 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -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=作者