Skip to content

Commit 7815f2f

Browse files
committed
feat: status bar
1 parent 5bbec2d commit 7815f2f

File tree

10 files changed

+838
-850
lines changed

10 files changed

+838
-850
lines changed

jupyterlab_wakatime/handlers.py

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,23 @@
1414
from .wakatime import USER_AGENT, WAKATIME_CLI
1515

1616

17-
class RequestData(TypedDict):
17+
class BeatData(TypedDict):
1818
filepath: str
1919
iswrite: bool
2020
timestamp: float
2121

2222

23-
class RouteHandler(APIHandler):
23+
class BeatHandler(APIHandler):
2424
# The following decorator should be present on all verb methods (head, get, post,
2525
# patch, put, delete, options) to ensure only authorized user can request the
2626
# Jupyter server
2727
@tornado.web.authenticated
2828
async def post(self):
29+
if not os.path.exists(WAKATIME_CLI):
30+
self.log.error("JupyterLab Wakatime plugin failed to find %s", WAKATIME_CLI)
31+
return self.finish(json.dumps({"code": 127}))
2932
try:
30-
data: RequestData = tornado.escape.json_decode(self.request.body)
33+
data: BeatData = tornado.escape.json_decode(self.request.body)
3134
cmd_args: list[str] = ["--plugin", USER_AGENT]
3235

3336
root_dir = os.path.expanduser(self.contents_manager.root_dir)
@@ -38,16 +41,16 @@ async def post(self):
3841
if data["iswrite"]:
3942
cmd_args.append("--write")
4043
except:
41-
self.set_status(400)
42-
return self.finish()
44+
self.log.info("wakatime-cli " + shlex.join(cmd_args))
45+
return self.finish(json.dumps({"code": 400}))
4346
self.log.info("wakatime-cli " + shlex.join(cmd_args))
4447

4548
# Async subprocess is required for non-blocking access to return code
4649
# However, it's not supported on Windows
4750
# As a workaround, create a Popen instance and leave it alone
4851
if platform.system() == "Windows":
4952
subprocess.Popen([WAKATIME_CLI, *cmd_args])
50-
return self.finish()
53+
return self.finish(json.dumps({"code": 0}))
5154

