在上篇文章的结尾提到本文将参考 VS Code 中 TimerMaster 插件的实现效果来实现一个在 IDEA 中统计编码情况的插件,TimerMaster 的效果如下:
本文最终实现的插件效果如下(增加了 CV 操作和代码提交的统计😎):
由于本文涉及到大部分知识都是在前几篇文章中介绍过的,因此本文只对关键功能点和实现方式进行介绍,一些基础知识和配置就不再讲解,该插件的完整代码已上传到GitHub。
在开发前,正如前言中提到的,是参考 TimerMaster 的实现效果。因此首先确认了要实现的功能如下:
对于第一点,主要通过本地持久化数据进行实现,当天数据和历史数据分别进行存储(选择了 JSON 格式进行存储),然后按需取用进行统计即可。
对于第二点,涉及的功能较多,这里分开进行介绍:
编辑器使用/活跃时间
增加一个对项目生命周期的监听器,在启动事件中注册一个定时任务,每隔 n 秒(可配置)将使用时间增加 n 秒。增加一个对文档和游标位置的监听器,只要有文档操作或者游标的移动都认为是活跃状态,然后更新最新操作时间,在定时任务中会将当前时间和最新操作时间进行比较,如果不大于 m 秒(可配置)就认为编辑器处于活跃状态,并将活跃时间增加 n 秒。
添加/删除的代码行数、总的键入数
通过增加对文档的监听器,根据变更内容中的换行数目得到添加/删除的代码行数,同时根据变更内容长度是否为 1 或者为空白符来决定是否增加键入数。
CV 统计
增加一个对复制粘贴事件的监听器,分别在复制和粘贴事件中增加相应的次数。
代码提交统计
监听自带 GIT 插件提供的 PUSH 事件监听器,在提交完成事件中增加对提交提交次数的统计。
对于第三点,只需要增加一个 action 并绑定到右键菜单,然后通过 ToolWindowFactory 获取到控制台视图来输出计算得到的结果即可。
根据个人需求,统计每天最早/最晚运行时间、活跃时间段、数据云存储等也很容易实现,本文就不进行拓展了。
在实现思路中提到了定时器间隔和代码活跃时间间隔是可配置的,由于界面比较简单,这里使用了 Kotlin UI DSL 的方式(参考第二篇文章使用 Swing UI 的拖拽方式也很容易实现),对应代码及效果如下:
// TimerMasterConfig 中部分代码
private var panel = panel {
row("更新间隔(秒): ") {
intTextField()
.bindIntText(model::updateInterval)
.comment("<icon src='AllIcons.General.Information'> 不设置或者小于 10, 最终都为 10.")
}
row("活跃间隔(秒): ") {
intTextField()
.bindIntText(model::activeInterval)
.comment("<icon src='AllIcons.General.Information'> 不设置或者小于 30, 最终都为 30.")
}
}
根据要存储的数据,定义了一个统计数据类,并增加了一个创建时间便于归档:
data class StatisticsData(
var runTime: Long = 0,
var activeTime: Long = 0,
var keyCount: Long = 0,
var addLineCount: Long = 0,
var removeLineCount: Long = 0,
var copyCount: Long = 0,
var pasteCount: Long = 0,
var pushCount: Long = 0,
var createDate: String = Utils.getTodayYmd()
)
同时为了便于解析和处理,在进行本地持久化的时候,使用了 GSON 库来进行数据的 JSON (反)序列化操作。
在前文中提到这里是通过在项目启动事件中增加定时任务来实现的,先展示代码:
class ProjectStartListener: ProjectActivity, Disposable {
private val state = TimerMasterState.getInstance()
override suspend fun execute(project: Project) {
while (true) {
delay(TimeUnit.SECONDS.toMillis(state.updateInterval.toLong()))
run {
// 避免多个项目运行时间统计多次, 增加满足以下规则才进行时间统计:
// 当前项目与配置信息一致, 或配置信息为空, 或配置信息内的项目不处于打开状态
val projectPath = project.locationHash
val firstOrNull = ProjectManager.getInstance().openProjects.firstOrNull { it.locationHash == state.runProjectPath }
// 判断编辑器是否处于活跃状态
val active = (System.currentTimeMillis() - state.activeTime) / 1000 <= state.activeInterval
state.runProjectPath.takeIf { it == projectPath || it.isBlank() || firstOrNull == null }?.let {
state.runProjectPath = projectPath
val data = Utils.parse(state.statisticsData, TypeToken.get(StatisticsData::class.java))
if (data.createDate == Utils.getTodayYmd()) {
// 计算运行和活跃时间
data.runTime += state.updateInterval
if (active) {
data.activeTime += state.updateInterval
}
state.statisticsData = Utils.stringify(data)
} else {
// 存储的如果不是当日的数据, 则将数据加入到历史数据, 然后再初始化数据
val arr = Utils.parse(state.historyData, object : TypeToken<MutableList<String>>() {})
arr.add(state.statisticsData)
state.historyData = Utils.stringify(arr)
val newData = StatisticsData()
newData.runTime = state.updateInterval.toLong()
if (active) {
newData.activeTime = state.updateInterval.toLong()
}
state.statisticsData = Utils.stringify(newData)
}
}
}
}
}
override fun dispose() {
}
}
增加以下配置:
<extensions defaultExtensionNs="com.intellij">
<postStartupActivity
implementation="cn.butterfly.timermaster.listener.ProjectStartListener"/>
</extensions>
这里需要注意的是我们可能会同时打开多个项目,因此为了避免运行和活跃时间被多次计算,这里通过使用 runProjectPath 来存储当前项目路径的 hash 值,只有该值为空(暂无运行的项目)或者该值不空但打开的项目中没有等于该值的项目(项目打开后又关闭)时才会更新该值为当前运行项目的路径 hash 值。相应地,也只有满足以上条件才会统计运行和活跃时间,同时根据 createDate 字段来决定是在当日数据上进行累积,还是归档该数据然后初始化今日数据。
这里是在编辑器创建事件中,对相应的文档和光标移动增加监听器来统计添加/删除的代码行数、总的键入数,此外还会更新编辑器活跃时间(Utils.initData() 中进行记录),代码如下:
class EditorListener: EditorFactoryListener, BulkAwareDocumentListener, CaretListener {
private val state = TimerMasterState.getInstance()
private val fileSet = mutableSetOf<String>()
override fun editorCreated(event: EditorFactoryEvent) {
// 避免重复增加监听器
val file = FileDocumentManager.getInstance().getFile(event.editor.document) ?: return
if (file.path in fileSet) {
return
}
fileSet.add(file.path)
// 监听编辑操作
event.editor.document.addDocumentListener(this)
// 监听光标移动事件
event.editor.caretModel.addCaretListener(this)
}
override fun documentChangedNonBulk(event: DocumentEvent) {
val data = Utils.initData()
event.takeIf { (it.oldFragment.isNotEmpty() or it.newFragment.isNotEmpty()) or !it.isWholeTextReplaced }?.let {
// 只对字符长度为 1 和非空空白符的情况进行统计
if (it.newFragment.isNotEmpty() && (it.newFragment.length == 1 || it.newFragment.trim().isEmpty())) {
++data.keyCount
}
// 根据文档代码段变更信息判断是新增还是删除行
if (it.oldFragment.contains('\n')) {
data.removeLineCount += it.oldFragment.count { item -> item == '\n' }
}
if (it.newFragment.contains('\n')) {
data.addLineCount += it.newFragment.count { item -> item == '\n' }
}
}
state.statisticsData = Utils.stringify(data)
}
override fun caretPositionChanged(event: CaretEvent) {
state.statisticsData = Utils.stringify(Utils.initData())
}
}
增加以下配置:
<extensions defaultExtensionNs="com.intellij">
<editorFactoryListener implementation="cn.butterfly.timermaster.listener.EditorListener"/>
</extensions>
结合注释,代码不难理解,这里不再介绍。
关于复制粘贴的统计也很简单,官方给我们提供了相应的扩展点,我们增加相应的实现即可:
class CopyPasteListener: CopyPastePreProcessor {
private val state = TimerMasterState.getInstance()
override fun preprocessOnCopy(p0: PsiFile?, p1: IntArray?, p2: IntArray?, p3: String?): String? {
val data = Utils.initData()
++data.copyCount
state.statisticsData = Utils.stringify(data)
return null
}
override fun preprocessOnPaste(p0: Project?, p1: PsiFile?, p2: Editor?, text: String?, p4: RawText?): String {
val data = Utils.initData()
++data.pasteCount
state.statisticsData = Utils.stringify(data)
return text ?: ""
}
}
增加以下配置:
<extensions defaultExtensionNs="com.intellij">
<copyPastePreProcessor
implementation="cn.butterfly.timermaster.listener.CopyPasteListener"/>
</extensions>
而如何知道官方给我们提供了哪些扩展点,在上一篇文章中也说明查看官方文档即可,例如复制粘贴的扩展点:
这里使用的GitPushListener
也是在上述官方文档中提供的:
使用方式也很简单:
class GitListener: GitPushListener {
private val state = TimerMasterState.getInstance()
override fun onCompleted(repository: GitRepository, pushResult: GitPushRepoResult) {
val data = Utils.initData()
++data.pushCount
state.statisticsData = Utils.stringify(data)
}
}
增加以下配置:
<depends>Git4Idea</depends>
<projectListeners>
<listener class="cn.butterfly.timermaster.listener.GitListener"
topic="git4idea.push.GitPushListener"/>
</projectListeners>
build.gradle.kts
中也需要增加以下配置:
intellij {
// 用到的插件
plugins.set(listOf("Git4Idea"))
}
由于GitPushListener
是自带 GIT 插件所提供的监听器,因此还增加了第一行,其中Git4Idea
是 GIT 的插件 id:
有了以上获取和存储数据的基础,这里就很简单了,关于得到展示数据的方式这里就不介绍了,大家可以去查看TimerMasterOutputAction的源码,这里主要介绍如何在控制台进行报告的输出。
其实控制台视图也可以看作是第二篇文章中提到的侧边栏,因此也需要继承实现ToolWindowFactory
:
class ConsoleWindow: ToolWindowFactory {
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
if (Utils.getConsoleViews()[project] == null) {
Utils.createToolWindow(project, toolWindow)
}
Utils.toolWindows[project] = toolWindow
}
}
// Utils 中的方法
fun createToolWindow(project: Project, toolWindow: ToolWindow) {
val consoleView = TextConsoleBuilderFactory.getInstance().createBuilder(project).console
consoleViews[project] = consoleView
val content = toolWindow.contentManager.factory.createContent(consoleView.component, "TimerMaster Output", false)
content.component.isVisible = true
content.isCloseable = true
toolWindow.contentManager.addContent(content)
}
// 在控制台中显示信息
fun consoleInfo(project: Project, msg: String) {
if (consoleViews[project] == null) {
ToolWindowManager.getInstance(project).getToolWindow("TimerMaster Console")
?.let { createToolWindow(project, it) }
}
consoleViews[project]?.clear()
consoleViews[project]?.print(msg, ConsoleViewContentType.NORMAL_OUTPUT)
// 显示控制台窗口, 减去手动点击侧边栏按钮的操作
toolWindows[project]?.activate(null, false)
}
这里需要注意的是由于可能多个项目都打开了该控制台视图,因此需要使用一个 map 继续保存,避免内容错乱。
最后以一个动图结尾:
本文通过实现一个对代码活动统计的插件算是对前几篇文章的一个总结,目前只在本地运行测试了几天,可能有一些不妥的实现方式和 BUG,欢迎一起交流讨论。另外截止到本篇,关于 IntelliJ 平台插件开发的基础知识也算告一段落,后续文章则优先讲解关于虚拟文件、PSI和编辑器操作相关的知识,而关于国际化、主题插件开发等则看情况夹杂在其中进行讲解。