├── .eslintrc.json ├── .github └── workflows │ └── publish_to_marketplace.yml ├── .gitignore ├── .pylintrc ├── .vscode-test.mjs ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── media ├── demo │ ├── demo-attach.gif │ ├── demo-attach.webp │ ├── demo-codecompletion.jpg │ ├── demo-documentation.gif │ ├── demo-documentation.webp │ ├── demo-exec.gif │ └── demo-exec.webp ├── icon-font │ ├── E800.svg │ └── ue-icon-font.woff2 └── icon.png ├── package-lock.json ├── package.json ├── python ├── add_sys_path.py ├── attach.py ├── documentation │ ├── build_toc.py │ └── get_page_content.py ├── execute.py ├── get_stub_path.py ├── reload.py └── vsc_eval.py ├── src ├── extension.ts ├── modules │ ├── code-exec.ts │ ├── extension-wiki.ts │ ├── logger.ts │ ├── remote-handler.ts │ └── utils.ts ├── scripts │ ├── attach.ts │ ├── execute.ts │ ├── reload.ts │ ├── select-instance.ts │ └── setup-code-completion.ts ├── test │ ├── modules │ │ └── extension-wiki.test.ts │ ├── scripts │ │ ├── attach.test.ts │ │ ├── execute.test.ts │ │ └── setup-code-completion.test.ts │ ├── test-utils.ts │ └── vscode-mock.ts └── views │ └── documentation-pannel.ts ├── test ├── debug-workspace │ ├── Test.py │ └── other_module.py └── fixture │ ├── module │ ├── file1.py │ └── file2.py │ └── test.py ├── tsconfig.json ├── vscode-unreal-python.code-workspace └── webview-ui ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── package-lock.json ├── package.json ├── public ├── index.html └── manifest.json ├── src ├── App.scss ├── App.tsx ├── Components │ ├── DropDownArea │ │ ├── dropDownArea.scss │ │ └── dropDownArea.tsx │ └── DynamicList │ │ ├── dynamicList.scss │ │ └── dynamicList.tsx ├── Documentation │ ├── Details │ │ ├── detailsPage.scss │ │ └── detailsPage.tsx │ ├── DocPage.tsx │ └── Index │ │ ├── Header │ │ ├── docHeader.scss │ │ └── docHeader.tsx │ │ ├── docIndex.scss │ │ └── docIndex.tsx ├── Modules │ └── vscode.ts ├── index.jsx ├── index.scss └── reportWebVitals.js └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "plugins": [ 9 | "@typescript-eslint" 10 | ], 11 | "rules": { 12 | "@typescript-eslint/naming-convention": "warn", 13 | "@typescript-eslint/semi": "warn", 14 | "eqeqeq": "error", 15 | "no-throw-literal": "error", 16 | "semi": "warn" 17 | }, 18 | "ignorePatterns": [ 19 | "out", 20 | "dist", 21 | "**/*.d.ts" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/publish_to_marketplace.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | - run: npm ci 17 | - name: Publish to Visual Studio Marketplace 18 | uses: HaaLeo/publish-vscode-extension@v2 19 | with: 20 | pat: ${{ secrets.VS_MARKETPLACE_TOKEN }} 21 | registryUrl: https://marketplace.visualstudio.com 22 | - name: Publish to Open VSX Registry 23 | uses: HaaLeo/publish-vscode-extension@v2 24 | id: publishToOpenVSX 25 | with: 26 | pat: ${{ secrets.OPEN_VSX_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode-test 2 | node_modules 3 | dist 4 | out 5 | **/__pycache__ 6 | *.vsix -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code. 6 | extension-pkg-allow-list= 7 | 8 | # A comma-separated list of package or module names from where C extensions may 9 | # be loaded. Extensions are loading into the active Python interpreter and may 10 | # run arbitrary code. (This is an alternative name to extension-pkg-allow-list 11 | # for backward compatibility.) 12 | extension-pkg-whitelist= 13 | 14 | # Return non-zero exit code if any of these messages/categories are detected, 15 | # even if score is above --fail-under value. Syntax same as enable. Messages 16 | # specified are enabled, while categories only check already-enabled messages. 17 | fail-on= 18 | 19 | # Specify a score threshold to be exceeded before program exits with error. 20 | fail-under=10.0 21 | 22 | # Files or directories to be skipped. They should be base names, not paths. 23 | ignore=CVS 24 | 25 | # Add files or directories matching the regex patterns to the ignore-list. The 26 | # regex matches against paths and can be in Posix or Windows format. 27 | ignore-paths= 28 | 29 | # Files or directories matching the regex patterns are skipped. The regex 30 | # matches against base names, not paths. The default value ignores emacs file 31 | # locks 32 | ignore-patterns=^\.# 33 | 34 | # Python code to execute, usually for sys.path manipulation such as 35 | # pygtk.require(). 36 | #init-hook= 37 | 38 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 39 | # number of processors available to use. 40 | jobs=1 41 | 42 | # Control the amount of potential inferred values when inferring a single 43 | # object. This can help the performance when dealing with large functions or 44 | # complex, nested conditions. 45 | limit-inference-results=100 46 | 47 | # List of plugins (as comma separated values of python module names) to load, 48 | # usually to register additional checkers. 49 | load-plugins= 50 | 51 | # Pickle collected data for later comparisons. 52 | persistent=yes 53 | 54 | # Minimum Python version to use for version dependent checks. Will default to 55 | # the version used to run pylint. 56 | py-version=3.7 57 | 58 | # Discover python modules and packages in the file system subtree. 59 | recursive=no 60 | 61 | # When enabled, pylint would attempt to guess common misconfiguration and emit 62 | # user-friendly hints instead of false-positive error messages. 63 | suggestion-mode=yes 64 | 65 | # Allow loading of arbitrary C extensions. Extensions are imported into the 66 | # active Python interpreter and may run arbitrary code. 67 | unsafe-load-any-extension=no 68 | 69 | 70 | [MESSAGES CONTROL] 71 | 72 | # Only show warnings with the listed confidence levels. Leave empty to show 73 | # all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, 74 | # UNDEFINED. 75 | confidence= 76 | 77 | # Disable the message, report, category or checker with the given id(s). You 78 | # can either give multiple identifiers separated by comma (,) or put this 79 | # option multiple times (only on the command line, not in the configuration 80 | # file where it should appear only once). You can also use "--disable=all" to 81 | # disable everything first and then re-enable specific checks. For example, if 82 | # you want to run only the similarities checker, you can use "--disable=all 83 | # --enable=similarities". If you want to run only the classes checker, but have 84 | # no Warning level messages displayed, use "--disable=all --enable=classes 85 | # --disable=W". 86 | disable=raw-checker-failed, 87 | bad-inline-option, 88 | locally-disabled, 89 | file-ignored, 90 | suppressed-message, 91 | useless-suppression, 92 | deprecated-pragma, 93 | use-symbolic-message-instead, 94 | 95 | broad-exception-caught, 96 | missing-function-docstring, 97 | missing-function-docstring, 98 | exec-used, 99 | line-too-long, 100 | unused-variable, 101 | too-many-arguments, 102 | missing-class-docstring, 103 | too-few-public-methods, 104 | import-outside-toplevel, 105 | unused-import 106 | 107 | # Enable the message, report, category or checker with the given id(s). You can 108 | # either give multiple identifier separated by comma (,) or put this option 109 | # multiple time (only on the command line, not in the configuration file where 110 | # it should appear only once). See also the "--disable" option for examples. 111 | enable=c-extension-no-member 112 | 113 | 114 | [REPORTS] 115 | 116 | # Python expression which should return a score less than or equal to 10. You 117 | # have access to the variables 'fatal', 'error', 'warning', 'refactor', 118 | # 'convention', and 'info' which contain the number of messages in each 119 | # category, as well as 'statement' which is the total number of statements 120 | # analyzed. This score is used by the global evaluation report (RP0004). 121 | evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) 122 | 123 | # Template used to display messages. This is a python new-style format string 124 | # used to format the message information. See doc for all details. 125 | #msg-template= 126 | 127 | # Set the output format. Available formats are text, parseable, colorized, json 128 | # and msvs (visual studio). You can also give a reporter class, e.g. 129 | # mypackage.mymodule.MyReporterClass. 130 | output-format=text 131 | 132 | # Tells whether to display a full report or only the messages. 133 | reports=no 134 | 135 | # Activate the evaluation score. 136 | score=yes 137 | 138 | 139 | [REFACTORING] 140 | 141 | # Maximum number of nested blocks for function / method body 142 | max-nested-blocks=5 143 | 144 | # Complete name of functions that never returns. When checking for 145 | # inconsistent-return-statements if a never returning function is called then 146 | # it will be considered as an explicit return statement and no message will be 147 | # printed. 148 | never-returning-functions=sys.exit,argparse.parse_error 149 | 150 | 151 | [BASIC] 152 | 153 | # Naming style matching correct argument names. 154 | argument-naming-style=snake_case 155 | 156 | # Regular expression matching correct argument names. Overrides argument- 157 | # naming-style. If left empty, argument names will be checked with the set 158 | # naming style. 159 | #argument-rgx= 160 | 161 | # Naming style matching correct attribute names. 162 | attr-naming-style=snake_case 163 | 164 | # Regular expression matching correct attribute names. Overrides attr-naming- 165 | # style. If left empty, attribute names will be checked with the set naming 166 | # style. 167 | #attr-rgx= 168 | 169 | # Bad variable names which should always be refused, separated by a comma. 170 | bad-names=foo, 171 | bar, 172 | baz, 173 | toto, 174 | tutu, 175 | tata 176 | 177 | # Bad variable names regexes, separated by a comma. If names match any regex, 178 | # they will always be refused 179 | bad-names-rgxs= 180 | 181 | # Naming style matching correct class attribute names. 182 | class-attribute-naming-style=any 183 | 184 | # Regular expression matching correct class attribute names. Overrides class- 185 | # attribute-naming-style. If left empty, class attribute names will be checked 186 | # with the set naming style. 187 | #class-attribute-rgx= 188 | 189 | # Naming style matching correct class constant names. 190 | class-const-naming-style=UPPER_CASE 191 | 192 | # Regular expression matching correct class constant names. Overrides class- 193 | # const-naming-style. If left empty, class constant names will be checked with 194 | # the set naming style. 195 | #class-const-rgx= 196 | 197 | # Naming style matching correct class names. 198 | class-naming-style=PascalCase 199 | 200 | # Regular expression matching correct class names. Overrides class-naming- 201 | # style. If left empty, class names will be checked with the set naming style. 202 | #class-rgx= 203 | 204 | # Naming style matching correct constant names. 205 | const-naming-style=UPPER_CASE 206 | 207 | # Regular expression matching correct constant names. Overrides const-naming- 208 | # style. If left empty, constant names will be checked with the set naming 209 | # style. 210 | #const-rgx= 211 | 212 | # Minimum line length for functions/classes that require docstrings, shorter 213 | # ones are exempt. 214 | docstring-min-length=-1 215 | 216 | # Naming style matching correct function names. 217 | function-naming-style=snake_case 218 | 219 | # Regular expression matching correct function names. Overrides function- 220 | # naming-style. If left empty, function names will be checked with the set 221 | # naming style. 222 | #function-rgx= 223 | 224 | # Good variable names which should always be accepted, separated by a comma. 225 | good-names=i, 226 | j, 227 | k, 228 | ex, 229 | Run, 230 | _ 231 | 232 | # Good variable names regexes, separated by a comma. If names match any regex, 233 | # they will always be accepted 234 | good-names-rgxs=^.$ 235 | 236 | # Include a hint for the correct naming format with invalid-name. 237 | include-naming-hint=no 238 | 239 | # Naming style matching correct inline iteration names. 240 | inlinevar-naming-style=any 241 | 242 | # Regular expression matching correct inline iteration names. Overrides 243 | # inlinevar-naming-style. If left empty, inline iteration names will be checked 244 | # with the set naming style. 245 | #inlinevar-rgx= 246 | 247 | # Naming style matching correct method names. 248 | method-naming-style=snake_case 249 | 250 | # Regular expression matching correct method names. Overrides method-naming- 251 | # style. If left empty, method names will be checked with the set naming style. 252 | #method-rgx= 253 | 254 | # Naming style matching correct module names. 255 | module-naming-style=snake_case 256 | 257 | # Regular expression matching correct module names. Overrides module-naming- 258 | # style. If left empty, module names will be checked with the set naming style. 259 | #module-rgx= 260 | 261 | # Colon-delimited sets of names that determine each other's naming style when 262 | # the name regexes allow several styles. 263 | name-group= 264 | 265 | # Regular expression which should only match function or class names that do 266 | # not require a docstring. 267 | no-docstring-rgx=^_ 268 | 269 | # List of decorators that produce properties, such as abc.abstractproperty. Add 270 | # to this list to register other decorators that produce valid properties. 271 | # These decorators are taken in consideration only for invalid-name. 272 | property-classes=abc.abstractproperty 273 | 274 | # Regular expression matching correct type variable names. If left empty, type 275 | # variable names will be checked with the set naming style. 276 | #typevar-rgx= 277 | 278 | # Naming style matching correct variable names. 279 | variable-naming-style=snake_case 280 | 281 | # Regular expression matching correct variable names. Overrides variable- 282 | # naming-style. If left empty, variable names will be checked with the set 283 | # naming style. 284 | #variable-rgx= 285 | 286 | 287 | [FORMAT] 288 | 289 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 290 | expected-line-ending-format= 291 | 292 | # Regexp for a line that is allowed to be longer than the limit. 293 | ignore-long-lines=^\s*(# )??$ 294 | 295 | # Number of spaces of indent required inside a hanging or continued line. 296 | indent-after-paren=4 297 | 298 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 299 | # tab). 300 | indent-string=' ' 301 | 302 | # Maximum number of characters on a single line. 303 | max-line-length=100 304 | 305 | # Maximum number of lines in a module. 306 | max-module-lines=1000 307 | 308 | # Allow the body of a class to be on the same line as the declaration if body 309 | # contains single statement. 310 | single-line-class-stmt=no 311 | 312 | # Allow the body of an if to be on the same line as the test if there is no 313 | # else. 314 | single-line-if-stmt=no 315 | 316 | 317 | [LOGGING] 318 | 319 | # The type of string formatting that logging methods do. `old` means using % 320 | # formatting, `new` is for `{}` formatting. 321 | logging-format-style=old 322 | 323 | # Logging modules to check that the string format arguments are in logging 324 | # function parameter format. 325 | logging-modules=logging 326 | 327 | 328 | [MISCELLANEOUS] 329 | 330 | # List of note tags to take in consideration, separated by a comma. 331 | notes=FIXME, 332 | XXX, 333 | TODO 334 | 335 | # Regular expression of note tags to take in consideration. 336 | #notes-rgx= 337 | 338 | 339 | [SIMILARITIES] 340 | 341 | # Comments are removed from the similarity computation 342 | ignore-comments=yes 343 | 344 | # Docstrings are removed from the similarity computation 345 | ignore-docstrings=yes 346 | 347 | # Imports are removed from the similarity computation 348 | ignore-imports=no 349 | 350 | # Signatures are removed from the similarity computation 351 | ignore-signatures=no 352 | 353 | # Minimum lines number of a similarity. 354 | min-similarity-lines=4 355 | 356 | 357 | [SPELLING] 358 | 359 | # Limits count of emitted suggestions for spelling mistakes. 360 | max-spelling-suggestions=4 361 | 362 | # Spelling dictionary name. Available dictionaries: none. To make it work, 363 | # install the 'python-enchant' package. 364 | spelling-dict= 365 | 366 | # List of comma separated words that should be considered directives if they 367 | # appear and the beginning of a comment and should not be checked. 368 | spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: 369 | 370 | # List of comma separated words that should not be checked. 371 | spelling-ignore-words= 372 | 373 | # A path to a file that contains the private dictionary; one word per line. 374 | spelling-private-dict-file= 375 | 376 | # Tells whether to store unknown words to the private dictionary (see the 377 | # --spelling-private-dict-file option) instead of raising a message. 378 | spelling-store-unknown-words=no 379 | 380 | 381 | [STRING] 382 | 383 | # This flag controls whether inconsistent-quotes generates a warning when the 384 | # character used as a quote delimiter is used inconsistently within a module. 385 | check-quote-consistency=no 386 | 387 | # This flag controls whether the implicit-str-concat should generate a warning 388 | # on implicit string concatenation in sequences defined over several lines. 389 | check-str-concat-over-line-jumps=no 390 | 391 | 392 | [TYPECHECK] 393 | 394 | # List of decorators that produce context managers, such as 395 | # contextlib.contextmanager. Add to this list to register other decorators that 396 | # produce valid context managers. 397 | contextmanager-decorators=contextlib.contextmanager 398 | 399 | # List of members which are set dynamically and missed by pylint inference 400 | # system, and so shouldn't trigger E1101 when accessed. Python regular 401 | # expressions are accepted. 402 | generated-members= 403 | 404 | # Tells whether missing members accessed in mixin class should be ignored. A 405 | # class is considered mixin if its name matches the mixin-class-rgx option. 406 | ignore-mixin-members=yes 407 | 408 | # Tells whether to warn about missing members when the owner of the attribute 409 | # is inferred to be None. 410 | ignore-none=yes 411 | 412 | # This flag controls whether pylint should warn about no-member and similar 413 | # checks whenever an opaque object is returned when inferring. The inference 414 | # can return multiple potential results while evaluating a Python object, but 415 | # some branches might not be evaluated, which results in partial inference. In 416 | # that case, it might be useful to still emit no-member and other checks for 417 | # the rest of the inferred objects. 418 | ignore-on-opaque-inference=yes 419 | 420 | # List of class names for which member attributes should not be checked (useful 421 | # for classes with dynamically set attributes). This supports the use of 422 | # qualified names. 423 | ignored-classes=optparse.Values,thread._local,_thread._local 424 | 425 | # List of module names for which member attributes should not be checked 426 | # (useful for modules/projects where namespaces are manipulated during runtime 427 | # and thus existing member attributes cannot be deduced by static analysis). It 428 | # supports qualified module names, as well as Unix pattern matching. 429 | ignored-modules=unreal 430 | 431 | # Show a hint with possible names when a member name was not found. The aspect 432 | # of finding the hint is based on edit distance. 433 | missing-member-hint=yes 434 | 435 | # The minimum edit distance a name should have in order to be considered a 436 | # similar match for a missing member name. 437 | missing-member-hint-distance=1 438 | 439 | # The total number of similar names that should be taken in consideration when 440 | # showing a hint for a missing member. 441 | missing-member-max-choices=1 442 | 443 | # Regex pattern to define which classes are considered mixins ignore-mixin- 444 | # members is set to 'yes' 445 | mixin-class-rgx=.*[Mm]ixin 446 | 447 | # List of decorators that change the signature of a decorated function. 448 | signature-mutators= 449 | 450 | 451 | [VARIABLES] 452 | 453 | # List of additional names supposed to be defined in builtins. Remember that 454 | # you should avoid defining new builtins when possible. 455 | additional-builtins= 456 | 457 | # Tells whether unused global variables should be treated as a violation. 458 | allow-global-unused-variables=yes 459 | 460 | # List of names allowed to shadow builtins 461 | allowed-redefined-builtins= 462 | 463 | # List of strings which can identify a callback function by name. A callback 464 | # name must start or end with one of those strings. 465 | callbacks=cb_, 466 | _cb 467 | 468 | # A regular expression matching the name of dummy variables (i.e. expected to 469 | # not be used). 470 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 471 | 472 | # Argument names that match this expression will be ignored. Default to name 473 | # with leading underscore. 474 | ignored-argument-names=_.*|^ignored_|^unused_ 475 | 476 | # Tells whether we should check for unused import in __init__ files. 477 | init-import=no 478 | 479 | # List of qualified module names which can have objects that can redefine 480 | # builtins. 481 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 482 | 483 | 484 | [CLASSES] 485 | 486 | # Warn about protected attribute access inside special methods 487 | check-protected-access-in-special-methods=no 488 | 489 | # List of method names used to declare (i.e. assign) instance attributes. 490 | defining-attr-methods=__init__, 491 | __new__, 492 | setUp, 493 | __post_init__ 494 | 495 | # List of member names, which should be excluded from the protected access 496 | # warning. 497 | exclude-protected=_asdict, 498 | _fields, 499 | _replace, 500 | _source, 501 | _make 502 | 503 | # List of valid names for the first argument in a class method. 504 | valid-classmethod-first-arg=cls 505 | 506 | # List of valid names for the first argument in a metaclass class method. 507 | valid-metaclass-classmethod-first-arg=cls 508 | 509 | 510 | [DESIGN] 511 | 512 | # List of regular expressions of class ancestor names to ignore when counting 513 | # public methods (see R0903) 514 | exclude-too-few-public-methods= 515 | 516 | # List of qualified class names to ignore when counting class parents (see 517 | # R0901) 518 | ignored-parents= 519 | 520 | # Maximum number of arguments for function / method. 521 | max-args=5 522 | 523 | # Maximum number of attributes for a class (see R0902). 524 | max-attributes=7 525 | 526 | # Maximum number of boolean expressions in an if statement (see R0916). 527 | max-bool-expr=5 528 | 529 | # Maximum number of branch for function / method body. 530 | max-branches=12 531 | 532 | # Maximum number of locals for function / method body. 533 | max-locals=15 534 | 535 | # Maximum number of parents for a class (see R0901). 536 | max-parents=7 537 | 538 | # Maximum number of public methods for a class (see R0904). 539 | max-public-methods=20 540 | 541 | # Maximum number of return / yield for function / method body. 542 | max-returns=6 543 | 544 | # Maximum number of statements in function / method body. 545 | max-statements=50 546 | 547 | # Minimum number of public methods for a class (see R0903). 548 | min-public-methods=2 549 | 550 | 551 | [IMPORTS] 552 | 553 | # List of modules that can be imported at any level, not just the top level 554 | # one. 555 | allow-any-import-level= 556 | 557 | # Allow wildcard imports from modules that define __all__. 558 | allow-wildcard-with-all=no 559 | 560 | # Analyse import fallback blocks. This can be used to support both Python 2 and 561 | # 3 compatible code, which means that the block might have code that exists 562 | # only in one or another interpreter, leading to false positives when analysed. 563 | analyse-fallback-blocks=no 564 | 565 | # Deprecated modules which should not be used, separated by a comma. 566 | deprecated-modules= 567 | 568 | # Output a graph (.gv or any supported image format) of external dependencies 569 | # to the given file (report RP0402 must not be disabled). 570 | ext-import-graph= 571 | 572 | # Output a graph (.gv or any supported image format) of all (i.e. internal and 573 | # external) dependencies to the given file (report RP0402 must not be 574 | # disabled). 575 | import-graph= 576 | 577 | # Output a graph (.gv or any supported image format) of internal dependencies 578 | # to the given file (report RP0402 must not be disabled). 579 | int-import-graph= 580 | 581 | # Force import order to recognize a module as part of the standard 582 | # compatibility libraries. 583 | known-standard-library= 584 | 585 | # Force import order to recognize a module as part of a third party library. 586 | known-third-party=enchant 587 | 588 | # Couples of modules and preferred modules, separated by a comma. 589 | preferred-modules= 590 | 591 | 592 | [EXCEPTIONS] 593 | 594 | # Exceptions that will emit a warning when being caught. Defaults to 595 | # "BaseException, Exception". 596 | overgeneral-exceptions=BaseException, 597 | Exception 598 | -------------------------------------------------------------------------------- /.vscode-test.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@vscode/test-cli'; 2 | 3 | export default defineConfig({ 4 | files: 'out/test/**/*.test.js', 5 | workspaceFolder: "./test/fixture", 6 | installExtensions: [ 7 | "ms-python.python" 8 | ], 9 | }); -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint", 6 | "connor4312.esbuild-problem-matchers" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": [ 13 | "${workspaceFolder}/test/debug-workspace", 14 | "--extensionDevelopmentPath=${workspaceFolder}" 15 | ], 16 | "outFiles": [ 17 | "${workspaceFolder}/dist/**/*.js" 18 | ], 19 | "preLaunchTask": "${defaultBuildTask}" 20 | }, 21 | { 22 | "name": "Extension Tests", 23 | "type": "extensionHost", 24 | "request": "launch", 25 | "args": [ 26 | "--extensionDevelopmentPath=${workspaceFolder}", 27 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 28 | ], 29 | "outFiles": [ 30 | "${workspaceFolder}/out/test/**/*.js" 31 | ], 32 | "preLaunchTask": "${defaultBuildTask}" 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | ".vscode-test": true, 4 | "dist": true, 5 | "node_modules": true, 6 | "webview-ui": true, // This folder is added as a seperate workspace folder in vscode-unreal-python.code-workspace 7 | "LICENSE": true, 8 | "*.code-workspace": true, 9 | "*.vsix": true, 10 | "**/__pycache__": true, 11 | "**/**.pyc": true 12 | }, 13 | 14 | "search.exclude": { 15 | "out": true, 16 | "test": true, 17 | "media": true, 18 | }, 19 | 20 | "python.analysis.typeCheckingMode": "standard", 21 | 22 | "autopep8.args": [ 23 | "--ignore=E501" 24 | ], 25 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$esbuild-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .github 2 | **/.gitignore 3 | 4 | *.code-workspace 5 | **/.vscode/** 6 | **/**.vsix 7 | 8 | .pylintrc 9 | **/__pycache__ 10 | **/**.pyc 11 | 12 | **/.eslintrc.json 13 | **/tsconfig.json 14 | 15 | **/*.map 16 | **/*.ts 17 | 18 | media/demo/** 19 | media/icon-font/*.svg 20 | src/** 21 | out/** 22 | test/** 23 | .vscode-test.mjs 24 | 25 | CONTRIBUTING.md 26 | 27 | node_modules/ 28 | !node_modules/**/[lL][iI][cC][eE][nN][sS][eE]* 29 | 30 | webview-ui/src/** 31 | webview-ui/public/** 32 | webview-ui/scripts/** 33 | webview-ui/**/*.bat 34 | webview-ui/index.html 35 | webview-ui/README.md 36 | webview-ui/package.json 37 | webview-ui/package-lock.json 38 | webview-ui/node_modules/** -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [1.8.1] - 2025-06-03 4 | - Fixed an issue where the internal `vsc_eval` function could be undefined, preventing the extension from working correctly [#48](https://github.com/nils-soderman/vscode-unreal-python/issues/48) 5 | 6 | ## [1.8.0] - 2025-05-04 7 | 8 | - Added command `Unreal Python: Reload Modules` that reloads all modules within the current workspace folder(s) 9 | - Attaching to Unreal Engine now uses the _"debugpy"_ configuration type, instead of _"python"_ which is deprecated 10 | 11 | ## [1.7.1] - 2025-04-06 12 | 13 | - Fixed executing code not working with Python versions below 3.8 [#46](https://github.com/nils-soderman/vscode-unreal-python/issues/46) 14 | 15 | ## [1.7.0] - 2025-03-16 16 | 17 | - Printing the last expression is now the default behavior, and setting `ue-python.experimental.printLastExpression` has been removed [#38](https://github.com/nils-soderman/vscode-unreal-python/issues/38) 18 | - The 'UE Python Log' output channel is now of type `LogOutputChannel`, leading to improved readability 19 | - Suppress deprecation warnings when opening the documentation 20 | - Fixed user `SyntaxError` not formatted correctly when executing unsaved files 21 | 22 | 23 | ## [1.6.3] - 2025-01-15 24 | 25 | - Fixed user exceptions not being formatted correctly 26 | 27 | ## [1.6.2] - 2025-01-11 28 | 29 | - Fixed formatting user exceptions not working in Python versions below 3.11 30 | 31 | 32 | ## [1.6.1] - 2025-01-06 33 | 34 | - Fixed some documentation pages not working due to data not being parsed correctly 35 | 36 | ## [1.6.0] - 2025-01-06 37 | 38 | - Code is no longer parsed twice when using `ue-python.experimental.printLastExpression` 39 | - Changed how the extension communicates with Unreal, removing some unwanted prints to stdout 40 | - Improved how user tracebacks are handled 41 | - Documentation data is no longer written to a file, all data is now sent over the socket [#40](https://github.com/nils-soderman/vscode-unreal-python/issues/40) 42 | 43 | ## [1.5.0] - 2024-11-17 44 | 45 | - Added experimental setting `ue-python.experimental.printLastExpression` that wraps the last expression in a `print()` statement when executing code, mimicking the behavior of the Python REPL [#38](https://github.com/nils-soderman/vscode-unreal-python/issues/38) 46 | 47 | ## [1.4.1] - 2024-08-25 48 | 49 | - Fixed stepping over indented code not always working correctly 50 | - Show an error message if [ms-python.vscode-pylance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance) is not installed and _"Setup Code Completion"_ is run 51 | - Fixed error if trying to attach to the same Unreal Engine instance multiple times 52 | - Use VS Code's API for opening external URLs 53 | 54 | ## [1.4.0] - 2024-05-26 55 | 56 | - Added Support for relative import _(if the script is within sys.path's scope)_ 57 | - The VS Code workspace folders are now added to the Python path when connecting to Unreal. Set `ue-python.environment.addWorkspaceToPath` to `false` to disable this behaviour [#28](https://github.com/nils-soderman/vscode-unreal-python/issues/28) 58 | - `ue-python.setupCodeCompletion` will now correctly insert the path in the correct setting scope _(user/workspace/folder)_ 59 | - Fixed traceback messages potentially having the wrong line number in the clickable URL 60 | 61 | ## [1.3.0] - 2024-04-13 62 | 63 | - **Breaking change!** Renamed setting `ue-python.debug.port` to `ue-python.attach.port`. `ue-python.debug.port` has been deprecated and will be removed in a future release _(Contributed by [@F-Dudley](https://github.com/F-Dudley))_ 64 | - Added setting `ue-python.attach.justMyCode` to allow debugging of standard library modules. [#23](https://github.com/nils-soderman/vscode-unreal-python/issues/23) _(Contributed by [@F-Dudley](https://github.com/F-Dudley))_ 65 | 66 | 67 | ## [1.2.1] - 2024-02-24 68 | 69 | - Added new output channel _"UE Python Log"_ where extension logs are written to. 70 | - Fixed folder settings being ignored when a workspace is opened. 71 | 72 | 73 | ## [1.2.0] - 2024-01-29 74 | 75 | ## Breaking changes: 76 | - `ue-python.remote.multicastBindAddress` now defaults to "127.0.0.1" to match the new default value in Unreal Engine 5.3 77 | - Renamed config `ue-python.remote.multicastBindAdress` to `ue-python.remote.multicastBindAddress` to fix spelling error. 78 | 79 | ### Changes: 80 | - Fixed config `ue-python.remote.multicastBindAddress` not read correctly, always defaulting to "0.0.0.0". 81 | - Fixed failed connection blocking new connections until user had interacted with the error message box. 82 | 83 | 84 | ## [1.1.1] - 2024-01-06 85 | 86 | - Added regex validation for `ue-python.remote` settings that takes strings. 87 | - Changes to the `ue-python.remote` settings no longer requires a restart of VS Code to take effect [#20](https://github.com/nils-soderman/vscode-unreal-python/issues/20) 88 | 89 | 90 | ## [1.1.0] - 2023-09-30 91 | 92 | - Added setting `ue-python.execute.unattended` that allows the user to execute code with the `-unattended` flag [#14](https://github.com/nils-soderman/vscode-unreal-python/issues/14) 93 | - Code is no longer executed with the `-unattended` flag by default [#14](https://github.com/nils-soderman/vscode-unreal-python/issues/14) 94 | - Fixed functions/methods not displaying properly in the documentation 95 | - Removed setting `ue-python.execute.enableShortcut` 96 | 97 | 98 | ## [1.0.0] - 2023-09-09 99 | 100 | - Added command `ue-python.selectInstance` that allows the user to select which Unreal Engine instance to connect to. [#3](https://github.com/nils-soderman/vscode-unreal-python/issues/3) 101 | - Added status bar item that shows the currently connected Unreal Engine instance 102 | - Added success/error messages when setting up code completion 103 | - `ue-python.remote.timeout` config is now in milliseconds instead of seconds. To be consistent with other VS Code timeout configs 104 | - Output is no longer written to a file, it's instead transferred through the `unreal-remote-exectution` socket 105 | - [unreal-remote-exectution](https://www.npmjs.com/package/unreal-remote-execution) is now a standalone NodeJS package 106 | - Catch any errors that occurs during the installation of debugpy and log them to the output 107 | - The ReadMe now uses WebP animations instead of GIFs 108 | - esbuild is now used for building the extension. Resulting in a smaller extension size and faster activation time 109 | 110 | 111 | ## [0.2.3] - 2023-06-21 112 | 113 | - Fixed unreal functions `log`, `log_warning` & `log_error` not showing up in the VS Code output. Issue [#8](https://github.com/nils-soderman/vscode-unreal-python/issues/8) 114 | - Fixed output not showing up if it's too large. Issue [#8](https://github.com/nils-soderman/vscode-unreal-python/issues/8) 115 | 116 | 117 | ## [0.2.2] - 2023-03-26 118 | 119 | - Documentation now caches the open states of the dropdowns 120 | - Improved filtering for the Documentation 121 | - Having a word selected will auto insert it into the searchbar when opening the documentation 122 | 123 | - Fixed bug where selecting a single indented line of code would fail to execute. 124 | - Documentation now remembers the applied filter when going back to the index page 125 | - Fixed broken UI styling for the documentation 126 | - Fixed not being able to open functions in the documentation 127 | 128 | 129 | ## [0.2.1] - 2023-03-13 130 | 131 | - Added command "Unreal Python: Open Documentation" _(`ue-python.openDocumentation`)_ that opens the UE python documentation in a new tab. 132 | - Removed documentation panel from the sidebar. 133 | - Fixed Output not showing up in Unreal Engine's "Output Log" if not attached. 134 | - Use UTF-8 to decode the files in Python 135 | 136 | 137 | ## [0.2.0] - 2023-02-18 138 | 139 | - Added documentation sidebar 140 | - Updated README.md to clarify that commands can be executed through the command palette. Closes [#2](https://github.com/nils-soderman/vscode-unreal-python/issues/2) 141 | - Fixed settings not read correctly from the folder settings 142 | - Fixed bug that would cause `Setup Code Completion` to continue asking the user to enable Developer Mode even if it was already enabled 143 | 144 | 145 | ## [0.1.2] - 2022-10-17 146 | 147 | - Added configuration `ue-python.strictPort` that prevents this extension from automatically finding a free port, and will strictly only use the ports assigned in the config. 148 | - Support for multiple VS Code instances connecting to the same Unreal Engine instance. 149 | 150 | ## [0.1.1] - 2022-10-09 151 | 152 | - Added command `ue-python.setupCodeCompletion` that adds the '\/Intermediate/PythonStub/' path to `python.analysis.extraPaths`. 153 | 154 | ## [0.1.0] - 2022-10-06 155 | 156 | - Added command `ue-python.attach` that attaches VS Code to Unreal Engine. 157 | - Added configuration `ue-python.debug.port` to set which port to use for the python debugpy server. 158 | - Removed the 'Settings' section from ReadMe.md 159 | 160 | 161 | ## [0.0.2] - 2022-10-01 162 | 163 | - Added configuration `ue-python.execute.name` that set's the python `__name__` variable while executing code, defaults to "\_\_main\_\_". 164 | - Added configuration `ue-python.execute.enableShortcut` which can be used to disable the `ue-python.execute` shortcut in specific workspaces 165 | - Added a help button if it fails to connect with Unreal Engine, that will bring the user to a troubleshooting webpage 166 | - The command `ue-python.execute` is now only enabled when a Python file is open 167 | - Updated default value of `ue-python.remote.timeout` to be 3 seconds. 168 | 169 | 170 | ## [0.0.1] - 2022-09-25 171 | 172 | - Initial pre-release 173 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome! 4 | 5 | Refer to the offical documentation for information about extension development: https://code.visualstudio.com/api 6 | 7 | 8 | ## Requirements 9 | This extension uses [esbuild](https://esbuild.github.io/), 10 | therefore you will need to install 11 | [connor4312.esbuild-problem-matchers](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers) to be able to debug the extension. 12 | 13 | 14 |
15 | 16 | 17 | ## Certificate of Origin 18 | 19 | *Developer's Certificate of Origin 1.1* 20 | 21 | By making a contribution to this project, I certify that: 22 | 23 | > 1. The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or 24 | > 1. The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source license and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or 25 | > 1. The contribution was provided directly to me by some other person who certified (1), (2) or (3) and I have not modified it. 26 | > 1. I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved. 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Nils Söderman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unreal Engine Python (Visual Studio Code) 2 | 3 | Editor features to assist when writing Python code for Unreal Engine. 4 | 5 |
6 | 7 | ## Features 8 | 9 | ### Execute Code 10 | 11 | Run code in Unreal Engine directly from within the editor: 12 | 13 | ![execute code in unreal demo](https://github.com/nils-soderman/vscode-unreal-python/blob/main/media/demo/demo-exec.webp?raw=true) 14 | 15 | Command: `Unreal Python: Execute`
16 | Keyboard Shortcut: Ctrl + Enter 17 | 18 | The selected text will be executed, or if nothing is selected the entire document will be executed. 19 | 20 |
21 | 22 | ### Setup Code Completion 23 | Setup code completion for the `unreal` module based on the current project. 24 | 25 | ![code completion demo](https://github.com/nils-soderman/vscode-unreal-python/blob/main/media/demo/demo-codecompletion.jpg?raw=true) 26 | 27 | Command: `Unreal Python: Setup code completion` 28 | 29 |
30 | 31 | ### Debugging 32 | Attach VS Code to Unreal Engine to debug your scripts, set breakpoints and step through the code. 33 | 34 | ![debug unreal python scripts demo](https://github.com/nils-soderman/vscode-unreal-python/blob/main/media/demo/demo-attach.webp?raw=true) 35 | 36 | Command: `Unreal Python: Attach` 37 | 38 |
39 | 40 | 41 | ### Documentation 42 | Browse the Unreal Engine Python documentation inside VS Code. This documentation is generated on the fly based on the currently opened Unreal Engine instance, therefore it will always be up to date & include any custom C++ functions/classes that you have exposed to Blueprint/Python. 43 | 44 | ![browse Unreal Engine's Python Documentation in VS Code demo](https://github.com/nils-soderman/vscode-unreal-python/blob/main/media/demo/demo-documentation.webp?raw=true) 45 | 46 | Command: `Unreal Python: Open Documentation` 47 | 48 |
49 | 50 | #### Notes: 51 | * Commands can be run from the command palette, `Show All Commands` _(Default shortcut: Ctrl + Shift + P)_ 52 | * Remote Execution must be enabled in Unreal Engine for this extension to work, [more details here](https://github.com/nils-soderman/vscode-unreal-python/wiki/Failed-to-connect-to-Unreal-Engine-%5BTroubleshooting%5D "Enable Unreal Engine Remote Execution - Wiki"). 53 | 54 |
55 | 56 | # Contact 57 | If you have any questions, suggestions or run into issues, please [open an issue](https://github.com/nils-soderman/vscode-unreal-python/issues "Open an issue on the GitHub repository") on the GitHub repository. 58 | 59 |
60 | 61 | _*This is a third-party extension and is not associated with Unreal Engine or Epic Games in any way._ 62 | -------------------------------------------------------------------------------- /media/demo/demo-attach.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nils-soderman/vscode-unreal-python/a5b5b0f49d02076b18d8da52bc0972830415d6d5/media/demo/demo-attach.gif -------------------------------------------------------------------------------- /media/demo/demo-attach.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nils-soderman/vscode-unreal-python/a5b5b0f49d02076b18d8da52bc0972830415d6d5/media/demo/demo-attach.webp -------------------------------------------------------------------------------- /media/demo/demo-codecompletion.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nils-soderman/vscode-unreal-python/a5b5b0f49d02076b18d8da52bc0972830415d6d5/media/demo/demo-codecompletion.jpg -------------------------------------------------------------------------------- /media/demo/demo-documentation.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nils-soderman/vscode-unreal-python/a5b5b0f49d02076b18d8da52bc0972830415d6d5/media/demo/demo-documentation.gif -------------------------------------------------------------------------------- /media/demo/demo-documentation.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nils-soderman/vscode-unreal-python/a5b5b0f49d02076b18d8da52bc0972830415d6d5/media/demo/demo-documentation.webp -------------------------------------------------------------------------------- /media/demo/demo-exec.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nils-soderman/vscode-unreal-python/a5b5b0f49d02076b18d8da52bc0972830415d6d5/media/demo/demo-exec.gif -------------------------------------------------------------------------------- /media/demo/demo-exec.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nils-soderman/vscode-unreal-python/a5b5b0f49d02076b18d8da52bc0972830415d6d5/media/demo/demo-exec.webp -------------------------------------------------------------------------------- /media/icon-font/E800.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 6 | 28 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /media/icon-font/ue-icon-font.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nils-soderman/vscode-unreal-python/a5b5b0f49d02076b18d8da52bc0972830415d6d5/media/icon-font/ue-icon-font.woff2 -------------------------------------------------------------------------------- /media/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nils-soderman/vscode-unreal-python/a5b5b0f49d02076b18d8da52bc0972830415d6d5/media/icon.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ue-python", 3 | "displayName": "Unreal Engine Python", 4 | "publisher": "NilsSoderman", 5 | "description": "Tools to assist when writing Python code for Unreal Engine", 6 | "version": "1.8.1", 7 | "categories": [ 8 | "Other", 9 | "Debuggers" 10 | ], 11 | "keywords": [ 12 | "python", 13 | "unreal", 14 | "engine", 15 | "game", 16 | "epic games", 17 | "debug", 18 | "ue5", 19 | "ue4" 20 | ], 21 | "main": "./dist/extension.js", 22 | "contributes": { 23 | "commands": [ 24 | { 25 | "category": "Unreal Python", 26 | "title": "Execute", 27 | "command": "ue-python.execute", 28 | "enablement": "editorLangId==python" 29 | }, 30 | { 31 | "category": "Unreal Python", 32 | "title": "Attach", 33 | "command": "ue-python.attach" 34 | }, 35 | { 36 | "category": "Unreal Python", 37 | "title": "Setup Code Completion", 38 | "command": "ue-python.setupCodeCompletion" 39 | }, 40 | { 41 | "category": "Unreal Python", 42 | "title": "Open Documentation", 43 | "command": "ue-python.openDocumentation" 44 | }, 45 | { 46 | "category": "Unreal Python", 47 | "title": "Select Instance", 48 | "command": "ue-python.selectInstance" 49 | }, 50 | { 51 | "category": "Unreal Python", 52 | "title": "Reload Modules", 53 | "command": "ue-python.reloadModules" 54 | } 55 | ], 56 | "keybindings": [ 57 | { 58 | "command": "ue-python.execute", 59 | "key": "ctrl+enter" 60 | } 61 | ], 62 | "configuration": [ 63 | { 64 | "properties": { 65 | "ue-python.strictPort": { 66 | "type": "boolean", 67 | "default": false, 68 | "markdownDescription": "Prevent this extension from automatically finding a free port if a port assigned in the config is busy.", 69 | "scope": "resource" 70 | }, 71 | "ue-python.environment.addWorkspaceToPath": { 72 | "type": "boolean", 73 | "default": true, 74 | "description": "Add the workspace folder(s) to the Python path when connecting to Unreal Engine", 75 | "scope": "resource" 76 | } 77 | } 78 | }, 79 | { 80 | "title": "Attach", 81 | "properties": { 82 | "ue-python.attach.port": { 83 | "type": "number", 84 | "default": 6868, 85 | "description": "Port to use for the debugpy server when attaching VS Code to Unreal", 86 | "scope": "resource" 87 | }, 88 | "ue-python.attach.justMyCode": { 89 | "type": "boolean", 90 | "default": true, 91 | "description": "Restricts debugging to user-written code only. Set to false to also enable debugging of standard library functions.", 92 | "scope": "resource" 93 | } 94 | } 95 | }, 96 | { 97 | "title": "Execute", 98 | "properties": { 99 | "ue-python.execute.showOutput": { 100 | "type": "boolean", 101 | "default": true, 102 | "description": "Display the output log when something is executed", 103 | "scope": "resource" 104 | }, 105 | "ue-python.execute.clearOutput": { 106 | "type": "boolean", 107 | "default": false, 108 | "description": "Clear output log each time somethings new is executed", 109 | "scope": "resource" 110 | }, 111 | "ue-python.execute.name": { 112 | "type": "string", 113 | "default": "__main__", 114 | "description": "The value for the Python variable `__name__` whenever executing code through VS Code", 115 | "scope": "resource" 116 | }, 117 | "ue-python.execute.unattended": { 118 | "type": "boolean", 119 | "default": false, 120 | "description": "Execute code with the `-unattended` flag, suppressing some UI that requires user input, such as message boxes", 121 | "scope": "resource" 122 | } 123 | } 124 | }, 125 | { 126 | "title": "Remote Execution Server", 127 | "properties": { 128 | "ue-python.remote.multicastGroupEndpoint": { 129 | "type": "string", 130 | "default": "239.0.0.1:6766", 131 | "markdownDescription": "The multicast group endpoint in the format of \"IP:PORT\" _(must match the \"Multicast Group Endpoint\" setting in the Python plugin)_", 132 | "scope": "resource", 133 | "order": 0, 134 | "pattern": "^[0-9.]+:[0-9]+$" 135 | }, 136 | "ue-python.remote.multicastBindAddress": { 137 | "type": "string", 138 | "default": "127.0.0.1", 139 | "markdownDescription": "The adapter address that the UDP multicast socket should bind to, or 0.0.0.0 to bind to all adapters _(must match the \"Multicast Bind Address\" setting in the Python plugin)_", 140 | "scope": "resource", 141 | "order": 1, 142 | "pattern": "^[0-9.]+$" 143 | }, 144 | "ue-python.remote.multicastTTL": { 145 | "type": "number", 146 | "default": 0, 147 | "markdownDescription": "Multicast TTL _(0 is limited to the local host, 1 is limited to the local subnet)_", 148 | "scope": "resource", 149 | "order": 2 150 | }, 151 | "ue-python.remote.commandEndpoint": { 152 | "type": "string", 153 | "default": "127.0.0.1:6776", 154 | "markdownDescription": "The endpoint for the TCP command connection hosted by this client _(that the remote client will connect to)_", 155 | "scope": "resource", 156 | "order": 3, 157 | "pattern": "^[0-9.]+:[0-9]+$" 158 | }, 159 | "ue-python.remote.timeout": { 160 | "type": "number", 161 | "default": 3000, 162 | "markdownDescription": "Timeout in milliseconds for an Unreal Engine instance to respond when establishing a connection", 163 | "scope": "resource", 164 | "order": 4 165 | } 166 | } 167 | } 168 | ], 169 | "icons": { 170 | "unreal-engine": { 171 | "description": "Unreal Engine Icon", 172 | "default": { 173 | "fontPath": "media/icon-font/ue-icon-font.woff2", 174 | "fontCharacter": "\\E800" 175 | } 176 | } 177 | } 178 | }, 179 | "galleryBanner": { 180 | "color": "#0f0f0f", 181 | "theme": "dark" 182 | }, 183 | "repository": { 184 | "type": "git", 185 | "url": "https://github.com/nils-soderman/vscode-unreal-python" 186 | }, 187 | "bugs": { 188 | "url": "https://github.com/nils-soderman/vscode-unreal-python/issues" 189 | }, 190 | "author": { 191 | "name": "Nils Söderman", 192 | "url": "https://nilssoderman.com" 193 | }, 194 | "qna": "marketplace", 195 | "license": "SEE LICENSE IN LICENSE", 196 | "icon": "media/icon.png", 197 | "scripts": { 198 | "postinstall": "cd ./webview-ui && npm install", 199 | "vscode:prepublish": "npm run build-webview && npm run esbuild-base -- --minify", 200 | "build-webview": "npm run build --prefix webview-ui", 201 | "esbuild-base": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node", 202 | "esbuild": "npm run esbuild-base -- --sourcemap", 203 | "watch": "npm run esbuild-base -- --sourcemap --watch", 204 | "lint": "eslint src --ext ts", 205 | "test": "tsc -p ./ && vscode-test", 206 | "watch-test": "tsc -p ./ --watch" 207 | }, 208 | "devDependencies": { 209 | "@types/mocha": "^10.0.10", 210 | "@types/node": "20.x", 211 | "@types/sinon": "^17.0.4", 212 | "@types/tcp-port-used": "^1.0.4", 213 | "@types/vscode": "^1.91.0", 214 | "@typescript-eslint/eslint-plugin": "^8.31.1", 215 | "@typescript-eslint/parser": "^8.31.1", 216 | "@vscode/test-cli": "^0.0.10", 217 | "@vscode/test-electron": "^2.5.2", 218 | "esbuild": "^0.25.3", 219 | "eslint": "^9.26.0", 220 | "mocha": "^11.2.2", 221 | "sinon": "^20.0.0", 222 | "typescript": "^5.8.3" 223 | }, 224 | "dependencies": { 225 | "tcp-port-used": "^1.0.2", 226 | "unreal-remote-execution": "^1.0.0" 227 | }, 228 | "engines": { 229 | "vscode": "^1.91.0" 230 | } 231 | } -------------------------------------------------------------------------------- /python/add_sys_path.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script to add the given paths to sys.path 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import sys 8 | import os 9 | 10 | def add_paths(paths: list[str]): 11 | for vsc_path in paths: 12 | normalized_path = os.path.normpath(vsc_path) 13 | # Make sure the path doesn't already exist in sys.path 14 | if not any(normalized_path.lower() == os.path.normpath(path).lower() for path in sys.path): 15 | sys.path.append(normalized_path) 16 | print(f'Added "{normalized_path}" to sys.path') 17 | -------------------------------------------------------------------------------- /python/attach.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | import unreal 6 | 7 | VSCODE_DEBUG_SERVER_ENV_VAR = "vscode_debugpy_server_port" 8 | 9 | 10 | def is_debugpy_installed() -> bool: 11 | """ 12 | Tries to import debugpy to check if it is installed. 13 | """ 14 | try: 15 | import debugpy 16 | return True 17 | except ModuleNotFoundError: 18 | return False 19 | 20 | 21 | def install_debugpy() -> bool: 22 | """ 23 | Installs debugpy using the current Unreal Python interpreter. 24 | """ 25 | import subprocess 26 | 27 | python_exe = unreal.get_interpreter_executable_path() 28 | if not python_exe: 29 | return False 30 | 31 | debugpy_install_args = [python_exe, "-m", "pip", "install", "debugpy"] 32 | 33 | env = os.environ.copy() 34 | env["PYTHONNOUSERSITE"] = "1" 35 | 36 | try: 37 | result = subprocess.run(debugpy_install_args, capture_output=True, check=True, text=True, env=env) 38 | unreal.log(result.stdout) 39 | unreal.log(result.stderr) 40 | except subprocess.CalledProcessError as e: 41 | unreal.log_error(f"Failed to install debugpy: {e}") 42 | unreal.log_error(e.stdout) 43 | unreal.log_error(e.stderr) 44 | except Exception as e: 45 | unreal.log_error(f"Failed to install debugpy: {e}") 46 | 47 | # Make sure the installation was successful by trying to import debugpy 48 | import debugpy 49 | 50 | return True 51 | 52 | 53 | def start_debugpy_server(port: int) -> bool: 54 | """ Starts a debugpy server on the specified port """ 55 | import debugpy 56 | 57 | python_exe = unreal.get_interpreter_executable_path() 58 | if not python_exe: 59 | return False 60 | 61 | debugpy.configure(python=python_exe) 62 | debugpy.listen(port) 63 | 64 | os.environ[VSCODE_DEBUG_SERVER_ENV_VAR] = str(port) 65 | 66 | return True 67 | 68 | 69 | def get_current_debugpy_port() -> int: 70 | """ Returns the current debugpy server port or -1 if it is not set """ 71 | return int(os.environ.get(VSCODE_DEBUG_SERVER_ENV_VAR, -1)) 72 | -------------------------------------------------------------------------------- /python/documentation/build_toc.py: -------------------------------------------------------------------------------- 1 | """ 2 | Print a JSON object with the table of contents for the Unreal Engine Python API. 3 | 4 | This script inspects the Unreal Engine Python API and generates a JSON object 5 | containing the table of contents, including classes, methods, properties, etc. 6 | """ 7 | from __future__ import annotations 8 | 9 | import warnings 10 | import inspect 11 | import types 12 | import json 13 | 14 | import unreal 15 | 16 | 17 | def issubclass_strict(__cls: type, __class_or_tuple): 18 | if not issubclass(__cls, __class_or_tuple): 19 | return False 20 | 21 | if isinstance(__class_or_tuple, tuple): 22 | return __cls not in __class_or_tuple 23 | 24 | return __cls is not __class_or_tuple 25 | 26 | 27 | class UnrealClassRepresentation: 28 | """ 29 | Each class in the unreal API will be represented by an instance of this class. (e.g. Enums, Structs, Classes) 30 | This class contains all methods, properties, constants, etc. of the class it represents. 31 | """ 32 | def __init__(self, name: str, cls): 33 | self.name = name 34 | self.cls = cls 35 | 36 | self.methods: list[tuple[str, types.MethodDescriptorType]] = [] 37 | self.classmethods: list[tuple[str, types.BuiltinFunctionType]] = [] 38 | self.properties: list[tuple[str, unreal.EnumBase | unreal.StructBase]] = [] 39 | self.constants: list[tuple[str, int]] = [] 40 | 41 | self.load_members() 42 | 43 | def load_members(self): 44 | for name, member in inspect.getmembers(self.cls): 45 | # ignore private methods / properties 46 | if name.startswith("_"): 47 | continue 48 | 49 | # ignore inherited methods / properties 50 | if name not in self.cls.__dict__: 51 | continue 52 | 53 | if inspect.ismethoddescriptor(member): 54 | self.methods.append((name, member)) 55 | elif inspect.isgetsetdescriptor(member): 56 | self.properties.append((name, member)) 57 | elif issubclass(type(member), unreal.EnumBase): 58 | self.properties.append((name, member)) 59 | elif issubclass(type(member), unreal.StructBase): 60 | self.properties.append((name, member)) 61 | elif inspect.isbuiltin(member): 62 | self.classmethods.append((name, member)) 63 | elif inspect.ismemberdescriptor(member): 64 | # TODO: this might be incorrect 65 | self.properties.append((name, member)) 66 | elif isinstance(member, int): 67 | self.constants.append((name, member)) 68 | # else: 69 | # print(f"{name}: {member} -> {type(member)}") 70 | 71 | def get_dict(self): 72 | data = {} 73 | 74 | for object_type, object_list, in (("func", self.methods), 75 | ("cls_func", self.classmethods), 76 | ("prop", self.properties), 77 | ("const", self.constants) 78 | ): 79 | if object_list: 80 | data[object_type] = [name for name, member in object_list] 81 | 82 | return data 83 | 84 | 85 | class TableOfContents: 86 | """ Main class used for generating the table of contents. """ 87 | def __init__(self): 88 | self.classes: list[UnrealClassRepresentation] = [] 89 | self.enums: list[UnrealClassRepresentation] = [] 90 | self.struct: list[UnrealClassRepresentation] = [] 91 | self.delegates: list[UnrealClassRepresentation] = [] 92 | self.natives: list[UnrealClassRepresentation] = [] 93 | self.functions: list[tuple[str, types.BuiltinFunctionType | types.FunctionType]] = [] 94 | 95 | def load(self): 96 | """ 97 | Load all classes, enums, structs, delegates, functions, etc. from the unreal module. 98 | """ 99 | for object_name, obj in inspect.getmembers(unreal): 100 | if inspect.isclass(obj): 101 | classobject = UnrealClassRepresentation(object_name, obj) 102 | 103 | if issubclass_strict(obj, unreal.EnumBase): 104 | self.enums.append(classobject) 105 | elif issubclass_strict(obj, unreal.StructBase): 106 | self.struct.append(classobject) 107 | elif issubclass_strict(obj, (unreal.DelegateBase, unreal.MulticastDelegateBase)): 108 | self.delegates.append(classobject) 109 | elif issubclass_strict(obj, unreal.Object): 110 | self.classes.append(classobject) 111 | else: 112 | self.natives.append(classobject) 113 | 114 | elif inspect.isfunction(obj) or isinstance(obj, types.BuiltinFunctionType): 115 | self.functions.append((object_name, obj)) 116 | 117 | # else: 118 | # print(f"Skip adding {object_name}: {obj} to the toc.") 119 | 120 | def get_dict(self): 121 | """ Generate a dictionary containing the table of contents """ 122 | data = {} 123 | for name, object_list, in (("Native", self.natives), 124 | ("Struct", self.struct), 125 | ("Class", self.classes), 126 | ("Enum", self.enums), 127 | ("Delegate", self.delegates), 128 | ): 129 | 130 | data[name] = {x.name: x.get_dict() for x in object_list} 131 | 132 | data["Function"] = {name: {} for name, function in self.functions} 133 | 134 | return data 135 | 136 | 137 | def get_table_of_content_json(): 138 | table_of_contents = TableOfContents() 139 | with warnings.catch_warnings(): 140 | # Suppress warnings about deprecated classes 141 | warnings.simplefilter("ignore") 142 | table_of_contents.load() 143 | 144 | # Use separators withouth spaces to reduce the size of the JSON object 145 | return json.dumps(table_of_contents.get_dict(), separators=(',', ':')) 146 | -------------------------------------------------------------------------------- /python/documentation/get_page_content.py: -------------------------------------------------------------------------------- 1 | """ Print a JSON object with an indepth documentation for a given object """ 2 | 3 | import inspect 4 | import types 5 | import copy 6 | import json 7 | import re 8 | 9 | import unreal 10 | 11 | 12 | class EMemberType: 13 | PROPERTY = "Properties" 14 | METHOD = "Methods" 15 | DECORATOR = "Decorators" 16 | 17 | 18 | DEFAULT_DICT_LAYOUT = { 19 | EMemberType.PROPERTY: [], 20 | EMemberType.METHOD: [], 21 | EMemberType.DECORATOR: [] 22 | } 23 | 24 | # Regex pattern that matches "(X): [X] X:", used for property docstrings 25 | PROPERTY_DOCSTRING_PATTERN = re.compile(r"\(*.+\): \[[^\]]+\] [^\)]+[\:]?") 26 | 27 | # Regex pattern that matches "abc.X(X) -> X ", where X is any character, used for function docstrings 28 | FUNCTION_DOCSTRING_PATTERN = re.compile(r"^[Xx].+\(*\)\s*->\s*[\w\,]*\s*(or None)?") 29 | 30 | 31 | def get_docstring(obj: object, object_name: str) -> str: 32 | is_class = inspect.isclass(obj) 33 | 34 | def _patch_line(line: str, index: int): 35 | line = line.rstrip() 36 | 37 | # Special cases for the first line 38 | if index == 0: 39 | 40 | # For classes, if docstring starts with just the class name, remove it 41 | if is_class: 42 | name_comparison = object_name.replace("_", "").lower() 43 | line_comparison = line.replace(" ", "").lower() 44 | if line_comparison == name_comparison or line_comparison[1:] == name_comparison: 45 | return "" 46 | 47 | else: # TODO: Spesifically check function/property 48 | matches = PROPERTY_DOCSTRING_PATTERN.findall(line) 49 | if matches: 50 | matching_text: str = matches[0] 51 | var_type, _, permission = matching_text.partition(":") 52 | permission = permission.rpartition("]")[0].strip("[ ") 53 | end_sign = ":" if matching_text.endswith(":") else "" 54 | line = f"{var_type} [_{permission}_]{end_sign} {line.replace(matching_text, '')}" 55 | 56 | # Add a new line before the C++ Source 57 | if is_class and "**C++ Source:" in line: 58 | line = line.replace("**C++ Source:", "\n**C++ Source:") 59 | 60 | if line.startswith(" "): 61 | line = f"- {line.strip().rstrip(':')}" 62 | 63 | return line 64 | 65 | doc_string = obj.__doc__ 66 | 67 | if doc_string and "\n" in doc_string: 68 | lines = [] 69 | for index, line in enumerate(doc_string.split("\n")): 70 | line = _patch_line(line, index) 71 | if not line: 72 | continue 73 | 74 | # Break before it list's all each class member 75 | if is_class and line.startswith("**Editor Properties"): 76 | break 77 | 78 | lines.append(line) 79 | 80 | doc_string = "\n".join(lines) 81 | 82 | elif doc_string: 83 | doc_string = _patch_line(doc_string, 0).strip() 84 | 85 | return doc_string 86 | 87 | 88 | def patch_method_name_and_doc(name: str, doc: str) -> tuple[str, str, str]: 89 | name_hints = "" 90 | 91 | if "--" in doc: 92 | name, _, doc = doc.partition("--") 93 | if "(" in name: 94 | name, delimiter, name_hints = name.partition("(") 95 | name_hints = delimiter + name_hints # re-append the delimiter 96 | else: 97 | match = FUNCTION_DOCSTRING_PATTERN.match(doc) 98 | if match: 99 | matching_text: str = doc[match.start():match.end()] 100 | _, delimiter, name_hints = matching_text.partition("(") 101 | name_hints = delimiter + name_hints 102 | doc = doc[match.end():] 103 | 104 | return name, name_hints, doc 105 | 106 | 107 | def get_member_data(member: object, memeber_name: str) -> tuple[str, dict]: 108 | name = memeber_name 109 | 110 | doc = get_docstring(member, memeber_name) 111 | 112 | member_type = None 113 | name_hints = "" 114 | if inspect.isgetsetdescriptor(member) or inspect.ismemberdescriptor(member): 115 | member_type = EMemberType.PROPERTY 116 | elif inspect.ismethoddescriptor(member) or inspect.isbuiltin(member): 117 | member_type = EMemberType.METHOD 118 | name, name_hints, doc = patch_method_name_and_doc(name, doc) 119 | elif issubclass(type(member), unreal.EnumBase): 120 | member_type = EMemberType.PROPERTY 121 | doc = str(member.value) 122 | elif inspect.isfunction(member): 123 | member_type = EMemberType.DECORATOR 124 | name += "()" 125 | 126 | return member_type, { 127 | "name": name.strip(), 128 | "doc": doc.strip(), 129 | "name_hints": name_hints.strip() 130 | } 131 | 132 | 133 | def get_object_documentation(object_name: str) -> dict: 134 | if not hasattr(unreal, object_name): 135 | return None 136 | 137 | ue_object = getattr(unreal, object_name) 138 | 139 | is_class = inspect.isclass(ue_object) 140 | 141 | if is_class: 142 | bases_names = [x.__name__ for x in ue_object.__bases__] 143 | 144 | object_dict = ue_object.__dict__ 145 | doc_string = get_docstring(ue_object, object_name) 146 | 147 | inherited_members = copy.deepcopy(DEFAULT_DICT_LAYOUT) 148 | unique_members = copy.deepcopy(DEFAULT_DICT_LAYOUT) 149 | 150 | for memeber_name, member in inspect.getmembers(ue_object): 151 | if memeber_name.startswith("_"): 152 | continue 153 | 154 | member_type, member_data = get_member_data(member, memeber_name) 155 | 156 | # Check where the method/property originates from 157 | # Inherited Overriden 158 | if memeber_name not in object_dict or any(hasattr(x, memeber_name) for x in ue_object.__bases__): 159 | inherited_members[member_type].append(member_data) 160 | else: 161 | # Unique 162 | unique_members[member_type].append(member_data) 163 | else: 164 | object_name = "Unreal Functions" 165 | doc_string = get_docstring(unreal, object_name) 166 | bases_names = [] 167 | inherited_members = copy.deepcopy(DEFAULT_DICT_LAYOUT) 168 | unique_members = copy.deepcopy(DEFAULT_DICT_LAYOUT) 169 | for function_name, function in inspect.getmembers(unreal): 170 | if not isinstance(function, (types.BuiltinFunctionType, types.FunctionType)): 171 | continue 172 | 173 | member_type, member_data = get_member_data(function, function_name) 174 | unique_members[member_type].append(member_data) 175 | 176 | return { 177 | "name": object_name, 178 | "doc": doc_string, 179 | "bases": bases_names, 180 | "members": { 181 | "inherited": inherited_members, 182 | "unique": unique_members 183 | }, 184 | "is_class": is_class 185 | } 186 | 187 | 188 | def get_object_documentation_json(object_name: str) -> str: 189 | data = get_object_documentation(object_name) 190 | return json.dumps(data, separators=(",", ":")) 191 | -------------------------------------------------------------------------------- /python/execute.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | """ 4 | This script will be called from 'vscode_execute_entry.py' and will execute the user script 5 | """ 6 | 7 | import traceback 8 | import tempfile 9 | import logging 10 | import ast 11 | import sys 12 | import os 13 | 14 | from contextlib import nullcontext 15 | 16 | import unreal 17 | 18 | TEMP_FOLDERPATH = os.path.join(tempfile.gettempdir(), "VSCode-Unreal-Python") 19 | OUTPUT_FILENAME = "exec-out" 20 | 21 | DATA_FILEPATH_GLOBAL_VAR_NAME = "data_filepath" 22 | 23 | 24 | class UnrealLogRedirectDebugging: 25 | """ 26 | Re-directs the Unreal log functions so that they are printed to python's stdout and can be read by the debugger 27 | """ 28 | 29 | def __init__(self): 30 | self.logger = logging.getLogger("Unreal") 31 | self.original_log = unreal.log 32 | self.original_log_error = unreal.log_error 33 | self.original_log_warning = unreal.log_warning 34 | 35 | def redirect_warning(self, msg: str): 36 | self.logger.warning(msg) 37 | 38 | def redirect_error(self, msg: str): 39 | self.logger.error(msg) 40 | 41 | def redirect(self, msg: str): 42 | print(msg) 43 | 44 | def __enter__(self): 45 | unreal.log = self.redirect 46 | unreal.log_error = self.redirect_error 47 | unreal.log_warning = self.redirect_warning 48 | 49 | def __exit__(self, exc_type, exc_val, exc_tb): 50 | unreal.log = self.original_log 51 | unreal.log_error = self.original_log_error 52 | unreal.log_warning = self.original_log_warning 53 | 54 | 55 | def get_exec_globals() -> dict: 56 | """ Get globals to be used in the exec function when executing user scripts """ 57 | if "__VsCodeVariables__" not in globals(): 58 | globals()["__VsCodeVariables__"] = { 59 | "__builtins__": __builtins__, "__IsVsCodeExec__": True} 60 | return globals()["__VsCodeVariables__"] 61 | 62 | 63 | def find_package(filepath: str): 64 | """ Find the expected __package__ value for the executed file, so relative imports work """ 65 | normalized_filepath = os.path.normpath(filepath).lower() 66 | 67 | valid_packages = [] 68 | for path in sys.path: 69 | normalized_path = os.path.normpath(path).lower() 70 | if normalized_filepath.startswith(normalized_path): 71 | package = os.path.relpath(os.path.dirname(filepath), path).replace(os.sep, ".") 72 | if package != ".": 73 | valid_packages.append(package) 74 | 75 | # If there are multiple valid packages, choose the shortest one 76 | if valid_packages: 77 | return min(valid_packages, key=len) 78 | 79 | return "" 80 | 81 | 82 | def add_print_for_last_expr(parsed_code: ast.Module) -> ast.Module: 83 | """ 84 | Modify the ast to print the last expression if it isn't None. 85 | """ 86 | if parsed_code.body: 87 | last_expr = parsed_code.body[-1] 88 | if isinstance(last_expr, ast.Expr): 89 | temp_var_name = "_" 90 | 91 | line_info: dict = { 92 | "lineno": last_expr.lineno, 93 | "col_offset": last_expr.col_offset, 94 | } 95 | 96 | if sys.version_info >= (3, 8): 97 | line_info["end_lineno"] = last_expr.end_lineno 98 | line_info["end_col_offset"] = last_expr.end_col_offset 99 | 100 | # Assign the last expression to a temporary variable 101 | temp_var_assign = ast.Assign( 102 | targets=[ast.Name(id=temp_var_name, ctx=ast.Store(), **line_info)], 103 | value=last_expr.value, 104 | **line_info 105 | ) 106 | 107 | # If the temporary variable isn't None, print it 108 | print_stmt = ast.IfExp( 109 | test=ast.Compare( 110 | left=ast.Name(id=temp_var_name, ctx=ast.Load(), **line_info), 111 | ops=[ast.IsNot()], 112 | comparators=[ast.Constant(value=None, **line_info)], 113 | **line_info 114 | ), 115 | body=ast.Call( 116 | func=ast.Name(id='print', ctx=ast.Load(), **line_info), 117 | args=[ast.Name(id=temp_var_name, ctx=ast.Load(), **line_info)], 118 | keywords=[], 119 | **line_info 120 | ), 121 | orelse=ast.Constant(value=None, **line_info), 122 | **line_info 123 | ) 124 | 125 | parsed_code.body[-1] = temp_var_assign 126 | parsed_code.body.append(ast.Expr(value=print_stmt, **line_info)) 127 | 128 | return parsed_code 129 | 130 | 131 | def format_exception(exception_in: BaseException, filename: str, code: str, num_ignore_tracebacks: int = 0) -> str: 132 | seen_exceptions = set() 133 | messages = [] 134 | lines = code.splitlines() 135 | 136 | exception = exception_in 137 | while exception: 138 | if id(exception) in seen_exceptions: 139 | break 140 | seen_exceptions.add(id(exception)) 141 | 142 | traceback_stack = [] 143 | for frame_summary in traceback.extract_tb(exception.__traceback__): 144 | if num_ignore_tracebacks > 0: 145 | num_ignore_tracebacks -= 1 146 | continue 147 | 148 | if frame_summary.filename == filename and \ 149 | (frame_summary.lineno is not None and 0 < frame_summary.lineno <= len(lines)): 150 | line = lines[frame_summary.lineno - 1] 151 | else: 152 | line = frame_summary.line 153 | 154 | if sys.version_info >= (3, 11): 155 | col_info = { 156 | "end_lineno": frame_summary.end_lineno, 157 | "colno": frame_summary.colno, 158 | "end_colno": frame_summary.end_colno, 159 | } 160 | else: 161 | col_info = {} 162 | 163 | traceback_stack.append( 164 | traceback.FrameSummary( 165 | f"{frame_summary.filename}:{frame_summary.lineno}", 166 | frame_summary.lineno, 167 | frame_summary.name, 168 | lookup_line=False, 169 | locals=frame_summary.locals, 170 | line=line, 171 | **col_info 172 | ) 173 | ) 174 | 175 | if isinstance(exception, SyntaxError): 176 | if exception.filename == filename: 177 | exception.filename = "%s:%s" % (exception.filename, exception.lineno) 178 | if exception.lineno is not None and 0 < exception.lineno <= len(lines): 179 | line = lines[exception.lineno - 1] 180 | exception.text = line 181 | 182 | text = "Traceback (most recent call last):\n" 183 | text += "".join(traceback.format_list(traceback_stack)) 184 | text += "".join(traceback.format_exception_only(type(exception), exception)) 185 | 186 | messages.append(text) 187 | 188 | exception = exception.__context__ 189 | 190 | return "\nDuring handling of the above exception, another exception occurred:\n\n".join(reversed(messages)) 191 | 192 | 193 | def execute_code(code: str, filename: str): 194 | try: 195 | parsed_code = ast.parse(code, filename) 196 | except (SyntaxError, ValueError) as e: 197 | unreal.log_error(format_exception(e, filename, code, num_ignore_tracebacks=2)) 198 | return 199 | 200 | parsed_code = add_print_for_last_expr(parsed_code) 201 | 202 | try: 203 | exec(compile(parsed_code, filename, 'exec'), get_exec_globals()) 204 | except Exception as e: 205 | unreal.log_error(format_exception(e, filename, code, num_ignore_tracebacks=1)) 206 | 207 | 208 | def main(exec_file: str, exec_origin: str, is_debugging: bool, name_var: str | None = None): 209 | # Set some global variables 210 | exec_globals = get_exec_globals() 211 | 212 | exec_globals["__file__"] = exec_origin 213 | if name_var: 214 | exec_globals["__name__"] = name_var 215 | elif "__name__" in exec_globals: 216 | exec_globals.pop("__name__") 217 | 218 | exec_globals["__package__"] = find_package(exec_origin) 219 | 220 | with open(exec_file, 'r', encoding="utf-8") as vscode_in_file: 221 | with UnrealLogRedirectDebugging() if is_debugging else nullcontext(): 222 | execute_code(vscode_in_file.read(), exec_origin) 223 | -------------------------------------------------------------------------------- /python/get_stub_path.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import unreal 4 | 5 | def get_python_stub_dir(): 6 | """ Get the directory to where the 'unreal.py' stub file is located """ 7 | return os.path.join(os.path.abspath(unreal.Paths.project_intermediate_dir()), "PythonStub") 8 | -------------------------------------------------------------------------------- /python/reload.py: -------------------------------------------------------------------------------- 1 | """ 2 | Reloads all modules in the user's workspace folders 3 | """ 4 | from __future__ import annotations 5 | 6 | import importlib 7 | import traceback 8 | import time 9 | import json 10 | import sys 11 | import os 12 | 13 | import unreal 14 | 15 | 16 | def reload(workspace_folders: list[str]): 17 | start_time = time.perf_counter() 18 | 19 | num_reloads = 0 20 | num_failed = 0 21 | 22 | workspace_folders = [os.path.normpath(folder).lower() for folder in workspace_folders] 23 | 24 | for variable in list(sys.modules.values()): 25 | # Check if variable is a module 26 | if not hasattr(variable, '__file__') or not variable.__file__: 27 | continue 28 | 29 | filepath = variable.__file__.lower() 30 | 31 | if not any(filepath.startswith(x) for x in workspace_folders): 32 | continue 33 | 34 | try: 35 | importlib.reload(variable) 36 | except Exception as e: 37 | unreal.log_error(f'Failed to reload "{filepath}":\n{traceback.format_exc()}') 38 | num_failed += 1 39 | continue 40 | 41 | num_reloads += 1 42 | 43 | elapsed_time_ms = int((time.perf_counter() - start_time) * 1000) 44 | 45 | print(f"Reloaded {num_reloads} modules in {elapsed_time_ms}ms") 46 | 47 | return json.dumps({"num_reloads": num_reloads, "time": elapsed_time_ms, "num_failed": num_failed}) 48 | -------------------------------------------------------------------------------- /python/vsc_eval.py: -------------------------------------------------------------------------------- 1 | import json # Needs to be here to ensure the json module is available in remote-handler.ts `evaluateFunction` 2 | 3 | 4 | def vsc_eval(filepath: str, function_name: str, use_globals: bool, **kwargs): 5 | """ 6 | Evaluate a function in a Python file, and return the function's return value 7 | This function is used to evaluate VS Code python files and return the result to the Extension 8 | """ 9 | with open(filepath, 'r', encoding="utf8") as file: 10 | code = file.read() 11 | 12 | # Find the function 13 | if use_globals: 14 | exec_globals = globals() 15 | else: 16 | exec_globals = {} 17 | 18 | exec(compile(code, filepath, 'exec'), exec_globals) 19 | 20 | if function_name in exec_globals: 21 | function = exec_globals[function_name] 22 | return function(**kwargs) 23 | else: 24 | raise ValueError(f"Function '{function_name}' not found in file '{filepath}'") 25 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | import * as remoteHandler from './modules/remote-handler'; 4 | import * as utils from './modules/utils'; 5 | 6 | import * as setupCodeCompletion from './scripts/setup-code-completion'; 7 | import * as documentationPannel from './views/documentation-pannel'; 8 | import * as selectInstance from './scripts/select-instance'; 9 | import * as execute from './scripts/execute'; 10 | import * as attach from './scripts/attach'; 11 | import * as reload from './scripts/reload'; 12 | 13 | 14 | export function activate(context: vscode.ExtensionContext) { 15 | // Set the extension directory 16 | utils.setExtensionUri(context.extensionUri); 17 | 18 | // Register commands 19 | context.subscriptions.push( 20 | vscode.commands.registerCommand('ue-python.execute', () => { 21 | execute.main(); 22 | }) 23 | ); 24 | 25 | context.subscriptions.push( 26 | vscode.commands.registerCommand('ue-python.attach', () => { 27 | attach.main(); 28 | }) 29 | ); 30 | 31 | context.subscriptions.push( 32 | vscode.commands.registerCommand('ue-python.setupCodeCompletion', () => { 33 | setupCodeCompletion.main(); 34 | }) 35 | ); 36 | 37 | context.subscriptions.push( 38 | vscode.commands.registerCommand('ue-python.openDocumentation', () => { 39 | return documentationPannel.openDocumentationWindow(context.extensionUri, context.globalStorageUri); 40 | }) 41 | ); 42 | 43 | context.subscriptions.push( 44 | vscode.commands.registerCommand('ue-python.selectInstance', () => { 45 | selectInstance.main(); 46 | }) 47 | ); 48 | 49 | context.subscriptions.push( 50 | vscode.commands.registerCommand('ue-python.reloadModules', () => { 51 | reload.reload(); 52 | }) 53 | ); 54 | 55 | // Check if config is changed 56 | context.subscriptions.push( 57 | vscode.workspace.onDidChangeConfiguration(onConfigurationChanged) 58 | ); 59 | } 60 | 61 | 62 | export async function deactivate() { 63 | remoteHandler.removeStatusBarItem(); 64 | 65 | await Promise.all([ 66 | remoteHandler.closeRemoteConnection(), 67 | utils.cleanupTempFiles() 68 | ]); 69 | } 70 | 71 | 72 | function onConfigurationChanged(event: vscode.ConfigurationChangeEvent) { 73 | // Check if we need to restart the remote execution instance 74 | const restartOnProperties = [ 75 | 'remote.multicastGroupEndpoint', 76 | 'remote.commandEndpoint', 77 | 'remote.multicastTTL', 78 | 'remote.multicastBindAdress', 79 | 'environment.addWorkspaceToPath' 80 | ]; 81 | 82 | for (const property of restartOnProperties) { 83 | if (event.affectsConfiguration(`ue-python.${property}`)) { 84 | remoteHandler.closeRemoteConnection(); 85 | break; 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /src/modules/code-exec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Module for retrieving an executable python file based on the current file/selection in Visual Studio Code 3 | */ 4 | 5 | import * as vscode from 'vscode'; 6 | 7 | 8 | /** 9 | * Get the user selection from VS Code as a python executable string 10 | */ 11 | export function getSelectedTextAsExecutableString() { 12 | if (!vscode.window.activeTextEditor) { 13 | return; 14 | } 15 | 16 | const activeDocument = vscode.window.activeTextEditor.document; 17 | let executableCodeString = ""; 18 | 19 | let selections: Array = [vscode.window.activeTextEditor.selection]; 20 | 21 | // Sort the selections them by their start line number 22 | if (vscode.window.activeTextEditor.selections.length > 1) { 23 | // Add selections into an array that we can run the sort function on 24 | selections = []; 25 | for (const selection of vscode.window.activeTextEditor.selections) { 26 | selections.push(selection); 27 | } 28 | 29 | selections = selections.sort((a, b) => a.start.line - b.start.line); 30 | } 31 | 32 | // Combine all selections into a single string 33 | for (const selection of selections) { 34 | if (!selection.isEmpty) { 35 | 36 | // Get the character index of the first character that's not whitespace (on the first line that's not whitespace) 37 | let firstCharIndex = -1; 38 | for (let i = 0; i <= (selection.end.line - selection.start.line); i++) { 39 | const line = activeDocument.lineAt(selection.start.line + i); 40 | if (!line.isEmptyOrWhitespace) { 41 | firstCharIndex = line.firstNonWhitespaceCharacterIndex; 42 | break; 43 | } 44 | } 45 | 46 | // Add empty lines to match line numbers with the actual source file. 47 | // This is to make sure you get correct line numbers for error messages & to make sure 48 | // breakpoints work correctly. 49 | const numberOfLines = executableCodeString.split("\n").length - 1; 50 | const additionalEmptyLines = "\n".repeat(selection.start.line - numberOfLines); 51 | 52 | executableCodeString += additionalEmptyLines + formatSelectedText(activeDocument.getText(selection), firstCharIndex); 53 | } 54 | } 55 | 56 | return executableCodeString; 57 | } 58 | 59 | 60 | /** 61 | * Try to make sure the text is runnable 62 | * This includes e.g. making sure that the code is correctly indented 63 | * @param text The text to format 64 | * @param firstCharIndex Index of the first character (how far it's indented) 65 | */ 66 | function formatSelectedText(text: string, firstCharIndex: number): string { 67 | if (firstCharIndex <= 0) { 68 | return text; 69 | } 70 | 71 | let formattedText = ""; 72 | let numCharactersToRemove = firstCharIndex; 73 | let i = 0; 74 | for (let line of text.split("\n")) { 75 | if (numCharactersToRemove) { 76 | if (i === 0) { 77 | line = line.trimStart(); 78 | } 79 | else { 80 | const trimmedLine = line.trimStart(); 81 | 82 | // Check if it's just an empty line or a comment 83 | if (trimmedLine && trimmedLine[0] !== "#") { 84 | const numberOfWhitespaceCharacters = line.length - trimmedLine.length; 85 | if (numberOfWhitespaceCharacters < numCharactersToRemove) { 86 | numCharactersToRemove = numberOfWhitespaceCharacters; 87 | } 88 | line = line.slice(numCharactersToRemove); 89 | } 90 | } 91 | } 92 | 93 | formattedText += line + "\n"; 94 | i++; 95 | } 96 | 97 | return formattedText; 98 | } 99 | 100 | 101 | /** 102 | * Save a file 103 | * @param filepath The absolute filepath 104 | * @param text Text to write to the file 105 | * @returns the absolute filepath of the file 106 | */ 107 | async function saveFile(filepath: vscode.Uri, text: string): Promise { 108 | await vscode.workspace.fs.writeFile(filepath, Buffer.from(text)); 109 | return filepath; 110 | } 111 | 112 | 113 | /** 114 | * @param tempFilepath If a temp file needs to be saved, this path will be used 115 | * @returns The filepath to a executable python file based on the curerrent file/selection in VS Code 116 | */ 117 | export async function getFileToExecute(tempFilepath: vscode.Uri): Promise { 118 | if (!vscode.window.activeTextEditor) { 119 | return; 120 | } 121 | 122 | const activeDocument = vscode.window.activeTextEditor.document; 123 | const selectedCode = getSelectedTextAsExecutableString(); 124 | 125 | // If user has any selected text, save the selection as a temp file 126 | if (selectedCode) { 127 | return saveFile(tempFilepath, selectedCode); 128 | } 129 | 130 | // If file is dirty, save a copy of the file 131 | else if (activeDocument.isDirty) { 132 | return saveFile(tempFilepath, activeDocument.getText()); 133 | } 134 | 135 | // No selection and everything is saved, return the current file 136 | return activeDocument.uri; 137 | } -------------------------------------------------------------------------------- /src/modules/extension-wiki.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | export const WIKI_URL = "https://github.com/nils-soderman/vscode-unreal-python/wiki/"; 4 | 5 | 6 | /** 7 | * Struct of pages available on the wiki. 8 | * All values are static 9 | */ 10 | export class FPages { 11 | static readonly failedToConnect = "Failed-to-connect-to-Unreal-Engine-%5BTroubleshooting%5D"; 12 | static readonly enableDevmode = "How-to-enable-Developer-Mode-for-Python-in-Unreal-Engine"; 13 | } 14 | 15 | 16 | /** 17 | * @param page The page to get the full URL of, should be a value of `FPages` 18 | * @returns The full page url 19 | */ 20 | export function getPageUrl(page: string): vscode.Uri { 21 | return vscode.Uri.parse(WIKI_URL + page); 22 | } 23 | 24 | /** 25 | * Open a wiki page in the user's default webbrowser 26 | * @param page The page to open, should be a value of `FPages` 27 | */ 28 | export function openPageInBrowser(page: string) { 29 | const url = getPageUrl(page); 30 | return vscode.env.openExternal(url); 31 | } -------------------------------------------------------------------------------- /src/modules/logger.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | const OUTPUT_CHANNEL_NAME = "UE Python Log"; 4 | let outputChannel: vscode.LogOutputChannel; 5 | 6 | 7 | export function getOutputChannel() { 8 | if (!outputChannel) { 9 | outputChannel = vscode.window.createOutputChannel(OUTPUT_CHANNEL_NAME, { log: true }); 10 | } 11 | return outputChannel; 12 | } 13 | 14 | 15 | /** 16 | * Log a message to the output channel 17 | * @param message The message to log 18 | */ 19 | export function info(message: string) { 20 | getOutputChannel().info(message); 21 | } 22 | 23 | export function warn(message: string) { 24 | getOutputChannel().warn(message); 25 | } 26 | 27 | export function error(message: string) { 28 | getOutputChannel().error(message); 29 | } 30 | 31 | 32 | /** 33 | * Show an error message to the user and log the error.message 34 | * @param message The message to show to the user in the error dialog 35 | * @param error The full error message to log 36 | */ 37 | export function showError(message: string, error?: Error) { 38 | const outputChannel = getOutputChannel(); 39 | 40 | if (error) 41 | outputChannel.error(error.message); 42 | 43 | const OPTION_SHOW_LOG = "Show Log"; 44 | vscode.window.showErrorMessage(message, OPTION_SHOW_LOG).then((value) => { 45 | if (value === OPTION_SHOW_LOG) { 46 | outputChannel.show(); 47 | } 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /src/modules/remote-handler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A module handling the connection between Unreal and VSCode 3 | */ 4 | 5 | import * as vscode from 'vscode'; 6 | 7 | import { RemoteExecution, RemoteExecutionConfig, RemoteExecutionNode, ECommandOutputType, EExecMode, IRemoteExecutionMessageCommandOutputData } from "unreal-remote-execution"; 8 | 9 | import * as extensionWiki from "./extension-wiki"; 10 | import * as utils from "./utils"; 11 | import * as logger from "./logger"; 12 | 13 | let gIsInitializatingConnection = false; 14 | let gCachedRemoteExecution: RemoteExecution | null = null; 15 | let gStatusBarItem: vscode.StatusBarItem | null = null; 16 | 17 | 18 | // ------------------------------------ 19 | // Status Bar Item 20 | // ------------------------------------ 21 | 22 | function getStatusBarItem(bEnsureExists = true) { 23 | if (!gStatusBarItem && bEnsureExists) { 24 | gStatusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 10); 25 | gStatusBarItem.command = "ue-python.selectInstance"; 26 | gStatusBarItem.tooltip = "Connected Unreal Engine instance"; 27 | } 28 | return gStatusBarItem; 29 | } 30 | 31 | 32 | export function removeStatusBarItem() { 33 | gStatusBarItem?.dispose(); 34 | gStatusBarItem = null; 35 | } 36 | 37 | 38 | export function updateStatusBar(node: RemoteExecutionNode) { 39 | const statusBarItem = getStatusBarItem(); 40 | if (statusBarItem) { 41 | statusBarItem.text = `$(unreal-engine) ${node.data.project_name}`; 42 | statusBarItem.show(); 43 | } 44 | } 45 | 46 | 47 | /** 48 | * Get a `RemoteExecutionConfig` based on the extension user settings 49 | */ 50 | function getRemoteConfig() { 51 | const extensionConfig = utils.getExtensionConfig(); 52 | 53 | const multicastTTL: number | undefined = extensionConfig.get("remote.multicastTTL"); 54 | const multicastBindAddress: string | undefined = extensionConfig.get("remote.multicastBindAddress"); 55 | 56 | let multicastGroupEndpoint: [string, number] | undefined = undefined; 57 | const multicastGroupEndpointStr: string | undefined = extensionConfig.get("remote.multicastGroupEndpoint"); 58 | if (multicastGroupEndpointStr) { 59 | const [multicastGroupStr, portStr] = multicastGroupEndpointStr.split(":", 2); 60 | multicastGroupEndpoint = [multicastGroupStr, Number(portStr)]; 61 | } 62 | 63 | let commandEndpoint: [string, number] | undefined = undefined; 64 | const commandEndpointStr: string | undefined = extensionConfig.get("remote.commandEndpoint"); 65 | if (commandEndpointStr) { 66 | const [commandHost, commandPortStr] = commandEndpointStr.split(":", 2); 67 | commandEndpoint = [commandHost, Number(commandPortStr)]; 68 | } 69 | 70 | return new RemoteExecutionConfig(multicastTTL, multicastGroupEndpoint, multicastBindAddress, commandEndpoint); 71 | } 72 | 73 | 74 | /** 75 | * Make sure the command port is avaliable, and if not it'll try to find a port that's free and modify the port in config to use this new port. 76 | * @param config The remote execution config 77 | * @returns A list with 2 elements, the first one is a boolean depending on if a free port was found/assigned to the config. Second element is a error message. 78 | */ 79 | async function ensureCommandPortAvaliable(config: RemoteExecutionConfig): Promise { 80 | const extensionConfig = utils.getExtensionConfig(); 81 | 82 | const host = config.commandEndpoint[0]; 83 | const commandEndpointPort = config.commandEndpoint[1]; 84 | 85 | // Check if user has enabled 'strictPort' 86 | if (extensionConfig.get("strictPort")) { 87 | if (!await utils.isPortAvailable(commandEndpointPort, host)) { 88 | vscode.window.showErrorMessage(`Port ${commandEndpointPort} is currently busy. Consider changing the config: 'ue-python.remote.commandEndpoint'.`); 89 | return false; 90 | } 91 | } 92 | else { 93 | // Check the next 100 ports, one should hopefully be free 94 | const freePort = await utils.findFreePort(commandEndpointPort, 101, host); 95 | if (!freePort) { 96 | vscode.window.showErrorMessage(`All ports between ${commandEndpointPort} - ${commandEndpointPort + 100} are busy. Consider changing the config: 'ue-python.remote.commandEndpoint'.`); 97 | return false; 98 | } 99 | 100 | // If the first found free port wasn't the original port, update it 101 | if (commandEndpointPort !== freePort) { 102 | config.commandEndpoint[1] = freePort; 103 | } 104 | } 105 | 106 | return true; 107 | } 108 | 109 | 110 | /** 111 | * Get the port the remote execution command connection is using 112 | */ 113 | export async function getRemoteExecutionCommandPort() { 114 | const remoteExecution = await getRemoteExecutionInstance(false); 115 | if (!remoteExecution) 116 | return null; 117 | 118 | return remoteExecution.config.commandEndpoint[1]; 119 | } 120 | 121 | 122 | /** 123 | * Get the global remote connection instance 124 | * @param bEnsureConnection Make sure the remote execution instance exists, if not it'll create one. 125 | */ 126 | export async function getRemoteExecutionInstance(bEnsureExists = true) { 127 | if (!gCachedRemoteExecution && bEnsureExists) { 128 | const config = getRemoteConfig(); 129 | if (await ensureCommandPortAvaliable(config)) { 130 | gCachedRemoteExecution = new RemoteExecution(config); 131 | gCachedRemoteExecution.events.addEventListener("commandConnectionClosed", onRemoteConnectionClosed); 132 | await gCachedRemoteExecution.start(); 133 | 134 | logger.info("Remote execution instance created"); 135 | logger.info(`Multicast TTL: ${config.multicastTTL}`); 136 | logger.info(`Multicast Bind Address: ${config.multicastBindAddress}`); 137 | logger.info(`Multicast Group Endpoint: ${config.multicastGroupEndpoint[0]}:${config.multicastGroupEndpoint[1]}`); 138 | logger.info(`Command Endpoint: ${config.commandEndpoint[0]}:${config.commandEndpoint[1]}`); 139 | } 140 | } 141 | 142 | return gCachedRemoteExecution; 143 | } 144 | 145 | 146 | /** 147 | * Get the global remote connection instance, and make sure it's connected 148 | * @returns The remote execution instance, or null if it failed to connect 149 | */ 150 | export async function getConnectedRemoteExecutionInstance(): Promise { 151 | const remoteExecution = await getRemoteExecutionInstance(); 152 | if (!remoteExecution) 153 | return null; 154 | 155 | if (!remoteExecution.hasCommandConnection()) { 156 | const config = getRemoteConfig(); 157 | 158 | if (gIsInitializatingConnection) { 159 | return new Promise((resolve) => { 160 | const interval = setInterval(() => { 161 | if (!gIsInitializatingConnection) { 162 | clearInterval(interval); 163 | resolve(getConnectedRemoteExecutionInstance()); 164 | } 165 | }, 1000); 166 | }); 167 | } 168 | gIsInitializatingConnection = true; 169 | 170 | if (await ensureCommandPortAvaliable(config)) { 171 | const extensionConfig = utils.getExtensionConfig(); 172 | const timeout: number = extensionConfig.get("remote.timeout") ?? 3000; 173 | 174 | logger.info(`Connecting with a timeout of ${timeout}ms.`); 175 | 176 | try { 177 | const node = await remoteExecution.getFirstRemoteNode(1000, timeout); 178 | await remoteExecution.openCommandConnection(node, true, timeout); 179 | 180 | logger.info("Connected to: " + JSON.stringify(node.data)); 181 | 182 | await onRemoteInstanceCreated(remoteExecution); 183 | 184 | updateStatusBar(node); 185 | } 186 | catch (error: any) { 187 | logger.info(error); 188 | let message: string = error.message; 189 | if (message.startsWith("Timed out")) 190 | message = "Timed out while trying to connect to Unreal Engine."; 191 | 192 | vscode.window.showErrorMessage(message, "Help").then((clickedItem) => { 193 | if (clickedItem === "Help") 194 | extensionWiki.openPageInBrowser(extensionWiki.FPages.failedToConnect); 195 | }); 196 | 197 | closeRemoteConnection(); 198 | return null; 199 | } 200 | finally { 201 | gIsInitializatingConnection = false; 202 | } 203 | } 204 | else { 205 | closeRemoteConnection(); 206 | gIsInitializatingConnection = false; 207 | return null; 208 | } 209 | 210 | } 211 | 212 | return remoteExecution; 213 | } 214 | 215 | 216 | /** 217 | * Called when a remote instance is created 218 | */ 219 | async function onRemoteInstanceCreated(instance: RemoteExecution) { 220 | if (!await defineVceEvalFunction()) 221 | return; 222 | 223 | // Check if we should add any workspace folders to the python path 224 | const workspaceFolders = vscode.workspace.workspaceFolders; 225 | if (workspaceFolders) { 226 | let foldersToAddToPath: string[] = []; 227 | for (const folder of workspaceFolders) { 228 | const config = vscode.workspace.getConfiguration(utils.EXTENSION_ID, folder.uri); 229 | if (config.get('environment.addWorkspaceToPath', false)) { 230 | foldersToAddToPath.push(folder.uri.fsPath); 231 | } 232 | } 233 | 234 | if (foldersToAddToPath.length > 0) { 235 | await evaluateFunction( 236 | utils.FPythonScriptFiles.getUri(utils.FPythonScriptFiles.addSysPath), "add_paths", 237 | { 238 | paths: foldersToAddToPath 239 | } 240 | ); 241 | } 242 | } 243 | } 244 | 245 | 246 | /** 247 | * Called when the remote connection is closed 248 | */ 249 | async function onRemoteConnectionClosed() { 250 | const remoteExecution = await getRemoteExecutionInstance(false); 251 | if (!remoteExecution?.hasCommandConnection()) 252 | removeStatusBarItem(); 253 | 254 | logger.info("Remote connection closed"); 255 | } 256 | 257 | /** 258 | * Define the vce_eval function used in `evaluateFunction` 259 | */ 260 | export async function defineVceEvalFunction(): Promise { 261 | const filepath = utils.FPythonScriptFiles.getUri(utils.FPythonScriptFiles.eval); 262 | const vsc_eval_response = await executeFile(filepath); 263 | if (!vsc_eval_response) { 264 | return false; 265 | } 266 | 267 | for (const output of vsc_eval_response.output) { 268 | logger.info(output.output.trimEnd()); 269 | } 270 | 271 | if (!vsc_eval_response.success) { 272 | logger.showError("Extension ran into an error", new Error(vsc_eval_response.result)); 273 | return false; 274 | } 275 | 276 | return true; 277 | } 278 | 279 | 280 | /** 281 | * Send a command to the remote connection 282 | * @param command The python code as a string 283 | */ 284 | export async function runCommand(command: string, bEval = false) { 285 | const remoteExec = await getConnectedRemoteExecutionInstance(); 286 | if (!remoteExec) { 287 | return; 288 | } 289 | 290 | const bUnattended = utils.getExtensionConfig().get("execute.unattended") ? true : false; 291 | const mode = bEval ? EExecMode.EVALUATE_STATEMENT : EExecMode.EXECUTE_STATEMENT; 292 | 293 | return remoteExec.runCommand(command, bUnattended, mode); 294 | } 295 | 296 | 297 | /** 298 | * Execute a file in Unreal through the remote exection 299 | * @param uri Absolute filepath to the python file to execute 300 | * @param variables Optional dict with global variables to set before executing the file 301 | */ 302 | export function executeFile(uri: vscode.Uri, globals: any = {}) { 303 | if (!globals.hasOwnProperty('__file__')) { 304 | globals["__file__"] = uri.fsPath; 305 | } 306 | 307 | let globalsStr = JSON.stringify(globals); 308 | globalsStr = globalsStr.replace(/\\/g, "\\\\"); 309 | 310 | // Put together one line of code for settings the global variables, then opening, reading & executing the given filepath 311 | const command = `import json;globals().update(json.loads('${globalsStr}'));f=open(r'${uri.fsPath}','r');exec(f.read());f.close()`; 312 | return runCommand(command); 313 | } 314 | 315 | 316 | export async function evaluateFunction(uri: vscode.Uri, functionName: string, kwargs: any = {}, useGlobals = false, logOutput = true) { 317 | let command = `vsc_eval(r'${uri.fsPath}', '${functionName}', ${useGlobals ? "True" : "False"}`; 318 | if (Object.keys(kwargs).length > 0) { 319 | command += `, **json.loads(r'${JSON.stringify(kwargs)}')`; 320 | } 321 | command += `)`; 322 | 323 | const response = await runCommand(command, true); 324 | if (response) { 325 | if (logOutput) { 326 | for (const output of response.output) { 327 | if (output.type === ECommandOutputType.ERROR) 328 | logger.error(output.output.trimEnd()); 329 | else if (output.type === ECommandOutputType.WARNING) 330 | logger.warn(output.output.trimEnd()); 331 | else 332 | logger.info(output.output.trimEnd()); 333 | } 334 | } 335 | 336 | if (!response.success) 337 | logger.showError("Extension ran into an error", new Error(response.result)); 338 | } 339 | 340 | return response; 341 | } 342 | 343 | 344 | /** 345 | * Close the global remote connection, if there is one 346 | */ 347 | export async function closeRemoteConnection() { 348 | gCachedRemoteExecution?.stop(); 349 | gCachedRemoteExecution = null; 350 | } -------------------------------------------------------------------------------- /src/modules/utils.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | import * as tcpPortUsed from 'tcp-port-used'; 4 | import * as path from 'path'; 5 | import * as os from "os"; 6 | 7 | export const EXTENSION_ID = "ue-python"; 8 | 9 | const DATA_FOLDER_NAME = "VSCode-Unreal-Python"; // Folder name used for Temp & Data directory 10 | const DEBUG_SESSION_NAME = "Unreal Python"; // The name of the debug session when debugging Unreal 11 | 12 | 13 | let _extensionDir: vscode.Uri | undefined; // Stores the absolute path to this extension's directory, set on activation 14 | 15 | /** 16 | * This function should only be called once, on activation 17 | * @param uri Should be: `ExtensionContext.extensionPath` 18 | */ 19 | export function setExtensionUri(uri: vscode.Uri) { 20 | _extensionDir = uri; 21 | } 22 | 23 | /** 24 | * This function cannot be called in top-level. It must be called after the extension has been activated 25 | * @returns The absolute path to this extension's directory 26 | */ 27 | export function getExtensionUri(): vscode.Uri { 28 | if (!_extensionDir) { 29 | throw Error("Extension Dir hasn't been set yet! This should be set on activation. This function cannot be called in top-level."); 30 | } 31 | return _extensionDir; 32 | } 33 | 34 | 35 | /** 36 | * Struct containing all available python script's provided by this extension. 37 | * All variables & methods are static, this class should not be instantiated. 38 | */ 39 | export class FPythonScriptFiles { 40 | static readonly buildDocumentationToC = "documentation/build_toc"; 41 | static readonly getDocPageContent = "documentation/get_page_content"; 42 | static readonly getStubPath = "get_stub_path"; 43 | static readonly addSysPath = "add_sys_path"; 44 | static readonly attach = "attach"; 45 | static readonly execute = "execute"; 46 | static readonly reload = "reload"; 47 | static readonly eval = "vsc_eval"; 48 | 49 | /** Get the absolute path to one of the scripts defined in this struct */ 50 | static getUri(file: string): vscode.Uri { 51 | return vscode.Uri.joinPath(getExtensionUri(), "python", `${file}.py`); 52 | } 53 | } 54 | 55 | 56 | // ----------------------------------------------------------------------------------------- 57 | // VS Code Utils 58 | // ----------------------------------------------------------------------------------------- 59 | 60 | /** 61 | * Get the workspace folder for the currently active file/text editor 62 | */ 63 | export function getActiveWorkspaceFolder(): vscode.WorkspaceFolder | undefined { 64 | if (vscode.window.activeTextEditor) { 65 | const activeDocumenet = vscode.window.activeTextEditor.document; 66 | return vscode.workspace.getWorkspaceFolder(activeDocumenet.uri); 67 | } 68 | } 69 | 70 | 71 | /** 72 | * @returns The workspace configuration for this extension _('ue-python')_ 73 | */ 74 | export function getExtensionConfig() { 75 | const activeWorkspaceFolder = getActiveWorkspaceFolder()?.uri; 76 | return vscode.workspace.getConfiguration(EXTENSION_ID, activeWorkspaceFolder); 77 | } 78 | 79 | export function getDebugSessionName(projectName: string) { 80 | return `${DEBUG_SESSION_NAME} - ${projectName}`; 81 | } 82 | 83 | /** Check if we're currently attached to an Unreal instance */ 84 | export function isDebuggingUnreal(projectName: string) { 85 | return vscode.debug.activeDebugSession !== undefined && vscode.debug.activeDebugSession.name === getDebugSessionName(projectName); 86 | } 87 | 88 | 89 | // ----------------------------------------------------------------------------------------- 90 | // Directories, Files & Paths 91 | // ----------------------------------------------------------------------------------------- 92 | 93 | 94 | /** 95 | * Compare two paths and check if they are pointing to the same file/directory 96 | * Regardless of case sensitivity, forward or backward slashes etc. 97 | */ 98 | export function isPathsSame(a: string, b: string) { 99 | return path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase(); 100 | } 101 | 102 | 103 | /** 104 | * @param bEnsureExists If folder doesn't exist, create it 105 | * @returns absolute path to this extensions tempdir 106 | */ 107 | export async function getExtensionTempUri(bEnsureExists = true): Promise { 108 | const tempUri = vscode.Uri.file(os.tmpdir()); 109 | const extensionTmpUri = vscode.Uri.joinPath(tempUri, DATA_FOLDER_NAME); 110 | if (bEnsureExists && !await uriExists(tempUri)) { 111 | await vscode.workspace.fs.createDirectory(tempUri); 112 | } 113 | 114 | return extensionTmpUri; 115 | } 116 | 117 | export async function uriExists(uri: vscode.Uri): Promise { 118 | try { 119 | await vscode.workspace.fs.stat(uri); 120 | return true; 121 | } catch { 122 | return false; 123 | } 124 | } 125 | 126 | 127 | /** 128 | * Delete this extension's temp folder (and all of the files inside of it) 129 | */ 130 | export async function cleanupTempFiles() { 131 | const tmpDir = await getExtensionTempUri(); 132 | if (await uriExists(tmpDir)) { 133 | await vscode.workspace.fs.delete(tmpDir, { recursive: true, useTrash: false }); 134 | } 135 | } 136 | 137 | 138 | // ----------------------------------------------------------------------------------------- 139 | // Misc 140 | // ----------------------------------------------------------------------------------------- 141 | 142 | 143 | /** 144 | * Check if a port is taken 145 | * @param port The port to check 146 | * @param host The ip, will default to localhost 147 | */ 148 | export async function isPortAvailable(port: number, host?: string) { 149 | return !await tcpPortUsed.check(port, host); 150 | } 151 | 152 | 153 | /** 154 | * Check the ports between `startPort` -> `startPort + num`, and return the first one that's free 155 | * @param startPort The port to start itterating from 156 | * @param num How many ports to check 157 | * @param host The ip, will default to localhost 158 | * @returns The port as a number, or `null` if all ports were taken 159 | */ 160 | export async function findFreePort(startPort: number, num: number, host?: string) { 161 | for (let i = 0; i < num + 1; i++) { 162 | const port = startPort + i; 163 | if (await isPortAvailable(port, host)) { 164 | return port; 165 | } 166 | } 167 | 168 | return null; 169 | } 170 | 171 | 172 | let gOutputChannel: vscode.OutputChannel | undefined; 173 | 174 | /** 175 | * Get the output channel for this extension 176 | * @param bEnsureChannelExists If channel doesn't exist, create it 177 | */ 178 | export function getOutputChannel(bEnsureExists?: true): vscode.OutputChannel; 179 | export function getOutputChannel(bEnsureExists?: false): vscode.OutputChannel | undefined; 180 | 181 | export function getOutputChannel(bEnsureChannelExists = true) { 182 | if (!gOutputChannel && bEnsureChannelExists) { 183 | gOutputChannel = vscode.window.createOutputChannel("UE Python"); 184 | } 185 | return gOutputChannel; 186 | } -------------------------------------------------------------------------------- /src/scripts/attach.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Script to attach VS Code to Unreal Engine by starting a debugpy server 3 | * If debugpy is not installed user will be prompted to install it, with an option to automatically install it 4 | */ 5 | 6 | import * as vscode from 'vscode'; 7 | 8 | import * as remoteHandler from '../modules/remote-handler'; 9 | import * as logger from '../modules/logger'; 10 | import * as utils from '../modules/utils'; 11 | 12 | 13 | const DEBUGPY_PYPI_URL = "https://pypi.org/project/debugpy/"; 14 | 15 | // ------------------------------------------------------------------------------------------ 16 | // Interfaces 17 | // ------------------------------------------------------------------------------------------ 18 | 19 | /** 20 | * Settings for attaching to Unreal Engine. 21 | */ 22 | interface IAttachConfiguration { 23 | port: number; 24 | justMyCode: boolean; 25 | }; 26 | 27 | 28 | // ------------------------------------------------------------------------------------------ 29 | // Installation of debugpy 30 | // ------------------------------------------------------------------------------------------ 31 | 32 | /** 33 | * Check if the python module "debugpy" is installed and accessible with the current `sys.paths` in Unreal Engine. 34 | */ 35 | export async function isDebugpyInstalled(): Promise { 36 | logger.info("Checking if debugpy is installed..."); 37 | 38 | const attachScript = utils.FPythonScriptFiles.getUri(utils.FPythonScriptFiles.attach); 39 | const response = await remoteHandler.evaluateFunction(attachScript, "is_debugpy_installed"); 40 | if (response && response.success) 41 | return response.result === "True"; 42 | 43 | return false; 44 | } 45 | 46 | 47 | /** 48 | * Check if the python module "debugpy" is installed and accessible with the current `sys.paths` in Unreal Engine. 49 | */ 50 | export async function getCurrentDebugpyPort(): Promise { 51 | logger.info("Checking if debugpy is currently running..."); 52 | 53 | const attachScript = utils.FPythonScriptFiles.getUri(utils.FPythonScriptFiles.attach); 54 | const response = await remoteHandler.evaluateFunction(attachScript, "get_current_debugpy_port"); 55 | if (response && response.success) { 56 | const port = Number(response.result); 57 | if (port > 0) { 58 | logger.info(`debugpy is already running on port ${port}`); 59 | return port; 60 | } 61 | 62 | logger.info("debugpy is not currently running"); 63 | } 64 | 65 | return null; 66 | } 67 | 68 | 69 | /** 70 | * pip install the "debugpy" python module 71 | */ 72 | export async function installDebugpy(): Promise { 73 | logger.info("Installing debugpy..."); 74 | 75 | const installDebugpyScript = utils.FPythonScriptFiles.getUri(utils.FPythonScriptFiles.attach); 76 | const response = await remoteHandler.evaluateFunction(installDebugpyScript, "install_debugpy"); 77 | if (response) { 78 | if (response.success && response.result === "True") 79 | return true; 80 | } 81 | 82 | return false; 83 | } 84 | 85 | 86 | // ------------------------------------------------------------------------------------------ 87 | // Attach to Unreal Engine 88 | // ------------------------------------------------------------------------------------------ 89 | 90 | 91 | /** 92 | * Start a debugpy server in Unreal Engine. 93 | * @param port The port to start the server on 94 | */ 95 | async function startDebugpyServer(port: number): Promise { 96 | logger.info(`Starting debugpy server on port ${port}`); 97 | 98 | const startDebugServerScript = utils.FPythonScriptFiles.getUri(utils.FPythonScriptFiles.attach); 99 | const response = await remoteHandler.evaluateFunction(startDebugServerScript, "start_debugpy_server", { port }); 100 | if (response && response.success) { 101 | return response.result === "True"; 102 | } 103 | 104 | return false; 105 | } 106 | 107 | 108 | /** 109 | * Start a python debug session and attach VS Code to a port 110 | * @param attachSettings Launch settings for the debug session 111 | */ 112 | async function attach(name: string, attachSettings: IAttachConfiguration) { 113 | const configuration = { 114 | "name": name, 115 | "type": "debugpy", 116 | "request": "attach", 117 | "host": "localhost", 118 | ...attachSettings 119 | }; 120 | 121 | logger.info(`Attaching to Unreal Engine with the following config:\n${JSON.stringify(configuration, null, 4)}`); 122 | 123 | return vscode.debug.startDebugging(undefined, configuration); 124 | } 125 | 126 | 127 | /** Attach VS Code to Unreal Engine */ 128 | export async function main(): Promise { 129 | const remoteExecution = await remoteHandler.getConnectedRemoteExecutionInstance(); 130 | const projectName = remoteExecution?.connectedNode?.data.project_name; 131 | if (!projectName) 132 | return false; 133 | 134 | if (utils.isDebuggingUnreal(projectName)) { 135 | logger.info(`Already attached to Unreal Engine project: ${projectName}`); 136 | return true; 137 | } 138 | 139 | // Make sure debugpy is installed 140 | const bInstalled = await isDebugpyInstalled(); 141 | if (!bInstalled) { 142 | const selectedInstallOption = await vscode.window.showWarningMessage( 143 | `Python module [debugpy](${DEBUGPY_PYPI_URL}) is required for debugging`, 144 | "Install" 145 | ); 146 | 147 | if (selectedInstallOption === "Install") { 148 | if (!await installDebugpy()) 149 | return false; 150 | } 151 | else { 152 | return false; 153 | } 154 | } 155 | 156 | const config = utils.getExtensionConfig(); 157 | const attachConfig = config.get("attach"); 158 | if (!attachConfig) { 159 | return false; 160 | } 161 | 162 | const debugSessionName = utils.getDebugSessionName(projectName); 163 | 164 | // Check if debugpy is already running 165 | const currentPort = await getCurrentDebugpyPort(); 166 | if (currentPort) { 167 | attachConfig.port = currentPort; 168 | return attach(debugSessionName, attachConfig); 169 | } 170 | else { 171 | // If "strictPort" is enabled, make sure the port specified is available 172 | const reservedCommandPort = await remoteHandler.getRemoteExecutionCommandPort(); 173 | if (config.get("strictPort")) { 174 | if (!(await utils.isPortAvailable(attachConfig.port)) || reservedCommandPort === attachConfig.port) { 175 | logger.info(`Port ${attachConfig.port} is currently busy.`); 176 | vscode.window.showErrorMessage(`Port ${attachConfig.port} is currently busy. Please update the 'config ue-python.attach.port'.`); 177 | return false; 178 | } 179 | } 180 | else { 181 | // Find a free port as close to the specified port as possible 182 | const startPort = reservedCommandPort === attachConfig.port ? attachConfig.port + 1 : attachConfig.port; 183 | const freePort = await utils.findFreePort(startPort, 100); 184 | if (!freePort) { 185 | logger.info(`All ports between ${attachConfig.port} -> ${attachConfig.port + 100} are busy.`); 186 | vscode.window.showErrorMessage(`All ports between ${attachConfig.port} -> ${attachConfig.port + 100} are busy. Please update the 'config ue-python.attach.port'.`); 187 | return false; 188 | } 189 | 190 | attachConfig.port = freePort; 191 | } 192 | 193 | // Start the debugpy server and attach to it 194 | if (await startDebugpyServer(attachConfig.port)) { 195 | return attach(debugSessionName, attachConfig); 196 | } 197 | } 198 | 199 | return false; 200 | } 201 | -------------------------------------------------------------------------------- /src/scripts/execute.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Script that executes the selected text in Unreal Engine, if nothing is selected the entire active document will be executed. 3 | */ 4 | 5 | import * as vscode from 'vscode'; 6 | 7 | import * as crypto from 'crypto'; 8 | 9 | import * as utils from '../modules/utils'; 10 | import * as logger from '../modules/logger'; 11 | 12 | import * as remoteHandler from "../modules/remote-handler"; 13 | import * as vsCodeExec from "../modules/code-exec"; 14 | 15 | import { IRemoteExecutionMessageCommandOutputData } from "unreal-remote-execution"; 16 | 17 | 18 | const INPUT_TEMP_PYTHON_FILENAME = "temp_exec"; 19 | 20 | 21 | // ------------------------------------------------------------------------------------------ 22 | // Filepaths 23 | // ------------------------------------------------------------------------------------------ 24 | 25 | /** 26 | * Get a filepath where a temp python file can be saved 27 | * @param commandId: The command ID will be appended to the filename 28 | */ 29 | async function getTempPythonExecFilepath(commandId: string): Promise { 30 | return vscode.Uri.joinPath(await utils.getExtensionTempUri(), `${INPUT_TEMP_PYTHON_FILENAME}-${commandId}.py`); 31 | } 32 | 33 | 34 | // ------------------------------------------------------------------------------------------ 35 | // File handlers 36 | // ------------------------------------------------------------------------------------------ 37 | 38 | /** 39 | * Clean up all temp files related to a spesific command 40 | * @param commandId The ID of which files to delete 41 | */ 42 | async function cleanUpTempFiles(commandId: string) { 43 | const filepaths = [ 44 | await getTempPythonExecFilepath(commandId), 45 | ]; 46 | 47 | for (const filepath of filepaths) { 48 | if (await utils.uriExists(filepath)) { 49 | vscode.workspace.fs.delete(filepath, { useTrash: false }); 50 | } 51 | } 52 | } 53 | 54 | 55 | // ------------------------------------------------------------------------------------------ 56 | // Remote Exec 57 | // ------------------------------------------------------------------------------------------ 58 | 59 | /** 60 | * Handle the response recived from Unreal 61 | */ 62 | function handleResponse(message: IRemoteExecutionMessageCommandOutputData, commandId: string, isDebugging: boolean) { 63 | if (!message.success) { 64 | logger.showError("Failed to execute code", Error(message.result)); 65 | return; 66 | } 67 | 68 | // If user is debugging, all output will automatically be appended to the debug console 69 | if (isDebugging) { 70 | vscode.debug.activeDebugConsole.appendLine(">>>"); 71 | return; 72 | } 73 | const outputChannel = utils.getOutputChannel(); 74 | if (!outputChannel) { 75 | return; 76 | } 77 | 78 | for (const output of message.output) { 79 | outputChannel.appendLine(output.output.trimEnd()); 80 | } 81 | 82 | outputChannel.appendLine(">>>"); 83 | 84 | if (utils.getExtensionConfig().get("execute.showOutput")) { 85 | outputChannel.show(true); 86 | } 87 | 88 | // Cleanup all temp that were written by this command 89 | cleanUpTempFiles(commandId); 90 | } 91 | 92 | 93 | export async function main() { 94 | if (!vscode.window.activeTextEditor) { 95 | return false; 96 | } 97 | 98 | // Generate a random id, used to differentiate from other commands run at the same time 99 | const commandId = crypto.randomUUID(); 100 | 101 | // Get a file to execute 102 | const tempExecFilepath = await getTempPythonExecFilepath(commandId); 103 | const fileToExecute = await vsCodeExec.getFileToExecute(tempExecFilepath); 104 | if (!fileToExecute) { 105 | return false; 106 | } 107 | 108 | const extensionConfig = utils.getExtensionConfig(); 109 | 110 | // Clear the output channel if enabled in user settings 111 | if (extensionConfig.get("execute.clearOutput")) { 112 | const outputChannel = utils.getOutputChannel(false); 113 | if (outputChannel) { 114 | outputChannel.clear(); 115 | } 116 | } 117 | 118 | const projectName = (await remoteHandler.getRemoteExecutionInstance(false))?.connectedNode?.data.project_name; 119 | 120 | // Write an info file telling mb what script to run, etc. 121 | const bIsDebugging = projectName !== undefined && utils.isDebuggingUnreal(projectName); 122 | const nameVar = extensionConfig.get("execute.name"); 123 | 124 | const execFile = utils.FPythonScriptFiles.getUri(utils.FPythonScriptFiles.execute); 125 | const response = await remoteHandler.evaluateFunction( 126 | execFile, 127 | "main", 128 | { 129 | exec_file: fileToExecute.fsPath, 130 | exec_origin: vscode.window.activeTextEditor.document.uri.fsPath, 131 | is_debugging: bIsDebugging, 132 | name_var: nameVar 133 | }, 134 | true, 135 | false 136 | ); 137 | 138 | if (response) { 139 | handleResponse(response, commandId, bIsDebugging); 140 | return true; 141 | } 142 | 143 | return false; 144 | } 145 | -------------------------------------------------------------------------------- /src/scripts/reload.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | import * as remoteHandler from '../modules/remote-handler'; 4 | import * as logger from '../modules/logger'; 5 | import * as utils from '../modules/utils'; 6 | 7 | 8 | interface IReloadResponse { 9 | num_reloads: number; 10 | time: number; 11 | num_failed: number; 12 | } 13 | 14 | let isCommandRegistered = false; 15 | 16 | export async function reload() { 17 | const disposableStatusMessage = vscode.window.setStatusBarMessage("$(sync~spin) Reloading modules...", 5000); 18 | 19 | const workspaceFolders = vscode.workspace.workspaceFolders?.map(folder => folder.uri.fsPath) || []; 20 | 21 | const attachScript = utils.FPythonScriptFiles.getUri(utils.FPythonScriptFiles.reload); 22 | const response = await remoteHandler.evaluateFunction(attachScript, "reload", 23 | { 24 | "workspace_folders": workspaceFolders, 25 | } 26 | ); 27 | 28 | disposableStatusMessage.dispose(); 29 | 30 | if (!response) 31 | return; 32 | 33 | let parsedResults: IReloadResponse; 34 | try { 35 | parsedResults = JSON.parse(response.result.slice(1, -1)); 36 | } catch (e) { 37 | logger.showError("Failed to parse JSON response from reload script", e as Error); 38 | return; 39 | } 40 | 41 | if (parsedResults.num_failed <= 0) { 42 | vscode.window.setStatusBarMessage(`$(check) Reloaded ${parsedResults.num_reloads} modules in ${parsedResults.time} ms`, 3500); 43 | } 44 | else if (!isCommandRegistered) { 45 | const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 5); 46 | statusBarItem.text = `$(error) Failed to reload ${parsedResults.num_failed} module${parsedResults.num_failed === 1 ? '' : 's'}`; 47 | statusBarItem.command = "ue-python.showReloadErrorMessage"; 48 | statusBarItem.color = new vscode.ThemeColor('errorForeground'); 49 | 50 | const timeout = setTimeout(() => { 51 | commandDisposable.dispose(); 52 | statusBarItem.dispose(); 53 | isCommandRegistered = false; 54 | }, 5000); 55 | 56 | const commandDisposable = vscode.commands.registerCommand(statusBarItem.command, () => { 57 | logger.getOutputChannel().show(); 58 | commandDisposable.dispose(); 59 | statusBarItem.dispose(); 60 | clearTimeout(timeout); 61 | isCommandRegistered = false; 62 | }); 63 | 64 | statusBarItem.show(); 65 | 66 | isCommandRegistered = true; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/scripts/select-instance.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | import * as remoteHandler from '../modules/remote-handler'; 4 | 5 | import { RemoteExecutionNode } from "unreal-remote-execution"; 6 | 7 | 8 | interface UnrealInstanceQuickPickItem extends vscode.QuickPickItem { 9 | node: RemoteExecutionNode; 10 | } 11 | 12 | 13 | export async function main() { 14 | const remoteExecution = await remoteHandler.getRemoteExecutionInstance(); 15 | if (!remoteExecution) 16 | return; 17 | 18 | const quickPick = vscode.window.createQuickPick(); 19 | 20 | // quickPick.title = "Select an Unreal Engine Instance"; 21 | quickPick.placeholder = "Searching for Unreal Engine instances..."; 22 | quickPick.busy = true; 23 | 24 | quickPick.onDidAccept(async () => { 25 | quickPick.hide(); 26 | 27 | if (quickPick.selectedItems.length > 0) { 28 | const item = quickPick.selectedItems[0] as UnrealInstanceQuickPickItem; 29 | 30 | if (remoteExecution.hasCommandConnection()) { 31 | // Check if we're already connected to this node 32 | if (remoteExecution.connectedNode === item.node) 33 | return; 34 | 35 | remoteExecution.closeCommandConnection(); 36 | } 37 | 38 | await remoteExecution.openCommandConnection(item.node); 39 | 40 | remoteHandler.updateStatusBar(item.node); 41 | } 42 | 43 | quickPick.dispose(); 44 | }); 45 | 46 | quickPick.onDidHide(() => { 47 | remoteExecution.stopSearchingForNodes(); 48 | quickPick.dispose(); 49 | }); 50 | 51 | let quickPickItems: UnrealInstanceQuickPickItem[] = []; 52 | remoteExecution.events.addEventListener("nodeFound", (node) => { 53 | const item = { 54 | "label": node.data.project_name, 55 | "description": node.data.project_root, 56 | "node": node, 57 | } as UnrealInstanceQuickPickItem; 58 | 59 | quickPickItems.push(item); 60 | 61 | quickPick.items = quickPickItems; 62 | }); 63 | 64 | remoteExecution.events.addEventListener("nodeTimedOut", (node) => { 65 | quickPickItems = quickPickItems.filter((item) => { 66 | return item.node.nodeId !== node.nodeId; 67 | }); 68 | 69 | quickPick.items = quickPickItems; 70 | }); 71 | 72 | remoteExecution.startSearchingForNodes(1000); 73 | 74 | quickPick.show(); 75 | } -------------------------------------------------------------------------------- /src/scripts/setup-code-completion.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Adds the directory where the 'unreal.py' stub file is generated to the `python.analysis.extraPaths` config. 3 | */ 4 | 5 | import * as vscode from 'vscode'; 6 | 7 | import * as path from 'path'; 8 | 9 | import * as remoteHandler from '../modules/remote-handler'; 10 | import * as extensionWiki from '../modules/extension-wiki'; 11 | import * as logger from '../modules/logger'; 12 | import * as utils from '../modules/utils'; 13 | 14 | export const STUB_FILE_NAME = "unreal.py"; 15 | 16 | const CONFIG_PYTHON = "python"; 17 | const CONFIG_KEY_EXTRA_PATHS = "analysis.extraPaths"; 18 | 19 | interface ISettingsInfo { 20 | niceName: string; 21 | paths: string[] | undefined; 22 | scope: vscode.ConfigurationTarget; 23 | openSettingsCommand: string; 24 | } 25 | 26 | interface IInspectionSettings { 27 | globalValue?: string[]; 28 | workspaceValue?: string[]; 29 | workspaceFolderValue?: string[]; 30 | defaultValue?: string[]; 31 | } 32 | 33 | 34 | /** 35 | * Get the path to the directory where the 'unreal.py' stubfile is generated, 36 | * Based on the currently connected Unreal Engine project. 37 | */ 38 | export async function getUnrealStubDirectory(): Promise { 39 | const getPythonPathScript = utils.FPythonScriptFiles.getUri(utils.FPythonScriptFiles.getStubPath); 40 | const response = await remoteHandler.evaluateFunction(getPythonPathScript, "get_python_stub_dir"); 41 | 42 | if (response && response.success) { 43 | // The result string contains quote characters, strip those 44 | const stubDirectoryPath = response.result.slice(1, -1); 45 | return vscode.Uri.file(stubDirectoryPath); 46 | } 47 | 48 | return null; 49 | } 50 | 51 | 52 | /** 53 | * Check if the 'ms-python.vscode-pylance' extension is installed, and if not prompt the user to install it. 54 | * @returns 55 | */ 56 | function validatePylanceExtension(): boolean { 57 | const PYLANCE_EXTENSION_ID = "ms-python.vscode-pylance"; 58 | const PYLANCE_EXTENSION_URL = "https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance"; 59 | const SHOW_PYLANCE = "Show Pylance"; 60 | 61 | // Pylance is the extension that provides the 'python.analysis.extraPaths' setting 62 | const pylanceExtension = vscode.extensions.getExtension(PYLANCE_EXTENSION_ID); 63 | if (!pylanceExtension) { 64 | vscode.window.showErrorMessage( 65 | `[${PYLANCE_EXTENSION_ID}](${PYLANCE_EXTENSION_URL}) not installed. Could not update the '${CONFIG_PYTHON}.${CONFIG_KEY_EXTRA_PATHS}' setting.`, 66 | SHOW_PYLANCE 67 | ).then((value) => { 68 | if (value === SHOW_PYLANCE) 69 | vscode.commands.executeCommand("extension.open", PYLANCE_EXTENSION_ID); 70 | }); 71 | 72 | return false; 73 | } 74 | 75 | return true; 76 | } 77 | 78 | /** 79 | * 80 | */ 81 | function getSettingsInfo(extraPathsConfig: IInspectionSettings): ISettingsInfo { 82 | const bHasWorkspaceFileOpen = vscode.workspace.workspaceFile !== undefined; 83 | 84 | const valuesToCheck: ISettingsInfo[] = [ 85 | { 86 | niceName: "Folder", 87 | paths: extraPathsConfig.workspaceFolderValue, 88 | scope: vscode.ConfigurationTarget.WorkspaceFolder, 89 | openSettingsCommand: bHasWorkspaceFileOpen ? "workbench.action.openFolderSettings" : "workbench.action.openWorkspaceSettings" 90 | }, 91 | { 92 | niceName: "Workspace", 93 | paths: extraPathsConfig.workspaceValue, 94 | scope: vscode.ConfigurationTarget.Workspace, 95 | openSettingsCommand: "workbench.action.openWorkspaceSettings" 96 | } 97 | ]; 98 | 99 | // Search through the different scopes to find the first one that has a custom value 100 | for (const value of valuesToCheck) { 101 | if (value.paths && value.paths !== extraPathsConfig.defaultValue) { 102 | return value; 103 | } 104 | } 105 | 106 | // Default to global/User settings 107 | return { 108 | niceName: "User", 109 | paths: extraPathsConfig.globalValue, 110 | scope: vscode.ConfigurationTarget.Global, 111 | openSettingsCommand: "workbench.action.openSettings" 112 | }; 113 | } 114 | 115 | 116 | /** 117 | * Add a path to the `python.analysis.extraPaths` config. 118 | * This function will also remove any current paths that ends w/ 'Intermediate/PythonStub' 119 | * to prevent multiple Unreal stub directories beeing added 120 | * @param pathToAdd The path to add 121 | * @returns `true` if the path was added or already existed, `false` if the path could not be added 122 | */ 123 | function addPythonAnalysisPath(pathToAdd: string): "add" | "exists" | false { 124 | if (!validatePylanceExtension()) 125 | return false; 126 | 127 | const extraPathsConfigName = `${CONFIG_PYTHON}.${CONFIG_KEY_EXTRA_PATHS}`; 128 | 129 | const pythonConfig = vscode.workspace.getConfiguration(CONFIG_PYTHON, utils.getActiveWorkspaceFolder()?.uri); 130 | 131 | let extraPathsConfig = pythonConfig.inspect(CONFIG_KEY_EXTRA_PATHS); 132 | if (!extraPathsConfig) { 133 | logger.info(`Failed to get the config '${extraPathsConfigName}'`); 134 | return false; 135 | } 136 | 137 | const settingsInfo = getSettingsInfo(extraPathsConfig); 138 | 139 | // Create a new list that will contain the old paths and the new one 140 | let newPathsValue = settingsInfo.paths ? [...settingsInfo.paths] : []; 141 | 142 | // Check if the path already exists 143 | if (newPathsValue.some(path => utils.isPathsSame(path, pathToAdd))) { 144 | const message = `Path "${pathToAdd}" already exists in '${extraPathsConfigName}' in ${settingsInfo.niceName} settings.`; 145 | logger.info(message); 146 | vscode.window.showInformationMessage(message); 147 | return "exists"; 148 | } 149 | 150 | // Make sure we only have one Unreal stub directory in the extra paths 151 | newPathsValue = newPathsValue.filter(path => !path.endsWith("Intermediate/PythonStub")); 152 | newPathsValue.push(pathToAdd); 153 | 154 | try { 155 | pythonConfig.update(CONFIG_KEY_EXTRA_PATHS, newPathsValue, settingsInfo.scope); 156 | } 157 | catch (error) { 158 | logger.showError(`Failed to update '${extraPathsConfigName}' in ${settingsInfo.niceName} settings.`, error as Error); 159 | return false; 160 | } 161 | 162 | logger.info(`Added path "${pathToAdd}" to '${extraPathsConfigName}' in ${settingsInfo.niceName} settings.`); 163 | 164 | vscode.window.showInformationMessage(`Updated '${extraPathsConfigName}' in ${settingsInfo.niceName} settings.`, "Show Setting").then( 165 | (value) => { 166 | if (value === "Show Setting") { 167 | vscode.commands.executeCommand(settingsInfo.openSettingsCommand, extraPathsConfigName); 168 | } 169 | } 170 | ); 171 | 172 | return "add"; 173 | } 174 | 175 | 176 | /** 177 | * Validate that a 'unreal.py' stub file exists in given directory, and if so add it to the `python.analysis.extraPaths` config. 178 | * If a valid stub file doesn't exist, user will be prompted to enable developer mode and the path will NOT be added to the python config. 179 | * @param stubDirectoryPath The directory where the 'unreal.py' stub file is located 180 | */ 181 | export async function validateStubAndAddToPath(stubDirectoryPath: vscode.Uri): Promise { 182 | // Check if a generated stub file exists 183 | const stubFilepath = vscode.Uri.joinPath(stubDirectoryPath, STUB_FILE_NAME); 184 | 185 | if (!await utils.uriExists(stubFilepath)) { 186 | logger.info(`Failed to find the generated stub file: "${stubFilepath}"`); 187 | // A generated stub file could not be found, ask the user to enable developer mode first 188 | vscode.window.showErrorMessage( 189 | "To setup code completion you first need to enable Developer Mode in Unreal Engine's Python plugin settings, then restart the Unreal", 190 | "Help" 191 | ).then((item) => { 192 | if (item === "Help") 193 | extensionWiki.openPageInBrowser(extensionWiki.FPages.enableDevmode); 194 | }); 195 | 196 | return false; 197 | } 198 | 199 | return addPythonAnalysisPath(stubDirectoryPath.fsPath); 200 | } 201 | 202 | 203 | export async function main() { 204 | const autoStubDirectoryPath = await getUnrealStubDirectory(); 205 | if (autoStubDirectoryPath) { 206 | validateStubAndAddToPath(autoStubDirectoryPath); 207 | } 208 | else { 209 | const selectedItem = await vscode.window.showErrorMessage( 210 | "Setup Code Completion: Failed to automatically get the path to current Unreal Engine project", 211 | "Browse Manually" 212 | ); 213 | 214 | if (selectedItem === "Browse Manually") { 215 | const selectedFiles = await vscode.window.showOpenDialog({ 216 | "filters": { "Unreal Project": ["uproject"] }, // eslint-disable-line @typescript-eslint/naming-convention 217 | "canSelectMany": false, 218 | "title": "Select the Unreal Engine project file (.uproject) to setup code completion for", 219 | "openLabel": "Select project" 220 | }); 221 | 222 | if (selectedFiles) { 223 | // `selectedFiles[0]` should now be the .uproject file that the user whish to setup code completion for 224 | const projectDirectory = vscode.Uri.file(path.dirname(selectedFiles[0].fsPath)); 225 | const manualStubDirectoryPath = vscode.Uri.joinPath(projectDirectory, "Intermediate", "PythonStub"); 226 | validateStubAndAddToPath(manualStubDirectoryPath); 227 | } 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/test/modules/extension-wiki.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | 3 | import sinon from 'sinon'; 4 | 5 | import * as vscodeMock from '../vscode-mock'; 6 | 7 | import * as wiki from '../../modules/extension-wiki'; 8 | 9 | 10 | suite('Extension Wiki', () => { 11 | setup(() => { 12 | vscodeMock.mockOpenExternal(); 13 | }); 14 | 15 | teardown(() => { 16 | sinon.restore(); 17 | }); 18 | 19 | test('Wiki Urls', async function () { 20 | for (const page of Object.values(wiki.FPages)) 21 | assert.ok(await wiki.openPageInBrowser(page), `Failed to open page ${page}`); 22 | }); 23 | }); -------------------------------------------------------------------------------- /src/test/scripts/attach.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | 3 | import * as vscode from 'vscode'; 4 | 5 | import sinon from 'sinon'; 6 | 7 | import * as testUtils from '../test-utils'; 8 | import * as vscodeMock from '../vscode-mock'; 9 | 10 | import * as utils from '../../modules/utils'; 11 | import * as attach from '../../scripts/attach'; 12 | import * as remote from '../../modules/remote-handler'; 13 | 14 | 15 | 16 | 17 | const CONFIG_KEYS = { 18 | port: "attach.port", 19 | autoPort: "attach.autoPort" 20 | }; 21 | 22 | 23 | suite('Attach', function () { 24 | testUtils.initializeExtension(); 25 | this.timeout(30 * 1000); 26 | 27 | const extensionConfig = new vscodeMock.ConfigMock({ 28 | [CONFIG_KEYS.port]: 4243, 29 | [CONFIG_KEYS.autoPort]: true, 30 | ...testUtils.CONNECTION_CONFIG 31 | }); 32 | 33 | let extensionContext: vscode.ExtensionContext; 34 | let tempDebugpyInstallDir: vscode.Uri; 35 | 36 | suiteTeardown(async () => { 37 | if (await testUtils.uriExists(tempDebugpyInstallDir)) 38 | await vscode.workspace.fs.delete(tempDebugpyInstallDir, { recursive: true }); 39 | }); 40 | 41 | setup(() => { 42 | extensionContext = vscodeMock.getExtensionContext(); 43 | tempDebugpyInstallDir = vscode.Uri.joinPath(extensionContext.globalStorageUri, "site-packages"); 44 | 45 | vscodeMock.stubGetConfiguration({ 46 | // eslint-disable-next-line @typescript-eslint/naming-convention 47 | "ue-python": extensionConfig 48 | }); 49 | }); 50 | 51 | teardown(async () => { 52 | sinon.restore(); 53 | extensionConfig.reset(); 54 | 55 | await vscode.debug.stopDebugging(); 56 | }); 57 | 58 | test('Install Debugpy', async function () { 59 | assert.ok(await attach.installDebugpy()); 60 | assert.ok(await attach.isDebugpyInstalled()); 61 | }); 62 | 63 | test('Start Debugpy & Attach', async function () { 64 | const projectName = (await remote.getRemoteExecutionInstance())?.connectedNode?.data.project_name; 65 | assert.ok(projectName, "Failed to get project name"); 66 | 67 | assert.ok(await attach.main(), "Failed to attach"); 68 | 69 | assert.ok(utils.isDebuggingUnreal(projectName), "isDebuggingUnreal() returned false"); 70 | 71 | // Re-attach should not start a new session 72 | assert.ok(await attach.main(), "Re-attach returned false"); 73 | }); 74 | 75 | test('Re-attach', async function () { 76 | const projectName = (await remote.getRemoteExecutionInstance())?.connectedNode?.data.project_name; 77 | assert.ok(projectName, "Failed to get project name"); 78 | 79 | assert.ok(await attach.getCurrentDebugpyPort(), "Failed to get current Debugpy port"); 80 | 81 | assert.ok(!utils.isDebuggingUnreal(projectName), "isDebuggingUnreal() returned true"); 82 | assert.ok(await attach.main(), "Failed to attach"); 83 | 84 | assert.ok(utils.isDebuggingUnreal(projectName), "isDebuggingUnreal() returned false"); 85 | }); 86 | 87 | }); 88 | -------------------------------------------------------------------------------- /src/test/scripts/execute.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | 3 | import * as vscode from 'vscode'; 4 | 5 | import sinon from 'sinon'; 6 | 7 | import * as testUtils from '../test-utils'; 8 | import * as vscodeMock from '../vscode-mock'; 9 | 10 | import * as utils from '../../modules/utils'; 11 | import * as execute from '../../scripts/execute'; 12 | import * as remoteHandler from '../../modules/remote-handler'; 13 | 14 | 15 | const CONFIG_KEYS = { 16 | port: "attach.port", 17 | autoPort: "attach.autoPort", 18 | name: "execute.name", 19 | addWorkspaceToPath: "environment.addWorkspaceToPath", 20 | clearOutput: "execute.clearOutput", 21 | showOutput: "execute.showOutput" 22 | }; 23 | 24 | suite('Execute', function () { 25 | testUtils.initializeExtension(); 26 | this.timeout(30 * 1000); 27 | 28 | const execName = "Hello World!"; 29 | 30 | const extensionConfig = new vscodeMock.ConfigMock({ 31 | [CONFIG_KEYS.autoPort]: true, 32 | [CONFIG_KEYS.name]: execName, 33 | [CONFIG_KEYS.addWorkspaceToPath]: true, 34 | [CONFIG_KEYS.clearOutput]: true, 35 | ...testUtils.CONNECTION_CONFIG 36 | }); 37 | 38 | let outputChannel: vscodeMock.MockOutputChannel; 39 | 40 | const fileTest = testUtils.getPythonTestFilepath("test.py"); 41 | 42 | 43 | setup(() => { 44 | outputChannel = new vscodeMock.MockOutputChannel(); 45 | sinon.stub(utils, "getOutputChannel").returns(outputChannel); 46 | 47 | vscodeMock.stubGetConfiguration({ 48 | "ue-python": extensionConfig // eslint-disable-line @typescript-eslint/naming-convention 49 | }); 50 | }); 51 | 52 | teardown(async () => { 53 | sinon.restore(); 54 | extensionConfig.reset(); 55 | 56 | await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); 57 | }); 58 | 59 | test('Sys Paths', async function () { 60 | const workspacePath = vscode.workspace.workspaceFolders?.[0].uri.fsPath; 61 | assert.ok(workspacePath, "No workspace folder open"); 62 | 63 | await remoteHandler.closeRemoteConnection(); 64 | 65 | await new Promise((resolve) => setTimeout(resolve, 1000)); 66 | 67 | const instance = await remoteHandler.getConnectedRemoteExecutionInstance(); 68 | const response = await instance?.runCommand("import sys;print(','.join(sys.path))"); 69 | 70 | assert.ok(response?.success); 71 | 72 | const paths = response?.output[0].output.trim().split(","); 73 | assert.ok(paths.includes(workspacePath), `Workspace path not found in sys.path: ${paths}`); 74 | }); 75 | 76 | test('Execute Test.py', async function () { 77 | await vscode.window.showTextDocument(fileTest); 78 | 79 | await execute.main(); 80 | 81 | assert.ok(outputChannel.output.length === 2, `Unexpected number of output lines: ${outputChannel.output.length}, output:\n${outputChannel.output.join("\n")}`); 82 | assert.strictEqual(outputChannel.output[0], execName + "\n"); 83 | }); 84 | 85 | test('Clear Output', async function () { 86 | await vscode.window.showTextDocument(fileTest); 87 | 88 | await extensionConfig.update(CONFIG_KEYS.clearOutput, false); 89 | for (let i = 1; i <= 3; i++) { 90 | await execute.main(); 91 | // *2 because of the '>>>' added to each output 92 | assert.equal(outputChannel.output.length, i * 2, `Unexpected number of output lines: ${outputChannel.output.length}, output:\n${outputChannel.output.join("\n")}`); 93 | } 94 | 95 | await extensionConfig.update(CONFIG_KEYS.clearOutput, true); 96 | for (let i = 1; i <= 3; i++) { 97 | await execute.main(); 98 | assert.equal(outputChannel.output.length, 2, `Unexpected number of output lines: ${outputChannel.output.length}, output:\n${outputChannel.output.join("\n")}`); 99 | } 100 | }); 101 | 102 | test('Show Output', async function () { 103 | await vscode.window.showTextDocument(fileTest); 104 | outputChannel.bVisible = false; 105 | 106 | extensionConfig.update(CONFIG_KEYS.showOutput, false); 107 | await execute.main(); 108 | assert.ok(!outputChannel.bVisible); 109 | 110 | extensionConfig.update(CONFIG_KEYS.showOutput, true); 111 | await execute.main(); 112 | assert.ok(outputChannel.bVisible); 113 | }); 114 | 115 | test('Execute Selection', async function () { 116 | // Create a new empty document 117 | const doc = await vscode.workspace.openTextDocument({ language: "python", content: " print('0')\n print('1')\n print('2')" }); 118 | const editor = await vscode.window.showTextDocument(doc); 119 | 120 | editor.selection = new vscode.Selection(new vscode.Position(1, 0), new vscode.Position(2, 0)); 121 | await execute.main(); 122 | 123 | assert.ok(outputChannel.output.length === 2); 124 | assert.strictEqual(outputChannel.output[0], '1\n'); 125 | 126 | editor.selection = new vscode.Selection(new vscode.Position(1, 0), new vscode.Position(3, 0)); 127 | await execute.main(); 128 | 129 | // @ts-ignore 130 | assert.ok(outputChannel.output.length === 3); 131 | assert.strictEqual(outputChannel.output[0], '1\n'); 132 | assert.strictEqual(outputChannel.output[1], '2\n'); 133 | }); 134 | 135 | test('UTF-8 Characters', async function () { 136 | const utf8String = "你好世界-öäå"; 137 | const doc = await vscode.workspace.openTextDocument({ language: "python", content: `print("${utf8String}")` }); 138 | await vscode.window.showTextDocument(doc); 139 | 140 | await execute.main(); 141 | 142 | assert.strictEqual(outputChannel.output[0], utf8String + '\n'); 143 | }); 144 | 145 | test('Large Unsaved Output', async function () { 146 | const utf8String = "abc-你好世界-öäå"; 147 | 148 | const doc = await vscode.workspace.openTextDocument({ language: "python", content: `for i in range(250):\n print('${utf8String}')` }); 149 | await vscode.window.showTextDocument(doc); 150 | 151 | await execute.main(); 152 | 153 | assert.strictEqual(outputChannel.output.length, 251); 154 | for (let i = 0; i < 250; i++) { 155 | assert.strictEqual(outputChannel.output[i], utf8String + '\n'); 156 | } 157 | }); 158 | 159 | test('No Editor', async function () { 160 | assert.equal(await execute.main(), false); 161 | }); 162 | 163 | test('Print Last Expression', async function () { 164 | const doc = await vscode.workspace.openTextDocument({ language: "python", content: `5\n10` }); 165 | const editor = await vscode.window.showTextDocument(doc); 166 | 167 | let edit = async (code: string) => { 168 | return editor.edit(editBuilder => { 169 | const fullRange = new vscode.Range( 170 | editor.document.positionAt(0), 171 | editor.document.positionAt(editor.document.getText().length) 172 | ); 173 | 174 | editBuilder.replace(fullRange, code); 175 | }); 176 | }; 177 | 178 | // Test #1 179 | await execute.main(); 180 | assert.strictEqual(outputChannel.output.length, 2, `Unexpected number of output lines for test #1: [${outputChannel.output.join(", ")}]`); 181 | assert.strictEqual(outputChannel.output[0].trim(), "10"); 182 | assert.strictEqual(outputChannel.output[1].trim(), ">>>"); 183 | 184 | // Test #2 185 | await edit("def test():\n\treturn None\ntest()"); 186 | await execute.main(); 187 | assert.strictEqual(outputChannel.output.length, 1, `Unexpected number of output lines for test #2: [${outputChannel.output.join(", ")}]`); 188 | assert.strictEqual(outputChannel.output[0].trim(), ">>>"); 189 | 190 | // Test #3 191 | await edit("def test():\n\tprint('ok')\n\treturn 5\ntest()"); 192 | await execute.main(); 193 | assert.strictEqual(outputChannel.output.length, 3, `Unexpected number of output lines for test #3: [${outputChannel.output.join(", ")}]`); 194 | assert.strictEqual(outputChannel.output[0].trim(), "ok"); 195 | assert.strictEqual(outputChannel.output[1].trim(), "5"); 196 | assert.strictEqual(outputChannel.output[2].trim(), ">>>"); 197 | 198 | // Test #4 - Print the temp variable holding the last expression 199 | await edit("print(_)"); 200 | await execute.main(); 201 | assert.strictEqual(outputChannel.output.length, 2, `Unexpected number of output lines for test #4: [${outputChannel.output.join(", ")}]`); 202 | assert.strictEqual(outputChannel.output[0].trim(), "5"); 203 | assert.strictEqual(outputChannel.output[1].trim(), ">>>"); 204 | }); 205 | 206 | }); 207 | -------------------------------------------------------------------------------- /src/test/scripts/setup-code-completion.test.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as assert from 'assert'; 3 | import sinon from 'sinon'; 4 | 5 | import * as vscodeMock from '../vscode-mock'; 6 | import * as utils from '../test-utils'; 7 | 8 | import * as codeCompletion from '../../scripts/setup-code-completion'; 9 | 10 | const PYTHON_CONFIG_KEY = 'analysis.extraPaths'; 11 | 12 | 13 | suite('Setup Code Completion', () => { 14 | utils.initializeExtension(); 15 | 16 | const tmpDir = vscodeMock.getTempDir("stub"); 17 | 18 | const pythonConfig = new vscodeMock.ConfigMock({ 19 | [PYTHON_CONFIG_KEY]: [], 20 | }); 21 | 22 | const extensionConfig = new vscodeMock.ConfigMock(utils.CONNECTION_CONFIG); 23 | 24 | setup(() => { 25 | vscodeMock.stubGetConfiguration({ 26 | "python": pythonConfig, 27 | "ue-python": extensionConfig // eslint-disable-line @typescript-eslint/naming-convention 28 | }); 29 | }); 30 | 31 | teardown(async () => { 32 | sinon.restore(); 33 | pythonConfig.reset(); 34 | 35 | if (await utils.uriExists(tmpDir)) { 36 | await vscode.workspace.fs.delete(tmpDir, { recursive: true }); 37 | } 38 | }); 39 | 40 | test('Get Directory', async function () { 41 | assert.ok(await codeCompletion.getUnrealStubDirectory()); 42 | }); 43 | 44 | const testAddPythonAnalysisPath = async (dir: vscode.Uri) => { 45 | assert.strictEqual(await codeCompletion.validateStubAndAddToPath(dir), false); 46 | 47 | // Create the unreal.py file 48 | const stubFilepath = vscode.Uri.joinPath(dir, codeCompletion.STUB_FILE_NAME); 49 | await vscode.workspace.fs.writeFile(stubFilepath, new Uint8Array()); 50 | 51 | assert.strictEqual(await codeCompletion.validateStubAndAddToPath(dir), "add"); 52 | assert.strictEqual(await codeCompletion.validateStubAndAddToPath(dir), "exists"); 53 | }; 54 | 55 | test('Add Path - Global', async function () { 56 | await testAddPythonAnalysisPath(tmpDir); 57 | 58 | assert.strictEqual(pythonConfig.globalValue[PYTHON_CONFIG_KEY].length, 1); 59 | }); 60 | 61 | test('Add Path - Workspace', async function () { 62 | pythonConfig.workspaceValue[PYTHON_CONFIG_KEY] = ['helloWorld']; 63 | 64 | await testAddPythonAnalysisPath(tmpDir); 65 | 66 | assert.strictEqual(pythonConfig.workspaceValue[PYTHON_CONFIG_KEY].length, 2); 67 | }); 68 | 69 | test('Add Path - Workspace Folder', async function () { 70 | pythonConfig.workspaceFolderValue[PYTHON_CONFIG_KEY] = ['helloWorld']; 71 | 72 | await testAddPythonAnalysisPath(tmpDir); 73 | 74 | assert.strictEqual(pythonConfig.workspaceFolderValue[PYTHON_CONFIG_KEY].length, 2); 75 | }); 76 | }); -------------------------------------------------------------------------------- /src/test/test-utils.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | import * as utils from '../modules/utils'; 4 | 5 | /* eslint-disable @typescript-eslint/naming-convention */ 6 | export const CONNECTION_CONFIG = { 7 | "remote.multicastGroupEndpoint": "239.0.0.1:6766", 8 | "remote.multicastBindAddress": "127.0.0.1", 9 | "remote.multicastTTL": 0, 10 | "remote.commandEndpoint": "127.0.0.1:6776" 11 | }; 12 | /* eslint-enable @typescript-eslint/naming-convention */ 13 | 14 | 15 | /** 16 | * This function needs to be called before the tests are run 17 | * `setExtensionUri` is normally called in the extension activation method. 18 | */ 19 | export function initializeExtension() { 20 | const folder = vscode.workspace.workspaceFolders?.[0]; 21 | if (!folder) 22 | throw new Error("No workspace folder found"); 23 | const extensionDir = vscode.Uri.joinPath(folder.uri, "..", ".."); 24 | utils.setExtensionUri(extensionDir); 25 | } 26 | 27 | export function getPythonTestFilepath(filename: string): vscode.Uri { 28 | return vscode.Uri.joinPath(utils.getExtensionUri(), "test", "fixture", filename); 29 | } 30 | 31 | export async function uriExists(uri: vscode.Uri): Promise { 32 | try { 33 | await vscode.workspace.fs.stat(uri); 34 | return true; 35 | } catch (error) { 36 | return false; 37 | } 38 | } -------------------------------------------------------------------------------- /src/test/vscode-mock.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains mock classes and functions for testing vscode extensions 3 | */ 4 | import * as vscode from 'vscode'; 5 | 6 | import * as https from 'https'; 7 | import * as os from 'os'; 8 | 9 | import sinon from 'sinon'; 10 | 11 | 12 | export const TEST_UUID = crypto.randomUUID(); 13 | 14 | 15 | export class ConfigMock implements vscode.WorkspaceConfiguration { 16 | globalValue: Record; 17 | workspaceValue: Record; 18 | workspaceFolderValue: Record; 19 | 20 | constructor(readonly defaultValue: Record) { 21 | this.globalValue = { ...defaultValue }; 22 | this.workspaceValue = { ...defaultValue }; 23 | this.workspaceFolderValue = { ...defaultValue }; 24 | } 25 | 26 | get(key: string, defaultValue?: any) { 27 | for (const value of [this.workspaceFolderValue, this.workspaceValue, this.globalValue]) { 28 | if (value[key] !== undefined) 29 | return value[key]; 30 | } 31 | 32 | for (const value of [this.workspaceFolderValue, this.workspaceValue, this.globalValue]) { 33 | let returnValue: any = {}; 34 | for (const k in value) { 35 | if (k.startsWith(`${key}.`)) { 36 | returnValue[k.substring(key.length + 1)] = value[k]; 37 | } 38 | } 39 | if (Object.keys(returnValue).length > 0) 40 | return returnValue; 41 | } 42 | 43 | return defaultValue; 44 | } 45 | 46 | inspect(key: string) { 47 | return { 48 | key: key, 49 | globalValue: this.globalValue[key], 50 | workspaceValue: this.workspaceValue[key], 51 | workspaceFolderValue: this.workspaceFolderValue[key], 52 | defaultValue: this.defaultValue[key] 53 | }; 54 | } 55 | 56 | update(key: string, value: any, configurationTarget: boolean | vscode.ConfigurationTarget = vscode.ConfigurationTarget.WorkspaceFolder, overrideInLanguage?: boolean) { 57 | if (configurationTarget === vscode.ConfigurationTarget.WorkspaceFolder) 58 | this.workspaceFolderValue[key] = value; 59 | else if (configurationTarget === vscode.ConfigurationTarget.Workspace) 60 | this.workspaceValue[key] = value; 61 | else 62 | this.globalValue[key] = value; 63 | 64 | return Promise.resolve(); 65 | } 66 | 67 | has(key: string) { 68 | return this.globalValue[key] !== undefined; 69 | } 70 | 71 | reset() { 72 | this.globalValue = { ...this.defaultValue }; 73 | this.workspaceValue = { ...this.defaultValue }; 74 | this.workspaceFolderValue = { ...this.defaultValue }; 75 | } 76 | } 77 | 78 | 79 | export class MockOutputChannel implements vscode.OutputChannel { 80 | bVisible = false; 81 | output: string[] = []; 82 | disposed = false; 83 | name: string = ""; 84 | 85 | logLevel: vscode.LogLevel = vscode.LogLevel.Info; 86 | 87 | appendLine(line: string) { 88 | this.output.push(line + "\n"); 89 | } 90 | 91 | clear() { 92 | this.output = []; 93 | } 94 | 95 | show() { 96 | this.bVisible = true; 97 | } 98 | 99 | hide() { 100 | this.bVisible = false; 101 | } 102 | 103 | append(value: string): void { 104 | this.output.push(value); 105 | } 106 | 107 | replace(value: string): void { 108 | this.output = [value]; 109 | } 110 | 111 | dispose(): void { 112 | this.output = []; 113 | this.bVisible = false; 114 | this.disposed = true; 115 | } 116 | } 117 | 118 | /** 119 | * Create a stub for vscode.window.showQuickPick that returns the first item in the list 120 | */ 121 | export function stubShowQuickPick() { 122 | const showQuickPickStub = sinon.stub(vscode.window, 'showQuickPick'); 123 | 124 | showQuickPickStub.callsFake(async (items: readonly vscode.QuickPickItem[] | Thenable, options: vscode.QuickPickOptions | undefined, token?: vscode.CancellationToken | undefined) => { 125 | return new Promise(async (resolve) => { 126 | resolve((await items)[0]); 127 | }); 128 | }); 129 | 130 | return showQuickPickStub; 131 | } 132 | 133 | export function stubGetConfiguration(config: Record) { 134 | const getConfigurationStub = sinon.stub(vscode.workspace, 'getConfiguration'); 135 | 136 | getConfigurationStub.callsFake((section?: string, scope?: vscode.ConfigurationScope | null) => { 137 | if (!section) { 138 | throw new Error('Section is required'); 139 | } 140 | 141 | return config[section] as vscode.WorkspaceConfiguration; 142 | }); 143 | 144 | return getConfigurationStub; 145 | } 146 | 147 | export function mockOpenExternal() { 148 | const stubOpenExternal = sinon.stub(vscode.env, "openExternal"); 149 | stubOpenExternal.callsFake((uri: vscode.Uri): Promise => { 150 | return new Promise((resolve, reject) => { 151 | https.get(uri.toString(), (res) => { 152 | resolve(res.statusCode === 200); 153 | }).on('error', (e) => { 154 | reject(`Failed to make a GET request to ${uri.toString()}: ${e.message}`); 155 | }); 156 | }); 157 | }); 158 | 159 | return stubOpenExternal; 160 | } 161 | 162 | 163 | export function getTempDir(name: string): vscode.Uri { 164 | const tmpDir = vscode.Uri.file(os.tmpdir()); 165 | return vscode.Uri.joinPath(tmpDir, "ue-python-test-" + TEST_UUID, name); 166 | } 167 | 168 | 169 | export function getExtensionContext() { 170 | const globalStorageTempDir = getTempDir("globalStorage"); 171 | 172 | return { 173 | globalStorageUri: globalStorageTempDir, 174 | } as vscode.ExtensionContext; 175 | } 176 | -------------------------------------------------------------------------------- /src/views/documentation-pannel.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | import * as crypto from 'crypto'; 4 | import * as path from 'path'; 5 | 6 | import * as remoteHandler from '../modules/remote-handler'; 7 | import * as logging from '../modules/logger'; 8 | import * as utils from '../modules/utils'; 9 | 10 | 11 | enum EInOutCommands { 12 | getTableOfContents = "getTableOfContents", 13 | getDocPage = "getDocPage", 14 | getDropDownAreaOpenStates = "getDropDownAreaOpenStates", 15 | getMaxListItems = "getMaxListItems", 16 | getInitialFilter = "getInitialFilter" 17 | } 18 | 19 | enum EOutCommands { 20 | } 21 | 22 | enum EInCommands { 23 | storeDropDownAreaOpenState = "storeDropDownAreaOpenState", 24 | storeMaxListItems = "storeMaxListItems" 25 | } 26 | 27 | enum EConfigFiles { 28 | dropDownAreaStates = "documentation_dropDownArea_states.json" 29 | } 30 | 31 | 32 | /** 33 | * Open the documentation in a new tab 34 | * @param extensionUri The extension's Uri 35 | */ 36 | export async function openDocumentationWindow(extensionUri: vscode.Uri, globalStorageUri: vscode.Uri, viewColumn = vscode.ViewColumn.Two): Promise { 37 | // Check if a single word is selected in the editor, and if so use that as the default filter 38 | let defaultFilter: string | undefined = undefined; 39 | const editor = vscode.window.activeTextEditor; 40 | 41 | if (editor?.selections.length === 1) { 42 | const selectedText = editor.document.getText(editor.selection); 43 | const words = selectedText.trim().split(" "); 44 | if (words.length === 1 && /^[a-zA-Z0-9_]+$/.test(words[0])) { 45 | defaultFilter = words[0]; 46 | } 47 | } 48 | 49 | // Create the documentation pannel and open it 50 | const documentationPannel = new DocumentationPannel(extensionUri, globalStorageUri); 51 | await documentationPannel.open(viewColumn, defaultFilter); 52 | 53 | return documentationPannel; 54 | } 55 | 56 | 57 | /** 58 | * Get the table of contents for the documentation 59 | * @returns The table of contents as a JSON object 60 | */ 61 | async function getTableOfContents() { 62 | const getTableOfContentScript = utils.FPythonScriptFiles.getUri(utils.FPythonScriptFiles.buildDocumentationToC); 63 | 64 | const response = await remoteHandler.evaluateFunction(getTableOfContentScript, "get_table_of_content_json"); 65 | if (response && response.success) { 66 | // As the result is stringified JSON, remove the quotes and parse it 67 | const result = response.result.replace(/^'|'$/g, ''); 68 | try { 69 | return JSON.parse(result); 70 | } 71 | catch (e) { 72 | logging.info(result); 73 | logging.showError("Failed to parse JSON", e as Error); 74 | } 75 | } 76 | 77 | return false; 78 | } 79 | 80 | 81 | /** 82 | * 83 | */ 84 | async function getPageContent(module: string) { 85 | const getDocPageContentScirpt = utils.FPythonScriptFiles.getUri(utils.FPythonScriptFiles.getDocPageContent); 86 | 87 | const kwargs = { 88 | "object_name": module 89 | }; 90 | 91 | const response = await remoteHandler.evaluateFunction(getDocPageContentScirpt, "get_object_documentation_json", kwargs); 92 | if (response && response.success) { 93 | // As the result is stringified JSON, make it parsable 94 | const result = response.result.replace(/^'|'$/g, '').replace(/\\'/g, '\'').replace(/\\\\/g, '\\'); 95 | try { 96 | return JSON.parse(result); 97 | } 98 | catch (e) { 99 | logging.info(result); 100 | logging.showError("Failed to parse JSON", e as Error); 101 | } 102 | } 103 | } 104 | 105 | 106 | 107 | export class DocumentationPannel { 108 | private readonly pannelName = "UE-Python-Documentation"; 109 | readonly title = "Unreal Engine Python"; 110 | 111 | private pannel?: vscode.WebviewPanel; 112 | 113 | private tableOfContentsCache: any = {}; 114 | 115 | private dropDownAreaStates: { [id: string]: boolean } = {}; 116 | private maxListItems: { [id: string]: number } = {}; 117 | private initialFilter: string | undefined = undefined; 118 | 119 | 120 | constructor( 121 | private readonly extensionUri: vscode.Uri, 122 | private readonly globalStorage: vscode.Uri 123 | ) { } 124 | 125 | /** 126 | * Open the documentation pannel in a new tab 127 | * @param viewColumn The view column to open the pannel in 128 | * @param defaultFilter The default filter to insert into the search bar 129 | */ 130 | public async open(viewColumn = vscode.ViewColumn.Two, defaultFilter?: string) { 131 | // Set/Load some default values 132 | this.initialFilter = defaultFilter; 133 | this.dropDownAreaStates = await this.loadDropDownAreaOpenState(); 134 | 135 | this.pannel = vscode.window.createWebviewPanel(this.pannelName, this.title, viewColumn, { 136 | enableScripts: true, 137 | localResourceRoots: [ 138 | this.extensionUri 139 | ] 140 | }); 141 | 142 | this.pannel.webview.onDidReceiveMessage(data => { this.onDidReceiveMessage(data); }); 143 | this.pannel.webview.html = this.getWebviewHtml(this.pannel.webview); 144 | } 145 | 146 | 147 | public async sendTableOfContents() { 148 | if (Object.keys(this.tableOfContentsCache).length === 0) 149 | this.tableOfContentsCache = await getTableOfContents(); 150 | 151 | if (this.pannel) { 152 | this.pannel.webview.postMessage({ command: EInOutCommands.getTableOfContents, data: this.tableOfContentsCache }); 153 | } 154 | } 155 | 156 | 157 | public async openDetailsPage(module: string, property?: string) { 158 | const data = await getPageContent(module); 159 | 160 | if (this.pannel) { 161 | this.pannel.webview.postMessage({ command: EInOutCommands.getDocPage, data: { pageData: data, property: property } }); 162 | } 163 | } 164 | 165 | 166 | private onDidReceiveMessage(data: any) { 167 | switch (data.command) { 168 | case EInOutCommands.getTableOfContents: 169 | { 170 | this.sendTableOfContents(); 171 | break; 172 | } 173 | case EInOutCommands.getDocPage: 174 | { 175 | this.openDetailsPage(data.data.object, data.data.property); 176 | break; 177 | } 178 | case EInCommands.storeDropDownAreaOpenState: 179 | { 180 | this.storeDropDownAreaOpenState(data.data.id, data.data.value); 181 | break; 182 | } 183 | case EInOutCommands.getDropDownAreaOpenStates: 184 | { 185 | this.pannel?.webview.postMessage({ command: EInOutCommands.getDropDownAreaOpenStates, data: this.dropDownAreaStates }); 186 | break; 187 | } 188 | case EInCommands.storeMaxListItems: 189 | { 190 | this.maxListItems[data.data.id] = data.data.value; 191 | break; 192 | } 193 | case EInOutCommands.getMaxListItems: 194 | { 195 | this.pannel?.webview.postMessage({ command: EInOutCommands.getMaxListItems, data: this.maxListItems }); 196 | break; 197 | } 198 | case EInOutCommands.getInitialFilter: 199 | { 200 | this.pannel?.webview.postMessage({ command: EInOutCommands.getInitialFilter, data: this.initialFilter }); 201 | this.initialFilter = undefined; 202 | break; 203 | } 204 | default: 205 | throw new Error(`Not implemented: ${this.pannelName} recived an unknown command: '${data.command}'`); 206 | } 207 | } 208 | 209 | private storeDropDownAreaOpenState(id: string, value: boolean) { 210 | this.dropDownAreaStates[id] = value; 211 | const dropDownStatesStorage = vscode.Uri.joinPath(this.globalStorage, EConfigFiles.dropDownAreaStates); 212 | return vscode.workspace.fs.writeFile(dropDownStatesStorage, Buffer.from(JSON.stringify(this.dropDownAreaStates))); 213 | } 214 | 215 | private async loadDropDownAreaOpenState() { 216 | const dropDownStatesStorage = vscode.Uri.joinPath(this.globalStorage, EConfigFiles.dropDownAreaStates); 217 | if (await utils.uriExists(dropDownStatesStorage)) { 218 | const data = await vscode.workspace.fs.readFile(dropDownStatesStorage); 219 | return JSON.parse(data.toString()); 220 | } 221 | 222 | return {}; 223 | } 224 | 225 | private getWebviewHtml(webview: vscode.Webview) { 226 | const webviewDirectory = vscode.Uri.joinPath(this.extensionUri, 'webview-ui', "build"); 227 | 228 | // Read the manifest file to locate the required script and style files 229 | const manifest = require(path.join(webviewDirectory.fsPath, 'asset-manifest.json')); 230 | const mainScript = manifest['files']['main.js']; 231 | const mainStyle = manifest['files']['main.css']; 232 | 233 | // Get default stylesheet 234 | let stylesheetUris = []; 235 | stylesheetUris.push( 236 | webview.asWebviewUri(vscode.Uri.joinPath(webviewDirectory, mainStyle)) 237 | ); 238 | 239 | let scritpUris = []; 240 | scritpUris.push( 241 | webview.asWebviewUri(vscode.Uri.joinPath(webviewDirectory, mainScript)) 242 | ); 243 | 244 | let styleSheetString = ""; 245 | for (const stylesheet of stylesheetUris) { 246 | styleSheetString += `\n`; 247 | } 248 | 249 | // Use a nonce to only allow a specific script to be run. 250 | const nonce = crypto.randomUUID().replace(/-/g, ""); 251 | 252 | let scriptsString = ""; 253 | for (const scriptUri of scritpUris) { 254 | scriptsString += `\n`; 255 | } 256 | 257 | return ` 258 | 259 | 260 | 261 | 262 | ${styleSheetString} 263 | 264 | 265 | 266 | 267 | 268 |
269 | ${scriptsString} 270 | 271 | `; 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /test/debug-workspace/Test.py: -------------------------------------------------------------------------------- 1 | import unreal 2 | 3 | 4 | def log(): 5 | print('Hello World') 6 | 7 | unreal.log("Logging Info") 8 | unreal.log_warning("Logging Warning") 9 | unreal.log_error("Logging Error") 10 | 11 | 12 | def error(): 13 | print('Exception:') 14 | Test = 1/0 15 | 16 | 17 | def non_ascii(): 18 | print('你好世界') 19 | 20 | 21 | def large_output(): 22 | engine_content = unreal.EditorAssetLibrary.list_assets('/Engine') 23 | for item in engine_content[:1000]: 24 | print(item) 25 | print('Done.') 26 | 27 | 28 | def message_box(): 29 | result = unreal.EditorDialog().show_message("Title", "Hello World", unreal.AppMsgType.YES_NO_CANCEL, unreal.AppReturnType.YES) 30 | print(f"{result = }") 31 | 32 | 33 | def workspace_import(): 34 | import other_module 35 | other_module.hello_world() 36 | 37 | 38 | 39 | log() 40 | -------------------------------------------------------------------------------- /test/debug-workspace/other_module.py: -------------------------------------------------------------------------------- 1 | def hello_world(): 2 | print("Hello, world!") -------------------------------------------------------------------------------- /test/fixture/module/file1.py: -------------------------------------------------------------------------------- 1 | from . import file2 2 | 3 | 4 | def test_file2(): 5 | assert file2.foo() == 'foo' 6 | 7 | 8 | if __name__ == '__main__': 9 | test_file2() 10 | print("ok") 11 | -------------------------------------------------------------------------------- /test/fixture/module/file2.py: -------------------------------------------------------------------------------- 1 | def foo(): 2 | return 'foo' 3 | -------------------------------------------------------------------------------- /test/fixture/test.py: -------------------------------------------------------------------------------- 1 | import unreal 2 | 3 | from module import file1 4 | file1.test_file2() 5 | 6 | print(__name__) 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2022", 5 | "outDir": "out", 6 | "lib": [ 7 | "ES2022" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | "strict": true, /* enable all strict type-checking options */ 12 | "removeComments": true, 13 | "esModuleInterop": true 14 | /* Additional Checks */ 15 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 16 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 17 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 18 | }, 19 | "exclude": [ 20 | "node_modules", 21 | ".vscode-test" 22 | ], 23 | "include": [ 24 | "src/**/*" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /vscode-unreal-python.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "name": "====== Extension Source ======", 5 | "path": "." 6 | }, 7 | { 8 | "name": "=== Documentation Webview ===", 9 | "path": "./webview-ui" 10 | } 11 | ], 12 | "settings": { 13 | "typescript.tsc.autoDetect": "off", 14 | 15 | "python.formatting.autopep8Args": [ 16 | "--max-line-length=9999" 17 | ] 18 | } 19 | } -------------------------------------------------------------------------------- /webview-ui/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /webview-ui/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "build": true, 4 | "node_modules": true, 5 | ".vscode": true, 6 | } 7 | } -------------------------------------------------------------------------------- /webview-ui/README.md: -------------------------------------------------------------------------------- 1 | # Documentation UI 2 | 3 | This folder contains the front-end of the webview documentation that can be opened using the command: `ue-python.openDocumentation` 4 | 5 | The webview is written using [React](https://reactjs.org) 6 | 7 | The backend for the documentation pannel can be found in [../src/views/documentation-pannel.ts](../src/views/documentation-pannel.ts) 8 | -------------------------------------------------------------------------------- /webview-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webview-ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@babel/plugin-proposal-private-property-in-object": "^7.21.11", 7 | "@vscode/webview-ui-toolkit": "^1.4.0", 8 | "react": "^19.1.0", 9 | "react-dom": "^19.1.0", 10 | "react-markdown": "^10.1.0", 11 | "react-scripts": "5.0.1", 12 | "sass": "^1.87.0", 13 | "web-vitals": "^4.2.4" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "eslintConfig": { 22 | "extends": [ 23 | "react-app", 24 | "react-app/jest" 25 | ] 26 | }, 27 | "browserslist": { 28 | "production": [ 29 | ">0.2%", 30 | "not dead", 31 | "not op_mini all" 32 | ], 33 | "development": [ 34 | "last 1 chrome version", 35 | "last 1 firefox version", 36 | "last 1 safari version" 37 | ] 38 | }, 39 | "devDependencies": { 40 | "@testing-library/jest-dom": "^6.6.3", 41 | "@testing-library/react": "^16.3.0", 42 | "@testing-library/user-event": "^14.6.1", 43 | "@types/react": "^19.1.2", 44 | "@types/react-dom": "^19.1.3", 45 | "@types/vscode-webview": "^1.57.5" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /webview-ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 16 | 17 | 26 | React App 27 | 28 | 29 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /webview-ui/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /webview-ui/src/App.scss: -------------------------------------------------------------------------------- 1 | .hidden { 2 | display: none; 3 | } 4 | 5 | .link { 6 | color: var(--vscode-textLink-foreground); 7 | cursor: pointer; 8 | text-decoration: none; 9 | } 10 | 11 | .link:hover { 12 | color: var(--vscode-textLink-activeForeground); 13 | text-decoration: underline; 14 | } 15 | 16 | .link:active { 17 | color: inherit; 18 | text-decoration: underline; 19 | } 20 | 21 | html, body, #root, .App { 22 | height: 100%; 23 | } 24 | 25 | .vscode-header { 26 | height: 22px; 27 | line-height: 22px; 28 | overflow: hidden; 29 | width: 100%; 30 | background-color: var(--vscode-breadcrumb-background); 31 | padding-left: 11px; 32 | } 33 | 34 | .main-content { 35 | height: calc(100% - 22px); 36 | overflow-x: hidden; 37 | overflow-y: scroll; 38 | } -------------------------------------------------------------------------------- /webview-ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import './App.scss'; 2 | import DocPage from './Documentation/DocPage'; 3 | 4 | function App() { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | 12 | export default App; 13 | -------------------------------------------------------------------------------- /webview-ui/src/Components/DropDownArea/dropDownArea.scss: -------------------------------------------------------------------------------- 1 | .dd-area-header { 2 | background-color: var(--vscode-sideBarSectionHeader-background); 3 | border-top: 1px solid var(--vscode-sideBarSectionHeader-border); 4 | height: 22px; 5 | cursor: pointer; 6 | 7 | h2 { 8 | line-height: 22px; 9 | color: var(--vscode-sideBarSectionHeader-foreground); 10 | 11 | font-weight: 700; 12 | font-size: 11px; 13 | 14 | margin: 0; 15 | margin-left: 5px; 16 | float: left; 17 | 18 | user-select: none; 19 | } 20 | 21 | .dd-area-badge-wrapper { 22 | float: right; 23 | margin: auto; 24 | margin-right: 12px; 25 | height: 100%; 26 | 27 | .dd-area-badge { 28 | background-color: var(--vscode-badge-background); 29 | color: var(--vscode-badge-foreground); 30 | border: 1px solid var(--vscode-contrastBorder); 31 | 32 | border-radius: 11px; 33 | padding: 3px 6px; 34 | font-size: 11px; 35 | min-width: 18px; 36 | min-height: 18px; 37 | line-height: 11px; 38 | display: inline-block; 39 | text-align: center; 40 | box-sizing: border-box; 41 | } 42 | } 43 | } 44 | 45 | .arrow { 46 | border-style: solid; 47 | border-color: var(--vscode-sideBarSectionHeader-foreground); 48 | border-width: 0 1px 1px 0; 49 | padding: 3px; 50 | display: block; 51 | width: fit-content; 52 | width: fit-content; 53 | float: left; 54 | 55 | 56 | margin-left: 8px; 57 | margin-top: 6px; 58 | } 59 | 60 | .right { 61 | transform: rotate(-45deg); 62 | -webkit-transform: rotate(-45deg); 63 | } 64 | 65 | .down { 66 | transform: rotate(45deg); 67 | -webkit-transform: rotate(45deg); 68 | } 69 | 70 | .dd-content { 71 | margin-bottom: 10px; 72 | overflow: hidden; 73 | } -------------------------------------------------------------------------------- /webview-ui/src/Components/DropDownArea/dropDownArea.tsx: -------------------------------------------------------------------------------- 1 | import "./dropDownArea.scss"; 2 | import * as vscode from "../../Modules/vscode"; 3 | 4 | import { Component } from "react"; 5 | 6 | interface DropDownAreaProps { 7 | title: string; 8 | children: any; 9 | id: string; 10 | badgeCount?: number; 11 | onHeaderClicked?: (id: string, bOpen: boolean) => void; 12 | bForceOpenState?: boolean; 13 | onComponentUpdated?: (id: string) => void; 14 | } 15 | 16 | interface DropDownAreaState { 17 | bOpen: boolean 18 | } 19 | 20 | class DropDownArea extends Component { 21 | state = { bOpen: false } 22 | 23 | constructor(props: DropDownAreaProps) { 24 | super(props); 25 | if (props.bForceOpenState !== undefined) 26 | this.state.bOpen = props.bForceOpenState; 27 | } 28 | 29 | onHeaderClicked() { 30 | const bOpen = !this.state.bOpen; 31 | 32 | this.setState({ bOpen }); 33 | 34 | vscode.sendMessage(vscode.EOutCommands.storeDropDownAreaOpenState, { id: this.props.id, value: bOpen }); 35 | 36 | if (this.props.onHeaderClicked) { 37 | this.props.onHeaderClicked(this.props.id, bOpen); 38 | } 39 | } 40 | 41 | async componentDidMount() { 42 | if (this.props.bForceOpenState !== undefined) 43 | return; 44 | 45 | const data = await vscode.sendMessageAndWaitForResponse(vscode.EInOutCommands.getDropDownAreaOpenStates, this.props.id); 46 | 47 | let bOpen = data[this.props.id]; 48 | if (bOpen === undefined) 49 | bOpen = true; 50 | 51 | this.setState({ bOpen }); 52 | } 53 | 54 | componentDidUpdate(prevProps: DropDownAreaProps) { 55 | if (this.props.onComponentUpdated) 56 | this.props.onComponentUpdated(this.props.id); 57 | } 58 | 59 | 60 | getArrowClass() { 61 | return "arrow " + (this.state.bOpen ? "down" : "right"); 62 | } 63 | 64 | render() { 65 | return ( 66 |
67 |
this.onHeaderClicked()}> 68 |
69 | 70 |

{this.props.title}

71 | 72 | { 73 | this.props.badgeCount !== undefined && 74 |
75 |
76 | {this.props.badgeCount} 77 |
78 |
79 | } 80 | 81 |
82 | { 83 | this.state.bOpen && this.props.children && 84 |
85 | {this.props.children} 86 |
87 | } 88 |
89 | ); 90 | } 91 | } 92 | 93 | export default DropDownArea; -------------------------------------------------------------------------------- /webview-ui/src/Components/DynamicList/dynamicList.scss: -------------------------------------------------------------------------------- 1 | .dynamic-list-show-more-button { 2 | cursor: pointer; 3 | opacity: 0.8; 4 | margin-top: 5px; 5 | margin-bottom: 10px; 6 | 7 | &:hover { 8 | opacity: 0.9; 9 | text-decoration: underline; 10 | } 11 | } -------------------------------------------------------------------------------- /webview-ui/src/Components/DynamicList/dynamicList.tsx: -------------------------------------------------------------------------------- 1 | import "./dynamicList.scss"; 2 | 3 | import { Component, Fragment, ReactNode } from "react"; 4 | 5 | interface DynamicListProps { 6 | children: any[]; 7 | startingMaxChildren: number; 8 | increaseMaxChildrenStep: number; 9 | id: string; 10 | onListExpanded?: (id: string, maxItems: number) => void; 11 | } 12 | 13 | interface DynamicListState { 14 | maxChildren: number; 15 | } 16 | 17 | class DynamicList extends Component { 18 | state = { maxChildren: 100 } 19 | 20 | constructor(props: DynamicListProps) { 21 | super(props); 22 | 23 | this.state.maxChildren = props.startingMaxChildren; 24 | } 25 | 26 | onShowMoreClicked() { 27 | const maxChildren = this.state.maxChildren + this.props.increaseMaxChildrenStep; 28 | 29 | this.setState({ maxChildren }); 30 | 31 | if (this.props.onListExpanded) { 32 | this.props.onListExpanded(this.props.id, maxChildren); 33 | } 34 | } 35 | 36 | render(): ReactNode { 37 | let childItems = this.props.children; 38 | const bSpliceNeeded = this.props.children.length > this.state.maxChildren; 39 | if (bSpliceNeeded) { 40 | childItems = this.props.children.slice(0, this.state.maxChildren); 41 | } 42 | 43 | return ( 44 | 45 | { 46 | childItems.map((child, index) => { 47 | return child; 48 | }) 49 | } 50 | { 51 | // Show a button allowing the user to show more items 52 | bSpliceNeeded && ( 53 |
this.onShowMoreClicked()}> 54 | Show more... 55 |
56 | ) 57 | } 58 |
59 | ); 60 | } 61 | } 62 | 63 | export default DynamicList; -------------------------------------------------------------------------------- /webview-ui/src/Documentation/Details/detailsPage.scss: -------------------------------------------------------------------------------- 1 | #doc-details-header { 2 | padding: 0 11px; 3 | margin-bottom: 30px; 4 | margin-top: 3px; 5 | overflow: hidden; 6 | 7 | #doc-details-title { 8 | // Center text 9 | text-align: center; 10 | margin: 5px; 11 | } 12 | } 13 | 14 | 15 | .doc-details-member { 16 | padding: 0 11px; 17 | 18 | h4{ 19 | margin-bottom: 5px; 20 | } 21 | 22 | .doc-details-doc { 23 | margin: 0px; 24 | 25 | p { 26 | margin: 0px; 27 | } 28 | opacity: 0.8; 29 | } 30 | 31 | .doc-details-name-hint { 32 | font-weight: normal; 33 | margin-left: -1px; 34 | opacity: 0.9; 35 | } 36 | 37 | } 38 | 39 | #doc-details-highlight { 40 | background-color: var(--vscode-list-inactiveSelectionBackground); 41 | } -------------------------------------------------------------------------------- /webview-ui/src/Documentation/Details/detailsPage.tsx: -------------------------------------------------------------------------------- 1 | import "./detailsPage.scss"; 2 | import { VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"; 3 | import { Component, Fragment } from "react"; 4 | import DropDownArea from "../../Components/DropDownArea/dropDownArea"; 5 | import ReactMarkdown from 'react-markdown' 6 | import * as vscode from '../../Modules/vscode'; 7 | 8 | const NONE_CLICKABLE_BASES = [ 9 | "object", 10 | "_WrapperBase", 11 | "_ObjectBase", 12 | ] 13 | 14 | interface PageTypeData { 15 | [type: string]: { 16 | name: string, 17 | doc: string, 18 | name_hints: string 19 | }[] 20 | }; 21 | 22 | export interface PageData { 23 | pageData: { 24 | name: string, 25 | bases: string[], 26 | doc: string, 27 | members: { 28 | inherited: PageTypeData, 29 | unique: PageTypeData 30 | }, 31 | is_class: boolean 32 | }, 33 | property?: string; // This is the property that was clicked when page was requested 34 | } 35 | 36 | interface DetailsPageProps { 37 | item: string; 38 | onBackClicked: () => void; 39 | } 40 | 41 | interface DetailsPageState { 42 | data?: PageData; 43 | } 44 | 45 | 46 | class DetailsPage extends Component { 47 | state = { data: undefined } 48 | 49 | objectName: string = ""; 50 | 51 | async componentDidMount() { 52 | this.browseItem(this.props.item); 53 | } 54 | 55 | componentDidUpdate() { 56 | const element = document.getElementById("doc-details-highlight"); 57 | if (element) 58 | element.scrollIntoView({ inline: "center" }); 59 | } 60 | 61 | async browseItem(name: string) { 62 | // Split by dot to get the object name and the member name 63 | this.objectName = name; 64 | let memberName = undefined; 65 | if (name.indexOf(".") !== -1) { 66 | [this.objectName, memberName] = name.split("."); 67 | } 68 | 69 | const data = await vscode.sendMessageAndWaitForResponse(vscode.EInOutCommands.getDocPage, { "object": this.objectName, "property": memberName }); 70 | this.setState({ data }); 71 | } 72 | 73 | renderContent(data: PageTypeData, prefix = "") { 74 | // Get the property that was clicked when the page was requested, to focus on it 75 | let focusPropertyName = this.state.data?.property; 76 | if (!focusPropertyName && !this.state.data?.pageData.is_class) { 77 | focusPropertyName = this.objectName; 78 | } 79 | 80 | return ( 81 | 82 | { 83 | Object.keys(data).map((type: string) => { 84 | if (data[type].length === 0) 85 | return null; 86 | 87 | // Check if DropDownArea needs to be forced open (if the focused property is in this type) 88 | let bForceOpenState: boolean = undefined; 89 | if (focusPropertyName) { 90 | if (data[type].find((member: any) => member.name === focusPropertyName)) 91 | bForceOpenState = true; 92 | } 93 | 94 | return ( 95 | 96 | { 97 | data[type].map((member: any, index: number) => { 98 | return ( 99 |
100 |

{member.name} {member.name_hints}

101 |
102 | {member.doc} 103 |
104 |
105 | ); 106 | }) 107 | } 108 |
109 | ); 110 | }) 111 | } 112 |
113 | ); 114 | 115 | } 116 | 117 | render() { 118 | if (!this.state.data) { 119 | return ( 120 |
121 | 122 |
123 | ); 124 | } 125 | 126 | const data = this.state.data.pageData; 127 | 128 | return ( 129 | 130 |
131 |
< Back
132 |
133 |
134 |
135 | 136 |

137 | {data.name} 138 |

139 | { 140 | data.bases.length > 0 && 141 |
142 | Bases: 143 | { 144 | data.bases.map((base: string, index: number) => { 145 | return ( 146 | 147 | this.browseItem(base)}>{base} 148 | {index !== data.bases.length - 1 ? "> " : ""} 149 | 150 | ); 151 | }) 152 | } 153 |
154 | } 155 | 156 |
157 | {data.doc} 158 |
159 | 160 |
161 | 162 |
163 | {this.renderContent(this.state.data.pageData.members.unique)} 164 | {this.renderContent(this.state.data.pageData.members.inherited, "Inherited ")} 165 |
166 |
167 |
168 | ); 169 | } 170 | } 171 | 172 | export default DetailsPage; 173 | -------------------------------------------------------------------------------- /webview-ui/src/Documentation/DocPage.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import DetailsPage from './Details/detailsPage'; 3 | import DocIndex from './Index/docIndex'; 4 | 5 | 6 | export default class DocPage extends Component { 7 | state = { detailsPageItem: null } 8 | 9 | cachedFilter = ""; 10 | cachedScrollPosY = 0; 11 | 12 | browseItem(name: string) { 13 | this.setState({ detailsPageItem: name }); 14 | 15 | const scrollElement = document.getElementById("doc-index-content"); 16 | if (scrollElement) { 17 | this.cachedScrollPosY = scrollElement.scrollTop; 18 | } 19 | } 20 | 21 | backToIndex() { 22 | this.setState({ detailsPageItem: null }); 23 | } 24 | 25 | onFilterChanged(filter: string) { 26 | this.cachedFilter = filter; 27 | } 28 | 29 | render() { 30 | if (this.state.detailsPageItem) { 31 | return ( this.backToIndex()}>); 32 | } 33 | 34 | return ( 35 | this.browseItem(item)} onFilterChanged={(filter: string) => this.onFilterChanged(filter)} scrollPosY={this.cachedScrollPosY} /> 36 | ); 37 | } 38 | } -------------------------------------------------------------------------------- /webview-ui/src/Documentation/Index/Header/docHeader.scss: -------------------------------------------------------------------------------- 1 | .doc-index-header { 2 | padding: 0; 3 | height: 46px; 4 | } 5 | 6 | #searchbar { 7 | width: 100%; 8 | } 9 | 10 | #doc-index-searchbar-wrapper { 11 | padding: 10px 11px; 12 | } -------------------------------------------------------------------------------- /webview-ui/src/Documentation/Index/Header/docHeader.tsx: -------------------------------------------------------------------------------- 1 | import "./docHeader.scss" 2 | 3 | import { Component, createRef } from 'react'; 4 | import { VSCodeTextField } from '@vscode/webview-ui-toolkit/react'; 5 | 6 | interface Props { 7 | handleSearchChanged?: CallableFunction, 8 | handleSearchInput?: CallableFunction 9 | filter?: string 10 | } 11 | 12 | interface State { 13 | } 14 | 15 | class DocHeader extends Component { 16 | state = {} 17 | 18 | textField = createRef(); 19 | 20 | componentDidUpdate() { 21 | // TODO: Because 'autofocus' is currently broken w/ webview-ui-toolkit/react 22 | // Swap to using autofocus when this is fixed: https://github.com/microsoft/vscode-webview-ui-toolkit/issues/381 23 | const shadowRoot = this.textField.current.shadowRoot; 24 | if (shadowRoot) { 25 | const input = shadowRoot.querySelector('input'); 26 | if (input) { 27 | input.focus(); 28 | } 29 | } 30 | } 31 | 32 | render() { 33 | return ( 34 |
35 |
36 | { if (this.props.handleSearchInput) this.props.handleSearchInput(e.target.value) }} ref={this.textField} value={this.props.filter}/> 37 |
38 |
39 | ); 40 | } 41 | } 42 | 43 | 44 | export default DocHeader; -------------------------------------------------------------------------------- /webview-ui/src/Documentation/Index/docIndex.scss: -------------------------------------------------------------------------------- 1 | #loading { 2 | width: fit-content; 3 | margin: 50px auto; 4 | } 5 | 6 | #doc-index-content { 7 | height: calc(100% - 46px); 8 | 9 | span { 10 | font-weight: 500; 11 | font-size: 13px; 12 | cursor: pointer; 13 | margin: 0; 14 | width: fit-content; 15 | display: block; 16 | } 17 | 18 | span:hover { 19 | opacity: 0.7; 20 | } 21 | 22 | .doc-index-dd-content { 23 | padding: 0 11px; 24 | } 25 | } -------------------------------------------------------------------------------- /webview-ui/src/Documentation/Index/docIndex.tsx: -------------------------------------------------------------------------------- 1 | import "./docIndex.scss"; 2 | import { Component, createRef, Fragment } from 'react'; 3 | import * as vscode from '../../Modules/vscode'; 4 | import DocHeader from './Header/docHeader'; 5 | 6 | import { VSCodeProgressRing } from '@vscode/webview-ui-toolkit/react'; 7 | import DropDownArea from "../../Components/DropDownArea/dropDownArea"; 8 | import DynamicList from "../../Components/DynamicList/dynamicList"; 9 | 10 | 11 | interface RawTableOfContents { 12 | [type: string]: { 13 | [name: string]: { 14 | ClassMethod?: string[], 15 | Constant?: string[], 16 | Method?: string[], 17 | Property?: string[], 18 | } 19 | }; 20 | } 21 | 22 | 23 | // We should convert toc into this format on mounted 24 | interface TableOfContents { 25 | [Type: string]: { 26 | [Name: string]: string[] 27 | }; 28 | } 29 | 30 | interface FilteredTableOfContents { 31 | [Type: string]: { 32 | prioritizedMatch: string[], 33 | items: string[] 34 | } 35 | } 36 | 37 | 38 | interface DocIndexProps { 39 | onItemClicked: (name: string) => void; 40 | onFilterChanged: (filter: string) => void; 41 | filter: string; 42 | scrollPosY: number; 43 | } 44 | 45 | 46 | export default class DocIndex extends Component { 47 | state = { bLoading: true, tableOfContents: {}, filter: "" }; 48 | 49 | contentRef: React.RefObject; 50 | numberOfDDAUpdates = 0; 51 | maxListItems: { [id: string]: number } = {}; 52 | 53 | constructor(props: DocIndexProps) { 54 | super(props); 55 | 56 | this.state.filter = props.filter; 57 | this.contentRef = createRef(); 58 | } 59 | 60 | async componentDidMount() { 61 | // Request the table of contents from the extension 62 | const tableOfContents: RawTableOfContents = await vscode.sendMessageAndWaitForResponse(vscode.EInOutCommands.getTableOfContents); 63 | this.maxListItems = await vscode.sendMessageAndWaitForResponse(vscode.EInOutCommands.getMaxListItems); 64 | 65 | const initialFilter = await vscode.sendMessageAndWaitForResponse(vscode.EInOutCommands.getInitialFilter); 66 | if (initialFilter) { 67 | this.applyFilter(initialFilter); 68 | } 69 | 70 | this.setState({ 71 | tableOfContents: this.parseTableOfContents(tableOfContents), 72 | bLoading: false, 73 | }); 74 | } 75 | 76 | onDropDownAreaUpdated(id: string) { 77 | if (this.numberOfDDAUpdates >= Object.keys(this.state.tableOfContents).length) { 78 | return; 79 | } 80 | this.numberOfDDAUpdates++; 81 | 82 | this.contentRef.current.scrollTo(0, this.props.scrollPosY); 83 | this.contentRef.current.scrollTop = this.props.scrollPosY; 84 | } 85 | 86 | parseTableOfContents(tableOfContents: RawTableOfContents) { 87 | let parsedTableOfContents: TableOfContents = {}; 88 | 89 | Object.keys(tableOfContents).forEach((type) => { 90 | parsedTableOfContents[type] = {}; 91 | 92 | Object.keys(tableOfContents[type]).forEach((name) => { 93 | parsedTableOfContents[type][name] = []; 94 | 95 | Object.keys(tableOfContents[type][name]).forEach((subType) => { 96 | parsedTableOfContents[type][name].push(...tableOfContents[type][name][subType]); 97 | }); 98 | }); 99 | }); 100 | 101 | return parsedTableOfContents; 102 | } 103 | 104 | renderProgressRing() { 105 | if (this.state.bLoading) { 106 | return ( 107 |
108 | 109 |
110 | ); 111 | } 112 | } 113 | 114 | applyFilter(searchText: string) { 115 | this.setState({ filter: searchText }); 116 | this.props.onFilterChanged(searchText); 117 | } 118 | 119 | /** 120 | * Callback for when the Show More button in a DynamicList is clicked 121 | */ 122 | onListExpanded(id: string, maxItems: number) { 123 | vscode.sendMessage(vscode.EOutCommands.storeMaxListItems, { id, value: maxItems }); 124 | } 125 | 126 | private passesFilter(itemName: string, includes: string[], alternativeIncludes?: string[]) { 127 | for (let include of includes) { 128 | if (!itemName.includes(include)) { 129 | if (alternativeIncludes) 130 | return this.passesFilter(itemName, alternativeIncludes); 131 | 132 | return false; 133 | } 134 | } 135 | 136 | return true; 137 | } 138 | 139 | 140 | renderContent() { 141 | let content: FilteredTableOfContents = {}; 142 | if (this.state.filter) { 143 | const filterLower = this.state.filter.toLocaleLowerCase(); 144 | 145 | let includes = []; 146 | let alternativeIncludes: string[] | undefined = []; 147 | for (let part of this.state.filter.split(/[\s,]+/)) { 148 | const partLower = part.toLocaleLowerCase(); 149 | includes.push(partLower); 150 | 151 | // Convert PascalCase to snake_case and use as alternatives, since the original C++ names are in PascalCase 152 | const partSnakeCase = part.replace(/([a-z])([A-Z])/g, (m, p1, p2) => `${p1}_${p2}`).toLowerCase(); 153 | if (partSnakeCase !== partLower) { 154 | alternativeIncludes.push(partSnakeCase); 155 | } 156 | } 157 | 158 | if (alternativeIncludes.length === 0) { 159 | alternativeIncludes = undefined; 160 | } 161 | 162 | for (let [type, items] of Object.entries(this.state.tableOfContents)) { 163 | content[type] = { items: [], prioritizedMatch: [] }; 164 | 165 | for (let [className, memberName] of Object.entries(items)) { 166 | const classNameLower = className.toLocaleLowerCase(); 167 | if (this.passesFilter(classNameLower, includes, alternativeIncludes)) { 168 | // Check if it's a perfect match 169 | if (classNameLower.startsWith(filterLower)) { 170 | content[type].prioritizedMatch.push(className); 171 | } 172 | else { 173 | content[type].items.push(className); 174 | } 175 | } 176 | 177 | for (let member of memberName) { 178 | const memberLower = member.toLocaleLowerCase(); 179 | if (this.passesFilter(memberLower, includes, alternativeIncludes)) { 180 | const fullname = `${className}.${member}`; 181 | if (memberLower.startsWith(filterLower)) { 182 | content[type].prioritizedMatch.push(fullname); 183 | } 184 | else { 185 | content[type].items.push(fullname); 186 | } 187 | } 188 | } 189 | } 190 | 191 | content[type].prioritizedMatch.sort(function (a, b) { 192 | return a.length - b.length; 193 | }); 194 | 195 | } 196 | 197 | 198 | 199 | } 200 | else { 201 | for (let type in this.state.tableOfContents) { 202 | content[type] = { 203 | items: Object.keys(this.state.tableOfContents[type]), 204 | prioritizedMatch: [] 205 | }; 206 | } 207 | } 208 | 209 | return ( 210 | 211 | { 212 | Object.entries(content).map(([typeName, itemData], index) => { 213 | return ( 214 | this.onDropDownAreaUpdated(id)}> 215 | { 216 | (itemData.items.length + itemData.prioritizedMatch.length > 0) && 217 |
218 | this.onListExpanded(id, maxItems)}> 220 | { 221 | [...itemData.prioritizedMatch, ...itemData.items].map((itemName, index) => { 222 | return ( 223 | this.props.onItemClicked(itemName)}> 224 | {itemName} 225 | 226 | ); 227 | }) 228 | } 229 | 230 |
231 | } 232 |
233 | ); 234 | }) 235 | 236 | } 237 |
238 | ); 239 | } 240 | 241 | 242 | render() { 243 | return ( 244 | 245 | this.applyFilter(text)} filter={this.state.filter} /> 246 | 247 |
248 | {this.renderProgressRing()} 249 | 250 | {this.renderContent()} 251 |
252 | 253 |
254 | ); 255 | } 256 | } -------------------------------------------------------------------------------- /webview-ui/src/Modules/vscode.ts: -------------------------------------------------------------------------------- 1 | const vscode = acquireVsCodeApi(); 2 | 3 | 4 | export enum EInOutCommands { 5 | getTableOfContents = "getTableOfContents", 6 | getDocPage = "getDocPage", 7 | getDropDownAreaOpenStates = "getDropDownAreaOpenStates", 8 | getMaxListItems = "getMaxListItems", 9 | getInitialFilter = "getInitialFilter" 10 | } 11 | 12 | export enum EOutCommands { 13 | storeDropDownAreaOpenState = "storeDropDownAreaOpenState", 14 | storeMaxListItems = "storeMaxListItems" 15 | } 16 | 17 | export enum EInCommands { 18 | } 19 | 20 | 21 | 22 | export function sendMessage(command: EInOutCommands | EOutCommands, data?: any) { 23 | vscode.postMessage({ command: command, data: data }); 24 | } 25 | 26 | export function sendMessageAndWaitForResponse(command: EInOutCommands, data?: any): Promise { 27 | return new Promise(resolve => { 28 | const callback = (data: any) => { 29 | listener.removeListener(command, callback); 30 | resolve(data); 31 | }; 32 | 33 | listener.addListener(command, callback); 34 | sendMessage(command, data); 35 | }); 36 | } 37 | 38 | class Listener { 39 | listeners: { [key: string]: ((data: any) => void)[] } = {}; 40 | 41 | constructor() { 42 | window.addEventListener('message', event => { 43 | const message = event.data; 44 | if (this.listeners[message.command] !== undefined) { 45 | for (const listener of this.listeners[message.command]) { 46 | listener(message.data); 47 | } 48 | } 49 | }); 50 | } 51 | 52 | addListener(command: EInOutCommands | EInCommands, callback: (data: any) => void) { 53 | if (this.listeners[command] === undefined) 54 | this.listeners[command] = []; 55 | 56 | this.listeners[command].push(callback); 57 | } 58 | 59 | removeListener(command: EInOutCommands | EInCommands, callback: (data: any) => void) { 60 | if (this.listeners[command] === undefined) 61 | return; 62 | 63 | const index = this.listeners[command].indexOf(callback); 64 | if (index > -1) 65 | this.listeners[command].splice(index, 1); 66 | } 67 | } 68 | 69 | export const listener = new Listener(); -------------------------------------------------------------------------------- /webview-ui/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.scss'; 4 | import App from './App'; 5 | 6 | const root = ReactDOM.createRoot(document.getElementById('root')); 7 | root.render( 8 | 9 | 10 | 11 | ); -------------------------------------------------------------------------------- /webview-ui/src/index.scss: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 0; 3 | } -------------------------------------------------------------------------------- /webview-ui/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /webview-ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | // tsconfig.json 2 | { 3 | "compilerOptions": { 4 | "jsx": "react-jsx" 5 | }, 6 | } --------------------------------------------------------------------------------