feat: support automatic sync (#455)

This commit is contained in:
hstyi
2025-04-03 15:33:30 +08:00
committed by GitHub
parent 129e1b149a
commit 1d88942e8e
11 changed files with 277 additions and 35 deletions

View File

@@ -6,6 +6,7 @@ import app.termora.keymap.Keymap
import app.termora.keymgr.OhKeyPair import app.termora.keymgr.OhKeyPair
import app.termora.macro.Macro import app.termora.macro.Macro
import app.termora.snippet.Snippet import app.termora.snippet.Snippet
import app.termora.sync.SyncManager
import app.termora.sync.SyncType import app.termora.sync.SyncType
import app.termora.terminal.CursorStyle import app.termora.terminal.CursorStyle
import jetbrains.exodus.bindings.StringBinding import jetbrains.exodus.bindings.StringBinding
@@ -288,6 +289,18 @@ class Database private constructor(private val env: Environment) : Disposable {
val k = StringBinding.stringToEntry(key) val k = StringBinding.stringToEntry(key)
val v = StringBinding.stringToEntry(value) val v = StringBinding.stringToEntry(value)
store.put(tx, k, v) 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) { 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 lastSyncTime by LongPropertyDelegate(0L)
/**
* 同步策略,为空就是默认手动
*/
var policy by StringPropertyDelegate(StringUtils.EMPTY)
} }
override fun dispose() { override fun dispose() {

View File

@@ -121,7 +121,7 @@ class NewHostTree : SimpleTree() {
val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus) 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 return c
} }
}) })

View File

