github-issue-tower-defence-management 1.91.4 → 1.92.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.
- package/CHANGELOG.md +14 -0
- package/README.md +2 -0
- package/bin/adapter/entry-points/cli/index.js +4 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/consoleServer.js +38 -1
- package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-BJixkRxv.css +1 -0
- package/bin/adapter/entry-points/console/ui-dist/assets/{index-D04N09aG.js → index-BSNMvjcB.js} +30 -30
- package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.ts +10 -0
- package/src/adapter/entry-points/console/consoleServer.test.ts +232 -0
- package/src/adapter/entry-points/console/consoleServer.ts +48 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.stories.tsx +4 -4
- package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.test.tsx +24 -9
- package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.tsx +4 -1
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +16 -8
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +0 -2
- package/src/adapter/entry-points/console/ui-dist/assets/index-BJixkRxv.css +1 -0
- package/src/adapter/entry-points/console/ui-dist/assets/{index-D04N09aG.js → index-BSNMvjcB.js} +30 -30
- package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/consoleServer.d.ts +2 -0
- package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-0IuY3q4G.css +0 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleProjectSummary.stories.tsx +0 -29
- package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleProjectSummary.test.tsx +0 -14
- package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleProjectSummary.tsx +0 -16
- package/src/adapter/entry-points/console/ui-dist/assets/index-0IuY3q4G.css +0 -1
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>TDPM Console</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-BSNMvjcB.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BJixkRxv.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -75,8 +75,12 @@ type ServeConsoleOptions = {
|
|
|
75
75
|
configFilePath: string;
|
|
76
76
|
port?: string;
|
|
77
77
|
consoleDataOutputDir?: string;
|
|
78
|
+
inTmuxDataDir?: string;
|
|
78
79
|
};
|
|
79
80
|
|
|
81
|
+
const DEFAULT_IN_TMUX_DATA_DIR =
|
|
82
|
+
'/home/hiromi/0_workspaces/workspace1/jsonpub/in-tmux-by-human';
|
|
83
|
+
|
|
80
84
|
type SelectOauthTokenOptions = {
|
|
81
85
|
tokenListJsonPath?: string;
|
|
82
86
|
cacheDir?: string;
|
|
@@ -591,6 +595,10 @@ program
|
|
|
591
595
|
'--consoleDataOutputDir <path>',
|
|
592
596
|
'Directory where console data files are written and served from',
|
|
593
597
|
)
|
|
598
|
+
.option(
|
|
599
|
+
'--inTmuxDataDir <path>',
|
|
600
|
+
`Directory containing the flat in-tmux-by-human static JSON files served at /in-tmux-by-human/*.json (default: ${DEFAULT_IN_TMUX_DATA_DIR})`,
|
|
601
|
+
)
|
|
594
602
|
.action(async (options: ServeConsoleOptions) => {
|
|
595
603
|
const config = loadConfigFile(options.configFilePath);
|
|
596
604
|
|
|
@@ -692,11 +700,13 @@ program
|
|
|
692
700
|
|
|
693
701
|
const uiDistDir = path.join(__dirname, '..', 'console', 'ui-dist');
|
|
694
702
|
const consoleDataOutputDir = options.consoleDataOutputDir ?? null;
|
|
703
|
+
const inTmuxDataDir = options.inTmuxDataDir ?? DEFAULT_IN_TMUX_DATA_DIR;
|
|
695
704
|
|
|
696
705
|
await startConsoleServer({
|
|
697
706
|
accessToken,
|
|
698
707
|
uiDistDir,
|
|
699
708
|
consoleDataOutputDir,
|
|
709
|
+
inTmuxDataDir,
|
|
700
710
|
issueRepository,
|
|
701
711
|
resolveProject,
|
|
702
712
|
issueTitleStateCache: new IssueTitleStateCache(),
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
isTokenValid,
|
|
12
12
|
isConsoleAppRoute,
|
|
13
13
|
extractProvidedToken,
|
|
14
|
+
resolveFlatInTmuxFilePath,
|
|
14
15
|
startConsoleServer,
|
|
15
16
|
} from './consoleServer';
|
|
16
17
|
import { IssueTitleStateCache } from './consoleReadApi';
|
|
@@ -175,6 +176,7 @@ describe('consoleServer integration', () => {
|
|
|
175
176
|
accessToken: testToken,
|
|
176
177
|
uiDistDir: path.join(tmpDir, 'ui-dist'),
|
|
177
178
|
consoleDataOutputDir: null,
|
|
179
|
+
inTmuxDataDir: null,
|
|
178
180
|
port: 0,
|
|
179
181
|
});
|
|
180
182
|
const address = server.address();
|
|
@@ -190,6 +192,7 @@ describe('consoleServer integration', () => {
|
|
|
190
192
|
accessToken: testToken,
|
|
191
193
|
uiDistDir: path.join(tmpDir, 'missing-ui-dist'),
|
|
192
194
|
consoleDataOutputDir: null,
|
|
195
|
+
inTmuxDataDir: null,
|
|
193
196
|
port: 0,
|
|
194
197
|
});
|
|
195
198
|
try {
|
|
@@ -214,6 +217,7 @@ describe('consoleServer integration', () => {
|
|
|
214
217
|
accessToken: testToken,
|
|
215
218
|
uiDistDir: path.join(tmpDir, 'missing-ui-dist'),
|
|
216
219
|
consoleDataOutputDir: null,
|
|
220
|
+
inTmuxDataDir: null,
|
|
217
221
|
port: 0,
|
|
218
222
|
});
|
|
219
223
|
try {
|
|
@@ -242,6 +246,7 @@ describe('consoleServer integration', () => {
|
|
|
242
246
|
accessToken: testToken,
|
|
243
247
|
uiDistDir,
|
|
244
248
|
consoleDataOutputDir: null,
|
|
249
|
+
inTmuxDataDir: null,
|
|
245
250
|
port: 0,
|
|
246
251
|
});
|
|
247
252
|
try {
|
|
@@ -272,6 +277,7 @@ describe('consoleServer integration', () => {
|
|
|
272
277
|
accessToken: testToken,
|
|
273
278
|
uiDistDir,
|
|
274
279
|
consoleDataOutputDir: null,
|
|
280
|
+
inTmuxDataDir: null,
|
|
275
281
|
port: 0,
|
|
276
282
|
});
|
|
277
283
|
try {
|
|
@@ -299,6 +305,7 @@ describe('consoleServer integration', () => {
|
|
|
299
305
|
accessToken: testToken,
|
|
300
306
|
uiDistDir: path.join(tmpDir, 'missing-ui-dist'),
|
|
301
307
|
consoleDataOutputDir: null,
|
|
308
|
+
inTmuxDataDir: null,
|
|
302
309
|
port: 0,
|
|
303
310
|
});
|
|
304
311
|
try {
|
|
@@ -320,6 +327,7 @@ describe('consoleServer integration', () => {
|
|
|
320
327
|
accessToken: testToken,
|
|
321
328
|
uiDistDir,
|
|
322
329
|
consoleDataOutputDir: null,
|
|
330
|
+
inTmuxDataDir: null,
|
|
323
331
|
port: 0,
|
|
324
332
|
});
|
|
325
333
|
try {
|
|
@@ -344,6 +352,7 @@ describe('consoleServer integration', () => {
|
|
|
344
352
|
accessToken: testToken,
|
|
345
353
|
uiDistDir: path.join(tmpDir, 'ui-dist'),
|
|
346
354
|
consoleDataOutputDir: null,
|
|
355
|
+
inTmuxDataDir: null,
|
|
347
356
|
port: 0,
|
|
348
357
|
});
|
|
349
358
|
try {
|
|
@@ -480,6 +489,7 @@ describe('consoleServer new routes integration', () => {
|
|
|
480
489
|
accessToken: testToken,
|
|
481
490
|
uiDistDir: path.join(tmpDir, 'ui-dist'),
|
|
482
491
|
consoleDataOutputDir: dataDir,
|
|
492
|
+
inTmuxDataDir: null,
|
|
483
493
|
port: 0,
|
|
484
494
|
});
|
|
485
495
|
try {
|
|
@@ -515,6 +525,7 @@ describe('consoleServer new routes integration', () => {
|
|
|
515
525
|
accessToken: testToken,
|
|
516
526
|
uiDistDir: path.join(tmpDir, 'ui-dist'),
|
|
517
527
|
consoleDataOutputDir: null,
|
|
528
|
+
inTmuxDataDir: null,
|
|
518
529
|
issueRepository,
|
|
519
530
|
issueTitleStateCache: new IssueTitleStateCache(),
|
|
520
531
|
port: 0,
|
|
@@ -547,6 +558,7 @@ describe('consoleServer new routes integration', () => {
|
|
|
547
558
|
accessToken: testToken,
|
|
548
559
|
uiDistDir: path.join(tmpDir, 'ui-dist'),
|
|
549
560
|
consoleDataOutputDir: null,
|
|
561
|
+
inTmuxDataDir: null,
|
|
550
562
|
issueRepository,
|
|
551
563
|
resolveProject: async (pjcode) =>
|
|
552
564
|
pjcode === 'umino' ? { pjcode, project: buildProject() } : null,
|
|
@@ -594,6 +606,7 @@ describe('consoleServer new routes integration', () => {
|
|
|
594
606
|
accessToken: testToken,
|
|
595
607
|
uiDistDir: path.join(tmpDir, 'ui-dist'),
|
|
596
608
|
consoleDataOutputDir: dataDir,
|
|
609
|
+
inTmuxDataDir: null,
|
|
597
610
|
issueRepository,
|
|
598
611
|
resolveProject: async (pjcode) =>
|
|
599
612
|
pjcode === 'umino' ? { pjcode, project: buildProject() } : null,
|
|
@@ -631,6 +644,7 @@ describe('consoleServer new routes integration', () => {
|
|
|
631
644
|
accessToken: testToken,
|
|
632
645
|
uiDistDir: path.join(tmpDir, 'ui-dist'),
|
|
633
646
|
consoleDataOutputDir: null,
|
|
647
|
+
inTmuxDataDir: null,
|
|
634
648
|
issueRepository,
|
|
635
649
|
resolveProject: async (pjcode) =>
|
|
636
650
|
pjcode === 'umino' ? { pjcode, project: buildProject() } : null,
|
|
@@ -680,6 +694,7 @@ describe('consoleServer new routes integration', () => {
|
|
|
680
694
|
accessToken: testToken,
|
|
681
695
|
uiDistDir: path.join(tmpDir, 'ui-dist'),
|
|
682
696
|
consoleDataOutputDir: null,
|
|
697
|
+
inTmuxDataDir: null,
|
|
683
698
|
port: 0,
|
|
684
699
|
});
|
|
685
700
|
try {
|
|
@@ -695,3 +710,220 @@ describe('consoleServer new routes integration', () => {
|
|
|
695
710
|
}
|
|
696
711
|
});
|
|
697
712
|
});
|
|
713
|
+
|
|
714
|
+
describe('resolveFlatInTmuxFilePath', () => {
|
|
715
|
+
const baseDir = path.join(os.tmpdir(), 'in-tmux-data');
|
|
716
|
+
|
|
717
|
+
it('resolves a flat .json file under the in-tmux data dir', () => {
|
|
718
|
+
const resolved = resolveFlatInTmuxFilePath(
|
|
719
|
+
baseDir,
|
|
720
|
+
'/in-tmux-by-human/index.v4.json',
|
|
721
|
+
);
|
|
722
|
+
expect(resolved).toBe(path.join(path.resolve(baseDir), 'index.v4.json'));
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it('returns null for paths outside the flat in-tmux prefix', () => {
|
|
726
|
+
expect(resolveFlatInTmuxFilePath(baseDir, '/index.v4.json')).toBeNull();
|
|
727
|
+
expect(
|
|
728
|
+
resolveFlatInTmuxFilePath(baseDir, '/projects/umino/in-tmux-by-human/x'),
|
|
729
|
+
).toBeNull();
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it('returns null for non-json or nested file names', () => {
|
|
733
|
+
expect(
|
|
734
|
+
resolveFlatInTmuxFilePath(baseDir, '/in-tmux-by-human/index.txt'),
|
|
735
|
+
).toBeNull();
|
|
736
|
+
expect(
|
|
737
|
+
resolveFlatInTmuxFilePath(baseDir, '/in-tmux-by-human/sub/index.json'),
|
|
738
|
+
).toBeNull();
|
|
739
|
+
expect(resolveFlatInTmuxFilePath(baseDir, '/in-tmux-by-human/')).toBeNull();
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it('returns null for path traversal attempts', () => {
|
|
743
|
+
expect(
|
|
744
|
+
resolveFlatInTmuxFilePath(baseDir, '/in-tmux-by-human/../secret.json'),
|
|
745
|
+
).toBeNull();
|
|
746
|
+
expect(
|
|
747
|
+
resolveFlatInTmuxFilePath(baseDir, '/in-tmux-by-human/..%2fsecret.json'),
|
|
748
|
+
).toBeNull();
|
|
749
|
+
});
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
describe('consoleServer flat in-tmux-by-human route integration', () => {
|
|
753
|
+
const testToken = 'integration-test-token-value';
|
|
754
|
+
|
|
755
|
+
const requestServer = (
|
|
756
|
+
server: http.Server,
|
|
757
|
+
requestPath: string,
|
|
758
|
+
headers: http.OutgoingHttpHeaders = {},
|
|
759
|
+
): Promise<{
|
|
760
|
+
statusCode: number;
|
|
761
|
+
body: string;
|
|
762
|
+
cacheControl: string | undefined;
|
|
763
|
+
contentType: string | undefined;
|
|
764
|
+
}> => {
|
|
765
|
+
const address = server.address();
|
|
766
|
+
if (address === null || typeof address === 'string') {
|
|
767
|
+
throw new Error('server is not listening on a TCP port');
|
|
768
|
+
}
|
|
769
|
+
const port = address.port;
|
|
770
|
+
return new Promise((resolve, reject) => {
|
|
771
|
+
const httpRequest = http.request(
|
|
772
|
+
{ host: '127.0.0.1', port, path: requestPath, headers },
|
|
773
|
+
(response) => {
|
|
774
|
+
const chunks: Uint8Array[] = [];
|
|
775
|
+
response.on('data', (chunk: Uint8Array) => chunks.push(chunk));
|
|
776
|
+
response.on('end', () => {
|
|
777
|
+
resolve({
|
|
778
|
+
statusCode: response.statusCode ?? 0,
|
|
779
|
+
body: Buffer.concat(chunks).toString('utf-8'),
|
|
780
|
+
cacheControl: response.headers['cache-control'],
|
|
781
|
+
contentType: response.headers['content-type'],
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
},
|
|
785
|
+
);
|
|
786
|
+
httpRequest.on('error', reject);
|
|
787
|
+
httpRequest.end();
|
|
788
|
+
});
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
const closeServer = (server: http.Server): Promise<void> =>
|
|
792
|
+
new Promise((resolve, reject) => {
|
|
793
|
+
server.close((error) => {
|
|
794
|
+
if (error) {
|
|
795
|
+
reject(error);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
resolve();
|
|
799
|
+
});
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
const indexV4Raw =
|
|
803
|
+
'{"version":4,"projects":[{"name":"umino","path":"/in-tmux-by-human/umino.v4.json?k=secret"}]}\n';
|
|
804
|
+
const indexV3Raw = '{"version":3,"projects":["umino"]}\n';
|
|
805
|
+
|
|
806
|
+
const startWithFixture = async (): Promise<{
|
|
807
|
+
server: http.Server;
|
|
808
|
+
tmpDir: string;
|
|
809
|
+
inTmuxDataDir: string;
|
|
810
|
+
}> => {
|
|
811
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
|
|
812
|
+
const inTmuxDataDir = path.join(tmpDir, 'in-tmux-by-human');
|
|
813
|
+
fs.mkdirSync(inTmuxDataDir, { recursive: true });
|
|
814
|
+
fs.writeFileSync(path.join(inTmuxDataDir, 'index.v4.json'), indexV4Raw);
|
|
815
|
+
fs.writeFileSync(path.join(inTmuxDataDir, 'index.v3.json'), indexV3Raw);
|
|
816
|
+
fs.writeFileSync(path.join(tmpDir, 'secret.json'), '{"secret":true}');
|
|
817
|
+
const server = await startConsoleServer({
|
|
818
|
+
accessToken: testToken,
|
|
819
|
+
uiDistDir: path.join(tmpDir, 'ui-dist'),
|
|
820
|
+
consoleDataOutputDir: null,
|
|
821
|
+
inTmuxDataDir,
|
|
822
|
+
port: 0,
|
|
823
|
+
});
|
|
824
|
+
return { server, tmpDir, inTmuxDataDir };
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
it('serves the flat index.v4.json byte-for-byte with a valid token', async () => {
|
|
828
|
+
const { server, tmpDir } = await startWithFixture();
|
|
829
|
+
try {
|
|
830
|
+
const response = await requestServer(
|
|
831
|
+
server,
|
|
832
|
+
`/in-tmux-by-human/index.v4.json?k=${testToken}`,
|
|
833
|
+
);
|
|
834
|
+
expect(response.statusCode).toBe(200);
|
|
835
|
+
expect(response.body).toBe(indexV4Raw);
|
|
836
|
+
expect(response.contentType).toContain('application/json');
|
|
837
|
+
expect(response.cacheControl).toBe('no-store');
|
|
838
|
+
} finally {
|
|
839
|
+
await closeServer(server);
|
|
840
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it('rejects the flat route without a token', async () => {
|
|
845
|
+
const { server, tmpDir } = await startWithFixture();
|
|
846
|
+
try {
|
|
847
|
+
const response = await requestServer(
|
|
848
|
+
server,
|
|
849
|
+
'/in-tmux-by-human/index.v4.json',
|
|
850
|
+
);
|
|
851
|
+
expect(response.statusCode).toBe(401);
|
|
852
|
+
} finally {
|
|
853
|
+
await closeServer(server);
|
|
854
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('serves a v3 file for backward compatibility', async () => {
|
|
859
|
+
const { server, tmpDir } = await startWithFixture();
|
|
860
|
+
try {
|
|
861
|
+
const response = await requestServer(
|
|
862
|
+
server,
|
|
863
|
+
`/in-tmux-by-human/index.v3.json?k=${testToken}`,
|
|
864
|
+
);
|
|
865
|
+
expect(response.statusCode).toBe(200);
|
|
866
|
+
expect(response.body).toBe(indexV3Raw);
|
|
867
|
+
} finally {
|
|
868
|
+
await closeServer(server);
|
|
869
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
it('rejects path traversal attempts with 404 and does not disclose files', async () => {
|
|
874
|
+
const { server, tmpDir } = await startWithFixture();
|
|
875
|
+
try {
|
|
876
|
+
const traversal = await requestServer(
|
|
877
|
+
server,
|
|
878
|
+
`/in-tmux-by-human/../secret.json?k=${testToken}`,
|
|
879
|
+
);
|
|
880
|
+
expect(traversal.statusCode).toBe(404);
|
|
881
|
+
expect(traversal.body).not.toContain('secret');
|
|
882
|
+
|
|
883
|
+
const encodedTraversal = await requestServer(
|
|
884
|
+
server,
|
|
885
|
+
`/in-tmux-by-human/..%2fsecret.json?k=${testToken}`,
|
|
886
|
+
);
|
|
887
|
+
expect(encodedTraversal.statusCode).toBe(404);
|
|
888
|
+
expect(encodedTraversal.body).not.toContain('secret');
|
|
889
|
+
} finally {
|
|
890
|
+
await closeServer(server);
|
|
891
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it('returns 404 for a non-existent flat file', async () => {
|
|
896
|
+
const { server, tmpDir } = await startWithFixture();
|
|
897
|
+
try {
|
|
898
|
+
const response = await requestServer(
|
|
899
|
+
server,
|
|
900
|
+
`/in-tmux-by-human/missing.v4.json?k=${testToken}`,
|
|
901
|
+
);
|
|
902
|
+
expect(response.statusCode).toBe(404);
|
|
903
|
+
} finally {
|
|
904
|
+
await closeServer(server);
|
|
905
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
it('returns 404 for the flat route when inTmuxDataDir is null', async () => {
|
|
910
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
|
|
911
|
+
const server = await startConsoleServer({
|
|
912
|
+
accessToken: testToken,
|
|
913
|
+
uiDistDir: path.join(tmpDir, 'ui-dist'),
|
|
914
|
+
consoleDataOutputDir: null,
|
|
915
|
+
inTmuxDataDir: null,
|
|
916
|
+
port: 0,
|
|
917
|
+
});
|
|
918
|
+
try {
|
|
919
|
+
const response = await requestServer(
|
|
920
|
+
server,
|
|
921
|
+
`/in-tmux-by-human/index.v4.json?k=${testToken}`,
|
|
922
|
+
);
|
|
923
|
+
expect(response.statusCode).toBe(404);
|
|
924
|
+
} finally {
|
|
925
|
+
await closeServer(server);
|
|
926
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
});
|
|
@@ -154,11 +154,36 @@ export type ConsoleServerOptions = {
|
|
|
154
154
|
accessToken: string;
|
|
155
155
|
uiDistDir: string;
|
|
156
156
|
consoleDataOutputDir: string | null;
|
|
157
|
+
inTmuxDataDir: string | null;
|
|
157
158
|
issueRepository?: IssueRepository | null;
|
|
158
159
|
resolveProject?: ConsoleProjectResolver | null;
|
|
159
160
|
issueTitleStateCache?: IssueTitleStateCache | null;
|
|
160
161
|
};
|
|
161
162
|
|
|
163
|
+
const FLAT_IN_TMUX_PREFIX = '/in-tmux-by-human/';
|
|
164
|
+
|
|
165
|
+
const FLAT_IN_TMUX_FILE = /^[A-Za-z0-9._-]+\.json$/;
|
|
166
|
+
|
|
167
|
+
export const resolveFlatInTmuxFilePath = (
|
|
168
|
+
inTmuxDataDir: string,
|
|
169
|
+
requestPath: string,
|
|
170
|
+
): string | null => {
|
|
171
|
+
if (!requestPath.startsWith(FLAT_IN_TMUX_PREFIX)) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const fileName = requestPath.slice(FLAT_IN_TMUX_PREFIX.length);
|
|
175
|
+
if (fileName.length === 0 || !FLAT_IN_TMUX_FILE.test(fileName)) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
const candidate = path.join(inTmuxDataDir, fileName);
|
|
179
|
+
const resolvedRoot = path.resolve(inTmuxDataDir);
|
|
180
|
+
const resolvedCandidate = path.resolve(candidate);
|
|
181
|
+
if (!resolvedCandidate.startsWith(resolvedRoot + path.sep)) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
return resolvedCandidate;
|
|
185
|
+
};
|
|
186
|
+
|
|
162
187
|
const sendNotFound = (response: http.ServerResponse): void => {
|
|
163
188
|
response.writeHead(404, {
|
|
164
189
|
'Content-Type': 'text/plain; charset=utf-8',
|
|
@@ -361,6 +386,29 @@ const handleTokenedRequest = async (
|
|
|
361
386
|
}
|
|
362
387
|
|
|
363
388
|
if (method === 'GET') {
|
|
389
|
+
if (requestPath.startsWith(FLAT_IN_TMUX_PREFIX)) {
|
|
390
|
+
if (options.inTmuxDataDir === null) {
|
|
391
|
+
sendNotFound(response);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const flatFilePath = resolveFlatInTmuxFilePath(
|
|
395
|
+
options.inTmuxDataDir,
|
|
396
|
+
requestPath,
|
|
397
|
+
);
|
|
398
|
+
const flatContent =
|
|
399
|
+
flatFilePath === null ? null : readStaticFile(flatFilePath);
|
|
400
|
+
if (flatContent === null) {
|
|
401
|
+
sendNotFound(response);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
response.writeHead(200, {
|
|
405
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
406
|
+
'Cache-Control': 'no-store',
|
|
407
|
+
});
|
|
408
|
+
response.end(flatContent);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
364
412
|
const dataRoute = parseConsoleDataRoute(requestPath);
|
|
365
413
|
if (dataRoute !== null && options.consoleDataOutputDir !== null) {
|
|
366
414
|
const dataResponse = buildConsoleDataResponse(
|
|
@@ -33,7 +33,7 @@ export const AllTabsWithCounts: Story = {
|
|
|
33
33
|
},
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
export const
|
|
36
|
+
export const ZeroCountTabsHidden: Story = {
|
|
37
37
|
args: {
|
|
38
38
|
activeTab: 'prs',
|
|
39
39
|
counts: {
|
|
@@ -46,15 +46,15 @@ export const ZeroCountTabsStillShown: Story = {
|
|
|
46
46
|
},
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
export const
|
|
49
|
+
export const ZeroCountActiveTabStaysVisible: Story = {
|
|
50
50
|
args: {
|
|
51
51
|
activeTab: 'failed-preparation',
|
|
52
52
|
counts: {
|
|
53
53
|
prs: 35,
|
|
54
|
-
triage:
|
|
54
|
+
triage: 0,
|
|
55
55
|
unread: 18,
|
|
56
56
|
'failed-preparation': 0,
|
|
57
|
-
'todo-by-human':
|
|
57
|
+
'todo-by-human': 0,
|
|
58
58
|
},
|
|
59
59
|
},
|
|
60
60
|
};
|
|
@@ -18,26 +18,41 @@ const baseProps = {
|
|
|
18
18
|
};
|
|
19
19
|
|
|
20
20
|
describe('ConsoleTabList', () => {
|
|
21
|
-
it('
|
|
21
|
+
it('hides zero-count tabs while showing non-zero tabs', () => {
|
|
22
22
|
const { queryByText } = render(
|
|
23
23
|
<ConsoleTabList {...baseProps} activeTab="prs" counts={counts} />,
|
|
24
24
|
);
|
|
25
25
|
expect(queryByText('Awaiting Quality Check')).not.toBeNull();
|
|
26
|
-
expect(queryByText('Triage')).not.toBeNull();
|
|
27
26
|
expect(queryByText('Unread')).not.toBeNull();
|
|
28
|
-
expect(queryByText('Failed Preparation')).not.toBeNull();
|
|
29
27
|
expect(queryByText('Todo by human')).not.toBeNull();
|
|
28
|
+
expect(queryByText('Triage')).toBeNull();
|
|
29
|
+
expect(queryByText('Failed Preparation')).toBeNull();
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
it('
|
|
33
|
-
const { getByText } = render(
|
|
34
|
-
<ConsoleTabList
|
|
32
|
+
it('keeps the active tab visible even when its count is zero', () => {
|
|
33
|
+
const { getByText, queryByText } = render(
|
|
34
|
+
<ConsoleTabList
|
|
35
|
+
{...baseProps}
|
|
36
|
+
activeTab="failed-preparation"
|
|
37
|
+
counts={counts}
|
|
38
|
+
/>,
|
|
35
39
|
);
|
|
36
|
-
const
|
|
40
|
+
const activeBadge = getByText('Failed Preparation')
|
|
37
41
|
.closest('a')
|
|
38
42
|
?.querySelector('.console-tab-badge');
|
|
39
|
-
expect(
|
|
40
|
-
expect(
|
|
43
|
+
expect(activeBadge).toHaveAttribute('data-zero', 'true');
|
|
44
|
+
expect(activeBadge?.textContent).toBe('0');
|
|
45
|
+
expect(getByText('Failed Preparation').closest('a')).toHaveAttribute(
|
|
46
|
+
'aria-current',
|
|
47
|
+
'page',
|
|
48
|
+
);
|
|
49
|
+
expect(queryByText('Triage')).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('marks the active tab as the current page', () => {
|
|
53
|
+
const { getByText } = render(
|
|
54
|
+
<ConsoleTabList {...baseProps} activeTab="prs" counts={counts} />,
|
|
55
|
+
);
|
|
41
56
|
expect(getByText('Awaiting Quality Check').closest('a')).toHaveAttribute(
|
|
42
57
|
'aria-current',
|
|
43
58
|
'page',
|
|
@@ -29,7 +29,10 @@ export const ConsoleTabList = ({
|
|
|
29
29
|
return (
|
|
30
30
|
<>
|
|
31
31
|
<nav aria-label="Console tabs" className="console-tabbar">
|
|
32
|
-
{CONSOLE_TABS.
|
|
32
|
+
{CONSOLE_TABS.filter((tab) => {
|
|
33
|
+
const count = counts[tab.name] ?? 0;
|
|
34
|
+
return count > 0 || tab.name === activeTab;
|
|
35
|
+
}).map((tab) => {
|
|
33
36
|
const count = counts[tab.name] ?? 0;
|
|
34
37
|
const isActive = tab.name === activeTab;
|
|
35
38
|
return (
|
|
@@ -127,19 +127,27 @@ describe('ConsolePage', () => {
|
|
|
127
127
|
).toBeInTheDocument();
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
-
|
|
131
|
-
const prsBadge =
|
|
132
|
-
prsTabLink?.querySelector('.console-tab-badge')?.textContent ?? '0';
|
|
133
|
-
expect(prsBadge).toBe('0');
|
|
130
|
+
expect(queryByText('Awaiting Quality Check')).toBeNull();
|
|
134
131
|
});
|
|
135
132
|
|
|
136
|
-
it('
|
|
133
|
+
it('hides zero-count tabs but keeps non-zero tabs', async () => {
|
|
137
134
|
const { getByText, queryByText } = render(<ConsolePage />);
|
|
138
135
|
await waitFor(() => {
|
|
139
136
|
expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
|
|
140
137
|
});
|
|
141
|
-
expect(queryByText('
|
|
142
|
-
expect(queryByText('
|
|
143
|
-
expect(queryByText('
|
|
138
|
+
expect(queryByText('Awaiting Quality Check')).not.toBeNull();
|
|
139
|
+
expect(queryByText('Unread')).not.toBeNull();
|
|
140
|
+
expect(queryByText('Triage')).toBeNull();
|
|
141
|
+
expect(queryByText('Failed Preparation')).toBeNull();
|
|
142
|
+
expect(queryByText('Todo by human')).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('does not render the project header bar above the tab bar', async () => {
|
|
146
|
+
const { getByText, queryByText } = render(<ConsolePage />);
|
|
147
|
+
await waitFor(() => {
|
|
148
|
+
expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
|
|
149
|
+
});
|
|
150
|
+
expect(queryByText('TDPM Console')).toBeNull();
|
|
151
|
+
expect(queryByText('project: umino')).toBeNull();
|
|
144
152
|
});
|
|
145
153
|
});
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { useMemo } from 'react';
|
|
2
|
-
import { ConsoleProjectSummary } from '../components/layout/ConsoleProjectSummary';
|
|
3
2
|
import { ConsoleTabList } from '../components/layout/ConsoleTabList';
|
|
4
3
|
import { ConsoleItemList } from '../components/list/ConsoleItemList';
|
|
5
4
|
import { useConsoleCaches } from '../hooks/useConsoleCaches';
|
|
@@ -102,7 +101,6 @@ export const ConsolePage = () => {
|
|
|
102
101
|
|
|
103
102
|
return (
|
|
104
103
|
<main className="console-app">
|
|
105
|
-
<ConsoleProjectSummary pjcode={pjcode} />
|
|
106
104
|
<ConsoleTabList
|
|
107
105
|
activeTab={activeTab}
|
|
108
106
|
counts={counts}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-font-weight:initial;--tw-outline-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--font-weight-medium:500;--font-weight-semibold:600;--radius-md:.375rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-input:#e5e5e5;--color-ring:#0a0a0a;--color-background:#fff;--color-foreground:#0a0a0a;--color-primary:#171717;--color-primary-foreground:#fafafa;--color-secondary:#f5f5f5;--color-secondary-foreground:#171717;--color-accent:#f5f5f5;--color-accent-foreground:#171717}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.relative{position:relative}.static{position:static}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.inline-flex{display:inline-flex}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.items-center{align-items:center}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-input{border-color:var(--color-input)}.border-transparent{border-color:#0000}.bg-background{background-color:var(--color-background)}.bg-primary{background-color:var(--color-primary)}.bg-secondary{background-color:var(--color-secondary)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-2{padding-block:calc(var(--spacing) * 2)}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.whitespace-nowrap{white-space:nowrap}.text-foreground{color:var(--color-foreground)}.text-primary-foreground{color:var(--color-primary-foreground)}.text-secondary-foreground{color:var(--color-secondary-foreground)}.lowercase{text-transform:lowercase}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media(hover:hover){.hover\:bg-accent:hover{background-color:var(--color-accent)}.hover\:bg-primary\/90:hover{background-color:#171717e6}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--color-primary) 90%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:#f5f5f5cc}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--color-secondary) 80%,transparent)}}.hover\:text-accent-foreground:hover{color:var(--color-accent-foreground)}}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color:var(--color-ring)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-50:disabled{opacity:.5}}body{color:#e6edf3;background-color:#0d1117;margin:0;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif}.console-app{flex-direction:column;max-width:920px;margin:0 auto;display:flex}.console-tabbar{background:#161b22;border-bottom:2px solid #30363d;align-items:stretch;gap:0;min-height:42px;padding:0 8px;display:flex}.console-tab{color:#8b949e;white-space:nowrap;cursor:pointer;background:0 0;border:none;border-bottom:2px solid #0000;align-items:center;gap:6px;margin-bottom:-2px;padding:10px 14px;font-size:13px;font-weight:500;line-height:1.2;text-decoration:none;display:inline-flex}.console-tab:hover{color:#e6edf3}.console-tab[data-active=true]{color:#e6edf3;border-bottom-color:#2f81f7;font-weight:700}.console-tab-badge{text-align:center;color:#e6edf3;background:#484f58;border-radius:20px;min-width:20px;padding:1px 7px;font-size:11px;font-weight:700;line-height:1.5}.console-tab-badge[data-zero=true]{color:#8b949e;background:#30363d}.console-tab-pjname{color:#8b949e;align-self:center;margin-left:auto;padding:0 8px;font-size:11.5px}.console-tab-geninfo{color:#8b949e;align-self:center;padding:0 4px;font-size:11px}.console-tab-count-heading{color:#8b949e;margin:0;padding:12px 18px 0;font-size:13px}.console-list{margin:0;padding:12px 18px 18px;list-style:none}.console-list-group{list-style:none}.console-item-row .console-item-icon{flex:none;margin-top:3px}.console-group-header{background:#0b0f14;border-bottom:1px solid #21262d;justify-content:space-between;align-items:center;padding:6px 12px;display:flex}.console-storytag{align-items:center;gap:8px;font-size:13px;font-weight:700;display:inline-flex}.console-story-dot{border-radius:999px;width:10px;height:10px;display:inline-block}.console-group-count{color:#8b949e;font-size:12px}.console-item-row{color:#e6edf3;text-align:left;cursor:pointer;background:#161b22;border:1px solid #30363d;border-radius:8px;align-items:flex-start;gap:14px;width:100%;margin-bottom:10px;padding:12px 16px;display:flex}.console-item-row:hover{background:#1a2029;border-color:#484f58}.console-item-row[data-active=true]{background:#1a2029;border-color:#4493f8}.console-item-meta{flex:1;min-width:0}.console-item-title{font-size:14.5px;font-weight:600;display:block}.console-item-sub{color:#8b949e;margin-top:3px;font-size:12.5px;display:block}.console-item-pill{color:#8b949e;border:1px solid #30363d;border-radius:20px;margin-right:6px;padding:1px 8px;font-size:11px;display:inline-block}.console-item-createdat{color:#8b949e;cursor:help}.console-list-message{color:#8b949e;padding:16px;font-size:14px}.console-list-empty{text-align:center;color:#8b949e;padding:40px;font-size:14px}.console-list-error,.console-comment-error,.console-files-error,.console-commits-error,.console-detail-body-error{color:#f85149}.console-detail{flex-direction:column;gap:12px;padding:16px;display:flex}.console-detail-title{align-items:center;gap:8px;margin:0;font-size:20px;display:flex}.console-detail-title-text{flex:1}.console-detail-number{color:#8b949e;font-weight:400}.console-detail-closed-label{color:#a371f7;font-size:13px}.console-detail-subbar{align-items:center;gap:12px;font-size:13px;display:flex}.console-detail-link{color:#4493f8}.console-detail-repo{color:#8b949e;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.console-detail-pill,.console-label-chip,.console-detail-status-chip{border:1px solid #30363d;border-radius:999px;padding:2px 8px;font-size:12px;display:inline-block}.console-detail-labels{flex-wrap:wrap;gap:6px;display:flex}.console-detail-createdat{color:#8b949e;font-size:12px}.console-panel{border:1px solid #30363d;border-radius:8px;overflow:hidden}.console-panel-header{background:#161b22;justify-content:space-between;align-items:center;padding:6px 12px;display:flex}.console-panel-toggle{color:#e6edf3;cursor:pointer;background:0 0;border:none;align-items:center;gap:8px;font-size:14px;font-weight:600;display:inline-flex}.console-panel-body{padding:12px}.console-markdown{word-break:break-word;font-size:14px;line-height:1.5}.console-mermaid-error{color:#f85149;font-size:13px}.console-comment{border-top:1px solid #21262d;padding-top:8px}.console-comment-header{color:#8b949e;gap:8px;font-size:12px;display:flex}.console-comment-author{color:#e6edf3;font-weight:600}.console-files,.console-commits{margin:0;padding:0;list-style:none}.console-file,.console-commit{align-items:center;gap:8px;padding:4px 0;font-size:13px;display:flex}.console-file-badge{text-align:center;border:1px solid;border-radius:4px;width:18px;font-size:11px}.console-file-path,.console-commit-message{flex:1;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.console-file-add,.console-pr-add{color:#3fb950}.console-file-del,.console-pr-del{color:#f85149}.console-commit-sha{color:#8b949e;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.console-actionbar{z-index:100;background:#161b22;border-top:2px solid #30363d;padding:10px 16px;position:fixed;bottom:0;left:0;right:0}.console-operation-bar{flex-direction:column;gap:8px;max-width:920px;margin:0 auto;display:flex}.console-op-group{flex-wrap:wrap;gap:8px;display:flex}.console-op-group-review{justify-content:flex-end;align-items:center;gap:10px}.console-op-button{color:#e6edf3;white-space:nowrap;cursor:pointer;background:#21262d;border:1px solid #30363d;border-radius:6px;padding:7px 16px;font-size:13px;font-weight:600}.console-op-button:hover{border-color:#484f58}.console-op-button-approve{color:#fff;background:#238636;border-color:#2ea043}.console-op-button-reject{color:#ffd166;background:#7d5000;border-color:#a06800}.console-op-button-wrong{color:#f85149;background:#3a1518;border-color:#f85149}.console-op-button-unneeded{color:#aab0b8;background:#2a2d31;border-color:#6e7681}.console-op-button-snooze{color:#79c0ff;background:#1c2b4a;border-color:#4493f8}.console-pr-section{border:1px solid #30363d;border-radius:8px;flex-direction:column;gap:10px;padding:12px;display:flex}.console-pr-statbar{color:#8b949e;gap:12px;font-size:12px;display:flex}.console-detail-screen{padding-bottom:140px}.console-panel-open-link{color:#4493f8;font-size:13px;font-weight:400}.console-composer{margin-top:4px}.console-composer-toggle{color:#8b949e;cursor:pointer;background:0 0;border:none;padding:2px 0;font-size:12.5px}.console-composer-toggle:hover{color:#e6edf3}.console-composer-posted{flex-direction:column;gap:8px;margin-top:8px;display:flex}.console-composer-form{margin-top:8px}.console-composer-input{box-sizing:border-box;color:#e6edf3;width:100%;font:inherit;resize:vertical;background:#21262d;border:1px solid #30363d;border-radius:6px;padding:8px;font-size:14px}.console-composer-row{align-items:center;gap:8px;margin-top:6px;display:flex}.console-composer-submit{color:#fff;cursor:pointer;background:#238636;border:1px solid #2ea043;border-radius:6px;padding:7px 16px;font-size:13px;font-weight:600}.console-composer-submit:disabled{opacity:.6;cursor:default}.console-composer-status{color:#8b949e;font-size:12px}.console-composer-error{color:#f85149}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}
|