feat: multi-session terminal frontend UI + tests (PR 3)

- Add TerminalSessionTabs component with status dots, rename, close, max-5 limit
- Add 7 component tests for tab rendering, selection, close, rename
- TerminalComponent: sessionId prop, forwardRef with fit() method
- TerminalPage: multi-session orchestration, tab switching, auto-create default
- Fullscreen mode: Alt+Shift+F toggle, auto-hide tabs, Esc exit
- Keyboard shortcuts: Alt+Shift+N/W/ArrowLeft/ArrowRight/R
- Add CSS for tabs, fullscreen, mobile responsive
- Update useTerminalSessions hook for session CRUD
- terminal_manager.py: lookup by internal session_id fallback

Quality gates: tsc --noEmit clean, vitest (7/7 new tests passed), pytest (182 passed)
This commit is contained in:
2026-05-28 13:35:45 +02:00
parent 0b35ae3bf0
commit 62d1bdc462
26 changed files with 4589 additions and 775 deletions
+568
View File
@@ -0,0 +1,568 @@
{
"version": "v2",
"timestamp": 1779889907001,
"ruleHash": "fd9b2b15f2ac8993",
"queries": [
{
"id": "bare-except",
"name": "Bare Except Clause",
"severity": "warning",
"language": "python",
"message": "Bare 'except:' clause — catches SystemExit, KeyboardInterrupt",
"query": " (except_clause\n \"except\") @CLAUSE",
"metavars": [
"CLAUSE"
],
"post_filter": "bare_except_only",
"defect_class": "silent-error",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/bare-except.yml"
},
{
"id": "eval-exec",
"name": "Eval/Exec Usage",
"severity": "warning",
"language": "python",
"message": "{{FUNC}}() detected — security risk, code injection vulnerability",
"query": " (call\n function: (identifier) @FUNC\n (#match? @FUNC \"^(eval|exec)$\")\n arguments: (argument_list) @ARGS)",
"metavars": [
"FUNC",
"ARGS"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/eval-exec.yml"
},
{
"id": "exit-signature-check",
"name": "__exit__ Missing Parameters",
"severity": "error",
"language": "python",
"message": "__exit__ should accept type, value, and traceback arguments",
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__exit__\")\n parameters: (parameters\n (_) @SELF\n . (_) @PARAM1?\n . (_) @PARAM2?\n . (_) @PARAM3?))",
"metavars": [
"NAME",
"SELF",
"PARAM1",
"PARAM2",
"PARAM3"
],
"post_filter": "exit_params_insufficient",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/exit-signature-check.yml"
},
{
"id": "in-operator-unsupported",
"name": "In and Not In Operators Should Be Used on Valid Objects",
"severity": "warning",
"language": "python",
"message": "'in' operator used on object that may not support containment",
"query": " (comparison_operator\n (identifier) @OBJ\n \"in\"\n (identifier) @TARGET)\n (comparison_operator\n (identifier) @OBJ\n \"not\"\n \"in\"\n (identifier) @TARGET)",
"metavars": [
"OBJ",
"TARGET"
],
"post_filter": "check_in_operator_types",
"defect_class": "correctness",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/in-operator-unsupported.yml"
},
{
"id": "is-vs-equals",
"name": "Is vs Equals for Literals",
"severity": "warning",
"language": "python",
"message": "Using 'is' with literal — use '==' for value comparison",
"query": " (comparison_operator\n (identifier)\n (\"is\")\n (string) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is not\")\n (string) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is\")\n (integer) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is not\")\n (integer) @LITERAL)",
"metavars": [
"LITERAL"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/is-vs-equals.yml"
},
{
"id": "iter-return-iterator",
"name": "__iter__ Should Return Iterator",
"severity": "warning",
"language": "python",
"message": "__iter__ should return an iterator (object with __next__ method)",
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__iter__\")\n body: (block\n (return_statement) @RETURN))",
"metavars": [
"NAME",
"RETURN"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/iter-return-iterator.yml"
},
{
"id": "mutable-default-arg",
"name": "Mutable Default Argument",
"severity": "warning",
"language": "python",
"message": "Mutable default argument — list/dict/set as default value",
"query": " (function_definition\n (parameters\n (default_parameter\n (identifier) @PARAM\n [(list) (dictionary) (set)] @MUTABLE)))",
"metavars": [
"PARAM",
"MUTABLE"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/mutable-default-arg.yml"
},
{
"id": "no-super-torchscript",
"name": "super Should Not Be Used in TorchScript Methods",
"severity": "error",
"language": "python",
"message": "super() calls should not be used in TorchScript methods",
"query": " (function_definition\n (decorator\n (call\n function: (identifier) @DEC (#match? @DEC \"^(torch\\.jit\\.script|jit\\.script)$\")))\n body: (block\n (call\n function: (identifier) @FUNC (#eq? @FUNC \"super\")) @CALL))",
"metavars": [
"DEC",
"FUNC",
"CALL"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/no-super-torchscript.yml"
},
{
"id": "notimplemented-boolean-context",
"name": "NotImplemented in Boolean Context",
"severity": "error",
"language": "python",
"message": "NotImplemented should not be used in boolean contexts",
"query": " (if_statement\n condition: (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (while_statement\n condition: (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (binary_operator\n (identifier) @COND (#eq? @COND \"NotImplemented\")\n (\"and\" | \"or\"))\n (boolean_operator\n (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (unary_operator\n operator: (\"not\")\n argument: (identifier) @COND (#eq? @COND \"NotImplemented\"))",
"metavars": [
"COND"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/notimplemented-boolean-context.yml"
},
{
"id": "python-assert-production",
"name": "Assert in Production Code",
"severity": "warning",
"language": "python",
"message": "assert statement stripped by Python -O flag — use explicit checks with exceptions in production code",
"query": " (assert_statement) @ASSERT",
"metavars": [
"ASSERT"
],
"defect_class": "correctness",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-assert-production.yml"
},
{
"id": "python-command-injection",
"name": "Command Injection Sink",
"severity": "error",
"language": "python",
"message": "Potential command injection sink — avoid shell execution with dynamic input",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"os\")\n (#match? @FN \"^(system|popen)$\"))\n\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n (keyword_argument\n name: (identifier) @KW\n value: (true)))\n (#eq? @MOD \"subprocess\")\n (#match? @FN \"^(run|Popen|call|check_output|check_call)$\")\n (#eq? @KW \"shell\"))",
"metavars": [
"MOD",
"FN",
"ARGS",
"KW"
],
"post_filter": "py_command_injection_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-command-injection.yml"
},
{
"id": "python-cross-language-method",
"name": "Cross-Language Method Leakage",
"severity": "warning",
"language": "python",
"message": "'{METHOD}' is not a Python method — likely a {LANG} idiom leaking in",
"query": " (call\n function: (attribute\n object: (_) @OBJ\n attribute: (identifier) @METHOD)\n (#match? @METHOD \"^(push|forEach|indexOf|charAt|substring|hasOwnProperty|unshift|flatMap|padStart|padEnd|trimStart|trimEnd|equals|isEmpty|println|printf|getClass|hashCode|toCharArray|getBytes|compareTo|equalsIgnoreCase|startsWith|endsWith|each|collect|select|reject|detect|inject|chomp|chop|gsub|upcase|downcase|present|blank|Add|Contains|ToLower|ToUpper|Trim|Substring|WriteLine|ReadLine|TryParse|forEach|includes|assign|freeze|splice|unshift|shift|flatMap)$\"))",
"metavars": [
"OBJ",
"METHOD"
],
"post_filter": "match_captures",
"post_filter_params": {
"METHOD": "^(push|forEach|indexOf|charAt|substring|hasOwnProperty|unshift|flatMap|padStart|padEnd|trimStart|trimEnd|equals|isEmpty|println|printf|getClass|hashCode|toCharArray|getBytes|compareTo|equalsIgnoreCase|startsWith|endsWith|each|collect|select|reject|detect|inject|chomp|chop|gsub|upcase|downcase|present|blank|Add|Contains|ToLower|ToUpper|Trim|Substring|WriteLine|ReadLine|TryParse|forEach|includes|assign|freeze|splice|unshift|shift|flatMap)$"
},
"defect_class": "hallucination",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-cross-language-method.yml"
},
{
"id": "python-debugger",
"name": "Debugger Statement",
"severity": "warning",
"language": "python",
"message": "Debugger call '{{FUNC}}' — remove before committing",
"query": " (call\n function: (identifier) @FUNC\n (#eq? @FUNC \"breakpoint\"))\n\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FUNC)\n (#eq? @MOD \"pdb\")\n (#match? @FUNC \"^(set_trace|post_mortem|pm|run|runcall)$\"))",
"metavars": [
"FUNC",
"MOD"
],
"defect_class": "safety",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-debugger.yml"
},
{
"id": "python-empty-except",
"name": "Empty Except Block",
"severity": "warning",
"language": "python",
"message": "Except block only contains 'pass' — handle or re-raise the exception",
"query": " (try_statement\n (except_clause\n body: (block) @BODY))",
"metavars": [
"BODY"
],
"post_filter": "python_empty_except",
"defect_class": "silent-error",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-empty-except.yml"
},
{
"id": "python-hallucinated-import",
"name": "Hallucinated Import",
"severity": "warning",
"language": "python",
"message": "Hallucinated import — '{NAME}' does not exist in '{MODULE}'",
"query": " (import_from_statement\n module_name: (dotted_name) @MODULE\n name: (dotted_name) @NAME)",
"metavars": [
"MODULE",
"NAME"
],
"post_filter": "match_captures",
"post_filter_params": {
"MODULE": "^(requests|flask|django|typing|collections|asyncio|json|unittest|pytest|urllib|sqlalchemy)$",
"NAME": "^(JSONResponse|HTMLResponse|RedirectResponse|StreamingResponse|Depends|Query|Path|Body|Header|Cookie|Form|File|UploadFile|FastAPI|APIRouter|HTTPException|BackgroundTasks|dataclass|fields|BaseModel|Field|validator|aiohttp|parse|stringify|fixture|TestCase|get|post|put|delete|Model|Session|Column|Integer|String)$"
},
"defect_class": "hallucination",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hallucinated-import.yml"
},
{
"id": "python-hardcoded-secrets",
"name": "Hardcoded Secret",
"severity": "warning",
"language": "python",
"message": "Hardcoded {{VARNAME}} — use environment variables or a secrets manager",
"query": " (assignment\n left: (identifier) @VARNAME\n right: (string) @VALUE)",
"metavars": [
"VARNAME",
"VALUE"
],
"post_filter": "check_secret_pattern",
"defect_class": "secrets",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hardcoded-secrets.yml"
},
{
"id": "python-insecure-deserialization",
"name": "Insecure Deserialization",
"severity": "error",
"language": "python",
"message": "Potential insecure deserialization sink — avoid unsafe loaders",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list (_) @DATA)\n (#match? @MOD \"^(pickle|yaml)$\")\n (#match? @FN \"^(load|loads|unsafe_load)$\"))",
"metavars": [
"MOD",
"FN",
"DATA"
],
"post_filter": "py_insecure_deserialization_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-insecure-deserialization.yml"
},
{
"id": "python-insecure-random",
"name": "Insecure Randomness",
"severity": "warning",
"language": "python",
"message": "Insecure randomness source detected — use secrets or os.urandom for security-sensitive values",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"random\")\n (#match? @FN \"^(random|randint|randrange|choice|choices)$\"))",
"metavars": [
"MOD",
"FN",
"ARGS"
],
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-insecure-random.yml"
},
{
"id": "python-mutable-class-attr",
"name": "Mutable Class Attribute",
"severity": "warning",
"language": "python",
"message": "Class attribute '{{VARNAME}}' is mutable — shared across all instances",
"query": " (class_definition\n body: (block\n (expression_statement\n (assignment\n left: (identifier) @VARNAME\n right: [\n (list) @VALUE\n (dictionary) @VALUE\n (set) @VALUE\n ]))))",
"metavars": [
"VARNAME",
"VALUE"
],
"post_filter": "not_in_function",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-mutable-class-attr.yml"
},
{
"id": "python-path-traversal",
"name": "Path Traversal Risk",
"severity": "warning",
"language": "python",
"message": "Potential path traversal sink — sanitize and constrain file paths",
"query": " [\n (call\n function: (identifier) @FN\n arguments: (argument_list\n [(identifier) (binary_operator) (call)] @PATH))\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(identifier) (binary_operator) (call)] @PATH))\n ]\n (#match? @FN \"^(open|read_text|read_bytes|write_text|write_bytes|remove|unlink|rmdir)$\")",
"metavars": [
"MOD",
"FN",
"PATH"
],
"post_filter": "py_path_traversal_sink",
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-path-traversal.yml"
},
{
"id": "python-print-statement",
"name": "Print Statement in Production",
"severity": "warning",
"language": "python",
"message": "print() — remove debug output before committing",
"query": " (call\n function: (identifier) @FUNC\n (#eq? @FUNC \"print\")\n arguments: (argument_list) @ARGS)",
"metavars": [
"FUNC",
"ARGS"
],
"post_filter": "not_in_test_block",
"defect_class": "safety",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-print-statement.yml"
},
{
"id": "python-raise-string",
"name": "Raise String Instead of Exception",
"severity": "warning",
"language": "python",
"message": "raise with string literal — Python 3 requires exception instances",
"query": " (raise_statement\n (string) @VALUE)",
"metavars": [
"VALUE"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-raise-string.yml"
},
{
"id": "python-sleep-in-test",
"name": "time.sleep in Test",
"severity": "warning",
"language": "python",
"message": "time.sleep() in test — use synchronisation primitives or polling helpers instead of fixed sleeps",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n (#eq? @MOD \"time\")\n (#eq? @FN \"sleep\")) @CALL",
"metavars": [
"MOD",
"FN",
"CALL"
],
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sleep-in-test.yml"
},
{
"id": "python-sql-injection",
"name": "SQL Injection Risk",
"severity": "error",
"language": "python",
"message": "Potential SQL injection sink — use parameterized queries",
"query": " (call\n function: (attribute\n object: (_) @OBJ\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(binary_operator) (identifier) (call)] @SQL\n (_)*))",
"metavars": [
"OBJ",
"FN",
"SQL"
],
"post_filter": "py_sql_injection_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sql-injection.yml"
},
{
"id": "python-ssrf",
"name": "SSRF Risk",
"severity": "warning",
"language": "python",
"message": "Potential SSRF sink — validate/allowlist outbound URLs",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(identifier) (subscript) (call)] @URL)\n (#eq? @MOD \"requests\")\n (#match? @FN \"^(get|post|put|patch|delete|request|head|options)$\"))",
"metavars": [
"MOD",
"FN",
"URL"
],
"post_filter": "py_ssrf_sink",
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-ssrf.yml"
},
{
"id": "python-subprocess-shell",
"name": "subprocess with shell=True",
"severity": "warning",
"language": "python",
"message": "subprocess called with shell=True — command injection risk if any argument is user-controlled",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n (keyword_argument\n name: (identifier) @KW\n value: (true) @VAL))\n (#eq? @MOD \"subprocess\")\n (#match? @FN \"^(run|Popen|call|check_output|check_call)$\")\n (#eq? @KW \"shell\"))",
"metavars": [
"MOD",
"FN",
"KW"
],
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-subprocess-shell.yml"
},
{
"id": "python-thread-global-write",
"name": "Threaded Shared State Risk",
"severity": "warning",
"language": "python",
"message": "Thread creation detected — ensure shared state mutations are synchronized",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS)\n (#eq? @MOD \"threading\")\n (#eq? @FN \"Thread\")",
"metavars": [
"MOD",
"FN",
"ARGS"
],
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-thread-global-write.yml"
},
{
"id": "python-unsafe-regex",
"name": "Unsafe Dynamic Regex",
"severity": "warning",
"language": "python",
"message": "re.{{FUNC}}() with variable pattern — ReDoS risk if pattern is user-controlled",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FUNC)\n arguments: (argument_list\n (identifier) @PATTERN)\n (#eq? @MOD \"re\")\n (#match? @FUNC \"^(compile|match|search|fullmatch|findall|finditer|sub|subn|split)$\"))",
"metavars": [
"MOD",
"FUNC",
"PATTERN"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-unsafe-regex.yml"
},
{
"id": "python-weak-hash",
"name": "Weak Hash Primitive",
"severity": "error",
"language": "python",
"message": "Weak hash primitive detected (MD5/SHA1) — use SHA-256+ for security-sensitive contexts",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"hashlib\")\n (#match? @FN \"^(md5|sha1)$\"))",
"metavars": [
"MOD",
"FN",
"ARGS"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-weak-hash.yml"
},
{
"id": "return-in-generator",
"name": "Return with Value in Generator",
"severity": "error",
"language": "python",
"message": "'return' with a value should not be used in a generator function",
"query": " (function_definition\n body: (block\n (return_statement\n (_) @RETURN_VAL) @RETURN)) @FUNCTION",
"metavars": [
"FUNCTION",
"RETURN",
"RETURN_VAL"
],
"post_filter": "is_generator_with_valued_return",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/return-in-generator.yml"
},
{
"id": "return-in-init",
"name": "Return Value in __init__",
"severity": "error",
"language": "python",
"message": "__init__ should not return a value — it must always return None",
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__init__\")\n body: (block\n (return_statement\n (_) @RETURN_VAL) @RETURN))",
"metavars": [
"NAME",
"RETURN",
"RETURN_VAL"
],
"post_filter": "has_return_value",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/return-in-init.yml"
},
{
"id": "send-file-mimetype",
"name": "send_file Should Specify Mimetype or Download Name",
"severity": "error",
"language": "python",
"message": "send_file should specify 'mimetype' or 'download_name' when used with file-like objects",
"query": " (call\n function: (identifier) @FUNC (#eq? @FUNC \"send_file\")\n arguments: (argument_list\n (_) @FIRST_ARG\n (keyword_argument)? @KW))",
"metavars": [
"FUNC",
"FIRST_ARG",
"KW"
],
"post_filter": "missing_mimetype_and_download_name",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/send-file-mimetype.yml"
},
{
"id": "unreachable-except",
"name": "Unreachable Except Clause",
"severity": "warning",
"language": "python",
"message": "Unreachable except clause — earlier except catches all",
"query": " (try_statement\n (except_clause\n \"except\") @GENERAL\n (except_clause\n \"except\"\n (identifier) @SPECIFIC))",
"metavars": [
"GENERAL",
"SPECIFIC"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/unreachable-except.yml"
},
{
"id": "wildcard-import",
"name": "Wildcard Import",
"severity": "warning",
"language": "python",
"message": "Wildcard import — pollutes namespace, hard to track origin",
"query": " (import_from_statement\n module_name: (dotted_name) @MODULE\n (wildcard_import) @WILDCARD)",
"metavars": [
"MODULE",
"WILDCARD"
],
"defect_class": "safety",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/wildcard-import.yml"
},
{
"id": "yield-return-outside-function",
"name": "Yield/Return Outside Function",
"severity": "error",
"language": "python",
"message": "{{STATEMENT}} used outside function — syntax error",
"query": " (module\n (expression_statement\n (yield) @STATEMENT))\n (module\n (expression_statement\n (yield_expression) @STATEMENT))\n (module\n (return_statement) @STATEMENT)",
"metavars": [
"STATEMENT"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/yield-return-outside-function.yml"
}
]
}
File diff suppressed because one or more lines are too long
+28 -3
View File
@@ -248,8 +248,26 @@ class TerminalManager:
instance_id: str,
session_id: str,
) -> TerminalSession | None:
"""Lookup a session by composite key."""
return self._sessions.get((instance_id, session_id))
"""Lookup a session by composite key, or by internal session_id."""
session = self._sessions.get((instance_id, session_id))
if session is not None:
return session
# Fallback: search by internal TerminalSession.session_id
for (iid, _sid), sess in self._sessions.items():
if iid == instance_id and sess.session_id == session_id:
return sess
return None
def _find_key_by_internal_id(
self,
instance_id: str,
internal_session_id: str,
) -> tuple[str, str] | None:
"""Find the manager dict key for a session by its internal session_id."""
for (iid, sid), session in self._sessions.items():
if iid == instance_id and session.session_id == internal_session_id:
return (iid, sid)
return None
def get_sessions_for_instance(
self,
@@ -328,6 +346,7 @@ class TerminalManager:
container_id: str,
startup_command: str | None = None,
session_id: str | None = None,
name: str | None = None,
) -> TerminalSession:
"""Reset a session by killing it and creating a new one.
@@ -336,6 +355,7 @@ class TerminalManager:
container_id: Docker container ID.
startup_command: Optional startup command.
session_id: Specific session to reset. If None, resets the default session.
name: Optional name to preserve for the new session.
Returns:
The newly created TerminalSession.
@@ -344,6 +364,11 @@ class TerminalManager:
target_session_id = session_id or "default"
key = (instance_id_str, target_session_id)
# Preserve old name if not provided
old_name = name
if old_name is None and key in self._sessions:
old_name = self._sessions[key].name
# Close existing session if any
if key in self._sessions:
logger.debug(
@@ -363,7 +388,7 @@ class TerminalManager:
instance_id=instance_id,
container_id=container_id,
startup_command=startup_command,
name="Session 1" if target_session_id == "default" else None,
name=old_name or ("Session 1" if target_session_id == "default" else None),
)
await new_session.start(startup_command=startup_command)
self._sessions[key] = new_session
File diff suppressed because one or more lines are too long
+36
View File
@@ -0,0 +1,36 @@
{
"version": "v2",
"timestamp": 1779892231625,
"ruleHash": "0a2423849fae7580",
"queries": [
{
"id": "dangerously-set-inner-html",
"name": "Dangerously Set Inner HTML",
"severity": "error",
"language": "tsx",
"message": "dangerouslySetInnerHTML — XSS risk, sanitize user input",
"query": " (jsx_attribute\n (property_identifier) @ATTR\n (#match? @ATTR \"dangerouslySetInnerHTML\"))",
"metavars": [
"ATTR"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/tsx/dangerously-set-inner-html.yml"
},
{
"id": "no-nested-links",
"name": "Nested anchor tags",
"severity": "error",
"language": "tsx",
"message": "Nested <a> tags are invalid HTML and cause unexpected behavior",
"query": " (jsx_element\n open_tag: (jsx_opening_element\n (identifier) @OUTER\n (#eq? @OUTER \"a\"))\n (jsx_element\n open_tag: (jsx_opening_element\n (identifier) @INNER\n (#eq? @INNER \"a\"))))",
"metavars": [
"OUTER",
"INNER"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/tsx/no-nested-links.yml"
}
]
}
+477
View File
@@ -0,0 +1,477 @@
{
"version": "v2",
"timestamp": 1779889832502,
"ruleHash": "45ab8be323739a4e",
"queries": [
{
"id": "console-statement",
"name": "Console Statement",
"severity": "warning",
"language": "typescript",
"message": "{{METHOD}} — remove debug statements before committing",
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD (#not-eq? @METHOD \"dbg\"))\n arguments: (arguments) @ARGS)",
"metavars": [
"OBJ",
"METHOD",
"ARGS"
],
"post_filter": "not_in_test_block # skip test blocks — no-console-in-tests handles that case",
"defect_class": "safety",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml"
},
{
"id": "debugger-statement",
"name": "Debugger Statement",
"severity": "error",
"language": "typescript",
"message": "Debugger statement — remove before committing",
"query": " (debugger_statement) @DEBUGGER",
"metavars": [
"DEBUGGER"
],
"defect_class": "safety",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/debugger.yml"
},
{
"id": "deep-nesting",
"name": "Deep Nesting",
"severity": "warning",
"language": "typescript",
"message": "Deep nesting (3+ levels) — consider early returns or extract functions",
"query": " [\n ;; Pattern 1: if inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement) @IF_NESTED)))))\n\n ;; Pattern 2: for inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement) @FOR_NESTED)))))\n\n ;; Pattern 3: while inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement) @WHILE_NESTED)))))\n\n ;; Pattern 4: try inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (try_statement) @TRY_NESTED)))))\n\n ;; Pattern 5: if inside for inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement\n body: (statement_block\n (if_statement) @IF_IN_FOR)))))\n\n ;; Pattern 6: if inside while inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement\n body: (statement_block\n (if_statement) @IF_IN_WHILE)))))\n\n ;; Pattern 7: for inside for inside for\n (statement_block\n (for_statement\n body: (statement_block\n (for_statement\n body: (statement_block\n (for_statement) @FOR_NESTED)))))\n ]",
"metavars": [
"IF_NESTED",
"FOR_NESTED",
"WHILE_NESTED",
"TRY_NESTED",
"IF_IN_FOR",
"IF_IN_WHILE"
],
"defect_class": "safety",
"inline_tier": "review",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/deep-nesting.yml"
},
{
"id": "deep-promise-chain",
"name": "Deep Promise Chain (4+ levels)",
"severity": "warning",
"language": "typescript",
"message": "Promise chain {{M1}} → {{M2}} → {{M3}} → {{M4}} — consider async/await",
"query": " (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n property: (property_identifier) @M1)\n arguments: (arguments))\n property: (property_identifier) @M2)\n arguments: (arguments))\n property: (property_identifier) @M3)\n arguments: (arguments))\n property: (property_identifier) @M4)\n arguments: (arguments)\n (#match? @M1 \"^(then|catch|finally)$\")\n (#match? @M2 \"^(then|catch|finally)$\")\n (#match? @M3 \"^(then|catch|finally)$\")\n (#match? @M4 \"^(then|catch|finally)$\"))",
"metavars": [
"M1",
"M2",
"M3",
"M4"
],
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/deep-promise-chain.yml"
},
{
"id": "default-not-last",
"name": "Default Clauses Should Be Last",
"severity": "error",
"language": "typescript",
"message": "default clause should be the last case",
"query": " (switch_statement\n body: (switch_body\n (switch_default) @DEFAULT\n (switch_case) @AFTER_CASE))",
"metavars": [
"DEFAULT",
"AFTER_CASE"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/default-not-last.yml"
},
{
"id": "duplicate-function-arg",
"name": "Function Argument Names Should Be Unique",
"severity": "error",
"language": "typescript",
"message": "Duplicate parameter name '{{NAME}}'",
"query": " (function_declaration\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))\n (arrow_function\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))",
"metavars": [
"PARAM1",
"PARAM2"
],
"post_filter": "same_param_name",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml"
},
{
"id": "empty-switch-case",
"name": "Switch Cases Should Not Be Empty",
"severity": "error",
"language": "typescript",
"message": "Switch case should not be empty",
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block) @BLOCK)))",
"metavars": [
"BLOCK"
],
"post_filter": "is_empty_block",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/empty-switch-case.yml"
},
{
"id": "no-eval",
"name": "Eval Usage",
"severity": "error",
"language": "typescript",
"message": "eval() detected — security risk, never use eval",
"query": " (call_expression\n function: (identifier) @FUNC\n (#eq? @FUNC \"eval\")\n arguments: (arguments) @ARGS)",
"metavars": [
"FUNC",
"ARGS"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/eval.yml"
},
{
"id": "ts-incomplete-assertion",
"name": "Incomplete Test Assertion",
"severity": "error",
"language": "typescript",
"message": "Incomplete assertion — expect() chain is not called",
"query": " (call_expression\n function: (identifier) @EXPECT\n (#eq? @EXPECT \"expect\")\n arguments: (arguments)) @EXPR",
"metavars": [
"EXPECT",
"EXPR"
],
"post_filter": "incomplete_assertion",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/incomplete-assertion.yml"
},
{
"id": "infinite-loop",
"name": "Loops Should Not Be Infinite",
"severity": "error",
"language": "typescript",
"message": "Loop appears to be infinite with no termination condition",
"query": " (while_statement\n condition: (true)\n body: (statement_block) @BODY)\n (for_statement\n condition: (null)\n body: (statement_block) @BODY)",
"metavars": [
"BODY"
],
"post_filter": "no_break_or_return_in_body",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/infinite-loop.yml"
},
{
"id": "mixed-async-styles",
"name": "Mixed Async/Await and Promise Chains",
"severity": "warning",
"language": "typescript",
"message": "Mixed async/await + promise chains — use consistent async style",
"query": " (function_declaration\n (async_modifier)\n body: (statement_block) @BODY)\n\n# Post-filter: Check if body contains both await and .then()",
"metavars": [
"BODY"
],
"post_filter": "has_mixed_async",
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/mixed-async-styles.yml"
},
{
"id": "no-console-in-tests",
"name": "Console Statement in Test",
"severity": "warning",
"language": "typescript",
"message": "console.{{METHOD}} in test block — use proper assertions or logging",
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD)\n arguments: (arguments) @ARGS)",
"metavars": [
"OBJ",
"METHOD",
"ARGS"
],
"post_filter": "in_test_block",
"defect_class": "safety",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/no-console-in-tests.yml"
},
{
"id": "self-assignment",
"name": "Variables Should Not Be Self-Assigned",
"severity": "error",
"language": "typescript",
"message": "'{{VAR}}' is assigned to itself",
"query": " (assignment_expression\n left: (identifier) @VAR\n right: (identifier) @SAME\n (#eq? @VAR @SAME))",
"metavars": [
"VAR",
"SAME"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/self-assignment.yml"
},
{
"id": "sql-injection",
"name": "SQL Injection Risk",
"severity": "error",
"language": "typescript",
"message": "SQL injection risk — use parameterized queries, never interpolate into SQL",
"query": " (call_expression\n function: [\n (identifier) @SQL_FUNC\n (member_expression property: (property_identifier) @SQL_FUNC)\n ]\n arguments: (arguments\n (template_string (template_substitution) @INTERPOLATION))\n (#match? @SQL_FUNC \"^(query|execute|exec|run)$\"))",
"metavars": [
"SQL_FUNC",
"INTERPOLATION"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/sql-injection.yml"
},
{
"id": "switch-case-termination",
"name": "Switch Cases Should End With Terminating Statement",
"severity": "error",
"language": "typescript",
"message": "Switch case should end with break, return, throw, or continue",
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block\n (expression_statement) @LAST))\n (switch_case) @NEXT))",
"metavars": [
"LAST",
"NEXT"
],
"post_filter": "no_terminating_statement",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/switch-case-termination.yml"
},
{
"id": "switch-non-case-labels-ts",
"name": "Switch Should Not Contain Non-Case Labels",
"severity": "error",
"language": "typescript",
"message": "switch statements should not contain non-case labels",
"query": " (switch_statement\n body: (switch_body\n (switch_case\n (labeled_statement\n (statement_identifier) @LABEL) @LABELED)))",
"metavars": [
"LABEL",
"LABELED"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/switch-non-case-labels.yml"
},
{
"id": "ts-command-injection",
"name": "Command Injection Sink",
"severity": "error",
"language": "typescript",
"message": "Potential command injection sink — avoid child_process command execution with untrusted input",
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n (call_expression\n function: (member_expression\n object: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @NS)\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n ]",
"metavars": [
"MOD",
"NS",
"FN",
"ARGS"
],
"post_filter": "ts_command_injection_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-command-injection.yml"
},
{
"id": "ts-detached-async-call",
"name": "Detached Async Call",
"severity": "warning",
"language": "typescript",
"message": "Detached async call — ensure this Promise is awaited or explicitly handled",
"query": " (expression_statement\n (call_expression\n function: [\n (identifier) @FN\n (member_expression\n property: (property_identifier) @FN)\n ]\n arguments: (arguments) @ARGS)\n (#match? @FN \"(Async$|fetch$|request$)\"))",
"metavars": [
"FN",
"ARGS"
],
"post_filter": "ts_detached_async_call",
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-detached-async-call.yml"
},
{
"id": "ts-dynamic-require",
"name": "Dynamic Require Injection",
"severity": "error",
"language": "typescript",
"message": "Dynamic require() — non-literal argument allows loading arbitrary modules",
"query": " (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @ARG)\n (#eq? @FN \"require\"))",
"metavars": [
"FN",
"ARG"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-dynamic-require.yml"
},
{
"id": "ts-hallucinated-react-import",
"name": "Hallucinated React Import",
"severity": "error",
"language": "typescript",
"message": "'{NAME}' is a Next.js API, not from 'react' — import from 'next/{CORRECT}' instead",
"query": " (import_statement\n (import_clause\n (named_imports\n (import_specifier\n name: (identifier) @NAME)))\n source: (string) @SRC)\n (#match? @SRC \"^['\\\"]react['\\\"]$\")\n (#match? @NAME \"^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$\")",
"metavars": [
"NAME",
"SRC"
],
"post_filter": "match_captures",
"post_filter_params": {
"SRC": "^['\\\"]react['\\\"]$",
"NAME": "^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$"
},
"defect_class": "hallucination",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-hallucinated-react-import.yml"
},
{
"id": "ts-insecure-random",
"name": "Insecure Randomness",
"severity": "warning",
"language": "typescript",
"message": "Insecure randomness source detected — use crypto.getRandomValues or secure RNG APIs",
"query": " (variable_declarator\n name: (identifier) @VAR\n value: (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS)\n (#eq? @OBJ \"Math\")\n (#eq? @FN \"random\")\n (#match? @VAR \"(?i)(token|secret|password|key|nonce|salt|csrf|auth|session|credential|hash|otp|pin)\"))",
"metavars": [
"OBJ",
"FN",
"ARGS",
"VAR"
],
"post_filter": "ts_insecure_random_source",
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-insecure-random.yml"
},
{
"id": "ts-nosql-injection",
"name": "NoSQL Injection",
"severity": "error",
"language": "typescript",
"message": "NoSQL injection — $where executes JavaScript server-side and must never be used with user input",
"query": " (pair\n key: [(property_identifier) (string)] @KEY\n (#match? @KEY \"\\\\$where\"))",
"metavars": [
"KEY"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-nosql-injection.yml"
},
{
"id": "ts-open-redirect",
"name": "Open Redirect",
"severity": "error",
"language": "typescript",
"message": "Open redirect — unvalidated URL in redirect/location lets attackers send users to malicious sites",
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n ]\n [\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n ]",
"metavars": [
"OBJ",
"FN",
"URL",
"WIN",
"LOC",
"PROP",
"VALUE"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-open-redirect.yml"
},
{
"id": "ts-react-antipatterns",
"name": "React Anti-Pattern",
"severity": "warning",
"language": "typescript",
"message": "React anti-pattern: setState inside a loop causes multiple re-renders — batch with a single state update",
"query": " [\n (for_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (for_in_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (while_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n ]",
"metavars": [
"BODY"
],
"defect_class": "logic-error",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-react-antipatterns.yml"
},
{
"id": "ts-ssrf",
"name": "SSRF Risk",
"severity": "error",
"language": "typescript",
"message": "Potential SSRF sink — validate and allowlist outbound URLs",
"query": " [\n (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n ]",
"metavars": [
"OBJ",
"FN",
"URL"
],
"post_filter": "ts_ssrf_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-ssrf.yml"
},
{
"id": "ts-weak-hash",
"name": "Weak Hash Primitive",
"severity": "error",
"language": "typescript",
"message": "Weak hash primitive selected (md5/sha1) — use sha256+ for security-sensitive contexts",
"query": " (call_expression\n function: (member_expression\n property: (property_identifier) @FN)\n arguments: (arguments\n (string (string_fragment) @ALG)\n (_)*)\n (#eq? @FN \"createHash\")\n (#match? @ALG \"^(md5|sha1)$\"))",
"metavars": [
"FN",
"ALG"
],
"post_filter": "ts_weak_hash_algorithm",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-weak-hash.yml"
},
{
"id": "ts-xss-dom-sink",
"name": "XSS DOM Sink",
"severity": "error",
"language": "typescript",
"message": "XSS risk — dynamic value written to innerHTML/outerHTML or document.write()",
"query": " [\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (await_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n ]\n [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n ]",
"metavars": [
"PROP",
"VALUE",
"OBJ",
"FN",
"ARG"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-xss-dom-sink.yml"
},
{
"id": "unsafe-regex",
"name": "Dynamic Regex Construction",
"severity": "error",
"language": "typescript",
"message": "Dynamic regex from user input — can cause ReDoS (Regular Expression Denial of Service)",
"query": " (new_expression\n constructor: (identifier) @CTOR\n (#eq? @CTOR \"RegExp\")\n arguments: (arguments\n (template_string\n (template_substitution) @INTERPOLATION) @PATTERN)\n (#not-match? @INTERPOLATION \"escape|Escape|replace\"))",
"metavars": [
"CTOR",
"INTERPOLATION",
"PATTERN"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/unsafe-regex.yml"
},
{
"id": "variable-shadowing",
"name": "Variable Shadowing",
"severity": "warning",
"language": "typescript",
"message": "Variable '{{NAME}}' shadows a parameter — use a distinct name",
"query": " (function_declaration\n parameters: (formal_parameters\n (required_parameter\n pattern: (identifier) @PARAM))\n body: (statement_block\n (lexical_declaration\n (variable_declarator\n name: (identifier) @NAME))))",
"metavars": [
"PARAM",
"NAME"
],
"post_filter": "name_matches_param",
"defect_class": "safety",
"inline_tier": "review",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/variable-shadowing.yml"
}
]
}
+54 -54
View File
@@ -1,87 +1,87 @@
import { apiClient } from "./client";
export interface TerminalSession {
id: string;
name: string;
status: string;
has_websockets: boolean;
created_at: string;
last_activity_at: string | null;
id: string;
name: string;
status: string;
has_websockets: boolean;
created_at: string;
last_activity_at: string | null;
}
export interface TerminalSessionListResponse {
sessions: TerminalSession[];
sessions: TerminalSession[];
}
export interface TerminalSessionCreateRequest {
name?: string;
name?: string;
}
export interface TerminalSessionCreateResponse {
id: string;
name: string;
status: string;
created_at: string;
id: string;
name: string;
status: string;
created_at: string;
}
export async function listTerminalSessions(
projectId: string,
repoId: string,
instanceId: string
projectId: string,
repoId: string,
instanceId: string,
): Promise<TerminalSession[]> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions`
);
return response.data.sessions;
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions`,
);
return response.data.sessions;
}
export async function createTerminalSession(
projectId: string,
repoId: string,
instanceId: string,
name?: string
projectId: string,
repoId: string,
instanceId: string,
name?: string,
): Promise<TerminalSessionCreateResponse> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions`,
{ name }
);
return response.data;
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions`,
{ name },
);
return response.data;
}
export async function closeTerminalSession(
projectId: string,
repoId: string,
instanceId: string,
sessionId: string
projectId: string,
repoId: string,
instanceId: string,
sessionId: string,
): Promise<{ status: string; session_id: string }> {
const response = await apiClient.delete(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions/${sessionId}`
);
return response.data;
const response = await apiClient.delete(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions/${sessionId}`,
);
return response.data;
}
export async function resetTerminalSession(
projectId: string,
repoId: string,
instanceId: string,
sessionId: string
projectId: string,
repoId: string,
instanceId: string,
sessionId: string,
): Promise<{ id: string; name: string; status: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions/${sessionId}/reset`
);
return response.data;
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions/${sessionId}/reset`,
);
return response.data;
}
export async function renameTerminalSession(
projectId: string,
repoId: string,
instanceId: string,
sessionId: string,
name: string
projectId: string,
repoId: string,
instanceId: string,
sessionId: string,
name: string,
): Promise<{ id: string; name: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions/${sessionId}/rename`,
{ name }
);
return response.data;
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions/${sessionId}/rename`,
{ name },
);
return response.data;
}
@@ -0,0 +1,161 @@
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
TerminalSessionTabs,
type TerminalSessionInfo,
} from "./terminal-session-tabs";
const mockSessions: TerminalSessionInfo[] = [
{ id: "s1", name: "Session 1", status: "connected" },
{ id: "s2", name: "Session 2", status: "connecting" },
{ id: "s3", name: "Session 3", status: "disconnected" },
];
afterEach(() => {
cleanup();
});
describe("TerminalSessionTabs", () => {
it("renders all tabs", () => {
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
expect(screen.getByText("Session 1")).toBeInTheDocument();
expect(screen.getByText("Session 2")).toBeInTheDocument();
expect(screen.getByText("Session 3")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /new session/i })).toBeInTheDocument();
});
it("clicking a tab calls onSelect", () => {
const onSelect = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={onSelect}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
fireEvent.click(screen.getAllByText("Session 2")[0]);
expect(onSelect).toHaveBeenCalledWith("s2");
});
it("close button calls onClose after confirmation", () => {
const onClose = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={onClose}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
const closeButton = screen.getByLabelText("Close session Session 1");
// First click shows confirm
fireEvent.click(closeButton);
expect(screen.getByText("Close?")).toBeInTheDocument();
// Click confirm text
fireEvent.click(screen.getByText("Close?"));
expect(onClose).toHaveBeenCalledWith("s1");
});
it("double-click enables rename and Enter commits", () => {
const onRename = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={onRename}
/>
);
fireEvent.doubleClick(screen.getAllByText("Session 1")[0]);
const input = screen.getByLabelText("Rename session");
expect(input).toBeInTheDocument();
fireEvent.change(input, { target: { value: "Renamed" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onRename).toHaveBeenCalledWith("s1", "Renamed");
});
it("double-click enables rename and Escape cancels", () => {
const onRename = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={onRename}
/>
);
fireEvent.doubleClick(screen.getAllByText("Session 1")[0]);
const input = screen.getByLabelText("Rename session");
fireEvent.change(input, { target: { value: "Renamed" } });
fireEvent.keyDown(input, { key: "Escape" });
expect(onRename).not.toHaveBeenCalled();
expect(screen.getByText("Session 1")).toBeInTheDocument();
});
it("plus button is disabled at 5 sessions", () => {
const fiveSessions: TerminalSessionInfo[] = Array.from({ length: 5 }, (_, i) => ({
id: `s${i + 1}`,
name: `Session ${i + 1}`,
status: "connected",
}));
render(
<TerminalSessionTabs
sessions={fiveSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
const newButton = screen.getByRole("button", { name: /new session/i });
expect(newButton).toBeDisabled();
});
it("status dot reflects connection state", () => {
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
const tabs = screen.getAllByRole("tab");
expect(tabs).toHaveLength(3);
expect(tabs[0].querySelector(".connected")).toBeInTheDocument();
expect(tabs[1].querySelector(".connecting")).toBeInTheDocument();
expect(tabs[2].querySelector(".disconnected")).toBeInTheDocument();
});
});
@@ -0,0 +1,167 @@
import React, { useState, useRef, useCallback } from "react";
export interface TerminalSessionInfo {
id: string;
name: string;
status: "connecting" | "connected" | "disconnected" | "error" | "resetting";
}
export interface TerminalSessionTabsProps {
sessions: TerminalSessionInfo[];
activeSessionId: string;
onSelect: (sessionId: string) => void;
onClose: (sessionId: string) => void;
onCreate: () => void;
onRename: (sessionId: string, newName: string) => void;
isMobile?: boolean;
}
export const TerminalSessionTabs: React.FC<TerminalSessionTabsProps> = ({
sessions,
activeSessionId,
onSelect,
onClose,
onCreate,
onRename,
isMobile = false,
}) => {
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [confirmCloseId, setConfirmCloseId] = useState<string | null>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const handleDoubleClick = useCallback((session: TerminalSessionInfo) => {
setRenamingId(session.id);
setRenameValue(session.name);
requestAnimationFrame(() => {
renameInputRef.current?.focus();
renameInputRef.current?.select();
});
}, []);
const commitRename = useCallback(() => {
if (renamingId && renameValue.trim()) {
onRename(renamingId, renameValue.trim());
}
setRenamingId(null);
setRenameValue("");
}, [renamingId, renameValue, onRename]);
const cancelRename = useCallback(() => {
setRenamingId(null);
setRenameValue("");
}, []);
const handleRenameKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
commitRename();
} else if (e.key === "Escape") {
cancelRename();
}
},
[commitRename, cancelRename],
);
const handleCloseClick = useCallback(
(e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
if (confirmCloseId === sessionId) {
setConfirmCloseId(null);
onClose(sessionId);
} else {
setConfirmCloseId(sessionId);
// Auto-dismiss confirm after 3s
setTimeout(() => {
setConfirmCloseId((prev) => (prev === sessionId ? null : prev));
}, 3000);
}
},
[confirmCloseId, onClose],
);
const isMaxSessions = sessions.length >= 5;
return (
<div
className={`terminal-session-tabs ${isMobile ? "mobile" : ""}`}
role="tablist"
aria-label="Terminal sessions"
>
<div className="terminal-session-tabs-scroll" ref={scrollRef}>
{sessions.map((session) => {
const isActive = session.id === activeSessionId;
const isRenaming = renamingId === session.id;
const isConfirmingClose = confirmCloseId === session.id;
return (
<div
key={session.id}
className={`terminal-session-tab ${isActive ? "active" : ""}`}
role="tab"
aria-selected={isActive}
onClick={() => onSelect(session.id)}
onDoubleClick={() => handleDoubleClick(session)}
title={isRenaming ? "" : `${session.name} (${session.status})`}
>
<span
className={`terminal-session-tab-status ${session.status}`}
aria-hidden="true"
/>
{isRenaming ? (
<input
ref={renameInputRef}
className="terminal-session-tab-input"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={commitRename}
onClick={(e) => e.stopPropagation()}
aria-label="Rename session"
/>
) : (
<span className="terminal-session-tab-name">
{session.name}
</span>
)}
{isConfirmingClose ? (
<button
className="terminal-session-tab-confirm"
onClick={(e) => {
e.stopPropagation();
setConfirmCloseId(null);
onClose(session.id);
}}
type="button"
>
Close?
</button>
) : (
<button
className="terminal-session-tab-close"
onClick={(e) => handleCloseClick(e, session.id)}
type="button"
aria-label={`Close session ${session.name}`}
tabIndex={-1}
>
×
</button>
)}
</div>
);
})}
<button
className="terminal-session-tab new-session"
onClick={onCreate}
disabled={isMaxSessions}
type="button"
aria-label="New session"
title={isMaxSessions ? "Maximum 5 sessions reached" : "New session"}
>
+
</button>
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
+141 -128
View File
@@ -1,145 +1,158 @@
import { useCallback, useEffect, useState } from "react";
import {
listTerminalSessions,
createTerminalSession,
closeTerminalSession,
resetTerminalSession,
renameTerminalSession,
type TerminalSession,
listTerminalSessions,
createTerminalSession,
closeTerminalSession,
resetTerminalSession,
renameTerminalSession,
type TerminalSession,
} from "../api/terminal";
export interface UseTerminalSessionsResult {
sessions: TerminalSession[];
activeSessionId: string | null;
setActiveSessionId: (id: string) => void;
createSession: (name?: string) => Promise<TerminalSession | null>;
closeSession: (sessionId: string) => Promise<void>;
renameSession: (sessionId: string, name: string) => Promise<void>;
resetSession: (sessionId: string) => Promise<void>;
loading: boolean;
error: string | null;
sessions: TerminalSession[];
activeSessionId: string | null;
setActiveSessionId: (id: string) => void;
createSession: (name?: string) => Promise<TerminalSession | null>;
closeSession: (sessionId: string) => Promise<void>;
renameSession: (sessionId: string, name: string) => Promise<void>;
resetSession: (sessionId: string) => Promise<void>;
loading: boolean;
error: string | null;
}
export function useTerminalSessions(
projectId: string,
repoId: string,
instanceId: string
projectId: string,
repoId: string,
instanceId: string,
): UseTerminalSessionsResult {
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadSessions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const sess = await listTerminalSessions(projectId, repoId, instanceId);
setSessions(sess);
if (sess.length > 0 && !activeSessionId) {
setActiveSessionId(sess[0].id);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load sessions");
} finally {
setLoading(false);
}
}, [projectId, repoId, instanceId, activeSessionId]);
const loadSessions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const sess = await listTerminalSessions(projectId, repoId, instanceId);
setSessions(sess);
if (sess.length > 0 && !activeSessionId) {
setActiveSessionId(sess[0].id);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load sessions");
} finally {
setLoading(false);
}
}, [projectId, repoId, instanceId, activeSessionId]);
const createSession = useCallback(
async (name?: string) => {
setError(null);
try {
const newSession = await createTerminalSession(
projectId,
repoId,
instanceId,
name
);
const session: TerminalSession = {
id: newSession.id,
name: newSession.name,
status: newSession.status,
has_websockets: false,
created_at: newSession.created_at,
last_activity_at: null,
};
setSessions((prev) => [...prev, session]);
setActiveSessionId(session.id);
return session;
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to create session";
setError(msg);
return null;
}
},
[projectId, repoId, instanceId]
);
const createSession = useCallback(
async (name?: string) => {
setError(null);
try {
const newSession = await createTerminalSession(
projectId,
repoId,
instanceId,
name,
);
const session: TerminalSession = {
id: newSession.id,
name: newSession.name,
status: newSession.status,
has_websockets: false,
created_at: newSession.created_at,
last_activity_at: null,
};
setSessions((prev) => [...prev, session]);
setActiveSessionId(session.id);
return session;
} catch (err) {
const msg =
err instanceof Error ? err.message : "Failed to create session";
setError(msg);
return null;
}
},
[projectId, repoId, instanceId],
);
const closeSession = useCallback(
async (sessionId: string) => {
setError(null);
try {
await closeTerminalSession(projectId, repoId, instanceId, sessionId);
setSessions((prev) => {
const filtered = prev.filter((s) => s.id !== sessionId);
if (activeSessionId === sessionId && filtered.length > 0) {
setActiveSessionId(filtered[0].id);
} else if (filtered.length === 0) {
setActiveSessionId(null);
}
return filtered;
});
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to close session");
}
},
[projectId, repoId, instanceId, activeSessionId]
);
const closeSession = useCallback(
async (sessionId: string) => {
setError(null);
try {
await closeTerminalSession(projectId, repoId, instanceId, sessionId);
setSessions((prev) => {
const filtered = prev.filter((s) => s.id !== sessionId);
if (activeSessionId === sessionId && filtered.length > 0) {
setActiveSessionId(filtered[0].id);
} else if (filtered.length === 0) {
setActiveSessionId(null);
}
return filtered;
});
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to close session",
);
}
},
[projectId, repoId, instanceId, activeSessionId],
);
const renameSession = useCallback(
async (sessionId: string, name: string) => {
setError(null);
try {
await renameTerminalSession(projectId, repoId, instanceId, sessionId, name);
setSessions((prev) =>
prev.map((s) => (s.id === sessionId ? { ...s, name } : s))
);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to rename session");
}
},
[projectId, repoId, instanceId]
);
const renameSession = useCallback(
async (sessionId: string, name: string) => {
setError(null);
try {
await renameTerminalSession(
projectId,
repoId,
instanceId,
sessionId,
name,
);
setSessions((prev) =>
prev.map((s) => (s.id === sessionId ? { ...s, name } : s)),
);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to rename session",
);
}
},
[projectId, repoId, instanceId],
);
const resetSession = useCallback(
async (sessionId: string) => {
setError(null);
try {
await resetTerminalSession(projectId, repoId, instanceId, sessionId);
// Refetch to get updated session info
await loadSessions();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to reset session");
}
},
[projectId, repoId, instanceId, loadSessions]
);
const resetSession = useCallback(
async (sessionId: string) => {
setError(null);
try {
await resetTerminalSession(projectId, repoId, instanceId, sessionId);
// Refetch to get updated session info
await loadSessions();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to reset session",
);
}
},
[projectId, repoId, instanceId, loadSessions],
);
// Initial load
useEffect(() => {
void loadSessions();
}, [loadSessions]);
// Initial load
useEffect(() => {
void loadSessions();
}, [loadSessions]);
return {
sessions,
activeSessionId,
setActiveSessionId,
createSession,
closeSession,
renameSession,
resetSession,
loading,
error,
};
return {
sessions,
activeSessionId,
setActiveSessionId,
createSession,
closeSession,
renameSession,
resetSession,
loading,
error,
};
}
+302 -42
View File
@@ -1,50 +1,310 @@
import React from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { TerminalComponent } from "../components/terminal";
import { MobileTerminalWrapper } from "../components/mobile-terminal-wrapper";
import { TerminalComponent, type TerminalRef } from "../components/terminal";
import {
TerminalSessionTabs,
type TerminalSessionInfo,
} from "../components/terminal-session-tabs";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { useTerminalSessions } from "../hooks/use-terminal-sessions";
import type { TerminalSession } from "../api/terminal";
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
sessions.map((s) => ({
id: s.id,
name: s.name,
status: s.status as TerminalSessionInfo["status"],
}));
export const TerminalPage: React.FC = () => {
const { instanceId } = useParams<{ instanceId: string }>();
const navigate = useNavigate();
const isMobile = useMobileViewport();
const { projectId, repoId, instanceId } = useParams<{
projectId: string;
repoId: string;
instanceId: string;
}>();
const navigate = useNavigate();
const isMobile = useMobileViewport();
const [isFullscreen, setIsFullscreen] = useState(false);
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
if (!instanceId) {
return (
<section className="stack">
<h1>Terminal</h1>
<p className="muted">No instance ID provided.</p>
</section>
);
}
const {
sessions,
activeSessionId,
setActiveSessionId,
createSession,
closeSession,
renameSession,
resetSession,
loading,
error,
} = useTerminalSessions(projectId ?? "", repoId ?? "", instanceId ?? "");
if (isMobile) {
return (
<MobileTerminalWrapper
instanceId={instanceId}
onBack={() => navigate(-1)}
onClose={() => navigate(-1)}
/>
);
}
// Auto-create default session if none exist
useEffect(() => {
if (!loading && sessions.length === 0 && !error && instanceId) {
void createSession("Session 1");
}
}, [loading, sessions.length, error, instanceId, createSession]);
return (
<section className="terminal-page">
<div className="terminal-page-header">
<button
className="secondary-button"
onClick={() => navigate(-1)}
type="button"
>
Back
</button>
<h1>Terminal</h1>
</div>
<TerminalComponent
instanceId={instanceId}
onClose={() => navigate(-1)}
isMobile={false}
/>
</section>
);
// Ensure refs map is kept in sync with sessions
useEffect(() => {
for (const session of sessions) {
if (!terminalRefs.current[session.id]) {
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
}
}
// Clean up refs for closed sessions
const currentIds = new Set(sessions.map((s) => s.id));
for (const id of Object.keys(terminalRefs.current)) {
if (!currentIds.has(id)) {
delete terminalRefs.current[id];
}
}
}, [sessions]);
// Fit active terminal when switching tabs
useEffect(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
const ref = terminalRefs.current[activeSessionId];
// Small delay to allow display:block to apply
const timer = setTimeout(() => {
ref.current?.fit();
}, 50);
return () => clearTimeout(timer);
}
}, [activeSessionId]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
if (!isAltShift) return;
switch (e.key.toLowerCase()) {
case "n":
e.preventDefault();
if (sessions.length < 5) {
void createSession(`Session ${sessions.length + 1}`);
}
break;
case "w":
e.preventDefault();
if (
activeSessionId &&
window.confirm("Close this terminal session?")
) {
void closeSession(activeSessionId);
}
break;
case "arrowleft":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx > 0) {
setActiveSessionId(sessions[idx - 1].id);
}
}
break;
case "arrowright":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx < sessions.length - 1) {
setActiveSessionId(sessions[idx + 1].id);
}
}
break;
case "r":
e.preventDefault();
if (activeSessionId) {
void resetSession(activeSessionId);
}
break;
case "f":
e.preventDefault();
setIsFullscreen((prev) => !prev);
break;
default:
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
sessions,
activeSessionId,
createSession,
closeSession,
resetSession,
setActiveSessionId,
]);
// Exit fullscreen on Escape
useEffect(() => {
if (!isFullscreen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsFullscreen(false);
}
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [isFullscreen]);
const handleSelect = useCallback(
(sessionId: string) => {
setActiveSessionId(sessionId);
},
[setActiveSessionId],
);
const handleClose = useCallback(
async (sessionId: string) => {
await closeSession(sessionId);
},
[closeSession],
);
const handleCreate = useCallback(() => {
void createSession(`Session ${sessions.length + 1}`);
}, [createSession, sessions.length]);
const handleRename = useCallback(
(sessionId: string, newName: string) => {
void renameSession(sessionId, newName);
},
[renameSession],
);
if (!instanceId) {
return (
<section className="stack">
<h1>Terminal</h1>
<p className="muted">No instance ID provided.</p>
</section>
);
}
const sessionInfos = SESSIONS_TO_INFO(sessions);
if (isMobile) {
return (
<section className={`terminal-page mobile ${isFullscreen ? "fullscreen" : ""}`}>
{!isFullscreen && (
<div className="terminal-page-header mobile-header">
<button className="secondary-button" onClick={() => navigate(-1)} type="button">
Back
</button>
<h1>Terminal</h1>
<button
className="secondary-button"
onClick={() => setIsFullscreen((p) => !p)}
type="button"
>
{isFullscreen ? "Exit" : "Fullscreen"}
</button>
</div>
)}
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={true}
/>
<div className="terminal-page-content">
{error && <div className="terminal-error-banner">{error}</div>}
{sessions.map((session) => (
<div
key={session.id}
className={`terminal-instance ${session.id === activeSessionId ? "active" : ""}`}
style={{
display: session.id === activeSessionId ? "flex" : "none",
}}
>
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => handleClose(session.id)}
isMobile={true}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
</div>
)}
</div>
</section>
);
}
return (
<section
className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}
>
{!isFullscreen && (
<div className="terminal-page-header">
<button
className="secondary-button"
onClick={() => navigate(-1)}
type="button"
>
Back
</button>
<h1>Terminal</h1>
<button
className="secondary-button"
onClick={() => setIsFullscreen((p) => !p)}
type="button"
title="Toggle fullscreen (Alt+Shift+F)"
>
{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
</button>
</div>
)}
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={false}
/>
<div className="terminal-page-content">
{error && (
<div className="terminal-error-banner">{error}</div>
)}
{sessions.map((session) => (
<div
key={session.id}
className={`terminal-instance ${session.id === activeSessionId ? "active" : ""}`}
style={{
display:
session.id === activeSessionId ? "flex" : "none",
}}
>
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => handleClose(session.id)}
isMobile={false}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
</div>
)}
</div>
</section>
);
};
+248
View File
@@ -2663,6 +2663,254 @@ a.nav-item,
}
}
/* ============================================
Terminal Session Tabs
============================================ */
.terminal-session-tabs {
display: flex;
flex-shrink: 0;
background: #2d2d2d;
border-bottom: 1px solid #3e3e3e;
overflow: hidden;
}
.terminal-session-tabs.mobile {
background: #1e1e1e;
}
.terminal-session-tabs-scroll {
display: flex;
gap: 2px;
padding: var(--space-1) var(--space-2);
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: #555 transparent;
flex: 1;
min-width: 0;
}
.terminal-session-tabs-scroll::-webkit-scrollbar {
height: 4px;
}
.terminal-session-tabs-scroll::-webkit-scrollbar-thumb {
background: #555;
border-radius: 2px;
}
.terminal-session-tab {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: #888;
cursor: pointer;
font-size: 0.8125rem;
white-space: nowrap;
transition: background 0.15s, color 0.15s, border-color 0.15s;
user-select: none;
min-width: 0;
}
.terminal-session-tab:hover {
background: #3e3e3e;
color: #ccc;
}
.terminal-session-tab.active {
background: #1e1e1e;
color: #d4d4d4;
border-color: #3e3e3e;
}
.terminal-session-tab-status {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.terminal-session-tab-status.connecting {
background: #f5f543;
animation: pulse 1.5s infinite;
}
.terminal-session-tab-status.connected {
background: #0dbc79;
}
.terminal-session-tab-status.disconnected,
.terminal-session-tab-status.error {
background: #cd3131;
}
.terminal-session-tab-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.terminal-session-tab-input {
background: #1e1e1e;
border: 1px solid var(--brand);
border-radius: 4px;
color: #d4d4d4;
font-size: 0.8125rem;
padding: 1px 4px;
width: 100px;
outline: none;
}
.terminal-session-tab-close {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
margin: 0;
background: transparent;
border: none;
color: #888;
cursor: pointer;
font-size: 0.875rem;
line-height: 1;
border-radius: 3px;
opacity: 0;
transition: opacity 0.15s, background 0.15s;
}
.terminal-session-tab:hover .terminal-session-tab-close {
opacity: 1;
}
.terminal-session-tab-close:hover {
background: #cd3131;
color: white;
}
.terminal-session-tab-confirm {
font-size: 0.6875rem;
color: #cd3131;
padding: 1px 4px;
border-radius: 3px;
background: rgba(205, 49, 49, 0.15);
white-space: nowrap;
}
.terminal-session-tab.new-session {
font-weight: 600;
font-size: 1rem;
line-height: 1;
padding: var(--space-1) var(--space-2);
color: #888;
}
.terminal-session-tab.new-session:hover {
background: #3e3e3e;
color: #d4d4d4;
}
.terminal-session-tab.new-session:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Terminal page with multi-session */
.terminal-page-content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: #1e1e1e;
}
.terminal-instance {
flex: 1;
min-height: 0;
display: none;
}
.terminal-instance.active {
display: flex;
}
.terminal-empty-state {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: #888;
font-size: 0.875rem;
}
.terminal-error-banner {
padding: var(--space-2) var(--space-4);
background: var(--danger-light);
color: var(--danger);
font-size: 0.875rem;
border-bottom: 1px solid var(--danger-light);
}
/* Fullscreen mode */
.terminal-page.fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
padding: 0;
gap: 0;
background: #1e1e1e;
}
.terminal-page.fullscreen .terminal-page-content {
border: none;
border-radius: 0;
}
.terminal-page.fullscreen .terminal-session-tabs {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
opacity: 0;
transition: opacity 0.3s;
}
.terminal-page.fullscreen .terminal-session-tabs:hover {
opacity: 1;
}
/* Mobile fullscreen */
@media (max-width: 767px) {
.terminal-page.fullscreen {
padding: 0;
}
.terminal-page.mobile .terminal-page-header {
padding: var(--space-2);
gap: var(--space-2);
}
.terminal-page.mobile .terminal-page-header h1 {
font-size: 1rem;
}
.terminal-session-tab-name {
max-width: 80px;
}
}
/* ============================================
Sessions Page Styles
============================================ */