@@ -18,10 +18,7 @@ import app.termora.native.FileChooser
import app.termora.sftp.SFTPTab import app.termora.sftp.SFTPTab
import app.termora.snippet.Snippet import app.termora.snippet.Snippet
import app.termora.snippet.SnippetManager import app.termora.snippet.SnippetManager
import app.termora.sync.SyncConfig import app.termora.sync.*
import app.termora.sync.SyncRange
import app.termora.sync.SyncType
import app.termora.sync.SyncerProvider
import app.termora.terminal.CursorStyle import app.termora.terminal.CursorStyle
import app.termora.terminal.DataKey import app.termora.terminal.DataKey
import app.termora.terminal.panel.FloatingToolbarPanel import app.termora.terminal.panel.FloatingToolbarPanel
@@ -596,6 +593,7 @@ class SettingsOptionsPane : OptionsPane() {
val typeComboBox = FlatComboBox<SyncType>() val typeComboBox = FlatComboBox<SyncType>()
val tokenTextField = OutlinePasswordField(255) val tokenTextField = OutlinePasswordField(255)
val gistTextField = OutlineTextField(255) val gistTextField = OutlineTextField(255)
val policyComboBox = JComboBox<SyncPolicy>()
val domainTextField = OutlineTextField(255) val domainTextField = OutlineTextField(255)
val syncConfigButton = JButton(I18n.getString("termora.settings.sync"), Icons.download) val syncConfigButton = JButton(I18n.getString("termora.settings.sync"), Icons.download)
val exportConfigButton = JButton(I18n.getString("termora.settings.sync.export"), Icons.export) val exportConfigButton = JButton(I18n.getString("termora.settings.sync.export"), Icons.export)
@@ -618,9 +616,22 @@ class SettingsOptionsPane : OptionsPane() {
} }
private fun initEvents() { private fun initEvents() {
syncConfigButton.addActionListener { 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() } swingCoroutineScope.launch(Dispatchers.IO) { sync() }
} }
})
typeComboBox.addItemListener { typeComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) { 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() { tokenTextField.document.addDocumentListener(object : DocumentAdaptor() {
override fun changedUpdate(e: DocumentEvent) { override fun changedUpdate(e: DocumentEvent) {
sync.token = String(tokenTextField.password) sync.token = String(tokenTextField.password)
@@ -659,6 +676,7 @@ class SettingsOptionsPane : OptionsPane() {
} }
}) })
visitGistBtn.addActionListener { visitGistBtn.addActionListener {
if (typeComboBox.selectedItem == SyncType.GitLab) { if (typeComboBox.selectedItem == SyncType.GitLab) {
if (domainTextField.text.isNotBlank()) { if (domainTextField.text.isNotBlank()) {
@@ -706,8 +724,14 @@ class SettingsOptionsPane : OptionsPane() {
} }
private suspend fun sync() { private suspend fun sync() {
// 如果 gist 为空说明要创建一个 gist
if (gistTextField.text.isBlank()) {
if (!pushOrPull(true)) return
} else {
if (!pushOrPull(false)) return if (!pushOrPull(false)) return
if (!pushOrPull(true)) return if (!pushOrPull(true)) return
}
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
if (hostsCheckBox.isSelected) { if (hostsCheckBox.isSelected) {
@@ -1118,7 +1142,7 @@ class SettingsOptionsPane : OptionsPane() {
// sync // sync
val syncResult = kotlin.runCatching { val syncResult = kotlin.runCatching {
val syncer = SyncerProvider.getInstance().getSyncer(syncConfig.type) val syncer = SyncManager.getInstance()
if (push) { if (push) {
syncer.push(syncConfig) syncer.push(syncConfig)
} else { } else {
@@ -1184,6 +1208,9 @@ class SettingsOptionsPane : OptionsPane() {
typeComboBox.addItem(SyncType.Gitee) typeComboBox.addItem(SyncType.Gitee)
typeComboBox.addItem(SyncType.WebDAV) typeComboBox.addItem(SyncType.WebDAV)
policyComboBox.addItem(SyncPolicy.Manual)
policyComboBox.addItem(SyncPolicy.OnChange)
hostsCheckBox.isFocusable = false hostsCheckBox.isFocusable = false
snippetsCheckBox.isFocusable = false snippetsCheckBox.isFocusable = false
keysCheckBox.isFocusable = false keysCheckBox.isFocusable = false
@@ -1198,6 +1225,12 @@ class SettingsOptionsPane : OptionsPane() {
macrosCheckBox.isSelected = sync.rangeMacros macrosCheckBox.isSelected = sync.rangeMacros
keymapCheckBox.isSelected = sync.rangeKeymap 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 typeComboBox.selectedItem = sync.type
gistTextField.text = sync.gist gistTextField.text = sync.gist
tokenTextField.text = sync.token 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 val lastSyncTime = sync.lastSyncTime
lastSyncTimeLabel.text = "${I18n.getString("termora.settings.sync.last-sync-time")}: ${ lastSyncTimeLabel.text = "${I18n.getString("termora.settings.sync.last-sync-time")}: ${
@@ -1255,6 +1305,7 @@ class SettingsOptionsPane : OptionsPane() {
refreshButtons() refreshButtons()
} }
override fun getIcon(isSelected: Boolean): Icon { override fun getIcon(isSelected: Boolean): Icon {
@@ -1272,7 +1323,7 @@ class SettingsOptionsPane : OptionsPane() {
private fun getCenterComponent(): JComponent { private fun getCenterComponent(): JComponent {
val layout = FormLayout( val layout = FormLayout(
"left:pref, $formMargin, default:grow, 30dlu", "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() val rangeBox = FormBuilder.create()
@@ -1322,10 +1373,17 @@ class SettingsOptionsPane : OptionsPane() {
gistTextField.trailingComponent = visitGistBtn gistTextField.trailingComponent = visitGistBtn
} }
val syncPolicyBox = Box.createHorizontalBox()
syncPolicyBox.add(policyComboBox)
syncPolicyBox.add(Box.createHorizontalGlue())
syncPolicyBox.add(Box.createHorizontalGlue())
builder.add("${tokenText}:").xy(1, rows) builder.add("${tokenText}:").xy(1, rows)
.add(if (isWebDAV) gistTextField else tokenTextField).xy(3, rows).apply { rows += step } .add(if (isWebDAV) gistTextField else tokenTextField).xy(3, rows).apply { rows += step }
.add("${gistText}:").xy(1, rows) .add("${gistText}:").xy(1, rows)
.add(if (isWebDAV) tokenTextField else gistTextField).xy(3, rows).apply { rows += step } .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("${I18n.getString("termora.settings.sync.range")}:").xy(1, rows)
.add(rangeBox).xy(3, rows).apply { rows += step } .add(rangeBox).xy(3, rows).apply { rows += step }
// Sync buttons // Sync buttons

View File

@@ -44,7 +44,7 @@ open class SimpleTree : JXTree() {
): Component { ): Component {
val node = value as SimpleTreeNode<*> val node = value as SimpleTreeNode<*>
val c = super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus) 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 return c
} }
}) })

View File

@@ -201,7 +201,7 @@ class KeywordHighlightDialog(owner: Window) : DialogWrapper(owner) {
for (row in rows) { for (row in rows) {
val id = model.getKeywordHighlight(row).id val id = model.getKeywordHighlight(row).id
keywordHighlightManager.removeKeywordHighlight(id) keywordHighlightManager.removeKeywordHighlight(id)
model.removeRow(row) model.fireTableRowsDeleted(row, row)
} }
} }
} }

View File

@@ -7,6 +7,11 @@ enum class SyncType {
WebDAV, WebDAV,
} }
enum class SyncPolicy {
Manual,
OnChange,
}
enum class SyncRange { enum class SyncRange {
Hosts, Hosts,
KeyPairs, KeyPairs,

View 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)
}

View File

@@ -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()
}
}
}

View File

@@ -97,6 +97,9 @@ termora.settings.sync.gist=Gist
termora.settings.sync.token=Token termora.settings.sync.token=Token
termora.settings.sync.type=Type termora.settings.sync.type=Type
termora.settings.sync.webdav.help=WebDAV storage address, e.g. https://yourhost/webdav/termora.json 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=About
termora.settings.about.author=Author termora.settings.about.author=Author

View File

@@ -99,6 +99,9 @@ termora.settings.sync.gist=片段
termora.settings.sync.token=令牌 termora.settings.sync.token=令牌
termora.settings.sync.type=类型 termora.settings.sync.type=类型
termora.settings.sync.webdav.help=WebDAV 的存储地址例如https://yourhost/webdav/termora.json 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=关于
termora.settings.about.author=作者 termora.settings.about.author=作者

View File

@@ -110,6 +110,9 @@ termora.settings.sync.gist=片段
termora.settings.sync.token=令牌 termora.settings.sync.token=令牌
termora.settings.sync.type=類型 termora.settings.sync.type=類型
termora.settings.sync.webdav.help=WebDAV 的儲存位址例如https://yourhost/webdav/termora.json 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=關於
termora.settings.about.author=作者 termora.settings.about.author=作者