mirror of
https://github.com/TermoraDev/termora.git
synced 2026-01-16 02:12:58 +08:00
feat: support plugin repository
This commit is contained in:
@@ -14,6 +14,7 @@ import org.jdesktop.swingx.JXHyperlink
|
|||||||
import java.awt.BorderLayout
|
import java.awt.BorderLayout
|
||||||
import java.awt.CardLayout
|
import java.awt.CardLayout
|
||||||
import java.awt.event.ActionEvent
|
import java.awt.event.ActionEvent
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import javax.swing.*
|
import javax.swing.*
|
||||||
|
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ class MarketplacePanel : JPanel(BorderLayout()), Disposable {
|
|||||||
private val busyLabel = JXBusyLabel()
|
private val busyLabel = JXBusyLabel()
|
||||||
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private val failedLabel = JLabel()
|
private val failedLabel = JLabel()
|
||||||
|
private val isLoading = AtomicBoolean(false)
|
||||||
|
|
||||||
private val marketplaceManager get() = MarketplaceManager.getInstance()
|
private val marketplaceManager get() = MarketplaceManager.getInstance()
|
||||||
|
|
||||||
@@ -90,39 +92,42 @@ class MarketplacePanel : JPanel(BorderLayout()), Disposable {
|
|||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun reload() {
|
fun reload() {
|
||||||
coroutineScope.launch {
|
if (isLoading.compareAndSet(false, true)) {
|
||||||
withContext(Dispatchers.Swing) {
|
coroutineScope.launch {
|
||||||
cardLayout.show(cardPanel, PanelState.Loading.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取插件
|
|
||||||
try {
|
|
||||||
val loadedPlugins = PluginManager.getInstance().getLoadedPluginDescriptor()
|
|
||||||
val plugins = marketplaceManager.getPlugins()
|
|
||||||
.filterNot { e -> loadedPlugins.any { it.id == e.id && it.version >= e.versions.first().version } }
|
|
||||||
|
|
||||||
withContext(Dispatchers.Swing) {
|
withContext(Dispatchers.Swing) {
|
||||||
if (plugins.isNotEmpty()) {
|
cardLayout.show(cardPanel, PanelState.Loading.name)
|
||||||
pluginsPanel.removeAll()
|
}
|
||||||
for (plugin in plugins) {
|
|
||||||
pluginsPanel.add(createMarketplacePluginPanel(plugin))
|
// 获取插件
|
||||||
pluginsPanel.add(JToolBar.Separator())
|
try {
|
||||||
|
val loadedPlugins = PluginManager.getInstance().getLoadedPluginDescriptor()
|
||||||
|
val plugins = marketplaceManager.getPlugins()
|
||||||
|
.filterNot { e -> loadedPlugins.any { it.id == e.id && it.version >= e.versions.first().version } }
|
||||||
|
|
||||||
|
withContext(Dispatchers.Swing) {
|
||||||
|
if (plugins.isNotEmpty()) {
|
||||||
|
pluginsPanel.removeAll()
|
||||||
|
for (plugin in plugins) {
|
||||||
|
pluginsPanel.add(createMarketplacePluginPanel(plugin))
|
||||||
|
pluginsPanel.add(JToolBar.Separator())
|
||||||
|
}
|
||||||
|
pluginsPanel.add(createRetry())
|
||||||
|
cardLayout.show(cardPanel, PanelState.Plugins.name)
|
||||||
|
} else {
|
||||||
|
failedLabel.text = "No plugins found"
|
||||||
|
cardLayout.show(cardPanel, PanelState.FetchFailed.name)
|
||||||
}
|
}
|
||||||
pluginsPanel.add(createRetry())
|
}
|
||||||
cardLayout.show(cardPanel, PanelState.Plugins.name)
|
} catch (_: Exception) {
|
||||||
} else {
|
withContext(Dispatchers.Swing) {
|
||||||
failedLabel.text = "No plugins found"
|
failedLabel.text = "Failed to fetch the plugins"
|
||||||
cardLayout.show(cardPanel, PanelState.FetchFailed.name)
|
cardLayout.show(cardPanel, PanelState.FetchFailed.name)
|
||||||
}
|
}
|
||||||
}
|
} finally {
|
||||||
} catch (_: Exception) {
|
isLoading.set(true)
|
||||||
withContext(Dispatchers.Swing) {
|
|
||||||
failedLabel.text = "Failed to fetch the plugins"
|
|
||||||
cardLayout.show(cardPanel, PanelState.FetchFailed.name)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import app.termora.plugin.PluginManager
|
|||||||
import app.termora.plugin.PluginOrigin
|
import app.termora.plugin.PluginOrigin
|
||||||
import app.termora.plugin.PluginXmlParser
|
import app.termora.plugin.PluginXmlParser
|
||||||
import app.termora.plugin.internal.extension.DynamicExtensionHandler
|
import app.termora.plugin.internal.extension.DynamicExtensionHandler
|
||||||
|
import app.termora.plugin.marketplace.MarketplaceManager
|
||||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||||
import com.formdev.flatlaf.extras.components.FlatTabbedPane
|
import com.formdev.flatlaf.extras.components.FlatTabbedPane
|
||||||
import com.formdev.flatlaf.extras.components.FlatToolBar
|
import com.formdev.flatlaf.extras.components.FlatToolBar
|
||||||
@@ -95,6 +96,19 @@ class PluginOption : JPanel(BorderLayout()), OptionsPane.Option, Disposable, Acc
|
|||||||
|
|
||||||
private fun showContextMenu() {
|
private fun showContextMenu() {
|
||||||
val popupMenu = FlatPopupMenu()
|
val popupMenu = FlatPopupMenu()
|
||||||
|
|
||||||
|
val managePluginRepositoryMenu =
|
||||||
|
popupMenu.add(I18n.getString("termora.settings.plugin.manage-plugin-repository"))
|
||||||
|
managePluginRepositoryMenu.addActionListener {
|
||||||
|
val dialog = PluginRepositoryDialog(owner)
|
||||||
|
dialog.isVisible = true
|
||||||
|
if (dialog.changed) {
|
||||||
|
MarketplaceManager.getInstance().clear()
|
||||||
|
marketplacePanel.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
popupMenu.addSeparator()
|
||||||
|
|
||||||
val installPluginFromDiskMenu = popupMenu.add(I18n.getString("termora.settings.plugin.install-from-disk"))
|
val installPluginFromDiskMenu = popupMenu.add(I18n.getString("termora.settings.plugin.install-from-disk"))
|
||||||
installPluginFromDiskMenu.addActionListener {
|
installPluginFromDiskMenu.addActionListener {
|
||||||
val chooser = FileChooser()
|
val chooser = FileChooser()
|
||||||
@@ -104,6 +118,7 @@ class PluginOption : JPanel(BorderLayout()), OptionsPane.Option, Disposable, Acc
|
|||||||
chooser.fileSelectionMode = JFileChooser.FILES_ONLY
|
chooser.fileSelectionMode = JFileChooser.FILES_ONLY
|
||||||
chooser.showOpenDialog(owner).thenAccept { if (it.isNotEmpty()) installPluginFromDisk(it.first()) }
|
chooser.showOpenDialog(owner).thenAccept { if (it.isNotEmpty()) installPluginFromDisk(it.first()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
popupMenu.show(settingsButton, 0, settingsButton.height)
|
popupMenu.show(settingsButton, 0, settingsButton.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package app.termora.plugin.internal.plugin
|
||||||
|
|
||||||
|
import app.termora.*
|
||||||
|
import com.formdev.flatlaf.extras.components.FlatToolBar
|
||||||
|
import java.awt.BorderLayout
|
||||||
|
import java.awt.Dimension
|
||||||
|
import java.awt.Window
|
||||||
|
import java.awt.event.ActionEvent
|
||||||
|
import java.net.URI
|
||||||
|
import javax.swing.*
|
||||||
|
|
||||||
|
internal class PluginRepositoryDialog(owner: Window) : DialogWrapper(owner) {
|
||||||
|
|
||||||
|
private val model = DefaultListModel<String>()
|
||||||
|
private val list = JList(model)
|
||||||
|
private val dialog get() = this
|
||||||
|
|
||||||
|
var changed = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150)
|
||||||
|
isModal = true
|
||||||
|
isResizable = false
|
||||||
|
controlsVisible = false
|
||||||
|
title = "Custom Plugin Repository"
|
||||||
|
list.fixedCellHeight = UIManager.getInt("Tree.rowHeight")
|
||||||
|
for (url in PluginRepositoryManager.getInstance().getRepositories()) {
|
||||||
|
model.addElement(url)
|
||||||
|
}
|
||||||
|
setLocationRelativeTo(owner)
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createCenterPanel(): JComponent {
|
||||||
|
val pluginRepositoryManager = PluginRepositoryManager.getInstance()
|
||||||
|
val panel = JPanel(BorderLayout())
|
||||||
|
val toolbar = FlatToolBar().apply { isFloatable = false }
|
||||||
|
val addBtn = JButton(Icons.add)
|
||||||
|
val deleteBtn = JButton(Icons.delete)
|
||||||
|
val urlBtn = JButton(Icons.externalLink)
|
||||||
|
deleteBtn.isEnabled = false
|
||||||
|
|
||||||
|
toolbar.add(addBtn)
|
||||||
|
toolbar.add(deleteBtn)
|
||||||
|
toolbar.add(Box.createHorizontalGlue())
|
||||||
|
toolbar.add(urlBtn)
|
||||||
|
toolbar.border = BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor)
|
||||||
|
|
||||||
|
panel.add(toolbar, BorderLayout.NORTH)
|
||||||
|
panel.add(JScrollPane(list).apply { border = BorderFactory.createEmptyBorder() }, BorderLayout.CENTER)
|
||||||
|
panel.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
|
||||||
|
|
||||||
|
addBtn.addActionListener(object : AbstractAction() {
|
||||||
|
override fun actionPerformed(e: ActionEvent) {
|
||||||
|
val text = OptionPane.showInputDialog(dialog)
|
||||||
|
if (text.isNullOrBlank()) return
|
||||||
|
if ((text.startsWith("http://") || text.startsWith("https://")).not()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pluginRepositoryManager.addRepository(text)
|
||||||
|
model.addElement(text)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
urlBtn.addActionListener(object : AbstractAction() {
|
||||||
|
override fun actionPerformed(e: ActionEvent) {
|
||||||
|
Application.browse(URI.create("https://github.com/TermoraDev/termora-marketplace/releases/latest"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
deleteBtn.addActionListener(object : AbstractAction() {
|
||||||
|
override fun actionPerformed(e: ActionEvent) {
|
||||||
|
if (OptionPane.showConfirmDialog(
|
||||||
|
dialog,
|
||||||
|
I18n.getString("termora.keymgr.delete-warning"),
|
||||||
|
I18n.getString("termora.remove"),
|
||||||
|
JOptionPane.YES_NO_OPTION,
|
||||||
|
JOptionPane.QUESTION_MESSAGE
|
||||||
|
) == JOptionPane.YES_OPTION
|
||||||
|
) {
|
||||||
|
for (i in list.selectedIndices.sortedByDescending { it }) {
|
||||||
|
pluginRepositoryManager.removeRepository(model.getElementAt(i))
|
||||||
|
model.removeElementAt(i)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
list.addListSelectionListener { deleteBtn.isEnabled = list.selectedIndex >= 0 }
|
||||||
|
|
||||||
|
return panel
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createSouthPanel(): JComponent? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package app.termora.plugin.internal.plugin
|
||||||
|
|
||||||
|
import app.termora.Application.ohMyJson
|
||||||
|
import app.termora.ApplicationScope
|
||||||
|
import app.termora.EnableManager
|
||||||
|
import org.apache.commons.lang3.StringUtils
|
||||||
|
|
||||||
|
internal class PluginRepositoryManager private constructor() {
|
||||||
|
companion object {
|
||||||
|
private const val KEY = "PluginRepositories"
|
||||||
|
fun getInstance(): PluginRepositoryManager {
|
||||||
|
return ApplicationScope.forApplicationScope()
|
||||||
|
.getOrCreate(PluginRepositoryManager::class) { PluginRepositoryManager() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val enableManager get() = EnableManager.getInstance()
|
||||||
|
|
||||||
|
fun addRepository(url: String) {
|
||||||
|
synchronized(this) {
|
||||||
|
val repositories = getRepositories().toMutableList()
|
||||||
|
repositories.add(url)
|
||||||
|
enableManager.setFlag(KEY, ohMyJson.encodeToString(repositories))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeRepository(url: String) {
|
||||||
|
synchronized(this) {
|
||||||
|
val repositories = getRepositories().toMutableList()
|
||||||
|
repositories.removeIf { it == url }
|
||||||
|
enableManager.setFlag(KEY, ohMyJson.encodeToString(repositories))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getRepositories(): List<String> {
|
||||||
|
synchronized(this) {
|
||||||
|
val text = enableManager.getFlag(KEY, StringUtils.EMPTY)
|
||||||
|
if (text.isBlank()) return emptyList()
|
||||||
|
return runCatching { ohMyJson.decodeFromString<List<String>>(text) }
|
||||||
|
.getOrNull() ?: emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package app.termora.plugin.marketplace
|
|||||||
|
|
||||||
import app.termora.*
|
import app.termora.*
|
||||||
import app.termora.plugin.PluginDescription
|
import app.termora.plugin.PluginDescription
|
||||||
|
import app.termora.plugin.internal.plugin.PluginRepositoryManager
|
||||||
import app.termora.plugin.internal.plugin.PluginSVGIcon
|
import app.termora.plugin.internal.plugin.PluginSVGIcon
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -18,6 +19,8 @@ import org.slf4j.LoggerFactory
|
|||||||
import org.xml.sax.InputSource
|
import org.xml.sax.InputSource
|
||||||
import java.io.StringReader
|
import java.io.StringReader
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
@@ -88,6 +91,55 @@ internal class MarketplaceManager private constructor() {
|
|||||||
private fun doGetPlugins(): List<MarketplacePlugin> {
|
private fun doGetPlugins(): List<MarketplacePlugin> {
|
||||||
val version = Semver.parse(Application.getVersion())
|
val version = Semver.parse(Application.getVersion())
|
||||||
?: return emptyList()
|
?: return emptyList()
|
||||||
|
|
||||||
|
val repositories = PluginRepositoryManager.getInstance().getRepositories().toMutableSet()
|
||||||
|
repositories.add("https://github.com/TermoraDev/termora-marketplace/releases/latest/download/plugins.xml")
|
||||||
|
|
||||||
|
val plugins = mutableListOf<MarketplacePlugin>()
|
||||||
|
val executorService = Executors.newVirtualThreadPerTaskExecutor()
|
||||||
|
val futures = repositories
|
||||||
|
.map { url -> executorService.submit<List<MarketplacePlugin>> { getPlugins(url, version) } }
|
||||||
|
for (future in futures) {
|
||||||
|
try {
|
||||||
|
plugins.addAll(future.get(1, TimeUnit.MINUTES))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (log.isWarnEnabled) {
|
||||||
|
log.warn(e.message, e)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plugins.isEmpty()) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val matchedPlugins = mutableListOf<MarketplacePlugin>()
|
||||||
|
|
||||||
|
// 获取到合适的插件
|
||||||
|
for (e in plugins) {
|
||||||
|
for (plugin in e.versions) {
|
||||||
|
if (version.satisfies(plugin.since).not()) continue
|
||||||
|
if (plugin.until.isNotBlank())
|
||||||
|
if (version.satisfies(plugin.until).not()) continue
|
||||||
|
matchedPlugins.add(e.copy(versions = mutableListOf(plugin)))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 因为可能有多个插件仓库,所以要去重
|
||||||
|
val map = matchedPlugins.groupBy { it.id }
|
||||||
|
matchedPlugins.clear()
|
||||||
|
for (entry in map) {
|
||||||
|
matchedPlugins.add(entry.value.maxBy { it.versions.first().version })
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchedPlugins.sortedBy { it.name.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun getPlugins(url: String, version: Semver): List<MarketplacePlugin> {
|
||||||
|
|
||||||
val language = I18n.containsLanguage(Locale.getDefault()) ?: "en_US"
|
val language = I18n.containsLanguage(Locale.getDefault()) ?: "en_US"
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.get()
|
.get()
|
||||||
@@ -95,7 +147,7 @@ internal class MarketplaceManager private constructor() {
|
|||||||
.header("X-Language", language)
|
.header("X-Language", language)
|
||||||
.header("X-OS", SystemUtils.OS_NAME)
|
.header("X-OS", SystemUtils.OS_NAME)
|
||||||
.header("X-Arch", SystemUtils.OS_ARCH)
|
.header("X-Arch", SystemUtils.OS_ARCH)
|
||||||
.url("https://github.com/TermoraDev/termora-marketplace/releases/latest/download/plugins.xml")
|
.url(url)
|
||||||
.build()
|
.build()
|
||||||
val response = Application.httpClient.newCall(request).execute()
|
val response = Application.httpClient.newCall(request).execute()
|
||||||
if (response.isSuccessful.not()) {
|
if (response.isSuccessful.not()) {
|
||||||
@@ -180,24 +232,7 @@ internal class MarketplaceManager private constructor() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (plugins.isEmpty()) {
|
return plugins
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
val matchedPlugins = mutableListOf<MarketplacePlugin>()
|
|
||||||
|
|
||||||
// 获取到合适的插件
|
|
||||||
for (e in plugins) {
|
|
||||||
for (plugin in e.versions) {
|
|
||||||
if (version.satisfies(plugin.since).not()) continue
|
|
||||||
if (plugin.until.isNotBlank())
|
|
||||||
if (version.satisfies(plugin.until).not()) continue
|
|
||||||
matchedPlugins.add(e.copy(versions = mutableListOf(plugin)))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchedPlugins.sortedBy { it.name.length }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,8 @@ termora.settings.plugin.cannot-uninstall=System built-in plugins cannot be unins
|
|||||||
termora.settings.plugin.uninstall-confirm=Are you sure you want to uninstall <b>{0}</b> ?
|
termora.settings.plugin.uninstall-confirm=Are you sure you want to uninstall <b>{0}</b> ?
|
||||||
termora.settings.plugin.uninstall-failed=Uninstall failed
|
termora.settings.plugin.uninstall-failed=Uninstall failed
|
||||||
termora.settings.plugin.install-failed=Install failed, please try again later
|
termora.settings.plugin.install-failed=Install failed, please try again later
|
||||||
termora.settings.plugin.install-from-disk=Install plugin from Disk...
|
termora.settings.plugin.install-from-disk=Install Plugin from Disk...
|
||||||
|
termora.settings.plugin.manage-plugin-repository=Manage Plugin Repository...
|
||||||
termora.settings.plugin.install-from-disk-warning=<b>{0}</b> plugin will have access to all your data. Are you sure you want to install it?
|
termora.settings.plugin.install-from-disk-warning=<b>{0}</b> plugin will have access to all your data. Are you sure you want to install it?
|
||||||
|
|
||||||
termora.settings.account=Account
|
termora.settings.account=Account
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ termora.settings.plugin.cannot-uninstall=系统内置插件不可以卸载
|
|||||||
termora.settings.plugin.uninstall-confirm=你确定要卸载 <b>{0}</b> 吗?
|
termora.settings.plugin.uninstall-confirm=你确定要卸载 <b>{0}</b> 吗?
|
||||||
termora.settings.plugin.uninstall-failed=卸载失败
|
termora.settings.plugin.uninstall-failed=卸载失败
|
||||||
termora.settings.plugin.install-from-disk=从磁盘安装插件...
|
termora.settings.plugin.install-from-disk=从磁盘安装插件...
|
||||||
|
termora.settings.plugin.manage-plugin-repository=管理插件仓库...
|
||||||
termora.settings.plugin.install-failed=安装失败,请稍后再试
|
termora.settings.plugin.install-failed=安装失败,请稍后再试
|
||||||
termora.settings.plugin.install-from-disk-warning=<b>{0}</b> 插件可以访问你的所有数据,你确定要安装吗?
|
termora.settings.plugin.install-from-disk-warning=<b>{0}</b> 插件可以访问你的所有数据,你确定要安装吗?
|
||||||
|
|
||||||
|
|||||||
@@ -128,8 +128,9 @@ termora.settings.plugin.cannot-uninstall=系統內建插件無法卸載
|
|||||||
termora.settings.plugin.uninstall-confirm=你確定要卸載 <b>{0}</b> 嗎?
|
termora.settings.plugin.uninstall-confirm=你確定要卸載 <b>{0}</b> 嗎?
|
||||||
termora.settings.plugin.uninstall-failed=解除安裝失敗
|
termora.settings.plugin.uninstall-failed=解除安裝失敗
|
||||||
termora.settings.plugin.install-from-disk=從磁碟安裝插件...
|
termora.settings.plugin.install-from-disk=從磁碟安裝插件...
|
||||||
|
termora.settings.plugin.manage-plugin-repository=管理插件倉庫...
|
||||||
termora.settings.plugin.install-failed=安裝失敗,請稍後再試
|
termora.settings.plugin.install-failed=安裝失敗,請稍後再試
|
||||||
termora.settings.plugin.install-from-disk-warning=<b>{0}</b> 外掛程式可以存取你的所有數據,你確定要安裝嗎?
|
termora.settings.plugin.install-from-disk-warning=<b>{0}</b> 插件可以存取你的所有數據,你確定要安裝嗎?
|
||||||
|
|
||||||
termora.settings.account=帳號
|
termora.settings.account=帳號
|
||||||
termora.settings.account.login=登入
|
termora.settings.account.login=登入
|
||||||
|
|||||||
Reference in New Issue
Block a user