@manthank/mgit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ // Drives a single catalog action: collect inputs, preview the git command
2
+ // (teaching moment), confirm, run it, then show the result.
3
+ import React, { useState, useEffect } from 'react';
4
+ import { Box, Text, useInput } from 'ink';
5
+ import TextInput from 'ink-text-input';
6
+ import Spinner from 'ink-spinner';
7
+ import { git } from '../git.js';
8
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
+ export default function ActionRunner({
10
+ action,
11
+ theme,
12
+ config
13
+ }) {
14
+ const inputs = action.inputs || [];
15
+ const [values, setValues] = useState(() => {
16
+ const init = {};
17
+ for (const inp of inputs) init[inp.name] = inp.default || '';
18
+ return init;
19
+ });
20
+ const [idx, setIdx] = useState(0);
21
+ const [phase, setPhase] = useState(inputs.length ? 'input' : 'preview');
22
+ const [result, setResult] = useState(null);
23
+ const args = phase !== 'input' ? safeBuild(action, values) : [];
24
+ const commandStr = 'git ' + (args || []).map(a => /\s/.test(a) ? `"${a}"` : a).join(' ');
25
+ async function execute() {
26
+ setPhase('running');
27
+ const r = await git(args);
28
+ setResult(r);
29
+ setPhase('result');
30
+ }
31
+
32
+ // Preview/confirm key handling.
33
+ useInput((input, key) => {
34
+ if (phase === 'preview') {
35
+ if (action.danger && config.confirmDanger) {
36
+ if (input.toLowerCase() === 'y') execute();
37
+ } else if (key.return) {
38
+ execute();
39
+ }
40
+ }
41
+ });
42
+ if (phase === 'input') {
43
+ const inp = inputs[idx];
44
+ return /*#__PURE__*/_jsxs(Box, {
45
+ flexDirection: "column",
46
+ children: [config.showHints && /*#__PURE__*/_jsx(Text, {
47
+ color: theme.muted,
48
+ children: action.hint
49
+ }), /*#__PURE__*/_jsx(Box, {
50
+ marginTop: 1,
51
+ flexDirection: "column",
52
+ children: inputs.map((f, i) => /*#__PURE__*/_jsxs(Box, {
53
+ children: [/*#__PURE__*/_jsxs(Text, {
54
+ color: i === idx ? theme.accent : theme.muted,
55
+ bold: i === idx,
56
+ children: [f.label.padEnd(20), ' ']
57
+ }), i === idx ? /*#__PURE__*/_jsx(TextInput, {
58
+ value: values[f.name],
59
+ placeholder: f.placeholder || '',
60
+ onChange: val => setValues(v => ({
61
+ ...v,
62
+ [f.name]: val
63
+ })),
64
+ onSubmit: () => {
65
+ if (!f.optional && !values[f.name].trim()) return;
66
+ if (idx < inputs.length - 1) setIdx(idx + 1);else setPhase('preview');
67
+ }
68
+ }) : /*#__PURE__*/_jsx(Text, {
69
+ color: "white",
70
+ children: values[f.name] || /*#__PURE__*/_jsx(Text, {
71
+ color: theme.muted,
72
+ children: "(blank)"
73
+ })
74
+ })]
75
+ }, f.name))
76
+ }), /*#__PURE__*/_jsx(Box, {
77
+ marginTop: 1,
78
+ children: /*#__PURE__*/_jsx(Text, {
79
+ color: theme.muted,
80
+ children: "Enter \u2192 next \xB7 fields marked optional can be left blank"
81
+ })
82
+ })]
83
+ });
84
+ }
85
+ return /*#__PURE__*/_jsxs(Box, {
86
+ flexDirection: "column",
87
+ children: [config.showHints && /*#__PURE__*/_jsx(Text, {
88
+ color: theme.muted,
89
+ children: action.hint
90
+ }), /*#__PURE__*/_jsxs(Box, {
91
+ marginTop: 1,
92
+ borderStyle: "round",
93
+ borderColor: action.danger ? theme.danger : theme.accent,
94
+ paddingX: 1,
95
+ children: [/*#__PURE__*/_jsx(Text, {
96
+ color: theme.muted,
97
+ children: "$ "
98
+ }), /*#__PURE__*/_jsx(Text, {
99
+ color: action.danger ? theme.danger : theme.secondary,
100
+ bold: true,
101
+ children: commandStr
102
+ })]
103
+ }), phase === 'preview' && /*#__PURE__*/_jsx(Box, {
104
+ marginTop: 1,
105
+ flexDirection: "column",
106
+ children: action.danger && config.confirmDanger ? /*#__PURE__*/_jsx(Text, {
107
+ color: theme.danger,
108
+ bold: true,
109
+ children: "\u26A0 This can change or delete work. Press Y to run, Esc to cancel."
110
+ }) : /*#__PURE__*/_jsx(Text, {
111
+ color: theme.accent,
112
+ children: "Press Enter to run \xB7 Esc to cancel"
113
+ })
114
+ }), phase === 'running' && /*#__PURE__*/_jsx(Box, {
115
+ marginTop: 1,
116
+ children: /*#__PURE__*/_jsxs(Text, {
117
+ color: theme.accent,
118
+ children: [/*#__PURE__*/_jsx(Spinner, {
119
+ type: "dots"
120
+ }), " Running\u2026"]
121
+ })
122
+ }), phase === 'result' && result && /*#__PURE__*/_jsxs(Box, {
123
+ flexDirection: "column",
124
+ marginTop: 1,
125
+ children: [/*#__PURE__*/_jsx(Text, {
126
+ color: result.ok ? theme.success : theme.danger,
127
+ bold: true,
128
+ children: result.ok ? '✓ Success' : `✗ Failed (exit ${result.code})`
129
+ }), /*#__PURE__*/_jsx(Output, {
130
+ text: result.stdout,
131
+ color: "white",
132
+ theme: theme
133
+ }), /*#__PURE__*/_jsx(Output, {
134
+ text: result.stderr,
135
+ color: result.ok ? theme.muted : theme.danger,
136
+ theme: theme
137
+ }), result.ok && !result.stdout && !result.stderr && /*#__PURE__*/_jsx(Text, {
138
+ color: theme.muted,
139
+ children: "(no output \u2014 command completed quietly)"
140
+ }), /*#__PURE__*/_jsx(Box, {
141
+ marginTop: 1,
142
+ children: /*#__PURE__*/_jsx(Text, {
143
+ color: theme.muted,
144
+ children: "Press Esc / \u2190 to go back to the menu."
145
+ })
146
+ })]
147
+ })]
148
+ });
149
+ }
150
+ function Output({
151
+ text,
152
+ color,
153
+ theme
154
+ }) {
155
+ if (!text) return null;
156
+ const lines = text.split('\n');
157
+ const shown = lines.slice(0, 200);
158
+ return /*#__PURE__*/_jsxs(Box, {
159
+ flexDirection: "column",
160
+ children: [shown.map((l, i) => /*#__PURE__*/_jsx(Text, {
161
+ color: color,
162
+ children: l || ' '
163
+ }, i)), lines.length > shown.length && /*#__PURE__*/_jsxs(Text, {
164
+ color: theme.muted,
165
+ children: ["\u2026", lines.length - shown.length, " more lines"]
166
+ })]
167
+ });
168
+ }
169
+ function safeBuild(action, values) {
170
+ try {
171
+ return action.build ? action.build(values) : [];
172
+ } catch {
173
+ return [];
174
+ }
175
+ }
@@ -0,0 +1,84 @@
1
+ // Visual branch map: each branch as a coloured node with its latest commit.
2
+ import React, { useState, useEffect } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import Spinner from 'ink-spinner';
5
+ import { getBranches } from '../git.js';
6
+ import { useFrame } from '../hooks.js';
7
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
+ export default function BranchView({
9
+ theme,
10
+ animations
11
+ }) {
12
+ const [state, setState] = useState({
13
+ loading: true
14
+ });
15
+ const frame = useFrame(animations, 4);
16
+ useEffect(() => {
17
+ let alive = true;
18
+ getBranches().then(s => alive && setState({
19
+ loading: false,
20
+ ...s
21
+ }));
22
+ return () => {
23
+ alive = false;
24
+ };
25
+ }, []);
26
+ if (state.loading) {
27
+ return /*#__PURE__*/_jsxs(Text, {
28
+ color: theme.accent,
29
+ children: [/*#__PURE__*/_jsx(Spinner, {
30
+ type: "dots"
31
+ }), " Reading branches\u2026"]
32
+ });
33
+ }
34
+ if (!state.ok) return /*#__PURE__*/_jsxs(Text, {
35
+ color: theme.danger,
36
+ children: ["Error: ", state.error]
37
+ });
38
+ if (state.branches.length === 0) {
39
+ return /*#__PURE__*/_jsx(Text, {
40
+ color: theme.muted,
41
+ children: "No branches yet \u2014 make your first commit!"
42
+ });
43
+ }
44
+ const colors = theme.branchColors;
45
+ return /*#__PURE__*/_jsxs(Box, {
46
+ flexDirection: "column",
47
+ children: [/*#__PURE__*/_jsxs(Text, {
48
+ color: theme.muted,
49
+ children: [state.branches.length, " local branch", state.branches.length === 1 ? '' : 'es', " \u2014 the \u2605 marks where you are."]
50
+ }), /*#__PURE__*/_jsx(Box, {
51
+ marginTop: 1,
52
+ flexDirection: "column",
53
+ children: state.branches.map((b, i) => {
54
+ const color = colors[i % colors.length];
55
+ const isTop = i === 0;
56
+ const isBottom = i === state.branches.length - 1;
57
+ const connector = isTop ? '┌' : isBottom ? '└' : '├';
58
+ const node = b.current && animations ? frame % 2 ? '◉' : '●' : b.current ? '◉' : '○';
59
+ return /*#__PURE__*/_jsxs(Box, {
60
+ children: [/*#__PURE__*/_jsxs(Text, {
61
+ color: color,
62
+ children: [connector, "\u2500", node, "\u2500 "]
63
+ }), /*#__PURE__*/_jsx(Text, {
64
+ color: b.current ? theme.accent : 'white',
65
+ bold: b.current,
66
+ children: b.name.padEnd(24)
67
+ }), /*#__PURE__*/_jsxs(Text, {
68
+ color: theme.muted,
69
+ children: [" ", b.sha, " "]
70
+ }), /*#__PURE__*/_jsx(Text, {
71
+ color: theme.muted,
72
+ children: truncate(b.subject, 40)
73
+ }), b.current && /*#__PURE__*/_jsx(Text, {
74
+ color: theme.accent,
75
+ children: " \u2605 HEAD"
76
+ })]
77
+ }, b.name);
78
+ })
79
+ })]
80
+ });
81
+ }
82
+ function truncate(s, n) {
83
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
84
+ }
@@ -0,0 +1,60 @@
1
+ // Read-only view of `git config --list`, grouped by section.
2
+ import React, { useState, useEffect } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import Spinner from 'ink-spinner';
5
+ import { getConfigList } from '../git.js';
6
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
+ export default function ConfigView({
8
+ theme
9
+ }) {
10
+ const [state, setState] = useState({
11
+ loading: true
12
+ });
13
+ useEffect(() => {
14
+ let alive = true;
15
+ getConfigList().then(s => alive && setState({
16
+ loading: false,
17
+ ...s
18
+ }));
19
+ return () => {
20
+ alive = false;
21
+ };
22
+ }, []);
23
+ if (state.loading) {
24
+ return /*#__PURE__*/_jsxs(Text, {
25
+ color: theme.accent,
26
+ children: [/*#__PURE__*/_jsx(Spinner, {
27
+ type: "dots"
28
+ }), " Reading git config\u2026"]
29
+ });
30
+ }
31
+ if (!state.ok) return /*#__PURE__*/_jsxs(Text, {
32
+ color: theme.danger,
33
+ children: ["Error: ", state.error]
34
+ });
35
+ return /*#__PURE__*/_jsxs(Box, {
36
+ flexDirection: "column",
37
+ children: [/*#__PURE__*/_jsxs(Text, {
38
+ color: theme.muted,
39
+ children: [state.entries.length, " settings currently active"]
40
+ }), /*#__PURE__*/_jsxs(Box, {
41
+ marginTop: 1,
42
+ flexDirection: "column",
43
+ children: [state.entries.slice(0, 40).map((e, i) => /*#__PURE__*/_jsxs(Text, {
44
+ children: [/*#__PURE__*/_jsx(Text, {
45
+ color: theme.secondary,
46
+ children: e.key
47
+ }), /*#__PURE__*/_jsx(Text, {
48
+ color: theme.muted,
49
+ children: " = "
50
+ }), /*#__PURE__*/_jsx(Text, {
51
+ color: "white",
52
+ children: e.value
53
+ })]
54
+ }, i)), state.entries.length > 40 && /*#__PURE__*/_jsxs(Text, {
55
+ color: theme.muted,
56
+ children: ["\u2026and ", state.entries.length - 40, " more"]
57
+ })]
58
+ })]
59
+ });
60
+ }
@@ -0,0 +1,80 @@
1
+ // Horizontal bar chart of commits per author, with an animated fill sweep.
2
+ import React, { useState, useEffect } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import Spinner from 'ink-spinner';
5
+ import { getShortstat } from '../git.js';
6
+ import { useFrame } from '../hooks.js';
7
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
+ export default function ContribView({
9
+ theme,
10
+ animations
11
+ }) {
12
+ const [state, setState] = useState({
13
+ loading: true
14
+ });
15
+ const frame = useFrame(animations, 20);
16
+ useEffect(() => {
17
+ let alive = true;
18
+ getShortstat().then(s => alive && setState({
19
+ loading: false,
20
+ ...s
21
+ }));
22
+ return () => {
23
+ alive = false;
24
+ };
25
+ }, []);
26
+ if (state.loading) {
27
+ return /*#__PURE__*/_jsxs(Text, {
28
+ color: theme.accent,
29
+ children: [/*#__PURE__*/_jsx(Spinner, {
30
+ type: "dots"
31
+ }), " Counting contributions\u2026"]
32
+ });
33
+ }
34
+ if (!state.ok) return /*#__PURE__*/_jsxs(Text, {
35
+ color: theme.danger,
36
+ children: ["Error: ", state.error]
37
+ });
38
+ if (state.authors.length === 0) {
39
+ return /*#__PURE__*/_jsx(Text, {
40
+ color: theme.muted,
41
+ children: "No commits yet \u2014 nothing to chart."
42
+ });
43
+ }
44
+ const max = Math.max(...state.authors.map(a => a.count));
45
+ const width = 30;
46
+ const colors = theme.branchColors;
47
+ // Animate a "grow-in" the first time, capped at full width.
48
+ const grow = animations ? Math.min(1, frame / 8) : 1;
49
+ return /*#__PURE__*/_jsxs(Box, {
50
+ flexDirection: "column",
51
+ children: [/*#__PURE__*/_jsx(Text, {
52
+ color: theme.muted,
53
+ children: "Commits per author (all branches)"
54
+ }), /*#__PURE__*/_jsx(Box, {
55
+ marginTop: 1,
56
+ flexDirection: "column",
57
+ children: state.authors.slice(0, 12).map((a, i) => {
58
+ const full = Math.round(a.count / max * width * grow);
59
+ const color = colors[i % colors.length];
60
+ const bar = '█'.repeat(full) + '░'.repeat(width - full);
61
+ return /*#__PURE__*/_jsxs(Box, {
62
+ children: [/*#__PURE__*/_jsx(Text, {
63
+ color: "white",
64
+ children: truncate(a.name, 18).padEnd(19)
65
+ }), /*#__PURE__*/_jsx(Text, {
66
+ color: color,
67
+ children: bar
68
+ }), /*#__PURE__*/_jsxs(Text, {
69
+ color: theme.accent,
70
+ bold: true,
71
+ children: [' ', a.count]
72
+ })]
73
+ }, a.name);
74
+ })
75
+ })]
76
+ });
77
+ }
78
+ function truncate(s, n) {
79
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
80
+ }
@@ -0,0 +1,85 @@
1
+ // Writes a sensible starter .gitignore into the current folder (with confirm).
2
+ import React, { useState, useEffect } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { join } from 'node:path';
5
+ import { existsSync, writeFileSync } from 'node:fs';
6
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
+ const TEMPLATE = `# Dependencies
8
+ node_modules/
9
+
10
+ # Build output
11
+ build/
12
+ dist/
13
+
14
+ # Logs
15
+ *.log
16
+
17
+ # Environment / secrets
18
+ .env
19
+ .env.local
20
+
21
+ # Editor
22
+ .vscode/
23
+ .idea/
24
+ .DS_Store
25
+ `;
26
+ export default function GitignoreView({
27
+ theme
28
+ }) {
29
+ const path = join(process.cwd(), '.gitignore');
30
+ const [exists] = useState(() => existsSync(path));
31
+ const [phase, setPhase] = useState('confirm'); // confirm | done | error
32
+ const [error, setError] = useState('');
33
+ function write() {
34
+ try {
35
+ writeFileSync(path, TEMPLATE, 'utf8');
36
+ setPhase('done');
37
+ } catch (e) {
38
+ setError(e.message);
39
+ setPhase('error');
40
+ }
41
+ }
42
+ useInput((input, key) => {
43
+ if (phase !== 'confirm') return;
44
+ if (input.toLowerCase() === 'y' || key.return) write();
45
+ });
46
+ return /*#__PURE__*/_jsxs(Box, {
47
+ flexDirection: "column",
48
+ children: [/*#__PURE__*/_jsx(Text, {
49
+ color: theme.muted,
50
+ children: "This will create a starter .gitignore in:"
51
+ }), /*#__PURE__*/_jsx(Text, {
52
+ color: theme.secondary,
53
+ children: path
54
+ }), /*#__PURE__*/_jsx(Box, {
55
+ marginTop: 1,
56
+ flexDirection: "column",
57
+ borderStyle: "round",
58
+ borderColor: theme.muted,
59
+ paddingX: 1,
60
+ children: TEMPLATE.trimEnd().split('\n').map((l, i) => /*#__PURE__*/_jsx(Text, {
61
+ color: l.startsWith('#') ? theme.muted : 'white',
62
+ children: l
63
+ }, i))
64
+ }), phase === 'confirm' && /*#__PURE__*/_jsx(Box, {
65
+ marginTop: 1,
66
+ children: exists ? /*#__PURE__*/_jsx(Text, {
67
+ color: theme.danger,
68
+ children: "\u26A0 .gitignore already exists \u2014 press Y / Enter to OVERWRITE, Esc to cancel."
69
+ }) : /*#__PURE__*/_jsx(Text, {
70
+ color: theme.accent,
71
+ children: "Press Y / Enter to create it, Esc to cancel."
72
+ })
73
+ }), phase === 'done' && /*#__PURE__*/_jsx(Box, {
74
+ marginTop: 1,
75
+ children: /*#__PURE__*/_jsx(Text, {
76
+ color: theme.success,
77
+ bold: true,
78
+ children: "\u2713 .gitignore written. Press Esc / \u2190 to go back."
79
+ })
80
+ }), phase === 'error' && /*#__PURE__*/_jsxs(Text, {
81
+ color: theme.danger,
82
+ children: ["\u2717 Could not write file: ", error]
83
+ })]
84
+ });
85
+ }
@@ -0,0 +1,116 @@
1
+ // A stylised, colourful commit graph. Renders commits top-to-bottom with a
2
+ // travelling pulse and ref badges (branches/tags) highlighted.
3
+ import React, { useState, useEffect } from 'react';
4
+ import { Box, Text } from 'ink';
5
+ import Spinner from 'ink-spinner';
6
+ import { getLog } from '../git.js';
7
+ import { useFrame } from '../hooks.js';
8
+ import { jsxs as _jsxs, Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
9
+ function RefBadges({
10
+ refs,
11
+ theme
12
+ }) {
13
+ if (!refs) return null;
14
+ const parts = refs.split(',').map(s => s.trim()).filter(Boolean);
15
+ return /*#__PURE__*/_jsx(_Fragment, {
16
+ children: parts.map((r, i) => {
17
+ const isHead = r.includes('HEAD');
18
+ const isTag = r.startsWith('tag:');
19
+ const color = isHead ? theme.accent : isTag ? theme.warn : theme.success;
20
+ const text = r.replace('tag:', '🏷 ').replace('HEAD ->', '➤');
21
+ return /*#__PURE__*/_jsxs(Text, {
22
+ color: color,
23
+ bold: isHead,
24
+ children: [' ', /*#__PURE__*/_jsxs(Text, {
25
+ backgroundColor: undefined,
26
+ children: ["[", text, "]"]
27
+ })]
28
+ }, i);
29
+ })
30
+ });
31
+ }
32
+ export default function LogGraphView({
33
+ theme,
34
+ animations
35
+ }) {
36
+ const [state, setState] = useState({
37
+ loading: true
38
+ });
39
+ const frame = useFrame(animations, 6);
40
+ useEffect(() => {
41
+ let alive = true;
42
+ getLog(20, true).then(s => alive && setState({
43
+ loading: false,
44
+ ...s
45
+ }));
46
+ return () => {
47
+ alive = false;
48
+ };
49
+ }, []);
50
+ if (state.loading) {
51
+ return /*#__PURE__*/_jsxs(Text, {
52
+ color: theme.accent,
53
+ children: [/*#__PURE__*/_jsx(Spinner, {
54
+ type: "dots"
55
+ }), " Building commit graph\u2026"]
56
+ });
57
+ }
58
+ if (!state.ok) return /*#__PURE__*/_jsxs(Text, {
59
+ color: theme.danger,
60
+ children: ["Error: ", state.error]
61
+ });
62
+ if (state.commits.length === 0) {
63
+ return /*#__PURE__*/_jsx(Text, {
64
+ color: theme.muted,
65
+ children: "No commits yet. Make your first commit to see the graph!"
66
+ });
67
+ }
68
+ const colors = theme.branchColors;
69
+ const pulse = state.commits.length ? frame % state.commits.length : 0;
70
+ return /*#__PURE__*/_jsxs(Box, {
71
+ flexDirection: "column",
72
+ children: [/*#__PURE__*/_jsxs(Text, {
73
+ color: theme.muted,
74
+ children: ["Newest at the top \xB7 ", state.commits.length, " commits across all branches"]
75
+ }), /*#__PURE__*/_jsx(Box, {
76
+ marginTop: 1,
77
+ flexDirection: "column",
78
+ children: state.commits.map((c, i) => {
79
+ const color = colors[i % colors.length];
80
+ const active = animations && i === pulse;
81
+ const node = active ? '◉' : '●';
82
+ const line = i === state.commits.length - 1 ? ' ' : '│';
83
+ return /*#__PURE__*/_jsxs(Box, {
84
+ flexDirection: "column",
85
+ children: [/*#__PURE__*/_jsxs(Box, {
86
+ children: [/*#__PURE__*/_jsx(Text, {
87
+ color: color,
88
+ bold: active,
89
+ children: node
90
+ }), /*#__PURE__*/_jsxs(Text, {
91
+ color: theme.muted,
92
+ children: [" ", c.hash, " "]
93
+ }), /*#__PURE__*/_jsx(Text, {
94
+ color: "white",
95
+ children: truncate(c.subject, 48)
96
+ }), /*#__PURE__*/_jsx(RefBadges, {
97
+ refs: c.refs,
98
+ theme: theme
99
+ })]
100
+ }), /*#__PURE__*/_jsxs(Box, {
101
+ children: [/*#__PURE__*/_jsx(Text, {
102
+ color: color,
103
+ children: line
104
+ }), /*#__PURE__*/_jsxs(Text, {
105
+ color: theme.muted,
106
+ children: [" ", c.author, " \xB7 ", c.date]
107
+ })]
108
+ })]
109
+ }, c.hash + i);
110
+ })
111
+ })]
112
+ });
113
+ }
114
+ function truncate(s, n) {
115
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
116
+ }