5255
proc = await asyncio.create_subprocess_exec(
5356
WAKATIME_CLI,
@@ -77,17 +80,40 @@ async def post(self):
7780
if log.get("level") != "error":
7881
continue
7982
self.log.error("WakaTime error: %s", log.get("message", line))
80-
self.finish()
83+
return self.finish(json.dumps({"code": proc.returncode}))
8184

8285

83-
def setup_handlers(web_app):
84-
if not os.path.exists(WAKATIME_CLI):
85-
raise RuntimeWarning(
86-
"JupyterLab Wakatime plugin failed to find " + WAKATIME_CLI
87-
)
86+
class StatusHandler(APIHandler):
87+
@tornado.web.authenticated
88+
async def get(self):
89+
if not os.path.exists(WAKATIME_CLI):
90+
self.log.error("JupyterLab Wakatime plugin failed to find %s", WAKATIME_CLI)
91+
return self.finish(json.dumps({"time": ""}))
8892

93+
cmd = [WAKATIME_CLI, "--today", "--output=raw-json"]
94+
if platform.system() == "Windows":
95+
proc = subprocess.run(cmd, stdout=subprocess.PIPE, encoding="utf8")
96+
if proc.returncode:
97+
return self.finish(json.dumps({"time": ""}))
98+
stdout = proc.stdout.strip()
99+
else:
100+
proc = await asyncio.create_subprocess_exec(
101+
*cmd, stdout=asyncio.subprocess.PIPE
102+
)
103+
stdout, _ = await proc.communicate()
104+
if proc.returncode:
105+
return self.finish(json.dumps({"time": ""}))
106+
stdout = stdout.decode().strip()
107+
data = json.loads(stdout).get("data", {})
108+
time = data.get("grand_total", {}).get("digital", "")
109+
self.finish(json.dumps({"time": time}))
110+
111+
112+
def setup_handlers(web_app):
89113
host_pattern = ".*$"
90-
base_url = web_app.settings["base_url"]
91-
route_pattern = url_path_join(base_url, "jupyterlab-wakatime", "heartbeat")
92-
handlers = [(route_pattern, RouteHandler)]
114+
base_url = url_path_join(web_app.settings["base_url"], "jupyterlab-wakatime")
115+
handlers = [
116+
(url_path_join(base_url, "heartbeat"), BeatHandler),
117+
(url_path_join(base_url, "status"), StatusHandler),
118+
]
93119
web_app.add_handlers(host_pattern, handlers)

package.json

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -70,23 +70,23 @@
7070
"@types/react-addons-linked-state-mixin": "^0.14.22",
7171
"@typescript-eslint/eslint-plugin": "^6.1.0",
7272
"@typescript-eslint/parser": "^6.1.0",
73-
"css-loader": "^6.7.1",
74-
"eslint": "^8.36.0",
75-
"eslint-config-prettier": "^8.8.0",
76-
"eslint-plugin-prettier": "^5.0.0",
77-
"mkdirp": "^1.0.3",
73+
"css-loader": "^6.9.1",
74+
"eslint": "^8.56.0",
75+
"eslint-config-prettier": "^9.1.0",
76+
"eslint-plugin-prettier": "^5.1.3",
77+
"mkdirp": "^3.0.1",
7878
"npm-run-all": "^4.1.5",
79-
"prettier": "^3.0.0",
80-
"rimraf": "^5.0.1",
81-
"source-map-loader": "^1.0.2",
82-
"style-loader": "^3.3.1",
83-
"stylelint": "^15.10.1",
84-
"stylelint-config-recommended": "^13.0.0",
85-
"stylelint-config-standard": "^34.0.0",
79+
"prettier": "^3.2.4",
80+
"rimraf": "^5.0.5",
81+
"source-map-loader": "^5.0.0",
82+
"style-loader": "^3.3.4",
83+
"stylelint": "^16.2.0",
84+
"stylelint-config-recommended": "^14.0.0",
85+
"stylelint-config-standard": "^36.0.0",
8686
"stylelint-csstree-validator": "^3.0.0",
87-
"stylelint-prettier": "^4.0.0",
88-
"typescript": "~5.0.2",
89-
"yjs": "^13.5.0"
87+
"stylelint-prettier": "^5.0.0",
88+
"typescript": "^5.3.3",
89+
"yjs": "^13.6.11"
9090
},
9191
"sideEffects": [
9292
"style/*.css",

schema/plugin.json

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
{
22
"jupyter.lab.shortcuts": [],
3-
"title": "jupyterlab-wakatime",
4-
"description": "jupyterlab-wakatime settings.",
3+
"title": "WakaTime",
4+
"description": "WakaTime settings.",
5+
"jupyter.lab.setting-icon": "jupyterlab-wakatime:wakatime",
56
"type": "object",
6-
"properties": {},
7+
"properties": {
8+
"status": {
9+
"type": "boolean",
10+
"title": "Show on status bar",
11+
"description": "Show WakaTime status on the status bar.",
12+
"default": true
13+
}
14+
},
715
"additionalProperties": false
816
}

src/index.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'
22
import { INotebookTracker } from '@jupyterlab/notebook'
33
import { IEditorTracker } from '@jupyterlab/fileeditor'
44
import { ISettingRegistry } from '@jupyterlab/settingregistry'
5+
import { IStatusBar } from '@jupyterlab/statusbar'
56

6-
import { beatHeart } from './watch'
7+
import { createHeart, pollStatus } from './watch'
8+
import { StatusModel, WakaTimeStatus } from './status'
79

810
/**
911
* Initialization data for the jupyterlab-wakatime extension.
@@ -13,14 +15,18 @@ const plugin: JupyterFrontEndPlugin<void> = {
1315
description: 'A JupyterLab WakaTime extension.',
1416
autoStart: true,
1517
requires: [INotebookTracker, IEditorTracker],
16-
optional: [ISettingRegistry],
18+
optional: [ISettingRegistry, IStatusBar],
1719
activate: (
1820
app: JupyterFrontEnd,
1921
notebooks: INotebookTracker,
2022
editors: IEditorTracker,
21-
settingRegistry: ISettingRegistry | null
23+
settingRegistry: ISettingRegistry | null,
24+
statusBar: IStatusBar | null,
2225
) => {
2326
console.log('JupyterLab extension jupyterlab-wakatime is activated!')
27+
const statusModel = new StatusModel()
28+
const beatHeart = createHeart(statusModel)
29+
2430
notebooks.widgetAdded.connect((_, notebook) => {
2531
const filepath = notebook.sessionContext.path
2632
notebook.content.model?.contentChanged.connect(() => {
@@ -57,10 +63,14 @@ const plugin: JupyterFrontEndPlugin<void> = {
5763
settingRegistry
5864
.load(plugin.id)
5965
.then(settings => {
60-
console.log(
61-
'jupyterlab-wakatime settings loaded:',
62-
settings.composite
63-
)
66+
if (settings.get('status').composite && statusBar) {
67+
const wakatimeStatus = new WakaTimeStatus(statusModel)
68+
statusBar.registerStatusItem('wakatime-status', {
69+
item: wakatimeStatus,
70+
align: 'right',
71+
})
72+
pollStatus(statusModel)
73+
}
6474
})
6575
.catch(reason => {
6676
console.error(

src/status.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import React from 'react'
2+
import { VDomModel, VDomRenderer } from '@jupyterlab/ui-components'
3+
import { LabIcon } from '@jupyterlab/ui-components'
4+
import { GroupItem, TextItem } from '@jupyterlab/statusbar'
5+
6+
import wakatimeSVG from '../style/icons/wakatime.svg'
7+
8+
9+
export const wakatimeIcon = new LabIcon({
10+
name: 'jupyterlab-wakatime:wakatime',
11+
svgstr: wakatimeSVG
12+
})
13+
14+
export class StatusModel extends VDomModel {
15+
private _time: string
16+
private _error: number
17+
18+
constructor() {
19+
super()
20+
this._time = 'WakaTime'
21+
this._error = 0
22+
}
23+
24+
get time() {
25+
return this._time
26+
}
27+
28+
get error() {
29+
return this._error
30+
}
31+
32+
get errorMsg() {
33+
switch (this._error) {
34+
// extension-defined error codes
35+
case 0: return "WakaTime is working"
36+
case 127: return "wakatime-cli not found"
37+
case 400: return "Plugin error"
38+
// wakatime-cli error codes
39+
case 112: return "Rate limited"
40+
case 102: return "API or network error"
41+
case 104: return "Invalid API key"
42+
case 103: return "Config file parse error"
43+
case 110: return "Config file read error"
44+
case 111: return "Config file write error"
45+
default: return "Unknown error"
46+
}
47+
}
48+
49+
set time(time: string) {
50+
if (time) {
51+
this._time = time
52+
this.stateChanged.emit()
53+
}
54+
}
55+
56+
set error(error: number) {
57+
this._error = error
58+
this.stateChanged.emit()
59+
}
60+
}
61+
62+
export class WakaTimeStatus extends VDomRenderer<StatusModel> {
63+
constructor(model: StatusModel) {
64+
super(model)
65+
this.addClass("jp-wakatime-status")
66+
}
67+
68+
render() {
69+
return (
70+
<GroupItem
71+
spacing={0}
72+
title={this.model.errorMsg}
73+
data-error={this.model.error || undefined}
74+
>
75+
<wakatimeIcon.react stylesheet={'statusBar'} />
76+
<TextItem source={this.model.time} />
77+
</GroupItem>
78+
)
79+
}
80+
}

src/typings.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
declare module "\*.svg" {
2+
const content: string;
3+
export default content;
4+
}

src/watch.ts

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { requestAPI } from './handler'
2+
import type { StatusModel } from './status'
23

34
// let heartHandle: ReturnType<typeof setTimeout> | null = null
45
let lastBeat = Date.now()
@@ -10,23 +11,38 @@ export type BeatData = {
1011
iswrite: boolean
1112
}
1213

13-
export const beatHeart = (
14-
filepath: string,
15-
type: 'switch' | 'change' | 'write'
16-
) => {
17-
console.log(type, filepath)
18-
const now = Date.now()
19-
if (type === 'change' && now - lastBeat < wakaInterval) {
20-
return
14+
export const createHeart = (statusModel: StatusModel) => {
15+
return async (
16+
filepath: string,
17+
type: 'switch' | 'change' | 'write'
18+
) => {
19+
console.log(type, filepath)
20+
const now = Date.now()
21+
if (type === 'change' && now - lastBeat < wakaInterval) {
22+
return
23+
}
24+
const data: BeatData = {
25+
filepath: filepath,
26+
timestamp: now / 1e3,
27+
iswrite: type === 'write'
28+
}
29+
lastBeat = now
30+
const { code } = await requestAPI<{ code: number }>('heartbeat', {
31+
body: JSON.stringify(data),
32+
method: 'POST'
33+
})
34+
statusModel.error = code
2135
}
22-
const data: BeatData = {
23-
filepath: filepath,
24-
timestamp: now / 1e3,
25-
iswrite: type === 'write'
26-
}
27-
lastBeat = now
28-
requestAPI<any>('heartbeat', {
29-
body: JSON.stringify(data),
30-
method: 'POST'
31-
})
36+
}
37+
38+
const immediateInterval = (callback: () => void, ms: number) => {
39+
callback()
40+
setInterval(callback, ms)
41+
}
42+
43+
export const pollStatus = (model: StatusModel) => {
44+
immediateInterval(async () => {
45+
const { time } = await requestAPI<{ time: string, error: number }>('status')
46+
model.time = time
47+
}, 6e4)
3248
}

style/base.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@
33
44
https://jupyterlab.readthedocs.io/en/stable/developer/css.html
55
*/
6+
7+
.jp-wakatime-status:has([data-error]) {
8+
background-color: var(--jp-warn-color3)
9+
}

style/icons/wakatime.svg

Lines changed: 17 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)